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
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:
+26
-10
@@ -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;
|
||||
|
||||
@@ -487,9 +487,16 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
|
||||
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<CalendarStore>()(
|
||||
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<CalendarStore>()(
|
||||
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<CalendarStore>()(
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user