From a2344ea0ae0a2f048be6e051700c801039c93f02 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:34:36 +0100 Subject: [PATCH 01/22] fix: keep a new calendar's name and colour until the next refresh --- src/api/__tests__/calendar.test.ts | 30 +++++++++++++++++ .../__tests__/live-stalwart-calendar.test.ts | 21 ++++++++++++ src/api/calendar.ts | 32 +++++++++++++++---- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index ea16e878..9f376758 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -15,6 +15,7 @@ import { queryEvents, getEvents, createEvent, + createCalendar, updateEvent, deleteEvents, batchCreateEvents, @@ -557,6 +558,35 @@ describe('calendar operations', () => { }); }); + describe('createCalendar', () => { + it('returns the new calendar with the name and colour sent when the server echoes only its id', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Calendar/set', { created: { 'new-cal': { id: 'c9', color: null, myRights: { mayDelete: true } } } }, '0'], + ['error', { type: 'serverFail' }, '1'], + ], + }); + const cal = await createCalendar('Work', '#123456'); + expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', isVisible: true, myRights: { mayDelete: true } }); + }); + + it('takes the read-back calendar with its rights', async () => { + const rights = { mayReadItems: true, mayWriteAll: true, mayDelete: true, mayShare: true }; + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Calendar/set', { created: { 'new-cal': { id: 'c9' } } }, '0'], + ['Calendar/get', { list: [{ id: 'c9', name: 'Work', color: '#123456', myRights: rights }] }, '1'], + ], + }); + const cal = await createCalendar('Work', '#123456'); + expect(cal).toMatchObject({ id: 'c9', name: 'Work', color: '#123456', myRights: rights }); + const calls = mockRequest.mock.calls[0][0]; + expect(calls[1][0]).toBe('Calendar/get'); + // The creation id: 0.16 refuses a result reference into `created`. + expect(calls[1][1].ids).toEqual(['#new-cal']); + }); + }); + describe('refusals for the participant limit (Stalwart 1.0)', () => { // 1.0 enforces maxParticipantsPerEvent on every create and update, an // answer included; 0.16 advertises the limit without enforcing it. diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index e5b382e8..546507cd 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -700,6 +700,27 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('a new calendar', () => { + let newCalendarId = ''; + + afterAll(async () => { + if (newCalendarId) { + await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: [newCalendarId], onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + }, 30_000); + + it('has its name, colour and rights in the store right after it is made', async () => { + await connectSingleton(); + await useCalendarStore.getState().fetchCalendars(); + const made = await useCalendarStore.getState().createCalendar(`${RUN} new`, '#0f766e'); + newCalendarId = made.id; + const inStore = useCalendarStore.getState().calendars.find((c) => c.id === made.id); + expect(inStore).toMatchObject({ name: `${RUN} new`, color: '#0f766e' }); + expect(inStore?.myRights?.mayDelete).toBe(true); + expect(inStore?.myRights?.mayWriteAll).toBe(true); + }, 30_000); + }); + describe('a calendar\'s default reminders (#1209)', () => { let defaultsCalendarId = ''; let otherCalendarId = ''; diff --git a/src/api/calendar.ts b/src/api/calendar.ts index d66e7471..a21b3ea5 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1250,23 +1250,43 @@ export async function createCalendar( const accountId = targetAccountId || jmapClient.accountId; const props: Record = { name, color, isVisible: true, isSubscribed: true }; if (description?.trim()) props.description = description.trim(); + // Stalwart's create answer never carries the name sent, and 1.0 answers + // `color: null` for one it stored. A get in the same request reads the + // calendar back with its rights, as the webmail re-reads it. The creation + // id stands for the new id: 0.16 refuses a result reference into + // `created`, both take `#new-cal`. const res = await jmapClient.request( - [['Calendar/set', { - accountId, - create: { 'new-cal': props }, - }, '0']], + [ + ['Calendar/set', { + accountId, + create: { 'new-cal': props }, + }, '0'], + ['Calendar/get', { + accountId, + ids: ['#new-cal'], + properties: CALENDAR_PROPERTIES, + }, '1'], + ], USING, ); const result = methodResult<{ created?: Record; notCreated?: Record; - }>(res); + }>(res, '0'); const created = result.created?.['new-cal']; if (!created) { const err = result.notCreated?.['new-cal']; throw new Error(err?.description || err?.type || 'Failed to create calendar'); } - return created; + let stored: Calendar | undefined; + try { + stored = methodResult<{ list?: Calendar[] }>(res, '1').list?.find((c) => c.id === created.id); + } catch { + // The calendar exists; what was sent stands in for the read-back. + } + if (stored) return { ...props, ...created, ...stored } as Calendar; + const echoed = Object.fromEntries(Object.entries(created).filter(([, v]) => v !== null)); + return { ...props, ...echoed } as Calendar; } /** From a19afe9b48e370faffc51ab81a8a5eccfca011cd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:44:02 +0100 Subject: [PATCH 02/22] feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425) --- .../__tests__/live-stalwart-calendar.test.ts | 85 ++++++++ .../email/CalendarInvitationBanner.tsx | 98 ++++++++- .../__tests__/calendar-cancellation.test.ts | 205 ++++++++++++++++++ src/lib/calendar-cancellation.ts | 129 +++++++++++ src/stores/__tests__/calendar-store.test.ts | 16 ++ src/stores/calendar-store.ts | 7 +- 6 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/calendar-cancellation.test.ts create mode 100644 src/lib/calendar-cancellation.ts diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 546507cd..da230dcd 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -29,6 +29,7 @@ import type { Calendar, CalendarEvent, Participant } from '../types'; import { createEvent, deleteEvents, + findEventsByUid, getCalendars, getEvents, queryEvents, @@ -43,6 +44,7 @@ import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { getEventStartDate } from '../../lib/calendar-utils'; import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts'; import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler'; +import { removeCancelledMeeting } from '../../lib/calendar-cancellation'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -607,6 +609,89 @@ live('live Stalwart calendar', () => { }, 120_000); }); + liveTwo('a cancelled meeting (#1425)', () => { + const after = '2027-10-01T00:00:00.000Z'; + const before = '2027-10-31T00:00:00.000Z'; + const me = [USER]; + + /** Run `fn` as the second user, in the app's singleton. */ + async function asOther(fn: () => Promise): Promise { + (await singleton()).restoreSnapshot(other!.snapshot()); + useCalendarStore.getState().reset(); + try { + return await fn(); + } finally { + await connectSingleton(); + } + } + + function removeAsOther(cancel: Partial) { + return asOther(async () => { + await useCalendarStore.getState().fetchCalendars(); + const s = useCalendarStore.getState(); + return removeCancelledMeeting(cancel, { + findEventsByUid: (u) => findEventsByUid(u), + deleteEvent: s.deleteEvent, + updateEvent: s.updateEvent, + }); + }); + } + + it('takes one cancelled occurrence, then the cancelled series, out of the attendee\'s calendar', async () => { + await connectSingleton(); + // Four Mondays from 4 October 2027, the second user invited. + const invitation = editorSave( + { title: `${RUN} cancelled`, start: '2027-10-04T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } as CalendarEvent, + { attendees: [{ name: 'Land', email: USER2! }] }, + me, + ); + const created = await useCalendarStore.getState().createEvent({ + ...seriesData('cancelled', '2027-10-04T10:00:00', 4), + participants: invitation.updates.participants, + organizerCalendarAddress: invitation.updates.organizerCalendarAddress, + }, calendarId, { sendSchedulingMessages: true }); + const uid = created.uid!; + const copies = await waitFor(() => seriesByUid(other!, uid), (list) => list.length > 0); + expect(copies, 'the invitation reached the second user').toHaveLength(1); + + // The organizer cancels the second Monday. The attendee's 1.0 keeps + // it, marked cancelled; 0.16 takes it out itself. + const occurrences = await loadOccurrences(uid, after, before); + const second = occurrences.find((e) => (e.recurrenceId ?? '').startsWith('2027-10-11'))!; + expect(await deleteOccurrence('this', { event: second, userEmails: me }, storeActions())).toBe(true); + const key = '2027-10-11T10:00:00'; + const marked = await waitFor(() => seriesByUid(other!, uid), (list) => + /cancelled|excluded/.test(JSON.stringify(list[0]?.recurrenceOverrides?.[key] ?? null))); + const entry = marked[0].recurrenceOverrides?.[key] as { sequence?: number; excluded?: boolean } | undefined; + expect(JSON.stringify(entry ?? null)).toMatch(/cancelled|excluded/); + + const one = await removeAsOther({ + uid, + recurrenceId: key, + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: entry?.sequence ?? 0, + }); + expect(one.kind).toBe(entry?.excluded ? 'none' : 'exclude'); + const [excluded] = await seriesByUid(other!, uid); + expect(excluded.recurrenceOverrides?.[key]).toEqual({ excluded: true }); + expect(excluded.status).not.toBe('cancelled'); + + // An older cancellation of the whole meeting is not applied. + const outdated = await removeAsOther({ uid, sequence: -1 }); + expect(outdated.kind).toBe('outdated'); + + // The organizer cancels the series. + const left = await loadOccurrences(uid, after, before); + expect(await deleteOccurrence('all', { event: left[0], userEmails: me }, storeActions())).toBe(true); + // Both servers keep it, marked cancelled. + const marked2 = await waitFor(() => seriesByUid(other!, uid), (list) => list[0]?.status === 'cancelled'); + expect(marked2[0]?.status).toBe('cancelled'); + const all = await removeAsOther({ uid, sequence: marked2[0].sequence ?? 0 }); + expect(all.kind).toBe('delete'); + expect(await seriesByUid(other!, uid)).toEqual([]); + }, 120_000); + }); + liveTwo('shares', () => { let mailboxId = ''; let sharedCalendarId = ''; diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 52c94a1d..01f2fb1b 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-nati import { openExternalUrl } from '../../lib/open-url'; import { CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle, - ShieldCheck, ShieldAlert, ChevronDown, + ShieldCheck, ShieldAlert, ChevronDown, Trash2, } from '../icons'; import { format, isSameDay } from 'date-fns'; import type { Calendar, Email, CalendarEvent } from '../../api/types'; @@ -32,6 +32,13 @@ import { canCreateEventsIn } from '../../lib/calendar-editability'; import { getCalendarColor, timePattern } from '../../lib/calendar-utils'; import { getDateFnsLocale } from '../../lib/calendar-locale'; import { useCalendarSubscriptionsStore } from '../../stores/calendar-subscriptions-store'; +import { + canRemoveCancelled as canRemoveCancelledMeeting, + planCancellationRemoval, + removeCancelledMeeting, + sameRecurrenceId, + type CancellationRemoval, +} from '../../lib/calendar-cancellation'; type BannerState = 'loading' | 'parsed' | 'done' | 'error'; type RsvpStatus = 'accepted' | 'tentative' | 'declined'; @@ -76,6 +83,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions); const importEvents = useCalendarStore((s) => s.importEvents); const rsvpEvent = useCalendarStore((s) => s.rsvpEvent); + const deleteEvent = useCalendarStore((s) => s.deleteEvent); + const updateEvent = useCalendarStore((s) => s.updateEvent); const attachment = React.useMemo(() => findCalendarAttachment(email), [email]); // Login address + identities + aliases, so invitations addressed to an // alias still show the RSVP buttons. Only an invitation looks the aliases up. @@ -97,6 +106,9 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { const [busy, setBusy] = React.useState(false); const [calendarId, setCalendarId] = React.useState(null); const [pickerOpen, setPickerOpen] = React.useState(false); + // What removing a cancelled meeting from the calendar takes, read from the + // events stored with its uid; null until known (webmail #1425). + const [cancelRemoval, setCancelRemoval] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -139,6 +151,17 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { return () => { cancelled = true; }; }, [invitationKey, enabled, jmapAccountId]); + const isCancellation = state !== 'loading' && method === 'cancel'; + React.useEffect(() => { + setCancelRemoval(null); + if (!isCancellation || !event?.uid) return; + let current = true; + findEventsByUid(event.uid) + .then((stored) => { if (current) setCancelRemoval(planCancellationRemoval(stored, event)); }) + .catch(() => { /* no remove action then */ }); + return () => { current = false; }; + }, [isCancellation, event]); + // Import into the account's default calendar; never into a shared calendar, // an iCal subscription (the next feed sync would delete the event) or a // read-only one. The user can still pick another writable calendar. @@ -255,6 +278,45 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { } }; + // A cancellation that fails authentication, or comes from someone other + // than the organizer without passing it, does not get to remove anything. + const senderAddress = email.from?.[0]?.email?.toLowerCase() ?? ''; + const userIsSender = !!senderAddress && currentUserEmails.some((a) => a.toLowerCase() === senderAddress); + const canRemoveCancelled = canRemoveCancelledMeeting({ + isCancellation, userIsSender, trustLevel: trust?.level, removal: cancelRemoval, + }); + const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated'; + + const handleRemoveCancelled = async () => { + const cancel = event; + if (busy || !cancel.uid) return; + setBusy(true); + setNotice(null); + try { + const removal = await removeCancelledMeeting(cancel, { + findEventsByUid: (uid) => findEventsByUid(uid), + deleteEvent, + updateEvent, + }); + if (removal.kind !== 'delete' && removal.kind !== 'exclude') { + setCancelRemoval(removal); + return; + } + // Occurrences of it in view go as well. + useCalendarStore.setState((s) => ({ + events: s.events.filter((e) => e.uid !== cancel.uid + || (Boolean(cancel.recurrenceId) && !sameRecurrenceId(e, cancel))), + })); + setServerMatch(null); + setCancelRemoval({ kind: 'none' }); + setNotice(t('calendar.invitation.removed_from_calendar', 'Removed from your calendar.')); + } catch { + setNotice(t('calendar.invitation.remove_error', 'Could not remove the event')); + } finally { + setBusy(false); + } + }; + const trustColor = trust?.level === 'warning' ? c.error : trust?.level === 'caution' ? c.warning : c.success; return ( @@ -270,6 +332,11 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { {method === 'cancel' && ( {t('calendar.invitation.cancelled', 'This event was cancelled')} )} + {cancellationOutdated && ( + + {t('calendar.invitation.cancel_outdated', 'Your calendar has a newer version of this event than this cancellation.')} + + )} {method === 'reply' && ( {t('calendar.invitation.reply', 'A participant replied to this invitation')} )} @@ -360,7 +427,21 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) { icon={} disabled={busy} onPress={() => ensureImportedAndRsvp('declined')} c={c} styles={styles} /> - ) : !existing && method !== 'reply' ? ( + ) : canRemoveCancelled ? ( + { void handleRemoveCancelled(); }} + disabled={busy} + accessibilityRole="button" + > + {busy ? ( + + ) : ( + + )} + {t('calendar.invitation.remove_from_calendar', 'Remove from calendar')} + + ) : !existing && method !== 'reply' && method !== 'cancel' ? ( { void handleImport(); }} @@ -521,6 +602,19 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.primary, }, importBtnText: { ...typography.bodyMedium, color: c.primaryForeground }, + removeBtn: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.errorBorder, + backgroundColor: c.background, + }, + removeBtnText: { ...typography.bodyMedium, color: c.error }, warnRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, marginTop: spacing.xs }, warnText: { ...typography.caption, color: c.warning }, }); diff --git a/src/lib/__tests__/calendar-cancellation.test.ts b/src/lib/__tests__/calendar-cancellation.test.ts new file mode 100644 index 00000000..510a66e2 --- /dev/null +++ b/src/lib/__tests__/calendar-cancellation.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; +import { canRemoveCancelled, planCancellationRemoval, removeCancelledMeeting, type CancellationRemoval } from '../calendar-cancellation'; +import type { CalendarEvent } from '../../api/types'; + +function stored(overrides: Partial): CalendarEvent { + return { + id: 'ev-1', + uid: 'meeting@example.com', + title: 'Weekly sync', + start: '2026-11-02T10:00:00', + timeZone: 'Europe/Berlin', + duration: 'PT1H', + sequence: 1, + recurrenceId: null, + recurrenceRules: null, + recurrenceOverrides: null, + ...overrides, + } as CalendarEvent; +} + +const WEEKLY = [{ '@type': 'RecurrenceRule', frequency: 'weekly' }] as CalendarEvent['recurrenceRules']; + +describe('planCancellationRemoval', () => { + it('deletes every stored event of a cancelled meeting, and nothing else', () => { + const events = [ + stored({ id: 'ev-1' }), + stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' }), + stored({ id: 'other', uid: 'other@example.com' }), + ]; + expect(planCancellationRemoval(events, { uid: 'meeting@example.com', sequence: 1 })) + .toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] }); + }); + + it('removes nothing when the calendar does not hold the meeting', () => { + expect(planCancellationRemoval([stored({ uid: 'other@example.com' })], { uid: 'meeting@example.com' })) + .toEqual({ kind: 'none' }); + }); + + it('leaves the meeting when the calendar holds a newer version than the cancellation', () => { + expect(planCancellationRemoval([stored({ sequence: 3 })], { uid: 'meeting@example.com', sequence: 2 })) + .toEqual({ kind: 'outdated' }); + }); + + it('applies a cancellation with the same sequence, as Stalwart leaves it after marking it cancelled', () => { + expect(planCancellationRemoval([stored({ sequence: 2, status: 'cancelled' })], { uid: 'meeting@example.com', sequence: 2 })) + .toEqual({ kind: 'delete', ids: ['ev-1'] }); + }); + + it('excludes one cancelled occurrence on the series instead of deleting it', () => { + const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: null }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, + }); + }); + + it('names the occurrence in the series time zone and keeps the other overrides', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-16T10:00:00': { title: 'Moved sync' } }, + }); + // 09:00 UTC is 10:00 in Berlin on 9 November. + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T09:00:00', + recurrenceIdTimeZone: 'Etc/UTC', + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } }, + }); + }); + + it('replaces the override Stalwart wrote for a cancelled occurrence', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { status: 'cancelled', sequence: 1 } }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ + kind: 'exclude', + id: 'ev-1', + patch: { 'recurrenceOverrides/2026-11-09T10:00:00': { excluded: true } }, + }); + }); + + it('leaves an occurrence that was changed after the cancellation was sent', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Back on', sequence: 4 } }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 3, + })).toEqual({ kind: 'outdated' }); + }); + + it('removes nothing for an occurrence already taken out of the series', () => { + const series = stored({ + recurrenceRules: WEEKLY, + recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } as Partial }, + }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + })).toEqual({ kind: 'none' }); + }); + + it('does not write the whole overrides map when the series was read without it', () => { + const series = stored({ recurrenceRules: WEEKLY, recurrenceOverrides: undefined }); + expect(planCancellationRemoval([series], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + })).toEqual({ kind: 'none' }); + }); + + it('deletes an occurrence the calendar holds on its own', () => { + const occurrence = stored({ id: 'occ', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }); + expect(planCancellationRemoval([occurrence], { + uid: 'meeting@example.com', + recurrenceId: '2026-11-09T10:00:00', + recurrenceIdTimeZone: 'Europe/Berlin', + sequence: 1, + })).toEqual({ kind: 'delete', ids: ['occ'] }); + }); +}); + +describe('canRemoveCancelled', () => { + const removal: CancellationRemoval = { kind: 'delete', ids: ['ev-1'] }; + const base = { isCancellation: true, userIsSender: false, trustLevel: 'trusted', removal }; + + it('offers removal for a trusted cancellation of a meeting the calendar holds', () => { + expect(canRemoveCancelled(base)).toBe(true); + expect(canRemoveCancelled({ ...base, trustLevel: 'caution' })).toBe(true); + expect(canRemoveCancelled({ ...base, removal: { kind: 'exclude', id: 'ev-1', patch: {} } })).toBe(true); + }); + + it('does not let a cancellation that fails the trust check, or the user\'s own, remove anything', () => { + expect(canRemoveCancelled({ ...base, trustLevel: 'warning' })).toBe(false); + expect(canRemoveCancelled({ ...base, userIsSender: true })).toBe(false); + expect(canRemoveCancelled({ ...base, isCancellation: false })).toBe(false); + }); + + it('offers nothing for an outdated cancellation or one with nothing left to remove', () => { + expect(canRemoveCancelled({ ...base, removal: { kind: 'outdated' } })).toBe(false); + expect(canRemoveCancelled({ ...base, removal: { kind: 'none' } })).toBe(false); + expect(canRemoveCancelled({ ...base, removal: null })).toBe(false); + }); +}); + +describe('removeCancelledMeeting', () => { + it('deletes the stored events without telling anyone, planned on the events as they are now', async () => { + const deleteEvent = vi.fn().mockResolvedValue(undefined); + const updateEvent = vi.fn().mockResolvedValue(undefined); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ id: 'ev-1' }), stored({ id: 'ev-2', recurrenceId: '2026-11-09T10:00:00' })]); + const done = await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 1 }, { findEventsByUid, deleteEvent, updateEvent }); + expect(done).toEqual({ kind: 'delete', ids: ['ev-1', 'ev-2'] }); + expect(findEventsByUid).toHaveBeenCalledWith('meeting@example.com'); + expect(deleteEvent.mock.calls).toEqual([ + ['ev-1', { sendSchedulingMessages: false }], + ['ev-2', { sendSchedulingMessages: false }], + ]); + expect(updateEvent).not.toHaveBeenCalled(); + }); + + it('excludes one occurrence on the series', async () => { + const deleteEvent = vi.fn(); + const updateEvent = vi.fn().mockResolvedValue(undefined); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ recurrenceRules: WEEKLY })]); + await removeCancelledMeeting( + { uid: 'meeting@example.com', recurrenceId: '2026-11-09T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }, + { findEventsByUid, deleteEvent, updateEvent }, + ); + expect(updateEvent).toHaveBeenCalledWith( + 'ev-1', + { recurrenceOverrides: { '2026-11-09T10:00:00': { excluded: true } } }, + { sendSchedulingMessages: false }, + ); + expect(deleteEvent).not.toHaveBeenCalled(); + }); + + it('changes nothing when the calendar has a newer version by now', async () => { + const deleteEvent = vi.fn(); + const updateEvent = vi.fn(); + const findEventsByUid = vi.fn().mockResolvedValue([stored({ sequence: 5 })]); + expect(await removeCancelledMeeting({ uid: 'meeting@example.com', sequence: 2 }, { findEventsByUid, deleteEvent, updateEvent })) + .toEqual({ kind: 'outdated' }); + expect(deleteEvent).not.toHaveBeenCalled(); + expect(updateEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/calendar-cancellation.ts b/src/lib/calendar-cancellation.ts new file mode 100644 index 00000000..3ec71af7 --- /dev/null +++ b/src/lib/calendar-cancellation.ts @@ -0,0 +1,129 @@ +import type { CalendarEvent } from '../api/types'; +import { buildFallbackExcludePatch } from './recurrence-instances'; +import { formatWallClock, isValidTimeZone, localDateTimeToInstant } from './calendar-timezone'; + +// Ported from the webmail's lib/calendar-cancellation.ts (#1425). + +/** + * `b`'s recurrence id as a local date-time in the zone of `a`'s, each read in + * its own zone (`recurrenceIdTimeZone`, else the event's `timeZone`). Without + * a usable zone on either side it is taken as it is. + */ +function recurrenceIdInZoneOf(a: Partial, b: Partial): string | null { + if (!b.recurrenceId) return null; + const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null; + const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null; + const local = b.recurrenceId.slice(0, 19); + if (!zoneA || !zoneB || !isValidTimeZone(zoneA) || !isValidTimeZone(zoneB)) return local; + const instant = localDateTimeToInstant(local, zoneB); + return instant ? formatWallClock(instant, zoneA) : local; +} + +/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */ +export function sameRecurrenceId(a: Partial, b: Partial): boolean { + if (!a.recurrenceId || !b.recurrenceId) return false; + return a.recurrenceId.slice(0, 19) === recurrenceIdInZoneOf(a, b); +} + +/** + * What removing a cancelled meeting from the calendar takes: + * - `delete`: destroy these stored events (the series, or an occurrence + * stored on its own). + * - `exclude`: write `patch` on the series `id`, taking one occurrence out. + * - `outdated`: the calendar holds a newer version than the cancellation. + * - `none`: nothing in the calendar to remove. + */ +export type CancellationRemoval = + | { kind: 'delete'; ids: string[] } + | { kind: 'exclude'; id: string; patch: Record } + | { kind: 'outdated' } + | { kind: 'none' }; + +/** + * How to remove what an iTIP CANCEL (RFC 5546 3.2.5) names from `stored`, + * the events stored with its uid. Stalwart applies a cancellation itself but + * only marks the event, or the occurrence, STATUS:CANCELLED and keeps it. + * A cancellation without a recurrence id is for the whole meeting; one with + * a recurrence id is for that occurrence only. A cancellation with a lower + * SEQUENCE than the event it names is older than the event and is not + * applied, as Stalwart does not apply it either. + */ +export function planCancellationRemoval( + stored: CalendarEvent[], + cancel: Partial, +): CancellationRemoval { + if (!cancel.uid) return { kind: 'none' }; + const sameUid = stored.filter((event) => event.uid === cancel.uid); + const cancelSequence = cancel.sequence ?? 0; + const isNewer = (sequence: number | null | undefined) => (sequence ?? 0) > cancelSequence; + + if (!cancel.recurrenceId) { + if (sameUid.length === 0) return { kind: 'none' }; + if (sameUid.some((event) => isNewer(event.sequence))) return { kind: 'outdated' }; + return { kind: 'delete', ids: sameUid.map((event) => event.id) }; + } + + const ownEvent = sameUid.find((event) => sameRecurrenceId(event, cancel)); + if (ownEvent) { + return isNewer(ownEvent.sequence) ? { kind: 'outdated' } : { kind: 'delete', ids: [ownEvent.id] }; + } + + const series = sameUid.find((event) => !event.recurrenceId && (event.recurrenceRules?.length ?? 0) > 0); + // Overrides not read: a whole-map write would drop the ones there are. + if (!series || series.recurrenceOverrides === undefined) return { kind: 'none' }; + const key = recurrenceIdInZoneOf(series, cancel); + if (!key) return { kind: 'none' }; + const override = series.recurrenceOverrides?.[key] as (Partial & { excluded?: boolean }) | undefined; + if (override?.excluded) return { kind: 'none' }; + if (override && isNewer(override.sequence)) return { kind: 'outdated' }; + const patch = buildFallbackExcludePatch({ + id: series.id, + start: '', + recurrenceId: key, + recurrenceOverrides: series.recurrenceOverrides, + }); + return patch ? { kind: 'exclude', id: series.id, patch } : { kind: 'none' }; +} + +/** + * Whether the banner offers "Remove from calendar": a cancellation the user + * didn't send, that doesn't fail the invitation trust check (authentication, + * sender not the organizer), for a meeting the calendar still holds. + */ +export function canRemoveCancelled(input: { + isCancellation: boolean; + userIsSender: boolean; + trustLevel: string | null | undefined; + removal: CancellationRemoval | null; +}): boolean { + return input.isCancellation + && !input.userIsSender + && input.trustLevel !== 'warning' + && (input.removal?.kind === 'delete' || input.removal?.kind === 'exclude'); +} + +export interface CancellationActions { + findEventsByUid: (uid: string) => Promise; + deleteEvent: (id: string, options: { sendSchedulingMessages: boolean }) => Promise; + updateEvent: (id: string, patch: Record, options: { sendSchedulingMessages: boolean }) => Promise; +} + +/** + * Remove what `cancel` names from the calendar, planned again on the events + * as they are now: the calendar may have changed since the mail was opened. + * No scheduling messages: the organizer cancelled it, there is no one to + * tell. Returns the plan it carried out (`none`/`outdated`: nothing done). + */ +export async function removeCancelledMeeting( + cancel: Partial, + actions: CancellationActions, +): Promise { + if (!cancel.uid) return { kind: 'none' }; + const removal = planCancellationRemoval(await actions.findEventsByUid(cancel.uid), cancel); + if (removal.kind === 'delete') { + for (const id of removal.ids) await actions.deleteEvent(id, { sendSchedulingMessages: false }); + } else if (removal.kind === 'exclude') { + await actions.updateEvent(removal.id, removal.patch, { sendSchedulingMessages: false }); + } + return removal; +} diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index ab33af99..9f3bbd3d 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -669,6 +669,22 @@ describe('calendar-store', () => { expect(useCalendarStore.getState().events[0].id).toBe('ev2'); }); + it('sends no scheduling message for a meeting with attendees when asked not to (a cancelled one)', async () => { + const participants = { + o: { roles: { owner: true }, calendarAddress: 'mailto:boss@example.com' }, + me: { roles: { attendee: true }, calendarAddress: 'mailto:me@example.com' }, + }; + useCalendarStore.setState({ events: [{ id: 'ev1', participants } as any] }); + mockDeleteEvents.mockResolvedValue(undefined); + + await useCalendarStore.getState().deleteEvent('ev1', { sendSchedulingMessages: false }); + expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev1'], false, 'acc-1'); + + useCalendarStore.setState({ events: [{ id: 'ev2', participants } as any] }); + await useCalendarStore.getState().deleteEvent('ev2'); + expect(mockDeleteEvents).toHaveBeenLastCalledWith(['ev2'], true, 'acc-1'); + }); + it('should drop every expanded occurrence of a destroyed master and refetch', async () => { useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Personal' } as any], diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index cfedee56..df9e4a15 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -263,7 +263,7 @@ export interface CalendarState { changes: Partial | Record, options?: { sendSchedulingMessages?: boolean }, ) => Promise; - deleteEvent: (id: string) => Promise; + deleteEvent: (id: string, options?: { sendSchedulingMessages?: boolean }) => Promise; // Resolve an expanded occurrence (or a master) to its master event, read // from the server: a copy the app holds may be older. getMasterEvent: (event: CalendarEvent) => Promise; @@ -1210,7 +1210,7 @@ export const useCalendarStore = create()( } }, - deleteEvent: async (id) => { + deleteEvent: async (id, options) => { // Deleting an occurrence removes just that one: through its synthetic // id, or by excluding it on the base event it was expanded from. const scope = writeScope(); @@ -1218,7 +1218,8 @@ export const useCalendarStore = create()( const { storeEvent, realId, accountId } = target; const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) || hasServerOccurrencesOf(get().events, realId, accountId); - const schedule = hasSchedulingParticipants(storeEvent) ? true : undefined; + const schedule = options?.sendSchedulingMessages + ?? (hasSchedulingParticipants(storeEvent) ? true : undefined); if (target.isOccurrence && storeEvent) { await destroyOccurrence(storeEvent, realId, schedule, writeAccount(scope, accountId), scope); assertInScope(scope); From 10e9f81dc12c71343c87f848e87e8846bd637087 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:48:23 +0100 Subject: [PATCH 03/22] fix: ask before the event and task editors drop typed changes on close or back --- src/components/calendar/EventModal.tsx | 33 ++++++++- src/components/calendar/TasksSheet.tsx | 43 ++++++++--- .../__tests__/calendar-editor-discard.test.ts | 71 +++++++++++++++++++ src/lib/calendar-editor-discard.ts | 43 +++++++++++ src/lib/task-editor.ts | 10 +++ 5 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 src/lib/__tests__/calendar-editor-discard.test.ts create mode 100644 src/lib/calendar-editor-discard.ts diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f3041203..f3d58ad5 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -79,6 +79,7 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -254,12 +255,34 @@ export function EventModal({ const [showEndTime, setShowEndTime] = React.useState(false); const [saving, setSaving] = React.useState(false); + // What the form holds against what it opened with: Close and Back ask + // before they drop changes (webmail 13f9f27c4). + const formSnapshot = React.useMemo(() => editorFormSnapshot({ + title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl, + }), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl]); + const [baseline, setBaseline] = React.useState(null); + // Before the reset below: the reset clears the baseline after this took + // the form of the render before it, and the next render takes the + // seeded form. + React.useEffect(() => { + if (!visible) { + if (baseline !== null) setBaseline(null); + return; + } + if (baseline === null) setBaseline(formSnapshot); + }, [visible, baseline, formSnapshot]); + const dirty = visible && editorFormChanged(baseline, formSnapshot); + const requestClose = () => askBeforeDiscard(dirty, t, onClose); + // Reset the form when the editor opens or switches to another event only. // `calendars` and `currentUserEmails` get new identities when a calendar // push or the alias lookup lands, which used to wipe what the user typed. const formKey = visible ? event?.id ?? 'new' : null; React.useEffect(() => { if (!visible) return; + setBaseline(null); if (event) { setTitle(event.title || ''); setDescription(event.description || ''); @@ -306,7 +329,11 @@ export function EventModal({ // The calendars can arrive after a new-event form opened. React.useEffect(() => { - if (visible && !calendarId && fallbackCalendarId) setCalendarId(fallbackCalendarId); + if (visible && !calendarId && fallbackCalendarId) { + setCalendarId(fallbackCalendarId); + // Not a change of the user's. + setBaseline(null); + } }, [visible, calendarId, fallbackCalendarId]); React.useEffect(() => { @@ -492,12 +519,12 @@ export function EventModal({ visible={visible} animationType="slide" presentationStyle="fullScreen" - onRequestClose={onClose} + onRequestClose={requestClose} > void>(onClose); const dragHandlers = useSheetDrag({ slideY, closedY: Dimensions.get('window').height, - onClose, + onClose: React.useCallback(() => closeRef.current(), []), }); const writableCalendars = React.useMemo( @@ -127,6 +132,12 @@ export function TasksSheet({ const [filter, setFilter] = React.useState('all'); const [editor, setEditor] = React.useState(() => emptyTaskEditor(defaultCalendarId)); + // The task as the editor was opened on it, to tell typed changes. + const [baseline, setBaseline] = React.useState(editor); + const seedEditor = (state: TaskEditorState) => { + setEditor(state); + setBaseline(state); + }; const [expanded, setExpanded] = React.useState(false); const [showDatePicker, setShowDatePicker] = React.useState(false); const [showTimePicker, setShowTimePicker] = React.useState(false); @@ -136,10 +147,10 @@ export function TasksSheet({ if (!visible) return; const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined; if (initial) { - setEditor(taskEditorFromTask(initial, defaultCalendarId)); + seedEditor(taskEditorFromTask(initial, defaultCalendarId)); setExpanded(true); } else { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(startNew); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -148,6 +159,7 @@ export function TasksSheet({ React.useEffect(() => { if (visible && !editor.calendarId && defaultCalendarId) { setEditor((e) => ({ ...e, calendarId: defaultCalendarId })); + setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId })); } }, [visible, editor.calendarId, defaultCalendarId]); @@ -189,10 +201,17 @@ export function TasksSheet({ }), [tasks]); const resetEditor = () => { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(false); }; + const dirty = visible && taskEditorChanged(baseline, editor); + const springBack = () => { + Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start(); + }; + const requestClose = () => askBeforeDiscard(dirty, t, onClose, springBack); + closeRef.current = requestClose; + const handleSave = async () => { if (!editor.title.trim() || !editor.calendarId || saving) return; setSaving(true); @@ -235,9 +254,9 @@ export function TasksSheet({ ]; return ( - + - + @@ -250,7 +269,7 @@ export function TasksSheet({ {t('calendar.tasks.label', 'Tasks')} )} {editor.id && ( - + askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}> {t('calendar.form.cancel', 'Cancel')} )} @@ -410,9 +429,11 @@ export function TasksSheet({ { - if (!onUpdate) return; - setEditor(taskEditorFromTask(task, defaultCalendarId)); - setExpanded(true); + if (!onUpdate || editor.id === task.id) return; + askBeforeDiscard(dirty, t, () => { + seedEditor(taskEditorFromTask(task, defaultCalendarId)); + setExpanded(true); + }); }} > diff --git a/src/lib/__tests__/calendar-editor-discard.test.ts b/src/lib/__tests__/calendar-editor-discard.test.ts new file mode 100644 index 00000000..8c2e5292 --- /dev/null +++ b/src/lib/__tests__/calendar-editor-discard.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const alert = vi.fn(); +vi.mock('react-native', () => ({ Alert: { alert: (...args: unknown[]) => alert(...args) } })); + +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../calendar-editor-discard'; +import { emptyTaskEditor, taskEditorChanged, taskEditorFromTask } from '../task-editor'; + +const t = (_key: string, fallback?: string) => fallback ?? ''; + +type Button = { text: string; style?: string; onPress?: () => void }; +const buttons = (): Button[] => alert.mock.calls.at(-1)![2] as Button[]; + +beforeEach(() => alert.mockReset()); + +describe('askBeforeDiscard', () => { + it('closes at once when nothing was changed', () => { + const close = vi.fn(); + askBeforeDiscard(false, t, close); + expect(close).toHaveBeenCalledTimes(1); + expect(alert).not.toHaveBeenCalled(); + }); + + it('asks before it drops changes, and closes only on Discard', () => { + const close = vi.fn(); + const keep = vi.fn(); + askBeforeDiscard(true, t, close, keep); + expect(close).not.toHaveBeenCalled(); + expect(alert.mock.calls[0][0]).toBe('Discard changes?'); + const [keepButton, discardButton] = buttons(); + expect(keepButton.text).toBe('Keep editing'); + keepButton.onPress?.(); + expect(keep).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + expect(discardButton.style).toBe('destructive'); + discardButton.onPress?.(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); + +describe('editor form changes', () => { + const form = (title: string, start = new Date('2026-11-02T10:00:00Z')) => + editorFormSnapshot({ title, start, attendees: [{ email: 'a@example.com' }] }); + + it('counts a typed title or a moved start as a change, the same form as none', () => { + const baseline = form('Lunch'); + expect(editorFormChanged(baseline, form('Lunch'))).toBe(false); + expect(editorFormChanged(baseline, form('Lunch with Ana'))).toBe(true); + expect(editorFormChanged(baseline, form('Lunch', new Date('2026-11-02T11:00:00Z')))).toBe(true); + }); + + it('counts nothing before the form was seeded', () => { + expect(editorFormChanged(null, form('Lunch'))).toBe(false); + }); +}); + +describe('taskEditorChanged', () => { + it('tells a typed title, note, due date or priority from the task as opened', () => { + const opened = taskEditorFromTask({ id: 't1', title: 'Pay rent', calendarIds: { c1: true } } as never, 'c1'); + expect(taskEditorChanged(opened, { ...opened })).toBe(false); + expect(taskEditorChanged(opened, { ...opened, title: 'Pay rent today' })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, description: 'IBAN' })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, due: new Date('2026-11-03T00:00:00Z') })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, priority: 'high' })).toBe(true); + }); + + it('compares due dates by their instant', () => { + const a = { ...emptyTaskEditor('c1'), due: new Date('2026-11-03T09:00:00Z') }; + expect(taskEditorChanged(a, { ...a, due: new Date('2026-11-03T09:00:00Z') })).toBe(false); + }); +}); diff --git a/src/lib/calendar-editor-discard.ts b/src/lib/calendar-editor-discard.ts new file mode 100644 index 00000000..f824a81b --- /dev/null +++ b/src/lib/calendar-editor-discard.ts @@ -0,0 +1,43 @@ +import { Alert } from 'react-native'; + +type Translate = (key: string, fallback?: string) => string; + +/** + * The editor's form as one comparable string. Dates compare by their + * instant (JSON renders them as ISO strings); the fields are compared in + * the order given, which the editor keeps fixed. + */ +export function editorFormSnapshot(fields: Record): string { + return JSON.stringify(fields); +} + +/** Whether the form holds changes against `baseline`, the form it opened with (null: not seeded yet). */ +export function editorFormChanged(baseline: string | null, current: string): boolean { + return baseline !== null && baseline !== current; +} + +/** + * Run `discard` at once when nothing would be lost, else after the user + * confirms "Discard"; "Keep editing" runs `keep` (webmail 13f9f27c4: the + * event and task editors ask before X, Cancel or Escape drop their changes). + */ +export function askBeforeDiscard( + dirty: boolean, + t: Translate, + discard: () => void, + keep?: () => void, +): void { + if (!dirty) { + discard(); + return; + } + Alert.alert( + t('calendar.discard.title', 'Discard changes?'), + t('calendar.discard.message', "Your changes haven't been saved."), + [ + { text: t('calendar.discard.keep', 'Keep editing'), style: 'cancel', onPress: keep }, + { text: t('calendar.discard.confirm', 'Discard'), style: 'destructive', onPress: discard }, + ], + { cancelable: true, onDismiss: keep }, + ); +} diff --git a/src/lib/task-editor.ts b/src/lib/task-editor.ts index 8933c8a0..6171a76d 100644 --- a/src/lib/task-editor.ts +++ b/src/lib/task-editor.ts @@ -7,6 +7,7 @@ import { getEffectiveTimeZone, isValidTimeZone, } from './calendar-timezone'; +import { editorFormSnapshot } from './calendar-editor-discard'; export type PriorityLevel = 'none' | 'high' | 'medium' | 'low'; @@ -44,6 +45,15 @@ export function emptyTaskEditor(calendarId: string): TaskEditorState { return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId }; } +/** Whether the editor holds changes against the task it was opened on (or the empty form). */ +export function taskEditorChanged(baseline: TaskEditorState, current: TaskEditorState): boolean { + const key = (e: TaskEditorState) => editorFormSnapshot({ + id: e.id, title: e.title, description: e.description, due: e.due?.getTime() ?? null, + withTime: e.withTime, priority: e.priority, calendarId: e.calendarId, + }); + return key(baseline) !== key(current); +} + export function taskEditorFromTask( task: Pick, fallbackCalendarId: string, From 6e697baa463f732a22e19018c6d6f30a3485c3e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:54:52 +0100 Subject: [PATCH 04/22] fix: keep the recurrence id of a daily series in another zone on the device's DST day --- src/lib/__tests__/recurrence-dst-gap.test.ts | 42 +++++++++ src/lib/recurrence-expansion.ts | 99 +++++++++++++++++--- 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/src/lib/__tests__/recurrence-dst-gap.test.ts b/src/lib/__tests__/recurrence-dst-gap.test.ts index e568091e..9ad8f8a2 100644 --- a/src/lib/__tests__/recurrence-dst-gap.test.ts +++ b/src/lib/__tests__/recurrence-dst-gap.test.ts @@ -38,3 +38,45 @@ describe('daily recurrence across a DST gap', () => { expect(out.map((o) => o.start.slice(11, 16))).toEqual(Array(7).fill('00:00')); }); }); + +describe("a daily series in another zone on the device's DST day", () => { + // Berlin skips 02:00-03:00 on 28 March 2027; New York changed on 14 March. + it("keeps the series' 02:30 as recurrence id and time on the night the device skips it", () => { + process.env.TZ = 'Europe/Berlin'; + const out = expandRecurringEvents( + [daily('2027-03-20T02:30:00', false, 'America/New_York')], + '2027-03-27T00:00:00', '2027-03-30T00:00:00', + ); + const day = out.find((o) => o.recurrenceId?.startsWith('2027-03-28')); + expect(day?.recurrenceId).toBe('2027-03-28T02:30:00'); + expect(day?.start).toBe('2027-03-28T02:30:00'); + expect(day?.id).toBe('E1:2027-03-28T02:30:00'); + // 02:30 in New York (EDT, -4) is 06:30 UTC. + expect(day?.utcStart).toBe('2027-03-28T06:30:00.000Z'); + expect(out.map((o) => o.recurrenceId)).toEqual([ + '2027-03-27T02:30:00', '2027-03-28T02:30:00', '2027-03-29T02:30:00', + ]); + }); + + it('applies the override stored for that night', () => { + process.env.TZ = 'Europe/Berlin'; + const event = { + ...daily('2027-03-20T02:30:00', false, 'America/New_York'), + recurrenceOverrides: { '2027-03-28T02:30:00': { title: 'Moved' } }, + } as CalendarEvent; + const out = expandRecurringEvents([event], '2027-03-28T00:00:00', '2027-03-29T00:00:00'); + expect(out).toHaveLength(1); + expect(out[0].title).toBe('Moved'); + }); + + it('gives the same occurrences whichever zone the device is in', () => { + const run = (tz: string) => { + process.env.TZ = tz; + return expandRecurringEvents( + [daily('2027-03-10T02:30:00', false, 'America/New_York')], + '2027-03-10T00:00:00', '2027-04-01T00:00:00', + ).map((o) => [o.recurrenceId, o.utcStart]); + }; + expect(run('Europe/Berlin')).toEqual(run('America/New_York')); + }); +}); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index fae990ec..04d3d15f 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -97,6 +97,59 @@ export function seriesOccursAt( return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime())); } +/** + * Dates stand for wall-clock times through their device-local fields. A + * wall time the device's clock skips (its own DST gap) can't be one: the + * Date moves past the gap. Such a Date carries the wall time it stands for + * here, so a series in another zone keeps its times on the device's DST day. + */ +const intendedWall = new WeakMap(); + +/** The LocalDateTime `date` stands for. */ +function wallString(date: Date): string { + return intendedWall.get(date) ?? format(date, "yyyy-MM-dd'T'HH:mm:ss"); +} + +/** A Date for the LocalDateTime `wall` ("yyyy-MM-ddTHH:mm:ss"), remembering it when the device's clock skips it. */ +function dateForWall(wall: string): Date { + const date = parseISO(wall); + const wallMs = localDateTimeAsUtc(wall); + if (!isNaN(date.getTime()) && wallMs !== null && wallAsUtc(date) !== wallMs) { + intendedWall.set(date, utcFieldsString(wallMs)); + } + return date; +} + +/** The UTC fields of `ms` as a LocalDateTime. */ +function utcFieldsString(ms: number): string { + const w = new Date(ms); + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + return `${pad(w.getUTCFullYear(), 4)}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}` + + `T${pad(w.getUTCHours())}:${pad(w.getUTCMinutes())}:${pad(w.getUTCSeconds())}`; +} + +/** The wall-clock fields of a LocalDateTime as a UTC timestamp, read from its digits. */ +function localDateTimeAsUtc(value: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value); + if (!m) return null; + return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)); +} + +/** + * The wall time `y-m-d h:mi:s` names in `timeZone`: itself, or past the gap + * when that zone's clock skips it (as a device-local Date would move). Null + * for an unknown zone. + */ +function wallInZone( + fields: [number, number, number, number, number, number], + timeZone: string, +): string | null { + const formatter = getTzFormatter(timeZone); + if (!formatter) return null; + const instant = zonedWallMsToUtc(Date.UTC(...fields), formatter); + return utcFieldsString(wallClockAsUtcTimestamp(formatter, instant)); +} + function occurrenceDateKey(master: CalendarEvent, date: Date): string { return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString(); } @@ -106,8 +159,10 @@ function expandEvent( rangeStart: Date, rangeEnd: Date, ): CalendarEvent[] { - const eventStart = parseISO(master.start); + const eventStart = master.showWithoutTime ? parseISO(master.start) : dateForWall(master.start.slice(0, 19)); if (isNaN(eventStart.getTime())) return []; + // Daily occurrences are built on the event's own clock (see wallInZone). + const zone = !master.showWithoutTime && master.timeZone ? master.timeZone : undefined; const rules = master.recurrenceRules || []; const overrides = master.recurrenceOverrides || {}; @@ -120,13 +175,13 @@ function expandEvent( 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)) { + for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false, undefined, zone)) { excludedDates.add(occurrenceDateKey(master, date)); } } for (const rule of rules) { - const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); + const dates = generateDates(eventStart, rule, rangeStart, rangeEnd, true, undefined, zone); for (const date of dates) { const dateKey = occurrenceDateKey(master, date); @@ -136,7 +191,7 @@ function expandEvent( const recurrenceId = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") - : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + : wallString(date); const override = overrides[recurrenceId] as | (Partial & { excluded?: boolean }) @@ -153,7 +208,7 @@ function expandEvent( for (const [recurrenceId, rawOverride] of Object.entries(overrides)) { const override = rawOverride as Partial & { excluded?: boolean }; if (override.excluded) continue; - const overrideDate = parseISO(recurrenceId); + const overrideDate = master.showWithoutTime ? parseISO(recurrenceId) : dateForWall(recurrenceId.slice(0, 19)); if (isNaN(overrideDate.getTime())) continue; // Overrides take precedence over excludedRecurrenceRules (they re-add @@ -288,12 +343,16 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; - const wallMs = wallAsUtc(wall); + return new Date(zonedWallMsToUtc(wallAsUtc(wall), formatter)); +} + +/** The instant of the wall clock `wallMs` (its fields read as UTC) in the formatter's zone. */ +function zonedWallMsToUtc(wallMs: number, formatter: Intl.DateTimeFormat): number { let guess = wallMs; for (let i = 0; i < 2; i++) { guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess); } - return new Date(guess); + return guess; } function createOccurrence( @@ -304,7 +363,7 @@ function createOccurrence( ): CalendarEvent { const startStr = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") - : format(date, "yyyy-MM-dd'T'HH:mm:ss"); + : wallString(date); // The occurrence as the rule generates it, with its override applied as a // patch: a moved or resized occurrence has its own start, time zone and @@ -340,7 +399,10 @@ function createOccurrence( // 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 = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null; + // From the digits: a wall time the device's clock skips is still this one. + const formatter = occurrence.timeZone ? getTzFormatter(occurrence.timeZone) : null; + const wallMs = localDateTimeAsUtc(start); + const zoned = formatter && wallMs !== null ? new Date(zonedWallMsToUtc(wallMs, formatter)) : null; if (zoned) { utcStart = zoned.toISOString(); if (durationMs !== null) { @@ -430,6 +492,8 @@ function generateDates( rangeEnd: Date, includeStartDate = true, maxOccurrences = 500, + // The series' time zone: daily occurrences are built on its clock. + timeZone?: string, ): Date[] { const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; @@ -465,7 +529,7 @@ function generateDates( break; } - const candidates = generateCandidatesForPeriod(current, rule, eventStart); + const candidates = generateCandidatesForPeriod(current, rule, eventStart, timeZone); const filtered = rule.bySetPosition?.length ? applyBySetPosition(candidates, rule.bySetPosition) @@ -509,6 +573,7 @@ function generateCandidatesForPeriod( periodStart: Date, rule: RecurrenceRule, eventStart: Date, + timeZone?: string, ): Date[] { const freq = rule.frequency; let candidates: Date[]; @@ -523,17 +588,27 @@ function generateCandidatesForPeriod( case 'weekly': candidates = expandWeekly(periodStart, rule, eventStart); break; - case 'daily': + case 'daily': { // The start's wall-clock time on this day. The period anchor is // advanced day by day and keeps whatever time a DST gap pushed it to // (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using // it made every later day fail the implicit byHour and the series // stopped at the gap. Mirrors the webmail (34fb9d247). - candidates = [new Date( + // With a time zone, on that zone's clock: the device's own DST gap + // must not move an occurrence of a series in another zone. + const time = timeZone ? /T(\d{2}):(\d{2}):(\d{2})/.exec(wallString(eventStart)) : null; + const zoned = time && timeZone + ? wallInZone([ + periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), + Number(time[1]), Number(time[2]), Number(time[3]), + ], timeZone) + : null; + candidates = [zoned ? dateForWall(zoned) : new Date( periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(), eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(), )]; break; + } case 'hourly': case 'minutely': case 'secondly': From dcbfc907fae782eb5f1026263bbd0c5b90cd7f24 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:05:57 +0100 Subject: [PATCH 05/22] feat: warn about the server's forward limit and an auto-reply too long for it (bulwarkmail/webmail#1155, bulwarkmail/webmail#1208) --- src/api/sieve-rules.ts | 10 ++ src/components/filters/FilterRuleModal.tsx | 43 ++++- src/components/filters/QuickRuleSheet.tsx | 27 ++- src/components/settings/VacationSettings.tsx | 40 ++++- src/lib/__tests__/vacation-limits.test.ts | 28 +++ .../__tests__/forward-limit-editor.test.ts | 62 +++++++ .../__tests__/forward-limit-vacation.test.ts | 59 +++++++ src/lib/sieve/__tests__/forward-limit.test.ts | 162 ++++++++++++++++++ src/lib/sieve/forward-limit.ts | 136 +++++++++++++++ src/lib/vacation-limits.ts | 29 ++++ src/stores/__tests__/filter-store.test.ts | 11 ++ src/stores/__tests__/vacation-store.test.ts | 2 +- src/stores/filter-store.ts | 8 + src/stores/vacation-store.ts | 4 + 14 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 src/lib/__tests__/vacation-limits.test.ts create mode 100644 src/lib/sieve/__tests__/forward-limit-editor.test.ts create mode 100644 src/lib/sieve/__tests__/forward-limit-vacation.test.ts create mode 100644 src/lib/sieve/__tests__/forward-limit.test.ts create mode 100644 src/lib/sieve/forward-limit.ts create mode 100644 src/lib/vacation-limits.ts diff --git a/src/api/sieve-rules.ts b/src/api/sieve-rules.ts index 672faf77..03ad09df 100644 --- a/src/api/sieve-rules.ts +++ b/src/api/sieve-rules.ts @@ -33,6 +33,16 @@ export class FiltersLoginChangedError extends Error { } } +/** The account's filters as the server has them now (its active script, parsed). */ +export async function readAccountFilters(accountId: string = getSieveAccountId()): Promise { + const all = await getSieveScripts(accountId); + const scripts = all.filter((s) => s.name !== VACATION_SCRIPT_NAME); + const script = scripts.find((s) => s.isActive) || scripts[0] || null; + return script + ? parseScript(await getSieveScriptContent(script.blobId, accountId)) + : { rules: [], isOpaque: false, externalRequires: [] }; +} + /** * Read the account's script from the server, let `modify` change its rules, * and write the result back as the active script, keeping the vacation diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 503cf800..7968b597 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -42,6 +42,8 @@ import type { FilterActionType, } from '../../lib/sieve/types'; import { SafeAreaModal } from '../SafeAreaModal'; +import { useFilterStore } from '../../stores/filter-store'; +import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all']; @@ -72,12 +74,19 @@ interface FilterRuleModalProps { mailboxes: Mailbox[]; /** The server can confine a rule to a period (Sieve "date" and "relational"). */ periodsSupported?: boolean; + /** + * The server's cap on forwards per message, and the forwards a message can + * have collected when it reaches this rule / still collect below it (see + * lib/sieve/forward-limit.ts). Left out: read from the filter list the + * Filters settings show. + */ + forwardContext?: { maxRedirects: number | null | undefined; before: number; after: number }; onSave: (rule: FilterRule) => void; onClose: () => void; } export function FilterRuleModal({ - visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose, + visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, forwardContext, onSave, onClose, }: FilterRuleModalProps) { const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); @@ -120,6 +129,25 @@ export function FilterRuleModal({ const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]); + // A message can collect only so many forwards on the server (#1155, #1208). + const listedRules = useFilterStore((s) => s.rules); + const listedForward = useFilterStore((s) => s.vacationForward); + const listedMaxRedirects = useFilterStore((s) => s.sieveCapabilities?.maxNumberRedirects); + const listedAround = useMemo( + () => forwardsOfEditedRule(listedRules, listedForward, editedRule?.id), + [listedRules, listedForward, editedRule?.id], + ); + const forwardLimit = ruleForwardLimit( + { actions, stopProcessing }, + forwardContext ? forwardContext.maxRedirects : listedMaxRedirects, + forwardContext ?? listedAround, + ); + const ruleForwardCount = actions.filter((a) => a.type === 'forward').length; + const actionOptionsFor = (action: FilterAction) => + action.type === 'forward' || forwardLimit.canAddForward + ? actionTypeOptions + : actionTypeOptions.filter((o) => o.value !== 'forward'); + const fieldOptions = useMemo( () => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })), [t], @@ -407,7 +435,7 @@ export function FilterRuleModal({