diff --git a/src/.gitignore b/src/.gitignore
index a26524ca..697d935f 100644
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -1,28 +1,30 @@
*.swp
database/database.sqlite
node_modules/
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
.env
.env.backup
.env.local
.env.testing
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
composer.lock
resources/countries.php
resources/build/js/
database/seeds/
cache
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
index 079152b7..dd06530a 100644
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -1,648 +1,660 @@
'urn:ietf:params:xml:ns:caldav',
self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
];
protected $url;
protected $user;
protected $password;
protected $responseHeaders = [];
protected $homes;
/**
* Object constructor
*/
public function __construct($user, $password, $url = null)
{
$this->url = $url ?: \config('services.dav.uri');
$this->user = $user;
$this->password = $password;
}
/**
* Discover DAV home (root) collection of a specified type.
*
* @return array|false Home locations or False on error
*/
public function discover()
{
if (is_array($this->homes)) {
return $this->homes;
}
$path = parse_url($this->url, PHP_URL_PATH);
$body = ''
. ''
. ''
. ''
. ''
. '';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request('', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get current-user-principal from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
$principal_href = '';
foreach ($elements as $element) {
foreach ($element->getElementsByTagName('current-user-principal') as $prop) {
$principal_href = $prop->nodeValue;
break;
}
}
if ($path && str_starts_with($principal_href, $path)) {
$principal_href = substr($principal_href, strlen($path));
}
$ns = [
'xmlns:d="DAV:"',
'xmlns:cal="urn:ietf:params:xml:ns:caldav"',
'xmlns:card="urn:ietf:params:xml:ns:carddav"',
];
$body = ''
. ''
. ''
. ''
. ''
. ''
. ''
. '';
$response = $this->request($principal_href, 'PROPFIND', $body);
if (empty($response)) {
\Log::error("Failed to get home collections from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
$homes = [];
if ($element = $response->getElementsByTagName('response')->item(0)) {
if ($prop = $element->getElementsByTagName('prop')->item(0)) {
foreach ($prop->childNodes as $home) {
if ($home->firstChild && $home->firstChild->localName == 'href') {
$href = $home->firstChild->nodeValue;
if ($path && str_starts_with($href, $path)) {
$href = substr($href, strlen($path));
}
$homes[$home->localName] = $href;
}
}
}
}
return $this->homes = $homes;
}
/**
* Get user home folder of specified type
*
* @param string $type Home type or component name
*
* @return string|null Folder location href
*/
public function getHome($type)
{
$options = [
self::TYPE_VEVENT => 'calendar-home-set',
self::TYPE_VTODO => 'calendar-home-set',
self::TYPE_VCARD => 'addressbook-home-set',
self::TYPE_NOTIFICATION => 'notification-URL',
];
$homes = $this->discover();
if (is_array($homes) && isset($options[$type])) {
return $homes[$options[$type]] ?? null;
}
return null;
}
/**
* Check if we can connect to the DAV server
*
* @return bool True on success, False otherwise
*/
public static function healthcheck(): bool
{
// TODO
return true;
}
/**
* Get list of folders of specified type.
*
* @param string $component Component to filter by (VEVENT, VTODO, VCARD)
*
* @return false|array List of folders' metadata or False on error
*/
public function listFolders(string $component)
{
$root_href = $this->getHome($component);
if ($root_href === null) {
return false;
}
$ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"';
$props = '';
if ($component != self::TYPE_VCARD) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"';
$props = ''
. ''
. '';
}
$body = ''
. ''
. ''
. ''
. ''
+ . ''
. ''
. $props
. ''
. '';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
$response = $this->request($root_href, 'PROPFIND', $body, $headers);
if (empty($response)) {
\Log::error("Failed to get folders list from the DAV server.");
return false;
}
$folders = [];
foreach ($response->getElementsByTagName('response') as $element) {
$folder = DAV\Folder::fromDomElement($element);
// Note: Addressbooks don't have 'type' specified
if (
($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
|| in_array($component, $folder->components)
) {
$folders[] = $folder;
}
}
return $folders;
}
/**
* Create a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function create(DAV\CommonObject $object)
{
$headers = ['Content-Type' => $object->contentType];
$content = (string) $object;
if (!strlen($content)) {
throw new \Exception("Cannot PUT an empty DAV object");
}
$response = $this->request($object->href, 'PUT', $content, $headers);
if ($response !== false) {
if (!empty($this->responseHeaders['ETag'])) {
$etag = $this->responseHeaders['ETag'][0];
if (preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
$object->etag = $etag;
}
return $object;
}
return false;
}
/**
* Update a DAV object in a folder
*
* @param DAV\CommonObject $object Object
*
* @return false|DAV\CommonObject Object on success, False on error
*/
public function update(DAV\CommonObject $object)
{
return $this->create($object);
}
/**
* Delete a DAV object from a folder
*
* @param string $location Object location
*
* @return bool True on success, False on error
*/
public function delete(string $location)
{
$response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']);
return $response !== false;
}
/**
* Create a DAV folder (collection)
*
* @param DAV\Folder $folder Folder object
*
* @return bool True on success, False on error
*/
public function folderCreate(DAV\Folder $folder)
{
$response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol'));
return $response !== false;
}
/**
* Delete a DAV folder (collection)
*
* @param string $location Folder location
*
* @return bool True on success, False on error
*/
public function folderDelete($location)
{
$response = $this->request($location, 'DELETE');
return $response !== false;
}
/**
* Get all properties of a folder.
*
* @param string $location Object location
*
* @return false|DAV\Folder Folder metadata or False on error
*/
public function folderInfo(string $location)
{
$body = DAV\Folder::propfindXML();
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']);
if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) {
return DAV\Folder::fromDomElement($element);
}
return false;
}
/**
* Update a DAV folder (collection)
*
* @param DAV\Folder $folder Folder object
*
* @return bool True on success, False on error
*/
public function folderUpdate(DAV\Folder $folder)
{
// Note: Changing resourcetype property is forbidden (at least by Cyrus)
$response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate'));
return $response !== false;
}
/**
* Initialize default DAV folders (collections)
*
* @param \App\User $user User object
*
* @throws \Exception
*/
public static function initDefaultFolders(\App\User $user): void
{
if (!\config('services.dav.uri')) {
return;
}
$folders = \config('services.dav.default_folders');
if (!count($folders)) {
return;
}
// Cyrus DAV does not support proxy authorization via DAV. Even though it has
// the Authorize-As header, it is used only for cummunication with Murder backends.
// We use a one-time token instead. It's valid for 10 seconds, assume it's enough time.
$password = \App\Auth\Utils::tokenCreate((string) $user->id);
if ($password === null) {
throw new \Exception("Failed to create an authentication token for DAV");
}
$dav = new self($user->email, $password);
foreach ($folders as $props) {
$folder = new DAV\Folder();
$folder->href = $props['type'] . 's' . '/user/' . $user->email . '/' . $props['path'];
$folder->types = ['collection', $props['type']];
$folder->name = $props['displayname'] ?? '';
$folder->components = $props['components'] ?? [];
$existing = null;
try {
$existing = $dav->folderInfo($folder->href);
} catch (RequestException $e) {
// Cyrus DAV returns 503 Service Unavailable on a non-existing location (?)
if ($e->getCode() != 503 && $e->getCode() != 404) {
throw $e;
}
}
// folder already exists? check the properties and update if needed
if ($existing) {
if ($existing->name != $folder->name || $existing->components != $folder->components) {
if (!$dav->folderUpdate($folder)) {
throw new \Exception("Failed to update DAV folder {$folder->href}");
}
}
} elseif (!$dav->folderCreate($folder)) {
throw new \Exception("Failed to create DAV folder {$folder->href}");
}
}
}
/**
* Check server options (and authentication)
*
* @return false|array DAV capabilities on success, False on error
*/
public function options()
{
$response = $this->request('', 'OPTIONS');
if ($response !== false) {
return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? []));
}
return false;
}
/**
* Search DAV objects in a folder.
*
* @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'];
$response = $this->request($location, 'REPORT', $search, $headers);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$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);
}
if ($object) {
if (is_array($object)) {
$objects[$object[0]] = $object[1];
} else {
$objects[] = $object;
}
}
}
return $objects;
}
/**
* Fetch DAV objects data from a folder
*
* @param string $location Folder location
* @param string $component Object type (VEVENT, VTODO, VCARD)
* @param array $hrefs List of objects' locations to fetch
*
* @return false|array Objects metadata on success, False on error
*/
public function getObjects(string $location, string $component, array $hrefs = [])
{
if (empty($hrefs)) {
return [];
}
$body = '';
foreach ($hrefs as $href) {
$body .= '' . $href . '';
}
$queries = [
self::TYPE_VEVENT => 'calendar-multiget',
self::TYPE_VTODO => 'calendar-multiget',
self::TYPE_VCARD => 'addressbook-multiget',
];
$types = [
self::TYPE_VEVENT => 'calendar-data',
self::TYPE_VTODO => 'calendar-data',
self::TYPE_VCARD => 'address-data',
];
$body = ''
. ' '
. ''
. ''
. ''
. ''
. $body
. '';
$response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
\Log::error("Failed to get objects from the DAV server.");
return false;
}
$objects = [];
foreach ($response->getElementsByTagName('response') as $element) {
$objects[] = $this->objectFromElement($element, $component);
}
return $objects;
}
/**
* Parse XML content
*/
protected function parseXML($xml)
{
$doc = new \DOMDocument('1.0', 'UTF-8');
if (str_starts_with($xml, 'loadXML($xml)) {
throw new \Exception("Failed to parse XML");
}
$doc->formatOutput = true;
}
return $doc;
}
/**
* Parse request/response body for debug purposes
*/
protected function debugBody($body, $headers)
{
$head = '';
foreach ($headers as $header_name => $header_value) {
if (is_array($header_value)) {
$header_value = implode("\n\t", $header_value);
}
$head .= "{$header_name}: {$header_value}\n";
}
if ($body instanceof DAV\CommonObject) {
$body = (string) $body;
}
if (str_starts_with($body, 'formatOutput = true;
$doc->preserveWhiteSpace = false;
if (!$doc->loadXML($body)) {
throw new \Exception("Failed to parse XML");
}
$body = $doc->saveXML();
}
return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : '');
}
/**
* Create DAV\CommonObject from a DOMElement
*/
protected function objectFromElement($element, $component)
{
switch ($component) {
case self::TYPE_VEVENT:
$object = DAV\Vevent::fromDomElement($element);
break;
case self::TYPE_VTODO:
$object = DAV\Vtodo::fromDomElement($element);
break;
case self::TYPE_VCARD:
$object = DAV\Vcard::fromDomElement($element);
break;
default:
throw new \Exception("Unknown component: {$component}");
}
return $object;
}
/**
* Execute HTTP request to a DAV server
*/
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
->withOptions(['verify' => \config('services.dav.verify')]);
if ($body) {
if (!isset($headers['Content-Type'])) {
$headers['Content-Type'] = 'application/xml; charset=utf-8';
}
$client->withBody($body, $headers['Content-Type']);
}
if (!empty($headers)) {
$client->withHeaders($headers);
}
if ($debug) {
$body = $this->debugBody($body, $headers);
\Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : ''));
}
$response = $client->send($method, $url);
$body = $response->body();
$code = $response->status();
if ($debug) {
\Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers()));
}
// Throw an exception if a client or server error occurred...
$response->throw();
$this->responseHeaders = $response->headers();
return $this->parseXML($body);
}
}
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
index dba51da0..fc9be423 100644
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -1,160 +1,172 @@
getElementsByTagName('href')->item(0)) {
$folder->href = $href->nodeValue;
}
if ($color = $element->getElementsByTagName('calendar-color')->item(0)) {
if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) {
$folder->color = substr($color->nodeValue, 1);
}
}
if ($name = $element->getElementsByTagName('displayname')->item(0)) {
$folder->name = $name->nodeValue;
}
if ($ctag = $element->getElementsByTagName('getctag')->item(0)) {
$folder->ctag = $ctag->nodeValue;
}
$components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
foreach ($set_element->getElementsByTagName('comp') as $comp) {
$components[] = $comp->attributes->getNamedItem('name')->nodeValue;
}
}
$types = [];
if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) {
foreach ($type_element->childNodes as $node) {
if ($node->nodeType == XML_ELEMENT_NODE) {
$_type = explode(':', $node->nodeName);
$types[] = count($_type) > 1 ? $_type[1] : $_type[0];
}
}
}
+ 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;
return $folder;
}
/**
* Parse folder properties input into XML string to use in a request
*
* @return string
*/
public function toXML($tag)
{
$ns = 'xmlns:d="DAV:"';
$props = '';
$type = null;
if (in_array('addressbook', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"';
$type = 'addressbook';
} elseif (in_array('calendar', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
$type = 'calendar';
}
// Cyrus DAV does not allow resourcetype property change
if ($tag != 'propertyupdate') {
$props .= '' . ($type ? "" : '') . '';
}
if (!empty($this->components)) {
// Note: Normally Cyrus DAV does not allow supported-calendar-component-set property update,
// but I found in Cyrus code that the action can be forced with force=yes attribute.
$props .= '';
foreach ($this->components as $component) {
$props .= '';
}
$props .= '';
}
if ($this->name !== null) {
$props .= '' . htmlspecialchars($this->name, ENT_XML1, 'UTF-8') . '';
}
if ($this->color !== null) {
$color = $this->color;
if (strlen($color) && $color[0] != '#') {
$color = '#' . $color;
}
$ns .= ' xmlns:a="http://apple.com/ns/ical/"';
$props .= '' . htmlspecialchars($color, ENT_XML1, 'UTF-8') . '';
}
return ''
. "{$props}";
}
/**
* Get XML string for PROPFIND query on a folder
*
* @return string
*/
public static function propfindXML()
{
$ns = implode(' ', [
'xmlns:d="DAV:"',
// 'xmlns:cs="http://calendarserver.org/ns/"',
'xmlns:c="urn:ietf:params:xml:ns:caldav"',
// 'xmlns:a="http://apple.com/ns/ical/"',
// 'xmlns:k="Kolab:"'
]);
// Note: does not include some of the properties we're interested in
return ''
. ''
. ''
// . ''
. ''
// . ''
// . ''
// . ''
. ''
. ''
// . ''
. ''
. '';
}
}
diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php
index 40744d0e..df3a2d76 100644
--- a/src/app/Backends/DAV/Opaque.php
+++ b/src/app/Backends/DAV/Opaque.php
@@ -1,27 +1,57 @@
content = file_get_contents($content);
} else {
$this->content = $content;
}
}
/**
* Create string representation of the DAV object
*
* @return string
*/
public function __toString()
{
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
index 9291a7af..9a6a009b 100644
--- a/src/app/Backends/DAV/Search.php
+++ b/src/app/Backends/DAV/Search.php
@@ -1,90 +1,91 @@
component = $component;
$this->withContent = $withContent;
+ $this->filters = $filters;
}
/**
* Create string representation of the search
*
* @return string
*/
public function __toString()
{
$ns = implode(' ', [
'xmlns:d="DAV:"',
'xmlns:c="' . DAV::NAMESPACES[$this->component] . '"',
]);
// Return properties
$props = [];
foreach ($this->properties as $prop) {
$props[] = '<' . $prop . '/>';
}
// Warning: Looks like some servers (iRony) ignore address-data/calendar-data
// and return full VCARD/VCALENDAR. Which leads to unwanted loads of data in a response.
if (!empty($this->dataProperties)) {
$more_props = [];
foreach ($this->dataProperties as $prop) {
$more_props[] = '';
}
if ($this->component == DAV::TYPE_VCARD) {
$props[] = '' . implode('', $more_props) . '';
} else {
$props[] = ''
. ''
. ''
. '' . implode('', $more_props) . ''
. '';
}
} elseif ($this->withContent) {
if ($this->component == DAV::TYPE_VCARD) {
$props[] = '';
} else {
$props[] = '';
}
}
// Search filter
- $filter = '';
+ $filters = $this->filters;
if ($this->component == DAV::TYPE_VCARD) {
$query = 'addressbook-query';
} else {
$query = 'calendar-query';
- $filter = ''
- . ''
- . ''
- . '';
+ array_unshift($filters, new SearchCompFilter('VCALENDAR', [new SearchCompFilter($this->component)]));
}
+ $filter = new SearchFilter($filters);
+
if (empty($props)) {
$props = '';
} else {
$props = '' . implode('', $props) . '';
}
return ''
. "" . $props . $filter . "";
}
}
diff --git a/src/app/Backends/DAV/SearchCompFilter.php b/src/app/Backends/DAV/SearchCompFilter.php
new file mode 100644
index 00000000..31a8df87
--- /dev/null
+++ b/src/app/Backends/DAV/SearchCompFilter.php
@@ -0,0 +1,42 @@
+name = $name;
+ $this->filters = $filters;
+ }
+
+ /**
+ * Create string representation of the prop-filter
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = 'filters)) {
+ $filter .= '/>';
+ } else {
+ $filter .= '>';
+
+ foreach ($this->filters as $sub_filter) {
+ $filter .= (string) $sub_filter;
+ }
+
+ $filter .= '';
+ }
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/SearchFilter.php b/src/app/Backends/DAV/SearchFilter.php
new file mode 100644
index 00000000..a3e08654
--- /dev/null
+++ b/src/app/Backends/DAV/SearchFilter.php
@@ -0,0 +1,33 @@
+filters = $filters;
+ }
+
+ /**
+ * Create string representation of the search
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = '';
+
+ foreach ($this->filters as $sub_filter) {
+ $filter .= (string) $sub_filter;
+ }
+
+ $filter .= '';
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/SearchPropFilter.php b/src/app/Backends/DAV/SearchPropFilter.php
new file mode 100644
index 00000000..58b67d6f
--- /dev/null
+++ b/src/app/Backends/DAV/SearchPropFilter.php
@@ -0,0 +1,60 @@
+name = $name;
+ $this->type = $type;
+ $this->value = $value;
+ $this->collation = $collation;
+ }
+
+ /**
+ * Create string representation of the prop-filter
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ $filter = '';
+
+ if ($this->type == self::IS_NOT_DEFINED) {
+ $filter .= '';
+ } elseif ($this->type) {
+ $filter .= 'collation) {
+ $filter .= ' collation="' . $this->collation . '"';
+ }
+
+ if ($this->negate) {
+ $filter .= ' negate-condition="yes"';
+ }
+
+ $filter .= '>' . htmlspecialchars($this->value, ENT_XML1, 'UTF-8') . '';
+ }
+
+ $filter .= '';
+
+ return $filter;
+ }
+}
diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
index 4d11812b..67d6ab5f 100644
--- a/src/app/Backends/DAV/Vevent.php
+++ b/src/app/Backends/DAV/Vevent.php
@@ -1,294 +1,309 @@
getElementsByTagName('calendar-data')->item(0)) {
$object->fromIcal($data->nodeValue);
}
return $object;
}
/**
* Set object properties from an iCalendar
*
* @param string $ical iCalendar string
*/
protected function fromIcal(string $ical): void
{
$this->vobject = Reader::read($ical, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES);
if ($this->vobject->name != 'VCALENDAR') {
return;
}
$selfType = strtoupper(class_basename(get_class($this)));
if (!empty($this->vobject->PRODID)) {
$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;
+ }
}
}
}
/**
* Set object properties from a Sabre/VObject component object
*
* @param Component $vobject Sabre/VObject component
*/
- protected function fromVObject(Component $vobject): void
+ public function fromVObject(Component $vobject): void
{
$string_properties = [
'CLASS',
'COMMENT',
'DESCRIPTION',
'LOCATION',
'PRIORITY',
+ 'RECURRENCE-ID',
'SEQUENCE',
'STATUS',
'SUMMARY',
'TRANSP',
'UID',
'URL',
];
// map string properties
foreach ($string_properties as $prop) {
if (isset($vobject->{$prop})) {
$key = Str::camel(strtolower($prop));
$this->{$key} = (string) $vobject->{$prop};
}
}
// map other properties
foreach ($vobject->children() as $prop) {
if (!($prop instanceof Property)) {
continue;
}
switch ($prop->name) {
case 'DTSTART':
case 'DTEND':
case 'CREATED':
case 'LAST-MODIFIED':
case 'DTSTAMP':
$key = Str::camel(strtolower($prop->name));
// These are of type Sabre\VObject\Property\ICalendar\DateTime
$this->{$key} = $prop;
break;
case 'RRULE':
$params = [];
foreach ($prop->getParts() as $k => $v) {
$params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v;
}
if (!empty($params['until'])) {
$params['until'] = new \DateTime($params['until']);
}
if (empty($params['interval'])) {
$params['interval'] = 1;
}
$this->rrule = array_filter($params);
break;
case 'EXDATE':
case 'RDATE':
$key = strtolower($prop->name);
-
- // TODO
+ $this->{$key}[] = $prop;
break;
case 'ATTENDEE':
case 'ORGANIZER':
$attendee = [
'rsvp' => false,
'email' => preg_replace('!^mailto:!i', '', (string) $prop),
];
$attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO',
'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY'];
foreach ($prop->parameters() as $name => $value) {
$key = Str::camel(strtolower($name));
switch ($name) {
case 'RSVP':
$attendee[$key] = strtolower($value) == 'true';
break;
case 'CN':
$attendee[$key] = str_replace('\,', ',', strval($value));
break;
default:
if (in_array($name, $attendeeProps)) {
$attendee[$key] = strval($value);
}
break;
}
}
if ($prop->name == 'ORGANIZER') {
$attendee['role'] = 'ORGANIZER';
$attendee['partstat'] = 'ACCEPTED';
$this->organizer = $attendee;
} elseif (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) {
$this->attendees[] = $attendee;
}
break;
default:
if (\str_starts_with($prop->name, 'X-')) {
$this->custom[$prop->name] = (string) $prop;
}
}
}
// Check DURATION property if no end date is set
/*
if (empty($this->dtend) && !empty($this->dtstart) && !empty($vobject->DURATION)) {
try {
$duration = new \DateInterval((string) $vobject->DURATION);
$end = clone $this->dtstart;
$end->add($duration);
$this->dtend = $end;
}
catch (\Exception $e) {
// TODO: Error?
}
}
*/
// Find alarms
foreach ($vobject->select('VALARM') as $valarm) {
$action = 'DISPLAY';
$trigger = null;
$alarm = [];
foreach ($valarm->children() as $prop) {
$value = strval($prop);
switch ($prop->name) {
case 'TRIGGER':
foreach ($prop->parameters as $param) {
if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') {
$trigger = '@' . $prop->getDateTime()->format('U');
$alarm['trigger'] = $prop->getDateTime();
} elseif ($param->name == 'RELATED') {
$alarm['related'] = $param->getValue();
}
}
/*
if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) {
$trigger = $values[2];
}
*/
if (empty($alarm['trigger'])) {
$alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T');
// if all 0-values have been stripped, assume 'at time'
if ($alarm['trigger'] == 'P') {
$alarm['trigger'] = 'PT0S';
}
}
break;
case 'ACTION':
$action = $alarm['action'] = strtoupper($value);
break;
case 'SUMMARY':
case 'DESCRIPTION':
case 'DURATION':
$alarm[strtolower($prop->name)] = $value;
break;
case 'REPEAT':
$alarm['repeat'] = (int) $value;
break;
case 'ATTENDEE':
$alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value);
break;
}
}
if ($action != 'NONE') {
if (!empty($alarm['trigger'])) {
$this->valarms[] = $alarm;
}
}
}
}
/**
* Create string representation of the DAV object (iCalendar)
*
* @return string
*/
public function __toString()
{
if (!$this->vobject) {
// TODO we currently can only serialize a message back that we just read
throw new \Exception("Writing from properties is not implemented");
}
return Writer::write($this->vobject);
}
}
diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php
index 760fa559..424bda88 100644
--- a/src/app/Backends/DAV/Vtodo.php
+++ b/src/app/Backends/DAV/Vtodo.php
@@ -1,42 +1,42 @@
children() as $prop) {
if (!($prop instanceof Property)) {
continue;
}
switch ($prop->name) {
case 'DUE':
// This is of type Sabre\VObject\Property\ICalendar\DateTime
$this->due = $prop;
break;
case 'PERCENT-COMPLETE':
$this->percentComplete = $prop->getValue();
break;
}
}
}
}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
index 15a81121..16a9d265 100644
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -1,399 +1,412 @@
input();
$request = new \App\Policy\Greylist\Request($data);
$shouldDefer = $request->shouldDefer();
if ($shouldDefer) {
return response()->json(
['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."],
403
);
}
$prependGreylist = $request->headerGreylist();
$result = [
'response' => 'DUNNO',
'prepend' => [$prependGreylist]
];
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.
*
* @return \Illuminate\Http\JsonResponse
*/
public function ratelimit()
{
$data = \request()->input();
list($local, $domain) = \App\Utils::normalizeAddress($data['sender'], true);
if (empty($local) || empty($domain)) {
return response()->json(['response' => 'HOLD', 'reason' => 'Invalid sender email'], 403);
}
$sender = $local . '@' . $domain;
if (in_array($sender, \config('app.ratelimit_whitelist', []), true)) {
return response()->json(['response' => 'DUNNO'], 200);
}
//
// Examine the individual sender
//
$user = \App\User::withTrashed()->where('email', $sender)->first();
if (!$user) {
$alias = \App\UserAlias::where('alias', $sender)->first();
if (!$alias) {
// external sender through where this policy is applied
return response()->json(['response' => 'DUNNO'], 200);
}
$user = $alias->user;
}
if (empty($user) || $user->trashed() || $user->isSuspended()) {
// use HOLD, so that it is silent (as opposed to REJECT)
return response()->json(['response' => 'HOLD', 'reason' => 'Sender deleted or suspended'], 403);
}
//
// Examine the domain
//
$domain = \App\Domain::withTrashed()->where('namespace', $domain)->first();
if (!$domain) {
// external sender through where this policy is applied
return response()->json(['response' => 'DUNNO'], 200);
}
if ($domain->trashed() || $domain->isSuspended()) {
// use HOLD, so that it is silent (as opposed to REJECT)
return response()->json(['response' => 'HOLD', 'reason' => 'Sender domain deleted or suspended'], 403);
}
// see if the user or domain is whitelisted
// use ./artisan policy:ratelimit:whitelist:create
if (RateLimitWhitelist::isListed($user) || RateLimitWhitelist::isListed($domain)) {
return response()->json(['response' => 'DUNNO'], 200);
}
// user nor domain whitelisted, continue scrutinizing the request
$recipients = (array)$data['recipients'];
sort($recipients);
$recipientCount = count($recipients);
$recipientHash = hash('sha256', implode(',', $recipients));
//
// Retrieve the wallet to get to the owner
//
$wallet = $user->wallet();
// wait, there is no wallet?
if (!$wallet || !$wallet->owner) {
return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403);
}
$owner = $wallet->owner;
// find or create the request
$request = RateLimit::where('recipient_hash', $recipientHash)
->where('user_id', $user->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())
->first();
if (!$request) {
$request = RateLimit::create([
'user_id' => $user->id,
'owner_id' => $owner->id,
'recipient_hash' => $recipientHash,
'recipient_count' => $recipientCount
]);
} else {
// ensure the request has an up to date timestamp
$request->updated_at = \Carbon\Carbon::now();
$request->save();
}
// Paying users have a 15 messages per minute limit
if ($wallet->hasMinimumBalanceAndPayments()) {
$ownerRates = RateLimit::where('owner_id', $owner->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subMinute());
if (($count = $ownerRates->count()) >= 15) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'The account is at 15 messages per minute, cool down.'
];
return response()->json($result, 403);
}
return response()->json(['response' => 'DUNNO'], 200);
}
//
// Examine the rates at which the owner (or its users) is sending
//
$ownerRates = RateLimit::where('owner_id', $owner->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour());
if (($count = $ownerRates->count()) >= 10) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'The account is at 10 messages per hour, cool down.'
];
// automatically suspend (recursively) if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($count >= 25 && $owner->created_at > $ageThreshold) {
$owner->suspendAccount();
}
return response()->json($result, 403);
}
if (($recipientCount = $ownerRates->sum('recipient_count')) >= 100) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'The account is at 100 recipients per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($recipientCount >= 250 && $owner->created_at > $ageThreshold) {
$owner->suspendAccount();
}
return response()->json($result, 403);
}
//
// Examine the rates at which the user is sending (if not also the owner)
//
if ($user->id != $owner->id) {
$userRates = RateLimit::where('user_id', $user->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour());
if (($count = $userRates->count()) >= 10) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'User is at 10 messages per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($count >= 25 && $user->created_at > $ageThreshold) {
$user->suspend();
}
return response()->json($result, 403);
}
if (($recipientCount = $userRates->sum('recipient_count')) >= 100) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'User is at 100 recipients per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($recipientCount >= 250 && $user->created_at > $ageThreshold) {
$user->suspend();
}
return response()->json($result, 403);
}
}
return response()->json(['response' => 'DUNNO'], 200);
}
/*
* Apply the sender policy framework to a request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function senderPolicyFramework()
{
$data = \request()->input();
if (!array_key_exists('client_address', $data)) {
\Log::error("SPF: Request without client_address: " . json_encode($data));
return response()->json(
[
'response' => 'DEFER_IF_PERMIT',
'reason' => 'Temporary error. Please try again later.',
'log' => ["SPF: Request without client_address: " . json_encode($data)]
],
403
);
}
list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']);
// This network can not be recognized.
if (!$netID) {
\Log::error("SPF: Request without recognizable network: " . json_encode($data));
return response()->json(
[
'response' => 'DEFER_IF_PERMIT',
'reason' => 'Temporary error. Please try again later.',
'log' => ["SPF: Request without recognizable network: " . json_encode($data)]
],
403
);
}
$senderLocal = 'unknown';
$senderDomain = 'unknown';
if (strpos($data['sender'], '@') !== false) {
list($senderLocal, $senderDomain) = explode('@', $data['sender']);
if (strlen($senderLocal) >= 255) {
$senderLocal = substr($senderLocal, 0, 255);
}
}
if ($data['sender'] === null) {
$data['sender'] = '';
}
// Compose the cache key we want.
$cacheKey = "{$netType}_{$netID}_{$senderDomain}";
$result = \App\Policy\SPF\Cache::get($cacheKey);
if (!$result) {
$environment = new \SPFLib\Check\Environment(
$data['client_address'],
$data['client_name'],
$data['sender']
);
$result = (new \SPFLib\Checker())->check($environment);
\App\Policy\SPF\Cache::set($cacheKey, serialize($result));
} else {
$result = unserialize($result);
}
$fail = false;
$prependSPF = '';
switch ($result->getCode()) {
case \SPFLib\Check\Result::CODE_ERROR_PERMANENT:
$fail = true;
$prependSPF = "Received-SPF: Permerror";
break;
case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY:
$prependSPF = "Received-SPF: Temperror";
break;
case \SPFLib\Check\Result::CODE_FAIL:
$fail = true;
$prependSPF = "Received-SPF: Fail";
break;
case \SPFLib\Check\Result::CODE_SOFTFAIL:
$prependSPF = "Received-SPF: Softfail";
break;
case \SPFLib\Check\Result::CODE_NEUTRAL:
$prependSPF = "Received-SPF: Neutral";
break;
case \SPFLib\Check\Result::CODE_PASS:
$prependSPF = "Received-SPF: Pass";
break;
case \SPFLib\Check\Result::CODE_NONE:
$prependSPF = "Received-SPF: None";
break;
}
$prependSPF .= " identity=mailfrom;";
$prependSPF .= " client-ip={$data['client_address']};";
$prependSPF .= " helo={$data['client_name']};";
$prependSPF .= " envelope-from={$data['sender']};";
if ($fail) {
// TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for
// inbound mail to a local recipient address.
$objects = null;
if (array_key_exists('recipient', $data)) {
$objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']);
}
if (!empty($objects)) {
// check if any of the recipient objects have whitelisted the helo, first one wins.
foreach ($objects as $object) {
if (method_exists($object, 'senderPolicyFrameworkWhitelist')) {
$result = $object->senderPolicyFrameworkWhitelist($data['client_name']);
if ($result) {
$response = [
'response' => 'DUNNO',
'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"],
'reason' => 'HELO name whitelisted'
];
return response()->json($response, 200);
}
}
}
}
$result = [
'response' => 'REJECT',
'prepend' => [$prependSPF],
'reason' => "Prohibited by Sender Policy Framework"
];
return response()->json($result, 403);
}
$result = [
'response' => 'DUNNO',
'prepend' => [$prependSPF],
'reason' => "Don't know"
];
return response()->json($result, 200);
}
}
diff --git a/src/app/Policy/Mailfilter/MailParser.php b/src/app/Policy/Mailfilter/MailParser.php
new file mode 100644
index 00000000..90f6aed4
--- /dev/null
+++ b/src/app/Policy/Mailfilter/MailParser.php
@@ -0,0 +1,382 @@
+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
index 00000000..aed9bf9d
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule.php
@@ -0,0 +1,250 @@
+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
index 00000000..e3fda8d7
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
@@ -0,0 +1,86 @@
+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 {
+ // 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);
+ }
+
+ // TODO: Send a notification email message
+
+ // Remove (not deliver) the message to the attendee's inbox
+ return new Result(Result::STATUS_IGNORE);
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
new file mode 100644
index 00000000..fa580b7d
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
@@ -0,0 +1,124 @@
+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
+
+ $attendee = $replyMaster->ATTENDEE;
+ $partstat = $attendee['PARTSTAT'];
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+
+ // 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
+
+ // TODO: Send a notification email message
+ // TODO: Use the COMMENT property from the REPLY in the notification body
+ }
+
+ return new Result(Result::STATUS_IGNORE);
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
new file mode 100644
index 00000000..8a32c054
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
@@ -0,0 +1,146 @@
+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/RequestHandler.php b/src/app/Policy/Mailfilter/RequestHandler.php
new file mode 100644
index 00000000..aee3dd0d
--- /dev/null
+++ b/src/app/Policy/Mailfilter/RequestHandler.php
@@ -0,0 +1,103 @@
+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_IGNORE) {
+ // FIXME: Better code? Should we use custom header instead?
+ return response('', 461);
+ }
+ }
+ }
+
+ // If mail content has been modified, stream it back to Postfix
+ if ($parser->isModified()) {
+ $response = new StreamedResponse();
+
+ $response->headers->replace([
+ 'Content-Type' => 'message/rfc822',
+ 'Content-Disposition' => 'attachment',
+ ]);
+
+ $stream = $parser->getStream();
+
+ $response->setCallback(function () use ($stream) {
+ fpassthru($stream);
+ fclose($stream);
+ });
+
+ return $response;
+ }
+
+ return response('', 201);
+ }
+}
diff --git a/src/app/Policy/Mailfilter/Result.php b/src/app/Policy/Mailfilter/Result.php
new file mode 100644
index 00000000..5cbf1d2f
--- /dev/null
+++ b/src/app/Policy/Mailfilter/Result.php
@@ -0,0 +1,30 @@
+status = $status ?: self::STATUS_ACCEPT;
+ }
+
+ /**
+ * Return the status
+ */
+ public function getStatus(): string
+ {
+ return $this->status;
+ }
+}
diff --git a/src/composer.json b/src/composer.json
index 3437f28a..b6503d93 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,88 +1,89 @@
{
"name": "kolab/kolab4",
"type": "project",
"description": "Kolab 4",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
"php": "^8.1",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0.1",
"doctrine/dbal": "^3.6",
"dyrynda/laravel-nullable-fields": "^4.3.0",
"garethp/php-ews": "dev-master",
"guzzlehttp/guzzle": "^7.8.0",
"jeremy379/laravel-openid-connect": "^2.4",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "^10.15.0",
"laravel/horizon": "^5.9",
"laravel/octane": "^2.0",
"laravel/passport": "^12.0",
"laravel/tinker": "^2.8",
"league/flysystem-aws-s3-v3": "^3.0",
"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",
"spomky-labs/otphp": "~10.0.0",
"stripe/stripe-php": "^10.7"
},
"require-dev": {
"code-lts/doctum": "^5.5.1",
"laravel/dusk": "~8.2.2",
"mockery/mockery": "^1.5",
"larastan/larastan": "^2.0",
"phpstan/phpstan": "^1.4",
"phpunit/phpunit": "^9",
"squizlabs/php_codesniffer": "^3.6"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/config/octane.php b/src/config/octane.php
index bf118711..e4a07226 100644
--- a/src/config/octane.php
+++ b/src/config/octane.php
@@ -1,246 +1,253 @@
env('OCTANE_SERVER', 'swoole'),
/*
|--------------------------------------------------------------------------
| Force HTTPS
|--------------------------------------------------------------------------
|
| When this configuration value is set to "true", Octane will inform the
| framework that all absolute links must be generated using the HTTPS
| protocol. Otherwise your links may be generated using plain HTTP.
|
*/
'https' => env('OCTANE_HTTPS', true),
/*
|--------------------------------------------------------------------------
| Octane Listeners
|--------------------------------------------------------------------------
|
| All of the event listeners for Octane's events are defined below. These
| listeners are responsible for resetting your application's state for
| the next request. You may even add your own listeners to the list.
|
*/
'listeners' => [
WorkerStarting::class => [
EnsureUploadedFilesAreValid::class,
EnsureUploadedFilesCanBeMoved::class,
],
RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
//
],
RequestHandled::class => [
//
],
RequestTerminated::class => [
// FlushUploadedFiles::class,
],
TaskReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TaskTerminated::class => [
//
],
TickReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TickTerminated::class => [
//
],
OperationTerminated::class => [
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
CollectGarbage::class,
],
WorkerErrorOccurred::class => [
ReportException::class,
StopWorkerIfNecessary::class,
],
WorkerStopping::class => [
//
],
],
/*
|--------------------------------------------------------------------------
| Warm / Flush Bindings
|--------------------------------------------------------------------------
|
| The bindings listed below will either be pre-warmed when a worker boots
| or they will be flushed before every new request. Flushing a binding
| will force the container to resolve that binding again when asked.
|
*/
'warm' => [
...Octane::defaultServicesToWarm(),
],
'flush' => [
],
/*
|--------------------------------------------------------------------------
| Octane Cache Table
|--------------------------------------------------------------------------
|
| While using Swoole, you may leverage the Octane cache, which is powered
| by a Swoole table. You may set the maximum number of rows as well as
| the number of bytes per row using the configuration options below.
|
*/
'cache' => [
'rows' => 1000,
'bytes' => 10000,
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Tables
|--------------------------------------------------------------------------
|
| While using Swoole, you may define additional tables as required by the
| application. These tables can be used to store data that needs to be
| quickly accessed by other workers on the particular Swoole server.
|
*/
'tables' => [
/*
'example:1000' => [
'name' => 'string:1000',
'votes' => 'int',
],
*/
],
/*
|--------------------------------------------------------------------------
| File Watching
|--------------------------------------------------------------------------
|
| The following list of files and directories will be watched when using
| the --watch option offered by Octane. If any of the directories and
| files are changed, Octane will automatically reload your workers.
|
*/
'watch' => [
'app',
'bootstrap',
'config',
'database',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'.env',
],
/*
|--------------------------------------------------------------------------
| Garbage Collection Threshold
|--------------------------------------------------------------------------
|
| When executing long-lived PHP scripts such as Octane, memory can build
| up before being cleared by PHP. You can force Octane to run garbage
| collection if your application consumes this amount of megabytes.
|
*/
'garbage' => 64,
/*
|--------------------------------------------------------------------------
| Maximum Execution Time
|--------------------------------------------------------------------------
|
| The following setting configures the maximum execution time for requests
| being handled by Octane. You may set this value to 0 to indicate that
| there isn't a specific time limit on Octane request execution time.
|
*/
'max_execution_time' => 30,
/*
|--------------------------------------------------------------------------
| Swoole configuration
|--------------------------------------------------------------------------
|
| See Laravel\Octane\Command\StartSwooleCommand
*/
'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),
//FIXME accessing app()->environment in here renders artisan disfunctional. I suppose it's too early.
//'log_level' => app()->environment('local') ? SWOOLE_LOG_INFO : SWOOLE_LOG_ERROR,
// 'reactor_num' => , // number of available cpus by default
'send_yield' => true,
'socket_buffer_size' => 10 * 1024 * 1024,
// 'task_worker_num' => // number of available cpus by default
// 'worker_num' => // number of available cpus by default
],
],
];
diff --git a/src/phpstan.neon b/src/phpstan.neon
index e48fa556..c60dcd80 100644
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -1,18 +1,19 @@
includes:
- ./vendor/larastan/larastan/extension.neon
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
parallel:
processTimeout: 300.0
paths:
- app/
- config/
- database/
- resources/
- routes/
- tests/
- resources/
diff --git a/src/routes/api.php b/src/routes/api.php
index 710e36f9..52e6364c 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,346 +1,347 @@
middleware(['auth:api']);
Route::group(
[
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('login', [API\AuthController::class, 'login']);
Route::group(
['middleware' => ['auth:api', 'scope:api']],
function () {
Route::get('info', [API\AuthController::class, 'info']);
Route::post('info', [API\AuthController::class, 'info']);
Route::get('location', [API\AuthController::class, 'location']);
Route::post('logout', [API\AuthController::class, 'logout']);
Route::post('refresh', [API\AuthController::class, 'refresh']);
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
}
);
if (\config('app.with_signup')) {
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::get('signup/domains', [API\SignupController::class, 'domains']);
Route::post('signup/init', [API\SignupController::class, 'init']);
Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
Route::get('signup/plans', [API\SignupController::class, 'plans']);
Route::post('signup/validate', [API\SignupController::class, 'signupValidate']);
Route::post('signup/verify', [API\SignupController::class, 'verify']);
Route::post('signup', [API\SignupController::class, 'signup']);
}
);
}
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => ['auth:api', 'scope:mfa,api'],
'prefix' => 'v4'
],
function () {
Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']);
Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']);
Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
}
);
if (\config('app.with_files')) {
Route::group(
[
'middleware' => ['auth:api', 'scope:fs,api'],
'prefix' => 'v4'
],
function () {
Route::apiResource('fs', API\V4\FsController::class);
Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']);
Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']);
Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']);
Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']);
}
);
Route::group(
[
'middleware' => [],
'prefix' => 'v4'
],
function () {
Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload'])
->middleware(['api']);
Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']);
}
);
}
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => ['auth:api', 'scope:api'],
'prefix' => 'v4'
],
function () {
Route::apiResource('companions', API\V4\CompanionAppsController::class);
// This must not be accessible with the 2fa token,
// to prevent an attacker from pairing a new device with a stolen token.
Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('rooms', API\V4\RoomsController::class);
Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
->withoutMiddleware(['auth:api', 'scope:api']);
Route::apiResource('resources', API\V4\ResourcesController::class);
Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
Route::post('payments', [API\V4\PaymentsController::class, 'store']);
//Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']);
Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']);
Route::get('search/self', [API\V4\SearchController::class, 'searchSelf']);
if (\config('app.with_user_search')) {
Route::get('search/user', [API\V4\SearchController::class, 'searchUser']);
}
Route::post('support/request', [API\V4\SupportController::class, 'request'])
->withoutMiddleware(['auth:api', 'scope:api'])
->middleware(['api']);
Route::get('vpn/token', [API\V4\VPNController::class, 'token']);
Route::get('license/{type}', [API\V4\LicenseController::class, 'license']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => 'webhooks'
],
function () {
Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
Route::post('meet', [API\V4\MeetController::class, 'webhook']);
}
);
if (\config('app.with_services')) {
Route::group(
[
'middleware' => ['allowedHosts'],
'prefix' => 'webhooks'
],
function () {
Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
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::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::get('health/status', [API\V4\HealthController::class, 'status']);
+ Route::post('policy/mail/filter', [API\V4\PolicyController::class, 'mailfilter']);
}
);
}
Route::get('health/readiness', [API\V4\HealthController::class, 'readiness']);
Route::get('health/liveness', [API\V4\HealthController::class, 'liveness']);
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
Route::get('eventlog/{type}/{id}', [API\V4\Admin\EventLogController::class, 'index']);
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']);
Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Admin\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']);
Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/receipts', [API\V4\Admin\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Admin\WalletsController::class, 'receiptDownload']);
Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
}
);
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'prefix' => 'v4',
],
function () {
Route::get('inspect-request', [API\V4\Admin\UsersController::class, 'inspectRequest']);
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
Route::get('eventlog/{type}/{id}', [API\V4\Reseller\EventLogController::class, 'index']);
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']);
Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']);
Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']);
Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Reseller\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
}
);
}
diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php
index c8059db7..d7149b62 100644
--- a/src/tests/BackendsTrait.php
+++ b/src/tests/BackendsTrait.php
@@ -1,406 +1,407 @@
DAV::TYPE_VEVENT,
Engine::TYPE_TASK => DAV::TYPE_VTODO,
Engine::TYPE_CONTACT => DAV::TYPE_VCARD,
Engine::TYPE_GROUP => DAV::TYPE_VCARD,
];
/**
* Append an DAV object to a DAV folder
*/
protected function davAppend(Account $account, $foldername, $filenames, $type): void
{
$dav = $this->getDavClient($account);
$folder = $this->davFindFolder($account, $foldername, $type);
if (empty($folder)) {
throw new \Exception("Failed to find folder {$account}/{$foldername}");
}
foreach ((array) $filenames as $filename) {
$path = __DIR__ . '/data/' . $filename;
if (!file_exists($path)) {
throw new \Exception("File does not exist: {$path}");
}
$content = file_get_contents($path);
$uid = preg_match('/\nUID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null;
if (empty($uid)) {
throw new \Exception("Filed to find UID in {$path}");
}
$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'
: 'text/calendar; charset=utf-8';
if ($dav->create($content) === false) {
throw new \Exception("Failed to append object into {$account}/{$location}");
}
}
}
/**
* Delete a DAV folder
*/
protected function davCreateFolder(Account $account, $foldername, $type): void
{
if ($this->davFindFolder($account, $foldername, $type)) {
return;
}
$dav = $this->getDavClient($account);
$dav_type = $this->davTypes[$type];
$home = $dav->getHome($dav_type);
$folder_id = Utils::uuidStr();
$collection_type = $dav_type == DAV::TYPE_VCARD ? 'addressbook' : 'calendar';
// We create all folders on the top-level
$folder = new DAV\Folder();
$folder->name = $foldername;
$folder->href = rtrim($home, '/') . '/' . $folder_id;
$folder->components = [$dav_type];
$folder->types = ['collection', $collection_type];
if ($dav->folderCreate($folder) === false) {
throw new \Exception("Failed to create folder {$account}/{$folder->href}");
}
}
/**
* Create a DAV folder
*/
protected function davDeleteFolder(Account $account, $foldername, $type): void
{
$folder = $this->davFindFolder($account, $foldername, $type);
if (empty($folder)) {
return;
}
$dav = $this->getDavClient($account);
if ($dav->folderDelete($folder->href) === false) {
throw new \Exception("Failed to delete folder {$account}/{$foldername}");
}
}
/**
* Remove all objects from a DAV folder
*/
protected function davEmptyFolder(Account $account, $foldername, $type): void
{
$dav = $this->getDavClient($account);
foreach ($this->davList($account, $foldername, $type) as $object) {
if ($dav->delete($object->href) === false) {
throw new \Exception("Failed to delete {$account}/{object->href}");
}
}
}
/**
* Find a DAV folder
*/
protected function davFindFolder(Account $account, $foldername, $type)
{
$dav = $this->getDavClient($account);
$list = $dav->listFolders($this->davTypes[$type]);
if ($list === false) {
throw new \Exception("Failed to list '{$type}' folders on {$account}");
}
foreach ($list as $folder) {
if (str_replace(' » ', '/', $folder->name) === $foldername) {
return $folder;
}
}
return null;
}
/**
* List objects in a DAV folder
*/
protected function davList(Account $account, $foldername, $type): array
{
$folder = $this->davFindFolder($account, $foldername, $type);
if (empty($folder)) {
throw new \Exception("Failed to find folder {$account}/{$foldername}");
}
$dav = $this->getDavClient($account);
$search = new DAV\Search($this->davTypes[$type], true);
$searchResult = $dav->search($folder->href, $search);
if ($searchResult === false) {
throw new \Exception("Failed to get items from a DAV folder {$account}/{$folder->href}");
}
$result = [];
foreach ($searchResult as $item) {
$result[] = $item;
}
return $result;
}
/**
* List DAV folders
*/
protected function davListFolders(Account $account, $type): array
{
$dav = $this->getDavClient($account);
$list = $dav->listFolders($this->davTypes[$type]);
if ($list === false) {
throw new \Exception("Failed to list '{$type}' folders on {$account}");
}
$result = [];
foreach ($list as $folder) {
// skip shared folders (iRony)
if (str_starts_with($folder->name, 'shared » ') || $folder->name[0] == '(') {
continue;
}
$result[$folder->href] = str_replace(' » ', '/', $folder->name);
}
return $result;
}
/**
* Get configured/initialized DAV client
*/
protected function getDavClient(Account $account): DAV
{
$clientId = (string) $account;
if (empty($this->clients[$clientId])) {
$uri = preg_replace('/^dav/', 'http', $account->uri);
$this->clients[$clientId] = new DAV($account->username, $account->password, $uri);
}
return $this->clients[$clientId];
}
/**
* Get configured/initialized IMAP client
*/
protected function getImapClient(Account $account): \rcube_imap_generic
{
$clientId = (string) $account;
if (empty($this->clients[$clientId])) {
$class = new \ReflectionClass(IMAP::class);
$initIMAP = $class->getMethod('initIMAP');
$getConfig = $class->getMethod('getConfig');
$initIMAP->setAccessible(true);
$getConfig->setAccessible(true);
$config = [
'user' => $account->username,
'password' => $account->password,
];
$login_as = $account->params['user'] ?? null;
$config = array_merge($getConfig->invoke(null), $config);
$this->clients[$clientId] = $initIMAP->invokeArgs(null, [$config, $login_as]);
}
return $this->clients[$clientId];
}
/**
* Initialize an account
*/
protected function initAccount(Account $account): void
{
// Remove all objects from all (personal) folders
if ($account->scheme == 'dav' || $account->scheme == 'davs') {
foreach (['event', 'task', 'contact'] as $type) {
foreach ($this->davListFolders($account, $type) as $folder) {
$this->davEmptyFolder($account, $folder, $type);
}
}
} else {
// TODO: Delete all folders except the default ones?
foreach ($this->imapListFolders($account) as $folder) {
$this->imapEmptyFolder($account, $folder);
}
}
}
/**
* Append an email message to the IMAP folder
*/
protected function imapAppend(Account $account, $folder, $filename, $flags = [], $date = null): string
{
$imap = $this->getImapClient($account);
$source = __DIR__ . '/data/' . $filename;
if (!file_exists($source)) {
throw new \Exception("File does not exist: {$source}");
}
$source = file_get_contents($source);
$source = preg_replace('/\r?\n/', "\r\n", $source);
$uid = $imap->append($folder, $source, $flags, $date, true);
if ($uid === false) {
throw new \Exception("Failed to append mail into {$account}/{$folder}");
}
return $uid;
}
/**
* Create an IMAP folder
*/
protected function imapCreateFolder(Account $account, $folder): void
{
$imap = $this->getImapClient($account);
if (!$imap->createFolder($folder)) {
if (str_contains($imap->error, "Mailbox already exists")) {
// Not an error
} else {
throw new \Exception("Failed to create an IMAP folder {$account}/{$folder}");
}
}
}
/**
* Delete an IMAP folder
*/
protected function imapDeleteFolder(Account $account, $folder): void
{
$imap = $this->getImapClient($account);
// Check the folder existence first, to prevent Cyrus IMAP fatal error when
// attempting to delete a non-existing folder
$existing = $imap->listMailboxes('', $folder);
if (is_array($existing) && in_array($folder, $existing)) {
return;
}
if (!$imap->deleteFolder($folder)) {
if (str_contains($imap->error, "Mailbox does not exist")) {
// Ignore
} else {
throw new \Exception("Failed to delete an IMAP folder {$account}/{$folder}");
}
}
$imap->unsubscribe($folder);
}
/**
* Remove all objects from a folder
*/
protected function imapEmptyFolder(Account $account, $folder): void
{
$imap = $this->getImapClient($account);
$deleted = $imap->flag($folder, '1:*', 'DELETED');
if (!$deleted) {
throw new \Exception("Failed to empty an IMAP folder {$account}/{$folder}");
}
// send expunge command in order to have the deleted message really deleted from the folder
$imap->expunge($folder, '1:*');
}
/**
* List emails over IMAP
*/
protected function imapList(Account $account, $folder): array
{
$imap = $this->getImapClient($account);
$messages = $imap->fetchHeaders($folder, '1:*', true, false, ['Message-Id']);
if ($messages === false) {
throw new \Exception("Failed to get all IMAP message headers for {$account}/{$folder}");
}
return $messages;
}
/**
* List IMAP folders
*/
protected function imapListFolders(Account $account): array
{
$imap = $this->getImapClient($account);
$folders = $imap->listMailboxes('', '');
if ($folders === false) {
throw new \Exception("Failed to list IMAP folders for {$account}");
}
$folders = array_filter(
$folders,
function ($folder) {
return !preg_match('~(Shared Folders|Other Users)/.*~', $folder);
}
);
return $folders;
}
/**
* Mark an email message as read over IMAP
*/
protected function imapFlagAs(Account $account, $folder, $uids, $flags): void
{
$imap = $this->getImapClient($account);
foreach ($flags as $flag) {
if (strpos($flag, 'UN') === 0) {
$flagged = $imap->unflag($folder, $uids, substr($flag, 2));
} else {
$flagged = $imap->flag($folder, $uids, $flag);
}
if (!$flagged) {
throw new \Exception("Failed to flag an IMAP messages as SEEN in {$account}/{$folder}");
}
}
}
}
diff --git a/src/tests/Feature/Controller/PolicyTest.php b/src/tests/Feature/Controller/PolicyTest.php
index 6026ea46..c2647688 100644
--- a/src/tests/Feature/Controller/PolicyTest.php
+++ b/src/tests/Feature/Controller/PolicyTest.php
@@ -1,97 +1,119 @@
clientAddress = '127.0.0.100';
$this->net = \App\IP4Net::create([
'net_number' => '127.0.0.0',
'net_broadcast' => '127.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$this->testDomain = $this->getTestDomain('test.domain', [
'type' => Domain::TYPE_EXTERNAL,
'status' => Domain::STATUS_ACTIVE | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED
]);
$this->testUser = $this->getTestUser('john@test.domain');
Greylist\Connect::where('sender_domain', 'sender.domain')->delete();
Greylist\Whitelist::where('sender_domain', 'sender.domain')->delete();
$this->useServicesUrl();
}
public function tearDown(): void
{
$this->deleteTestUser($this->testUser->email);
$this->deleteTestDomain($this->testDomain->namespace);
$this->net->delete();
Greylist\Connect::where('sender_domain', 'sender.domain')->delete();
Greylist\Whitelist::where('sender_domain', 'sender.domain')->delete();
parent::tearDown();
}
/**
* Test greylist policy webhook
*
* @group greylist
*/
public function testGreylist()
{
// Note: Only basic tests here. More detailed policy handler tests are in another place
// Test 403 response
$post = [
'sender' => 'someone@sender.domain',
'recipient' => $this->testUser->email,
'client_address' => $this->clientAddress,
'client_name' => 'some.mx'
];
$response = $this->post('/api/webhooks/policy/greylist', $post);
$response->assertStatus(403);
$json = $response->json();
$this->assertEquals('DEFER_IF_PERMIT', $json['response']);
$this->assertEquals("Greylisted for 5 minutes. Try again later.", $json['reason']);
// Test 200 response
$connect = Greylist\Connect::where('sender_domain', 'sender.domain')->first();
$connect->created_at = \Carbon\Carbon::now()->subMinutes(6);
$connect->save();
$response = $this->post('/api/webhooks/policy/greylist', $post);
$response->assertStatus(200);
$json = $response->json();
$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/ItipModuleTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModuleTest.php
new file mode 100644
index 00000000..672de31b
--- /dev/null
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModuleTest.php
@@ -0,0 +1,293 @@
+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']);
+
+ // 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
+ {
+ Queue::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);
+ }
+
+ /**
+ * Test REPLY method
+ *
+ * @group @dav
+ */
+ public function testItipReply(): void
+ {
+ Queue::fake();
+
+ $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', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+
+ // 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', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_IGNORE, $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']);
+
+ // TODO: Test corner cases, spoofing case, etc.
+ }
+
+ /**
+ * Test REPLY method with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipReplyRecurrence(): void
+ {
+ Queue::fake();
+
+ $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);
+
+ $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_IGNORE, $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']);
+
+ // TODO: Test corner cases, etc.
+ }
+
+ /**
+ * Test CANCEL method
+ *
+ * @group @dav
+ */
+ public function testItipCancel(): void
+ {
+ Queue::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 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);
+
+ // 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_IGNORE, $result->getStatus());
+
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(0, $list);
+ }
+
+ /**
+ * Test CANCEL method with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipCancelRecurrence(): void
+ {
+ Queue::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/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_IGNORE, $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());
+ }
+}
diff --git a/src/tests/Unit/Backends/DAV/VeventTest.php b/src/tests/Unit/Backends/DAV/VeventTest.php
index 5ad3c585..85f00482 100644
--- a/src/tests/Unit/Backends/DAV/VeventTest.php
+++ b/src/tests/Unit/Backends/DAV/VeventTest.php
@@ -1,81 +1,103 @@
/dav/calendars/user/test@test.com/Default/$uid.ics
"d27382e0b401384becb0d5b157d6b73a2c2084a2"
HTTP/1.1 200 OK
XML;
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->loadXML($ical);
$event = Vevent::fromDomElement($doc->getElementsByTagName('response')->item(0));
$this->assertInstanceOf(Vevent::class, $event);
$this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $event->etag);
$this->assertSame("/dav/calendars/user/test@test.com/Default/{$uid}.ics", $event->href);
$this->assertSame('text/calendar; charset=utf-8', $event->contentType);
$this->assertSame($uid, $event->uid);
$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);
$this->assertSame('20221013', (string) $event->dtstart);
$organizer = [
'rsvp' => false,
'email' => 'organizer@test.com',
'role' => 'ORGANIZER',
'partstat' => 'ACCEPTED',
];
$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,
];
$this->assertSame($recurrence, $event->rrule);
// TODO: Test all supported properties in detail
}
}
diff --git a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
new file mode 100644
index 00000000..1ad910cf
--- /dev/null
+++ b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
@@ -0,0 +1,129 @@
+getBody();
+
+ $this->assertSame('eeea', $body);
+
+ // Multipart/alternative mail
+ $parser = $this->getParserForFile('mailfilter/itip1.eml');
+
+ $body = $parser->getBody();
+
+ $this->assertSame(1639, strlen($body));
+
+ $body = $parser->getBody(0); // text/plain part
+
+ $this->assertSame(189, strlen($body));
+ $this->assertStringStartsWith('*Test Meeting', $body);
+
+ $body = $parser->getBody(1); // text/calendar part
+
+ $this->assertStringStartsWith("BEGIN:VCALENDAR\r\n", $body);
+ $this->assertStringEndsWith("\r\nEND:VCALENDAR", $body);
+
+ // Non-existing part
+ $this->expectException(\Exception::class);
+ $parser->getBody(30);
+ }
+
+ /**
+ * Test access to headers
+ */
+ public function testGetHeader(): void
+ {
+ // Multipart/alternative email
+ $parser = $this->getParserForFile('mailfilter/itip1.eml');
+
+ $this->assertSame('Jack ', $parser->getHeader('from'));
+ $this->assertSame('Jack ', $parser->getHeader('From'));
+ $this->assertSame('multipart/alternative', $parser->getContentType());
+
+ $part = $parser->getParts()[0]; // text/plain part
+
+ $this->assertSame('quoted-printable', $part->getHeader('content-transfer-encoding'));
+ $this->assertSame('text/plain', $part->getContentType());
+
+ $part = $parser->getParts()[1]; // text/calendar part
+
+ $this->assertSame('8bit', $part->getHeader('content-transfer-encoding'));
+ $this->assertSame('text/calendar', $part->getContentType());
+ }
+
+ /**
+ * Test replacing mail content
+ */
+ public function testReplaceBody(): void
+ {
+ // Replace whole body in a non-multipart mail
+ // Note: The body is base64 encoded
+ $parser = self::getParserForFile('mail/1.eml');
+
+ $parser->replaceBody('aa=aa');
+
+ $this->assertSame('aa=aa', $parser->getBody());
+ $this->assertTrue($parser->isModified());
+
+ $parser = new MailParser($parser->getStream());
+
+ $this->assertSame('aa=aa', $parser->getBody());
+ $this->assertSame('text/plain', $parser->getContentType());
+ $this->assertSame('base64', $parser->getHeader('content-transfer-encoding'));
+
+ // Replace text part in multipart/alternative mail
+ // Note: The body is quoted-printable encoded
+ $parser = $this->getParserForFile('mailfilter/itip1.eml');
+
+ $parser->replaceBody('aa=aa', 0);
+ $part = $parser->getParts()[0];
+
+ $this->assertSame('aa=aa', $part->getBody());
+ $this->assertSame('aa=aa', $parser->getBody(0));
+ $this->assertTrue($parser->isModified());
+
+ $parser = new MailParser($parser->getStream());
+ $part = $parser->getParts()[0];
+
+ $this->assertSame('aa=aa', $parser->getBody(0));
+ $this->assertSame('multipart/alternative', $parser->getContentType());
+ $this->assertSame(null, $parser->getHeader('content-transfer-encoding'));
+ $this->assertSame('aa=aa', $part->getBody());
+ $this->assertSame('text/plain', $part->getContentType());
+ $this->assertSame('quoted-printable', $part->getHeader('content-transfer-encoding'));
+ }
+
+ /**
+ * Create mail parser instance for specified test message
+ */
+ public static function getParserForFile(string $file, $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/data/mailfilter/event1.ics b/src/tests/data/mailfilter/event1.ics
new file mode 100644
index 00000000..b09dcd1f
--- /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
index 00000000..cb0690e6
--- /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
index 00000000..96d3ebd8
--- /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
index 00000000..b7001a79
--- /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
index 00000000..17c8cfb3
--- /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
index 00000000..f0b04998
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_cancel.eml
@@ -0,0 +1,67 @@
+MIME-Version: 1.0
+From: Jack
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID:
+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
+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
index 00000000..087260b2
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_reply.eml
@@ -0,0 +1,64 @@
+MIME-Version: 1.0
+From: John
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID:
+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
+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
index 00000000..5dca94d6
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_request.eml
@@ -0,0 +1,73 @@
+MIME-Version: 1.0
+From: Jack
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID:
+To: john@kolab.org
+Subject: You've been invited to "Test Meeting"
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8;
+ format=flowed
+
+*Test Meeting *
+
+When: 2024-07-10 10:30 - 11:30 (Europe/Berlin)
+
+Please find attached an iCalendar file with all the event details which you=
+=20
+can import to your calendar application.
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=REQUEST;
+ name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.4//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-MICROSOFT-CDO-TZID:4
+BEGIN:STANDARD
+DTSTART:20231029T010000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+END:STANDARD
+BEGIN:STANDARD
+DTSTART:20241027T010000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:20240331T010000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
+DTSTAMP:20240709T124304Z
+CREATED:20240709T124304Z
+LAST-MODIFIED:20240709T124304Z
+DTSTART;TZID=Europe/Berlin:20240710T103000
+DTEND;TZID=Europe/Berlin:20240710T113000
+SUMMARY:Test Meeting
+LOCATION:Berlin
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN=John;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
+ VIDUAL;RSVP=TRUE:mailto:john@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND
+ IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip2_cancel.eml b/src/tests/data/mailfilter/itip2_cancel.eml
new file mode 100644
index 00000000..313bbea6
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_cancel.eml
@@ -0,0 +1,70 @@
+MIME-Version: 1.0
+From: Jack
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID:
+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
index 00000000..b64b8eb0
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_reply.eml
@@ -0,0 +1,64 @@
+MIME-Version: 1.0
+From: John
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID:
+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
index 00000000..3d169236
--- /dev/null
+++ b/src/tests/data/mailfilter/itip2_request.eml
@@ -0,0 +1,67 @@
+MIME-Version: 1.0
+From: Jack
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID:
+To: john@kolab.org
+Subject: You've been invited to "Test Meeting"
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8;
+ format=flowed
+
+*Test Meeting*
+
+--=_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
index 00000000..c4a856c9
--- /dev/null
+++ b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
@@ -0,0 +1,68 @@
+MIME-Version: 1.0
+From: Jack
+Date: Tue, 09 Jul 2024 14:43:04 +0200
+Message-ID:
+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--