diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 17cc4fde..bd21fc54 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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 { diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index 30edbacb..48d8f840 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -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); + 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 // ----------------------------------------------------------------------- diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 09e914be..538c6af5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 { + 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 `. Re-emitting that as the JMAP // from.name field produces a doubled From header (`"Name " `) @@ -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 { 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 { 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 = { 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 { 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 = { 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 = { 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[] = []; + 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 { - 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[]; - 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 | 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, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const { '@type': _type, ...taskData } = task; diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 76f9e8fb..a805bb0c 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -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 = { 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(); + // RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules + // (iCalendar EXRULE) are removed from the recurrence set. + const excludedDates = new Set(); + 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(); + +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 = {}; + 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) // --------------------------------------------------------------------------- diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 3d21a554..7040d55e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -471,13 +471,9 @@ export const useCalendarStore = create()( } 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()( 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()( 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,