fix(calendar): send invitation emails client-side via iMIP
Publish Docker Image / prepare (push) Successful in 7s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 7s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s

This commit is contained in:
2026-08-24 17:23:47 +02:00
parent 60fde141b5
commit 88bca86c1e
2 changed files with 61 additions and 19 deletions
+26 -10
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarParticipant, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface"; import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
@@ -3135,9 +3135,18 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found'); throw new Error('No drafts mailbox found');
} }
// Find the organizer participant // Find the organizer participant. Stalwart may not echo `roles.owner`, so
// also fall back to the event-level organizerCalendarAddress, and derive
// participant emails from email / calendarAddress / sendTo.imip.
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || ''; const organizerName = organizerEntry?.name || '';
// Resolve identity // Resolve identity
@@ -3152,7 +3161,7 @@ export class JMAPClient implements IJMAPClient {
} }
// Collect attendee participants (non-organizer) // Collect attendee participants (non-organizer)
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner); const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return; if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3212,7 +3221,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) { for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); const email = participantEmail(attendee);
if (!email) continue; if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
const partstat = attendee.participationStatus const partstat = attendee.participationStatus
@@ -3228,7 +3237,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Invitation: ${event.title || 'Event'}`; const subject = `Invitation: ${event.title || 'Event'}`;
const toAddresses = attendees const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' })) .map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email); .filter(a => a.email);
if (toAddresses.length === 0) return; if (toAddresses.length === 0) return;
@@ -3313,8 +3322,15 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found'); throw new Error('No drafts mailbox found');
} }
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || ''; const organizerName = organizerEntry?.name || '';
const identityResponse = await this.request([ const identityResponse = await this.request([
@@ -3327,7 +3343,7 @@ export class JMAPClient implements IJMAPClient {
identityId = match?.id || identities[0]?.id || this.accountId; identityId = match?.id || identities[0]?.id || this.accountId;
} }
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner); const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return; if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3370,7 +3386,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) { for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); const email = participantEmail(attendee);
if (!email) continue; if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
lines.push(`ATTENDEE${cn}:mailto:${email}`); lines.push(`ATTENDEE${cn}:mailto:${email}`);
@@ -3382,7 +3398,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Cancelled: ${event.title || 'Event'}`; const subject = `Cancelled: ${event.title || 'Event'}`;
const toAddresses = attendees const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' })) .map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email); .filter(a => a.email);
if (toAddresses.length === 0) return; if (toAddresses.length === 0) return;
+35 -9
View File
@@ -487,9 +487,16 @@ export const useCalendarStore = create<CalendarStore>()(
} }
set((state) => ({ events: [...state.events, mappedCreated] })); set((state) => ({ events: [...state.events, mappedCreated] }));
// Invitation emails are sent by the server: `sendSchedulingMessages` // Send invitation emails (iTIP REQUEST) to participants. Stalwart
// on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. // 0.16 does not reliably queue these server-side via
// Sending a client-side iMIP copy here produced duplicate emails. // `sendSchedulingMessages`, so fall back to a client-side iMIP send.
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
return mappedCreated; return mappedCreated;
} catch (error) { } catch (error) {
debug.error('Failed to create event:', error); debug.error('Failed to create event:', error);
@@ -559,9 +566,23 @@ export const useCalendarStore = create<CalendarStore>()(
return merged; return merged;
}), }),
})); }));
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the // Send invitation emails (iTIP REQUEST) when scheduling is requested.
// `sendSchedulingMessages` argument already passed above - a manual if (sendSchedulingMessages) {
// iMIP send here produced duplicate emails. const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants;
if (mergedParticipants) {
const eventForInvitation = {
...(storeEvent ?? {}),
...cleanUpdates,
id: realId,
participants: mergedParticipants,
} as CalendarEvent;
try {
await client.sendImipInvitation(eventForInvitation);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
}
} catch (error) { } catch (error) {
debug.error('Failed to update event:', error); debug.error('Failed to update event:', error);
if (isNetworkError(error)) { if (isNetworkError(error)) {
@@ -808,9 +829,6 @@ export const useCalendarStore = create<CalendarStore>()(
try { try {
// Resolve shared event IDs and client-side expanded occurrence IDs // Resolve shared event IDs and client-side expanded occurrence IDs
client = resolveAccountClient(client, storeEvent?.localAccountId); client = resolveAccountClient(client, storeEvent?.localAccountId);
// Cancellation emails (iTIP CANCEL) are sent by the server via the
// `sendSchedulingMessages` argument on the destroy below - a manual
// iMIP send here produced duplicate emails.
debug.log('calendar', 'Calendar deleteEvent', { debug.log('calendar', 'Calendar deleteEvent', {
storeId: id, storeId: id,
realId, realId,
@@ -823,6 +841,14 @@ export const useCalendarStore = create<CalendarStore>()(
events: state.events.filter(e => e.id !== id), events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
})); }));
// Send cancellation emails (iTIP CANCEL) to participants.
if (sendSchedulingMessages && storeEvent?.participants) {
try {
await client.sendImipCancellation(storeEvent);
} catch (e) {
debug.error('Failed to send cancellation emails:', e);
}
}
} catch (error) { } catch (error) {
debug.error('Failed to delete event:', error); debug.error('Failed to delete event:', error);
if (isNetworkError(error)) { if (isNetworkError(error)) {