Page MenuHomePhorge

D5511.1775261005.diff
No OneTemporary

Authored By
Unknown
Size
90 KB
Referenced Files
None
Subscribers
None

D5511.1775261005.diff

diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php
--- a/src/app/Backends/DAV/Vevent.php
+++ b/src/app/Backends/DAV/Vevent.php
@@ -187,6 +187,11 @@
case 'CN':
$attendee[$key] = str_replace('\,', ',', (string) $value);
break;
+ case 'SENT-BY':
+ case 'DELEGATED-TO':
+ case 'DELEGATED-FROM':
+ $attendee[$key] = preg_replace(['!^mailto:!i', '!"!'], '', (string) $value);
+ break;
default:
if (in_array($name, $attendeeProps)) {
$attendee[$key] = (string) $value;
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule.php b/src/app/Policy/Mailfilter/Modules/ItipModule.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule.php
@@ -271,4 +271,145 @@
return $object;
}
+
+ /**
+ * Check iTip message origin. Spoofing detection
+ *
+ * @param Component $request iTip content
+ * @param ?Component $existing Existing object
+ */
+ protected function checkOrigin(Component $request, ?Component $existing): bool
+ {
+ $method = strtoupper(str_replace('Handler', '', class_basename(static::class)));
+ $sender = strtolower($this->parser->getSender());
+
+ // First we check if the envelope sender matches the ORGANIZER/ATTENDEE in the iTip
+ // TODO: Support a local user using one of his aliases?
+ switch ($method) {
+ case 'REQUEST':
+ case 'CANCEL':
+ $property = 'ORGANIZER';
+ $email = strtolower(str_ireplace('mailto:', '', (string) $request->ORGANIZER));
+ $result = $email === $sender;
+ break;
+ case 'REPLY':
+ $property = 'ATTENDEE';
+ // Per https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.3 there can be only
+ // one ATTENDEE in a REPLY. However, there are examples for a delegation case that
+ // have two ATTENDEEs
+ $result = false;
+ foreach ($request->ATTENDEE ?? [] as $attendee) {
+ $attendee_email = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+ if ($attendee_email === $sender) {
+ $result = true;
+ $email = $attendee_email;
+ break 2;
+ }
+ }
+ if (count($request->ATTENDEE) == 1) {
+ $email = str_ireplace('mailto:', '', (string) $request->ATTENDEE);
+ }
+ if (empty($email)) {
+ return false;
+ }
+ break;
+ default:
+ throw new \Exception("Unexpected iTip method: {$method}");
+ }
+
+ // Check if this ORGANIZER/ATTENDEE matches the one in the existing object
+ // Note: According to https://datatracker.ietf.org/doc/html/rfc5546 (3.2.2.4, 3.2.2.5)
+ // change of ORGANIZER is possible, but there's no way to do anything about it.
+ // In such a case passing the iTip to the recipient's Inbox is probably the best we can do.
+ // The same applies to uninvited users https://datatracker.ietf.org/doc/html/rfc5546 (3.2.2.6).
+ if ($result && $existing) {
+ $rid = (string) $request->{'RECURRENCE-ID'};
+ $master = $this->extractMainComponent($existing);
+ $occurence = $rid ? $this->extractRecurrenceInstanceComponent($existing, $rid) : null;
+
+ switch ($method) {
+ case 'REQUEST':
+ case 'CANCEL':
+ $source = $occurence && !empty($occurence->ORGANIZER) ? $occurence : $master;
+ $organizer = str_ireplace('mailto:', '', (string) $source->ORGANIZER);
+
+ $result = strtolower($organizer) === $email;
+ break;
+ case 'REPLY':
+ $source = $occurence && !empty($occurence->ATTENDEE) ? $occurence : $master;
+ $attendees = self::getAttendeeEmails($source);
+
+ $result = in_array($email, $attendees);
+ break;
+ }
+ }
+
+ // Check if this is a delegation request, where an ATTENDEE sends it to a delegatee
+ if (!$result && !$existing && $method == 'REQUEST') {
+ foreach ($request->ATTENDEE ?? [] as $attendee) {
+ $email = str_ireplace('mailto:', '', (string) $attendee);
+ // TODO: Check if DELEGATED-TO matches the iTip recipient
+ if ($email == $sender && !empty($attendee['DELEGATED-TO'])) {
+ $result = true;
+ break;
+ }
+ }
+ }
+
+ if (!$result) {
+ \Log::warning("Itip {$method} origin mismatch for {$property}.");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get email addresses of all attendees, including delegatees, in a component
+ */
+ private static function getAttendeeEmails(Component $object): array
+ {
+ $attendees = [];
+
+ foreach ($object->ATTENDEE ?? [] as $attendee) {
+ if ($email = str_ireplace('mailto:', '', (string) $attendee)) {
+ $attendees[] = $email;
+ }
+
+ if (!empty($attendee['DELEGATED-TO'])) {
+ $delegates = array_map(
+ fn ($item) => str_ireplace('mailto:', '', $item),
+ $attendee['DELEGATED-TO']->getParts()
+ );
+
+ $attendees = array_merge($attendees, $delegates);
+ }
+ }
+
+ return array_map('strtolower', $attendees);
+ }
+
+ /**
+ * Check if the iTip message is eligible for an auto-update.
+ *
+ * @param Component $request iTip content
+ * @param ?Component $existing Existing object
+ */
+ protected function isEligibleForUpdate(Component $request, ?Component $existing = null): bool
+ {
+ if ($existing === null) {
+ return true;
+ }
+
+ // SEQUENCE does not match, we'll let the MUAs deal with this
+ if ((string) $existing->SEQUENCE > (string) $request->SEQUENCE) {
+ $this->parser->debug("Sequence mismatch. Ignored.");
+ return false;
+ }
+
+ // FIXME: When SEQUENCEs are equal we should compare DTSTAMP. Should we?
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-2.1.4
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-5.3
+
+ return true;
+ }
}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/CancelHandler.php
@@ -33,27 +33,19 @@
$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?
$parser->debug("Failed to get the main component. Ignored.");
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 ((string) $existingMaster->SEQUENCE != (string) $cancelMaster->SEQUENCE) {
- $parser->debug("Sequence mismatch. Ignored.");
+ // Spoofing protection
+ if (!$this->checkOrigin($cancelMaster, $existing)) {
return null;
}
@@ -65,11 +57,16 @@
// First find and remove the exception object, if exists
if ($existingInstance = $this->extractRecurrenceInstanceComponent($existing, $recurrence_id)) {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($cancelMaster, $existingInstance)) {
+ return null;
+ }
+
$existing->remove($existingInstance);
}
// Add the EXDATE entry
- // FIXME: Do we need to handle RECURRENE-ID differently to get the exception date (timezone)?
+ // FIXME: Do we need to handle RECURRENCE-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');
@@ -79,6 +76,11 @@
$dav = $this->getDAVClient($user);
$dav->update($this->toOpaqueObject($existing, $this->davLocation));
} else {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($cancelMaster, $existingMaster)) {
+ return null;
+ }
+
$existingInstance = $existingMaster;
$parser->debug("Deleting object at {$this->davLocation}");
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/ReplyHandler.php
@@ -40,14 +40,9 @@
$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);
@@ -56,34 +51,31 @@
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 ((string) $existingMaster->SEQUENCE != (string) $replyMaster->SEQUENCE) {
- $parser->debug("Sequence mismatch. Ignored.");
+ // Spoofing protection
+ if (!$this->checkOrigin($replyMaster, $existing)) {
return null;
}
- // Per RFC 5546 there can be only one ATTENDEE in REPLY
- if (count($replyMaster->ATTENDEE) != 1) {
- $parser->debug("Too many attendees in REPLY. Ignored.");
- return null;
+ // Per https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.3 there can be only
+ // one ATTENDEE in a REPLY. However, there are examples for a delegation case that
+ // have two ATTENDEEs
+ $sender_email = strtolower($parser->getSender());
+ $sender = null;
+ foreach ($replyMaster->ATTENDEE ?? [] as $attendee) {
+ $attendee_email = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+ // TODO: Support using of an alias by a local user?
+ if ($attendee_email === $sender_email) {
+ $sender = $attendee;
+ }
}
- // TODO: Delegation
-
- $sender = $replyMaster->ATTENDEE;
- $partstat = $sender['PARTSTAT'];
- $email = strtolower(preg_replace('!^mailto:!i', '', (string) $sender));
-
- // Supporting attendees w/o an email address could be considered in the future
- if (empty($email)) {
- $parser->debug("Attendee without an email address. Ignored.");
+ if (empty($sender)) {
+ $parser->debug("The sender does not match any ATTENDEE in the REPLY. Ignored.");
return null;
}
// Invalid/useless reply, let the MUA deal with it
- // FIXME: Or should we stop delivery?
- if (empty($partstat) || $partstat == 'NEEDS-ACTION') {
+ if (in_array($sender['PARTSTAT'] ?? '', ['', 'NEEDS-ACTION'])) {
$parser->debug("Unexpected PARTSTAT in REPLY. Ignored.");
return null;
}
@@ -92,8 +84,8 @@
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) {
$parser->debug("No existing recurrence instance. Ignored.");
return null;
@@ -102,21 +94,12 @@
$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']) || (string) $attendee['PARTSTAT'] != $partstat) {
- $attendee['PARTSTAT'] = $partstat;
- $updated = true;
- }
- }
- }
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($replyMaster, $existingInstance)) {
+ return null;
}
- if ($updated) {
+ if ($this->updateObject($existingInstance, $replyMaster, $sender)) {
$parser->debug("Updating object at {$this->davLocation}");
$dav = $this->getDAVClient($user);
@@ -146,8 +129,93 @@
$params->comment = (string) $comment;
$params->partstat = (string) $attendee['PARTSTAT'];
$params->senderName = (string) $attendee['CN'];
- $params->senderEmail = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ $params->senderEmail = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+
+ if ($attendee['DELEGATED-TO']) {
+ $names = [];
+ $delegates = array_map(
+ fn ($item) => str_ireplace('mailto:', '', $item),
+ $attendee['DELEGATED-TO']->getParts()
+ );
+
+ foreach ($delegates as $delegate) {
+ foreach ($existing->ATTENDEE ?? [] as $attendee) {
+ $attendee_email = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+ if ($attendee_email === $delegate && $attendee['CN']) {
+ $names[] = $attendee['CN'];
+ }
+ }
+ }
+
+ $params->delegateEmail = implode(', ', $delegates);
+ $params->delegateName = (count($names) == count($delegates)) ? implode(', ', $names) : '';
+ }
return new ItipNotification($params);
}
+
+ /**
+ * Update the object with attendee state from the reply
+ */
+ private function updateObject(Component $existing, Component $reply, $sender): bool
+ {
+ $sender_email = strtolower(str_ireplace('mailto:', '', (string) $sender));
+ $updated = false;
+ $attendees = [];
+
+ // Update organizer's event with attendee status
+ foreach ($existing->ATTENDEE ?? [] as $attendee) {
+ $attendee_email = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+ $attendees[] = $attendee_email;
+
+ if ($attendee_email === $sender_email) {
+ // FIXME: Is there a cleaner way to copy parameters?
+ foreach (array_keys($attendee->parameters()) as $key) {
+ unset($attendee[$key]);
+ }
+ foreach ($sender->parameters() as $key => $value) {
+ $attendee[$key] = $value;
+ }
+ $updated = true;
+ }
+ }
+
+ // When an attendee delegates another user he may reply to the organizer
+ // with his ATTENDEE property, and optionally the delegatee's ATTENDEE property
+ // We make sure to add delegatee's ATTENDEE property to the object (if not exists yet)
+ if ($updated && $sender['DELEGATED-TO']) {
+ $delegates = array_map(
+ fn ($item) => str_ireplace('mailto:', '', $item),
+ $sender['DELEGATED-TO']->getParts()
+ );
+
+ foreach ($delegates as $delegate) {
+ if (!in_array($delegate, $attendees)) {
+ $params = [];
+ foreach ($reply->ATTENDEE as $attendee) {
+ $attendee_email = strtolower(str_ireplace('mailto:', '', (string) $attendee));
+ if ($attendee_email === $delegate) {
+ $params = $attendee->parameters();
+ break;
+ }
+ }
+
+ if (empty($params)) {
+ $params = [
+ 'PARTSTAT' => 'NEEDS-ACTION',
+ 'DELEGATED-FROM' => 'mailto:' . $sender_email,
+ 'ROLE' => $sender['ROLE'],
+ 'CUTYPE' => $sender['CUTYPE'],
+ ];
+ }
+
+ $existing->add('ATTENDEE', 'mailto:' . $delegate, $params);
+ }
+ }
+ }
+
+ // FIXME: We should probably bump LAST-MODIFIED and/or DTSTAMP property
+
+ return $updated;
+ }
}
diff --git a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
--- a/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
+++ b/src/app/Policy/Mailfilter/Modules/ItipModule/RequestHandler.php
@@ -4,9 +4,11 @@
use App\Policy\Mailfilter\MailParser;
use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Notifications\ItipNotificationParams;
use App\Policy\Mailfilter\Result;
+use App\User;
use Sabre\VObject\Component;
-use Sabre\VObject\Document;
class RequestHandler extends ItipModule
{
@@ -37,10 +39,6 @@
// - 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.
-
$this->parser = $parser;
// Check whether the object already exists in the recipient's calendar
@@ -52,12 +50,21 @@
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 = (string) $requestMaster->{'RECURRENCE-ID'};
+ // Spoofing protection
+ if (!$this->checkOrigin($requestMaster, $existing)) {
+ return null;
+ }
+
+ // If REQUEST attendees do not match with the recipient email(s)
+ // stop processing here and pass the message to the Inbox.
+ if (!$this->checkRecipient($requestMaster)) {
+ $parser->debug("REQUEST attendees do not match recipient's addresses. Ignored.");
+ return null;
+ }
+
// The event does not exist yet in the recipient's calendar, create it
if (!$existing) {
if (!empty($recurrence_id)) {
@@ -76,45 +83,33 @@
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) {
- // TODO: Bump LAST-MODIFIED on the master object
- $existing->add($requestMaster);
- } 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 ((int) ((string) $existingInstance->SEQUENCE) != (int) ((string) $requestMaster->SEQUENCE)) {
- return null;
- }
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($requestMaster, $existingInstance)) {
+ return null;
+ }
- $this->mergeComponents($existingInstance, $requestMaster);
- // TODO: Bump LAST-MODIFIED on the master object
+ // Organizer is the event owner, always replace the whole exception with the new one
+ if ($existingInstance) {
+ $existing->remove($existingInstance);
}
+
+ $existing->add($requestMaster);
} 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 ((int) ((string) $existingMaster->SEQUENCE) != (int) ((string) $requestMaster->SEQUENCE)) {
+ // Outdated message, just deliver it, let the MUAs deal with this
+ if (!$this->isEligibleForUpdate($requestMaster, $existingMaster)) {
return null;
}
- // FIXME: Merge all components included in the request?
- $this->mergeComponents($existingMaster, $requestMaster);
+ // Organizer is the event owner, always replace the whole event with the new one
+ $existing = $this->itip;
+ $existingInstance = $existingMaster;
}
$parser->debug("Updating object at {$this->davLocation}");
@@ -122,44 +117,83 @@
$dav = $this->getDAVClient($user);
$dav->update($this->toOpaqueObject($existing, $this->davLocation));
+ // If the recipient's action is not required replace the message with a notification
+ if (!$this->isActionRequired($requestMaster, $existingInstance)) {
+ $parser->debug("Sending notification to {$user->email}");
+ $user->notify($this->notification($requestMaster));
+
+ return new Result(Result::STATUS_DISCARD);
+ }
+
return null;
}
/**
- * Merge VOBJECT component properties into another component
+ * Check if the request recipient is one of the event attendees
*/
- protected function mergeComponents(Component $to, Component $from): void
+ private function checkRecipient(Component $request): bool
{
- // TODO: Every property? What other properties? EXDATE/RDATE? ORGANIZER? ATTACH?
- // TODO: Removal of RRULE from the master event
- $props = ['SEQUENCE', 'RRULE'];
- foreach ($props as $prop) {
- if (isset($from->{$prop})) {
- $to->{$prop} = $from->{$prop};
+ if (empty($request->ATTENDEE)) {
+ return false;
+ }
+
+ $user = $this->parser->getUser();
+ $attendees = [];
+
+ // Check the main user email address
+ foreach ($request->ATTENDEE as $attendee) {
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ if ($email === $user->email) {
+ return true;
}
+ if ($email) {
+ $attendees[] = $email;
+ }
+ }
+
+ // Check the user aliases
+ return $user->aliases->whereIn('alias', $attendees)->count() > 0;
+ }
+
+ /**
+ * Check if attendee action is required for the request
+ */
+ private function isActionRequired(Component $request, ?Component $existing = null): bool
+ {
+ if (empty($existing)) {
+ return true;
}
- // Replace the list of ATTENDEEs
- $to->remove('ATTENDEE');
- foreach ($from->ATTENDEE ?? [] as $attendee) {
- $class = $attendee::class;
- $to->add(new $class($to->parent, 'ATTENDEE', $attendee->getValue(), $attendee->parameters()));
+ if ((string) $existing->SEQUENCE < (string) $request->SEQUENCE) {
+ return true;
}
- // 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);
- }
+ $user = $this->parser->getUser();
+
+ foreach ($request->ATTENDEE as $attendee) {
+ $email = strtolower(preg_replace('!^mailto:!i', '', (string) $attendee));
+ if ($email === $user->email || $user->aliases->contains('alias', $email)) {
+ if (empty($attendee['PARTSTAT']) || (string) $attendee['PARTSTAT'] == 'NEEDS-ACTION') {
+ return true;
}
}
}
+
+ return false;
+ }
+
+ /**
+ * Create a notification
+ */
+ private function notification(Component $request): ItipNotification
+ {
+ $organizer = $request->ORGANIZER;
+
+ $params = new ItipNotificationParams('request', $request);
+ $params->comment = (string) $request->COMMENT;
+ $params->senderName = (string) $organizer['CN'];
+ $params->senderEmail = strtolower(preg_replace('!^mailto:!i', '', (string) $organizer));
+
+ return new ItipNotification($params);
}
}
diff --git a/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php b/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php
--- a/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php
+++ b/src/app/Policy/Mailfilter/Notifications/ItipNotificationMail.php
@@ -29,6 +29,7 @@
$vars = get_object_vars($this->params);
$vars['sender'] = $this->params->senderName ?: $this->params->senderEmail ?: '';
+ $vars['delegatee'] = $this->params->delegateName ?: $this->params->delegateEmail ?: '';
$vars['body1'] = \trans("mail.itip-{$mode}-body", $vars);
$vars['body2'] = '';
diff --git a/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php b/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php
--- a/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php
+++ b/src/app/Policy/Mailfilter/Notifications/ItipNotificationParams.php
@@ -10,6 +10,12 @@
/** @var ?string iTip COMMENT property */
public ?string $comment;
+ /** @var string iTip delegatee (attendee) email address */
+ public string $delegateEmail = '';
+
+ /** @var string iTip delegatee (attendee) name */
+ public string $delegateName = '';
+
/** @var ?string Notification mode (iTip method) */
public ?string $mode;
diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php
--- a/src/resources/lang/en/mail.php
+++ b/src/resources/lang/en/mail.php
@@ -35,7 +35,6 @@
'itip-request-subject' => "\":summary\" has been updated",
'itip-request-body' => "The event \":summary\" at :start has been updated in your calendar.",
- 'itip-request-changes' => "Changes submitted by :sender have been automatically applied.",
'itip-attendee-accepted' => ":sender accepted the invitation.",
'itip-attendee-declined' => ":sender declined the invitation.",
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/CancelHandlerTest.php
@@ -34,29 +34,24 @@
$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');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
Notification::assertNothingSent();
// Jack cancelled the meeting, and now the event exists in John's calendar
$this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event');
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
- $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
-
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(0, $list);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
Notification::assertCount(1);
Notification::assertSentTo(
@@ -72,6 +67,63 @@
&& empty($notification->params->recurrenceId);
}
);
+
+ Notification::fake();
+
+ // Jack cancelled the meeting, but the iTIP has outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0/' => 'SEQUENCE:1',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on CANCEL
+ *
+ * @group @dav
+ */
+ public function testItipCancelSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Ned impersonates Jack cancelling the meeting, but there's no event in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event2.ics'], 'event');
+
+ // Ned impersonates Jack cancelling the meeting, but now the event exists in John's calendar
+ $replaces = [
+ 'mailto:jack@kolab.org' => 'mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_cancel.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
}
/**
@@ -90,8 +142,8 @@
$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');
+ // Jack cancelled the meeting occurence, and the event exists in John's calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -119,5 +171,56 @@
&& $notification->params->recurrenceId == '20240717T123000';
}
);
+
+ Notification::fake();
+
+ // Jack cancelled the meeting occurence, but the iTip CANCEL contains outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0\nTRANSP/' => 'SEQUENCE:1\nTRANSP',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $list = $this->davList($account, 'Calendar', 'event');
+ $this->assertCount(1, $list[0]->exceptions);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on CANCEL with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipCancelRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('john@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Ned impersonates Jack cancelling the meeting occurrence, the event exists in John's calendar
+ $replaces = [
+ 'mailto:jack@kolab.org' => 'mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_cancel.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(0, $list[0]->exdate);
+
+ Notification::assertNothingSent();
}
}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/DelegationTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/DelegationTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/DelegationTest.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Tests\Feature\Policy\Mailfilter\Modules\ItipModule;
+
+use App\DataMigrator\Account;
+use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
+use Illuminate\Support\Facades\Notification;
+use Tests\BackendsTrait;
+use Tests\TestCase;
+use Tests\Unit\Policy\Mailfilter\MailParserTest;
+
+class DelegationTest extends TestCase
+{
+ use BackendsTrait;
+
+ /**
+ * Test REQUEST/REPLY/CANCEL with delegation
+ *
+ * @group @dav
+ */
+ public function testDelegation(): void
+ {
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.2.3
+ // https://datatracker.ietf.org/doc/html/rfc5546#section-4.2.5
+
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $jack_account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+ $john_account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+ $joe_account = new Account(preg_replace('|://|', '://joe%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($jack_account, 'Calendar', 'event');
+ $this->davEmptyFolder($john_account, 'Calendar', 'event');
+ $this->davEmptyFolder($joe_account, 'Calendar', 'event');
+
+ // Jack invites John
+ $this->davAppend($jack_account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+ $this->davAppend($john_account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ // John delegates the invitation to Joe
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request_delegation.eml', 'joe@kolab.org', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($joe_account, 'Calendar', 'event'));
+ $this->assertCount(3, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('DELEGATED', $list[0]->attendees[0]['partstat']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[0]['delegatedTo']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $list[0]->attendees[1]['partstat']);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[1]['delegatedFrom']);
+
+ Notification::assertNothingSent();
+
+ // John replies to Jack the organizer
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply_delegation1.eml', 'jack@kolab.org', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($jack_account, 'Calendar', 'event'));
+ $this->assertCount(3, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('DELEGATED', $list[0]->attendees[0]['partstat']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[0]['delegatedTo']);
+ $this->assertFalse($list[0]->attendees[0]['rsvp']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[2]['email']);
+ $this->assertSame('NEEDS-ACTION', $list[0]->attendees[2]['partstat']);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[2]['delegatedFrom']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('jack@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'reply'
+ && $notification->params->senderEmail == 'john@kolab.org'
+ && $notification->params->senderName == 'John'
+ && $notification->params->comment == 'a reply from John'
+ && $notification->params->partstat == 'DELEGATED'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+
+ $this->davEmptyFolder($john_account, 'Calendar', 'event');
+ $this->davAppend($john_account, 'Calendar', ['mailfilter/event6.ics'], 'event');
+
+ // Joe replies to John the delegator
+ Notification::fake();
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply_delegation2.eml', 'john@kolab.org', 'joe@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($john_account, 'Calendar', 'event'));
+ $this->assertCount(3, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('DELEGATED', $list[0]->attendees[0]['partstat']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[0]['delegatedTo']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[1]['email']);
+ $this->assertSame('ACCEPTED', $list[0]->attendees[1]['partstat']);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[1]['delegatedFrom']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('john@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'reply'
+ && $notification->params->senderEmail == 'joe@kolab.org'
+ && $notification->params->senderName == 'Joe'
+ && $notification->params->comment == 'a reply from Joe'
+ && $notification->params->partstat == 'ACCEPTED'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+
+ // Joe replies to Jack the organizer
+ Notification::fake();
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply_delegation2.eml', 'jack@kolab.org', 'joe@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($jack_account, 'Calendar', 'event'));
+ $this->assertCount(3, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('DELEGATED', $list[0]->attendees[0]['partstat']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[0]['delegatedTo']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+ $this->assertSame('joe@kolab.org', $list[0]->attendees[2]['email']);
+ $this->assertSame('ACCEPTED', $list[0]->attendees[2]['partstat']);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[2]['delegatedFrom']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('jack@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'reply'
+ && $notification->params->senderEmail == 'joe@kolab.org'
+ && $notification->params->senderName == 'Joe'
+ && $notification->params->comment == 'a reply from Joe'
+ && $notification->params->partstat == 'ACCEPTED'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/ReplyHandlerTest.php
@@ -34,7 +34,7 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
// John's response to the invitation, but there's no event in Jack's (organizer's) calendar
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
@@ -45,14 +45,12 @@
// John's response to the invitation, the Jack's event exists now
$this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email);
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -77,7 +75,71 @@
}
);
- // TODO: Test corner cases, spoofing case, etc.
+ Notification::fake();
+
+ // John's response to the invitation, but with outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0/' => 'SEQUENCE:1',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $attendees = $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $attendees[0]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[0]['partstat']);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection for REPLY
+ *
+ * @group @dav
+ */
+ public function testItipReplySpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Unknown impersonates John's response to the invitation, but there's no event in Jack's (organizer's) calendar
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ // Unknown user impersonates John's response to the invitation, the Jack's event exists now
+ $replaces = [
+ 'mailto:john@kolab.org' => 'mailto:unknown@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_reply.eml', $user->email, 'unknown@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertCount(2, $attendees = $list[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']);
+
+ Notification::assertNothingSent();
}
/**
@@ -97,16 +159,12 @@
$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');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
-
- Notification::assertNothingSent();
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -116,18 +174,18 @@
$this->assertSame('jack@kolab.org', $list[0]->organizer['email']);
$this->assertCount(0, $list[0]->exceptions);
+ Notification::assertNothingSent();
+
$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');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -158,6 +216,82 @@
}
);
- // TODO: Test corner cases, etc.
+ Notification::fake();
+
+ // John's response to the invitation, but with the outdated SEQUENCE
+ $replaces = [
+ '/SEQUENCE:0\nTRANSP/' => 'SEQUENCE:1\nTRANSP',
+ ];
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event', $replaces);
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'john@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+ }
+
+ /**
+ * Test spoofing protection on REPLY with recurrence
+ *
+ * @group @dav
+ */
+ public function testItipReplyRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $user = $this->getTestUser('jack@kolab.org');
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
+
+ // Unknown user impersonates 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', 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Unknown impersonates John's response to the invitation, the Jack's event exists now with the exception
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'unknown@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+
+ // Unknown impersonates John's response to the invitation, the Jack's event exists now with the exception
+ $replaces = [
+ 'mailto:john@kolab.org' => 'mailto:unknown@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_reply.eml', 'jack@kolab.org', 'unknown@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+
+ Notification::assertNothingSent();
}
}
diff --git a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
--- a/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
+++ b/src/tests/Feature/Policy/Mailfilter/Modules/ItipModule/RequestHandlerTest.php
@@ -4,6 +4,8 @@
use App\DataMigrator\Account;
use App\Policy\Mailfilter\Modules\ItipModule;
+use App\Policy\Mailfilter\Notifications\ItipNotification;
+use App\Policy\Mailfilter\Result;
use Illuminate\Support\Facades\Notification;
use Tests\BackendsTrait;
use Tests\TestCase;
@@ -31,14 +33,12 @@
$this->davEmptyFolder($account, 'Calendar', 'event');
// Jack invites John (and Ned) to a new meeting
- $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org');
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
$this->assertCount(2, $list[0]->attendees);
$this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
@@ -49,7 +49,7 @@
Notification::assertNothingSent();
- // Test REQUEST to an existing event, and other corner cases
+ // Test REQUEST to an existing event
$replaces = [
'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
];
@@ -58,10 +58,9 @@
$result = $module->handle($parser);
$this->assertNull($result);
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5', $list[0]->uid);
+ $this->assertSame('Test Meeting 1', $list[0]->summary);
$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']);
@@ -71,7 +70,264 @@
Notification::assertNothingSent();
- // TODO: Test various supported message structures (ItipModule::getItip())
+ // Jack sends a REQUEST with old SEQUENCE (master event, not exception)
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->exceptions);
+ $this->assertSame(1, (int) $list[0]->sequence);
+
+ // Jack sends a REQUEST with updated SEQUENCE (master event, not exception)
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+ $this->assertSame(2, (int) $list[0]->sequence);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test REQUEST method against checkRecipient()
+ *
+ * @group dav
+ */
+ public function testItipRequestCheckRecipient(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // Test a REQUEST where none of the ATTENDEEs is the recipient
+ $replace = [
+ 'john@kolab.org' => 'johnathan@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $list = $this->davList($account, 'Calendar', 'event'));
+
+ // Test a REQUEST where ATTENDEE is the recipient
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+
+ // Test a REQUEST where ATTENDEE is the recipient's alias
+ $replace = [
+ 'john@kolab.org' => 'john.doe@kolab.org',
+ 'SEQUENCE:0' => 'SEQUENCE:1',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(2, $list[0]->attendees);
+ $this->assertSame('john.doe@kolab.org', $list[0]->attendees[0]['email']);
+ $this->assertSame('ned@kolab.org', $list[0]->attendees[1]['email']);
+
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test notifications on a REQUEST
+ *
+ * @group dav
+ */
+ public function testItipRequestNotifications(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event5.ics'], 'event');
+
+ // Jack sends a REQUEST with same SEQUENCE, a notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:1',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('john@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'request'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == 'Test comment'
+ && $notification->params->start == '2024-07-10 10:30'
+ && $notification->params->summary == 'Test Meeting'
+ && empty($notification->params->recurrenceId);
+ }
+ );
+
+ Notification::fake();
+
+ // Jack sends a REQUEST with updated SEQUENCE, no notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame(2, (int) $list[0]->sequence);
+
+ Notification::assertNothingSent();
+
+ // Jack sends a REQUEST with same SEQUENCE, but reset John's PARTSTAT, no notification is expected
+ $replace = [
+ 'SEQUENCE:0' => 'SEQUENCE:2',
+ 'CN=John;PARTSTAT=ACCEPTED' => 'CN=John;PARTSTAT=NEEDS-ACTION',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+
+ Notification::assertNothingSent();
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Jack sends a REQUEST to existing occurrence (SEQUENCE bump), no notification expected
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+
+ Notification::assertNothingSent();
+
+ // Jack sends a REQUEST to existing occurrence (PARTSTAT update), a notification is expected
+ $replace = [
+ 'CN=John;PARTSTAT=NEEDS-ACTION' => 'CN=John;PARTSTAT=ACCEPTED',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replace);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertSame(Result::STATUS_DISCARD, $result->getStatus());
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $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']);
+
+ Notification::assertCount(1);
+ Notification::assertSentTo(
+ $user = $this->getTestUser('john@kolab.org'),
+ static function (ItipNotification $notification, array $channels, object $notifiable) use ($user) {
+ return $notifiable->id == $user->id
+ && $notification->params->mode == 'request'
+ && $notification->params->senderEmail == 'jack@kolab.org'
+ && $notification->params->senderName == 'Jack'
+ && $notification->params->comment == 'Ex'
+ && $notification->params->start == '2024-07-17 12:30'
+ && $notification->params->summary == 'Test Meeting Ex'
+ && !empty($notification->params->recurrenceId);
+ }
+ );
+ }
+
+ /**
+ * Test spoofing protections on REQUEST
+ *
+ * @group dav
+ */
+ public function testItipRequestSpoofing(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // A new meeting, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
+
+ Notification::assertNothingSent();
+
+ $this->davAppend($account, 'Calendar', ['mailfilter/event1.ics'], 'event');
+
+ // An existing meeting, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('Test Meeting', $list[0]->summary);
+ $this->assertSame('12:43:04', $list[0]->lastModified->getDateTime()->format('H:i:s'));
+
+ Notification::assertNothingSent();
+
+ // An existing meeting, Ned as himself, but the organizer is already Jack
+ $replaces = [
+ 'CN=Jack:mailto:jack@kolab.org' => 'CN=Ned:mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip1_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertSame('Test Meeting', $list[0]->summary);
+ $this->assertSame('12:43:04', $list[0]->lastModified->getDateTime()->format('H:i:s'));
+
+ Notification::assertNothingSent();
}
/**
@@ -87,18 +343,25 @@
$account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
$this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // A recurrence exception in the iTip, but the event does not exist yet
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(0, $this->davList($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');
+ // is already in John's calendar and has no recurrence exceptions yet
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
-
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -113,18 +376,32 @@
$this->assertSame('ned@kolab.org', $attendees[1]['email']);
$this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
- // Test updating an existing occurence
+ // Test updating an existing occurence using old SEQUENCE - expect no changes to the event
$replaces = [
'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
+ 'SEQUENCE:1' => 'SEQUENCE:0',
];
$parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replaces);
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertCount(2, $attendees = $list[0]->exceptions[0]->attendees);
+ $this->assertSame('ned@kolab.org', $attendees[1]['email']);
+ $this->assertSame('NEEDS-ACTION', $attendees[1]['partstat']);
+
+ // Test updating an existing occurence using same SEQUENCE as in the existing exception
+ $replaces = [
+ 'CN=Ned;PARTSTAT=NEEDS-ACTION' => 'CN=Ned;PARTSTAT=ACCEPTED',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'jack@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
- $list = $this->davList($account, 'Calendar', 'event');
- $this->assertCount(1, $list);
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
$this->assertSame('5463F1DDF6DA264A3FC70E7924B729A5-222222', $list[0]->uid);
$this->assertCount(2, $attendees = $list[0]->attendees);
$this->assertSame('john@kolab.org', $attendees[0]['email']);
@@ -139,28 +416,66 @@
$this->assertSame('ned@kolab.org', $attendees[1]['email']);
$this->assertSame('ACCEPTED', $attendees[1]['partstat']);
- // 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');
+ Notification::assertNothingSent();
+ }
+
+ /**
+ * Test spoofing protection on REQUEST with recurrence
+ *
+ * @group dav
+ */
+ public function testItipRequestRecurrenceSpoofing(): void
+ {
+ Notification::fake();
+
+ $uri = preg_replace('|^http|', 'dav', \config('services.dav.uri'));
+ $account = new Account(preg_replace('|://|', '://john%40kolab.org:simple123@', $uri));
- $parser = MailParserTest::getParserForFile('mailfilter/itip3_request_rrule_update.eml', 'john@kolab.org');
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+
+ // A recurrence exception in the iTip, but the event does not exist yet, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org');
$module = new ItipModule();
$result = $module->handle($parser);
$this->assertNull($result);
+ $this->assertCount(0, $this->davList($account, 'Calendar', 'event'));
- $list = $this->davList($account, 'Calendar', 'event');
- $list = array_filter($list, static fn ($event) => $event->uid == '5464F1DDF6DA264A3FC70E7924B729A5-333333');
- $event = $list[array_key_first($list)];
+ $this->davAppend($account, 'Calendar', ['mailfilter/event3.ics'], 'event');
- $this->assertCount(1, $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('NEEDS-ACTION', $attendees[1]['partstat']);
- $this->assertCount(1, $event->exceptions);
- $this->assertSame('20240717T123000', $event->exceptions[0]->recurrenceId);
+ // The event is already in John's calendar, but has no recurrence exceptions yet, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org');
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ // The event is already in John's calendar, but has no recurrence exceptions yet, Ned impersonates Jack
+ $replaces = [
+ 'CN=Jack:mailto:jack@kolab.org' => 'CN=Ned:mailto:ned@kolab.org',
+ ];
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(0, $list[0]->exceptions);
+
+ $this->davEmptyFolder($account, 'Calendar', 'event');
+ $this->davAppend($account, 'Calendar', ['mailfilter/event4.ics'], 'event');
+
+ // Test updating an existing occurence, Ned impersonates Jack
+ $parser = MailParserTest::getParserForFile('mailfilter/itip2_request.eml', 'john@kolab.org', 'ned@kolab.org', $replaces);
+ $module = new ItipModule();
+ $result = $module->handle($parser);
+
+ $this->assertNull($result);
+ $this->assertCount(1, $list = $this->davList($account, 'Calendar', 'event'));
+ $this->assertCount(1, $list[0]->exceptions);
+ $this->assertSame('Test Meeting', $list[0]->exceptions[0]->summary);
Notification::assertNothingSent();
}
diff --git a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
--- a/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
+++ b/src/tests/Unit/Policy/Mailfilter/MailParserTest.php
@@ -24,7 +24,7 @@
$body = $parser->getBody();
- $this->assertSame(1639, strlen($body));
+ $this->assertSame(1641, strlen($body));
$body = $parser->getBody(0); // text/plain part
diff --git a/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
--- a/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
+++ b/src/tests/Unit/Policy/Mailfilter/Notifications/ItipNotificationMailTest.php
@@ -81,6 +81,75 @@
$this->assertStringContainsString($line, $plain);
}
+ // Delegation case
+ $params = new ItipNotificationParams('reply');
+ $params->user = new User(['email' => 'john@kolab.org']);
+ $params->senderEmail = 'jack@kolab.org';
+ $params->senderName = 'Jack Strong';
+ $params->delegateEmail = 'joe@kolab.org';
+ $params->delegateName = 'Joe Black';
+ $params->partstat = 'DELEGATED';
+ $params->summary = 'Test Meeting';
+ $params->start = '2024-01-01';
+ $params->comment = 'Attendee comment';
+ $params->recurrenceId = '2024-01-01';
+
+ $mail = $this->renderMail(new ItipNotificationMail($params));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $expected = [
+ "The event \"Test Meeting\" at 2024-01-01 has been updated in your calendar",
+ "Jack Strong delegated the invitation to Joe Black.",
+ "Jack Strong provided comment: Attendee comment",
+ "NOTE: This only refers to this single occurrence!",
+ "*** This is an automated message. Please do not reply. ***",
+ ];
+
+ $this->assertSame("\"Test Meeting\" has been updated", $mail['subject']);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ foreach ($expected as $line) {
+ $this->assertStringContainsString($line, $html);
+ $this->assertStringContainsString($line, $plain);
+ }
+
+ // TODO: Test with some properties unset
+ }
+
+ /**
+ * Test REQUEST notification
+ */
+ public function testRequest(): void
+ {
+ $params = new ItipNotificationParams('request');
+ $params->user = new User(['email' => 'john@kolab.org']);
+ $params->senderEmail = 'jack@kolab.org';
+ $params->senderName = 'Jack Strong';
+ $params->summary = 'Test Meeting';
+ $params->start = '2024-01-01';
+ $params->comment = 'Organizer comment';
+ $params->recurrenceId = '2024-01-01';
+
+ $mail = $this->renderMail(new ItipNotificationMail($params));
+
+ $html = $mail['html'];
+ $plain = $mail['plain'];
+
+ $expected = [
+ "The event \"Test Meeting\" at 2024-01-01 has been updated in your calendar",
+ "Jack Strong provided comment: Organizer comment",
+ "NOTE: This only refers to this single occurrence!",
+ "*** This is an automated message. Please do not reply. ***",
+ ];
+
+ $this->assertSame("\"Test Meeting\" has been updated", $mail['subject']);
+ $this->assertStringStartsWith('<!DOCTYPE html>', $html);
+ foreach ($expected as $line) {
+ $this->assertStringContainsString($line, $html);
+ $this->assertStringContainsString($line, $plain);
+ }
+
// TODO: Test with some properties unset
}
}
diff --git a/src/tests/data/mailfilter/event4.ics b/src/tests/data/mailfilter/event4.ics
--- a/src/tests/data/mailfilter/event4.ics
+++ b/src/tests/data/mailfilter/event4.ics
@@ -34,12 +34,12 @@
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
+TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
UID:5463F1DDF6DA264A3FC70E7924B729A5-222222
diff --git a/src/tests/data/mailfilter/event5.ics b/src/tests/data/mailfilter/event5.ics
--- a/src/tests/data/mailfilter/event5.ics
+++ b/src/tests/data/mailfilter/event5.ics
@@ -33,7 +33,7 @@
SUMMARY:Test Meeting
LOCATION:Berlin
RRULE:FREQ=WEEKLY;INTERVAL=1
-SEQUENCE:0
+SEQUENCE:1
TRANSP:OPAQUE
ATTENDEE;CN=John;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;CUTYPE=INDI
VIDUAL:mailto:john@kolab.org
diff --git a/src/tests/data/mailfilter/event4.ics b/src/tests/data/mailfilter/event6.ics
copy from src/tests/data/mailfilter/event4.ics
copy to src/tests/data/mailfilter/event6.ics
--- a/src/tests/data/mailfilter/event4.ics
+++ b/src/tests/data/mailfilter/event6.ics
@@ -4,6 +4,7 @@
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
+X-MICROSOFT-CDO-TZID:4
BEGIN:STANDARD
DTSTART:20231029T010000
TZOFFSETFROM:+0200
@@ -24,7 +25,7 @@
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
-UID:5463F1DDF6DA264A3FC70E7924B729A5-222222
+UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
DTSTAMP:20240709T124304Z
CREATED:20240709T124304Z
LAST-MODIFIED:20240709T124304Z
@@ -32,31 +33,14 @@
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
+ATTENDEE;CN=John;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:joe@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=FALSE:mailto:john@kolab.org
+ATTENDEE;CN=Joe;PARTSTAT=NEEDS-ACTION;DELEGATED-FROM="mailto:john@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:joe@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
END:VEVENT
END:VCALENDAR
diff --git a/src/tests/data/mailfilter/itip1_reply_delegation1.eml b/src/tests/data/mailfilter/itip1_reply_delegation1.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_reply_delegation1.eml
@@ -0,0 +1,36 @@
+MIME-Version: 1.0
+From: John <john@kolab.org>
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID: <f493eee5182874694372696db14c90f2@kolab.org>
+To: jack@kolab.org
+Subject: Reply to "Test Meeting"
+Content-Type: multipart/alternative;
+ boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
+
+--=_f77327deb61c6eccadcf01b3f6f854cb
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8
+
+Delegator replies to the organizer
+
+--=_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:VEVENT
+UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
+DTSTAMP:20240709T124304Z
+SEQUENCE:0
+ATTENDEE;CN=John;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:joe@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=FALSE:mailto:john@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:a reply from John
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip1_reply_delegation2.eml b/src/tests/data/mailfilter/itip1_reply_delegation2.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/mailfilter/itip1_reply_delegation2.eml
@@ -0,0 +1,38 @@
+MIME-Version: 1.0
+From: Joe <joe@kolab.org>
+Date: Tue, 09 Jul 2024 14:45:04 +0200
+Message-ID: <f493eee5182874694372696db14c90f2@kolab.org>
+To: john@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
+
+Delegatee replies
+
+--=_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:VEVENT
+UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
+DTSTAMP:20240709T124304Z
+SEQUENCE:0
+ATTENDEE;CN=John;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:joe@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:john@kolab.org
+ATTENDEE;CN=Joe;PARTSTAT=ACCEPTED;DELEGATED-FROM="mailto:john@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:joe@kolab.org
+ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:a reply from Joe
+END:VEVENT
+END:VCALENDAR
+--=_f77327deb61c6eccadcf01b3f6f854cb--
diff --git a/src/tests/data/mailfilter/itip1_request.eml b/src/tests/data/mailfilter/itip1_request.eml
--- a/src/tests/data/mailfilter/itip1_request.eml
+++ b/src/tests/data/mailfilter/itip1_request.eml
@@ -56,10 +56,10 @@
UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
DTSTAMP:20240709T124304Z
CREATED:20240709T124304Z
-LAST-MODIFIED:20240709T124304Z
+LAST-MODIFIED:20240709T124305Z
DTSTART;TZID=Europe/Berlin:20240710T103000
DTEND;TZID=Europe/Berlin:20240710T113000
-SUMMARY:Test Meeting
+SUMMARY:Test Meeting 1
LOCATION:Berlin
SEQUENCE:0
TRANSP:OPAQUE
diff --git a/src/tests/data/mailfilter/itip1_request.eml b/src/tests/data/mailfilter/itip1_request_delegation.eml
copy from src/tests/data/mailfilter/itip1_request.eml
copy to src/tests/data/mailfilter/itip1_request_delegation.eml
--- a/src/tests/data/mailfilter/itip1_request.eml
+++ b/src/tests/data/mailfilter/itip1_request_delegation.eml
@@ -1,8 +1,8 @@
MIME-Version: 1.0
-From: Jack <jack@kolab.org>
+From: John <john@kolab.org>
Date: Tue, 09 Jul 2024 14:43:04 +0200
-Message-ID: <f49deee5182874694372696db14c90f2@kolab.org>
-To: john@kolab.org
+Message-ID: <f49eee5182874694372696db14c90f2@kolab.org>
+To: joe@kolab.org
Subject: You've been invited to "Test Meeting"
Content-Type: multipart/alternative;
boundary="=_f77327deb61c6eccadcf01b3f6f854cb"
@@ -56,17 +56,19 @@
UID:5463F1DDF6DA264A3FC70E7924B729A5-D9F1889254B163F5
DTSTAMP:20240709T124304Z
CREATED:20240709T124304Z
-LAST-MODIFIED:20240709T124304Z
+LAST-MODIFIED:20240709T124305Z
DTSTART;TZID=Europe/Berlin:20240710T103000
DTEND;TZID=Europe/Berlin:20240710T113000
-SUMMARY:Test Meeting
+SUMMARY:Test Meeting 1
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
+ATTENDEE;CN=John;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:joe@kolab.org","mailto:jeoe@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:john@kolab.org
+ATTENDEE;CN=Joe;PARTSTAT=NEEDS-ACTION;DELEGATED-FROM="mailto:john@kolab.org";ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:joe@kolab.org
+ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;
+ RSVP=TRUE:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
END:VEVENT
END:VCALENDAR
diff --git a/src/tests/data/mailfilter/itip2_request.eml b/src/tests/data/mailfilter/itip2_request.eml
--- a/src/tests/data/mailfilter/itip2_request.eml
+++ b/src/tests/data/mailfilter/itip2_request.eml
@@ -53,15 +53,16 @@
LAST-MODIFIED:20240709T124304Z
DTSTART;TZID=Europe/Berlin:20240717T123000
DTEND;TZID=Europe/Berlin:20240717T133000
-SUMMARY:Test Meeting
+SUMMARY:Test Meeting Ex
LOCATION:Berlin
-SEQUENCE:0
+SEQUENCE:1
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
+COMMENT:Ex
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
--- a/src/tests/data/mailfilter/itip3_request_rrule_update.eml
+++ b/src/tests/data/mailfilter/itip3_request_rrule_update.eml
@@ -63,6 +63,7 @@
ATTENDEE;CN=Ned;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=IND
IVIDUAL;RSVP=TRUE:mailto:ned@kolab.org
ORGANIZER;CN=Jack:mailto:jack@kolab.org
+COMMENT:Test comment
END:VEVENT
END:VCALENDAR
--=_f77327deb61c6eccadcf01b3f6f854cb--

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 12:03 AM (17 h, 52 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18824622
Default Alt Text
D5511.1775261005.diff (90 KB)

Event Timeline