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 { 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;