From 7c155287c4213004ef87c883a5b5fd1c332876ae Mon Sep 17 00:00:00 2001 From: Wolfgang Lubowski Date: Wed, 22 Apr 2026 21:02:02 +0200 Subject: [PATCH 1/3] fix(imap): detect iMIP when method= is missing or mime is application/ics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two upstream-detection gaps let calendar invitations slip through: 1. Proton Mail Bridge strips the `method=` parameter from the text/calendar Content-Type header while re-assembling a message after E2E-decryption. `MessageMapper::getBodyStructureData()` requires that parameter to flag the message as iMIP, and `ImapMessageFetcher::getPart()` requires it to populate the scheduling list — so every Bridge-delivered invitation is silently dropped. 2. Google's calendar invitations occasionally ship the same event twice: once as text/calendar and once as application/ics. Upstream ignored the application/ics part entirely, which means a mail client fed the "wrong" part (some MUAs prefer the second attachment) saw nothing. This commit widens both detection sites to also accept application/ics, and in the fetcher falls back to the METHOD: line inside the ICS body when the Content-Type parameter is missing. The body is only loaded in the fallback path, so the common case (method= present) keeps the same I/O pattern it had before. Added two parameterised test fixtures: - request_proton_bridge.txt (text/calendar, no method= param) - request_application_ics.txt (application/ics) Both are expected to now be flagged as iMIP. AI-assisted: Claude Code (Claude Opus 4.7) Signed-off-by: Wolfgang Lubowski --- lib/IMAP/ImapMessageFetcher.php | 31 ++++++--- lib/IMAP/MessageMapper.php | 15 +++-- tests/Unit/IMAP/MessageMapperTest.php | 10 +++ tests/data/imip/request_application_ics.txt | 74 +++++++++++++++++++++ tests/data/imip/request_proton_bridge.txt | 74 +++++++++++++++++++++ 5 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 tests/data/imip/request_application_ics.txt create mode 100644 tests/data/imip/request_proton_bridge.txt 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-- From e4990e65a872a94bdcb522c41f51ad87fd65a6ea Mon Sep 17 00:00:00 2001 From: Wolfgang Lubowski Date: Wed, 13 May 2026 13:27:56 +0200 Subject: [PATCH 2/3] fixup! fix(imap): detect iMIP when method= is missing or mime is application/ics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from kesselb: - drop application/ics handling in both detection sites (Thunderbird's stance — re-evaluate if a real-world provider that only ships application/ics shows up) - tighten METHOD regex to /^METHOD:([A-Z]+)/mi with === 1 check (matches the iCalendar grammar: METHOD value is bare token, no whitespace allowed between colon and value per RFC 5545) - remove the now-obsolete application/ics fixture + test row NOTE for squash: original commit body still mentions application/ics support. When squashing, please drop that section so the message only covers the Proton Bridge / METHOD-from-body fix. AI-assisted: Claude Code (Claude Opus 4.7) Signed-off-by: Wolfgang Lubowski --- lib/IMAP/ImapMessageFetcher.php | 10 ++- lib/IMAP/MessageMapper.php | 17 +++-- tests/Unit/IMAP/MessageMapperTest.php | 4 -- tests/data/imip/request_application_ics.txt | 74 --------------------- 4 files changed, 12 insertions(+), 93 deletions(-) delete mode 100644 tests/data/imip/request_application_ics.txt diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index 8c5caaddde..76e8cc4316 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -305,13 +305,11 @@ 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 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 + // 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 // data like regular attachments. $allContentTypeParameters = $p->getAllContentTypeParameters(); - if ($p->getType() === 'text/calendar' - || $p->getType() === 'application/ics') { + if ($p->getType() === 'text/calendar') { // Handle event data like a regular attachment // Outlook doesn't set a content disposition // We work around this by checking for the name only @@ -336,7 +334,7 @@ private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): v $contents = null; if ($method === null) { $contents = $this->loadBodyData($p, $partNo, $isFetched); - if (preg_match('/^METHOD:\s*(\S+)/mi', $contents, $m)) { + if (preg_match('/^METHOD:([A-Z]+)/mi', $contents, $m) === 1) { $method = $m[1]; } } diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index 266916a254..4a57c66e62 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -908,16 +908,15 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client, $hasAttachments = 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 + // Flag the message as iMIP when we see a text/calendar 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') { + if ($part->getType() === 'text/calendar') { $isImipMessage = true; } } diff --git a/tests/Unit/IMAP/MessageMapperTest.php b/tests/Unit/IMAP/MessageMapperTest.php index 0722dea9ba..3b3679a3ed 100644 --- a/tests/Unit/IMAP/MessageMapperTest.php +++ b/tests/Unit/IMAP/MessageMapperTest.php @@ -755,10 +755,6 @@ public function isImipMessageProvider(): array { // 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 deleted file mode 100644 index e3b6c8a18f..0000000000 --- a/tests/data/imip/request_application_ics.txt +++ /dev/null @@ -1,74 +0,0 @@ -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-- From 404bb931a54bf2c7fbb4d82d4475403cc36ca896 Mon Sep 17 00:00:00 2001 From: Wolfgang Lubowski Date: Wed, 13 May 2026 18:11:05 +0200 Subject: [PATCH 3/3] fixup! fix(imap): detect iMIP when method= is missing or mime is application/ics Add a regression test exercising ImapMessageFetcher::getPart()'s METHOD-from-body fallback (which is the new branch introduced by this PR). Uses the existing request_proton_bridge fixture and the same saveMimeMessage / fetchMessage pattern as the surrounding S/MIME tests, so it goes through the real Horde+IMAP stack instead of just parsing the MIME tree like the MessageMapperTest case does. Asserts that the scheduling entry is emitted, its method is REQUEST, and the contents we stash contain the METHOD: line we parsed. AI-assisted: Claude Code (Claude Opus 4.7) Signed-off-by: Wolfgang Lubowski --- .../ImapMessageFetcherIntegrationTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Integration/IMAP/ImapMessageFetcherIntegrationTest.php b/tests/Integration/IMAP/ImapMessageFetcherIntegrationTest.php index 5dfa9e7c0f..b0c75582eb 100644 --- a/tests/Integration/IMAP/ImapMessageFetcherIntegrationTest.php +++ b/tests/Integration/IMAP/ImapMessageFetcherIntegrationTest.php @@ -187,4 +187,29 @@ public function testFetchMessageWithOpaqueSignedMessage(): void { $this->assertTrue($message->isSigned()); $this->assertTrue($message->isSignatureValid()); } + + /** + * Proton Mail Bridge strips the `method=` parameter from the + * text/calendar Content-Type during E2E re-assembly. The fetcher must + * fall back to parsing the METHOD: line out of the ICS body, and + * still emit a scheduling entry so downstream rendering picks it up. + */ + public function testFetchMessageWithImipRequestMissingMethodParam(): void { + $rawMessage = file_get_contents(__DIR__ . '/../../data/imip/request_proton_bridge.txt'); + $uid = $this->saveMimeMessage('INBOX', $rawMessage); + $fetcher = $this->fetcherFactory + ->build( + $uid, + 'INBOX', + $this->getTestClient(), + $this->account->getUserId() + ) + ->withBody(true); + + $message = $fetcher->fetchMessage(); + + $this->assertCount(1, $message->scheduling); + $this->assertSame('REQUEST', $message->scheduling[0]['method']); + $this->assertStringContainsString('METHOD:REQUEST', $message->scheduling[0]['contents']); + } }