fix: enhance calendar event handling with IMIP invitation and cancellation support
This commit is contained in:
+287
-3
@@ -1448,11 +1448,11 @@ export class JMAPClient {
|
|||||||
"text": { value: body },
|
"text": { value: body },
|
||||||
"html": { value: htmlBody },
|
"html": { value: htmlBody },
|
||||||
};
|
};
|
||||||
emailCreate.textBody = [{ partId: "text" }];
|
emailCreate.textBody = [{ partId: "text", type: "text/plain" }];
|
||||||
emailCreate.htmlBody = [{ partId: "html" }];
|
emailCreate.htmlBody = [{ partId: "html", type: "text/html" }];
|
||||||
} else {
|
} else {
|
||||||
emailCreate.bodyValues = { "1": { value: body } };
|
emailCreate.bodyValues = { "1": { value: body } };
|
||||||
emailCreate.textBody = [{ partId: "1" }];
|
emailCreate.textBody = [{ partId: "1", type: "text/plain" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachments?.length) {
|
if (attachments?.length) {
|
||||||
@@ -1683,6 +1683,290 @@ export class JMAPClient {
|
|||||||
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
|
||||||
|
* Used when creating or updating an event with participants.
|
||||||
|
*/
|
||||||
|
async sendImipInvitation(event: CalendarEvent): Promise<void> {
|
||||||
|
if (!event.participants) return;
|
||||||
|
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (!sentMailbox) {
|
||||||
|
throw new Error('No sent mailbox found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the organizer participant
|
||||||
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
||||||
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
||||||
|
const organizerName = organizerEntry?.name || '';
|
||||||
|
|
||||||
|
// Resolve identity
|
||||||
|
const identityResponse = await this.request([
|
||||||
|
["Identity/get", { accountId: this.accountId }, "0"]
|
||||||
|
]);
|
||||||
|
let identityId = this.accountId;
|
||||||
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||||
|
const match = identities.find((id) => id.email === organizerEmail);
|
||||||
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect attendee participants (non-organizer)
|
||||||
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
||||||
|
if (attendees.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
|
||||||
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
||||||
|
if (dateStr.endsWith('Z')) {
|
||||||
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
if (tz) return `TZID=${tz}:${basic}`;
|
||||||
|
return basic;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'PRODID:-//JMAP-Webmail//EN',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:REQUEST',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${event.uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.start) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.utcEnd) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||||
|
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
|
||||||
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||||
|
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
|
||||||
|
|
||||||
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||||
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||||
|
if (!email) continue;
|
||||||
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||||
|
const partstat = attendee.participationStatus
|
||||||
|
? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}`
|
||||||
|
: ';PARTSTAT=NEEDS-ACTION';
|
||||||
|
const rsvp = attendee.expectReply ? ';RSVP=TRUE' : '';
|
||||||
|
lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
|
const subject = `Invitation: ${event.title || 'Event'}`;
|
||||||
|
const toAddresses = attendees
|
||||||
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
||||||
|
.filter(a => a.email);
|
||||||
|
|
||||||
|
if (toAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const emailId = `imip-invite-${Date.now()}`;
|
||||||
|
const emailCreate: Record<string, unknown> = {
|
||||||
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
||||||
|
to: toAddresses,
|
||||||
|
subject,
|
||||||
|
keywords: { "$seen": true },
|
||||||
|
mailboxIds: { [sentMailbox.id]: true },
|
||||||
|
bodyStructure: {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: [
|
||||||
|
{ partId: 'text', type: 'text/plain' },
|
||||||
|
{ partId: 'cal', type: 'text/calendar; method=REQUEST' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bodyValues: {
|
||||||
|
text: { value: `You have been invited to: ${event.title || 'Event'}` },
|
||||||
|
cal: { value: icsContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [
|
||||||
|
["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailCreate },
|
||||||
|
}, "0"],
|
||||||
|
["EmailSubmission/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
|
}, "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
if (response.methodResponses) {
|
||||||
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
|
if (methodName.endsWith('/error')) {
|
||||||
|
throw new Error(result.description || `iMIP invitation failed: ${result.type}`);
|
||||||
|
}
|
||||||
|
if (result.notCreated) {
|
||||||
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||||
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP invitation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
|
||||||
|
* Used when deleting an event that has participants.
|
||||||
|
*/
|
||||||
|
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
||||||
|
if (!event.participants) return;
|
||||||
|
|
||||||
|
const mailboxes = await this.getMailboxes();
|
||||||
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||||||
|
if (!sentMailbox) {
|
||||||
|
throw new Error('No sent mailbox found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
||||||
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
||||||
|
const organizerName = organizerEntry?.name || '';
|
||||||
|
|
||||||
|
const identityResponse = await this.request([
|
||||||
|
["Identity/get", { accountId: this.accountId }, "0"]
|
||||||
|
]);
|
||||||
|
let identityId = this.accountId;
|
||||||
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||||||
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||||||
|
const match = identities.find((id) => id.email === organizerEmail);
|
||||||
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
||||||
|
if (attendees.length === 0) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
|
||||||
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
||||||
|
if (dateStr.endsWith('Z')) {
|
||||||
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
if (tz) return `TZID=${tz}:${basic}`;
|
||||||
|
return basic;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'PRODID:-//JMAP-Webmail//EN',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:CANCEL',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
`UID:${event.uid}`,
|
||||||
|
`DTSTAMP:${now}`,
|
||||||
|
`STATUS:CANCELLED`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.start) {
|
||||||
|
if (event.showWithoutTime) {
|
||||||
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
||||||
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||||||
|
} else {
|
||||||
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
||||||
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
||||||
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
||||||
|
|
||||||
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
||||||
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
||||||
|
if (!email) continue;
|
||||||
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
||||||
|
lines.push(`ATTENDEE${cn}:mailto:${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
const icsContent = lines.join('\r\n') + '\r\n';
|
||||||
|
|
||||||
|
const subject = `Cancelled: ${event.title || 'Event'}`;
|
||||||
|
const toAddresses = attendees
|
||||||
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
||||||
|
.filter(a => a.email);
|
||||||
|
|
||||||
|
if (toAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const emailId = `imip-cancel-${Date.now()}`;
|
||||||
|
const emailCreate: Record<string, unknown> = {
|
||||||
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
||||||
|
to: toAddresses,
|
||||||
|
subject,
|
||||||
|
keywords: { "$seen": true },
|
||||||
|
mailboxIds: { [sentMailbox.id]: true },
|
||||||
|
bodyStructure: {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: [
|
||||||
|
{ partId: 'text', type: 'text/plain' },
|
||||||
|
{ partId: 'cal', type: 'text/calendar; method=CANCEL' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bodyValues: {
|
||||||
|
text: { value: `The event "${event.title || 'Event'}" has been cancelled.` },
|
||||||
|
cal: { value: icsContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodCalls: JMAPMethodCall[] = [
|
||||||
|
["Email/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { [emailId]: emailCreate },
|
||||||
|
}, "0"],
|
||||||
|
["EmailSubmission/set", {
|
||||||
|
accountId: this.accountId,
|
||||||
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
||||||
|
}, "1"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await this.request(methodCalls);
|
||||||
|
|
||||||
|
if (response.methodResponses) {
|
||||||
|
for (const [methodName, result] of response.methodResponses) {
|
||||||
|
if (methodName.endsWith('/error')) {
|
||||||
|
throw new Error(result.description || `iMIP cancellation failed: ${result.type}`);
|
||||||
|
}
|
||||||
|
if (result.notCreated) {
|
||||||
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||||||
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP cancellation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||||
if (!this.session) {
|
if (!this.session) {
|
||||||
throw new Error('Not connected. Call connect() first.');
|
throw new Error('Not connected. Call connect() first.');
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
const created = await client.createCalendarEvent(event, sendSchedulingMessages);
|
const created = await client.createCalendarEvent(event, sendSchedulingMessages);
|
||||||
set((state) => ({ events: [...state.events, created] }));
|
set((state) => ({ events: [...state.events, created] }));
|
||||||
|
if (sendSchedulingMessages && created.participants) {
|
||||||
|
try {
|
||||||
|
await client.sendImipInvitation(created);
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send invitation emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
return created;
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to create event:', error);
|
debug.error('Failed to create event:', error);
|
||||||
@@ -141,6 +148,16 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
|
||||||
}));
|
}));
|
||||||
|
if (sendSchedulingMessages) {
|
||||||
|
try {
|
||||||
|
const updatedEvent = await client.getCalendarEvent(id);
|
||||||
|
if (updatedEvent?.participants) {
|
||||||
|
await client.sendImipInvitation(updatedEvent);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send update notification emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Failed to update event:', error);
|
debug.error('Failed to update event:', error);
|
||||||
set({ error: 'Failed to update event' });
|
set({ error: 'Failed to update event' });
|
||||||
@@ -296,6 +313,16 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
deleteEvent: async (client, id, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
|
if (sendSchedulingMessages) {
|
||||||
|
try {
|
||||||
|
const event = await client.getCalendarEvent(id);
|
||||||
|
if (event?.participants) {
|
||||||
|
await client.sendImipCancellation(event);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debug.error('Failed to send cancellation emails:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
await client.deleteCalendarEvent(id, sendSchedulingMessages);
|
await client.deleteCalendarEvent(id, sendSchedulingMessages);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
events: state.events.filter(e => e.id !== id),
|
events: state.events.filter(e => e.id !== id),
|
||||||
|
|||||||
Reference in New Issue
Block a user