From b39d208f1fbd599a7724cc29f21454d0a404cf42 Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Sat, 19 Sep 2026 16:15:48 +0200 Subject: [PATCH 1/4] feat(calendar): sync iCal subscriptions via encrypted settings store (cherry picked from commit 67a7aebc29808c991412ad4034662ab516df7d16) --- .../calendar-subscription-sync.test.ts | 133 ++++++++++++++++++ lib/calendar-subscription-sync.ts | 118 ++++++++++++++++ .../calendar-subscription-sync.test.ts | 107 ++++++++++++++ stores/__tests__/settings-store-sync.test.ts | 90 ++++++++++++ stores/calendar-store.ts | 84 ++++++++++- stores/settings-store.ts | 55 +++++++- 6 files changed, 576 insertions(+), 11 deletions(-) create mode 100644 lib/__tests__/calendar-subscription-sync.test.ts create mode 100644 lib/calendar-subscription-sync.ts create mode 100644 stores/__tests__/calendar-subscription-sync.test.ts diff --git a/lib/__tests__/calendar-subscription-sync.test.ts b/lib/__tests__/calendar-subscription-sync.test.ts new file mode 100644 index 000000000..8436f0b8b --- /dev/null +++ b/lib/__tests__/calendar-subscription-sync.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + mergeSyncedSubscriptions, + parseSyncedSubscriptions, + parseSubscriptionTombstones, + SUBSCRIPTION_TOMBSTONE_TTL_MS, +} from '../calendar-subscription-sync'; +import type { ICalSubscription } from '@/stores/calendar-store'; + +function makeSubscription(overrides: Partial = {}): ICalSubscription { + return { + id: 'sub-test-id', + url: 'https://example.com/calendar.ics', + calendarId: 'cal-123', + name: 'Test Subscription', + color: '#3b82f6', + refreshInterval: 60, + lastRefreshed: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +const NOW = new Date('2026-03-01T00:00:00Z'); + +describe('parseSyncedSubscriptions', () => { + it('returns null for non-array values (pre-sync build)', () => { + expect(parseSyncedSubscriptions(undefined)).toBeNull(); + expect(parseSyncedSubscriptions({ not: 'an array' })).toBeNull(); + }); + + it('preserves valid subscriptions and normalizes webcal URLs', () => { + const result = parseSyncedSubscriptions([ + makeSubscription({ id: 'sub-1', url: 'webcal://example.com/feed.ics' }), + ]); + expect(result).toHaveLength(1); + expect(result![0].id).toBe('sub-1'); + expect(result![0].url).toBe('https://example.com/feed.ics'); + expect(result![0].updatedAt).toBe('2026-01-01T00:00:00Z'); + }); + + it('drops entries without required fields or duplicate ids', () => { + const result = parseSyncedSubscriptions([ + makeSubscription({ id: 'a' }), + makeSubscription({ id: 'a', name: 'Duplicate' }), + { ...makeSubscription(), id: undefined }, + { ...makeSubscription(), url: '' }, + { ...makeSubscription(), calendarId: '' }, + { ...makeSubscription(), name: ' ' }, + 'invalid string entry', + ]); + expect(result!.map((s) => s.id)).toEqual(['a']); + }); +}); + +describe('parseSubscriptionTombstones', () => { + it('returns empty record for non-record values', () => { + expect(parseSubscriptionTombstones(undefined)).toEqual({}); + expect(parseSubscriptionTombstones(['a'])).toEqual({}); + }); + + it('drops invalid date values', () => { + expect( + parseSubscriptionTombstones({ a: '2026-01-02T00:00:00Z', b: 'invalid', c: 123 }) + ).toEqual({ a: '2026-01-02T00:00:00Z' }); + }); +}); + +describe('mergeSyncedSubscriptions', () => { + it('adds subscriptions present in incoming state', () => { + const merged = mergeSyncedSubscriptions( + { icalSubscriptions: [makeSubscription({ id: 'local' })], deletedSubscriptionIds: {} }, + { icalSubscriptions: [makeSubscription({ id: 'remote' })], deletedSubscriptionIds: {} }, + NOW + ); + expect(merged.icalSubscriptions.map((s) => s.id).sort()).toEqual(['local', 'remote']); + }); + + it('keeps newer copy per id, preferring local on tie', () => { + const merged = mergeSyncedSubscriptions( + { + icalSubscriptions: [ + makeSubscription({ id: 'a', name: 'Local newer', updatedAt: '2026-02-01T00:00:00Z' }), + makeSubscription({ id: 'b', name: 'Local tie' }), + ], + deletedSubscriptionIds: {}, + }, + { + icalSubscriptions: [ + makeSubscription({ id: 'a', name: 'Remote older', updatedAt: '2026-01-15T00:00:00Z' }), + makeSubscription({ id: 'b', name: 'Remote tie' }), + ], + deletedSubscriptionIds: {}, + }, + NOW + ); + const byId = Object.fromEntries(merged.icalSubscriptions.map((s) => [s.id, s.name])); + expect(byId).toEqual({ a: 'Local newer', b: 'Local tie' }); + }); + + it('applies incoming tombstone to local subscription', () => { + const merged = mergeSyncedSubscriptions( + { icalSubscriptions: [makeSubscription({ id: 'a' })], deletedSubscriptionIds: {} }, + { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, + NOW + ); + expect(merged.icalSubscriptions).toEqual([]); + expect(merged.deletedSubscriptionIds).toEqual({ a: '2026-02-01T00:00:00Z' }); + }); + + it('resurrects subscription edited after its deletion and clears tombstone', () => { + const merged = mergeSyncedSubscriptions( + { + icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: '2026-03-01T00:00:00Z' })], + deletedSubscriptionIds: {}, + }, + { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, + NOW + ); + expect(merged.icalSubscriptions.map((s) => s.id)).toEqual(['a']); + expect(merged.deletedSubscriptionIds).toEqual({}); + }); + + it('prunes tombstones older than TTL', () => { + const expired = new Date(NOW.getTime() - SUBSCRIPTION_TOMBSTONE_TTL_MS - 1000).toISOString(); + const merged = mergeSyncedSubscriptions( + { icalSubscriptions: [], deletedSubscriptionIds: { old: expired } }, + { icalSubscriptions: [], deletedSubscriptionIds: {} }, + NOW + ); + expect(merged.deletedSubscriptionIds).toEqual({}); + }); +}); diff --git a/lib/calendar-subscription-sync.ts b/lib/calendar-subscription-sync.ts new file mode 100644 index 000000000..ead0cdf4d --- /dev/null +++ b/lib/calendar-subscription-sync.ts @@ -0,0 +1,118 @@ +import type { ICalSubscription } from '@/stores/calendar-store'; + +/** + * Tombstones older than this are pruned from the synced blob. A device that + * stays offline longer than this may resurrect a deletion - the accepted + * trade-off for keeping the blob bounded. + */ +export const SUBSCRIPTION_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +export interface SyncedSubscriptionState { + icalSubscriptions: ICalSubscription[]; + /** Subscription id -> ISO time it was deleted. */ + deletedSubscriptionIds: Record; +} + +/** + * Structurally validates an `icalSubscriptions` value from a synced settings blob or + * settings-file import. Returns null when the value is not an array (a blob + * from a build that predates subscription sync), so callers can leave the local + * store untouched; malformed entries are dropped, ids are preserved. + */ +export function parseSyncedSubscriptions(value: unknown): ICalSubscription[] | null { + if (!Array.isArray(value)) return null; + + const subscriptions: ICalSubscription[] = []; + const seen = new Set(); + const now = new Date().toISOString(); + + for (const item of value) { + if (typeof item !== 'object' || item === null) continue; + const s = item as Record; + if (typeof s.id !== 'string' || !s.id || seen.has(s.id)) continue; + if (typeof s.url !== 'string' || !s.url.trim()) continue; + if (typeof s.calendarId !== 'string' || !s.calendarId.trim()) continue; + if (typeof s.name !== 'string' || !s.name.trim()) continue; + seen.add(s.id); + + const refreshInterval = typeof s.refreshInterval === 'number' && Number.isFinite(s.refreshInterval) && s.refreshInterval > 0 + ? s.refreshInterval + : 60; + + subscriptions.push({ + id: s.id, + url: s.url.trim().replace(/^webcals?:\/\//i, 'https://'), + calendarId: s.calendarId.trim(), + name: s.name.trim(), + color: typeof s.color === 'string' && s.color ? s.color : '#3b82f6', + refreshInterval, + lastRefreshed: typeof s.lastRefreshed === 'string' ? s.lastRefreshed : null, + accountId: typeof s.accountId === 'string' && s.accountId ? s.accountId : undefined, + updatedAt: typeof s.updatedAt === 'string' && s.updatedAt ? s.updatedAt : (typeof s.lastRefreshed === 'string' && s.lastRefreshed ? s.lastRefreshed : now), + }); + } + return subscriptions; +} + +/** Structurally validates a `deletedSubscriptionIds` map from a synced blob. */ +export function parseSubscriptionTombstones(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [id, deletedAt] of Object.entries(value as Record)) { + if (typeof deletedAt === 'string' && !Number.isNaN(Date.parse(deletedAt))) { + out[id] = deletedAt; + } + } + return out; +} + +/** + * Merges a synced subscription state into the local one. Per subscription id the + * newer `updatedAt` wins (ties keep the local copy); a deletion tombstone + * beats a subscription unless the subscription was edited after the deletion, which + * resurrects it and clears the tombstone. Merging (rather than replacing) + * keeps a stale per-account blob from wiping subscriptions created under another + * account or on another device. + */ +export function mergeSyncedSubscriptions( + local: SyncedSubscriptionState, + incoming: SyncedSubscriptionState, + now: Date = new Date() +): SyncedSubscriptionState { + const cutoff = now.getTime() - SUBSCRIPTION_TOMBSTONE_TTL_MS; + + // Union of tombstones, newest deletion time per id, pruned by TTL. + const tombstones: Record = {}; + for (const source of [local.deletedSubscriptionIds, incoming.deletedSubscriptionIds]) { + for (const [id, deletedAt] of Object.entries(source)) { + if (Date.parse(deletedAt) < cutoff) continue; + if (!tombstones[id] || Date.parse(deletedAt) > Date.parse(tombstones[id])) { + tombstones[id] = deletedAt; + } + } + } + + const byId = new Map(); + for (const s of local.icalSubscriptions) byId.set(s.id, s); + for (const s of incoming.icalSubscriptions) { + const existing = byId.get(s.id); + const existingTime = existing?.updatedAt ? Date.parse(existing.updatedAt) : 0; + const incomingTime = s.updatedAt ? Date.parse(s.updatedAt) : 0; + if (!existing || incomingTime > existingTime) { + byId.set(s.id, s); + } + } + + const icalSubscriptions: ICalSubscription[] = []; + for (const s of byId.values()) { + const deletedAt = tombstones[s.id]; + if (deletedAt !== undefined) { + const updatedTime = s.updatedAt ? Date.parse(s.updatedAt) : 0; + if (updatedTime <= Date.parse(deletedAt)) continue; + delete tombstones[s.id]; + } + icalSubscriptions.push(s); + } + + return { icalSubscriptions, deletedSubscriptionIds: tombstones }; +} diff --git a/stores/__tests__/calendar-subscription-sync.test.ts b/stores/__tests__/calendar-subscription-sync.test.ts new file mode 100644 index 000000000..5d71c481c --- /dev/null +++ b/stores/__tests__/calendar-subscription-sync.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useCalendarStore, type ICalSubscription } from '../calendar-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +function makeSubscription(overrides: Partial = {}): ICalSubscription { + return { + id: 'sub-test-id', + url: 'https://example.com/calendar.ics', + calendarId: 'cal-123', + name: 'Test Subscription', + color: '#3b82f6', + refreshInterval: 60, + lastRefreshed: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +const mockClient: IJMAPClient = { + deleteCalendar: async () => {}, + getAccountId: () => 'acc-1', +} as unknown as IJMAPClient; + +beforeEach(() => { + useCalendarStore.setState({ + calendars: [], + events: [], + icalSubscriptions: [], + deletedSubscriptionIds: {}, + }); +}); + +describe('removeICalSubscription', () => { + it('records a tombstone so the deletion can propagate through sync', async () => { + useCalendarStore.setState({ + icalSubscriptions: [makeSubscription({ id: 'sub-a' })], + }); + + await useCalendarStore.getState().removeICalSubscription(mockClient, 'sub-a'); + + const state = useCalendarStore.getState(); + expect(state.icalSubscriptions).toEqual([]); + expect(Object.keys(state.deletedSubscriptionIds)).toEqual(['sub-a']); + expect(Date.parse(state.deletedSubscriptionIds['sub-a'])).not.toBeNaN(); + }); +}); + +describe('applySyncedState', () => { + it('merges by id on server loads instead of clobbering local subscriptions', () => { + useCalendarStore.setState({ + icalSubscriptions: [makeSubscription({ id: 'local-sub' })], + }); + + useCalendarStore.getState().applySyncedState( + [makeSubscription({ id: 'remote-sub' })], + {}, + { merge: true } + ); + + const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort(); + expect(ids).toEqual(['local-sub', 'remote-sub']); + }); + + it('replaces wholesale on file imports', () => { + useCalendarStore.setState({ + icalSubscriptions: [makeSubscription({ id: 'local-sub' })], + }); + + useCalendarStore.getState().applySyncedState( + [makeSubscription({ id: 'imported-sub' })], + {}, + { merge: false } + ); + + expect(useCalendarStore.getState().icalSubscriptions.map((s) => s.id)).toEqual(['imported-sub']); + }); + + it('leaves the store untouched when the blob has no subscriptions array', () => { + useCalendarStore.setState({ + icalSubscriptions: [makeSubscription({ id: 'local-sub' })], + }); + + useCalendarStore.getState().applySyncedState(undefined, undefined, { merge: true }); + + expect(useCalendarStore.getState().icalSubscriptions.map((s) => s.id)).toEqual(['local-sub']); + }); + + it('removes subscriptions deleted on another device', () => { + const deletedAt = new Date(Date.now() - 1000).toISOString(); + useCalendarStore.setState({ + icalSubscriptions: [ + makeSubscription({ id: 'sub-a' }), + makeSubscription({ id: 'sub-b' }), + ], + }); + + useCalendarStore.getState().applySyncedState( + [makeSubscription({ id: 'sub-b' })], + { 'sub-a': deletedAt }, + { merge: true } + ); + + const state = useCalendarStore.getState(); + expect(state.icalSubscriptions.map((s) => s.id)).toEqual(['sub-b']); + expect(state.deletedSubscriptionIds).toEqual({ 'sub-a': deletedAt }); + }); +}); diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts index c4d689ea1..ad30dbc17 100644 --- a/stores/__tests__/settings-store-sync.test.ts +++ b/stores/__tests__/settings-store-sync.test.ts @@ -6,6 +6,7 @@ vi.mock('@/lib/browser-navigation', () => ({ apiFetch })); import { useSettingsStore } from '../settings-store'; import { useTemplateStore } from '../template-store'; +import { useCalendarStore } from '../calendar-store'; describe('settings sync debounce', () => { beforeEach(() => { @@ -17,6 +18,7 @@ describe('settings sync debounce', () => { useSettingsStore.getState().disableSync(); useSettingsStore.setState({ sendDelaySeconds: 0, settingsSyncDisabled: false }); useTemplateStore.setState({ templates: [], recentTemplateIds: [], deletedTemplateIds: {} }); + useCalendarStore.setState({ icalSubscriptions: [], deletedSubscriptionIds: {} }); }); it('flushes a pending snapshot for the account that scheduled it', async () => { @@ -266,3 +268,91 @@ describe('template push after a server load', () => { expect(await login()).toHaveLength(0); }); }); + +describe('calendar subscription sync', () => { + beforeEach(() => { + apiFetch.mockReset(); + apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + useSettingsStore.getState().disableSync(); + useSettingsStore.setState({ settingsSyncDisabled: false }); + useCalendarStore.setState({ icalSubscriptions: [], deletedSubscriptionIds: {} }); + }); + + it('merges server-loaded subscriptions instead of replacing local ones', () => { + useCalendarStore.setState({ + icalSubscriptions: [{ + id: 'local-1', + url: 'https://example.com/local.ics', + calendarId: 'cal-local', + name: 'Local', + color: '#3b82f6', + refreshInterval: 60, + lastRefreshed: null, + }], + }); + + const ok = useSettingsStore.getState().importSettings( + JSON.stringify({ + icalSubscriptions: [{ + id: 'remote-1', + url: 'https://example.com/remote.ics', + calendarId: 'cal-remote', + name: 'Remote', + color: '#ef4444', + refreshInterval: 60, + lastRefreshed: null, + }], + deletedSubscriptionIds: {}, + }), + { serverAccountId: 'acct-1' } + ); + + expect(ok).toBe(true); + const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort(); + expect(ids).toEqual(['local-1', 'remote-1']); + }); + + it('leaves local subscriptions untouched when importing a pre-subscription settings blob', () => { + useCalendarStore.setState({ + icalSubscriptions: [{ + id: 'keep-1', + url: 'https://example.com/keep.ics', + calendarId: 'cal-keep', + name: 'Keep', + color: '#3b82f6', + refreshInterval: 60, + lastRefreshed: null, + }], + }); + + const ok = useSettingsStore.getState().importSettings( + JSON.stringify({ sendDelaySeconds: 10 }), + { serverAccountId: 'acct-1' } + ); + + expect(ok).toBe(true); + expect(useCalendarStore.getState().icalSubscriptions).toHaveLength(1); + }); + + it('round-trips subscriptions through exportSettings', () => { + useCalendarStore.setState({ + icalSubscriptions: [{ + id: 'export-1', + url: 'https://example.com/export.ics', + calendarId: 'cal-export', + name: 'Exported Sub', + color: '#3b82f6', + refreshInterval: 60, + lastRefreshed: null, + }], + }); + + const exported = JSON.parse(useSettingsStore.getState().exportSettings()); + expect(exported.icalSubscriptions).toHaveLength(1); + expect(exported.icalSubscriptions[0].name).toBe('Exported Sub'); + expect(exported.deletedSubscriptionIds).toEqual({}); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 13fd76342..ca9d0dc7e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -26,6 +26,12 @@ import { apiFetch } from '@/lib/browser-navigation'; import { explainServerAuthError } from '@/lib/server-auth-status'; import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar'; import { getClientByLocalAccountId } from './client-registry'; +import { + parseSyncedSubscriptions, + parseSubscriptionTombstones, + mergeSyncedSubscriptions, +} from '@/lib/calendar-subscription-sync'; +import { registerCalendarSubscriptionSyncBridge } from './settings-store'; /** * When the Pro shell aggregates calendars/events from every connected @@ -544,6 +550,7 @@ export interface ICalSubscription { color: string; refreshInterval: number; // minutes lastRefreshed: string | null; + updatedAt?: string; } /** @@ -604,6 +611,7 @@ interface CalendarStore { // iCal subscriptions icalSubscriptions: ICalSubscription[]; + deletedSubscriptionIds: Record; addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise; updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; @@ -614,6 +622,12 @@ interface CalendarStore { clearICalSubscriptions: () => void; refreshAllSubscriptions: (client: IJMAPClient) => Promise; isSubscriptionCalendar: (calendarId: string) => boolean; + + applySyncedState: ( + subscriptions: unknown, + deletedSubscriptionIds: unknown, + opts: { merge: boolean } + ) => void; } const initialState = { @@ -629,6 +643,7 @@ const initialState = { error: null as string | null, dateRange: null as { start: string; end: string } | null, icalSubscriptions: [] as ICalSubscription[], + deletedSubscriptionIds: {} as Record, }; function getSafeCalendarViewMode(value: unknown): CalendarViewMode { @@ -1491,6 +1506,7 @@ export const useCalendarStore = create()( }); if (!calendar) throw new Error('Failed to create calendar'); + const now = new Date().toISOString(); const subscription: ICalSubscription = { id: generateUUID(), url: normalizedUrl, @@ -1501,13 +1517,18 @@ export const useCalendarStore = create()( color, refreshInterval, lastRefreshed: null, + updatedAt: now, }; - set((state) => ({ - calendars: [...state.calendars, calendar!], - selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id], - icalSubscriptions: [...state.icalSubscriptions, subscription], - })); + set((state) => { + const { [subscription.id]: _, ...remainingTombstones } = state.deletedSubscriptionIds; + return { + calendars: [...state.calendars, calendar!], + selectedCalendarIds: [...state.selectedCalendarIds, calendar!.id], + icalSubscriptions: [...state.icalSubscriptions, subscription], + deletedSubscriptionIds: remainingTombstones, + }; + }); // Initial fetch - roll back the calendar create if it fails so we // don't leave a phantom calendar around after a bad URL / 404 / etc. @@ -1558,7 +1579,11 @@ export const useCalendarStore = create()( } // Update local subscription record - const updated = { ...sub, ...normalizedUpdates }; + const updated: ICalSubscription = { + ...sub, + ...normalizedUpdates, + updatedAt: new Date().toISOString(), + }; set((state) => ({ icalSubscriptions: state.icalSubscriptions.map(s => s.id === subscriptionId ? updated : s), calendars: state.calendars.map(c => { @@ -1592,14 +1617,44 @@ export const useCalendarStore = create()( } } + const now = new Date().toISOString(); set((state) => ({ icalSubscriptions: state.icalSubscriptions.filter(s => s.id !== subscriptionId), + deletedSubscriptionIds: { ...state.deletedSubscriptionIds, [subscriptionId]: now }, calendars: state.calendars.filter(c => c.id !== sub.calendarId), selectedCalendarIds: state.selectedCalendarIds.filter(id => id !== sub.calendarId), events: state.events.filter(e => !e.calendarIds?.[sub.calendarId]), })); }, + applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => { + const parsedSubs = parseSyncedSubscriptions(subscriptions); + if (parsedSubs === null) return; + const parsedTombstones = parseSubscriptionTombstones(deletedSubscriptionIds); + + if (opts.merge) { + const merged = mergeSyncedSubscriptions( + { + icalSubscriptions: get().icalSubscriptions, + deletedSubscriptionIds: get().deletedSubscriptionIds, + }, + { + icalSubscriptions: parsedSubs, + deletedSubscriptionIds: parsedTombstones, + } + ); + set({ + icalSubscriptions: merged.icalSubscriptions, + deletedSubscriptionIds: merged.deletedSubscriptionIds, + }); + } else { + set({ + icalSubscriptions: parsedSubs, + deletedSubscriptionIds: parsedTombstones, + }); + } + }, + refreshICalSubscription: async (client, subscriptionId) => { const existing = refreshInFlight.get(subscriptionId); if (existing) return existing; @@ -1751,10 +1806,12 @@ export const useCalendarStore = create()( // They're now scoped per-account via sub.accountId - wiping them // here would lose them from localStorage on every switch. const preservedSubs = get().icalSubscriptions; + const preservedTombstones = get().deletedSubscriptionIds; set({ ...initialState, selectedDate: displayNow(), icalSubscriptions: preservedSubs, + deletedSubscriptionIds: preservedTombstones, }); import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => { useCalendarNotificationStore.getState().clearAll(); @@ -1779,7 +1836,22 @@ export const useCalendarStore = create()( selectedCalendarIds: state.selectedCalendarIds, viewMode: state.viewMode, icalSubscriptions: state.icalSubscriptions, + deletedSubscriptionIds: state.deletedSubscriptionIds, }), } ) ); + +registerCalendarSubscriptionSyncBridge( + { + getSyncedState: () => ({ + icalSubscriptions: useCalendarStore.getState().icalSubscriptions, + deletedSubscriptionIds: useCalendarStore.getState().deletedSubscriptionIds, + }), + applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => { + useCalendarStore.getState().applySyncedState(subscriptions, deletedSubscriptionIds, opts); + }, + }, + (listener) => useCalendarStore.subscribe(listener) +); + diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 2a7862171..d2a81a7b1 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -130,6 +130,32 @@ function localTemplatesAhead(serverSettings: Record | null): bo templateStateKey(serverSettings.templates, serverSettings.deletedTemplateIds); } +// --- Calendar subscription sync bridge --------------------------------------- +// iCal subscriptions ride along in the synced settings blob to avoid turning +// into writable/lost calendars on browser cache clear or new devices. +interface CalendarSubscriptionSyncBridge { + getSyncedState: () => { + icalSubscriptions: unknown[]; + deletedSubscriptionIds: Record; + }; + applySyncedState: ( + subscriptions: unknown, + deletedSubscriptionIds: unknown, + opts: { merge: boolean } + ) => void; +} + +let calendarSubscriptionSyncBridge: CalendarSubscriptionSyncBridge | null = null; +let onCalendarSubscriptionStoreChange: (() => void) | null = null; + +export function registerCalendarSubscriptionSyncBridge( + bridge: CalendarSubscriptionSyncBridge, + subscribe: (listener: () => void) => void +): void { + calendarSubscriptionSyncBridge = bridge; + subscribe(() => onCalendarSubscriptionStoreChange?.()); +} + async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise { syncLog('Syncing settings to server for', job.username); const res = await apiFetch('/api/settings', { @@ -945,6 +971,7 @@ export const useSettingsStore = create()( exportSettings: () => { const state = get(); const templateSync = templateSyncBridge?.getSyncedState(); + const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState(); const settings = { fontSize: state.fontSize, density: state.density, @@ -1066,6 +1093,14 @@ export const useSettingsStore = create()( deletedTemplateIds: templateSync.deletedTemplateIds, } : {}), + // Omitted entirely if the bridge has not registered, so an importing + // device leaves its calendar subscriptions alone. + ...(subscriptionSync + ? { + icalSubscriptions: subscriptionSync.icalSubscriptions, + deletedSubscriptionIds: subscriptionSync.deletedSubscriptionIds, + } + : {}), }; return JSON.stringify(settings, null, 2); }, @@ -1171,6 +1206,11 @@ export const useSettingsStore = create()( settings.deletedTemplateIds, { merge: Boolean(opts?.serverAccountId) } ); + calendarSubscriptionSyncBridge?.applySyncedState( + settings.icalSubscriptions, + settings.deletedSubscriptionIds, + { merge: Boolean(opts?.serverAccountId) } + ); return true; } catch (error) { @@ -1671,12 +1711,17 @@ if (typeof window !== 'undefined') { if (useSettingsStore.getState().settingsSyncDisabled) return; triggerSync(); }; - // Ensure template-store is loaded (and the bridge registered) even before - // any UI component imports it, so the first sync push already carries the - // templates. Best effort: the UI imports the store itself when it needs it, - // and under vitest a short test file can finish (and tear its environment - // down) before this chain has loaded, which rejects the import. + + // And when iCal subscriptions change (they ride along in the synced blob). + onCalendarSubscriptionStoreChange = () => { + if (useSettingsStore.getState().settingsSyncDisabled) return; + triggerSync(); + }; + + // Ensure template-store and calendar-store are loaded (and bridges registered) even before + // any UI component imports them, so the first sync push already carries them. import('./template-store').catch(() => {}); + import('./calendar-store').catch(() => {}); } /** From bdabf5893c7241e84cc7db5e3647421474378050 Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Sun, 20 Sep 2026 01:36:13 +0200 Subject: [PATCH 2/4] test(calendar): add edge-case tests and scheme validation for ical sync (cherry picked from commit b2296e1f5ce2698c0bb2f4ea768653c0adb4d520) --- .../calendar-subscription-sync.test.ts | 34 ++++++++++++++++++- lib/calendar-subscription-sync.ts | 4 ++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/calendar-subscription-sync.test.ts b/lib/__tests__/calendar-subscription-sync.test.ts index 8436f0b8b..5d6ba1b2b 100644 --- a/lib/__tests__/calendar-subscription-sync.test.ts +++ b/lib/__tests__/calendar-subscription-sync.test.ts @@ -45,12 +45,23 @@ describe('parseSyncedSubscriptions', () => { makeSubscription({ id: 'a', name: 'Duplicate' }), { ...makeSubscription(), id: undefined }, { ...makeSubscription(), url: '' }, + { ...makeSubscription(), url: 'ftp://example.com/unsupported.ics' }, { ...makeSubscription(), calendarId: '' }, { ...makeSubscription(), name: ' ' }, 'invalid string entry', ]); expect(result!.map((s) => s.id)).toEqual(['a']); }); + + it('normalizes non-finite or negative refresh intervals to default (60m)', () => { + const result = parseSyncedSubscriptions([ + makeSubscription({ id: 's1', refreshInterval: -10 }), + makeSubscription({ id: 's2', refreshInterval: NaN as unknown as number }), + makeSubscription({ id: 's3', refreshInterval: Infinity as unknown as number }), + makeSubscription({ id: 's4', refreshInterval: 0 }), + ]); + expect(result?.every((s) => s.refreshInterval === 60)).toBe(true); + }); }); describe('parseSubscriptionTombstones', () => { @@ -76,6 +87,16 @@ describe('mergeSyncedSubscriptions', () => { expect(merged.icalSubscriptions.map((s) => s.id).sort()).toEqual(['local', 'remote']); }); + it('preserves subscriptions from multiple distinct accounts during merge', () => { + const merged = mergeSyncedSubscriptions( + { icalSubscriptions: [makeSubscription({ id: 'sub-1', accountId: 'acc-1' })], deletedSubscriptionIds: {} }, + { icalSubscriptions: [makeSubscription({ id: 'sub-2', accountId: 'acc-2' })], deletedSubscriptionIds: {} }, + NOW + ); + expect(merged.icalSubscriptions).toHaveLength(2); + expect(merged.icalSubscriptions.map((s) => s.accountId)).toEqual(['acc-1', 'acc-2']); + }); + it('keeps newer copy per id, preferring local on tie', () => { const merged = mergeSyncedSubscriptions( { @@ -108,6 +129,17 @@ describe('mergeSyncedSubscriptions', () => { expect(merged.deletedSubscriptionIds).toEqual({ a: '2026-02-01T00:00:00Z' }); }); + it('tombstone wins over subscription on exact timestamp tie (defensive deletion)', () => { + const timestamp = '2026-02-01T00:00:00.000Z'; + const merged = mergeSyncedSubscriptions( + { icalSubscriptions: [makeSubscription({ id: 'a', updatedAt: timestamp })], deletedSubscriptionIds: {} }, + { icalSubscriptions: [], deletedSubscriptionIds: { a: timestamp } }, + NOW + ); + expect(merged.icalSubscriptions).toEqual([]); + expect(merged.deletedSubscriptionIds).toEqual({ a: timestamp }); + }); + it('resurrects subscription edited after its deletion and clears tombstone', () => { const merged = mergeSyncedSubscriptions( { @@ -117,7 +149,7 @@ describe('mergeSyncedSubscriptions', () => { { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, NOW ); - expect(merged.icalSubscriptions.map((s) => s.id)).toEqual(['a']); + expect(merged.icalSubscriptions).toEqual(['a']); expect(merged.deletedSubscriptionIds).toEqual({}); }); diff --git a/lib/calendar-subscription-sync.ts b/lib/calendar-subscription-sync.ts index ead0cdf4d..56140d845 100644 --- a/lib/calendar-subscription-sync.ts +++ b/lib/calendar-subscription-sync.ts @@ -31,6 +31,8 @@ export function parseSyncedSubscriptions(value: unknown): ICalSubscription[] | n const s = item as Record; if (typeof s.id !== 'string' || !s.id || seen.has(s.id)) continue; if (typeof s.url !== 'string' || !s.url.trim()) continue; + const normalizedUrl = s.url.trim().replace(/^webcals?:\/\//i, 'https://'); + if (!/^https?:\/\//i.test(normalizedUrl)) continue; if (typeof s.calendarId !== 'string' || !s.calendarId.trim()) continue; if (typeof s.name !== 'string' || !s.name.trim()) continue; seen.add(s.id); @@ -41,7 +43,7 @@ export function parseSyncedSubscriptions(value: unknown): ICalSubscription[] | n subscriptions.push({ id: s.id, - url: s.url.trim().replace(/^webcals?:\/\//i, 'https://'), + url: normalizedUrl, calendarId: s.calendarId.trim(), name: s.name.trim(), color: typeof s.color === 'string' && s.color ? s.color : '#3b82f6', From 6c1b6f32a0fe5b2aa34174812a85e2dd5afdbddb Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Sun, 20 Sep 2026 01:57:55 +0200 Subject: [PATCH 3/4] test(calendar): fix assertion in resurrects subscription test (cherry picked from commit 52759b2f963d121ebed8d946efa5d8d946e244ed) --- lib/__tests__/calendar-subscription-sync.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/__tests__/calendar-subscription-sync.test.ts b/lib/__tests__/calendar-subscription-sync.test.ts index 5d6ba1b2b..7020efe29 100644 --- a/lib/__tests__/calendar-subscription-sync.test.ts +++ b/lib/__tests__/calendar-subscription-sync.test.ts @@ -149,7 +149,7 @@ describe('mergeSyncedSubscriptions', () => { { icalSubscriptions: [], deletedSubscriptionIds: { a: '2026-02-01T00:00:00Z' } }, NOW ); - expect(merged.icalSubscriptions).toEqual(['a']); + expect(merged.icalSubscriptions.map((s) => s.id)).toEqual(['a']); expect(merged.deletedSubscriptionIds).toEqual({}); }); From e6c2d154a15e6eea98b2596cd20b4bd0389697b2 Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Tue, 29 Sep 2026 01:35:13 +0200 Subject: [PATCH 4/4] fix(calendar): keep synced iCal subscriptions with the login that owns them --- .../calendar-subscription-sync.test.ts | 8 ++++ lib/calendar-subscription-sync.ts | 9 ++++ .../calendar-subscription-sync.test.ts | 6 ++- stores/__tests__/settings-store-sync.test.ts | 45 +++++++++++++++++++ stores/calendar-store.ts | 15 +++++-- stores/settings-store.ts | 29 ++++++++---- 6 files changed, 98 insertions(+), 14 deletions(-) diff --git a/lib/__tests__/calendar-subscription-sync.test.ts b/lib/__tests__/calendar-subscription-sync.test.ts index 7020efe29..0d44a7100 100644 --- a/lib/__tests__/calendar-subscription-sync.test.ts +++ b/lib/__tests__/calendar-subscription-sync.test.ts @@ -39,6 +39,14 @@ describe('parseSyncedSubscriptions', () => { expect(result![0].updatedAt).toBe('2026-01-01T00:00:00Z'); }); + it('keeps the owning login so another device only acts through it', () => { + const owner = 'https://mail.example.com|alice@example.com'; + const [parsed] = parseSyncedSubscriptions([makeSubscription({ owner })])!; + expect(parsed.owner).toBe(owner); + const [unowned] = parseSyncedSubscriptions([{ ...makeSubscription(), owner: 42 }])!; + expect(unowned.owner).toBeUndefined(); + }); + it('drops entries without required fields or duplicate ids', () => { const result = parseSyncedSubscriptions([ makeSubscription({ id: 'a' }), diff --git a/lib/calendar-subscription-sync.ts b/lib/calendar-subscription-sync.ts index 56140d845..0dd39c7b1 100644 --- a/lib/calendar-subscription-sync.ts +++ b/lib/calendar-subscription-sync.ts @@ -7,6 +7,14 @@ import type { ICalSubscription } from '@/stores/calendar-store'; */ export const SUBSCRIPTION_TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000; +/** + * Identifies the login a subscription belongs to: server plus login name, + * the same key calendar-store records as `ICalSubscription.owner`. + */ +export function subscriptionOwnerFor(serverUrl: string, username: string): string { + return `${serverUrl.replace(/\/+$/, '').toLowerCase()}|${username.toLowerCase()}`; +} + export interface SyncedSubscriptionState { icalSubscriptions: ICalSubscription[]; /** Subscription id -> ISO time it was deleted. */ @@ -50,6 +58,7 @@ export function parseSyncedSubscriptions(value: unknown): ICalSubscription[] | n refreshInterval, lastRefreshed: typeof s.lastRefreshed === 'string' ? s.lastRefreshed : null, accountId: typeof s.accountId === 'string' && s.accountId ? s.accountId : undefined, + owner: typeof s.owner === 'string' && s.owner ? s.owner : undefined, updatedAt: typeof s.updatedAt === 'string' && s.updatedAt ? s.updatedAt : (typeof s.lastRefreshed === 'string' && s.lastRefreshed ? s.lastRefreshed : now), }); } diff --git a/stores/__tests__/calendar-subscription-sync.test.ts b/stores/__tests__/calendar-subscription-sync.test.ts index 5d71c481c..dfd4119f3 100644 --- a/stores/__tests__/calendar-subscription-sync.test.ts +++ b/stores/__tests__/calendar-subscription-sync.test.ts @@ -19,8 +19,12 @@ function makeSubscription(overrides: Partial = {}): ICalSubscr const mockClient: IJMAPClient = { deleteCalendar: async () => {}, getAccountId: () => 'acc-1', + getServerUrl: () => 'https://mail.example.com', + getUsername: () => 'alice@example.com', } as unknown as IJMAPClient; +const OWNER = 'https://mail.example.com|alice@example.com'; + beforeEach(() => { useCalendarStore.setState({ calendars: [], @@ -33,7 +37,7 @@ beforeEach(() => { describe('removeICalSubscription', () => { it('records a tombstone so the deletion can propagate through sync', async () => { useCalendarStore.setState({ - icalSubscriptions: [makeSubscription({ id: 'sub-a' })], + icalSubscriptions: [makeSubscription({ id: 'sub-a', owner: OWNER })], }); await useCalendarStore.getState().removeICalSubscription(mockClient, 'sub-a'); diff --git a/stores/__tests__/settings-store-sync.test.ts b/stores/__tests__/settings-store-sync.test.ts index ad30dbc17..873aed0ff 100644 --- a/stores/__tests__/settings-store-sync.test.ts +++ b/stores/__tests__/settings-store-sync.test.ts @@ -355,4 +355,49 @@ describe('calendar subscription sync', () => { expect(exported.icalSubscriptions[0].name).toBe('Exported Sub'); expect(exported.deletedSubscriptionIds).toEqual({}); }); + it('pushes only the active login\'s subscriptions into its server blob', async () => { + const sub = (id: string, owner?: string) => ({ + id, url: `https://example.com/${id}.ics`, calendarId: `cal-${id}`, name: id, + color: '#3b82f6', refreshInterval: 60, lastRefreshed: null, owner, + }); + useSettingsStore.getState().enableSync('Alice@Example.com', 'https://mail.example.com/'); + // Secret feed URLs of another login must not land in this login's blob, + // and unowned (pre-owner) entries are claimed locally before they sync. + useCalendarStore.setState({ + icalSubscriptions: [ + sub('mine', 'https://mail.example.com|alice@example.com'), + sub('bobs', 'https://other.example.org|bob@example.org'), + sub('unowned'), + ], + }); + + await useSettingsStore.getState().flushSync(); + + const body = JSON.parse((apiFetch.mock.calls[0][1] as RequestInit).body as string); + expect(body.settings.icalSubscriptions.map((s: { id: string }) => s.id)).toEqual(['mine']); + }); + + it('ignores other logins\' subscriptions in a server blob written before owners were synced', async () => { + const sub = (id: string, owner?: string) => ({ + id, url: `https://example.com/${id}.ics`, calendarId: `cal-${id}`, name: id, + color: '#3b82f6', refreshInterval: 60, lastRefreshed: null, owner, + }); + apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ + settings: { + icalSubscriptions: [ + sub('mine', 'https://mail.example.com|alice@example.com'), + sub('bobs', 'https://other.example.org|bob@example.org'), + sub('unowned'), + ], + deletedSubscriptionIds: {}, + }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await useSettingsStore.getState().loadFromServer('alice@example.com', 'https://mail.example.com'); + + // Unowned entries stay: calendar-store only adopts them once their + // calendar id and name are found in this login's account. + const ids = useCalendarStore.getState().icalSubscriptions.map((s) => s.id).sort(); + expect(ids).toEqual(['mine', 'unowned']); + }); }); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index ca9d0dc7e..c5574ce92 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -30,6 +30,7 @@ import { parseSyncedSubscriptions, parseSubscriptionTombstones, mergeSyncedSubscriptions, + subscriptionOwnerFor, } from '@/lib/calendar-subscription-sync'; import { registerCalendarSubscriptionSyncBridge } from './settings-store'; @@ -498,7 +499,7 @@ function getStoreEventDebugSnapshot(event: Partial | null | undef /** Identifies the login a subscription belongs to: server plus login name. */ export function subscriptionOwner(client: Pick): string { - return `${client.getServerUrl().replace(/\/+$/, '').toLowerCase()}|${client.getUsername().toLowerCase()}`; + return subscriptionOwnerFor(client.getServerUrl(), client.getUsername()); } type CalendarStoreSet = (partial: Partial | ((state: CalendarStore) => Partial)) => void; @@ -626,7 +627,7 @@ interface CalendarStore { applySyncedState: ( subscriptions: unknown, deletedSubscriptionIds: unknown, - opts: { merge: boolean } + opts: { merge: boolean; owner?: string } ) => void; } @@ -1628,8 +1629,14 @@ export const useCalendarStore = create()( }, applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => { - const parsedSubs = parseSyncedSubscriptions(subscriptions); - if (parsedSubs === null) return; + const parsed = parseSyncedSubscriptions(subscriptions); + if (parsed === null) return; + // A server blob speaks only for its own login. Older blobs may still + // carry other logins' subscriptions; unowned ones stay and are + // adopted by claimSubscription() only if their calendar is found. + const parsedSubs = opts.owner + ? parsed.filter((s) => !s.owner || s.owner === opts.owner) + : parsed; const parsedTombstones = parseSubscriptionTombstones(deletedSubscriptionIds); if (opts.merge) { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index d2a81a7b1..df0fadd6d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -6,6 +6,7 @@ import type { EmailTemplate } from '@/lib/template-types'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { apiFetch } from '@/lib/browser-navigation'; import { generateAccountId } from '@/lib/account-utils'; +import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync'; import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order'; import { DEFAULT_SUB_ADDRESS_DELIMITER, @@ -135,13 +136,13 @@ function localTemplatesAhead(serverSettings: Record | null): bo // into writable/lost calendars on browser cache clear or new devices. interface CalendarSubscriptionSyncBridge { getSyncedState: () => { - icalSubscriptions: unknown[]; + icalSubscriptions: Array<{ owner?: string }>; deletedSubscriptionIds: Record; }; applySyncedState: ( subscriptions: unknown, deletedSubscriptionIds: unknown, - opts: { merge: boolean } + opts: { merge: boolean; owner?: string } ) => void; } @@ -652,8 +653,8 @@ interface SettingsState { value: SettingsState[K] ) => void; resetToDefaults: () => void; - exportSettings: () => string; - importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean; + exportSettings: (opts?: { subscriptionOwner?: string }) => string; + importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean; // Folder icons setFolderIcon: (mailboxId: string, icon: string) => void; @@ -968,7 +969,7 @@ export const useSettingsStore = create()( applyAnimations(DEFAULT_SETTINGS.animationsEnabled); }, - exportSettings: () => { + exportSettings: (opts) => { const state = get(); const templateSync = templateSyncBridge?.getSyncedState(); const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState(); @@ -1097,7 +1098,14 @@ export const useSettingsStore = create()( // device leaves its calendar subscriptions alone. ...(subscriptionSync ? { - icalSubscriptions: subscriptionSync.icalSubscriptions, + // A server blob carries only its own login's subscriptions: + // feed URLs are secrets, and another login's (or an unowned + // one's) would reappear there after that login signs out. + icalSubscriptions: opts?.subscriptionOwner + ? subscriptionSync.icalSubscriptions.filter( + (sub) => sub.owner === opts.subscriptionOwner, + ) + : subscriptionSync.icalSubscriptions, deletedSubscriptionIds: subscriptionSync.deletedSubscriptionIds, } : {}), @@ -1105,7 +1113,7 @@ export const useSettingsStore = create()( return JSON.stringify(settings, null, 2); }, - importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => { + importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => { try { const settings = JSON.parse(json); @@ -1209,7 +1217,7 @@ export const useSettingsStore = create()( calendarSubscriptionSyncBridge?.applySyncedState( settings.icalSubscriptions, settings.deletedSubscriptionIds, - { merge: Boolean(opts?.serverAccountId) } + { merge: Boolean(opts?.serverAccountId), owner: opts?.subscriptionOwner } ); return true; @@ -1422,6 +1430,7 @@ export const useSettingsStore = create()( get().importSettings(JSON.stringify(settings), { serverAccountId: accountId, keepCrossAccountViews, + subscriptionOwner: subscriptionOwnerFor(serverUrl, username), }); isLoadingFromServer = false; writeSettingsOwner(accountId); @@ -1670,7 +1679,9 @@ if (typeof window !== 'undefined') { pendingSync = { username: syncUsername, serverUrl: syncServerUrl, - settings: JSON.parse(useSettingsStore.getState().exportSettings()), + settings: JSON.parse(useSettingsStore.getState().exportSettings({ + subscriptionOwner: subscriptionOwnerFor(syncServerUrl, syncUsername), + })), }; if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(async () => {