diff --git a/stores/__tests__/calendar-subscription-owner.test.ts b/stores/__tests__/calendar-subscription-owner.test.ts new file mode 100644 index 000000000..8e6c5380d --- /dev/null +++ b/stores/__tests__/calendar-subscription-owner.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { subscriptionOwner, useCalendarStore } from '@/stores/calendar-store'; + +// A persisted iCal subscription was matched to an account by its bare JMAP +// account id - or not at all for subscriptions from older builds - and its +// calendar by a bare id. Stalwart hands out small ids ("b", "c") on every +// server, so refreshing it through another login diffed away the events of +// an unrelated calendar there, and "Remove" destroyed that calendar. + +function fakeClient(server: string, username: string, jmapAccountId: string, calendars: Array<{ id: string; name: string }>) { + const deleted: string[][] = []; + const destroyedCalendars: string[] = []; + const client = { + getServerUrl: () => server, + getUsername: () => username, + getAccountId: () => jmapAccountId, + getCalendarsAccountId: () => jmapAccountId, + getCalendars: async () => calendars, + uploadBlob: async () => ({ blobId: 'blob-feed' }), + parseCalendarEvents: async () => [{ uid: 'feed-holiday-1', title: 'Public holiday', start: '2026-10-03T00:00:00' }], + getCalendarEvents: async () => B_EVENTS, + batchDeleteCalendarEvents: async (ids: string[]) => { deleted.push(ids); }, + updateCalendarEvent: async () => {}, + deleteCalendar: async (id: string) => { destroyedCalendars.push(id); }, + }; + return { client, deleted, destroyedCalendars }; +} + +// Account B's own "Work" calendar has raw id "b"; two personal events live in it. +const B_EVENTS = [ + { id: 'e1', uid: 'b-dentist', title: 'Dentist', calendarIds: { b: true }, start: '2026-10-01T09:00:00' }, + { id: 'e2', uid: 'b-review', title: 'Perf review', calendarIds: { b: true }, start: '2026-10-02T09:00:00' }, +]; +const B = () => fakeClient('https://mail-b.example', 'bob@b.example', 'c', [{ id: 'b', name: 'Work' }]); + +function subscribe(sub: Record) { + useCalendarStore.setState({ + icalSubscriptions: [{ id: 's1', url: 'https://feeds.example/holidays.ics', calendarId: 'b', name: 'Holidays', color: '#f00', refreshInterval: 60, lastRefreshed: null, ...sub }] as never, + }); +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', { status: 200 }))); + useCalendarStore.setState({ importEvents: vi.fn(async () => {}) as never, dateRange: null }); +}); + +describe('iCal subscriptions stay with the login that made them', () => { + it('an untagged subscription from another account does not wipe events on refresh', async () => { + subscribe({}); + const { client, deleted } = B(); + await useCalendarStore.getState().refreshAllSubscriptions(client as never); + expect(deleted).toEqual([]); + }); + + it('a subscription tagged with the same JMAP account id on another server is left alone', async () => { + subscribe({ accountId: 'c', owner: 'https://mail-a.example|alice@a.example' }); + const { client, deleted } = B(); + await useCalendarStore.getState().refreshAllSubscriptions(client as never); + expect(deleted).toEqual([]); + }); + + it('removing an unowned subscription does not destroy the calendar with its id', async () => { + subscribe({}); + const { client, destroyedCalendars } = B(); + await useCalendarStore.getState().removeICalSubscription(client as never, 's1'); + expect(destroyedCalendars).toEqual([]); + expect(useCalendarStore.getState().icalSubscriptions).toEqual([]); + }); + + it('adopts an untagged subscription whose calendar id and name are in this account', async () => { + subscribe({}); + const { client } = fakeClient('https://mail-b.example', 'bob@b.example', 'c', [{ id: 'b', name: 'Holidays' }]); + await useCalendarStore.getState().refreshICalSubscription(client as never, 's1'); + expect(useCalendarStore.getState().icalSubscriptions[0].owner).toBe(subscriptionOwner(client)); + expect(useCalendarStore.getState().icalSubscriptions[0].lastRefreshed).not.toBeNull(); + }); + + it('forgets a signed-out login\'s subscriptions and keeps the others', () => { + useCalendarStore.setState({ + icalSubscriptions: [ + { id: 's1', owner: 'https://mail-a.example|alice@a.example', url: 'https://calendar.example/private-SECRET/basic.ics' }, + { id: 's2', owner: 'https://mail-b.example|bob@b.example', url: 'https://feeds.example/b.ics' }, + ] as never, + }); + useCalendarStore.getState().forgetICalSubscriptions('https://mail-a.example|alice@a.example'); + expect(useCalendarStore.getState().icalSubscriptions.map((s) => s.id)).toEqual(['s2']); + useCalendarStore.getState().clearICalSubscriptions(); + expect(window.localStorage.getItem('calendar-storage')).not.toContain('private-SECRET'); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 4ad0c78c4..d919cb927 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -6,7 +6,7 @@ import { useIdentityStore } from './identity-store'; import { setClientLookup } from './client-registry'; import { useContactStore } from './contact-store'; import { useVacationStore } from './vacation-store'; -import { useCalendarStore } from './calendar-store'; +import { subscriptionOwner, useCalendarStore } from './calendar-store'; import { useFilterStore } from './filter-store'; import { useSettingsStore } from './settings-store'; import { useAccountStore, type AccountEntry } from './account-store'; @@ -1087,6 +1087,18 @@ function clearAllRefreshTimers(): void { * Synchronously clears all auth and feature store state. * Called during full logout (no remaining accounts). */ +/** + * Drop the calendar subscriptions of a login being signed out: they persist + * across account switches, and their feed URLs are often secret. + */ +function forgetCalendarSubscriptions(client: IJMAPClient): void { + try { + useCalendarStore.getState().forgetICalSubscriptions(subscriptionOwner(client)); + } catch { + // A client that cannot name its server and login owns no subscription. + } +} + function performFullLogout(set: (state: Partial) => void): void { useSettingsStore.getState().disableSync(); @@ -1111,6 +1123,9 @@ function performFullLogout(set: (state: Partial) => void): void { }); clearAllStores(); + // Calendar subscriptions outlive account switches, but their feed URLs are + // often secret: nobody is signed in any more, so none may stay behind. + useCalendarStore.getState().clearICalSubscriptions(); // Remove persisted state AFTER the final set() so the persist middleware // doesn't re-write stale values. @@ -1841,6 +1856,7 @@ export const useAuthStore = create()( const oldClient = state.client; set({ client: null }); oldClient?.disconnect(); + if (oldClient && !wasDemoMode) forgetCalendarSubscriptions(oldClient); // Remove client from multi-account map if (accountId) { @@ -1954,7 +1970,10 @@ export const useAuthStore = create()( void clearSlotCredentials(slot, wasOAuth); clearRefreshTimer(accountId); const client = clients.get(accountId); - if (client) { try { client.disconnect(); } catch { /* noop */ } } + if (client) { + forgetCalendarSubscriptions(client); + try { client.disconnect(); } catch { /* noop */ } + } clients.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 77338d1cf..6819e8a74 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -448,6 +448,39 @@ 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()}`; +} + +type CalendarStoreSet = (partial: Partial | ((state: CalendarStore) => Partial)) => void; + +/** + * Whether `client` may act on `sub`: refresh it (a destructive diff of the + * calendar's events), rename or delete its calendar. A subscription owned + * by this login may. One with another owner may not. One without an owner + * (created before owners were recorded) is adopted only when its calendar + * id and name both exist in this account - a bare id match would name an + * unrelated calendar in another account or on another server. + */ +async function claimSubscription(client: IJMAPClient, sub: ICalSubscription, set: CalendarStoreSet): Promise { + const owner = subscriptionOwner(client); + if (sub.owner) return sub.owner === owner; + if (sub.accountId && sub.accountId !== client.getAccountId()) return false; + let calendars: Calendar[]; + try { + calendars = await client.getCalendars(); + } catch { + return false; + } + const found = calendars.some((c) => (c.originalId ?? c.id) === sub.calendarId && c.name === sub.name); + if (!found) return false; + set((state) => ({ + icalSubscriptions: state.icalSubscriptions.map((s) => (s.id === sub.id ? { ...s, owner } : s)), + })); + return true; +} + export interface ICalSubscription { id: string; url: string; @@ -457,6 +490,14 @@ export interface ICalSubscription { // legacy entries with no accountId are shown only in whichever account // the user has active (treated as floating). New subs always set it. accountId?: string; + /** + * The login that owns the subscription (subscriptionOwner()). A JMAP + * account id alone is not unique - two servers hand out the same small + * ids - so a subscription is only refreshed or removed through the login + * it was created with. Missing on subscriptions created before this was + * recorded; those are adopted once their calendar is found in the account. + */ + owner?: string; name: string; color: string; refreshInterval: number; // minutes @@ -525,6 +566,10 @@ interface CalendarStore { updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise; removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; + /** Drop every subscription of a signed-out login (its feed URLs are secrets). */ + forgetICalSubscriptions: (owner: string) => void; + /** Drop all subscriptions, e.g. once nobody is signed in any more. */ + clearICalSubscriptions: () => void; refreshAllSubscriptions: (client: IJMAPClient) => Promise; isSubscriptionCalendar: (calendarId: string) => boolean; } @@ -1395,6 +1440,7 @@ export const useCalendarStore = create()( url: normalizedUrl, calendarId: calendar.id, accountId: client.getAccountId(), + owner: subscriptionOwner(client), name, color, refreshInterval, @@ -1437,6 +1483,10 @@ export const useCalendarStore = create()( updateICalSubscription: async (client, subscriptionId, updates) => { const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); if (!sub) return; + if (!(await claimSubscription(client, sub, set))) { + debug.warn('calendar', 'Not updating a subscription of another account', { sub: sub.name }); + return; + } // Normalize webcal(s):// in the new URL so refreshes don't break. const normalizedUpdates: typeof updates = updates.url @@ -1475,11 +1525,15 @@ export const useCalendarStore = create()( const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); if (!sub) return; - try { - await client.deleteCalendar(sub.calendarId); - } catch (error) { - debug.error('Failed to delete subscription calendar:', error); - // Continue removing subscription record even if calendar delete fails + // Only destroy the calendar in the account the subscription belongs + // to: through another login its id names an unrelated calendar. + if (await claimSubscription(client, sub, set)) { + try { + await client.deleteCalendar(sub.calendarId); + } catch (error) { + debug.error('Failed to delete subscription calendar:', error); + // Continue removing subscription record even if calendar delete fails + } } set((state) => ({ @@ -1497,10 +1551,10 @@ export const useCalendarStore = create()( const sub = get().icalSubscriptions.find(s => s.id === subscriptionId); if (!sub) return; - // Skip if the subscription is scoped to a different JMAP account - // than the one this client is talking to - otherwise we'd create - // events in the wrong account / against a missing calendar. - if (sub.accountId && sub.accountId !== client.getAccountId()) { + // Skip unless the subscription belongs to this login - otherwise the + // diff below would delete the events of whatever calendar in this + // account happens to have the same id. + if (!(await claimSubscription(client, sub, set))) { debug.warn('calendar', 'Skipping subscription refresh: account mismatch', { sub: sub.name }); return; } @@ -1606,13 +1660,13 @@ export const useCalendarStore = create()( refreshAllSubscriptions: async (client) => { const { icalSubscriptions } = get(); const currentAccountId = client.getAccountId(); + const owner = subscriptionOwner(client); const now = Date.now(); for (const sub of icalSubscriptions) { - // Only refresh subs for the current account (or legacy untagged - // subs, which are treated as belonging to whichever account the - // user has active). - if (sub.accountId && sub.accountId !== currentAccountId) continue; + // Only refresh subs of this login. Older ones without an owner + // are checked (and adopted) by refreshICalSubscription. + if (sub.owner ? sub.owner !== owner : (sub.accountId && sub.accountId !== currentAccountId)) continue; const lastRefreshed = sub.lastRefreshed ? new Date(sub.lastRefreshed).getTime() : 0; const intervalMs = sub.refreshInterval * 60 * 1000; @@ -1627,6 +1681,14 @@ export const useCalendarStore = create()( } }, + forgetICalSubscriptions: (owner) => { + set((state) => ({ icalSubscriptions: state.icalSubscriptions.filter(s => s.owner !== owner) })); + }, + + clearICalSubscriptions: () => { + set({ icalSubscriptions: [] }); + }, + clearState: () => { refetchVisibleRange = null; // Preserve iCal subscriptions across the account-switch teardown.