Page MenuHomePhorge

D4974.id14241.diff
No OneTemporary

D4974.id14241.diff

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;
+ 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,80 @@
+<?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 is specified by:
+ // - w/ Swoole: package_max_length in config/octane.php,
+ // - w/o Swoole: post_max_size in php.ini
+
+ // 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
+
+ // 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.
+
+ $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/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

Mime Type
text/plain
Expires
Thu, Oct 3, 2:32 PM (21 h, 5 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
9641676
Default Alt Text
D4974.id14241.diff (25 KB)

Event Timeline