diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index 2407d82fe3..8c5caaddde 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -305,11 +305,13 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM */ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): void { // iMIP messages - // Handle text/calendar parts first because they might be attachments at the same time. - // Otherwise, some of the following if-conditions might break the handling and treat iMIP + // Handle text/calendar and application/ics parts first because they + // might be attachments at the same time. Otherwise, some of the + // following if-conditions might break the handling and treat iMIP // data like regular attachments. $allContentTypeParameters = $p->getAllContentTypeParameters(); - if ($p->getType() === 'text/calendar') { + if ($p->getType() === 'text/calendar' + || $p->getType() === 'application/ics') { // Handle event data like a regular attachment // Outlook doesn't set a content disposition // We work around this by checking for the name only @@ -325,18 +327,29 @@ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): v ]; } - // return if this is an event attachment only - // the method parameter determines if this is a iMIP message - if (!isset($allContentTypeParameters['method'])) { + // Try the Content-Type method= parameter first — that's the common + // case. If it is missing, fall back to the METHOD: line inside + // the ICS body: Proton Mail Bridge strips the parameter during + // E2E re-assembly, and requiring it here would mean every + // Proton-Bridge user silently loses inbound invitations. + $method = $allContentTypeParameters['method'] ?? null; + $contents = null; + if ($method === null) { + $contents = $this->loadBodyData($p, $partNo, $isFetched); + if (preg_match('/^METHOD:\s*(\S+)/mi', $contents, $m)) { + $method = $m[1]; + } + } + if ($method === null) { return; } - if (in_array(strtoupper($allContentTypeParameters['method']), ['REQUEST', 'REPLY', 'CANCEL'])) { + if (in_array(strtoupper($method), ['REQUEST', 'REPLY', 'CANCEL'])) { $this->scheduling[] = [ 'id' => $p->getMimeId(), 'messageId' => $this->uid, - 'method' => strtoupper($allContentTypeParameters['method']), - 'contents' => $this->loadBodyData($p, $partNo, $isFetched), + 'method' => strtoupper($method), + 'contents' => $contents ?? $this->loadBodyData($p, $partNo, $isFetched), ]; return; } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index f63c28f9b6..266916a254 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -908,10 +908,17 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $hasAttachments = true; } - if ($part->getType() === 'text/calendar') { - if ($part->getContentTypeParameter('method') !== null) { - $isImipMessage = true; - } + // Flag the message as iMIP when we see a text/calendar or + // application/ics part. Proton Mail Bridge is known to strip the + // `method=` parameter from the Content-Type header while + // re-assembling a message after E2E-decryption, so requiring the + // parameter here would drop every Bridge-delivered invitation. + // The actual method (REQUEST/REPLY/CANCEL) is parsed out of the + // ICS body downstream in ImapMessageFetcher::getPart() — see the + // matching fallback there. + if ($part->getType() === 'text/calendar' + || $part->getType() === 'application/ics') { + $isImipMessage = true; } } diff --git a/tests/Unit/IMAP/MessageMapperTest.php b/tests/Unit/IMAP/MessageMapperTest.php index 864bcca156..0722dea9ba 100644 --- a/tests/Unit/IMAP/MessageMapperTest.php +++ b/tests/Unit/IMAP/MessageMapperTest.php @@ -749,6 +749,16 @@ public function isImipMessageProvider(): array { return [ 'google request' => ['request_google', true], 'outlook.com request' => ['request_outlook_com', true], + // Proton Mail Bridge strips the `method=` parameter from the + // text/calendar Content-Type header during E2E re-assembly. The + // message is still an iMIP REQUEST — the method lives in the ICS + // body. Must still be flagged as iMIP so downstream processing + // picks it up. + 'proton bridge request (method= stripped)' => ['request_proton_bridge', true], + // Google occasionally attaches the same invitation twice: once as + // text/calendar and once as application/ics. We used to ignore the + // latter even though it is functionally equivalent. + 'application/ics request' => ['request_application_ics', true], ]; } diff --git a/tests/data/imip/request_application_ics.txt b/tests/data/imip/request_application_ics.txt new file mode 100644 index 0000000000..e3b6c8a18f --- /dev/null +++ b/tests/data/imip/request_application_ics.txt @@ -0,0 +1,74 @@ +MIME-Version: 1.0 +Message-ID: +Date: Thu, 06 Feb 2025 16:21:41 +0000 +From: alice@example.org +To: bob@example.org, john@example.org +Content-Type: multipart/mixed; boundary="f2d8330e8efc4039bd8073c70ec6cf24" +Subject: Invitation: Imip Testing @ Thu Feb 20, 2025 7pm - 8pm + (CET) (alice@example.org) + +--f2d8330e8efc4039bd8073c70ec6cf24 +Content-Type: multipart/alternative; boundary="f55a70234308486098b936b62a6d5e33" + +--f55a70234308486098b936b62a6d5e33 +Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes +Content-Transfer-Encoding: base64 + +SW1pcCBUZXN0aW5nICh0ZXh0L3BsYWluKQo= +--f55a70234308486098b936b62a6d5e33 +Content-Type: application/ics; name="invite.ics" +Content-Transfer-Encoding: 7bit + +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:GMT+2 +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:GMT+1 +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20250220T190000 +DTEND;TZID=Europe/Berlin:20250220T200000 +DTSTAMP:20250206T162141Z +ORGANIZER;CN=alice@example.org:mailto:alice@example.org +UID:69d4c40b4a274636bf23517938df9673@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP= + TRUE;CN=john@example.org;X-NUM-GUESTS=0:mailto:john@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE + ;CN=alice@example.org;X-NUM-GUESTS=0:mailto:alice@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP= + TRUE;CN=bob@example.org;X-NUM-GUESTS=0:mailto:bob@example.org +CREATED:20250206T162140Z +DESCRIPTION: +LAST-MODIFIED:20250206T162140Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Imip Testing +TRANSP:OPAQUE +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P0DT0H30M0S +END:VALARM +END:VEVENT +END:VCALENDAR + +--f55a70234308486098b936b62a6d5e33-- +--f2d8330e8efc4039bd8073c70ec6cf24-- diff --git a/tests/data/imip/request_proton_bridge.txt b/tests/data/imip/request_proton_bridge.txt new file mode 100644 index 0000000000..e5f1497662 --- /dev/null +++ b/tests/data/imip/request_proton_bridge.txt @@ -0,0 +1,74 @@ +MIME-Version: 1.0 +Message-ID: +Date: Thu, 06 Feb 2025 16:21:41 +0000 +From: alice@example.org +To: bob@example.org, john@example.org +Content-Type: multipart/mixed; boundary="f2d8330e8efc4039bd8073c70ec6cf24" +Subject: Invitation: Imip Testing @ Thu Feb 20, 2025 7pm - 8pm + (CET) (alice@example.org) + +--f2d8330e8efc4039bd8073c70ec6cf24 +Content-Type: multipart/alternative; boundary="f55a70234308486098b936b62a6d5e33" + +--f55a70234308486098b936b62a6d5e33 +Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes +Content-Transfer-Encoding: base64 + +SW1pcCBUZXN0aW5nICh0ZXh0L3BsYWluKQo= +--f55a70234308486098b936b62a6d5e33 +Content-Type: text/calendar; charset="UTF-8" +Content-Transfer-Encoding: 7bit + +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:GMT+2 +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:GMT+1 +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20250220T190000 +DTEND;TZID=Europe/Berlin:20250220T200000 +DTSTAMP:20250206T162141Z +ORGANIZER;CN=alice@example.org:mailto:alice@example.org +UID:69d4c40b4a274636bf23517938df9673@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP= + TRUE;CN=john@example.org;X-NUM-GUESTS=0:mailto:john@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE + ;CN=alice@example.org;X-NUM-GUESTS=0:mailto:alice@example.org +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP= + TRUE;CN=bob@example.org;X-NUM-GUESTS=0:mailto:bob@example.org +CREATED:20250206T162140Z +DESCRIPTION: +LAST-MODIFIED:20250206T162140Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Imip Testing +TRANSP:OPAQUE +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:This is an event reminder +TRIGGER:-P0DT0H30M0S +END:VALARM +END:VEVENT +END:VCALENDAR + +--f55a70234308486098b936b62a6d5e33-- +--f2d8330e8efc4039bd8073c70ec6cf24--