fix(calendar): keep synced iCal subscriptions with the login that owns them

This commit is contained in:
Sebastian Sanitz
2026-10-12 11:53:31 +02:00
parent 6c1b6f32a0
commit e6c2d154a1
6 changed files with 98 additions and 14 deletions
@@ -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' }),
+9
View File
@@ -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),
});
}
@@ -19,8 +19,12 @@ function makeSubscription(overrides: Partial<ICalSubscription> = {}): 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');
@@ -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']);
});
});
+11 -4
View File
@@ -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<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()}`;
return subscriptionOwnerFor(client.getServerUrl(), client.getUsername());
}
type CalendarStoreSet = (partial: Partial<CalendarStore> | ((state: CalendarStore) => Partial<CalendarStore>)) => 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<CalendarStore>()(
},
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) {
+20 -9
View File
@@ -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<string, unknown> | null): bo
// into writable/lost calendars on browser cache clear or new devices.
interface CalendarSubscriptionSyncBridge {
getSyncedState: () => {
icalSubscriptions: unknown[];
icalSubscriptions: Array<{ owner?: string }>;
deletedSubscriptionIds: Record<string, string>;
};
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<SettingsState>()(
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<SettingsState>()(
// 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<SettingsState>()(
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<SettingsState>()(
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<SettingsState>()(
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 () => {