diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php --- a/src/app/Http/Controllers/API/V4/PolicyController.php +++ b/src/app/Http/Controllers/API/V4/PolicyController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\V4; use App\Http\Controllers\Controller; +use App\Policy\Mailfilter\RequestHandler as Mailfilter; use App\Policy\RateLimit; use App\Policy\RateLimitWhitelist; use Illuminate\Http\Request; @@ -41,6 +42,18 @@ return response()->json($result, 200); } + /** + * SMTP Content Filter + * + * @param Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function mailfilter(Request $request) + { + return Mailfilter::handle($request); + } + /* * Apply a sensible rate limitation to a request. * diff --git a/src/app/Policy/Mailfilter/MailParser.php b/src/app/Policy/Mailfilter/MailParser.php new file mode 100644 --- /dev/null +++ b/src/app/Policy/Mailfilter/MailParser.php @@ -0,0 +1,339 @@ +stream = $stream; + $this->start = $start; + $this->end = $end; + + $this->parseHeaders(); + } + + /** + * Get mail header + */ + public function getHeader($name): ?string + { + return $this->headers[strtolower($name)] ?? null; + } + + /** + * Get content type + */ + public function getContentType(): ?string + { + return $this->ctype; + } + + /** + * Get the message (or part) body + * + * @param ?int $part_id Part identifier + */ + public function getBody($part_id = null): string + { + // TODO: Let's start with a string result, but we might need to use streams + + // get the whole message body + if (!is_int($part_id)) { + $result = ''; + $position = $this->bodyPosition; + + fseek($this->stream, $this->bodyPosition); + + while (($line = fgets($this->stream, 2048)) !== false) { + $position += strlen($line); + + $result .= $line; + + if ($this->end && $position >= $this->end) { + break; + } + } + + if (str_ends_with($result, "\r\n")) { + $result = substr($result, 0, -2); + } + + return \rcube_mime::decode($result, $this->headers['content-transfer-encoding'] ?? null); + } + + // get the part's body + $part = $this->getParts()[$part_id] ?? null; + + if (!$part) { + throw new \Exception("Invalid body identifier"); + } + + return $part->getBody(); + } + + /** + * Returns start position (in the stream) of the body part + */ + public function getBodyPosition(): int + { + return $this->bodyPosition; + } + + /** + * Returns email address of the recipient + */ + public function getMailbox(): string + { + // TODO: We need to pass the target mailbox from Postfix in some way (Delivered-To header?) + return ''; + } + + /** + * Return the current mail structure parts (top-level only) + */ + public function getParts() + { + if ($this->parts === null) { + $this->parts = []; + + if (!empty($this->ctypeParams['boundary']) && str_starts_with($this->ctype, 'multipart/')) { + $start_line = '--' . $this->ctypeParams['boundary'] . "\r\n"; + $end_line = '--' . $this->ctypeParams['boundary'] . "--\r\n"; + $position = $this->bodyPosition; + $part_position = null; + + fseek($this->stream, $position); + + while (($line = fgets($this->stream, 2048)) !== false) { + $position += strlen($line); + + if ($line == $start_line) { + if ($part_position) { + $this->addPart($part_position, $position - strlen($start_line)); + } + + $part_position = $position; + } elseif ($line == $end_line) { + if ($part_position) { + $this->addPart($part_position, $position - strlen($end_line)); + $part_position = $position; + } + + break; + } + + if ($this->end && $position >= $this->end) { + break; + } + } + } + } + + return $this->parts; + } + + /** + * Returns start position of the message/part + */ + public function getStart(): int + { + return $this->start; + } + + /** + * Returns end position of the message/part + */ + public function getEnd(): ?int + { + return $this->end; + } + + /** + * Return the mail content stream + */ + public function getStream() + { + fseek($this->stream, $this->start); + + return $this->stream; + } + + /** + * Indicate if the mail content has been modified + */ + public function isModified(): bool + { + return $this->modified; + } + + /** + * Replace mail part content + * + * @param string $body Body content + * @param ?int $part_id Part identifier (NULL to replace the whole message body) + */ + public function replaceBody($body, $part_id = null): void + { + // TODO: We might need to support stream input + // TODO: We might need to use different encoding than the original (i.e. set headers) + // TODO: format=flowed handling for text/plain parts? + + // TODO: This method should work also on parts, but we'd have to reset all parents + if ($this->start > 0) { + throw new \Exception("Replacing body supported from the message level only"); + } + + // Replace the whole message body + if (is_int($part_id)) { + $part = $this->getParts()[$part_id] ?? null; + + if (!$part) { + throw new \Exception("Invalid body identifier"); + } + } else { + $part = $this; + } + + $copy = fopen('php://temp', 'r+'); + + fseek($this->stream, $this->start); + stream_copy_to_stream($this->stream, $copy, $part->getBodyPosition()); + fwrite($copy, self::encode($body, $part->getHeader('content-transfer-encoding'))); + fwrite($copy, "\r\n"); + + if ($end = $part->getEnd()) { + stream_copy_to_stream($this->stream, $copy, null, $end); + } + + $this->stream = $copy; + + // Reset structure information, the message will need to be re-parsed (in some cases) + $this->parts = null; + $this->modified = true; + } + + /** + * Extract mail headers from the mail content + */ + protected function parseHeaders(): void + { + $header = ''; + $position = $this->start; + + fseek($this->stream, $this->start); + + while (($line = fgets($this->stream, 2048)) !== false) { + $position += strlen($line); + + if ($this->end && $position >= $this->end) { + $position = $this->end; + break; + } + + if ($line == "\n" || $line == "\r\n") { + break; + } + + $line = rtrim($line, "\r\n"); + + if ($line[0] == ' ' || $line[0] == "\t") { + $header .= ' ' . preg_replace('/^(\s+|\t+)/', '', $line); + } else { + $this->addHeader($header); + $header = $line; + } + } + + $this->addHeader($header); + $this->bodyPosition = $position; + } + + /** + * Add parsed header to the headers list + */ + protected function addHeader($content) + { + if (preg_match('/^([a-zA-Z0-9_-]+):/', $content, $matches)) { + $name = strtolower($matches[1]); + + // Keep only headers we need + if (in_array($name, $this->validHeaders)) { + $this->headers[$name] = ltrim(substr($content, strlen($matches[1]) + 1)); + } + + if ($name == 'content-type') { + $parts = preg_split('/[; ]+/', $this->headers[$name]); + $this->ctype = strtolower($parts[0]); + + for ($i = 1; $i < count($parts); $i++) { + $tokens = explode('=', $parts[$i], 2); + if (count($tokens) == 2) { + $value = $tokens[1]; + if (preg_match('/^".*"$/', $value)) { + $value = substr($value, 1, -1); + } + + $this->ctypeParams[strtolower($tokens[0])] = $value; + } + } + } + } + } + + /** + * Add part to the parts list + */ + protected function addPart($start, $end) + { + $pos = ftell($this->stream); + + $this->parts[] = new self($this->stream, $start, $end); + + fseek($this->stream, $pos); + } + + /** + * Encode mail body + */ + protected static function encode($data, $encoding) + { + switch ($encoding) { + case 'quoted-printable': + return \Mail_mimePart::quotedPrintableEncode($data, 76, "\r\n"); + + case 'base64': + return rtrim(chunk_split(base64_encode($data), 76, "\r\n")); + + case '8bit': + case '7bit': + default: + // TODO: Ensure \r\n line-endings + return $data; + } + } +} diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule.php b/src/app/Policy/Mailfilter/Modules/ItipModule.php new file mode 100644 --- /dev/null +++ b/src/app/Policy/Mailfilter/Modules/ItipModule.php @@ -0,0 +1,55 @@ +getMailbox(); + + $itip = self::getItip($parser); + + if ($itip === null) { + return null; // do nothing + } + + // TODO: Get the user's invitation policy + + // TODO: Process the iTip content + + return null; + } + + /** + * Check if the message contains an iTip content and get it + */ + protected static function getItip($parser): ?string + { + $calendar_types = ['text/calendar', 'text/x-vcalendar', 'application/ics']; + $message_type = $parser->getContentType(); + + if (in_array($message_type, $calendar_types)) { + return $parser->getBody(); + } + + // Return early, so we don't have to parse the message + if (!in_array($message_type, ['multipart/mixed', 'multipart/alternative'])) { + return null; + } + + // Find the calendar part (only top-level parts for now) + foreach ($parser->getParts() as $part) { + // TODO: Apple sends files as application/x-any (!?) + // ($mimetype == 'application/x-any' && !empty($filename) && preg_match('/\.ics$/i', $filename)) + if (in_array($part->getContentType(), $calendar_types)) { + return $part->getBody(); + } + } + + return null; + } +} diff --git a/src/app/Policy/Mailfilter/RequestHandler.php b/src/app/Policy/Mailfilter/RequestHandler.php new file mode 100644 --- /dev/null +++ b/src/app/Policy/Mailfilter/RequestHandler.php @@ -0,0 +1,80 @@ +getContent(true); + + $parser = new MailParser($stream); + + // TODO: The list of modules and their config will come from somewhere + $modules = ['Itip' /*, 'Footer'*/]; + + foreach ($modules as $module) { + $class = "\\App\\Policy\\Mailfilter\\Modules\\{$module}Module"; + $engine = new $class(); + + $result = $engine->handle($parser); + + if ($result) { + if ($result->getStatus() == Result::STATUS_REJECT) { + // FIXME: Better code? Should we use custom header instead? + return response('', 460); + } elseif ($result->getStatus() == Result::STATUS_IGNORE) { + // FIXME: Better code? Should we use custom header instead? + return response('', 461); + } + } + } + + // If mail content has been modified, stream it back to Postfix + if ($parser->isModified()) { + $response = new StreamedResponse(); + + $response->headers->replace([ + 'Content-Type' => 'message/rfc822', + 'Content-Disposition' => 'attachment', + ]); + + $stream = $parser->getStream(); + + $response->setCallback(function () use ($stream) { + fpassthru($stream); + fclose($stream); + }); + + return $response; + } + + return response('', 201); + } +} diff --git a/src/app/Policy/Mailfilter/Result.php b/src/app/Policy/Mailfilter/Result.php new file mode 100644 --- /dev/null +++ b/src/app/Policy/Mailfilter/Result.php @@ -0,0 +1,30 @@ +status = $status ?: self::STATUS_ACCEPT; + } + + /** + * Return the status + */ + public function getStatus(): string + { + return $this->status; + } +} diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -32,6 +32,7 @@ "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.22", "pear/crypt_gpg": "^1.6.6", + "pear/mail_mime": "~1.10.11", "predis/predis": "^2.0", "sabre/vobject": "^4.5", "spatie/laravel-translatable": "^6.5", diff --git a/src/config/octane.php b/src/config/octane.php --- a/src/config/octane.php +++ b/src/config/octane.php @@ -230,7 +230,7 @@ 'swoole' => [ 'options' => [ 'log_file' => storage_path('logs/swoole_http.log'), - 'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 10 * 1024 * 1024), + 'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 50 * 1024 * 1024), 'enable_coroutine' => false, //FIXME the daemonize option does not work // 'daemonize' => env('OCTANE_DAEMONIZE', true), diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -226,6 +226,7 @@ Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); + Route::post('policy/mail/filter', [API\V4\PolicyController::class, 'mailfilter']); } ); } diff --git a/src/tests/Feature/Controller/PolicyTest.php b/src/tests/Feature/Controller/PolicyTest.php --- a/src/tests/Feature/Controller/PolicyTest.php +++ b/src/tests/Feature/Controller/PolicyTest.php @@ -94,4 +94,23 @@ $this->assertEquals('DUNNO', $json['response']); $this->assertMatchesRegularExpression('/^Received-Greylist: greylisted from/', $json['prepend'][0]); } + + /** + * Test mail filter (POST /api/webhooks/policy/mail/filter) + */ + public function testMailfilter() + { + // Note: Only basic tests here. More detailed policy handler tests are in another place + + $headers = ['CONTENT_TYPE' => 'message/rfc822']; + $post = file_get_contents(__DIR__ . '/../../data/mailfilter/itip1.eml'); + $post = str_replace("\n", "\r\n", $post); + + $response = $this->call('POST', '/api/webhooks/policy/mail/filter', [], [], [], $headers, $post) + ->assertStatus(201); + + // TODO: test returning mail content (footer module) + // TODO: test rejecting mail + $this->markTestIncomplete(); + } } diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModuleTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModuleTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModuleTest.php @@ -0,0 +1,41 @@ +handle($parser); + + $this->markTestIncomplete(); + } + + /** + * Test invitation REPLY + */ + public function testItipReply(): void + { + $this->markTestIncomplete(); + } + + /** + * Test invitation CANCEL + */ + public function testItipCancel(): void + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php @@ -0,0 +1,123 @@ +getBody(); + + $this->assertSame('eeea', $body); + + // Multipart/alternative mail + $parser = $this->getParserForFile('mailfilter/itip1.eml'); + + $body = $parser->getBody(); + + $this->assertSame(1639, strlen($body)); + + $body = $parser->getBody(0); // text/plain part + + $this->assertSame(189, strlen($body)); + $this->assertStringStartsWith('*Test Meeting', $body); + + $body = $parser->getBody(1); // text/calendar part + + $this->assertStringStartsWith("BEGIN:VCALENDAR\r\n", $body); + $this->assertStringEndsWith("\r\nEND:VCALENDAR", $body); + + // Non-existing part + $this->expectException(\Exception::class); + $parser->getBody(30); + } + + /** + * Test access to headers + */ + public function testGetHeader(): void + { + // Multipart/alternative email + $parser = $this->getParserForFile('mailfilter/itip1.eml'); + + $this->assertSame('Jack ', $parser->getHeader('from')); + $this->assertSame('Jack ', $parser->getHeader('From')); + $this->assertSame('multipart/alternative', $parser->getContentType()); + + $part = $parser->getParts()[0]; // text/plain part + + $this->assertSame('quoted-printable', $part->getHeader('content-transfer-encoding')); + $this->assertSame('text/plain', $part->getContentType()); + + $part = $parser->getParts()[1]; // text/calendar part + + $this->assertSame('8bit', $part->getHeader('content-transfer-encoding')); + $this->assertSame('text/calendar', $part->getContentType()); + } + + /** + * Test replacing mail content + */ + public function testReplaceBody(): void + { + // Replace whole body in a non-multipart mail + // Note: The body is base64 encoded + $parser = self::getParserForFile('mail/1.eml'); + + $parser->replaceBody('aa=aa'); + + $this->assertSame('aa=aa', $parser->getBody()); + $this->assertTrue($parser->isModified()); + + $parser = new MailParser($parser->getStream()); + + $this->assertSame('aa=aa', $parser->getBody()); + $this->assertSame('text/plain', $parser->getContentType()); + $this->assertSame('base64', $parser->getHeader('content-transfer-encoding')); + + // Replace text part in multipart/alternative mail + // Note: The body is quoted-printable encoded + $parser = $this->getParserForFile('mailfilter/itip1.eml'); + + $parser->replaceBody('aa=aa', 0); + $part = $parser->getParts()[0]; + + $this->assertSame('aa=aa', $part->getBody()); + $this->assertSame('aa=aa', $parser->getBody(0)); + $this->assertTrue($parser->isModified()); + + $parser = new MailParser($parser->getStream()); + $part = $parser->getParts()[0]; + + $this->assertSame('aa=aa', $parser->getBody(0)); + $this->assertSame('multipart/alternative', $parser->getContentType()); + $this->assertSame(null, $parser->getHeader('content-transfer-encoding')); + $this->assertSame('aa=aa', $part->getBody()); + $this->assertSame('text/plain', $part->getContentType()); + $this->assertSame('quoted-printable', $part->getHeader('content-transfer-encoding')); + } + + /** + * Create mail parser instance for specified test message + */ + public static function getParserForFile(string $file): MailParser + { + $mail = file_get_contents(__DIR__ . '/../../../data/' . $file); + $mail = str_replace("\n", "\r\n", $mail); + + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $mail); + rewind($stream); + + return new MailParser($stream); + } +} diff --git a/src/tests/data/mailfilter/itip1.eml b/src/tests/data/mailfilter/itip1.eml new file mode 100644 --- /dev/null +++ b/src/tests/data/mailfilter/itip1.eml @@ -0,0 +1,73 @@ +MIME-Version: 1.0 +From: Jack +Date: Tue, 09 Jul 2024 14:43:04 +0200 +Message-ID: +To: john@kolab.org +Subject: You've been invited to "Test Meeting" +Content-Type: multipart/alternative; + boundary="=_f77327deb61c6eccadcf01b3f6f854cb" + +--=_f77327deb61c6eccadcf01b3f6f854cb +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8; + format=flowed + +*Test Meeting * + +When: 2024-07-10 10:30 - 11:30 (Europe/Berlin) + +Please find attached an iCalendar file with all the event details which you= +=20 +can import to your calendar application. + +--=_f77327deb61c6eccadcf01b3f6f854cb +Content-Transfer-Encoding: 8bit +Content-Type: text/calendar; charset=UTF-8; method=REQUEST; + name=event.ics + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-MICROSOFT-CDO-TZID:4 +BEGIN:STANDARD +DTSTART:20231029T010000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +END:STANDARD +BEGIN:STANDARD +DTSTART:20241027T010000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20240331T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5 +DTSTAMP:20240709T124304Z +CREATED:20240709T124304Z +LAST-MODIFIED:20240709T124304Z +DTSTART;TZID=Europe/Berlin:20240710T103000 +DTEND;TZID=Europe/Berlin:20240710T113000 +SUMMARY:Test Meeting +LOCATION:Berlin +SEQUENCE:0 +TRANSP:OPAQUE +ATTENDEE;CN=John;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDI + VIDUAL;RSVP=TRUE:mailto:john@kolab.org +ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND + IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org +ORGANIZER;CN=Jack:mailto:jack@kolab.org +END:VEVENT +END:VCALENDAR +--=_f77327deb61c6eccadcf01b3f6f854cb--