fix: dedupe scheduling emails, Stalwart-compatible calendar filters

This commit is contained in:
Linus Rath
2026-06-11 18:24:43 +02:00
parent a27a5be3dd
commit be58cee989
5 changed files with 438 additions and 267 deletions
@@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '')
|| parsedEvent?.organizerCalendarAddress?.replace('mailto:', '')
|| summary?.organizerEmail
|| null;
// Send the iMIP REPLY email to the organizer (client-side scheduling).
// Called after updating the local calendar event. Best-effort - if it
// fails we still report the RSVP as sent since the calendar was updated.
const sendImipReply = async () => {
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
return;
}
try {
await client.sendImipReply({
organizerEmail,
organizerName: summary?.organizer || undefined,
attendeeEmail: currentUserEmail,
attendeeName: myParticipant?.participant.name || undefined,
uid: parsedEvent.uid,
summary: parsedEvent.title,
dtStart: parsedEvent.start || undefined,
dtEnd: summary?.end || undefined,
timeZone: parsedEvent.timeZone || undefined,
isAllDay: parsedEvent.showWithoutTime || false,
sequence: parsedEvent.sequence,
status: imipStatus,
});
} catch {
// Best-effort: don't block the RSVP success notification
}
};
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
const eventForRsvp = existingEvent
? await resolveExistingEventForRsvp()
@@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
);
if (eventForRsvp && existingEventParticipant) {
await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -615,7 +584,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
}, true);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
@@ -632,7 +600,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|| (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null);
if (newEvent && participant) {
await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp);
await sendImipReply();
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
@@ -323,6 +323,89 @@ describe('expandRecurringEvents', () => {
});
});
// -----------------------------------------------------------------------
// excludedRecurrenceRules (EXRULE)
// -----------------------------------------------------------------------
describe('excludedRecurrenceRules', () => {
it('removes occurrences generated by excluded rules', () => {
const event = makeEvent({
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
excludedRecurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
byDay: [{ day: 'tu' }],
} as any],
});
// Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded
const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).not.toContain('2025-01-07');
expect(days).toContain('2025-01-06');
expect(days).toContain('2025-01-08');
expect(result).toHaveLength(6);
});
});
// -----------------------------------------------------------------------
// Long-running series
// -----------------------------------------------------------------------
describe('long-running series', () => {
it('expands a daily series started years before the visible range', () => {
const event = makeEvent({
start: '2022-01-03T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any],
});
const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00');
expect(starts(result)).toEqual([
'2025-06-02T09:00:00',
'2025-06-03T09:00:00',
'2025-06-04T09:00:00',
]);
});
it('expands a weekly series started years before the visible range', () => {
const event = makeEvent({
start: '2020-01-06T09:00:00', // Monday
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
});
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
const days = result.map(e => e.start.substring(0, 10));
expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']);
});
it('still respects count for old series (no fast-forward shortcut)', () => {
const event = makeEvent({
start: '2025-01-06T09:00:00',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any],
});
// Range far after the 5 occurrences ran out
const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00');
expect(result).toHaveLength(0);
});
});
// -----------------------------------------------------------------------
// Event-timezone DST handling
// -----------------------------------------------------------------------
describe('event timezone DST handling', () => {
it('keeps wall time across a DST transition in the event timezone', () => {
// America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after
const event = makeEvent({
start: '2025-03-03T10:00:00',
timeZone: 'America/New_York',
utcStart: '2025-03-03T15:00:00Z',
utcEnd: '2025-03-03T16:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any],
} as Partial<CalendarEvent>);
const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00');
const utcStarts = result.map(e => (e as any).utcStart);
expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5
expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4
const utcEnds = result.map(e => (e as any).utcEnd);
expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z');
});
});
// -----------------------------------------------------------------------
// All-day events
// -----------------------------------------------------------------------
+150 -170
View File
@@ -358,12 +358,53 @@ function foldIcsLine(line: string): string {
return chunks.join('\r\n');
}
/**
* Escape a TEXT property value per RFC 5545 §3.3.11. Backslash, semicolon,
* comma and newlines must be escaped - otherwise a title like "1,2;3" or a
* multi-line description corrupts the component.
* @see https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11
*/
function escapeIcsText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
}
/**
* Encode a parameter value (e.g. CN=) per RFC 5545 §3.2: values containing
* COLON, SEMICOLON or COMMA must be quoted; DQUOTE itself is not allowed in
* parameter values, so replace it.
*/
function icsParamValue(value: string): string {
const cleaned = value.replace(/[\r\n"]/g, "'");
return /[;:,]/.test(cleaned) ? `"${cleaned}"` : cleaned;
}
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
* calendar id per condition), not the draft's plural `inCalendars` array -
* sending the plural form fails the whole query with `unsupportedFilter`.
* Multiple calendars are expressed as an OR of singular conditions.
*/
function buildInCalendarFilter(calendarIds: string[]): Record<string, unknown> {
if (calendarIds.length === 1) {
return { inCalendar: calendarIds[0] };
}
return {
operator: 'OR',
conditions: calendarIds.map((id) => ({ inCalendar: id })),
};
}
// Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox
// form: `Display Name <addr@example.com>`. Re-emitting that as the JMAP
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
@@ -2374,7 +2415,10 @@ export class JMAPClient implements IJMAPClient {
/**
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
* This is needed when the server does not handle sendSchedulingMessages.
*
* Fallback only: when `sendSchedulingMessages` is passed to the
* CalendarEvent/set RSVP patch, the server sends the iTIP REPLY itself -
* calling this in addition produces duplicate reply emails.
*/
async sendImipReply(opts: {
organizerEmail: string;
@@ -2474,14 +2518,14 @@ export class JMAPClient implements IJMAPClient {
}
}
if (opts.summary) {
lines.push(`SUMMARY:${opts.summary}`);
lines.push(`SUMMARY:${escapeIcsText(opts.summary)}`);
}
if (opts.sequence != null) {
lines.push(`SEQUENCE:${opts.sequence}`);
}
const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : '';
const orgCn = opts.organizerName ? `;CN=${icsParamValue(opts.organizerName)}` : '';
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : '';
const attCn = opts.attendeeName ? `;CN=${icsParamValue(opts.attendeeName)}` : '';
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
lines.push('END:VEVENT');
lines.push('END:VCALENDAR');
@@ -2568,7 +2612,12 @@ export class JMAPClient implements IJMAPClient {
/**
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
* Used when creating or updating an event with participants.
*
* Fallback only: when `sendSchedulingMessages` is passed to CalendarEvent/set,
* the server (Stalwart) queues the iTIP messages itself - calling this in
* addition produces duplicate invitation emails. Note the generated ICS is
* a minimal snapshot (no RRULE/VTIMEZONE), so server-side scheduling should
* always be preferred.
*/
async sendImipInvitation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
@@ -2639,7 +2688,13 @@ export class JMAPClient implements IJMAPClient {
}
}
if (event.utcEnd) {
// Prefer DURATION over DTEND (RFC 5545 §3.6.1): DTSTART above is a local
// date-time (optionally with TZID) while event.utcEnd is a UTC instant -
// emitting both mixed reference frames, and paired a floating DTSTART
// with a UTC DTEND for events without a timezone.
if (event.duration) {
lines.push(`DURATION:${event.duration}`);
} else if (event.utcEnd) {
if (event.showWithoutTime) {
const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8);
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
@@ -2647,23 +2702,20 @@ export class JMAPClient implements IJMAPClient {
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
}
} else if (event.duration) {
// Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1)
lines.push(`DURATION:${event.duration}`);
}
if (event.title) lines.push(`SUMMARY:${event.title}`);
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`);
if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(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}` : '';
const orgCn = organizerName ? `;CN=${icsParamValue(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 cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
const partstat = attendee.participationStatus
? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}`
: ';PARTSTAT=NEEDS-ACTION';
@@ -2738,7 +2790,10 @@ export class JMAPClient implements IJMAPClient {
/**
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
* Used when deleting an event that has participants.
*
* Fallback only: when `sendSchedulingMessages` is passed to the
* CalendarEvent/set destroy, the server sends the iTIP CANCEL itself -
* calling this in addition produces duplicate cancellation emails.
*/
async sendImipCancellation(event: CalendarEvent): Promise<void> {
if (!event.participants) return;
@@ -2810,16 +2865,16 @@ export class JMAPClient implements IJMAPClient {
}
}
if (event.title) lines.push(`SUMMARY:${event.title}`);
if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`);
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
const orgCn = organizerName ? `;CN=${organizerName}` : '';
const orgCn = organizerName ? `;CN=${icsParamValue(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 cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
lines.push(`ATTENDEE${cn}:mailto:${email}`);
}
@@ -4193,7 +4248,7 @@ export class JMAPClient implements IJMAPClient {
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = { inCalendars: calendarIds };
queryArgs.filter = buildInCalendarFilter(calendarIds);
}
// First, query to get all IDs
@@ -4693,86 +4748,96 @@ export class JMAPClient implements IJMAPClient {
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
/**
* Fetch all Task objects via the CalendarEvent endpoints.
*
* Stalwart has no `types` filter on CalendarEvent/query (it fails the whole
* query with `unsupportedFilter`), and the previous CalendarEvent/get
* ids:null fallback was capped at the server's maxObjectsInGet (500 by
* default), silently hiding tasks in larger accounts. Instead, page through
* CalendarEvent/query (which returns events *and* tasks), fetch in
* /get-sized batches, and detect tasks client-side.
*/
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.group('CalendarTask/fetch', 'tasks');
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
try {
// Strategy 1: query with types filter (JMAP spec compliant)
const filter: Record<string, unknown> = { types: ['Task'] };
if (calendarIds && calendarIds.length > 0) {
filter.inCalendars = calendarIds;
}
// Page through the query to collect all object ids.
const QUERY_PAGE = 1000;
const MAX_IDS = 50000; // safety bound
const ids: string[] = [];
for (let position = 0; position < MAX_IDS;) {
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
if (calendarIds && calendarIds.length > 0) {
queryArgs.filter = buildInCalendarFilter(calendarIds);
}
const response = await this.request([
["CalendarEvent/query", queryArgs, "0"],
], this.calendarUsing());
debug.log('tasks', 'CalendarTask/fetch query filter', filter);
const response = await this.request([
["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"],
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_TASK_PROPERTIES],
"#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" },
}, "1"]
], this.calendarUsing());
const queryResponse = response.methodResponses?.[0];
const getResponse = response.methodResponses?.[1];
debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]);
debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]);
if (queryResponse?.[0] === "error") {
debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]);
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
debug.groupEnd();
return tasks;
}
if (getResponse?.[0] === "CalendarEvent/get") {
const list = (getResponse[1].list || []) as CalendarTask[];
const queryIds = queryResponse?.[1]?.ids || [];
debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds);
debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects');
// If the types filter returned 0 results, the server may have silently
// ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to
// a full scan so we can detect tasks by their properties.
if (queryIds.length === 0) {
debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan');
const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId);
debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks');
if (response.methodResponses?.[0]?.[0] === "error") {
const error = response.methodResponses[0][1];
debug.warn('tasks', 'CalendarTask/fetch query failed', error);
debug.groupEnd();
return tasks;
return [];
}
list.forEach((task, i) => {
debug.log('tasks', `CalendarTask/fetch [${i}]`, {
id: task.id,
uid: task.uid,
'@type': task['@type'],
title: task.title,
due: task.due,
start: task.start,
progress: task.progress,
showWithoutTime: task.showWithoutTime,
calendarIds: task.calendarIds,
});
});
const results = list.map((task) => ({
...task,
'@type': 'Task' as const,
}));
debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks');
debug.groupEnd();
return results;
const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || [];
ids.push(...pageIds);
if (pageIds.length < QUERY_PAGE) break;
position += pageIds.length;
}
debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses);
debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids');
if (ids.length === 0) {
debug.groupEnd();
return [];
}
// Fetch the objects in batches that respect the server's /get limit.
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
const allObjects: Record<string, unknown>[] = [];
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_TASK_PROPERTIES],
ids: batchIds,
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
allObjects.push(...(getResponse.methodResponses[0][1].list || []));
}
}
const tasks: CalendarTask[] = [];
for (const obj of allObjects) {
const type = obj['@type'];
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
// set to something other than 'Event'. Detect them by the presence of
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
// defines as Task-only - a VEVENT will never include them in the response.
// We check for key presence (even if null) because Stalwart may return null
// instead of the RFC defaults (e.g. progress default is "needs-action").
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
const hasTaskFields = ('due' in obj)
|| ('progress' in obj)
|| ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields;
if (!isExplicitTask && !isCalDavTask) continue;
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
}
debug.log('tasks', 'CalendarTask/fetch complete,', tasks.length, 'tasks of', allObjects.length, 'objects');
debug.groupEnd();
return [];
return tasks;
} catch (error) {
debug.error('CalendarTask/fetch failed', error);
debug.groupEnd();
@@ -4780,91 +4845,6 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Fallback for servers that don't support the `types` filter in CalendarEvent/query.
* Uses CalendarEvent/get with ids:null to fetch ALL calendar objects (JMAP spec),
* since CalendarEvent/query may only return Event-type objects on some servers.
*/
private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
const accountId = targetAccountId || this.getCalendarsAccountId();
debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects');
// CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type
const response = await this.request([
["CalendarEvent/get", {
accountId,
ids: null,
properties: [...CALENDAR_TASK_PROPERTIES],
}, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") {
debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]);
return [];
}
const allObjects = (response.methodResponses[0][1].list || []) as Record<string, unknown>[];
debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length);
const tasks: CalendarTask[] = [];
const calendarIdSet = calendarIds ? new Set(calendarIds) : null;
allObjects.forEach((obj) => {
const type = obj['@type'];
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
// set to something other than 'Event'. Detect them by the presence of
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
// defines as Task-only - a VEVENT will never include them in the response.
// We check for key presence (even if null) because Stalwart may return null
// instead of the RFC defaults (e.g. progress default is "needs-action").
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
const hasTaskFields = ('due' in obj)
|| ('progress' in obj)
|| ('percentComplete' in obj);
const isCalDavTask = type !== 'Event' && hasTaskFields;
debug.log('tasks', 'CalendarTask/fallback scan', {
id: obj.id,
'@type': type,
title: obj.title,
hasProgress: 'progress' in obj,
progress: obj.progress,
due: obj.due,
isExplicitTask,
isCalDavTask,
});
if (!isExplicitTask && !isCalDavTask) return;
// Filter by calendar if requested
if (calendarIdSet) {
const objCalendarIds = obj.calendarIds as Record<string, boolean> | undefined;
if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) {
debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id);
return;
}
}
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
});
debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks');
tasks.forEach((t, i) => {
debug.log('tasks', `CalendarTask/fallback [${i}]`, {
id: t.id,
uid: t.uid,
title: t.title,
due: t.due,
progress: t.progress,
showWithoutTime: t.showWithoutTime,
calendarIds: t.calendarIds,
});
});
return tasks;
}
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
const accountId = targetAccountId || this.getCalendarsAccountId();
const { '@type': _type, ...taskData } = task;
+192 -27
View File
@@ -11,7 +11,7 @@
* in the browser.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
@@ -64,6 +64,10 @@ export function expandRecurringEvents(
return result;
}
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
@@ -77,13 +81,23 @@ function expandEvent(
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
// (iCalendar EXRULE) are removed from the recurrence set.
const excludedDates = new Set<string>();
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
const dateKey = occurrenceDateKey(master, date);
if (excludedDates.has(dateKey)) continue;
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -106,9 +120,9 @@ function expandEvent(
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
// Overrides take precedence over excludedRecurrenceRules (they re-add
// a concrete instance), so only dedupe against already-generated dates.
const dateKey = occurrenceDateKey(master, overrideDate);
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
@@ -118,6 +132,65 @@ function expandEvent(
return occurrences;
}
// Cache Intl formatters per IANA timezone id - constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
let formatter = tzFormatterCache.get(timeZone);
if (formatter === undefined) {
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
formatter = null;
}
tzFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
const map: Record<string, string> = {};
for (const part of formatter.formatToParts(new Date(instant))) {
if (part.type !== 'literal') map[part.type] = part.value;
}
const hour = map.hour === '24' ? 0 : Number(map.hour);
return Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
);
}
/**
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
function createOccurrence(
master: CalendarEvent,
date: Date,
@@ -133,17 +206,33 @@ function createOccurrence(
// return the correct dates instead of the master's original UTC times.
let utcStart: string | undefined;
let utcEnd: string | undefined;
if (!master.showWithoutTime && master.utcStart && master.start) {
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (!master.showWithoutTime) {
let durationMs: number | null = null;
if (master.utcStart && master.utcEnd) {
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
if (!isNaN(ms)) durationMs = ms;
}
// Shift utcEnd by the same amount as utcStart
if (master.utcEnd) {
const masterUtcEnd = parseISO(master.utcEnd);
const durationMs = masterUtcEnd.getTime() - masterUtc.getTime();
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
// Convert the occurrence's wall time using the event's own timezone, so
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (master.utcStart && master.start) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
}
@@ -218,6 +307,7 @@ function generateDates(
rawRule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -225,20 +315,34 @@ function generateDates(
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
let current = new Date(eventStart);
// Without a `count` limit we can jump straight to the period containing
// the visible range. With one, every occurrence since the series start
// must be generated so it counts against `count`.
let current = rule.count
? new Date(eventStart)
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
const maxIterations = 2000;
// Cap on *emitted* (in-range) dates. This must not count occurrences
// before rangeStart, otherwise a series started long ago (e.g. a daily
// event from two years back) exhausts the budget before reaching the
// visible range and silently renders nothing.
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
if (until && current > until) break;
// For frequencies that produce one candidate per iteration at a time,
// we can stop when we pass rangeEnd. But for frequencies that expand
// into multiple candidates per period, we need the candidate generation.
if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly'
&& rule.frequency !== 'weekly') break;
// Stop once no candidate in this or a later period can fall before
// rangeEnd. Monthly/yearly candidates may precede the period anchor
// within the same month/year, so floor the anchor before comparing.
if (rule.frequency === 'monthly') {
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
} else if (rule.frequency === 'yearly') {
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
} else if (current >= rangeEnd) {
break;
}
// Generate candidates for the current period, then filter
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
@@ -250,7 +354,7 @@ function generateDates(
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
@@ -262,7 +366,7 @@ function generateDates(
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || totalCount >= maxOccurrences) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
if (current <= eventStart && iterations === 1) {
@@ -272,7 +376,7 @@ function generateDates(
}
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
// Check it's not already in the list
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
@@ -535,6 +639,67 @@ function advancePeriod(
}
}
// ---------------------------------------------------------------------------
// Jump to the period just before the visible range in O(1), so long-running
// series don't burn the iteration budget on years of out-of-range periods.
// Only valid for rules without `count` (counted rules must enumerate every
// occurrence from the series start).
// ---------------------------------------------------------------------------
function fastForwardToRange(
eventStart: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
rangeStart: Date,
): Date {
if (eventStart >= rangeStart) return new Date(eventStart);
let periods: number;
switch (frequency) {
case 'secondly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
break;
case 'minutely':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
break;
case 'hourly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
break;
case 'daily':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
break;
case 'weekly':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
break;
case 'monthly': {
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
+ (rangeStart.getMonth() - eventStart.getMonth());
periods = Math.floor(months / interval);
break;
}
case 'yearly':
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
break;
default:
return new Date(eventStart);
}
// Land one full period early so candidates inside the boundary period
// (which can precede the period anchor) are still generated.
periods = Math.max(0, periods - 1);
if (periods === 0) return new Date(eventStart);
switch (frequency) {
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
case 'daily': return addDays(eventStart, periods * interval);
case 'weekly': return addWeeks(eventStart, periods * interval);
case 'monthly': return addMonths(eventStart, periods * interval);
case 'yearly': return addYears(eventStart, periods * interval);
default: return new Date(eventStart);
}
}
// ---------------------------------------------------------------------------
// Helper: expand byDay within a specific month (monthly or yearly context)
// ---------------------------------------------------------------------------
+9 -33
View File
@@ -471,13 +471,9 @@ export const useCalendarStore = create<CalendarStore>()(
}
set((state) => ({ events: [...state.events, mappedCreated] }));
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
// 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.
return mappedCreated;
} catch (error) {
debug.error('Failed to create event:', error);
@@ -541,22 +537,9 @@ export const useCalendarStore = create<CalendarStore>()(
return merged;
}),
}));
if (sendSchedulingMessages) {
const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants;
if (mergedParticipants) {
const eventForInvitation = {
...(storeEvent ?? {}),
...cleanUpdates,
id: realId,
participants: mergedParticipants,
} as import('@/lib/jmap/types').CalendarEvent;
try {
await client.sendImipInvitation(eventForInvitation);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
}
// 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.
} catch (error) {
debug.error('Failed to update event:', error);
set({ error: 'Failed to update event' });
@@ -787,16 +770,9 @@ export const useCalendarStore = create<CalendarStore>()(
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
const targetAccountId = storeEvent?.accountId;
client = resolveAccountClient(client, storeEvent?.localAccountId);
if (sendSchedulingMessages) {
try {
const event = await client.getCalendarEvent(realId, targetAccountId);
if (event?.participants) {
await client.sendImipCancellation(event);
}
} catch (e) {
debug.error('Failed to send cancellation emails:', e);
}
}
// 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,