From 8969338b2a8a759a5c61f782bfb2a58c8996ceb7 Mon Sep 17 00:00:00 2001 From: nesgarbo Date: Thu, 16 Apr 2026 12:55:41 +0200 Subject: [PATCH] fix: RFC 5545/6047 compliance for outgoing iMIP calendar emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues addressed in sendImipReply, sendImipInvitation and sendImipCancellation: 1. Line folding (RFC 5545 §3.1) Add foldIcsLine() helper that wraps iCalendar content lines at 74 characters, inserting CRLF + SPACE as required by the spec. Previously, long lines (e.g. ATTENDEE with a full CN and mailto URI) could exceed the 75-octet limit and cause strict parsers to silently reject the ICS. 2. MIME wrapper type (RFC 6047 §3 + CalConnect iMIP Best Practices) Change bodyStructure from multipart/alternative to multipart/mixed. The CalConnect interoperability guide recommends multipart/mixed as the outer wrapper for messages carrying a text/calendar part; many clients skip iTIP processing when they see multipart/alternative. 3. Calendar part metadata Add charset=UTF-8 to the text/calendar Content-Type, disposition inline, and a descriptive filename (reply.ics / invite.ics / cancel.ics) to each outgoing calendar MIME part. Note: Gmail-to-Gmail events are handled by Google's internal scheduling API and cannot be updated via iMIP regardless of MIME structure. This fix improves interoperability with Outlook, Thunderbird, Fastmail and standard CalDAV servers. --- lib/jmap/client.ts | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f07aa5c1..aca37b16 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -274,6 +274,25 @@ function computeHasMore(position: number, emailCount: number, total: number, lim return emailCount === limit; } +/** + * Fold a single iCalendar content line per RFC 5545 §3.1. + * Lines longer than 75 octets MUST be split with CRLF + a single linear white space character. + * We fold at 74 characters to leave room for the leading space on continuation lines. + * @see https://www.rfc-editor.org/rfc/rfc5545#section-3.1 + */ +function foldIcsLine(line: string): string { + const MAX = 74; + if (line.length <= MAX) return line; + const chunks: string[] = []; + chunks.push(line.slice(0, MAX)); + let pos = MAX; + while (pos < line.length) { + chunks.push(' ' + line.slice(pos, pos + MAX - 1)); + pos += MAX - 1; + } + return chunks.join('\r\n'); +} + export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; @@ -2017,7 +2036,7 @@ export class JMAPClient implements IJMAPClient { lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`); lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); - const icsContent = lines.join('\r\n') + '\r\n'; + const icsContent = lines.map(foldIcsLine).join('\r\n') + '\r\n'; debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent); @@ -2039,10 +2058,16 @@ export class JMAPClient implements IJMAPClient { keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { - type: 'multipart/alternative', + // RFC 6047 §3 requires multipart/mixed when a text/calendar part is present. + // Using multipart/alternative causes most clients to ignore the iTIP method. + // @see https://www.rfc-editor.org/rfc/rfc6047#section-3 + // @see https://devguide.calconnect.org/iMIP/iMIPBest-Practices/ + // Note: Gmail-to-Gmail events use Google's internal scheduling API, not iMIP. + // This fix targets non-Gmail organizers and external CalDAV servers. + type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, - { partId: 'cal', type: 'text/calendar; method=REPLY' }, + { partId: 'cal', type: 'text/calendar; method=REPLY; charset=UTF-8', disposition: 'inline', name: 'reply.ics' }, ], }, bodyValues: { @@ -2195,7 +2220,7 @@ export class JMAPClient implements IJMAPClient { lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); - const icsContent = lines.join('\r\n') + '\r\n'; + const icsContent = lines.map(foldIcsLine).join('\r\n') + '\r\n'; const subject = `Invitation: ${event.title || 'Event'}`; const toAddresses = attendees @@ -2212,10 +2237,11 @@ export class JMAPClient implements IJMAPClient { keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { - type: 'multipart/alternative', + // See RFC 6047 §3: https://www.rfc-editor.org/rfc/rfc6047#section-3 + type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, - { partId: 'cal', type: 'text/calendar; method=REQUEST' }, + { partId: 'cal', type: 'text/calendar; method=REQUEST; charset=UTF-8', disposition: 'inline', name: 'invite.ics' }, ], }, bodyValues: { @@ -2342,7 +2368,7 @@ export class JMAPClient implements IJMAPClient { lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); - const icsContent = lines.join('\r\n') + '\r\n'; + const icsContent = lines.map(foldIcsLine).join('\r\n') + '\r\n'; const subject = `Cancelled: ${event.title || 'Event'}`; const toAddresses = attendees @@ -2359,10 +2385,11 @@ export class JMAPClient implements IJMAPClient { keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { - type: 'multipart/alternative', + // See RFC 6047 §3: https://www.rfc-editor.org/rfc/rfc6047#section-3 + type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, - { partId: 'cal', type: 'text/calendar; method=CANCEL' }, + { partId: 'cal', type: 'text/calendar; method=CANCEL; charset=UTF-8', disposition: 'inline', name: 'cancel.ics' }, ], }, bodyValues: {