fix: keep iCal subscriptions with the login that created them

Persisted calendar subscriptions were matched to an account by the bare
JMAP account id, or not at all for subscriptions saved by older builds,
and to their calendar by a bare id. Stalwart hands out the same small
ids on every server, so refreshing a subscription through another login
diffed away the events of an unrelated calendar there, and "Remove"
destroyed that calendar. The secret feed URLs also stayed in
localStorage after signing out.

- new subscriptions record their owner (server plus login name), and
  refresh, rename and remove act only through that login
- a subscription without an owner is adopted only when its calendar id
  and name both exist in the account; otherwise it is left alone and
  removing it only drops the local record
- signing out forgets that login's subscriptions, and a full sign-out
  clears them all
This commit is contained in:
Linus Rath
2026-09-26 15:24:30 +02:00
parent a5a06bb25f
commit 5cc3ddba91
3 changed files with 186 additions and 15 deletions
@@ -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<string, unknown>) {
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');
});
});
+21 -2
View File
@@ -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<AuthState>) => void): void {
useSettingsStore.getState().disableSync();
@@ -1111,6 +1123,9 @@ function performFullLogout(set: (state: Partial<AuthState>) => 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<AuthState>()(
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<AuthState>()(
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);
+75 -13
View File
@@ -448,6 +448,39 @@ function getStoreEventDebugSnapshot(event: Partial<CalendarEvent> | null | undef
};
}
/** Identifies the login a subscription belongs to: server plus login name. */
export function subscriptionOwner(client: Pick<IJMAPClient, 'getServerUrl' | 'getUsername'>): string {
return `${client.getServerUrl().replace(/\/+$/, '').toLowerCase()}|${client.getUsername().toLowerCase()}`;
}
type CalendarStoreSet = (partial: Partial<CalendarStore> | ((state: CalendarStore) => Partial<CalendarStore>)) => 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<boolean> {
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<void>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
/** 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<void>;
isSubscriptionCalendar: (calendarId: string) => boolean;
}
@@ -1395,6 +1440,7 @@ export const useCalendarStore = create<CalendarStore>()(
url: normalizedUrl,
calendarId: calendar.id,
accountId: client.getAccountId(),
owner: subscriptionOwner(client),
name,
color,
refreshInterval,
@@ -1437,6 +1483,10 @@ export const useCalendarStore = create<CalendarStore>()(
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<CalendarStore>()(
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<CalendarStore>()(
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<CalendarStore>()(
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<CalendarStore>()(
}
},
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.