From 88bca86c1e57389ce350944070921f23bcf30ae5 Mon Sep 17 00:00:00 2001 From: andreawoe Date: Mon, 24 Aug 2026 17:23:47 +0200 Subject: [PATCH] fix(calendar): send invitation emails client-side via iMIP --- lib/jmap/client.ts | 36 +++++++++++++++++++++++--------- stores/calendar-store.ts | 44 ++++++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index aa52e619..15f933db 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -3135,9 +3135,18 @@ export class JMAPClient implements IJMAPClient { 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 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 || ''; // Resolve identity @@ -3152,7 +3161,7 @@ export class JMAPClient implements IJMAPClient { } // 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; 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}`); for (const attendee of attendees) { - const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); + const email = participantEmail(attendee); if (!email) continue; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const partstat = attendee.participationStatus @@ -3228,7 +3237,7 @@ export class JMAPClient implements IJMAPClient { const subject = `Invitation: ${event.title || 'Event'}`; 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); if (toAddresses.length === 0) return; @@ -3313,8 +3322,15 @@ export class JMAPClient implements IJMAPClient { 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 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 identityResponse = await this.request([ @@ -3327,7 +3343,7 @@ export class JMAPClient implements IJMAPClient { 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; 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}`); for (const attendee of attendees) { - const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); + const email = participantEmail(attendee); if (!email) continue; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; lines.push(`ATTENDEE${cn}:mailto:${email}`); @@ -3382,7 +3398,7 @@ export class JMAPClient implements IJMAPClient { const subject = `Cancelled: ${event.title || 'Event'}`; 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); if (toAddresses.length === 0) return; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 6529a6f0..7a7ed4c3 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -487,9 +487,16 @@ export const useCalendarStore = create()( } set((state) => ({ events: [...state.events, mappedCreated] })); - // Invitation emails are sent by the server: `sendSchedulingMessages` - // on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. - // Sending a client-side iMIP copy here produced duplicate emails. + // Send invitation emails (iTIP REQUEST) to participants. Stalwart + // 0.16 does not reliably queue these server-side via + // `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; } catch (error) { debug.error('Failed to create event:', error); @@ -559,9 +566,23 @@ export const useCalendarStore = create()( return merged; }), })); - // Update emails (iTIP REQUEST/REPLY) are sent by the server via the - // `sendSchedulingMessages` argument already passed above - a manual - // iMIP send here produced duplicate emails. + // Send invitation emails (iTIP REQUEST) when scheduling is requested. + if (sendSchedulingMessages) { + 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) { debug.error('Failed to update event:', error); if (isNetworkError(error)) { @@ -808,9 +829,6 @@ export const useCalendarStore = create()( try { // Resolve shared event IDs and client-side expanded occurrence IDs 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', { storeId: id, realId, @@ -823,6 +841,14 @@ export const useCalendarStore = create()( events: state.events.filter(e => e.id !== id), 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) { debug.error('Failed to delete event:', error); if (isNetworkError(error)) {