Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117794225
D5511.1775261005.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
90 KB
Referenced Files
None
Subscribers
None
D5511.1775261005.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5511: iTip spoofing protection
Attached
Detach File
Event Timeline