mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge pull request #1064 from sanitz/feat/sync-ical-subscriptions
feat(calendar): sync iCal subscriptions via encrypted settings store
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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> = {}): 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('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' }),
|
||||
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', () => {
|
||||
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('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(
|
||||
{
|
||||
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('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(
|
||||
{
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
deletedSubscriptionIds: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const s = item as Record<string, unknown>;
|
||||
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);
|
||||
|
||||
const refreshInterval = typeof s.refreshInterval === 'number' && Number.isFinite(s.refreshInterval) && s.refreshInterval > 0
|
||||
? s.refreshInterval
|
||||
: 60;
|
||||
|
||||
subscriptions.push({
|
||||
id: s.id,
|
||||
url: normalizedUrl,
|
||||
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,
|
||||
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),
|
||||
});
|
||||
}
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
/** Structurally validates a `deletedSubscriptionIds` map from a synced blob. */
|
||||
export function parseSubscriptionTombstones(value: unknown): Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, deletedAt] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, string> = {};
|
||||
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<string, ICalSubscription>();
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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> = {}): 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',
|
||||
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: [],
|
||||
events: [],
|
||||
icalSubscriptions: [],
|
||||
deletedSubscriptionIds: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeICalSubscription', () => {
|
||||
it('records a tombstone so the deletion can propagate through sync', async () => {
|
||||
useCalendarStore.setState({
|
||||
icalSubscriptions: [makeSubscription({ id: 'sub-a', owner: OWNER })],
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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,136 @@ 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({});
|
||||
});
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,13 @@ import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
import { getClientByLocalAccountId } from './client-registry';
|
||||
import { calendarHooks } from '@/lib/plugin-hooks';
|
||||
import { eventToView } from '@/lib/plugin-projection';
|
||||
import {
|
||||
parseSyncedSubscriptions,
|
||||
parseSubscriptionTombstones,
|
||||
mergeSyncedSubscriptions,
|
||||
subscriptionOwnerFor,
|
||||
} from '@/lib/calendar-subscription-sync';
|
||||
import { registerCalendarSubscriptionSyncBridge } from './settings-store';
|
||||
|
||||
/**
|
||||
* When the Pro shell aggregates calendars/events from every connected
|
||||
@@ -496,7 +503,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;
|
||||
@@ -548,6 +555,7 @@ export interface ICalSubscription {
|
||||
color: string;
|
||||
refreshInterval: number; // minutes
|
||||
lastRefreshed: string | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,6 +616,7 @@ interface CalendarStore {
|
||||
|
||||
// iCal subscriptions
|
||||
icalSubscriptions: ICalSubscription[];
|
||||
deletedSubscriptionIds: Record<string, string>;
|
||||
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
|
||||
updateICalSubscription: (client: IJMAPClient, subscriptionId: string, updates: { url?: string; name?: string; color?: string; refreshInterval?: number }) => Promise<void>;
|
||||
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||
@@ -618,6 +627,12 @@ interface CalendarStore {
|
||||
clearICalSubscriptions: () => void;
|
||||
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
||||
isSubscriptionCalendar: (calendarId: string) => boolean;
|
||||
|
||||
applySyncedState: (
|
||||
subscriptions: unknown,
|
||||
deletedSubscriptionIds: unknown,
|
||||
opts: { merge: boolean; owner?: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
@@ -633,6 +648,7 @@ const initialState = {
|
||||
error: null as string | null,
|
||||
dateRange: null as { start: string; end: string } | null,
|
||||
icalSubscriptions: [] as ICalSubscription[],
|
||||
deletedSubscriptionIds: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
function getSafeCalendarViewMode(value: unknown): CalendarViewMode {
|
||||
@@ -1355,6 +1371,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
});
|
||||
if (!calendar) throw new Error('Failed to create calendar');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const subscription: ICalSubscription = {
|
||||
id: generateUUID(),
|
||||
url: normalizedUrl,
|
||||
@@ -1365,13 +1382,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
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.
|
||||
@@ -1429,7 +1451,11 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
@@ -1464,8 +1490,10 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
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]),
|
||||
@@ -1473,6 +1501,40 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
void calendarHooks.onICalSubscriptionChange.emit({ subscriptionId, change: 'removed' });
|
||||
},
|
||||
|
||||
applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => {
|
||||
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) {
|
||||
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;
|
||||
@@ -1627,10 +1689,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
// 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();
|
||||
@@ -1655,6 +1719,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
selectedCalendarIds: state.selectedCalendarIds,
|
||||
viewMode: state.viewMode,
|
||||
icalSubscriptions: state.icalSubscriptions,
|
||||
deletedSubscriptionIds: state.deletedSubscriptionIds,
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -1858,3 +1923,17 @@ async function importEventsUnhooked(
|
||||
|
||||
return imported + linked;
|
||||
}
|
||||
|
||||
registerCalendarSubscriptionSyncBridge(
|
||||
{
|
||||
getSyncedState: () => ({
|
||||
icalSubscriptions: useCalendarStore.getState().icalSubscriptions,
|
||||
deletedSubscriptionIds: useCalendarStore.getState().deletedSubscriptionIds,
|
||||
}),
|
||||
applySyncedState: (subscriptions, deletedSubscriptionIds, opts) => {
|
||||
useCalendarStore.getState().applySyncedState(subscriptions, deletedSubscriptionIds, opts);
|
||||
},
|
||||
},
|
||||
(listener) => useCalendarStore.subscribe(listener)
|
||||
);
|
||||
|
||||
|
||||
+68
-11
@@ -8,6 +8,7 @@ import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type Notification
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { settingsHooks } from '@/lib/plugin-hooks';
|
||||
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,
|
||||
@@ -132,6 +133,32 @@ function localTemplatesAhead(serverSettings: Record<string, unknown> | 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: Array<{ owner?: string }>;
|
||||
deletedSubscriptionIds: Record<string, string>;
|
||||
};
|
||||
applySyncedState: (
|
||||
subscriptions: unknown,
|
||||
deletedSubscriptionIds: unknown,
|
||||
opts: { merge: boolean; owner?: string }
|
||||
) => 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<void> {
|
||||
syncLog('Syncing settings to server for', job.username);
|
||||
const res = await apiFetch('/api/settings', {
|
||||
@@ -684,8 +711,8 @@ interface SettingsState {
|
||||
) => void;
|
||||
resetToDefaults: () => void;
|
||||
/** `forSync` builds the snapshot pushed to the server; it is not a user export. */
|
||||
exportSettings: (opts?: { forSync?: boolean }) => string;
|
||||
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean;
|
||||
exportSettings: (opts?: { forSync?: boolean; subscriptionOwner?: string }) => string;
|
||||
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean;
|
||||
|
||||
// Folder icons
|
||||
setFolderIcon: (mailboxId: string, icon: string) => void;
|
||||
@@ -993,9 +1020,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}
|
||||
};
|
||||
|
||||
const buildExport = (): string => {
|
||||
const buildExport = (opts?: { subscriptionOwner?: string }): string => {
|
||||
const state = get();
|
||||
const templateSync = templateSyncBridge?.getSyncedState();
|
||||
const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState();
|
||||
const settings = {
|
||||
fontSize: state.fontSize,
|
||||
density: state.density,
|
||||
@@ -1121,6 +1149,21 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
deletedTemplateIds: templateSync.deletedTemplateIds,
|
||||
}
|
||||
: {}),
|
||||
// Omitted entirely if the bridge has not registered, so an importing
|
||||
// device leaves its calendar subscriptions alone.
|
||||
...(subscriptionSync
|
||||
? {
|
||||
// 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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return JSON.stringify(settings, null, 2);
|
||||
};
|
||||
@@ -1146,14 +1189,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
|
||||
exportSettings: (opts) => {
|
||||
const json = buildExport();
|
||||
const json = buildExport(opts);
|
||||
if (!opts?.forSync) {
|
||||
void settingsHooks.onSettingsExport.emit({ keys: Object.keys(JSON.parse(json)) });
|
||||
}
|
||||
return json;
|
||||
},
|
||||
|
||||
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => {
|
||||
importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => {
|
||||
try {
|
||||
const settings = JSON.parse(json);
|
||||
|
||||
@@ -1267,6 +1310,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
settings.deletedTemplateIds,
|
||||
{ merge: Boolean(opts?.serverAccountId) }
|
||||
);
|
||||
calendarSubscriptionSyncBridge?.applySyncedState(
|
||||
settings.icalSubscriptions,
|
||||
settings.deletedSubscriptionIds,
|
||||
{ merge: Boolean(opts?.serverAccountId), owner: opts?.subscriptionOwner }
|
||||
);
|
||||
|
||||
// A load from the server reports itself as a sync (loadFromServer);
|
||||
// only a file import the user picked is an import.
|
||||
@@ -1489,6 +1537,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
get().importSettings(JSON.stringify(settings), {
|
||||
serverAccountId: accountId,
|
||||
keepCrossAccountViews,
|
||||
subscriptionOwner: subscriptionOwnerFor(serverUrl, username),
|
||||
});
|
||||
isLoadingFromServer = false;
|
||||
writeSettingsOwner(accountId);
|
||||
@@ -1751,7 +1800,10 @@ if (typeof window !== 'undefined') {
|
||||
pendingSync = {
|
||||
username: syncUsername,
|
||||
serverUrl: syncServerUrl,
|
||||
settings: JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true })),
|
||||
settings: JSON.parse(useSettingsStore.getState().exportSettings({
|
||||
forSync: true,
|
||||
subscriptionOwner: subscriptionOwnerFor(syncServerUrl, syncUsername),
|
||||
})),
|
||||
};
|
||||
if (syncTimeout) clearTimeout(syncTimeout);
|
||||
syncTimeout = setTimeout(async () => {
|
||||
@@ -1792,12 +1844,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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user