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 { 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;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user