mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix(calendar): keep synced iCal subscriptions with the login that owns them
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user