Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117868006
D4974.1775323915.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
118 KB
Referenced Files
None
Subscribers
None
D4974.1775323915.diff
View Options
diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -4,11 +4,13 @@
package-lock.json
public/css/*.css
public/hot
-public/js/*.js
+public/js/
public/storage/
+public/themes/
storage/*.key
storage/*.log
storage/*-????-??-??*
+storage/ews/
storage/export/
tests/report/
vendor
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -186,6 +186,7 @@
. '<d:prop>'
. '<d:resourcetype />'
. '<d:displayname />'
+ . '<d:owner/>'
. '<cs:getctag />'
. $props
. '</d:prop>'
@@ -425,10 +426,11 @@
* @param string $location Folder location
* @param DAV\Search $search Search request parameters
* @param callable $callback A callback to execute on every item
+ * @param bool $opaque Return objects as instances of DAV\Opaque
*
* @return false|array List of objects on success, False on error
*/
- public function search(string $location, DAV\Search $search, $callback = null)
+ public function search(string $location, DAV\Search $search, $callback = null, $opaque = false)
{
$headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal'];
@@ -442,7 +444,11 @@
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
- $object = $this->objectFromElement($element, $search->component);
+ if ($opaque) {
+ $object = DAV\Opaque::fromDomElement($element);
+ } else {
+ $object = $this->objectFromElement($element, $search->component);
+ }
if ($callback) {
$object = $callback($object);
@@ -598,15 +604,21 @@
protected function request($path, $method, $body = '', $headers = [])
{
$debug = \config('app.debug');
- $url = $this->url;
+ $url = trim($this->url, '/');
$this->responseHeaders = [];
- if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) {
- $path = substr($path, strlen($rootPath));
- }
+ // Remove the duplicate path prefix
+ if ($path) {
+ $rootPath = parse_url($url, PHP_URL_PATH);
+ $path = '/' . ltrim($path, '/');
- $url .= $path;
+ if ($rootPath && str_starts_with($path, $rootPath)) {
+ $path = substr($path, strlen($rootPath));
+ }
+
+ $url .= $path;
+ }
$client = Http::withBasicAuth($this->user, $this->password)
// ->withToken($token) // Bearer token
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -22,6 +22,9 @@
/** @var ?string Folder color (calendar-color property) */
public $color;
+ /** @var ?string Folder owner (email) */
+ public $owner;
+
/**
* Create Folder object from a DOMElement element
@@ -69,6 +72,15 @@
}
}
+ if ($owner = $element->getElementsByTagName('owner')->item(0)) {
+ if ($owner->firstChild) {
+ $href = $owner->firstChild->nodeValue; // owner principal href
+ $href = explode('/', trim($href, '/'));
+
+ $folder->owner = urldecode(end($href));
+ }
+ }
+
$folder->types = $types;
$folder->components = $components;
diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php
--- a/src/app/Backends/DAV/Opaque.php
+++ b/src/app/Backends/DAV/Opaque.php
@@ -6,7 +6,7 @@
{
protected $content;
- public function __construct($content, $is_file = false)
+ public function __construct($content = null, $is_file = false)
{
if ($is_file) {
$this->content = file_get_contents($content);
@@ -24,4 +24,34 @@
{
return $this->content;
}
+
+ /**
+ * Create an object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with object properties
+ *
+ * @return Opaque
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ /** @var self $object */
+ $object = parent::fromDomElement($element);
+
+ foreach (['address-data', 'calendar-data'] as $name) {
+ if ($data = $element->getElementsByTagName($name)->item(0)) {
+ $object->setContent($data->nodeValue);
+ break;
+ }
+ }
+
+ return $object;
+ }
+
+ /**
+ * Set the object content
+ */
+ public function setContent($content): void
+ {
+ $this->content = $content;
+ }
}
diff --git a/src/app/Backends/DAV/Search.php b/src/app/Backends/DAV/Search.php
--- a/src/app/Backends/DAV/Search.php
+++ b/src/app/Backends/DAV/Search.php
@@ -16,11 +16,13 @@
public $withContent = false;
+ public $filters = [];
- public function __construct($component, $withContent = false)
+ public function __construct($component, $withContent = false, $filters = [])
{
$this->component = $component;
$this->withContent = $withContent;
+ $this->filters = $filters;
}
/**
@@ -67,17 +69,16 @@
}
// Search filter
- $filter = '';
+ $filters = $this->filters;
if ($this->component == DAV::TYPE_VCARD) {
$query = 'addressbook-query';
} else {
$query = 'calendar-query';
- $filter = '<c:filter>'
- . '<c:comp-filter name="VCALENDAR">'
- . '<c:comp-filter name="' . $this->component . '" /></c:comp-filter>'
- . '</c:filter>';
+ array_unshift($filters, new SearchCompFilter('VCALENDAR', [new SearchCompFilter($this->component)]));
}
+ $filter = new SearchFilter($filters);
+
if (empty($props)) {
$props = '<d:allprop/>';
} else {
diff --git a/src/app/Backends/DAV/SearchCompFilter.php b/src/app/Backends/DAV/SearchCompFilter.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/SearchCompFilter.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Backends\DAV;
+
+use App\Backends\DAV;
+
+class SearchCompFilter
+{
+ public $name;
+ public $filters = [];
+
+
+ public function __construct($name, $filters = [])
+ {
+ $this->name = $name;
+ $this->filters = $filters;
+ }
+
+ /**
+ * Create string representation of the prop-filter
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = '<c:comp-filter name="' . $this->name . '"';
+
+ if (empty($this->filters)) {
+ $filter .= '/>';
+ } else {
+ $filter .= '>';
+
+ foreach ($this->filters as $sub_filter) {
+ $filter .= (string) $sub_filter;
+ }
+
+ $filter .= '</c:comp-filter>';
+ }
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/SearchFilter.php b/src/app/Backends/DAV/SearchFilter.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/SearchFilter.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Backends\DAV;
+
+use App\Backends\DAV;
+
+class SearchFilter
+{
+ public $filters = [];
+
+ public function __construct($filters = [])
+ {
+ $this->filters = $filters;
+ }
+
+ /**
+ * Create string representation of the search
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = '<c:filter>';
+
+ foreach ($this->filters as $sub_filter) {
+ $filter .= (string) $sub_filter;
+ }
+
+ $filter .= '</c:filter>';
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/SearchPropFilter.php b/src/app/Backends/DAV/SearchPropFilter.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/SearchPropFilter.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Backends\DAV;
+
+use App\Backends\DAV;
+
+class SearchPropFilter
+{
+ public const IS_NOT_DEFINED = 'is-not-defined';
+
+ public const MATCH_EQUALS = 'equals';
+ public const MATCH_CONTAINS = 'contains';
+ public const MATCH_STARTS_WITH = 'starts-with';
+ public const MATCH_ENDS_WITH = 'ends-with';
+
+ public $name;
+ public $type;
+ public $collation;
+ public $negate = false;
+ public $value;
+
+
+ public function __construct(string $name, string $type, ?string $value = null, ?string $collation = null)
+ {
+ $this->name = $name;
+ $this->type = $type;
+ $this->value = $value;
+ $this->collation = $collation;
+ }
+
+ /**
+ * Create string representation of the prop-filter
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = '<c:prop-filter name="' . $this->name . '">';
+
+ if ($this->type == self::IS_NOT_DEFINED) {
+ $filter .= '<c:is-not-defined/>';
+ } elseif ($this->type) {
+ $filter .= '<c:text-match match-type="' . $this->type . '"';
+
+ if ($this->collation) {
+ $filter .= ' collation="' . $this->collation . '"';
+ }
+
+ if ($this->negate) {
+ $filter .= ' negate-condition="yes"';
+ }
+
+ $filter .= '>' . htmlspecialchars($this->value, ENT_XML1, 'UTF-8') . '</c:text-match>';
+ }
+
+ $filter .= '</c:prop-filter>';
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
--- a/src/app/Backends/DAV/Vevent.php
+++ b/src/app/Backends/DAV/Vevent.php
@@ -17,11 +17,14 @@
public $class;
public $comment;
public $description;
+ public $exceptions = [];
public $exdate = [];
public $location;
+ public $method;
public $organizer;
public $priority;
public $prodid;
+ public $recurrenceId;
public $rdate = [];
public $rrule = [];
public $sequence;
@@ -78,10 +81,22 @@
$this->prodid = (string) $this->vobject->PRODID;
}
+ if (!empty($this->vobject->METHOD)) {
+ $this->method = (string) $this->vobject->METHOD;
+ }
+
+ $hasMaster = null;
+
foreach ($this->vobject->getComponents() as $component) {
if ($component->name == $selfType) {
- $this->fromVObject($component);
- return;
+ if (empty($hasMaster) && empty($component->{'RECURRENCE-ID'})) {
+ $this->fromVObject($component);
+ $hasMaster = true;
+ } elseif ($this->uid && $this->uid == $component->uid && !empty($component->{'RECURRENCE-ID'})) {
+ $exception = new static(); // @phpstan-ignore-line
+ $exception->fromVObject($component);
+ $this->exceptions[] = $exception;
+ }
}
}
}
@@ -91,7 +106,7 @@
*
* @param Component $vobject Sabre/VObject component
*/
- protected function fromVObject(Component $vobject): void
+ public function fromVObject(Component $vobject): void
{
$string_properties = [
'CLASS',
@@ -99,6 +114,7 @@
'DESCRIPTION',
'LOCATION',
'PRIORITY',
+ 'RECURRENCE-ID',
'SEQUENCE',
'STATUS',
'SUMMARY',
@@ -153,8 +169,7 @@
case 'EXDATE':
case 'RDATE':
$key = strtolower($prop->name);
-
- // TODO
+ $this->{$key}[] = $prop;
break;
case 'ATTENDEE':
diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php
--- a/src/app/Backends/DAV/Vtodo.php
+++ b/src/app/Backends/DAV/Vtodo.php
@@ -16,7 +16,7 @@
*
* @param Component $vobject Sabre/VObject component
*/
- protected function fromVObject(Component $vobject): void
+ public function fromVObject(Component $vobject): void
{
// Handle common properties with VEVENT
parent::fromVObject($vobject);
diff --git a/src/app/Console/Commands/DB/ExpungeCommand.php b/src/app/Console/Commands/DB/ExpungeCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/DB/ExpungeCommand.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Console\Commands\DB;
+
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class ExpungeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'db:expunge';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Expunge old records from the database tables';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ \App\Policy\Greylist\Connect::where('updated_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
+ ->delete();
+
+ \App\Policy\Greylist\Whitelist::where('updated_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
+ ->delete();
+
+ \App\Policy\RateLimit::where('updated_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
+ ->delete();
+
+ \App\SignupCode::where('created_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
+ ->forceDelete();
+
+ DB::table('failed_jobs')->where('failed_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
+ ->delete();
+
+ // TODO: What else? Should we force-delete deleted "dummy/spammer" accounts?
+ }
+}
diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php
--- a/src/app/Console/Kernel.php
+++ b/src/app/Console/Kernel.php
@@ -40,6 +40,9 @@
// This removes passport expired/revoked tokens and auth codes from the database
$schedule->command('passport:purge')->dailyAt('06:30');
+
+ // Keep the database size under control (every Monday)
+ $schedule->command('db:expunge')->weeklyOn(1, '04:00');
}
/**
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,382 @@
+<?php
+
+namespace App\Policy\Mailfilter;
+
+use App\User;
+
+class MailParser
+{
+ protected $stream;
+
+ protected int $start = 0;
+ protected ?int $end;
+ protected ?int $bodyPosition;
+ protected bool $modified = false;
+ protected string $recipient = '';
+ protected string $sender = '';
+ protected ?User $user = null;
+
+ 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 getRecipient(): string
+ {
+ // TODO: We need to pass the target mailbox from Postfix in some way
+ // Delivered-To header or a HTTP request header? or in the URL?
+
+ return $this->recipient;
+ }
+
+ /**
+ * 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 email address of the sender (envelope sender)
+ */
+ public function getSender(): string
+ {
+ return $this->sender;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Returns User object of the recipient
+ */
+ public function getUser(): ?User
+ {
+ if ($this->user === null) {
+ $this->user = User::where('email', $this->getRecipient())->firstOrFail();
+ }
+
+ return $this->user;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Set email address of the recipient
+ */
+ public function setRecipient(string $recipient): void
+ {
+ $this->recipient = $recipient;
+ }
+
+ /**
+ * Set email address of the sender (envelope sender)
+ */
+ public function setSender(string $sender): void
+ {
+ $this->sender = $sender;
+ }
+
+ /**
+ * 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,250 @@
+<?php
+
+namespace App\Policy\Mailfilter\Modules;
+
+use App\Backends\DAV;
+use App\User;
+use App\Policy\Mailfilter\MailParser;
+use App\Policy\Mailfilter\Result;
+use Sabre\VObject\Component;
+use Sabre\VObject\Document;
+use Sabre\VObject\Reader;
+
+class ItipModule
+{
+ protected $davClient;
+ protected $davFolder;
+ protected $davLocation;
+ protected $davTokenExpiresOn;
+ protected $davTTL = 10;
+
+ /** @var string Processed object type ('VEVENT' or 'VTODO') */
+ protected $type;
+
+ /** @var string Processed object UID property */
+ protected $uid;
+
+ /**
+ * Handle the email message
+ */
+ public function handle(MailParser $parser): ?Result
+ {
+ $itip = self::getItip($parser);
+
+ if ($itip === null) {
+ return null; // do nothing
+ }
+
+ // TODO: Get the user's invitation policy
+
+ $vobject = $this->parseICal($itip);
+
+ if ($vobject === null) {
+ return null; // do nothing
+ }
+
+ // Note: Some iTip handling implementation can be find in vendor/sabre/vobject/lib/ITip/Broker.php,
+ // however I think we need something more sophisticated that we can extend ourselves.
+
+ // FIXME: If $vobject->METHOD is empty fallback to 'method' param from the Content-Type header?
+ // rfc5545#section-3.7.2 says if one is specified the other must be too
+ // @phpstan-ignore-next-line
+ switch (\strtoupper((string) $vobject->METHOD)) {
+ case 'REQUEST':
+ $handler = new ItipModule\RequestHandler($vobject, $this->type, $this->uid);
+ break;
+ case 'CANCEL':
+ $handler = new ItipModule\CancelHandler($vobject, $this->type, $this->uid);
+ break;
+ case 'REPLY':
+ $handler = new ItipModule\ReplyHandler($vobject, $this->type, $this->uid);
+ break;
+ }
+
+ // FIXME: Should we handle (any?) errors silently and just deliver the message to Inbox as a fallback?
+ if (!empty($handler)) {
+ return $handler->handle($parser);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the main event/task from the VCALENDAR object
+ */
+ protected static function extractMainComponent(COmponent $vobject): ?Component
+ {
+ foreach ($vobject->getComponents() as $component) {
+ if ($component->name == 'VEVENT' || $component->name == 'VTODO') {
+ if (empty($component->{'RECURRENCE-ID'})) {
+ return $component;
+ }
+ }
+ }
+
+ // If no recurrence-instance components were found, return any
+ foreach ($vobject->getComponents() as $component) {
+ if ($component->name == 'VEVENT' || $component->name == 'VTODO') {
+ return $component;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get specific event/task recurrence instance from the VCALENDAR object
+ */
+ protected static function extractRecurrenceInstanceComponent(COmponent $vobject, string $recurrence_id): ?Component
+ {
+ foreach ($vobject->getComponents() as $component) {
+ if ($component->name == 'VEVENT' || $component->name == 'VTODO') {
+ if (strval($component->{'RECURRENCE-ID'}) === $recurrence_id) {
+ return $component;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Find an event in user calendar
+ */
+ protected function findObject(User $user, $uid, $dav_type): ?Component
+ {
+ if ($uid === null || $uid === '') {
+ return null;
+ }
+
+ $dav = $this->getDAVClient($user);
+ $filters = [new DAV\SearchPropFilter('UID', DAV\SearchPropFilter::MATCH_EQUALS, $uid)];
+ $search = new DAV\Search($dav_type, true, $filters);
+
+ foreach ($dav->listFolders($dav_type) as $folder) {
+ // No delegation yet, we skip other users' folders
+ if ($folder->owner !== $user->email) {
+ continue;
+ }
+
+ // Skip schedule inbox/outbox
+ if (in_array('schedule-inbox', $folder->types) || in_array('schedule-outbox', $folder->types)) {
+ continue;
+ }
+
+ // TODO: This default folder detection is kinda silly, but this is what we do in other places
+ if ($this->davFolder === null || preg_match('~/(Default|Tasks)/?$~', $folder->href)) {
+ $this->davFolder = $folder;
+ }
+
+ foreach ($dav->search($folder->href, $search, null, true) as $event) {
+ if ($vobject = $this->parseICal((string) $event)) {
+ $this->davLocation = $event->href;
+ $this->davFolder = $folder;
+
+ return $vobject;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get DAV client
+ */
+ protected function getDAVClient(User $user): DAV
+ {
+ // Use short-lived token to authenticate as user
+ if (!$this->davTokenExpiresOn || now()->greaterThanOrEqualTo($this->davTokenExpiresOn)) {
+ $password = \App\Auth\Utils::tokenCreate((string) $user->id, $this->davTTL);
+
+ $this->davTokenExpiresOn = now()->addSeconds($this->davTTL - 1);
+ $this->davClient = new DAV($user->email, $password);
+ }
+
+ return $this->davClient;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Parse an iTip content
+ */
+ protected function parseICal($ical): ?Document
+ {
+ $vobject = Reader::read($ical, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES);
+
+ if ($vobject->name != 'VCALENDAR') {
+ return null;
+ }
+
+ foreach ($vobject->getComponents() as $component) {
+ // TODO: VTODO
+ if ($component->name == 'VEVENT') {
+ if ($this->uid === null) {
+ $this->uid = (string) $component->uid;
+ $this->type = (string) $component->name;
+
+ // TODO: We should probably sanity check the VCALENDAR content,
+ // e.g. we should ignore/remove all components with UID different then the main (first) one.
+ // In case of some obvious issues, delivering the message to inbox is probably safer.
+ } elseif (strval($component->uid) != $this->uid) {
+ continue;
+ }
+
+ return $vobject;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Prepare VCALENDAR object for submission to DAV
+ */
+ protected function toOpaqueObject(Component $vobject, $location = null): DAV\Opaque
+ {
+ // Cleanup
+ $vobject->remove('METHOD');
+
+ // Create an opaque object
+ $object = new DAV\Opaque($vobject->serialize());
+ $object->contentType = 'text/calendar; charset=utf-8';
+ $object->href = $location;
+
+ // no location? then it's a new object
+ if (!$location) {
+ $object->href = trim($this->davFolder->href, '/') . '/' . urlencode($this->uid) . '.ics';
+ }
+
+ return $object;
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Policy\Mailfilter\Modules\ItipModule;
+
+use App\Policy\Mailfilter\MailParser;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Notifications\ItipNotificationParams;
+use App\Policy\Mailfilter\Result;
+use Sabre\VObject\Component;
+
+class CancelHandler extends ItipModule
+{
+ protected Component $itip;
+
+ public function __construct(Component $itip, string $type, string $uid)
+ {
+ $this->itip = $itip;
+ $this->type = $type;
+ $this->uid = $uid;
+ }
+
+ /**
+ * Handle the email message
+ */
+ public function handle(MailParser $parser): ?Result
+ {
+ $user = $parser->getUser();
+
+ // Check whether the event already exists
+ $existing = $this->findObject($user, $this->uid, $this->type);
+
+ if (!$existing) {
+ // FIXME: Should we stop message delivery?
+ return null;
+ }
+
+ // FIXME: what to do if CANCEL attendees do not match with the recipient email(s)?
+ // FIXME: what to do if CANCEL does not come from the organizer's email?
+ // stop processing here and pass the message to the inbox?
+
+ $existingMaster = $this->extractMainComponent($existing);
+ $cancelMaster = $this->extractMainComponent($this->itip);
+
+ if (!$existingMaster || !$cancelMaster) {
+ // FIXME: Should we stop message delivery?
+ return null;
+ }
+
+ // SEQUENCE does not match, deliver the message, let the MUAs to deal with this
+ // FIXME: Is this even a valid aproach regarding recurrence?
+ if (strval($existingMaster->SEQUENCE) != strval($cancelMaster->SEQUENCE)) {
+ return null;
+ }
+
+ $recurrence_id = (string) $cancelMaster->{'RECURRENCE-ID'};
+
+ if ($recurrence_id) {
+ // When we cancel an event occurence we update the main event by removing
+ // the exception VEVENT components, and adding EXDATE entries into the master.
+
+ // First find and remove the exception object, if exists
+ if ($existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id)) {
+ $existing->remove($existingInstance);
+ }
+
+ // Add the EXDATE entry
+ // FIXME: Do we need to handle RECURRENE-ID differently to get the exception date (timezone)?
+ // TODO: We should probably make sure the entry does not exist yet
+ $exdate = $cancelMaster->{'RECURRENCE-ID'}->getDateTime();
+ $existingMaster->add('EXDATE', $exdate, ['VALUE' => 'DATE'], 'DATE');
+
+ $dav = $this->getDAVClient($user);
+ $dav->update($this->toOpaqueObject($existing, $this->davLocation));
+ } else {
+ $existingInstance = $existingMaster;
+
+ // Remove the event from attendee's calendar
+ // Note: We make this the default case because Outlook does not like events with cancelled status
+ // optionally we could update the event with STATUS=CANCELLED instead.
+ $dav = $this->getDAVClient($user);
+ $dav->delete($this->davLocation);
+ }
+
+ // Send a notification to the recipient (attendee)
+ $user->notify($this->notification($existingInstance, $cancelMaster->COMMENT));
+
+ // Remove (not deliver) the message to the attendee's inbox
+ return new Result(Result::STATUS_DISCARD);
+ }
+
+ /**
+ * Create a notification
+ */
+ private function notification(Component $existing, $comment): ItipNotification
+ {
+ $organizer = $existing->ORGANIZER;
+
+ $params = new ItipNotificationParams('cancel', $existing);
+ $params->comment = (string) $comment;
+ $params->senderName = (string) $organizer['CN'];
+ $params->senderEmail = strtolower(preg_replace('!^mailto:!i', '', (string) $organizer));
+
+ return new ItipNotification($params);
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Policy\Mailfilter\Modules\ItipModule;
+
+use App\Policy\Mailfilter\MailParser;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Notifications\ItipNotificationParams;
+use App\Policy\Mailfilter\Result;
+use Sabre\VObject\Component;
+
+class ReplyHandler extends ItipModule
+{
+ protected Component $itip;
+
+ public function __construct(Component $itip, string $type, string $uid)
+ {
+ $this->itip = $itip;
+ $this->type = $type;
+ $this->uid = $uid;
+ }
+
+ /**
+ * Handle the email message
+ */
+ public function handle(MailParser $parser): ?Result
+ {
+ $user = $parser->getUser();
+
+ // Accourding to https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.3 REPLY is used to:
+ // - respond (e.g., accept or decline) to a "REQUEST"
+ // - reply to a delegation "REQUEST"
+
+ // TODO: We might need to use DAV locking mechanism if multiple processes
+ // are likely to attempt to update the same event at the same time.
+
+ // Check whether the event already exists
+ $existing = $this->findObject($user, $this->uid, $this->type);
+
+ if (!$existing) {
+ // FIXME: Should we stop message delivery?
+ return null;
+ }
+
+ // FIXME: what to do if the REPLY comes from an address not mentioned in the event?
+ // FIXME: Should we check if the recipient is an organizer?
+ // stop processing here and pass the message to the inbox, or drop it?
+
+ $existingMaster = $this->extractMainComponent($existing);
+ $replyMaster = $this->extractMainComponent($this->itip);
+
+ if (!$existingMaster || !$replyMaster) {
+ return null;
+ }
+
+ // SEQUENCE does not match, deliver the message, let the MUAs to deal with this
+ // FIXME: Is this even a valid aproach regarding recurrence?
+ if (strval($existingMaster->SEQUENCE) != strval($replyMaster->SEQUENCE)) {
+ return null;
+ }
+
+ // Per RFC 5546 there can be only one ATTENDEE in REPLY
+ if (count($replyMaster->ATTENDEE) != 1) {
+ return null;
+ }
+
+ // TODO: Delegation
+
+ $sender = $replyMaster->ATTENDEE;
+ $partstat = $sender['PARTSTAT'];
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $sender));
+
+ // Supporting attendees w/o an email address could be considered in the future
+ if (empty($email)) {
+ return null;
+ }
+
+ // Invalid/useless reply, let the MUA deal with it
+ // FIXME: Or should we stop delivery?
+ if (empty($partstat) || $partstat == 'NEEDS-ACTION') {
+ return null;
+ }
+
+ $recurrence_id = (string) $replyMaster->{'RECURRENCE-ID'};
+
+ if ($recurrence_id) {
+ $existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id);
+ // No such recurrence exception, let the MUA deal with it
+ // FIXME: Or should we stop delivery?
+ if (!$existingInstance) {
+ return null;
+ }
+ } else {
+ $existingInstance = $existingMaster;
+ }
+
+ // Update organizer's event with attendee status
+ $updated = false;
+ if (isset($existingInstance->ATTENDEE)) {
+ foreach ($existingInstance->ATTENDEE as $attendee) {
+ $value = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ if ($value === $email) {
+ if (empty($attendee['PARTSTAT']) || strval($attendee['PARTSTAT']) != $partstat) {
+ $attendee['PARTSTAT'] = $partstat;
+ $updated = true;
+ }
+ }
+ }
+ }
+
+ if ($updated) {
+ $dav = $this->getDAVClient($user);
+ $dav->update($this->toOpaqueObject($existing, $this->davLocation));
+
+ // TODO: We do not update the status in other attendee's calendars. We should consider
+ // doing something more standard, send them unsolicited REQUEST in the name of the organizer,
+ // as described in https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.2.2.
+ // Remove (not deliver) the message to the organizer's inbox
+
+ // Send a notification to the organizer
+ $user->notify($this->notification($existingInstance, $sender, $replyMaster->COMMENT));
+ }
+
+ return new Result(Result::STATUS_DISCARD);
+ }
+
+ /**
+ * Create a notification
+ */
+ private function notification(Component $existing, $attendee, $comment): ItipNotification
+ {
+ $params = new ItipNotificationParams('reply', $existing);
+ $params->comment = (string) $comment;
+ $params->partstat = (string) $attendee['PARTSTAT'];
+ $params->senderName = (string) $attendee['CN'];
+ $params->senderEmail = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+
+ return new ItipNotification($params);
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace App\Policy\Mailfilter\Modules\ItipModule;
+
+use App\Policy\Mailfilter\MailParser;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Result;
+use Sabre\VObject\Component;
+use Sabre\VObject\Document;
+
+class RequestHandler extends ItipModule
+{
+ protected Component $itip;
+
+ public function __construct(Component $itip, string $type, string $uid)
+ {
+ $this->itip = $itip;
+ $this->type = $type;
+ $this->uid = $uid;
+ }
+
+ /**
+ * Handle the email message
+ */
+ public function handle(MailParser $parser): ?Result
+ {
+ $user = $parser->getUser();
+
+ // According to https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.2 REQUESTs are used to:
+ // - Invite "Attendees" to an event.
+ // - Reschedule an existing event.
+ // - Response to a REFRESH request.
+ // - Update the details of an existing event, without rescheduling it.
+ // - Update the status of "Attendees" of an existing event, without rescheduling it.
+ // - Reconfirm an existing event, without rescheduling it.
+ // - Forward a "VEVENT" to another uninvited user.
+ // - For an existing "VEVENT" calendar component, delegate the role of "Attendee" to another user.
+ // - For an existing "VEVENT" calendar component, change the role of "Organizer" to another user.
+
+ // FIXME: This whole method could be async, if we wanted to be more responsive on mail delivery,
+ // but CANCEL and REPLY could not, because we're potentially stopping mail delivery there,
+ // so I suppose we'll do all of them synchronously for now. Still some parts of it can be async.
+
+ // Check whether the object already exists in the recipient's calendar
+ $existing = $this->findObject($user, $this->uid, $this->type);
+
+ // Sanity check
+ if (!$this->davFolder) {
+ \Log::error("Failed to get any DAV folder for {$user->email}.");
+ return null;
+ }
+
+ // FIXME: what to do if REQUEST attendees do not match with the recipient email(s)?
+ // stop processing here and pass the message to the inbox?
+
+ $requestMaster = $this->extractMainComponent($this->itip);
+ $recurrence_id = strval($requestMaster->{'RECURRENCE-ID'});
+
+ // The event does not exist yet in the recipient's calendar, create it
+ if (!$existing) {
+ if (!empty($recurrence_id)) {
+ return null;
+ }
+
+ // Create the event in the recipient's calendar
+ $dav = $this->getDAVClient($user);
+ $dav->create($this->toOpaqueObject($this->itip));
+
+ return null;
+ }
+
+ // TODO: Cover all cases mentioned above
+
+ // FIXME: For updates that don't create a new exception should we replace the iTip with a notification?
+ // Or maybe we should not even bother with auto-updating and leave it to MUAs?
+
+ if ($recurrence_id) {
+ // Recurrence instance
+ $existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id);
+
+ // A new recurrence instance, just add it to the existing event
+ if (!$existingInstance) {
+ $existing->add($requestMaster);
+ // TODO: Bump LAST-MODIFIED on the master object
+ } else {
+ // SEQUENCE does not match, deliver the message, let the MUAs deal with this
+ // TODO: A higher SEQUENCE indicates a re-scheduled object, we should update the existing event.
+ if (intval(strval($existingInstance->SEQUENCE)) != intval(strval($requestMaster->SEQUENCE))) {
+ return null;
+ }
+
+ $this->mergeComponents($existingInstance, $requestMaster);
+ // TODO: Bump LAST-MODIFIED on the master object
+ }
+ } else {
+ // Master event
+ $existingMaster = $this->extractMainComponent($existing);
+
+ if (!$existingMaster) {
+ return null;
+ }
+
+ // SEQUENCE does not match, deliver the message, let the MUAs deal with this
+ // TODO: A higher SEQUENCE indicates a re-scheduled object, we should update the existing event.
+ if (intval(strval($existingMaster->SEQUENCE)) != intval(strval($requestMaster->SEQUENCE))) {
+ return null;
+ }
+
+ // FIXME: Merge all components included in the request?
+ $this->mergeComponents($existingMaster, $requestMaster);
+ }
+
+ $dav = $this->getDAVClient($user);
+ $dav->update($this->toOpaqueObject($existing, $this->davLocation));
+
+ return null;
+ }
+
+ /**
+ * Merge VOBJECT component properties into another component
+ */
+ protected function mergeComponents(Component $to, Component $from): void
+ {
+ // TODO: Every property? What other properties? EXDATE/RDATE? ATTENDEE?
+ $props = ['SEQUENCE', 'RRULE'];
+ foreach ($props as $prop) {
+ $to->{$prop} = $from->{$prop} ?? null;
+ }
+
+ // If RRULE contains UNTIL remove exceptions from the timestamp forward
+ if (isset($to->RRULE) && ($parts = $to->RRULE->getParts()) && !empty($parts['UNTIL'])) {
+ // TODO: Timezone? Also test that with UNTIL using a date format
+ $until = new \DateTime($parts['UNTIL']);
+ /** @var Document $doc */
+ $doc = $to->parent;
+
+ foreach ($doc->getComponents() as $component) {
+ if ($component->name == $to->name && !empty($component->{'RECURRENCE-ID'})) {
+ if ($component->{'RECURRENCE-ID'}->getDateTime() >= $until) {
+ $doc->remove($component);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Notifications/ItipNotification.php b/src/app/Policy/Mailfilter/Notifications/ItipNotification.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Notifications/ItipNotification.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Policy\Mailfilter\Notifications;
+
+use App\User;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Mail\Mailable;
+use Illuminate\Notifications\Notification;
+
+class ItipNotification extends Notification implements ShouldQueue
+{
+ use Queueable;
+
+ public ItipNotificationParams $params;
+
+ /**
+ * Create a new notification instance.
+ */
+ public function __construct(ItipNotificationParams $params)
+ {
+ $this->params = $params;
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ */
+ public function toMail(User $user): Mailable
+ {
+ $this->params->user = $user;
+
+ return (new ItipNotificationMail($this->params));
+ }
+
+ /**
+ * Get the notification's delivery channels.
+ *
+ * @return array<int, string>
+ */
+ public function via(User $user): array
+ {
+ // If we wanted to use different/custom notification channel e.g. IMAP
+ // we have to return the Channel class.
+ // https://laravel.com/docs/10.x/notifications#custom-channels
+
+ return ['mail'];
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php b/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Policy\Mailfilter\Notifications;
+
+use App\Mail\Mailable;
+
+class ItipNotificationMail extends Mailable
+{
+ protected ItipNotificationParams $params;
+
+ /**
+ * Create a new message instance.
+ *
+ * @param ItipNotificationParams $params Mail content parameters
+ *
+ * @return void
+ */
+ public function __construct(ItipNotificationParams $params)
+ {
+ $this->params = $params;
+ }
+
+ /**
+ * Build the message.
+ *
+ * @return $this
+ */
+ public function build()
+ {
+ $mode = $this->params->mode ?? 'request';
+ $vars = get_object_vars($this->params);
+
+ $vars['sender'] = $this->params->senderName ?: $this->params->senderEmail ?: '';
+
+ $vars['body1'] = \trans("mail.itip-{$mode}-body", $vars);
+ $vars['body2'] = '';
+
+ if ($mode == 'reply' && !empty($this->params->partstat)) {
+ $partstat = strtolower($this->params->partstat);
+ $vars['body2'] = \trans("mail.itip-attendee-{$partstat}", $vars);
+ }
+
+ $this->view('emails.html.itip_notification')
+ ->text('emails.plain.itip_notification')
+ ->subject(\trans("mail.itip-{$mode}-subject", $vars))
+ ->with(['vars' => $vars]);
+
+ // TODO: Support aliases, i.e. use the email from the email message
+ $this->to($this->params->user->email);
+
+ // FIXME: Should we just send the message using Cockpit's "noreply" address?
+ if (!empty($this->params->senderEmail)) {
+ $this->from($this->params->senderEmail, $this->params->senderName);
+ }
+
+ return $this;
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php b/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php
new file mode 100644
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Policy\Mailfilter\Notifications;
+
+use App\User;
+use Sabre\VObject\Component;
+
+class ItipNotificationParams
+{
+ /** @var ?string $comment iTip COMMENT property */
+ public ?string $comment;
+
+ /** @var ?string $mode Notification mode (iTip method) */
+ public ?string $mode;
+
+ /** @var ?string $partstat Attendee PARTSTAT property in an iTip REPLY */
+ public ?string $partstat;
+
+ /** @var ?string $recurrenceId Recurrence identifier of an event/task occurence */
+ public ?string $recurrenceId;
+
+ /** @var ?string $summary iTip sender (attendee or organizer) email address */
+ public ?string $senderEmail;
+
+ /** @var ?string $summary iTip sender (attendee or organizer) name */
+ public ?string $senderName;
+
+ /** @var ?string $start Event/task start date or date-time */
+ public ?string $start;
+
+ /** @var ?string $summary Event/Task summary */
+ public ?string $summary;
+
+ /** @var ?User $user The recipient of the notification */
+ public ?User $user;
+
+ /**
+ * Object constructor
+ *
+ * @param string $mode Notification mode (request, cancel, reply)
+ * @param ?Component $object Event/task object
+ */
+ public function __construct(string $mode, ?Component $object = null)
+ {
+ $this->mode = $mode;
+
+ if ($object) {
+ $this->recurrenceId = (string) $object->{'RECURRENCE-ID'};
+ $this->summary = (string) $object->SUMMARY;
+ // TODO: Format date-time according to the user locale
+ $this->start = $object->DTSTART->getDateTime()->format($object->DTSTART->hasTime() ? 'Y-m-d H:i' : 'Y-m-d');
+
+ if (empty($this->summary) && !empty($this->recurrenceId)) {
+ // TODO: Get the 'summary' from the master event
+ }
+ }
+ }
+}
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,103 @@
+<?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.
+
+ $files = $request->allFiles();
+ if (count($files) == 1) {
+ $file = $files[array_key_first($files)];
+ if (!$file->isValid()) {
+ return response('Invalid file upload', 500);
+ }
+
+ $stream = fopen($file->path(), 'r');
+ } else {
+ $stream = $request->getContent(true);
+ }
+
+ $parser = new MailParser($stream);
+
+ if ($recipient = $request->recipient) {
+ $parser->setRecipient($recipient);
+ }
+
+ if ($sender = $request->sender) {
+ $parser->setSender($sender);
+ }
+
+ // 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_DISCARD) {
+ // 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_DISCARD = 'discard';
+
+ 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/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -13,6 +13,7 @@
use App\Traits\StatusPropertyTrait;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -37,6 +38,7 @@
use EntitleableTrait;
use EmailPropertyTrait;
use HasApiTokens;
+ use Notifiable;
use NullableFields;
use UserConfigTrait;
use UuidIntKeyTrait;
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/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -3,6 +3,7 @@
parameters:
ignoreErrors:
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
+ - '#Access to an undefined property Sabre\\VObject\\(Component|Document)::#'
- '#Call to an undefined method Tests\\Browser::#'
- '#Call to an undefined method garethp\\ews\\API\\Type::#'
level: 5
diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php
--- a/src/resources/lang/en/mail.php
+++ b/src/resources/lang/en/mail.php
@@ -27,6 +27,26 @@
'degradedaccountreminder-body4' => "You can also delete your account there, making sure your data disappears from our systems.",
'degradedaccountreminder-body5' => "Thank you for your consideration!",
+ 'itip-cancel-subject' => "\":summary\" has been canceled",
+ 'itip-cancel-body' => "The event \":summary\" at :start has been canceled by the organizer."
+ . " The copy in your calendar has been removed accordingly.",
+
+ 'itip-reply-subject' => "\":summary\" has been updated",
+ 'itip-reply-body' => "The event \":summary\" at :start has been updated in your calendar.",
+
+ 'itip-request-subject' => "\":summary\" has been updated",
+ 'itip-request-body' => "The event \":summary\" at :start has been updated in your calendar.",
+ 'itip-request-changes' => "Changes submitted by :sender have been automatically applied.",
+
+ 'itip-attendee-accepted' => ":sender accepted the invitation.",
+ 'itip-attendee-declined' => ":sender declined the invitation.",
+ 'itip-attendee-tentative' => ":sender accepted the invitation tentatively.",
+ 'itip-attendee-delegated' => ":sender delegated the invitation to :delegatee.",
+
+ 'itip-recurrence-note' => "NOTE: This only refers to this single occurrence!",
+ 'itip-footer' => "This is an automated message. Please do not reply.",
+ 'itip-comment' => ":sender provided comment: :comment",
+
'negativebalance-subject' => ":site Payment Required",
'negativebalance-body' => "This is a notification to let you know that your :email account balance has run into the negative and requires your attention. "
. "Consider setting up an automatic payment to avoid messages like this in the future.",
diff --git a/src/resources/views/emails/html/itip_notification.blade.php b/src/resources/views/emails/html/itip_notification.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/html/itip_notification.blade.php
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>{!! $vars['body1'] !!}</p>
+@if ($vars['body2'])
+ <p>{!! $vars['body2'] !!}</p>
+@endif
+@if ($vars['comment'])
+ <p>{!! __('mail.itip-comment', $vars) !!}</p>
+@endif
+@if ($vars['recurrenceId'])
+ <p>{!! __('mail.itip-recurrence-note') !!}</p>
+@endif
+
+ <p><i>*** {!! __('mail.itip-footer') !!} ***</i><p>
+ </body>
+</html>
diff --git a/src/resources/views/emails/plain/itip_notification.blade.php b/src/resources/views/emails/plain/itip_notification.blade.php
new file mode 100644
--- /dev/null
+++ b/src/resources/views/emails/plain/itip_notification.blade.php
@@ -0,0 +1,16 @@
+{!! $vars['body1'] !!}
+@if ($vars['body2'])
+
+{!! $vars['body2'] !!}
+@endif
+@if ($vars['comment'])
+
+{!! __('mail.itip-comment', $vars) !!}
+@endif
+@if ($vars['recurrenceId'])
+
+{!! __('mail.itip-recurrence-note') !!}
+@endif
+
+
+*** {!! __('mail.itip-footer') !!} ***
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -223,10 +223,13 @@
Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']);
Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']);
+
+ Route::get('metrics', [API\V4\MetricsController::class, 'metrics']);
+
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::get('metrics', [API\V4\MetricsController::class, 'metrics']);
+ Route::post('policy/mail/filter', [API\V4\PolicyController::class, 'mailfilter']);
}
);
}
diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php
--- a/src/tests/BackendsTrait.php
+++ b/src/tests/BackendsTrait.php
@@ -49,6 +49,7 @@
$location = rtrim($folder->href, '/') . '/' . $uid . '.' . pathinfo($filename, \PATHINFO_EXTENSION);
$content = new DAV\Opaque($content);
+ $content->uid = $uid;
$content->href = $location;
$content->contentType = $type == Engine::TYPE_CONTACT
? 'text/vcard; charset=utf-8'
diff --git a/src/tests/Feature/Console/DB/ExpungeTest.php b/src/tests/Feature/Console/DB/ExpungeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/DB/ExpungeTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Tests\Feature\Console\DB;
+
+use Tests\TestCase;
+
+class ExpungeTest extends TestCase
+{
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ $code = \Artisan::call("db:expunge");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(0, $code);
+ }
+}
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,26 @@
$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/mail/1.eml');
+ $post = str_replace("\n", "\r\n", $post);
+
+ $url = '/api/webhooks/policy/mail/filter?recipient=john@kolab.org';
+ $response = $this->call('POST', $url, [], [], [], $headers, $post)
+ ->assertStatus(201);
+
+ // TODO: Test multipart/form-data request
+ // TODO: test returning (modified) mail content
+ // TODO: test rejecting mail
+ // TODO: Test running multiple modules
+ $this->markTestIncomplete();
+ }
}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Tests\Feature\Policy\Mailfilter\Modules\ItipModule;
+
+use App\DataMigrator\Account;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
+use Illuminate\Support\Facades\Notification;
+use Tests\BackendsTrait;
+use Tests\TestCase;
+use Tests\Unit\Policy\Mailfilter\MailParserTest;
+
+/**
+ * @todo Mock the DAV server to make these tests faster
+ */
+class CancelHandlerTest extends TestCase
+{
+ use BackendsTrait;
+
+ /**
+ * Test CANCEL method
+ *
+ * @group @dav
+ */
+ public function testItipCancel(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Jack cancelled the meeting, but there's no event in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ Notification::assertNothingSent();
+
+ // Jack cancelled the meeting, and now the event exists in John's calendar
+ $this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event');
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(0, $list);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user,
+ function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'cancel'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == 'event canceled'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+ }
+
+ /**
+ * Test CANCEL method with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipCancelRecurrence(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Jack cancelled the meeting, and the event exists in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertCount(0, $list[0]->exceptions);
+ $this->assertCount(1, $list[0]->exdate);
+ $this->assertSame('20240717', (string) $list[0]->exdate[0]);
+ $this->assertFalse($list[0]->exdate[0]->hasTime());
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user,
+ function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'cancel'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == ''
+ && $notification->params->start == '2024-07-17 12:30'
+ && $notification->params->summary == 'Test Meeting'
+ && $notification->params->recurrenceId == '20240717T123000';
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Tests\Feature\Policy\Mailfilter\Modules\ItipModule;
+
+use App\DataMigrator\Account;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
+use Illuminate\Support\Facades\Notification;
+use Tests\BackendsTrait;
+use Tests\TestCase;
+use Tests\Unit\Policy\Mailfilter\MailParserTest;
+
+/**
+ * @todo Mock the DAV server to make these tests faster
+ */
+class ReplyHandlerTest extends TestCase
+{
+ use BackendsTrait;
+
+ /**
+ * Test REPLY method
+ *
+ * @group @dav
+ */
+ public function testItipReply(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // John's response to the invitation, but there's no event in Jack's (organizer's) calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ Notification::assertNothingSent();
+
+ // John's response to the invitation, the Jack's event exists now
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
+ $this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user,
+ function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'reply'
+ && $notification->params->senderEmail == 'john@kolab.org'
+ && $notification->params->senderName == 'John'
+ && $notification->params->comment == 'a reply from John'
+ && $notification->params->partstat == 'ACCEPTED'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+
+ // TODO: Test corner cases, spoofing case, etc.
+ }
+
+ /**
+ * Test REPLY method with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipReplyRecurrence(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
+
+ // John's response to the invitation, but there's no exception in Jack's event
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ Notification::assertNothingSent();
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('TENTATIVE', $attendees[1]['partstat']);
+ $this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
+ $this->assertCount(0, $list[0]->exceptions);
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // John's response to the invitation, the Jack's event exists now
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('TENTATIVE', $attendees[1]['partstat']);
+ $this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user,
+ function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'reply'
+ && $notification->params->senderEmail == 'john@kolab.org'
+ && $notification->params->senderName == 'John'
+ && $notification->params->comment == ''
+ && $notification->params->partstat == 'ACCEPTED'
+ && $notification->params->start == '2024-07-17 12:30'
+ && $notification->params->summary == 'Test Meeting'
+ && $notification->params->recurrenceId == '20240717T123000';
+ }
+ );
+
+ // TODO: Test corner cases, etc.
+ }
+}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Tests\Feature\Policy\Mailfilter\Modules\ItipModule;
+
+use App\DataMigrator\Account;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
+use Illuminate\Support\Facades\Notification;
+use Tests\BackendsTrait;
+use Tests\TestCase;
+use Tests\Unit\Policy\Mailfilter\MailParserTest;
+
+/**
+ * @todo Mock the DAV server to make these tests faster
+ */
+class RequestHandlerTest extends TestCase
+{
+ use BackendsTrait;
+
+ /**
+ * Test REQUEST method
+ *
+ * @group @dav
+ */
+ public function testItipRequest(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Jack invites John (and Ned) to a new meeting
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $list[0]->attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $list[0]->attendees[1]['partstat']);
+ $this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
+
+ Notification::assertNothingSent();
+
+ // TODO: Test REQUEST to an existing event, and other corner cases
+
+ // TODO: Test various supported message structures (ItipModule::getItip())
+ }
+
+ /**
+ * Test REQUEST method with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipRequestRecurrence(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
+
+ // Jack invites John (and Ned) to a new meeting occurrence, the event
+ // is already in John's calendar, but has no recurrence exceptions yet
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list);
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('TENTATIVE', $attendees[1]['partstat']);
+ $this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
+
+ // TODO: Test updating an existing occurence
+
+ // Jack sends REQUEST with RRULE containing UNTIL parameter, which is a case when
+ // an organizer deletes "this and future" event occurence
+ $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $list = array_filter($list, fn ($event) => $event->uid == '5464F1DDF6DA264A3FC70E7924B729A5-333333');
+ $event = $list[array_key_first($list)];
+
+ $this->assertCount(2, $attendees = $event->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('ACCEPTED', $attendees[0]['partstat']);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('TENTATIVE', $attendees[1]['partstat']);
+ $this->assertCount(1, $event->exceptions);
+ $this->assertSame('20240717T123000', $event->exceptions[0]->recurrenceId);
+
+ Notification::assertNothingSent();
+ }
+}
diff --git a/src/tests/Unit/Backends/DAV/VeventTest.php b/src/tests/Unit/Backends/DAV/VeventTest.php
--- a/src/tests/Unit/Backends/DAV/VeventTest.php
+++ b/src/tests/Unit/Backends/DAV/VeventTest.php
@@ -24,17 +24,23 @@
<c:calendar-data><![CDATA[BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//EN
+METHOD:PUBLISH
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:$uid
DTSTAMP:20221016T103238Z
DTSTART;VALUE=DATE:20221013
DTEND;VALUE=DATE:20221014
+SEQUENCE:1
SUMMARY:My summary
DESCRIPTION:desc
RRULE:FREQ=WEEKLY
TRANSP:OPAQUE
ORGANIZER:mailto:organizer@test.com
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND
+ IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org
END:VEVENT
END:VCALENDAR
]]></c:calendar-data>
@@ -57,6 +63,9 @@
$this->assertSame('My summary', $event->summary);
$this->assertSame('desc', $event->description);
$this->assertSame('OPAQUE', $event->transp);
+ $this->assertSame('1', $event->sequence);
+ $this->assertSame('PUBLISH', $event->method);
+ $this->assertSame('-//Test//EN', $event->prodid);
// TODO: Should we make these Sabre\VObject\Property\ICalendar\DateTime properties
$this->assertSame('20221016T103238Z', (string) $event->dtstamp);
@@ -70,6 +79,19 @@
];
$this->assertSame($organizer, $event->organizer);
+ $this->assertSame(false, $event->attendees[0]['rsvp']);
+ $this->assertSame('john@kolab.org', $event->attendees[0]['email']);
+ $this->assertSame('John', $event->attendees[0]['cn']);
+ $this->assertSame('ACCEPTED', $event->attendees[0]['partstat']);
+ $this->assertSame('REQ-PARTICIPANT', $event->attendees[0]['role']);
+ $this->assertSame('INDIVIDUAL', $event->attendees[0]['cutype']);
+ $this->assertSame(true, $event->attendees[1]['rsvp']);
+ $this->assertSame('ned@kolab.org', $event->attendees[1]['email']);
+ $this->assertSame('Ned', $event->attendees[1]['cn']);
+ $this->assertSame('NEEDS-ACTION', $event->attendees[1]['partstat']);
+ $this->assertSame('REQ-PARTICIPANT', $event->attendees[1]['role']);
+ $this->assertSame('INDIVIDUAL', $event->attendees[1]['cutype']);
+
$recurrence = [
'freq' => 'WEEKLY',
'interval' => 1,
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,129 @@
+<?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, $recipient = null): 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);
+
+ $parser = new MailParser($stream);
+
+ if ($recipient) {
+ $parser->setRecipient($recipient);
+ }
+
+ return $parser;
+ }
+}
diff --git a/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Unit\Policy\Mailfilter\Notifications;
+
+use App\Policy\Mailfilter\Notifications\ItipNotificationMail;
+use App\Policy\Mailfilter\Notifications\ItipNotificationParams;
+use App\User;
+use Tests\TestCase;
+
+class ItipNotificationMailTest extends TestCase
+{
+ /**
+ * Test CANCEL notification
+ */
+ public function testCancel(): void
+ {
+ $params = new ItipNotificationParams('cancel');
+ $params->user = new User(['email' => 'john@kolab.org']);
+ $params->senderEmail = 'jack@kolab.org';
+ $params->senderName = 'Jack Strong';
+ $params->summary = 'Test Meeting';
+ $params->start = '2024-01-01';
+ $params->comment = 'Attendee comment';
+ $params->recurrenceId = '2024-01-01';
+
+ $mail = $this->renderMail(new ItipNotificationMail($params));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $expected = [
+ "The event \"Test Meeting\" at 2024-01-01 has been canceled by the organizer.",
+ "The copy in your calendar has been removed accordingly.",
+ "Jack Strong provided comment: Attendee comment",
+ "NOTE: This only refers to this single occurrence!",
+ "*** This is an automated message. Please do not reply. ***",
+ ];
+
+ $this->assertSame("\"Test Meeting\" has been canceled", $mail['subject']);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ foreach ($expected as $line) {
+ $this->assertStringContainsString($line, $html);
+ $this->assertStringContainsString($line, $plain);
+ }
+
+ // TODO: Test with some properties unset
+ }
+
+ /**
+ * Test REPLY notification
+ */
+ public function testReply(): void
+ {
+ $params = new ItipNotificationParams('reply');
+ $params->user = new User(['email' => 'john@kolab.org']);
+ $params->senderEmail = 'jack@kolab.org';
+ $params->senderName = 'Jack Strong';
+ $params->partstat = 'ACCEPTED';
+ $params->summary = 'Test Meeting';
+ $params->start = '2024-01-01';
+ $params->comment = 'Attendee comment';
+ $params->recurrenceId = '2024-01-01';
+
+ $mail = $this->renderMail(new ItipNotificationMail($params));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $expected = [
+ "The event \"Test Meeting\" at 2024-01-01 has been updated in your calendar",
+ "Jack Strong accepted the invitation.",
+ "Jack Strong provided comment: Attendee comment",
+ "NOTE: This only refers to this single occurrence!",
+ "*** This is an automated message. Please do not reply. ***",
+ ];
+
+ $this->assertSame("\"Test Meeting\" has been updated", $mail['subject']);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ foreach ($expected as $line) {
+ $this->assertStringContainsString($line, $html);
+ $this->assertStringContainsString($line, $plain);
+ }
+
+ // TODO: Test with some properties unset
+ }
+}
diff --git a/src/tests/data/mailfilter/event1.ics b/src/tests/data/mailfilter/event1.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/event1.ics
@@ -0,0 +1,44 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+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
diff --git a/src/tests/data/mailfilter/event2.ics b/src/tests/data/mailfilter/event2.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/event2.ics
@@ -0,0 +1,44 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+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=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL: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
diff --git a/src/tests/data/mailfilter/event3.ics b/src/tests/data/mailfilter/event3.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/event3.ics
@@ -0,0 +1,44 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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-222222
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240710T103000
+DTEND;TZID=Europe/Berlin:20240710T113000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+RRULE:FREQ=WEEKLY;INTERVAL=1
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;CUTYPE=IND
+ IVIDUAL:mailto:ned@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+END:VEVENT
+END:VCALENDAR
diff --git a/src/tests/data/mailfilter/event4.ics b/src/tests/data/mailfilter/event4.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/event4.ics
@@ -0,0 +1,62 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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-222222
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240710T103000
+DTEND;TZID=Europe/Berlin:20240710T113000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+RRULE:FREQ=WEEKLY;INTERVAL=1
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;CUTYPE=IND
+ IVIDUAL:mailto:ned@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+END:VEVENT
+BEGIN:VEVENT
+UID:5463F1DDF6DA264A3FC70E7924B729A5-222222
+RECURRENCE-ID;TZID=Europe/Berlin:20240717T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240717T123000
+DTEND;TZID=Europe/Berlin:20240717T133000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL: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
diff --git a/src/tests/data/mailfilter/event5.ics b/src/tests/data/mailfilter/event5.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/event5.ics
@@ -0,0 +1,73 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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:5464F1DDF6DA264A3FC70E7924B729A5-333333
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240710T103000
+DTEND;TZID=Europe/Berlin:20240710T113000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+RRULE:FREQ=WEEKLY;INTERVAL=1
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT;CUTYPE=IND
+ IVIDUAL:mailto:ned@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+END:VEVENT
+BEGIN:VEVENT
+UID:5464F1DDF6DA264A3FC70E7924B729A5-333333
+RECURRENCE-ID;TZID=Europe/Berlin:20240717T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240717T123000
+DTEND;TZID=Europe/Berlin:20240717T133000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL: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
+BEGIN:VEVENT
+UID:5464F1DDF6DA264A3FC70E7924B729A5-333333
+RECURRENCE-ID;TZID=Europe/Berlin:20240724T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240724T123000
+DTEND;TZID=Europe/Berlin:20240724T133000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+END:VEVENT
+END:VCALENDAR
diff --git a/src/tests/data/mailfilter/itip1_cancel.eml b/src/tests/data/mailfilter/itip1_cancel.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_cancel.eml
@@ -0,0 +1,68 @@
+MIME-Version: 1.0
+From: Jack <jack@kolab.org>
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID: <f49deee5182804694372696db14c90f2@kolab.org>
+To: john@kolab.org
+Subject: The meeting has been cancelled
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+Cancelled by the organizer
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=CANCEL;
+ name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+METHOD:CANCEL
+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
+STATUS:CANCELLED
+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
+COMMENT:event canceled
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip1_reply.eml b/src/tests/data/mailfilter/itip1_reply.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_reply.eml
@@ -0,0 +1,65 @@
+MIME-Version: 1.0
+From: John <john@kolab.org>
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID: <f493eee5182874694372696db14c90f2@kolab.org>
+To: jack@kolab.org
+Subject: Reply to "Test Meeting"
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+Accepted by John
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=REPLY;
+ name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+METHOD:REPLY
+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=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:a reply from John
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip1_request.eml b/src/tests/data/mailfilter/itip1_request.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_request.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--
diff --git a/src/tests/data/mailfilter/itip2_cancel.eml b/src/tests/data/mailfilter/itip2_cancel.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_cancel.eml
@@ -0,0 +1,70 @@
+MIME-Version: 1.0
+From: Jack <jack@kolab.org>
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID: <fe9deee5182804694372696db14c90f2@kolab.org>
+To: john@kolab.org
+Subject: The meeting has been cancelled
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+An even occurence cancelled by the organizer.
+
+This is what happens if you remove a single occurrence (also if an exception does not exist yet)
+using Kolab Webmail.
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=CANCEL;
+ name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+METHOD:CANCEL
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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-222222
+RECURRENCE-ID;TZID=Europe/Berlin:20240717T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240717T123000
+DTEND;TZID=Europe/Berlin:20240717T133000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+TRANSP:OPAQUE
+STATUS:CANCELLED
+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--
diff --git a/src/tests/data/mailfilter/itip2_reply.eml b/src/tests/data/mailfilter/itip2_reply.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_reply.eml
@@ -0,0 +1,64 @@
+MIME-Version: 1.0
+From: John <john@kolab.org>
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID: <f493eee5182874694372696db14c90f2@kolab.org>
+To: jack@kolab.org
+Subject: Reply to "Test Meeting"
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+Accepted by John
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=REPLY;
+ name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+METHOD:REPLY
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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-222222
+RECURRENCE-ID;TZID=Europe/Berlin:20240717T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240717T123000
+DTEND;TZID=Europe/Berlin:20240717T133000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL:mailto:john@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip2_request.eml b/src/tests/data/mailfilter/itip2_request.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_request.eml
@@ -0,0 +1,67 @@
+MIME-Version: 1.0
+From: Jack <jack@kolab.org>
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID: <f49ee5182874694372696db14c90f2@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*
+
+--=_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
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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-222222
+RECURRENCE-ID;TZID=Europe/Berlin:20240717T123000
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240717T123000
+DTEND;TZID=Europe/Berlin:20240717T133000
+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--
diff --git a/src/tests/data/mailfilter/itip3_request_rrule_update.eml b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
@@ -0,0 +1,68 @@
+MIME-Version: 1.0
+From: Jack <jack@kolab.org>
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID: <f49ee35182874694372696db14c90f2@kolab.org>
+To: john@kolab.org
+Subject: "Test Meeting" has been updated
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8;
+ format=flowed
+
+This is what Roundcube does if you remove a recurrent event occurence and all future occurences.
+It does send a REQUEST (not CANCEL) with an updated RRULE.
+
+--=_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
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+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:5464F1DDF6DA264A3FC70E7924B729A5-333333
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240710T124304Z
+DTSTART;TZID=Europe/Berlin:20240710T103000
+DTEND;TZID=Europe/Berlin:20240710T113000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+RRULE:FREQ=WEEKLY;INTERVAL=1;UNTIL=20240723T123000Z
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL: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, Apr 4, 5:31 PM (10 h, 9 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18830567
Default Alt Text
D4974.1775323915.diff (118 KB)
Attached To
Mode
D4974: SMTP content filter
Attached
Detach File
Event Timeline