fix: RFC 5545/6047 compliance for outgoing iMIP calendar emails

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.
This commit is contained in:
nesgarbo
2026-04-16 16:54:09 +02:00
committed by Linus Rath
parent 4c720d6855
commit 8969338b2a
+36 -9
View File
@@ -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: {