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--