Skip to content

Commit 7c15528

Browse files
lubo92Wolfgang Lubowski
authored andcommitted
fix(imap): detect iMIP when method= is missing or mime is application/ics
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 <w.lub92@gmail.com>
1 parent de964d8 commit 7c15528

5 files changed

Lines changed: 191 additions & 13 deletions

File tree

lib/IMAP/ImapMessageFetcher.php

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,13 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM
305305
*/
306306
private function getPart(Horde_Mime_Part $p, string $partNo, bool $isFetched): void {
307307
// iMIP messages
308-
// Handle text/calendar parts first because they might be attachments at the same time.
309-
// Otherwise, some of the following if-conditions might break the handling and treat iMIP
308+
// Handle text/calendar and application/ics parts first because they
309+
// might be attachments at the same time. Otherwise, some of the
310+
// following if-conditions might break the handling and treat iMIP
310311
// data like regular attachments.
311312
$allContentTypeParameters = $p->getAllContentTypeParameters();
312-
if ($p->getType() === 'text/calendar') {
313+
if ($p->getType() === 'text/calendar'
314+
|| $p->getType() === 'application/ics') {
313315
// Handle event data like a regular attachment
314316
// Outlook doesn't set a content disposition
315317
// 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
325327
];
326328
}
327329

328-
// return if this is an event attachment only
329-
// the method parameter determines if this is a iMIP message
330-
if (!isset($allContentTypeParameters['method'])) {
330+
// Try the Content-Type method= parameter first — that's the common
331+
// case. If it is missing, fall back to the METHOD: line inside
332+
// the ICS body: Proton Mail Bridge strips the parameter during
333+
// E2E re-assembly, and requiring it here would mean every
334+
// Proton-Bridge user silently loses inbound invitations.
335+
$method = $allContentTypeParameters['method'] ?? null;
336+
$contents = null;
337+
if ($method === null) {
338+
$contents = $this->loadBodyData($p, $partNo, $isFetched);
339+
if (preg_match('/^METHOD:\s*(\S+)/mi', $contents, $m)) {
340+
$method = $m[1];
341+
}
342+
}
343+
if ($method === null) {
331344
return;
332345
}
333346

334-
if (in_array(strtoupper($allContentTypeParameters['method']), ['REQUEST', 'REPLY', 'CANCEL'])) {
347+
if (in_array(strtoupper($method), ['REQUEST', 'REPLY', 'CANCEL'])) {
335348
$this->scheduling[] = [
336349
'id' => $p->getMimeId(),
337350
'messageId' => $this->uid,
338-
'method' => strtoupper($allContentTypeParameters['method']),
339-
'contents' => $this->loadBodyData($p, $partNo, $isFetched),
351+
'method' => strtoupper($method),
352+
'contents' => $contents ?? $this->loadBodyData($p, $partNo, $isFetched),
340353
];
341354
return;
342355
}

lib/IMAP/MessageMapper.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -908,10 +908,17 @@ public function getBodyStructureData(Horde_Imap_Client_Socket $client,
908908
$hasAttachments = true;
909909
}
910910

911-
if ($part->getType() === 'text/calendar') {
912-
if ($part->getContentTypeParameter('method') !== null) {
913-
$isImipMessage = true;
914-
}
911+
// Flag the message as iMIP when we see a text/calendar or
912+
// application/ics part. Proton Mail Bridge is known to strip the
913+
// `method=` parameter from the Content-Type header while
914+
// re-assembling a message after E2E-decryption, so requiring the
915+
// parameter here would drop every Bridge-delivered invitation.
916+
// The actual method (REQUEST/REPLY/CANCEL) is parsed out of the
917+
// ICS body downstream in ImapMessageFetcher::getPart() — see the
918+
// matching fallback there.
919+
if ($part->getType() === 'text/calendar'
920+
|| $part->getType() === 'application/ics') {
921+
$isImipMessage = true;
915922
}
916923
}
917924

tests/Unit/IMAP/MessageMapperTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,16 @@ public function isImipMessageProvider(): array {
749749
return [
750750
'google request' => ['request_google', true],
751751
'outlook.com request' => ['request_outlook_com', true],
752+
// Proton Mail Bridge strips the `method=` parameter from the
753+
// text/calendar Content-Type header during E2E re-assembly. The
754+
// message is still an iMIP REQUEST — the method lives in the ICS
755+
// body. Must still be flagged as iMIP so downstream processing
756+
// picks it up.
757+
'proton bridge request (method= stripped)' => ['request_proton_bridge', true],
758+
// Google occasionally attaches the same invitation twice: once as
759+
// text/calendar and once as application/ics. We used to ignore the
760+
// latter even though it is functionally equivalent.
761+
'application/ics request' => ['request_application_ics', true],
752762
];
753763
}
754764

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
MIME-Version: 1.0
2+
Message-ID: <imip-testing-1234@example.org>
3+
Date: Thu, 06 Feb 2025 16:21:41 +0000
4+
From: alice@example.org
5+
To: bob@example.org, john@example.org
6+
Content-Type: multipart/mixed; boundary="f2d8330e8efc4039bd8073c70ec6cf24"
7+
Subject: Invitation: Imip Testing @ Thu Feb 20, 2025 7pm - 8pm
8+
(CET) (alice@example.org)
9+
10+
--f2d8330e8efc4039bd8073c70ec6cf24
11+
Content-Type: multipart/alternative; boundary="f55a70234308486098b936b62a6d5e33"
12+
13+
--f55a70234308486098b936b62a6d5e33
14+
Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
15+
Content-Transfer-Encoding: base64
16+
17+
SW1pcCBUZXN0aW5nICh0ZXh0L3BsYWluKQo=
18+
--f55a70234308486098b936b62a6d5e33
19+
Content-Type: application/ics; name="invite.ics"
20+
Content-Transfer-Encoding: 7bit
21+
22+
BEGIN:VCALENDAR
23+
PRODID:-//Google Inc//Google Calendar 70.9054//EN
24+
VERSION:2.0
25+
CALSCALE:GREGORIAN
26+
METHOD:REQUEST
27+
BEGIN:VTIMEZONE
28+
TZID:Europe/Berlin
29+
X-LIC-LOCATION:Europe/Berlin
30+
BEGIN:DAYLIGHT
31+
TZOFFSETFROM:+0100
32+
TZOFFSETTO:+0200
33+
TZNAME:GMT+2
34+
DTSTART:19700329T020000
35+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
36+
END:DAYLIGHT
37+
BEGIN:STANDARD
38+
TZOFFSETFROM:+0200
39+
TZOFFSETTO:+0100
40+
TZNAME:GMT+1
41+
DTSTART:19701025T030000
42+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
43+
END:STANDARD
44+
END:VTIMEZONE
45+
BEGIN:VEVENT
46+
DTSTART;TZID=Europe/Berlin:20250220T190000
47+
DTEND;TZID=Europe/Berlin:20250220T200000
48+
DTSTAMP:20250206T162141Z
49+
ORGANIZER;CN=alice@example.org:mailto:alice@example.org
50+
UID:69d4c40b4a274636bf23517938df9673@example.org
51+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
52+
TRUE;CN=john@example.org;X-NUM-GUESTS=0:mailto:john@example.org
53+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
54+
;CN=alice@example.org;X-NUM-GUESTS=0:mailto:alice@example.org
55+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
56+
TRUE;CN=bob@example.org;X-NUM-GUESTS=0:mailto:bob@example.org
57+
CREATED:20250206T162140Z
58+
DESCRIPTION:
59+
LAST-MODIFIED:20250206T162140Z
60+
LOCATION:
61+
SEQUENCE:0
62+
STATUS:CONFIRMED
63+
SUMMARY:Imip Testing
64+
TRANSP:OPAQUE
65+
BEGIN:VALARM
66+
ACTION:DISPLAY
67+
DESCRIPTION:This is an event reminder
68+
TRIGGER:-P0DT0H30M0S
69+
END:VALARM
70+
END:VEVENT
71+
END:VCALENDAR
72+
73+
--f55a70234308486098b936b62a6d5e33--
74+
--f2d8330e8efc4039bd8073c70ec6cf24--
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
MIME-Version: 1.0
2+
Message-ID: <imip-testing-1234@example.org>
3+
Date: Thu, 06 Feb 2025 16:21:41 +0000
4+
From: alice@example.org
5+
To: bob@example.org, john@example.org
6+
Content-Type: multipart/mixed; boundary="f2d8330e8efc4039bd8073c70ec6cf24"
7+
Subject: Invitation: Imip Testing @ Thu Feb 20, 2025 7pm - 8pm
8+
(CET) (alice@example.org)
9+
10+
--f2d8330e8efc4039bd8073c70ec6cf24
11+
Content-Type: multipart/alternative; boundary="f55a70234308486098b936b62a6d5e33"
12+
13+
--f55a70234308486098b936b62a6d5e33
14+
Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
15+
Content-Transfer-Encoding: base64
16+
17+
SW1pcCBUZXN0aW5nICh0ZXh0L3BsYWluKQo=
18+
--f55a70234308486098b936b62a6d5e33
19+
Content-Type: text/calendar; charset="UTF-8"
20+
Content-Transfer-Encoding: 7bit
21+
22+
BEGIN:VCALENDAR
23+
PRODID:-//Google Inc//Google Calendar 70.9054//EN
24+
VERSION:2.0
25+
CALSCALE:GREGORIAN
26+
METHOD:REQUEST
27+
BEGIN:VTIMEZONE
28+
TZID:Europe/Berlin
29+
X-LIC-LOCATION:Europe/Berlin
30+
BEGIN:DAYLIGHT
31+
TZOFFSETFROM:+0100
32+
TZOFFSETTO:+0200
33+
TZNAME:GMT+2
34+
DTSTART:19700329T020000
35+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
36+
END:DAYLIGHT
37+
BEGIN:STANDARD
38+
TZOFFSETFROM:+0200
39+
TZOFFSETTO:+0100
40+
TZNAME:GMT+1
41+
DTSTART:19701025T030000
42+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
43+
END:STANDARD
44+
END:VTIMEZONE
45+
BEGIN:VEVENT
46+
DTSTART;TZID=Europe/Berlin:20250220T190000
47+
DTEND;TZID=Europe/Berlin:20250220T200000
48+
DTSTAMP:20250206T162141Z
49+
ORGANIZER;CN=alice@example.org:mailto:alice@example.org
50+
UID:69d4c40b4a274636bf23517938df9673@example.org
51+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
52+
TRUE;CN=john@example.org;X-NUM-GUESTS=0:mailto:john@example.org
53+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
54+
;CN=alice@example.org;X-NUM-GUESTS=0:mailto:alice@example.org
55+
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
56+
TRUE;CN=bob@example.org;X-NUM-GUESTS=0:mailto:bob@example.org
57+
CREATED:20250206T162140Z
58+
DESCRIPTION:
59+
LAST-MODIFIED:20250206T162140Z
60+
LOCATION:
61+
SEQUENCE:0
62+
STATUS:CONFIRMED
63+
SUMMARY:Imip Testing
64+
TRANSP:OPAQUE
65+
BEGIN:VALARM
66+
ACTION:DISPLAY
67+
DESCRIPTION:This is an event reminder
68+
TRIGGER:-P0DT0H30M0S
69+
END:VALARM
70+
END:VEVENT
71+
END:VCALENDAR
72+
73+
--f55a70234308486098b936b62a6d5e33--
74+
--f2d8330e8efc4039bd8073c70ec6cf24--

0 commit comments

Comments
 (0)