Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F16235643
D4974.id14253.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Size
27 KB
Referenced Files
None
Subscribers
None
D4974.id14253.diff
View Options
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 @@
+<?php
+
+namespace App\Policy\Mailfilter;
+
+class MailParser
+{
+ protected $stream;
+
+ protected int $start = 0;
+ protected ?int $end;
+ protected ?int $bodyPosition;
+ protected bool $modified = false;
+
+ protected ?string $ctype = null;
+ protected array $ctypeParams = [];
+ protected array $headers = [];
+ protected ?array $parts = null;
+ protected array $validHeaders = [
+ 'content-transfer-encoding',
+ 'content-type',
+ 'from',
+ ];
+
+ /**
+ * Class constructor.
+ *
+ * @param resource $stream Mail content stream
+ * @param int $start Start position in the stream
+ * @param ?int $end End position in the stream
+ */
+ public function __construct($stream, int $start = 0, ?int $end = null)
+ {
+ $this->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 @@
+<?php
+
+namespace App\Policy\Mailfilter\Modules;
+
+use App\Policy\Mailfilter\MailParser;
+use App\Policy\Mailfilter\Result;
+
+class ItipModule
+{
+ public function handle(MailParser $parser): ?Result
+ {
+ $email = $parser->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,93 @@
+<?php
+
+namespace App\Policy\Mailfilter;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class RequestHandler
+{
+ /**
+ * SMTP Content Filter
+ *
+ * @param Request $request The API request.
+ *
+ * @return Response|StreamedResponse The response
+ */
+ public static function handle(Request $request)
+ {
+ // How big file we can handle depends on the method. We support both: 1) passing
+ // file in the request body, or 2) using multipart/form-data method (standard file upload).
+ // 1. For the first case maximum size is defined by:
+ // - w/ Swoole: package_max_length in config/octane.php,
+ // In this case Swoole needs twice as much memory as Laravel w/o Octane
+ // (https://github.com/laravel/octane/issues/959), e.g. 10 MB file under Octane will need 20MB
+ // plus the memory allocated initially (~22MB) = ~42MB
+ // So, to handle 50MB email message we need ~125MB memory (w/o Swoole it'll be ~55MB)
+ // Note: This does not yet consider parsing/modifying the content, but outputing the content
+ // back itself does not require any extra memory.
+ // - w/o Swoole: post_max_size in php.ini.
+ // 2. For the second case maximum size is defined by upload_max_filesize in config/octane.php or php.ini.
+ // In this case temp files are used no matter it's under Swoole or not, i.e. memory limit is not an issue.
+ // PHP's post_max_size have to be equal or greater for the w/o Swoole case.
+
+ // TODO: As a performance optimization... Not all mail bodies will need to be parsed.
+ // We should consider doing two requests. In first we'd send only mail headers,
+ // then we'd send body in another request, but only if needed. For example, a text/plain
+ // message from same domain sender does not include an iTip, nor needs a footer injection.
+
+ if ($request->hasFile('file')) {
+ if (!$request->file('file')->isValid()) {
+ return response('Invalid file upload', 500);
+ }
+
+ $stream = fopen($request->file->path(), 'r');
+ } else {
+ $stream = $request->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 @@
+<?php
+
+namespace App\Policy\Mailfilter;
+
+class Result
+{
+ public const STATUS_ACCEPT = 'ok';
+ public const STATUS_REJECT = 'reject';
+ public const STATUS_IGNORE = 'ignore';
+
+ protected $status;
+
+ /**
+ * Class constructor.
+ *
+ * @param ?string $status Delivery status
+ */
+ public function __construct(?string $status = null)
+ {
+ $this->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,14 @@
'swoole' => [
'options' => [
'log_file' => storage_path('logs/swoole_http.log'),
+
+ // Max input size, this does not apply to file uploads
'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 10 * 1024 * 1024),
+
+ // This defines max. size of a file uploaded using multipart/form-data method
+ // Swoole will handle the content with a temp. file.
+ 'upload_max_filesize' => env('SWOOLE_UPLOAD_MAX_FILESIZE', 10 * 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 @@
+<?php
+
+namespace Tests\Feature\Policy\Mailfilter\Modules;
+
+use App\Policy\Mailfilter\Modules\ItipModule;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ItipModuleTest extends TestCase
+{
+ /**
+ * Test invitation REQUEST
+ */
+ public function testItipRequest(): void
+ {
+ Queue::fake();
+
+ // Jack invites John and Ned
+ $parser = \Tests\Unit\Policy\Mailfilter\MailParserTest::getParserForFile('mailfilter/itip1.eml');
+ $module = new ItipModule();
+ $result = $module->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 @@
+<?php
+
+namespace Tests\Unit\Policy\Mailfilter;
+
+use App\Policy\Mailfilter\MailParser;
+use Tests\TestCase;
+
+class MailParserTest extends TestCase
+{
+ /**
+ * Test getBody()
+ */
+ public function testGetBody(): void
+ {
+ // Simple non-multipart mail
+ $parser = self::getParserForFile('mail/1.eml');
+
+ $body = $parser->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 <jack@kolab.org>', $parser->getHeader('from'));
+ $this->assertSame('Jack <jack@kolab.org>', $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 <jack@kolab.org>
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID: <f49deee5182874694372696db14c90f2@kolab.org>
+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--
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Oct 19, 9:25 PM (21 h, 55 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9894333
Default Alt Text
D4974.id14253.diff (27 KB)
Attached To
Mode
D4974: SMTP content filter
Attached
Detach File
Event Timeline
Log In to Comment