Page MenuHomePhorge

D4974.1775323915.diff
No OneTemporary

Authored By
Unknown
Size
118 KB
Referenced Files
None
Subscribers
None

D4974.1775323915.diff

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

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)

Event Timeline