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 () => {