Skip to content

feat(calendar): sync iCal subscriptions via encrypted settings store - #1064

Open
sanitz wants to merge 5 commits into
bulwarkmail:mainfrom
sanitz:feat/sync-ical-subscriptions
Open

sanitz wants to merge 5 commits into
bulwarkmail:mainfrom
sanitz:feat/sync-ical-subscriptions

Conversation

@sanitz

@sanitz sanitz commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

This PR persists and syncs iCal calendar subscriptions (icalSubscriptions) and their deletion tombstones (deletedSubscriptionIds) via Bulwark's existing encrypted settings synchronization (SETTINGS_SYNC_ENABLED), using the same bridge pattern introduced for email templates in #825 (CalendarSubscriptionSyncBridge).

Context & Problem Solved

  1. Subscriptions becoming regular/writable calendars on cache reset:
    When a user subscribes to an external iCal feed, Bulwark creates a JMAP calendar on the backend and stores the feed metadata (icalSubscriptions) only in browser localStorage. If that is cleared, the metadata is lost and the calendar falls back to a standard writable personal calendar, inviting accidental edits.

  2. Cross-device subscription sync:
    A subscription added on one device was not known on the user's other devices.

Changes

  • lib/calendar-subscription-sync.ts:
    • mergeSyncedSubscriptions: conflict-free merge of local and server state (newer updatedAt wins, deletion tombstones with TTL, a later edit resurrects a deleted subscription).
    • Validation and parsing helpers (parseSyncedSubscriptions, parseSubscriptionTombstones).
    • subscriptionOwnerFor(serverUrl, username): the owner key, now also used by subscriptionOwner().
  • stores/calendar-store.ts:
    • Registers CalendarSubscriptionSyncBridge to push subscriptions and tombstones into the encrypted settings payload.
    • Records a tombstone in deletedSubscriptionIds when a subscription is removed, so deletions propagate across devices.
    • applySyncedState merges incoming remote subscriptions without disrupting active UI state.
  • stores/settings-store.ts:
    • Extends the encrypted settings snapshot with icalSubscriptions and deletedSubscriptionIds.
    • Round-trip support in exportSettings and importSettings.

Per-login ownership

Subscriptions record their owning login (owner, server plus login name, #5cc3ddba). The sync respects it:

  • parseSyncedSubscriptions keeps owner.
  • A server blob only receives the active login's subscriptions, so one login's secret feed URLs never land in another login's settings, and subscriptions forgotten on sign-out don't come back with the next load.
  • A server load ignores subscriptions owned by another login. Unowned (older) ones still load and are adopted by claimSubscription() only once their calendar id and name are found in the account.

Testing Done

  • Unit and store tests in lib/__tests__/calendar-subscription-sync.test.ts, stores/__tests__/calendar-subscription-sync.test.ts and stores/__tests__/settings-store-sync.test.ts (merge, tombstones, corrupted payloads, owner kept, owner-filtered export and import).
  • Full suite with the CI parameters (--pool=forks --maxWorkers=2 --testTimeout=15000): 5240 passed. tsc --noEmit clean.
  • Running in production on our instance since 2026-09-29.

@sanitz
sanitz force-pushed the feat/sync-ical-subscriptions branch from ea979cc to e2948aa Compare September 19, 2026 15:21
sanitz and others added 4 commits September 20, 2026 01:49
…s them

Since subscriptions record their owning login (server plus login name),
syncing them through the settings blob has to respect that owner:

- The parser dropped the owner field, so every synced copy lost it and
  could again be refreshed or removed through an unrelated login.
- Each account's server blob received every local subscription, so one
  login's secret feed URLs were stored in another login's settings, and
  subscriptions forgotten on sign-out came back with the next load.

Keep the owner when parsing, push only the active login's subscriptions
into its blob, and on a server load ignore subscriptions owned by another
login. Unowned (older) entries still load and are adopted by
claimSubscription() only once their calendar is found. The owner key is
built in one place, subscriptionOwnerFor(), which subscriptionOwner() now
uses. The store test's client mock gains getServerUrl/getUsername.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant