Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions lib/IMAP/ImapMessageFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,13 @@
*/
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
Expand All @@ -320,18 +322,30 @@
];
}

// 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 (is_string($contents)

Check failure on line 334 in lib/IMAP/ImapMessageFetcher.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

RedundantCondition

lib/IMAP/ImapMessageFetcher.php:334:9: RedundantCondition: Type string for $contents is always string (see https://psalm.dev/122)

Check failure on line 334 in lib/IMAP/ImapMessageFetcher.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable33

RedundantCondition

lib/IMAP/ImapMessageFetcher.php:334:9: RedundantCondition: Type string for $contents is always string (see https://psalm.dev/122)

Check failure on line 334 in lib/IMAP/ImapMessageFetcher.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable32

RedundantCondition

lib/IMAP/ImapMessageFetcher.php:334:9: RedundantCondition: Type string for $contents is always string (see https://psalm.dev/122)
&& 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;
}
Expand Down
15 changes: 11 additions & 4 deletions lib/IMAP/MessageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -916,10 +916,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;
}
}

Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/IMAP/MessageMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
];
}

Expand Down
74 changes: 74 additions & 0 deletions tests/data/imip/request_application_ics.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
MIME-Version: 1.0
Message-ID: <imip-testing-1234@example.org>
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--
74 changes: 74 additions & 0 deletions tests/data/imip/request_proton_bridge.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
MIME-Version: 1.0
Message-ID: <imip-testing-1234@example.org>
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--
Loading