From 1c201b077212c72f9ae03f5fb2409815dae15264 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:04:12 +0100 Subject: [PATCH 01/35] fix: stop private files with odd casing and links reaching Docker builds --- lib/__tests__/standalone-guard.test.ts | 32 +++++++++++++++++++++++++- scripts/standalone-guard.mjs | 9 +++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index 347d8f526..32965e957 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { afterEach, describe, expect, it } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; @@ -120,6 +120,36 @@ describe('standalone guard check', () => { expect(findForbidden(dir)).toEqual(['worktrees/webmail/data/']); }); + it('goes by the name in any case, as a case-insensitive disk and Docker context would', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.ENV', 'SECRET=1'); + put(dir, 'Plugin-Signing.KEY', 'SECRET=1'); + put(dir, 'Data/admin.json'); + put(dir, 'LOCAL-DATA/a'); + put(dir, 'Repos/a'); + expect(findForbidden(dir)).toEqual(['.ENV', 'Data/', 'LOCAL-DATA/', 'Plugin-Signing.KEY', 'Repos/']); + }); + + it('refuses a link: it would ship whatever it points at', () => { + const dir = tempDir(); + const outside = tempDir(); + put(outside, 'admin.json'); + put(dir, 'server.js'); + symlinkSync(outside, path.join(dir, 'state'), 'junction'); + expect(findForbidden(dir)).toEqual(['state (link)']); + }); + + it('leaves the links of .next and node_modules alone: Turbopack makes those on purpose', () => { + const dir = tempDir(); + const outside = tempDir(); + put(dir, 'node_modules/a/index.js'); + mkdirSync(path.join(dir, '.next', 'node_modules'), { recursive: true }); + symlinkSync(path.join(dir, 'node_modules', 'a'), path.join(dir, '.next', 'node_modules', 'a-1234'), 'junction'); + symlinkSync(outside, path.join(dir, 'node_modules', 'b'), 'junction'); + expect(findForbidden(dir)).toEqual([]); + }); + it('removes the .env files Next copies next to server.js, then passes', () => { const dir = tempDir(); put(dir, 'worktrees/webmail/server.js'); diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 192cc94e8..80a268e1d 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -25,7 +25,7 @@ const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'pa /** Never in a standalone tree, wherever it sits outside node_modules. */ const FORBIDDEN_DIRS = new Set(['data', 'local-data', 'repos']); -const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/; +const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/i; function toPosix(p) { return p.split(path.sep).join('/'); @@ -106,10 +106,13 @@ export function findForbidden(standaloneDir) { const visit = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) { + if (entry.isSymbolicLink()) { + // Not .next's or a package's, which aren't visited: Turbopack links those on purpose. + if (entry.name !== 'node_modules' && entry.name !== '.next') found.push(`${toPosix(path.relative(standaloneDir, full))} (link)`); + } else if (entry.isDirectory()) { // Packages may hold anything, and .next/server has routes named after data. if (entry.name === 'node_modules' || entry.name === '.next') continue; - if (FORBIDDEN_DIRS.has(entry.name)) found.push(toPosix(path.relative(standaloneDir, full)) + '/'); + if (FORBIDDEN_DIRS.has(entry.name.toLowerCase())) found.push(toPosix(path.relative(standaloneDir, full)) + '/'); else visit(full); } else if (FORBIDDEN_FILE.test(entry.name)) { found.push(toPosix(path.relative(standaloneDir, full))); From a048cb3553f4853a64358ed276a84d0a0a431a90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:10:26 +0100 Subject: [PATCH 02/35] fix: stop an approved mail server sending the desktop app to a server nobody approved --- lib/auth/verify-jmap-auth.ts | 7 +- lib/desktop/__tests__/server-redirect.test.ts | 200 ++++++++++++++++++ lib/desktop/server-redirect.ts | 31 +++ lib/stalwart/jmap-api.ts | 3 +- lib/stalwart/server-fetch.ts | 18 +- 5 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 lib/desktop/__tests__/server-redirect.test.ts create mode 100644 lib/desktop/server-redirect.ts diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 6c455cdf4..b565de6e0 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -6,6 +6,7 @@ import { } from '@/lib/security/url-guard'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; import { observeUpstream } from '@/lib/metrics/upstream'; +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; const VERIFY_TIMEOUT_MS = 10000; const MAX_REDIRECTS = 3; @@ -453,7 +454,11 @@ async function fetchVerifiedSession( if (!location) { throw new JmapAuthVerificationError('Failed to verify JMAP session', 502); } - currentUrl = new URL(location, currentUrl).toString(); + const next = new URL(location, currentUrl).toString(); + if (desktopRedirectRefused(currentUrl, next, options.trusted === true)) { + throw new JmapAuthVerificationError('The mail server redirected to a server that has not been approved', 502); + } + currentUrl = next; continue; } break; diff --git a/lib/desktop/__tests__/server-redirect.test.ts b/lib/desktop/__tests__/server-redirect.test.ts new file mode 100644 index 000000000..8442de02b --- /dev/null +++ b/lib/desktop/__tests__/server-redirect.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment node +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * An approved server sits inside the SSRF guard, so where it redirects to is the + * whole question. Real sockets: what counts is who received the request, and + * whether it carried the user's credentials. + */ + +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); +const holder = globalThis as unknown as Record; +const AUTH = `Basic ${Buffer.from('probe@example.org:not-a-real-password').toString('base64')}`; + +interface Host { + origin: string; + hits: { url: string; auth: boolean }[]; + answer: (req: IncomingMessage, res: ServerResponse) => void; +} + +const servers: Server[] = []; + +function session(res: ServerResponse) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ apiUrl: '/jmap/', accounts: { a: {} }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, username: 'probe@example.org' })); +} + +async function host(answer: Host['answer'] = (_req, res) => session(res)): Promise { + const self: Host = { origin: '', hits: [], answer }; + const server = createServer((req, res) => { + self.hits.push({ url: req.url ?? '', auth: !!req.headers.authorization }); + self.answer(req, res); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + self.origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + return self; +} + +const redirectTo = (location: string, status = 307): Host['answer'] => (_req, res) => { + res.writeHead(status, { location }); + res.end(); +}; + +function approve(...origins: string[]) { + holder[TRUST_KEY] = new Set(origins); +} + +beforeEach(() => { + vi.resetModules(); + delete holder[TRUST_KEY]; + delete holder[POLICY_KEY]; +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + delete holder[TRUST_KEY]; + delete holder[POLICY_KEY]; + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); +}); + +async function verify(server: string) { + const { fetchJmapSessionIdentity } = await import('@/lib/auth/verify-jmap-auth'); + return fetchJmapSessionIdentity(server, AUTH, { trusted: true }); +} + +async function fetchVia(url: string, init: RequestInit = {}) { + const { fetchJmapServer } = await import('@/lib/stalwart/server-fetch'); + return fetchJmapServer(url, { headers: { Authorization: AUTH }, ...init }, true); +} + +describe('the desktop build, signing in to an approved server', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + it('follows a redirect on the same origin, as Stalwart answers /.well-known/jmap', async () => { + const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res))); + approve(a.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(a.hits).toEqual([{ url: '/.well-known/jmap', auth: true }, { url: '/jmap/session', auth: true }]); + }); + + it('refuses a redirect to a server nobody approved, which gets nothing', async () => { + const b = await host(); + const a = await host(redirectTo('')); + a.answer = redirectTo(`${b.origin}/.well-known/jmap`); + approve(a.origin); + await expect(verify(a.origin)).rejects.toMatchObject({ name: 'JmapAuthVerificationError', status: 502 }); + expect(b.hits).toEqual([]); + }); + + it('counts another name for the same machine as another server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin.replace('127.0.0.1', 'localhost')}/x`)); + approve(a.origin); + await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 }); + expect(b.hits).toEqual([]); + }); + + it('follows one to another approved server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin, b.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]); + }); + + it('holds AllowedJmapServers across the redirect, even to an approved server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin, b.origin); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [a.origin] }; + await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 }); + expect(b.hits).toEqual([]); + }); +}); + +describe('the desktop build, other requests to an approved server', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + it('follows the same origin', async () => { + const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res))); + approve(a.origin); + expect((await fetchVia(`${a.origin}/.well-known/jmap`)).status).toBe(200); + expect(a.hits.map((hit) => hit.auth)).toEqual([true, true]); + }); + + it('refuses a server nobody approved, which gets nothing', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + approve(a.origin); + await expect(fetchVia(`${a.origin}/y`)).rejects.toThrow(); + expect(b.hits).toEqual([]); + }); + + it('still hands back the redirect when asked to', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + approve(a.origin); + expect((await fetchVia(`${a.origin}/y`, { redirect: 'manual' })).status).toBe(307); + expect(b.hits).toEqual([]); + }); + + it('postJmap keeps to the approved set too', async () => { + const b = await host(); + const a = await host(redirectTo(`https://127.0.0.1:${new URL(b.origin).port}/jmap/`)); + approve(a.origin); + const { postJmap } = await import('@/lib/stalwart/jmap-api'); + await expect(postJmap(`${a.origin}/jmap`, AUTH, '{}', { trusted: true })).rejects.toThrow(/untrusted host/); + expect(b.hits).toEqual([]); + }); +}); + +describe('which hops count', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + async function refused(from: string, to: string, approved = true) { + const { desktopRedirectRefused } = await import('../server-redirect'); + return desktopRedirectRefused(from, to, approved); + } + + it('lets the same host move up to https on the standard ports only', async () => { + expect(await refused('http://mail.lan/x', 'https://mail.lan/x')).toBe(false); + expect(await refused('http://mail.lan:8080/x', 'https://mail.lan/x')).toBe(true); + expect(await refused('http://mail.lan/x', 'https://mail.lan:8443/x')).toBe(true); + expect(await refused('http://mail.lan/x', 'https://other.lan/x')).toBe(true); + expect(await refused('https://mail.lan/x', 'http://mail.lan/x')).toBe(true); + }); + + it('leaves a public server to the hop-by-hop guard, but not to the policy', async () => { + expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(false); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: ['https://a.example'] }; + expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(true); + expect(await refused('https://a.example/x', 'https://a.example/y', false)).toBe(false); + }); +}); + +describe('a server install', () => { + it('follows an admin-configured server as it always did', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]); + }); + + it('other requests too, where fetch takes the credentials off', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + expect((await fetchVia(`${a.origin}/y`)).status).toBe(200); + expect(b.hits).toEqual([{ url: '/x', auth: false }]); + }); + + it('has no opinion on any hop', async () => { + const { desktopRedirectRefused } = await import('../server-redirect'); + approve(); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [] }; + expect(desktopRedirectRefused('http://a.lan/', 'http://169.254.169.254/', true)).toBe(false); + }); +}); diff --git a/lib/desktop/server-redirect.ts b/lib/desktop/server-redirect.ts new file mode 100644 index 000000000..64223a61d --- /dev/null +++ b/lib/desktop/server-redirect.ts @@ -0,0 +1,31 @@ +/** + * Where a mail server may send the desktop app's own server with a redirect. + * + * An approved server gets past the SSRF guard, and the guard only knows about the + * address the user approved. Following its redirects anywhere would let it aim the + * app at the LAN or at loopback, with the user's credentials on the request. So a + * hop stays on its origin, goes to another approved server, or moves the same host + * from http to https on the standard ports. Nothing else counts as the same server. + * + * A public server's hops go through the rebinding-safe fetch, which checks each of + * them. AllowedJmapServers holds for every hop, wherever it comes from. + * + * Every other build keeps its rules: a server an admin configured is the admin's. + */ + +import { mailServerRefusedByDevicePolicy } from './server-policy'; +import { isDesktopTrustedServer } from './trusted-servers'; + +function movesUpToHttps(from: URL, to: URL): boolean { + return from.protocol === 'http:' && to.protocol === 'https:' && from.hostname === to.hostname && from.port === '' && to.port === ''; +} + +/** `approved`: the request was let past the guard as an approved server. */ +export function desktopRedirectRefused(from: string | URL, to: string | URL, approved: boolean): boolean { + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false; + const source = new URL(from); + const target = new URL(to); + if (mailServerRefusedByDevicePolicy(target.href)) return true; + if (!approved || source.origin === target.origin) return false; + return !isDesktopTrustedServer(target.href) && !movesUpToHttps(source, target); +} diff --git a/lib/stalwart/jmap-api.ts b/lib/stalwart/jmap-api.ts index dfe1cd0e7..660267896 100644 --- a/lib/stalwart/jmap-api.ts +++ b/lib/stalwart/jmap-api.ts @@ -17,6 +17,7 @@ * this process — see calendar-agenda's session handling). */ +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; @@ -88,7 +89,7 @@ export async function postJmap( if (!location) return response; const next = new URL(location, url); - if (!isTrustedRedirect(url, next)) { + if (!isTrustedRedirect(url, next) || desktopRedirectRefused(url, next, options.trusted !== false)) { throw new JmapRedirectError( `JMAP endpoint redirected to an untrusted host: ${next.host}`, ); diff --git a/lib/stalwart/server-fetch.ts b/lib/stalwart/server-fetch.ts index 7e0296cf2..d5e657732 100644 --- a/lib/stalwart/server-fetch.ts +++ b/lib/stalwart/server-fetch.ts @@ -20,6 +20,7 @@ import { logger } from '@/lib/logger'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; import { observeUpstream } from '@/lib/metrics/upstream'; +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; import { isStoredCredentialRefused, recordStoredCredentialStatus, @@ -100,7 +101,9 @@ async function fetchUnbudgeted( // endpoints on, one the user chose) must not pin a connection for as long // as it likes. Callers with their own signal keep it. const init: ServerFetchInit = givenInit.signal ? givenInit : { ...givenInit, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }; - if (trusted !== false) { + // The desktop app's approved server gets its hops checked by hand as well. + const approvedOnDesktop = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && trusted !== false; + if (trusted !== false && !approvedOnDesktop) { return fetch(url, init); } @@ -111,12 +114,10 @@ async function fetchUnbudgeted( let body = rest.body; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - const response: PublicFetchResponse = await fetchPublicUrl(currentUrl, { - ...(rest as Omit), - method, - body: body as never, - headers, - }); + const hop = { ...(rest as Omit), method, body: body as never, headers }; + const response: PublicFetchResponse = approvedOnDesktop + ? ((await fetch(currentUrl, { ...hop, redirect: 'manual' } as RequestInit)) as unknown as PublicFetchResponse) + : await fetchPublicUrl(currentUrl, hop); if (redirect === 'manual' || response.status < 300 || response.status >= 400) { return response as unknown as Response; @@ -129,6 +130,9 @@ async function fetchUnbudgeted( await response.body?.cancel().catch(() => {}); const next = new URL(location, currentUrl); + if (desktopRedirectRefused(currentUrl, next, approvedOnDesktop)) { + throw new Error(`${currentUrl} redirected to a server that has not been approved`); + } if (next.origin !== new URL(currentUrl).origin) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === 'authorization' || key.toLowerCase() === 'cookie') { From 3a805d1631ec3394b5149d205d99d7880dc32482 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:42 +0100 Subject: [PATCH 03/35] fix: restart push verification when the mail server skipped it after a quick off and on --- lib/__tests__/web-push.test.ts | 35 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 29 ++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ac95771cf..b02630fa9 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -472,6 +472,41 @@ describe('enableWebPush', () => { // Registrations made before the client learned about the delivery filter are // repaired in the background on app start - nobody should have to find the // settings toggle to stop the spam pushes. +describe('enableWebPush when the verification does not come', () => { + // The relay's poll stays empty until `arrivesAfterTouch` calls come in, like a + // Stalwart that skipped the verification until the subscription is touched. + function installSilentRelay(codeOnceTouched: () => boolean) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) return json({ verificationCode: codeOnceTouched() ? 'late-code' : null }); + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + } + + afterEach(() => { vi.useRealTimers(); }); + + it('touches the subscription after the server\'s minute, which makes it send the code', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => (client.updatePushSubscription as ReturnType).mock.calls.length > 0); + + const run = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + const settled = run.then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(58_000); + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.updatePushSubscription).toHaveBeenCalledTimes(1); + expect((client.updatePushSubscription as ReturnType).mock.calls[0][0]).toBe('push-new'); + await vi.advanceTimersByTimeAsync(5_000); + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'late-code'); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index 4ab2e43de..568c20143 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -632,17 +632,32 @@ async function relayReportsDead( return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive'; } +// Stalwart rate-limits PushVerification per account (60 s by default). +const VERIFICATION_RESEND_AFTER_MS = 62_000; +const VERIFICATION_TIMEOUT_MS = 80_000; + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, + askAgain: () => Promise, ): Promise { - // Stalwart per-account rate-limits PushVerification posts (default 60s). - // If there are leftover unverified subscriptions on the account, our new - // one queues up behind them - so we wait long enough to clear one verify - // window even in the unlucky case. - const timeoutAt = Date.now() + 75_000; + // If there are leftover unverified subscriptions on the account, our new one + // queues up behind them, so we wait long enough to clear one verify window. + // + // Stalwart hands out the lowest free subscription id, so one destroyed and + // created again within the window (off and on again, or a retry) gets the same + // id. It takes that for a repeat and drops the verification for good, on 0.16.27 + // and 1.0 alike (push.rs: `verified_id == subscription.id`). Any change to the + // subscription after the window makes it send one. + const startedAt = Date.now(); + const timeoutAt = startedAt + VERIFICATION_TIMEOUT_MS; + let askedAgain = false; let delay = 400; while (Date.now() < timeoutAt) { + if (!askedAgain && Date.now() - startedAt >= VERIFICATION_RESEND_AFTER_MS) { + askedAgain = true; + await askAgain().catch(() => undefined); + } const res = await fetch( buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), ); @@ -866,7 +881,9 @@ export async function enableWebPush( const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); - const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => + params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), + ); await params.client.verifyPushSubscription(serverAssignedId, verificationCode); localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); From 4586f16c2ead15ac4866dbe203b930afa79acf7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:36:58 +0100 Subject: [PATCH 04/35] fix: remove the unverified subscription when push verification fails, and poll through a network blip --- lib/__tests__/web-push.test.ts | 62 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 31 +++++++++++------ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index b02630fa9..7f3dc3811 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -488,6 +488,68 @@ describe('enableWebPush when the verification does not come', () => { afterEach(() => { vi.useRealTimers(); }); + it('keeps polling through a network blip', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + let polls = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) { + polls += 1; + if (polls === 1) throw new TypeError('Failed to fetch'); + return json({ verificationCode: 'code' }); + } + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(5_000); + + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code'); + }); + + it('removes its unverified subscription and reads as off when the code never comes', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => false); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(120_000); + + expect(await settled).toMatch(/verif/i); + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + expect(client.verifyPushSubscription).not.toHaveBeenCalled(); + }); + + it('removes its subscription when the server refuses the code', async () => { + const client = makeClient([]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('Verification code does not match.')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow('does not match'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('keeps the old subscription working when its replacement fails verification', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('nope')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true })).rejects.toThrow('nope'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); + }); + it('touches the subscription after the server\'s minute, which makes it send the code', async () => { vi.useFakeTimers(); const client = makeClient([]); diff --git a/lib/web-push.ts b/lib/web-push.ts index 568c20143..66d296a7b 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -658,12 +658,16 @@ async function pollVerificationCode( askedAgain = true; await askAgain().catch(() => undefined); } - const res = await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), - ); - if (res.ok) { - const body = (await res.json()) as { verificationCode?: string | null }; - if (body.verificationCode) return body.verificationCode; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), + ); + if (res.ok) { + const body = (await res.json()) as { verificationCode?: string | null }; + if (body.verificationCode) return body.verificationCode; + } + } catch { + // A blip on the way to the relay; the code is still coming. } await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 2000); @@ -881,10 +885,17 @@ export async function enableWebPush( const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); - const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => - params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), - ); - await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + try { + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => + params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), + ); + await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + } catch (err) { + // Unverified, it never delivers anything, takes up one of the account's + // places and stands in the way of the next verification. + await params.client.destroyPushSubscription(serverAssignedId).catch(() => undefined); + throw err; + } localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); if (replacedServerId) { From 2551823006d6990a1fd5859cddc1f4f5e16de949 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:38:12 +0100 Subject: [PATCH 05/35] fix: let tabs take turns setting up push so two can't race into two subscriptions --- lib/__tests__/web-push.test.ts | 71 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 23 ++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 7f3dc3811..ddb758e49 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -569,6 +569,77 @@ describe('enableWebPush when the verification does not come', () => { }); }); +describe('two tabs at once', () => { + // What the browser's Web Locks do: one holder at a time, in order. + function installLocks() { + let tail: Promise = Promise.resolve(); + const request = vi.fn(async (_name: string, run: () => Promise) => { + const next = tail.then(run, run); + tail = next.catch(() => undefined); + return next; + }); + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } }); + return request; + } + + afterEach(() => { + delete (navigator as unknown as { locks?: unknown }).locks; + }); + + // A server that knows what was created and destroyed, as the race needs. + function statefulClient() { + const subs: JmapPushSubscription[] = []; + const client = makeClient(subs); + let next = 0; + (client.createPushSubscription as ReturnType).mockImplementation(async (params: { deviceClientId: string }) => { + const id = `push-${++next}`; + subs.push(sub(id, params.deviceClientId)); + return id; + }); + (client.verifyPushSubscription as ReturnType).mockImplementation(async (id: string) => { + if (client.destroyed.includes(id)) throw new Error('notFound'); + }); + return client; + } + + it('makes one subscription, and neither tab fails', async () => { + installLocks(); + const client = statefulClient(); + installFetch({}); + + const results = await Promise.allSettled([ + enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }), + enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }), + ]); + + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + expect(client.destroyed).toEqual([]); + expect(await client.listPushSubscriptions()).toHaveLength(1); + }); + + it('waits for a running enable before turning off', async () => { + const request = installLocks(); + const client = statefulClient(); + installFetch({}); + + const enabling = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await Promise.all([enabling, disableWebPush({ client, relayBaseUrl: RELAY })]); + + expect(request).toHaveBeenCalledTimes(2); + expect(await client.listPushSubscriptions()).toEqual([]); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('still works in a browser without Web Locks', async () => { + const client = statefulClient(); + installFetch({}); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index 66d296a7b..ef865513c 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -720,6 +720,15 @@ async function refreshSubscriptionExpires( } } +// Another tab may be on the same steps, and the relay and the server only know +// one registration per device: one tab would reap the subscription the other is +// verifying. Tabs take turns, in every browser that has Web Locks. Whoever comes +// second finds the first one's work and only refreshes it. +function oneTabAtATime(run: () => Promise): Promise { + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined; + return locks ? locks.request('bulwark-web-push', run) as Promise : run(); +} + export async function enableWebPush( params: EnableWebPushParams, ): Promise { @@ -732,7 +741,15 @@ export async function enableWebPush( const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); if (!relayBaseUrl) throw new Error('relayBaseUrl is required'); + // Outside the lock: the browser's question must come with the user's click. await ensurePermission(); + return oneTabAtATime(() => enableWebPushForThisTab(params, relayBaseUrl)); +} + +async function enableWebPushForThisTab( + params: EnableWebPushParams, + relayBaseUrl: string, +): Promise { const registration = await ensureServiceWorker(); const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl); @@ -914,7 +931,11 @@ export interface DisableWebPushParams { // (only when no other accounts still need it) the browser-wide // PushSubscription. Any single failure is swallowed so the user always ends // up in a "disabled" state locally. -export async function disableWebPush(params: DisableWebPushParams): Promise { +export function disableWebPush(params: DisableWebPushParams): Promise { + return oneTabAtATime(() => disableWebPushForThisTab(params)); +} + +async function disableWebPushForThisTab(params: DisableWebPushParams): Promise { const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); const accountId = params.client.getAccountId(); From 6ca6448b5e5f58dea8165f7edc08586425fcfd9d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:38:36 +0100 Subject: [PATCH 06/35] fix: remove the record from the old relay when push moves to another one --- lib/__tests__/web-push.test.ts | 54 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 40 ++++++++++++++++--------- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ddb758e49..1cd65d948 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -672,6 +672,48 @@ describe('resyncWebPush when the relay changed (#903)', () => { expect(client.destroyed).toContain('push-old'); }); + it('leaves no record on the relay it moved away from', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); + + expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + expect(calls).not.toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY); + expect(client.destroyed).toEqual(['push-old']); + }); + + it('keeps the old relay until the new subscription is verified', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('nope')); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.some((c) => c.method === 'DELETE')).toBe(false); + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe('https://old-relay.example'); + }); + + it('does not contact the built-in relay to clean up when third-party traffic is off', async () => { + setNoThirdPartyTraffic(true); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.filter((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toEqual([]); + }); + it('re-registers inside the daily limit when the admin switched relays, and only once', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); @@ -1556,6 +1598,18 @@ describe('disableWebPush', () => { expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); + it('drops the record on the relay it was registered with, not only the current one', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-tracked'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-tracked', THIS_DEVICE)]); + const calls = installFetch({}); + + await disableWebPush({ client, relayBaseUrl: RELAY }); + + expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + }); + it('drops the relay registration for this device', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([]); diff --git a/lib/web-push.ts b/lib/web-push.ts index ef865513c..3fcd93ccb 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -913,11 +913,16 @@ async function enableWebPushForThisTab( await params.client.destroyPushSubscription(serverAssignedId).catch(() => undefined); throw err; } + const previousRelay = localStorage.getItem(relayBaseKey(accountId)); localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); if (replacedServerId) { await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined); } + // Moved: the old relay would otherwise keep this browser's endpoint for 90 days. + if (previousRelay && previousRelay !== relayBaseUrl) { + await dropRelayRecord(previousRelay, deviceClientId); + } return { subscriptionId: serverAssignedId }; } @@ -927,6 +932,20 @@ export interface DisableWebPushParams { relayBaseUrl?: string; } +/** Nothing is sent to a relay that may not be used, such as the built-in one in the no-third-party mode. */ +async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Promise { + if (!relayBaseUrl || !relayAllowed(relayBaseUrl)) return true; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), + { method: 'DELETE' }, + ); + return res.ok; + } catch { + return false; + } +} + // Best-effort teardown: clear the JMAP subscription, the relay mapping, and // (only when no other accounts still need it) the browser-wide // PushSubscription. Any single failure is swallowed so the user always ends @@ -944,6 +963,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise undefined); + if (deviceClientId) { + // The relay this device registered with can differ from the current choice. + for (const relay of new Set([registeredWith ?? relayBaseUrl, relayBaseUrl])) { + await dropRelayRecord(relay, deviceClientId); + } } // Keep the deviceClientId around so a later re-enable for this account // reuses the same relay subscriptionId rather than scattering orphans. @@ -1053,15 +1073,7 @@ export async function revokePushDevice(params: { } await params.client.destroyPushSubscription(params.device.id); - if (relayBaseUrl && relayAllowed(relayBaseUrl) && params.device.deviceClientId) { - await fetch( - buildRelayUrl( - relayBaseUrl, - `/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`, - ), - { method: 'DELETE' }, - ).catch(() => undefined); - } + if (params.device.deviceClientId) await dropRelayRecord(relayBaseUrl, params.device.deviceClientId); } export async function isWebPushEnabled(accountId: string): Promise { From 376465ed80b774877d9d69da91f6b10850b8a35f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:44:37 +0100 Subject: [PATCH 07/35] fix: notify for every account a state-change push names --- lib/__tests__/sw-push-delivery.test.ts | 88 ++++++++++++++++++++++++++ public/sw.js | 15 ++++- 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/sw-push-delivery.test.ts diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts new file mode 100644 index 000000000..5999eee6b --- /dev/null +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// What one new message, several at once and several accounts turn into on screen. +const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + +type Preview = Record | null; +type Shown = { title: string; body?: unknown; data?: Record; tag: string; options: Record; close: () => void }; + +function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; openWindow?: (url: string) => Promise } = {}) { + const listeners = new Map void>(); + const shade = new Map(); + const previews: URL[] = []; + const cacheStore = new Map(); + if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs)); + const self = { + location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, + addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), + skipWaiting: () => {}, + clients: { claim: () => {}, matchAll: async () => extra.clients ?? [], openWindow: extra.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + const tag = String(options.tag ?? ''); + shade.set(tag, { title, body: options.body, data: options.data as Record, tag, options, close: () => shade.delete(tag) }); + }, + getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag), + }, + }; + const caches = { + open: async () => ({ + match: async (key: string) => { + const value = cacheStore.get(key); + return value === undefined ? undefined : { json: async () => JSON.parse(value) }; + }, + put: async (key: string, res: { text: () => Promise }) => { cacheStore.set(key, await res.text()); }, + }), + }; + const fetchMock = vi.fn(async (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + previews.push(url); + const body = preview(url); + return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) }; + }); + const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals }); + vm.runInContext(SW_SOURCE, context); + async function dispatch(type: string, event: Record) { + const waits: Promise[] = []; + listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); + await Promise.all(waits); + } + const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } }); + return { push, dispatch, shade, previews }; +} + +const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' }); +const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds }); + +afterEach(() => vi.useRealTimers()); + +describe('a state-change push naming several accounts', () => { + const stateChange = (changed: Record>, accountId = Object.keys(changed)[0]) => ({ + kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed, + }); + + it('notifies for each account with new mail', async () => { + const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) })); + await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']); + expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']); + expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' }); + }); + + it('leaves out an account that only changed some other way', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); + + it('still shows something for a ping without new mail in it', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ me: { Mailbox: 's1' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); +}); + +// -- end of cases -- diff --git a/public/sw.js b/public/sw.js index 62a4a7b62..14701f44e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -589,17 +589,26 @@ async function handlePush(event) { // message ids that passed it - spam filed into Junk never gets here. // - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped // as { changed: { [accountId]: {...} } }, fired for every ingested - // message including junk. The relay forwards a single account per push, - // so the first key is the one this notification is for. + // message including junk. Stalwart batches what changes between two + // pushes, so it can name the user's own account and a group's at once. const changed = payload && payload.changed && typeof payload.changed === "object" ? payload.changed : null; - const accountId = (payload && typeof payload.accountId === "string" && payload.accountId) + const named = (payload && typeof payload.accountId === "string" && payload.accountId) || (changed ? Object.keys(changed)[0] || "" : ""); const emailIds = payload && Array.isArray(payload.emailIds) ? payload.emailIds.filter((id) => typeof id === "string" && id) : []; + const withNewMail = emailIds.length > 0 || !changed + ? [] + : Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]); + for (const accountId of withNewMail.length > 0 ? withNewMail : [named]) { + await notifyAccount(accountId, emailIds, accountLabel); + } +} + +async function notifyAccount(accountId, emailIds, accountLabel) { // Never notify twice for the same message. A push can be redelivered (relay // retry, browser replay after coming back online) and, on the older // state-change path, a junk delivery wakes us while an unread message we From 7fb0242a7b6d1335f94f8b3137427485ac639db5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:45:17 +0100 Subject: [PATCH 08/35] fix: open the new message from its notification however much older mail is unread --- lib/__tests__/helpers/push-click.ts | 4 +- lib/__tests__/sw-notification-actions.test.ts | 5 +- lib/__tests__/sw-push-delivery.test.ts | 58 +++++++++++++++++++ lib/__tests__/sw-push-owner.test.ts | 12 ++-- lib/__tests__/sw-push-redacted.test.ts | 4 +- public/sw.js | 41 +++++++++---- 6 files changed, 101 insertions(+), 23 deletions(-) diff --git a/lib/__tests__/helpers/push-click.ts b/lib/__tests__/helpers/push-click.ts index 369efd6a2..eb2f8e2c0 100644 --- a/lib/__tests__/helpers/push-click.ts +++ b/lib/__tests__/helpers/push-click.ts @@ -5,7 +5,7 @@ import vm from 'node:vm'; export async function pushClick( accountId: string, preview: (url: string) => Promise, - options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {}, + options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean; onScreen?: string[] } = {}, ): Promise { const source = readFileSync(resolve('public/sw.js'), 'utf8'); let notification: { data: unknown } | undefined; @@ -33,7 +33,7 @@ export async function pushClick( }, registration: { showNotification: async (_title: string, data: { data: unknown }) => { notification = data; }, - getNotifications: async () => [], + getNotifications: async () => (options.onScreen?.length ? [{ data: { ids: options.onScreen } }] : []), }, }, caches: { open: async () => ({ diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts index 96e94949f..4f81014f9 100644 --- a/lib/__tests__/sw-notification-actions.test.ts +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -90,12 +90,13 @@ const previewOf = (unreadTotal: number) => JSON.stringify({ }); describe('the buttons a notification gets', () => { - const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => { + const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number; onScreen?: string[] }) => { const w = worker({ caches: await cachesWith(opts.prefs), max: opts.max, inline: opts.inline, fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }), + ...(opts.onScreen ? { notifications: [{ data: { ids: opts.onScreen } }] } : {}), }); await pushFor({})(w); expect(w.shown).toHaveLength(1); @@ -133,7 +134,7 @@ describe('the buttons a notification gets', () => { }); it('gives the group notification none: it names no message', async () => { - expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined(); + expect(await buttonsFor({ prefs: PREFS, onScreen: ['e0'] })).toBeUndefined(); }); it('gives a message without its login none', async () => { diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 5999eee6b..0acba2790 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -85,4 +85,62 @@ describe('a state-change push naming several accounts', () => { }); }); +describe('what a notification counts', () => { + it('opens the one new message, however much older mail is unread', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 40, slot: 0, login: 'fp', email: mail('e1') })); + await push(delivery('a', 'e1')); + const shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('Hello'); + expect(shown.data).toMatchObject({ kind: 'email', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' }); + }); + + it('counts the messages it announced, not the unread mail, and opens the inbox for a group', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 40, slot: 0, email: mail(String(url.searchParams.get('emailId')), `About ${url.searchParams.get('emailId')}`) })); + await push(delivery('a', 'e1')); + await push(delivery('a', 'e2')); + let shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('About e2\n+1 more message'); + expect(shown.data).toMatchObject({ kind: 'mail-list', accountId: 'a', slot: 0 }); + await push(delivery('a', 'e3', 'e4')); + shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('About e4\n+3 more messages'); + }); + + it('starts over once the notification was dismissed', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) })); + await push(delivery('a', 'e1')); + shade.get('bulwark-mail:a')!.close(); + await push(delivery('a', 'e2')); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e2' }); + }); + + it('does not count a redelivered message twice', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) })); + await push(delivery('a', 'e1')); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true }); + }); + + it('counts on the state-change path too', async () => { + let id = 'e1'; + const { push, shade } = loadWorker(() => ({ unreadTotal: 5, slot: 0, email: mail(id) })); + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + id = 'e2'; + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's2' } } }); + expect(shade.get('bulwark-mail:a')!.body).toBe('Hello\n+1 more message'); + }); + + it('says how many new messages without naming any when details are hidden', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 9, slot: 0, redacted: true, email: { id: url.searchParams.get('emailId'), threadId: 't' } })); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.body).toBe('You have new mail'); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + await push(delivery('a', 'e2')); + expect(shade.get('bulwark-mail:a')!.title).toBe('New mail (me@example.org)'); + expect(shade.get('bulwark-mail:a')!.body).toBe('2 new messages'); + }); +}); + // -- end of cases -- diff --git a/lib/__tests__/sw-push-owner.test.ts b/lib/__tests__/sw-push-owner.test.ts index ade1e246a..e735dee8d 100644 --- a/lib/__tests__/sw-push-owner.test.ts +++ b/lib/__tests__/sw-push-owner.test.ts @@ -15,16 +15,16 @@ function parseTarget(target: string) { describe('push click keeps the owning mailbox (#1491)', () => { for (const owner of ['personal', 'team']) { - for (const unreadTotal of [1, 3]) { + for (const onScreen of [0, 2]) { for (const warm of [false, true]) { - it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => { + it(`${owner}, ${onScreen} announced already, ${warm ? 'existing' : 'new'} window`, async () => { const target = await pushClick(owner, async (url) => { expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner); - return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } }; - }, { warm }); + return { slot: 0, unreadTotal: 3, email: { id: 'same-id', threadId: 't', subject: 'Mail' } }; + }, { warm, onScreen: Array.from({ length: onScreen }, (_, i) => `earlier-${i}`) }); const link = parseTarget(target); expect(link?.slot).toBe(0); - if (unreadTotal === 1) { + if (onScreen === 0) { expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner }); } else { expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner }); @@ -42,7 +42,7 @@ describe('push click keeps the owning mailbox (#1491)', () => { }); it('keeps the login slot and owner with a mount prefix and legacy payload', async () => { - const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true }); + const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true, onScreen: ['earlier'] }); expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/); expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 }); }); diff --git a/lib/__tests__/sw-push-redacted.test.ts b/lib/__tests__/sw-push-redacted.test.ts index 7f0477b3f..36fd14892 100644 --- a/lib/__tests__/sw-push-redacted.test.ts +++ b/lib/__tests__/sw-push-redacted.test.ts @@ -45,10 +45,10 @@ describe('service worker with a redacted preview', () => { expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' }); }); - it('counts several unread messages without naming any', async () => { + it('opens the one new message however much else is unread', async () => { const { push, calls } = loadWorker({ unreadTotal: 3, redacted: true, email: { id: 'e1', threadId: 't1' } }); await push(delivery); expect(calls[0].title).toBe('New mail'); - expect(calls[0].options.body).toBe('3 unread messages'); + expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' }); }); }); diff --git a/public/sw.js b/public/sw.js index 14701f44e..7681b4ac5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -693,31 +693,36 @@ async function notifyAccount(accountId, emailIds, accountLabel) { // into a single, self-updating notification instead of one toast per message // (Android renders one notification per unique tag, which is why 50 arrivals // used to stack 50 toasts). The newest message is the headline and a - // Gmail-style "+N more" line carries the rest, counted from the account's - // unread total. + // Gmail-style "+N more" line carries the rest: the messages this notification + // has announced and the user hasn't dismissed. Mail that was unread before + // doesn't count, or one new message in a full inbox would say "+40 more" and + // open the inbox instead of itself. + const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); + const ids = Array.from(new Set((await idsOnScreen(groupTag)).concat(announced))).slice(-NOTIFICATION_IDS_LIMIT); + const more = Math.max(ids.length - 1, 0); + const oneMessage = email ? { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login, ids } : null; let title; let body; - let data = { kind: "mail-list", accountId, slot }; + let data = { kind: "mail-list", accountId, slot, ids }; if (email && preview && preview.redacted) { // The organisation keeps sender and subject off notifications // (Organisation > Devices): the route sent neither. title = accountLabel ? `New mail (${accountLabel})` : "New mail"; - body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; - if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; + body = more > 0 ? `${ids.length} new messages` : "You have new mail"; + if (more === 0) data = oneMessage; } else if (email) { const sender = email.from && email.from[0]; const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; title = senderName + (accountLabel ? ` (${accountLabel})` : ""); body = email.subject || email.preview || "(no subject)"; - const more = unreadTotal > 1 ? unreadTotal - 1 : 0; if (more > 0) { - // Several unread: this is a group. Keep the newest as the headline, add - // the "+N more" count, and open the inbox (not one message) on click. + // Several new: this is a group. Keep the newest as the headline, add the + // "+N more" count, and open the inbox (not one message) on click. body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`); } else { - // Exactly one unread: deep-link straight to that message on click. - data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; + // One new message: deep-link straight to it on click. + data = oneMessage; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; @@ -742,12 +747,26 @@ async function notifyAccount(accountId, emailIds, accountLabel) { silent: !audible, }); - const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); if (announced.length > 0) { await writeNotifiedIds(accountId, notified.concat(announced)); } } +const NOTIFICATION_IDS_LIMIT = 100; + +/** The messages the account's notification on screen stands for. */ +async function idsOnScreen(groupTag) { + try { + const shown = (await self.registration.getNotifications({ tag: groupTag }))[0]; + const data = shown && shown.data; + if (!data) return []; + if (Array.isArray(data.ids)) return data.ids.filter((id) => typeof id === "string"); + return typeof data.emailId === "string" ? [data.emailId] : []; + } catch (_) { + return []; + } +} + // How long the preview may take before the generic notification is shown. const PREVIEW_DEADLINE_MS = 10_000; From fc6a209727e33ad80c0acec539bdb8ce0dfa7559 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:48:21 +0100 Subject: [PATCH 09/35] fix: keep push notifications off screen with email notifications off or do not disturb on --- lib/__tests__/push-preferences.test.ts | 16 ++++++------ lib/__tests__/sw-push-delivery.test.ts | 30 ++++++++++++++++++++++ lib/push-preferences.ts | 2 ++ public/sw.js | 20 +++++++++++++++ stores/__tests__/settings-push-dnd.test.ts | 16 +++++++++--- stores/settings-store.ts | 16 +++++++----- 6 files changed, 82 insertions(+), 18 deletions(-) diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index 47a08b510..80dd45065 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -17,18 +17,18 @@ describe('writePushPreferences', () => { it('stores the preference under the key the worker reads', async () => { const { writePushPreferences, pushPreferencesKey } = await import('../push-preferences'); - await writePushPreferences({ sound: false }); + await writePushPreferences({ sound: false, show: true }); expect(put).toHaveBeenCalledTimes(1); const [key, response] = put.mock.calls[0] as [string, Response]; expect(key).toBe(pushPreferencesKey(window.location.origin)); expect(key.endsWith('/__push-state/prefs')).toBe(true); - expect(await response.json()).toEqual({ sound: false }); + expect(await response.json()).toEqual({ sound: false, show: true }); }); it('does nothing without a Cache store', async () => { vi.stubGlobal('caches', undefined); const { writePushPreferences } = await import('../push-preferences'); - await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); + await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); @@ -42,7 +42,7 @@ describe('writePushPreferences', () => { }, }); const { writePushPreferences } = await import('../push-preferences'); - await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); + await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); }); @@ -54,21 +54,21 @@ describe('settings store push preference mirror', () => { vi.doMock('@/lib/push-preferences', () => ({ writePushPreferences })); const { useSettingsStore } = await import('@/stores/settings-store'); - expect(writePushPreferences).toHaveBeenCalledWith({ sound: true }); + expect(writePushPreferences).toHaveBeenCalledWith({ sound: true, show: true }); writePushPreferences.mockClear(); useSettingsStore.getState().updateSetting('emailNotificationSound', false); - expect(writePushPreferences).toHaveBeenCalledWith({ sound: false }); + expect(writePushPreferences).toHaveBeenCalledWith({ sound: false, show: true }); writePushPreferences.mockClear(); // Unrelated settings do not rewrite it. useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true); expect(writePushPreferences).not.toHaveBeenCalled(); - // Email notifications off means no sound for push either. + // Email notifications off means no notification, so no sound either. useSettingsStore.getState().updateSetting('emailNotificationSound', true); useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); vi.doUnmock('@/lib/push-preferences'); }); }); diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 0acba2790..96b1d737f 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -143,4 +143,34 @@ describe('what a notification counts', () => { }); }); +describe('with notifications off or do not disturb on', () => { + it('announces nothing: shows and removes at once, without asking for the message', async () => { + const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { prefs: { sound: false, show: false } }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(0); + expect(previews).toHaveLength(0); + }); + + it('does not bring the message up later when turned on again', async () => { + const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: false } }); + await off.push(delivery('a', 'e1')); + const { push, shade } = off; + // Same store, now switched on: a redelivery stays quiet. + await push(delivery('a', 'e1')); + expect(shade.size).toBe(0); + }); + + it('keeps showing with only the sound off', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: true } }); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false }); + }); + + it('shows as before for prefs written by an older page', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: true } }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(1); + }); +}); + // -- end of cases -- diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index a148e34a9..237b45d40 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -13,6 +13,8 @@ export const PUSH_STATE_CACHE = 'bulwark-push-state-v1'; export interface PushPreferences { /** False: show push notifications for new mail without a sound. */ sound: boolean; + /** False: "Email notifications" is off or do not disturb is on. */ + show: boolean; } /** The cache key sw.js reads the preferences from. */ diff --git a/public/sw.js b/public/sw.js index 7681b4ac5..0b9ebc9e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -622,6 +622,14 @@ async function notifyAccount(accountId, emailIds, accountLabel) { return; } + // Email notifications off, or do not disturb on: a browser still wants a + // notification for the push, so one comes and goes with nothing on it. + if (!(await showPreference())) { + if (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds)); + await showWithoutAlert(groupTag); + return; + } + // Best effort: ask the webmail to look up the email so we can build a useful // notification. With ids from the server we ask for exactly the newest of // them; otherwise the API falls back to the newest unread Inbox message. If @@ -880,6 +888,18 @@ async function soundPreference() { } } +/** The "Email notifications" switch and do not disturb, mirrored the same way. Nothing stored: show. */ +async function showPreference() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(pushPreferencesKey()); + const prefs = res ? await res.json() : null; + return !prefs || prefs.show !== false; + } catch (_) { + return true; + } +} + /** True when another account alerted audibly less than QUIET_WINDOW_MS ago; otherwise records this one. */ async function withinQuietWindow(accountId) { const now = Date.now(); diff --git a/stores/__tests__/settings-push-dnd.test.ts b/stores/__tests__/settings-push-dnd.test.ts index 52df90530..87cd4c0a5 100644 --- a/stores/__tests__/settings-push-dnd.test.ts +++ b/stores/__tests__/settings-push-dnd.test.ts @@ -6,12 +6,22 @@ vi.mock('@/lib/push-preferences', () => ({ writePushPreferences })); import { useSettingsStore } from '../settings-store'; describe('do not disturb and push notifications (CO-13)', () => { - it('keeps pushed notifications silent while do not disturb is on', () => { + it('keeps pushed notifications off screen while do not disturb is on', () => { useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false }); writePushPreferences.mockClear(); useSettingsStore.getState().updateSetting('doNotDisturb', true); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); useSettingsStore.getState().updateSetting('doNotDisturb', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true, show: true }); + }); + + it('keeps them off screen with email notifications off, and only silent with the sound off', () => { + useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false }); + writePushPreferences.mockClear(); + useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); + useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true); + useSettingsStore.getState().updateSetting('emailNotificationSound', false); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: true }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 440c3a190..dcb1e7aff 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -2276,17 +2276,19 @@ if (typeof window !== 'undefined') { applyInterfaceLayout(store.interfaceLayout, store.density); // The service worker shows push notifications without this store, so it - // reads the sound preference from its own Cache store (#1289). - // Do not disturb can't keep a push from showing (browsers require one per - // push), but it keeps it silent. - const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb; + // reads these from its own Cache store (#1289). + const pushShow = (s: SettingsState) => s.emailNotificationsEnabled && !s.doNotDisturb; + const pushSound = (s: SettingsState) => pushShow(s) && s.emailNotificationSound; let lastPushSound = pushSound(store); - void writePushPreferences({ sound: lastPushSound }); + let lastPushShow = pushShow(store); + void writePushPreferences({ sound: lastPushSound, show: lastPushShow }); useSettingsStore.subscribe((state) => { const sound = pushSound(state); - if (sound === lastPushSound) return; + const show = pushShow(state); + if (sound === lastPushSound && show === lastPushShow) return; lastPushSound = sound; - void writePushPreferences({ sound }); + lastPushShow = show; + void writePushPreferences({ sound, show }); }); // The policy arrives after this store has rehydrated (and again with the From 2c462c304e60111d7b23cea04d7014da70125c7b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:48:39 +0100 Subject: [PATCH 10/35] fix: show the right preview when an account id is in more than one login --- app/api/push/preview/route.ts | 13 +++--- .../push-preview-route-ambiguous.test.ts | 43 ++++++++++++++++--- lib/__tests__/sw-push-delivery.test.ts | 14 ++++++ public/sw.js | 3 ++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index c862eeb99..2c214351c 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -45,11 +45,12 @@ interface ResolvedTarget { // session of everyone it is shared with, who sees only that folder - not its // Inbox, nor the message that just arrived there. // -// The push names nothing but the account id, and ids repeat across servers -// (and a group account is listed by each member). When that leaves more than -// one login, answer 'ambiguous' rather than guess: a wrong guess previews +// Ids repeat across servers (and a group account is listed by each member), so +// the account id alone can leave several logins. The push's label names the login +// that registered it, which settles it among those that have the account. Without +// one that fits, answer 'ambiguous' rather than guess: a wrong guess previews // another login's mail and opens it there. -async function resolveTargetForAccount(accountId: string, forwarded: Record): Promise { +async function resolveTargetForAccount(accountId: string, login: string | null, forwarded: Record): Promise { const cookieStore = await cookies(); const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { @@ -91,6 +92,8 @@ async function resolveTargetForAccount(accountId: string, forwarded: Record r !== null); + const named = login ? results.filter((r) => r.target.username.toLowerCase() === login.toLowerCase()) : []; + if (named.length === 1) return named[0].target; const owners = results.filter((r) => r.own); if (owners.length === 1) return owners[0].target; if (results.length === 1) return results[0].target; @@ -141,7 +144,7 @@ export async function GET(request: NextRequest) { let target: ResolvedTarget | null = null; let authHeader: string; if (requestedAccountId) { - const resolved = await resolveTargetForAccount(requestedAccountId, forwarded); + const resolved = await resolveTargetForAccount(requestedAccountId, request.nextUrl.searchParams.get('login'), forwarded); if (!resolved) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index f83e8bb77..93e74d40f 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -26,9 +26,9 @@ vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 })); // slot 0: alice on one.example, slot 1: bob on two.example, slot 2: carol on one.example const LOGINS = [ - { serverUrl: 'https://one.example', authHeader: 'Basic alice' }, - { serverUrl: 'https://two.example', authHeader: 'Basic bob' }, - { serverUrl: 'https://one.example', authHeader: 'Basic carol' }, + { serverUrl: 'https://one.example', authHeader: 'Basic alice', username: 'alice@one.example' }, + { serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' }, + { serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' }, ]; vi.mock('@/lib/stalwart/auth-context', () => ({ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, @@ -73,10 +73,10 @@ vi.mock('@/lib/admin/config-manager', async () => { return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } }; }); -async function callRoute(accountId: string) { +async function callRoute(accountId: string, login?: string) { const { GET } = await import('@/app/api/push/preview/route'); const request = { - nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : null) } }, + nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : k === 'login' ? login ?? null : null) } }, headers: new Headers(), }; const res = (await GET(request as unknown as Parameters[0])) as unknown as { @@ -93,6 +93,39 @@ describe('push preview when the account id is in more than one login', () => { fetchJmapServer.mockClear(); }); + it('takes the login the push names when two servers use the same id', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'z'), + }; + const { status, body } = await callRoute('z', 'BOB@two.example'); + expect(status).toBe(200); + expect(body.slot).toBe(1); + expect((body.email as { subject: string }).subject).toBe('mail of Basic bob'); + }); + + it('takes the login the push names for a group two logins belong to', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a', ['team']), + 'Basic bob': session('https://two.example', 'b'), + 'Basic carol': session('https://one.example', 'c', ['team']), + }; + const { status, body } = await callRoute('team', 'carol@one.example'); + expect(status).toBe(200); + expect(body.slot).toBe(2); + }); + + it('does not take a login that does not have the account', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a', ['team']), + 'Basic bob': session('https://two.example', 'b'), + 'Basic carol': session('https://one.example', 'c', ['team']), + }; + const { status } = await callRoute('team', 'bob@two.example'); + expect(status).toBe(409); + }); + it('shows no preview and names no login when two servers use the same id', async () => { SESSIONS = { 'Basic alice': session('https://one.example', 'a'), diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 96b1d737f..d2d00f7a4 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -173,4 +173,18 @@ describe('with notifications off or do not disturb on', () => { }); }); +describe('which login a push is for', () => { + it('names the login in the preview request', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(delivery('a', 'e1')); + expect(previews[0].searchParams.get('login')).toBe('me@example.org'); + }); + + it('leaves it out when the push has no label', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }); + expect(previews[0].searchParams.has('login')).toBe(false); + }); +}); + // -- end of cases -- diff --git a/public/sw.js b/public/sw.js index 0b9ebc9e2..14d135af6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -642,6 +642,9 @@ async function notifyAccount(accountId, emailIds, accountLabel) { if (!lockHidesContent) try { const query = new URLSearchParams(); if (accountId) query.set("accountId", accountId); + // The relay record's label is the login that registered. Account and message + // ids repeat across logins, and this tells them apart. + if (accountLabel) query.set("login", accountLabel); if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); const qs = query.toString(); const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`; From 33249ca57a3540bbf7c70459b9521b48017f31ed Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:48:49 +0100 Subject: [PATCH 11/35] fix: show a notification even if building it fails --- lib/__tests__/sw-push-delivery.test.ts | 22 ++++++++++++++++++++++ public/sw.js | 14 +++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index d2d00f7a4..2f5d8c5b5 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -187,4 +187,26 @@ describe('which login a push is for', () => { }); }); +describe('when building the notification goes wrong', () => { + it('still shows one', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, login: 'fp', email: mail('e1') }), { + globals: { Notification: { get maxActions() { throw new Error('no such thing'); } } }, + }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(1); + }); + + it('shows one for a push that is no JSON at all', async () => { + const { dispatch, shade } = loadWorker(() => null); + await dispatch('push', { data: { json: () => { throw new SyntaxError('not JSON'); } } }); + expect(shade.size).toBe(1); + }); + + it('shows one for a push without any data', async () => { + const { dispatch, shade } = loadWorker(() => null); + await dispatch('push', {}); + expect(shade.size).toBe(1); + }); +}); + // -- end of cases -- diff --git a/public/sw.js b/public/sw.js index 14d135af6..b8e848cc7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -604,7 +604,19 @@ async function handlePush(event) { ? [] : Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]); for (const accountId of withNewMail.length > 0 ? withNewMail : [named]) { - await notifyAccount(accountId, emailIds, accountLabel); + try { + await notifyAccount(accountId, emailIds, accountLabel); + } catch (_) { + // Nothing above may cost the user the alert, or a browser its quota for + // this site: see showWithoutAlert. + await self.registration.showNotification(accountLabel ? `New mail (${accountLabel})` : "New mail", { + body: "You have new mail", + tag: "bulwark-mail:" + (accountId || "default"), + icon: `${BASE_PATH}/api/pwa-icon/192`, + badge: `${BASE_PATH}/api/pwa-icon/192`, + data: { kind: "mail-list", accountId }, + }); + } } } From 8a69c352067751ad6fff1f1c23c546b37136199a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:49:24 +0100 Subject: [PATCH 12/35] fix: open a notification in the app window, not a pop-out or a frame --- lib/__tests__/sw-push-delivery.test.ts | 45 ++++++++++++++++++++++++++ public/sw.js | 21 +++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 2f5d8c5b5..46cd713c5 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -209,4 +209,49 @@ describe('when building the notification goes wrong', () => { }); }); +describe('which window a click uses', () => { + const win = (id: string, url: string, extra: Record = {}) => { + const client = { + id, url: `https://mail.example.com${url}`, frameType: 'top-level', visibilityState: 'hidden', focused: false, + postMessage: vi.fn(), + focus: vi.fn(async () => client), + navigate: vi.fn(async () => client), + ...extra, + }; + return client; + }; + const clickOn = (w: ReturnType) => w.dispatch('notificationclick', { + notification: { tag: 'bulwark-mail:a', close() {}, data: { kind: 'email', emailId: 'e1', accountId: 'a', slot: 0 } }, + }); + + it('leaves a pop-out and a frame alone and takes the app window', async () => { + const popout = win('p', '/popout/message/x'); + const frame = win('f', '/mail/folder/inbox', { frameType: 'nested' }); + const main = win('m', '/mail/folder/inbox'); + const w = loadWorker(() => null, { clients: [popout, frame, main] }); + await clickOn(w); + expect(popout.navigate).not.toHaveBeenCalled(); + expect(frame.navigate).not.toHaveBeenCalled(); + expect(main.navigate).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a'); + }); + + it('takes the window in front when there are several', async () => { + const back = win('b', '/mail/folder/inbox'); + const front = win('f', '/mail/folder/inbox', { focused: true, visibilityState: 'visible' }); + const w = loadWorker(() => null, { clients: [back, front] }); + await clickOn(w); + expect(front.navigate).toHaveBeenCalled(); + expect(back.navigate).not.toHaveBeenCalled(); + }); + + it('opens a new window when only a pop-out is open', async () => { + const popout = win('p', '/popout/compose'); + const openWindow = vi.fn(async () => null); + const w = loadWorker(() => null, { clients: [popout], openWindow }); + await clickOn(w); + expect(popout.navigate).not.toHaveBeenCalled(); + expect(openWindow).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a'); + }); +}); + // -- end of cases -- diff --git a/public/sw.js b/public/sw.js index b8e848cc7..b6cec3ee7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1036,7 +1036,7 @@ async function handleNotificationClick(event) { if (action === "reply") targetUrl = buildClickUrl(data, true); } - for (const client of allClients) { + for (const client of windowsForClick(allClients)) { // Reuse an existing tab whenever possible - users on desktop browsers // get annoyed when each notification opens a fresh window. if ("focus" in client) { @@ -1077,6 +1077,25 @@ async function handleNotificationClick(event) { } } +/** + * The windows a click may take over, the one in front first. Not a frame inside + * a page, and not a pop-out or a protocol page: navigating one of those would + * lose what it shows and leave the message in a window without the app around it. + */ +function windowsForClick(allClients) { + const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2); + return allClients + .filter((client) => { + if (client.frameType && client.frameType !== "top-level") return false; + try { + return !/\/(popout|protocol)(\/|$)/.test(new URL(client.url).pathname); + } catch (_) { + return true; + } + }) + .sort((a, b) => score(a) - score(b)); +} + async function focusExistingWindowClient(sourceClientId, requireMailtoReady) { const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady); const client = entry && entry.client; From d520af51b2d3b34ebd0129199de143d6971d8660 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:49:41 +0100 Subject: [PATCH 13/35] fix: restore push when the browser replaces its subscription with no tab open --- .../push-relay-registrations.test.ts | 40 ++++++++++++ .../sw-push-subscription-change.test.ts | 49 +++++++++++++-- lib/pwa-notify/worker-state.ts | 54 ++++++++++++++++ lib/web-push.ts | 7 +++ public/sw.js | 61 ++++++++++++++++--- 5 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 lib/__tests__/push-relay-registrations.test.ts diff --git a/lib/__tests__/push-relay-registrations.test.ts b/lib/__tests__/push-relay-registrations.test.ts new file mode 100644 index 000000000..de53872fc --- /dev/null +++ b/lib/__tests__/push-relay-registrations.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state'; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined), + put: async (key: string, res: Response) => { store.set(key, await res.text()); }, + }), + }); +}); + +const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null'); + +describe('what the worker keeps to re-register without a tab', () => { + it('keeps one entry per account and the key', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + expect(stored()).toEqual({ + applicationServerKey: 'KEY', + entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } }, + }); + }); + + it('forgets an account, and leaves the others', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + await forgetRelayRegistration('a'); + expect(Object.keys(stored().entries)).toEqual(['b']); + }); + + it('stays out of the way without a Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined(); + await expect(forgetRelayRegistration('a')).resolves.toBeUndefined(); + }); +}); diff --git a/lib/__tests__/sw-push-subscription-change.test.ts b/lib/__tests__/sw-push-subscription-change.test.ts index b379d6c85..813a8fa69 100644 --- a/lib/__tests__/sw-push-subscription-change.test.ts +++ b/lib/__tests__/sw-push-subscription-change.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest'; // in a VM with a minimal ServiceWorkerGlobalScope and fire the event. const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); -function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { +function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) { const listeners = new Map void>(); - const subscribe = vi.fn(async () => ({})); + const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) }; + const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription); + const relayCalls: Array<{ url: string; body: unknown }> = []; const self = { location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), @@ -18,7 +20,14 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { registration: { pushManager: { subscribe } }, }; const context = vm.createContext({ - self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console, + self, + atob, + caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) }, + fetch: vi.fn(async (url: string, init: { body: string }) => { + relayCalls.push({ url, body: JSON.parse(init.body) }); + return { ok: true }; + }), + URL, URLSearchParams, Response, Date, console, Uint8Array, }); vm.runInContext(SW_SOURCE, context); @@ -27,13 +36,45 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); await Promise.all(waits); } - return { fire, subscribe }; + return { fire, subscribe, relayCalls, newSubscription }; } // The push service dropped or replaced the subscription while no page was // involved (#1321). The worker puts a subscription back and tells the open // tabs, which hold the login needed to register the new endpoint. describe('service worker pushsubscriptionchange', () => { + const REGISTRATIONS = { + applicationServerKey: 'QUJD', + entries: { + a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' }, + b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' }, + }, + }; + + it('tells the relay the new endpoint itself, for every account, with no tab open', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([ + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } }, + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } }, + ]); + }); + + it('subscribes with the kept key when the browser gives no old subscription', async () => { + const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null }); + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true }); + expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]); + expect(relayCalls).toHaveLength(2); + }); + + it('calls no relay for a browser where push was never on', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([]); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + it('re-subscribes with the old key and tells the open tabs to re-register', async () => { const tab = { postMessage: vi.fn() }; const { fire, subscribe } = loadWorker([tab]); diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 161f3885c..283e7b6dd 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -29,6 +29,60 @@ export function pendingReplyKey(origin: string): string { return `${origin}${BASE_PATH}/__push-state/pending-reply`; } +/** The cache key sw.js finds the relay registrations under. */ +export function relayRegistrationsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/relay-registrations`; +} + +export interface RelayRegistration { + relay: string; + subscriptionId: string; + accountLabel?: string; +} + +interface RelayRegistrations { + applicationServerKey?: string; + entries: Record; +} + +async function readRelayRegistrations(cache: Cache, key: string): Promise { + const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined; + return stored?.entries ? stored : { entries: {} }; +} + +/** + * Lets the worker re-register a new browser endpoint with the relay when no tab + * is open (pushsubscriptionchange). None of this is secret to the browser that + * subscribed, and it opens nothing but the relay record. + */ +export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readRelayRegistrations(cache, key); + stored.entries[accountId] = entry; + stored.applicationServerKey = applicationServerKey; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // A new endpoint then waits for the page's re-sync. + } +} + +export async function forgetRelayRegistration(accountId: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readRelayRegistrations(cache, key); + if (!(accountId in stored.entries)) return; + delete stored.entries[accountId]; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // Signing out deletes the store anyway. + } +} + export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { try { if (typeof caches === 'undefined') return; diff --git a/lib/web-push.ts b/lib/web-push.ts index 3fcd93ccb..d3bf880d3 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -13,6 +13,7 @@ import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; import { IS_LITE } from '@/lib/lite'; import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update'; +import { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, @@ -801,6 +802,11 @@ async function enableWebPushForThisTab( }, accountLabel: params.accountLabel, }); + await keepRelayRegistration(accountId, { + relay: relayBaseUrl, + subscriptionId: deviceClientId, + ...(params.accountLabel ? { accountLabel: params.accountLabel } : {}), + }, vapidPublicKey); // Reuse the JMAP-side PushSubscription if the server still has it, just // refreshing the expiry so it doesn't time out between sessions. With @@ -983,6 +989,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise character.charCodeAt(0)); +} + // The push service dropped or replaced the browser's subscription (expiry, // a key rotation, revoked permission). Re-subscribe with the same relay key -// unless the browser already did, then tell the open tabs: only a page has -// the login needed to register the new endpoint with the relay and the mail -// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page -// load's re-sync picks the new subscription up. +// unless the browser already did. The mail server's subscription points at the +// relay record, not at the endpoint, so telling the relay is all it takes, and +// that needs no login: push is back with no tab open. Open tabs hear of it too, +// for what only a page can do (resyncWebPush in lib/web-push.ts). async function handlePushSubscriptionChange(event) { const old = event.oldSubscription; - if (!event.newSubscription && old && old.options) { - try { - await self.registration.pushManager.subscribe(old.options); - } catch (_) { - // Permission revoked or push service unreachable: the page's re-sync - // subscribes again itself when it can. + const registrations = await readRelayRegistrations(); + let subscription = event.newSubscription || null; + if (!subscription) { + const options = old && old.options + ? old.options + : registrations && registrations.applicationServerKey + ? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) } + : null; + if (options) { + try { + subscription = await self.registration.pushManager.subscribe(options); + } catch (_) { + // Permission revoked or push service unreachable: the page's re-sync + // subscribes again itself when it can. + } + } + } + if (subscription && registrations && typeof subscription.toJSON === "function") { + const json = subscription.toJSON(); + for (const entry of Object.values(registrations.entries)) { + try { + await fetch(`${entry.relay}/api/push/register/web`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }), + }); + } catch (_) { + // The page's re-sync registers it when it next runs. + } } } const allClients = await self.clients.matchAll({ From 8b4fb7235727fe0ab188687640062c876e996a0a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:00:44 +0100 Subject: [PATCH 14/35] fix: say in plain words why push could not be turned on or off --- components/push-notification-prompt.tsx | 10 +- components/settings/multi-account-push.tsx | 26 +++-- components/settings/notification-settings.tsx | 23 ++-- lib/__tests__/web-push.test.ts | 100 ++++++++++++++++++ lib/web-push-errors.ts | 32 ++++++ lib/web-push.ts | 44 +++++--- locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/et/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nb/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/th/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/vi/common.json | 7 +- locales/zh-TW/common.json | 7 +- locales/zh/common.json | 7 +- 36 files changed, 371 insertions(+), 74 deletions(-) create mode 100644 lib/web-push-errors.ts diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index c88ce9361..b591eaa8c 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -19,8 +19,8 @@ import { resyncWebPush, waitForWebPushResync, } from "@/lib/web-push"; +import { webPushErrorKey } from "@/lib/web-push-errors"; import { resolveActiveRelayUrl } from "@/lib/push-relays"; -import { jmapErrorType } from "@/lib/jmap/jmap-error"; import { isPWAInstallPromptVisible, PWA_INSTALL_PROMPT_VISIBILITY_EVENT, @@ -310,11 +310,9 @@ export function PushNotificationPrompt() { }); setShowPrompt(false); } catch (err) { - // Readable text for the server refusals a user can act on. - const type = jmapErrorType(err); - if (type === "forbidden") setError(tPush("error_forbidden")); - else if (type === "overQuota") setError(tPush("error_over_quota")); - else setError(err instanceof Error ? err.message : "Failed to enable push"); + // Readable text for what a user can act on. + const key = webPushErrorKey(err); + setError(key ? tPush(key) : err instanceof Error ? err.message : "Failed to enable push"); } finally { setIsEnabling(false); } diff --git a/components/settings/multi-account-push.tsx b/components/settings/multi-account-push.tsx index cd361fb50..0440f2ef0 100644 --- a/components/settings/multi-account-push.tsx +++ b/components/settings/multi-account-push.tsx @@ -9,6 +9,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push'; +import { webPushErrorKey } from '@/lib/web-push-errors'; interface FailedAccount { id: string; @@ -17,11 +18,6 @@ interface FailedAccount { reason: string; } -function failureReason(error: unknown): string { - if (error instanceof Error && error.message) return error.message; - return String(error); -} - /** * Turn push on or off for several accounts at once. * @@ -32,6 +28,18 @@ function failureReason(error: unknown): string { */ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) { const t = useTranslations('settings.notifications.push_all'); + const tPush = useTranslations('settings.notifications.push'); + const failureReason = (error: unknown): string => { + const key = webPushErrorKey(error); + if (key) return tPush(key); + return error instanceof Error && error.message ? error.message : String(error); + }; + const failureOf = (account: (typeof accounts)[number], error: unknown): FailedAccount => ({ + id: account.id, + name: account.displayName || account.label || account.username, + email: account.email || account.username, + reason: failureReason(error), + }); const accounts = useAccountStore(s => s.accounts); // Re-read the client map whenever a connection appears or drops. const revision = useAuthStore(s => s.connectedAccountsRevision); @@ -95,11 +103,15 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri }; })); } else { - // Teardown swallows its own failures, so every account ends up local-off. + // Every account ends up off here; one the server or relay couldn't be + // told about is named, since it may keep getting notifications for days. + const failed: FailedAccount[] = []; for (const account of targets) { - await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }); + await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }) + .catch((error: unknown) => { failed.push(failureOf(account, error)); }); } setNotice(t('result_disabled', { count: targets.length })); + setFailures(failed); } setChosen(new Set()); } finally { diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index f94cd4134..9f51ce1c3 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; -import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; @@ -27,6 +26,7 @@ import { revokePushDevice, serverSupportsEmailPush, } from '@/lib/web-push'; +import { webPushErrorKey } from '@/lib/web-push-errors'; import type { PushDevice } from '@/lib/web-push'; import { IS_LITE } from '@/lib/lite'; import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; @@ -171,14 +171,9 @@ export function NotificationSettings() { // The refusals a user can do something about get readable text; anything // else keeps the error's own message. const pushErrorMessage = (err: unknown, fallback: string) => { - switch (jmapErrorType(err)) { - case 'forbidden': - return t('push.error_forbidden'); - case 'overQuota': - return t('push.error_over_quota'); - default: - return err instanceof Error ? err.message : fallback; - } + const key = webPushErrorKey(err); + if (key) return t(`push.${key}`); + return err instanceof Error ? err.message : fallback; }; // `forceRecreate` is what the Re-register button is for: without it the enable @@ -265,10 +260,7 @@ export function NotificationSettings() { await disableWebPush({ client, relayBaseUrl: activeRelayUrl }); setPushStatus({ kind: 'idle' }); } catch (err) { - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to disable push', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to disable push') }); } finally { await refreshDevices(); } @@ -290,10 +282,7 @@ export function NotificationSettings() { await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl }); if (device.isThisDevice) setPushStatus({ kind: 'idle' }); } catch (err) { - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to revoke device', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to revoke device') }); } finally { setRevokingId(null); await refreshDevices(); diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 1cd65d948..32dafd0a3 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -16,6 +16,7 @@ import { revokePushDevice, waitForWebPushResync, } from '@/lib/web-push'; +import { webPushErrorKey } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; @@ -640,6 +641,105 @@ describe('two tabs at once', () => { }); }); +describe('what enabling and disabling say when they fail', () => { + it('tells a blocked browser from a question not answered', async () => { + const client = makeClient([]); + installFetch({}); + (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + (window as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_blocked'); + + const unanswered = { permission: 'default', requestPermission: async () => 'default' }; + (globalThis as unknown as { Notification: unknown }).Notification = unanswered; + (window as unknown as { Notification: unknown }).Notification = unanswered; + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_not_granted'); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); + + it('names an unreachable relay, and a relay that refuses', async () => { + const client = makeClient([]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay'); + + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + return String(input).endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}, 500); + })); + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay'); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); + + it('names a verification that never came', async () => { + vi.useFakeTimers(); + try { + const client = makeClient([]); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' }); + return json({ verificationCode: null }); + })); + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e: unknown) => webPushErrorKey(e)); + await vi.advanceTimersByTimeAsync(120_000); + expect(await settled).toBe('error_verification_timeout'); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps naming the server refusals, and nothing for an unknown error', () => { + expect(webPushErrorKey(refusedMap())).toBe('error_forbidden'); + expect(webPushErrorKey(jmapSetError('x', OVER_QUOTA))).toBe('error_over_quota'); + expect(webPushErrorKey(new Error('boom'))).toBeNull(); + }); + + it('says so when turning off could not reach the server, and still ends up off here', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + (client.destroyPushSubscription as ReturnType).mockRejectedValue(new Error('offline')); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown'); + + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('says so when turning off could not reach the relay', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown'); + + expect(client.destroyed).toEqual(['push-mine']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('does not count a record the relay no longer has as a failure', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }))); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined(); + }); + + it('keeps the browser subscription for the other account, and says nothing when all went well', async () => { + const { browserSub } = installPushBrowser(); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem('bulwark.push.subscriptionId.v1.other-account', 'push-other'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined(); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push-errors.ts b/lib/web-push-errors.ts new file mode 100644 index 000000000..69d5f8573 --- /dev/null +++ b/lib/web-push-errors.ts @@ -0,0 +1,32 @@ +import { jmapErrorType } from '@/lib/jmap/jmap-error'; + +type WebPushStep = 'blocked' | 'not-granted' | 'relay' | 'verification-timeout' | 'teardown'; + +/** A step that failed for a reason the user can act on. The message stays for logs. */ +export class WebPushStepError extends Error { + constructor(public readonly step: WebPushStep, message: string) { + super(message); + this.name = 'WebPushStepError'; + } +} + +export type WebPushErrorKey = + | 'error_blocked' | 'error_not_granted' | 'error_relay' | 'error_verification_timeout' | 'error_teardown' + | 'error_forbidden' | 'error_over_quota'; + +const STEP_MESSAGES: Record = { + blocked: 'error_blocked', + 'not-granted': 'error_not_granted', + relay: 'error_relay', + 'verification-timeout': 'error_verification_timeout', + teardown: 'error_teardown', +}; + +/** The key under settings.notifications.push that says what went wrong, if any does. */ +export function webPushErrorKey(error: unknown): WebPushErrorKey | null { + if (error instanceof WebPushStepError) return STEP_MESSAGES[error.step]; + const type = jmapErrorType(error); + if (type === 'forbidden') return 'error_forbidden'; + if (type === 'overQuota') return 'error_over_quota'; + return null; +} diff --git a/lib/web-push.ts b/lib/web-push.ts index d3bf880d3..9b7ee0473 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -8,6 +8,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { isDesktopApp } from '@/lib/desktop/bridge'; import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; +import { WebPushStepError } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; @@ -526,12 +527,14 @@ function readPushKey( } async function fetchVapidPublicKey(relayBaseUrl: string): Promise { - const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')); + const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')).catch((err: unknown) => { + throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`); + }); if (!res.ok) { if (res.status === 503) { throw new Error('The push relay does not have Web Push configured'); } - throw new Error(`Failed to fetch VAPID key: ${res.status}`); + throw new WebPushStepError('relay', `Failed to fetch VAPID key: ${res.status}`); } const body = (await res.json()) as { publicKey?: string }; if (!body.publicKey) throw new Error('Relay returned an empty VAPID key'); @@ -541,11 +544,11 @@ async function fetchVapidPublicKey(relayBaseUrl: string): Promise { async function ensurePermission(): Promise { if (Notification.permission === 'granted') return; if (Notification.permission === 'denied') { - throw new Error('Notifications are blocked - allow them in browser settings to continue'); + throw new WebPushStepError('blocked', 'Notifications are blocked - allow them in browser settings to continue'); } const result = await Notification.requestPermission(); if (result !== 'granted') { - throw new Error('Notification permission was not granted'); + throw new WebPushStepError(result === 'denied' ? 'blocked' : 'not-granted', 'Notification permission was not granted'); } } @@ -584,9 +587,11 @@ async function registerWithRelay(params: { subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }, accountLabel: params.accountLabel, }), + }).catch((err: unknown) => { + throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`); }); if (!res.ok) { - throw new Error(`Relay register failed: ${res.status}`); + throw new WebPushStepError('relay', `Relay register failed: ${res.status}`); } } @@ -673,7 +678,7 @@ async function pollVerificationCode( await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 2000); } - throw new Error('Timed out waiting for PushVerification from the JMAP server'); + throw new WebPushStepError('verification-timeout', 'Timed out waiting for PushVerification from the JMAP server'); } // Push the subscription's expiry forward and re-sync `types` / the delivery @@ -946,16 +951,17 @@ async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Pr buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), { method: 'DELETE' }, ); - return res.ok; + return res.ok || res.status === 404; } catch { return false; } } -// Best-effort teardown: clear the JMAP subscription, the relay mapping, and -// (only when no other accounts still need it) the browser-wide -// PushSubscription. Any single failure is swallowed so the user always ends -// up in a "disabled" state locally. +// Teardown: clear the JMAP subscription, the relay mapping, and (only when no +// other accounts still need it) the browser-wide PushSubscription. A failed step +// doesn't stop the others, and the browser always ends up "disabled" locally, so +// a later page load won't bring push back. But a server or relay that couldn't be +// told keeps pinging this browser for days, so the caller hears about it at the end. export function disableWebPush(params: DisableWebPushParams): Promise { return oneTabAtATime(() => disableWebPushForThisTab(params)); } @@ -976,16 +982,22 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise(); if (storedServerId) idsToDestroy.add(storedServerId); if (deviceClientId) { - const existingSubs = await params.client.listPushSubscriptions().catch(() => []); + const existingSubs = await params.client.listPushSubscriptions().catch(() => { + failed.push('listing the subscriptions'); + return []; + }); for (const s of existingSubs) { if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id); } } for (const id of idsToDestroy) { - await params.client.destroyPushSubscription(id).catch(() => undefined); + await params.client.destroyPushSubscription(id).catch(() => { + failed.push('destroying a subscription'); + }); } localStorage.removeItem(subIdKey); localStorage.removeItem(relayBaseKey(accountId)); @@ -996,7 +1008,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise undefined); } + + if (failed.length > 0) { + throw new WebPushStepError('teardown', `Push is off in this browser, but ${failed.join(' and ')} failed`); + } } export interface PushDevice { diff --git a/locales/ar/common.json b/locales/ar/common.json index 7c9bf2825..9160b9d9e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية.", "shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.", "error_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.", - "error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى." + "error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى.", + "error_blocked": "الإشعارات محظورة لهذا الموقع. اسمح بها في إعدادات الموقع في المتصفح، ثم حاول مرة أخرى.", + "error_not_granted": "لم يتم السماح بالإشعارات، لذا يبقى الإشعار الفوري متوقفًا.", + "error_verification_timeout": "لم يؤكد خادم البريد هذا الجهاز في الوقت المحدد، لذا يبقى الإشعار الفوري متوقفًا. حاول مرة أخرى بعد دقيقة.", + "error_relay": "لا يمكن الوصول إلى مرحّل الإشعارات الآن، لذا يبقى الإشعار الفوري متوقفًا. حاول لاحقًا.", + "error_teardown": "تم إيقاف الإشعارات الفورية في هذا المتصفح، لكن تعذر الوصول إلى خادم البريد أو المرحّل. قد تستمر الإشعارات في الوصول لبضعة أيام. ألغِ هذا الجهاز أدناه عندما تعود إلى الاتصال." }, "push_all": { "title": "All accounts", diff --git a/locales/ca/common.json b/locales/ca/common.json index 9c2f3e529..0c8b0db4f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament.", "shared_mailboxes_no_push": "Les bústies que altres usuaris comparteixen amb vós no envien notificacions en segon pla.", "error_forbidden": "El servidor de correu no permet notificacions en segon pla per a aquest compte.", - "error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar." + "error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar.", + "error_blocked": "Les notificacions estan bloquejades per a aquest lloc. Permet-les a la configuració del lloc del navegador i torna-ho a provar.", + "error_not_granted": "No s'han permès les notificacions, així que les notificacions push continuen desactivades.", + "error_verification_timeout": "El servidor de correu no ha confirmat aquest dispositiu a temps, així que les notificacions push continuen desactivades. Torna-ho a provar d'aquí a un minut.", + "error_relay": "Ara mateix no es pot accedir al repetidor de notificacions, així que les notificacions push continuen desactivades. Torna-ho a provar més tard.", + "error_teardown": "Les notificacions push estan desactivades en aquest navegador, però no s'ha pogut accedir al servidor de correu o al repetidor. Encara poden arribar notificacions durant uns dies. Revoca aquest dispositiu aquí sota quan tornis a tenir connexió." }, "push_all": { "title": "All accounts", diff --git a/locales/cs/common.json b/locales/cs/common.json index d4f12d33d..00c194398 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí.", "shared_mailboxes_no_push": "Poštovní schránky, které s vámi sdílejí ostatní, neodesílají oznámení na pozadí.", "error_forbidden": "Poštovní server pro tento účet nepovoluje oznámení na pozadí.", - "error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu." + "error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu.", + "error_blocked": "Oznámení jsou pro tento web blokována. Povolte je v nastavení webu v prohlížeči a zkuste to znovu.", + "error_not_granted": "Oznámení nebyla povolena, takže push zůstává vypnutý.", + "error_verification_timeout": "Poštovní server toto zařízení včas nepotvrdil, takže push zůstává vypnutý. Zkuste to znovu za minutu.", + "error_relay": "Push relay teď není dostupné, takže push zůstává vypnutý. Zkuste to později.", + "error_teardown": "Push je v tomto prohlížeči vypnutý, ale poštovní server nebo relay nebyly dostupné. Oznámení mohou ještě několik dní přicházet. Až budete online, odvolejte toto zařízení níže." } }, "language_region": { diff --git a/locales/da/common.json b/locales/da/common.json index 3b9943a02..3b6cedfea 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme.", "shared_mailboxes_no_push": "Postkasser, som andre deler med dig, sender ikke baggrundsnotifikationer.", "error_forbidden": "Mailserveren tillader ikke baggrundsnotifikationer for denne konto.", - "error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen." + "error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen.", + "error_blocked": "Notifikationer er blokeret for dette websted. Tillad dem i browserens webstedsindstillinger, og prøv igen.", + "error_not_granted": "Notifikationer blev ikke tilladt, så push forbliver slået fra.", + "error_verification_timeout": "Mailserveren bekræftede ikke denne enhed i tide, så push forbliver slået fra. Prøv igen om et minut.", + "error_relay": "Push-relayet kan ikke nås lige nu, så push forbliver slået fra. Prøv igen senere.", + "error_teardown": "Push er slået fra i denne browser, men mailserveren eller relayet kunne ikke nås. Der kan stadig komme notifikationer i nogle dage. Tilbagekald denne enhed nedenfor, når du er online igen." }, "push_all": { "title": "All accounts", diff --git a/locales/de/common.json b/locales/de/common.json index 3a51bd665..8683aef99 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr.", "shared_mailboxes_no_push": "Postfächer, die andere mit Ihnen teilen, senden keine Hintergrundbenachrichtigungen.", "error_forbidden": "Der Mailserver erlaubt für dieses Konto keine Hintergrundbenachrichtigungen.", - "error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut." + "error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut.", + "error_blocked": "Benachrichtigungen sind für diese Website blockiert. Erlauben Sie sie in den Website-Einstellungen Ihres Browsers und versuchen Sie es dann erneut.", + "error_not_granted": "Benachrichtigungen wurden nicht erlaubt, Push bleibt deshalb aus.", + "error_verification_timeout": "Der Mailserver hat dieses Gerät nicht rechtzeitig bestätigt, Push bleibt deshalb aus. Versuchen Sie es in einer Minute erneut.", + "error_relay": "Das Push-Relay ist gerade nicht erreichbar, Push bleibt deshalb aus. Versuchen Sie es später erneut.", + "error_teardown": "Push ist in diesem Browser aus, aber der Mailserver oder das Relay waren nicht erreichbar. Benachrichtigungen können noch einige Tage ankommen. Widerrufen Sie dieses Gerät unten, sobald Sie wieder online sind." } }, "language_region": { diff --git a/locales/en/common.json b/locales/en/common.json index b478a1f4b..581b3425a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "This device will stop receiving background notifications immediately.", "shared_mailboxes_no_push": "Mailboxes others share with you don't send background notifications.", "error_forbidden": "The mail server doesn't allow background notifications for this account.", - "error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again." + "error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again.", + "error_blocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", + "error_not_granted": "Notifications weren't allowed, so push stays off.", + "error_verification_timeout": "The mail server didn't confirm this device in time, so push stays off. Try again in a minute.", + "error_relay": "The push relay can't be reached right now, so push stays off. Try again later.", + "error_teardown": "Push is off in this browser, but the mail server or the relay couldn't be reached. Notifications may keep arriving for a few days. Revoke this device below once you're back online." }, "push_all": { "title": "All accounts", diff --git a/locales/es/common.json b/locales/es/common.json index adb67ce9d..ccdb06d71 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato.", "shared_mailboxes_no_push": "Los buzones que otras personas comparten contigo no envían notificaciones en segundo plano.", "error_forbidden": "El servidor de correo no permite notificaciones en segundo plano para esta cuenta.", - "error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo." + "error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo.", + "error_blocked": "Las notificaciones están bloqueadas para este sitio. Permítelas en la configuración del sitio de tu navegador y vuelve a intentarlo.", + "error_not_granted": "No se permitieron las notificaciones, así que las notificaciones push siguen desactivadas.", + "error_verification_timeout": "El servidor de correo no confirmó este dispositivo a tiempo, así que las notificaciones push siguen desactivadas. Inténtalo de nuevo en un minuto.", + "error_relay": "No se puede acceder al relé de notificaciones en este momento, así que las notificaciones push siguen desactivadas. Inténtalo más tarde.", + "error_teardown": "Las notificaciones push están desactivadas en este navegador, pero no se pudo acceder al servidor de correo o al relé. Es posible que sigan llegando notificaciones durante unos días. Revoca este dispositivo abajo cuando vuelvas a tener conexión." } }, "language_region": { diff --git a/locales/et/common.json b/locales/et/common.json index 4eb83347b..7fa14e178 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "See seade lakkab taustateavitusi koheselt saamast.", "shared_mailboxes_no_push": "Postkastid, mida teised sinuga jagavad, taustateavitusi ei saada.", "error_forbidden": "Meiliserver ei luba selle konto jaoks taustateavitusi.", - "error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti." + "error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti.", + "error_blocked": "Teavitused on selle saidi jaoks blokeeritud. Luba need brauseri saidiseadetes ja proovi uuesti.", + "error_not_granted": "Teavitusi ei lubatud, seega jääb tõuketeavitus välja.", + "error_verification_timeout": "E-posti server ei kinnitanud seda seadet õigel ajal, seega jääb tõuketeavitus välja. Proovi minuti pärast uuesti.", + "error_relay": "Tõuketeavituste releed ei saa praegu kätte, seega jääb tõuketeavitus välja. Proovi hiljem uuesti.", + "error_teardown": "Tõuketeavitus on selles brauseris väljas, kuid e-posti serverit või releed ei saadud kätte. Teavitusi võib veel mõne päeva tulla. Tühista see seade allpool, kui oled taas võrgus." }, "push_all": { "title": "Kõik kontod", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2ee1df545..16da56923 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند.", "shared_mailboxes_no_push": "صندوق‌های پستی که دیگران با شما به اشتراک می‌گذارند اعلان پس‌زمینه ارسال نمی‌کنند.", "error_forbidden": "سرور ایمیل اعلان‌های پس‌زمینه را برای این حساب مجاز نمی‌داند.", - "error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید." + "error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید.", + "error_blocked": "اعلان‌ها برای این سایت مسدود هستند. آن‌ها را در تنظیمات سایت مرورگر مجاز کنید و دوباره امتحان کنید.", + "error_not_granted": "اعلان‌ها مجاز نشدند، پس اعلان فوری خاموش می‌ماند.", + "error_verification_timeout": "سرور ایمیل این دستگاه را به‌موقع تأیید نکرد، پس اعلان فوری خاموش می‌ماند. یک دقیقه دیگر دوباره امتحان کنید.", + "error_relay": "در حال حاضر دسترسی به رله اعلان ممکن نیست، پس اعلان فوری خاموش می‌ماند. بعداً دوباره امتحان کنید.", + "error_teardown": "اعلان فوری در این مرورگر خاموش شد، اما دسترسی به سرور ایمیل یا رله ممکن نبود. ممکن است تا چند روز اعلان‌ها برسند. وقتی دوباره آنلاین شدید، این دستگاه را در پایین لغو کنید." }, "push_all": { "title": "All accounts", diff --git a/locales/fr/common.json b/locales/fr/common.json index d80d3a08f..54c3a8f12 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan.", "shared_mailboxes_no_push": "Les boîtes aux lettres que d'autres personnes partagent avec vous n'envoient pas de notifications en arrière-plan.", "error_forbidden": "Le serveur de messagerie n'autorise pas les notifications en arrière-plan pour ce compte.", - "error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez." + "error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez.", + "error_blocked": "Les notifications sont bloquées pour ce site. Autorisez-les dans les paramètres du site de votre navigateur, puis réessayez.", + "error_not_granted": "Les notifications n'ont pas été autorisées, les notifications push restent donc désactivées.", + "error_verification_timeout": "Le serveur de messagerie n'a pas confirmé cet appareil à temps, les notifications push restent donc désactivées. Réessayez dans une minute.", + "error_relay": "Le relais de notifications est injoignable pour le moment, les notifications push restent donc désactivées. Réessayez plus tard.", + "error_teardown": "Les notifications push sont désactivées dans ce navigateur, mais le serveur de messagerie ou le relais n'a pas pu être joint. Des notifications peuvent encore arriver pendant quelques jours. Révoquez cet appareil ci-dessous une fois de nouveau en ligne." } }, "language_region": { diff --git a/locales/he/common.json b/locales/he/common.json index e3dc77704..ad4a5610d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1742,7 +1742,12 @@ "confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד.", "shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.", "error_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.", - "error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב." + "error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב.", + "error_blocked": "ההתראות חסומות עבור אתר זה. אפשר אותן בהגדרות האתר בדפדפן ונסה שוב.", + "error_not_granted": "ההתראות לא אושרו, ולכן התראות הפוש נשארות כבויות.", + "error_verification_timeout": "שרת הדואר לא אישר את המכשיר הזה בזמן, ולכן התראות הפוש נשארות כבויות. נסה שוב בעוד דקה.", + "error_relay": "אי אפשר להגיע כרגע לממסר ההתראות, ולכן התראות הפוש נשארות כבויות. נסה שוב מאוחר יותר.", + "error_teardown": "התראות הפוש כבויות בדפדפן הזה, אבל לא ניתן היה להגיע לשרת הדואר או לממסר. ייתכן שהתראות ימשיכו להגיע עוד כמה ימים. בטל את המכשיר הזה למטה כשתחזור להיות מחובר." } }, "language_region": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 984e0aead..712b314b6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést.", "shared_mailboxes_no_push": "A mások által Önnel megosztott postafiókok nem küldenek háttérértesítéseket.", "error_forbidden": "A levelezőszerver ennél a fióknál nem engedélyezi a háttérértesítéseket.", - "error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra." + "error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra.", + "error_blocked": "Az értesítések le vannak tiltva ezen a webhelyen. Engedélyezd őket a böngésző webhelybeállításaiban, majd próbáld újra.", + "error_not_granted": "Az értesítések nem lettek engedélyezve, ezért a push kikapcsolva marad.", + "error_verification_timeout": "A levelezőszerver nem erősítette meg időben ezt az eszközt, ezért a push kikapcsolva marad. Próbáld újra egy perc múlva.", + "error_relay": "A push relay most nem érhető el, ezért a push kikapcsolva marad. Próbáld újra később.", + "error_teardown": "A push ki van kapcsolva ebben a böngészőben, de a levelezőszerver vagy a relay nem volt elérhető. Néhány napig még érkezhetnek értesítések. Ha újra online vagy, vond vissza lent ezt az eszközt." }, "push_all": { "title": "All accounts", diff --git a/locales/it/common.json b/locales/it/common.json index 87166cde4..3b2f3f309 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background.", "shared_mailboxes_no_push": "Le caselle di posta che altri condividono con te non inviano notifiche in background.", "error_forbidden": "Il server di posta non consente le notifiche in background per questo account.", - "error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova." + "error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova.", + "error_blocked": "Le notifiche sono bloccate per questo sito. Consentile nelle impostazioni del sito del browser, poi riprova.", + "error_not_granted": "Le notifiche non sono state consentite, quindi le notifiche push restano disattivate.", + "error_verification_timeout": "Il server di posta non ha confermato questo dispositivo in tempo, quindi le notifiche push restano disattivate. Riprova tra un minuto.", + "error_relay": "Il relay delle notifiche non è raggiungibile in questo momento, quindi le notifiche push restano disattivate. Riprova più tardi.", + "error_teardown": "Le notifiche push sono disattivate in questo browser, ma il server di posta o il relay non erano raggiungibili. Potrebbero arrivare notifiche ancora per qualche giorno. Revoca questo dispositivo qui sotto quando torni online." } }, "language_region": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 57f690cab..14bd89589 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。", "shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。", "error_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。", - "error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。" + "error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。", + "error_blocked": "このサイトの通知はブロックされています。ブラウザのサイト設定で許可してから、もう一度お試しください。", + "error_not_granted": "通知が許可されなかったため、プッシュはオフのままです。", + "error_verification_timeout": "メールサーバーがこのデバイスを時間内に確認しなかったため、プッシュはオフのままです。1分後にもう一度お試しください。", + "error_relay": "プッシュリレーに接続できないため、プッシュはオフのままです。後でもう一度お試しください。", + "error_teardown": "このブラウザのプッシュはオフになりましたが、メールサーバーまたはリレーに接続できませんでした。数日間は通知が届く場合があります。オンラインに戻ったら、下でこのデバイスを取り消してください。" } }, "language_region": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 67dfe6f99..9d973a353 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다.", "shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않습니다.", "error_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않습니다.", - "error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요." + "error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요.", + "error_blocked": "이 사이트의 알림이 차단되어 있습니다. 브라우저의 사이트 설정에서 허용한 후 다시 시도하세요.", + "error_not_granted": "알림이 허용되지 않아 푸시가 꺼진 상태로 유지됩니다.", + "error_verification_timeout": "메일 서버가 이 기기를 제때 확인하지 않아 푸시가 꺼진 상태로 유지됩니다. 1분 후에 다시 시도하세요.", + "error_relay": "지금은 푸시 릴레이에 연결할 수 없어 푸시가 꺼진 상태로 유지됩니다. 나중에 다시 시도하세요.", + "error_teardown": "이 브라우저에서 푸시가 꺼졌지만 메일 서버나 릴레이에 연결할 수 없었습니다. 며칠 동안 알림이 계속 올 수 있습니다. 다시 온라인 상태가 되면 아래에서 이 기기를 취소하세요." } }, "language_region": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 71d547dc1..8212ea83d 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus.", "shared_mailboxes_no_push": "Pastkastes, ko ar jums kopīgo citi, nesūta fona paziņojumus.", "error_forbidden": "Pasta serveris šim kontam neatļauj fona paziņojumus.", - "error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz." + "error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz.", + "error_blocked": "Paziņojumi šai vietnei ir bloķēti. Atļaujiet tos pārlūka vietnes iestatījumos un mēģiniet vēlreiz.", + "error_not_granted": "Paziņojumi netika atļauti, tāpēc push paliek izslēgts.", + "error_verification_timeout": "Pasta serveris laikus neapstiprināja šo ierīci, tāpēc push paliek izslēgts. Mēģiniet vēlreiz pēc minūtes.", + "error_relay": "Push releju pašlaik nevar sasniegt, tāpēc push paliek izslēgts. Mēģiniet vēlreiz vēlāk.", + "error_teardown": "Push šajā pārlūkā ir izslēgts, bet pasta serveri vai releju nevarēja sasniegt. Paziņojumi vēl dažas dienas var turpināt pienākt. Kad atkal būsiet tiešsaistē, atsauciet šo ierīci zemāk." } }, "language_region": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 884624adc..d5c216da0 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино.", "shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.", "error_forbidden": "Шуудангийн сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.", - "error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу." + "error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу.", + "error_blocked": "Энэ сайтын мэдэгдлийг хориглосон байна. Хөтчийнхөө сайтын тохиргооноос зөвшөөрөөд дахин оролдоно уу.", + "error_not_granted": "Мэдэгдлийг зөвшөөрөөгүй тул push унтраалттай хэвээр байна.", + "error_verification_timeout": "Шуудангийн сервер энэ төхөөрөмжийг цагт нь баталгаажуулаагүй тул push унтраалттай хэвээр байна. Нэг минутын дараа дахин оролдоно уу.", + "error_relay": "Push релейд одоогоор холбогдож чадахгүй байгаа тул push унтраалттай хэвээр байна. Дараа дахин оролдоно уу.", + "error_teardown": "Энэ хөтөч дээр push унтарсан ч шуудангийн сервер эсвэл релейд холбогдож чадсангүй. Мэдэгдэл хэдэн өдрийн турш ирсээр байж магадгүй. Онлайн болсныхоо дараа энэ төхөөрөмжийг доороос цуцална уу." }, "push_all": { "title": "All accounts", diff --git a/locales/nb/common.json b/locales/nb/common.json index 20d450b83..009591c0c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler.", "shared_mailboxes_no_push": "Postbokser som andre deler med deg, sender ikke bakgrunnsvarsler.", "error_forbidden": "E-postserveren tillater ikke bakgrunnsvarsler for denne kontoen.", - "error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen." + "error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen.", + "error_blocked": "Varsler er blokkert for dette nettstedet. Tillat dem i nettleserens nettstedsinnstillinger, og prøv igjen.", + "error_not_granted": "Varsler ble ikke tillatt, så push forblir av.", + "error_verification_timeout": "E-postserveren bekreftet ikke denne enheten i tide, så push forblir av. Prøv igjen om et minutt.", + "error_relay": "Push-reléet kan ikke nås akkurat nå, så push forblir av. Prøv igjen senere.", + "error_teardown": "Push er av i denne nettleseren, men e-postserveren eller reléet kunne ikke nås. Varsler kan fortsette å komme i noen dager. Tilbakekall denne enheten nedenfor når du er på nett igjen." }, "push_all": { "title": "All accounts", diff --git a/locales/nl/common.json b/locales/nl/common.json index 42f59a638..9fda40fb1 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer.", "shared_mailboxes_no_push": "Mailboxen die anderen met u delen, sturen geen achtergrondmeldingen.", "error_forbidden": "De mailserver staat voor dit account geen achtergrondmeldingen toe.", - "error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw." + "error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw.", + "error_blocked": "Meldingen zijn geblokkeerd voor deze site. Sta ze toe in de site-instellingen van uw browser en probeer het opnieuw.", + "error_not_granted": "Meldingen zijn niet toegestaan, dus push blijft uit.", + "error_verification_timeout": "De mailserver heeft dit apparaat niet op tijd bevestigd, dus push blijft uit. Probeer het over een minuut opnieuw.", + "error_relay": "De pushrelay is nu niet bereikbaar, dus push blijft uit. Probeer het later opnieuw.", + "error_teardown": "Push staat uit in deze browser, maar de mailserver of de relay was niet bereikbaar. Er kunnen nog een paar dagen meldingen binnenkomen. Trek dit apparaat hieronder in zodra u weer online bent." } }, "language_region": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 2932b5663..58eb0889b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle.", "shared_mailboxes_no_push": "Skrzynki pocztowe udostępnione ci przez innych nie wysyłają powiadomień w tle.", "error_forbidden": "Serwer poczty nie zezwala na powiadomienia w tle dla tego konta.", - "error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie." + "error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie.", + "error_blocked": "Powiadomienia są zablokowane dla tej witryny. Zezwól na nie w ustawieniach witryny w przeglądarce i spróbuj ponownie.", + "error_not_granted": "Nie zezwolono na powiadomienia, więc push pozostaje wyłączony.", + "error_verification_timeout": "Serwer poczty nie potwierdził tego urządzenia na czas, więc push pozostaje wyłączony. Spróbuj ponownie za minutę.", + "error_relay": "Przekaźnik push jest teraz niedostępny, więc push pozostaje wyłączony. Spróbuj ponownie później.", + "error_teardown": "Push jest wyłączony w tej przeglądarce, ale serwer poczty lub przekaźnik był niedostępny. Powiadomienia mogą przychodzić jeszcze przez kilka dni. Gdy znów będziesz online, unieważnij to urządzenie poniżej." } }, "language_region": { diff --git a/locales/pt/common.json b/locales/pt/common.json index c4b6b8348..ceef37c67 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente.", "shared_mailboxes_no_push": "Caixas de correio que outras pessoas compartilham com você não enviam notificações em segundo plano.", "error_forbidden": "O servidor de e-mail não permite notificações em segundo plano para esta conta.", - "error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente." + "error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente.", + "error_blocked": "As notificações estão bloqueadas para este site. Permita-as nas definições do site do navegador e tente novamente.", + "error_not_granted": "As notificações não foram permitidas, por isso as notificações push continuam desligadas.", + "error_verification_timeout": "O servidor de correio não confirmou este dispositivo a tempo, por isso as notificações push continuam desligadas. Tente novamente dentro de um minuto.", + "error_relay": "Não é possível contactar o retransmissor de notificações neste momento, por isso as notificações push continuam desligadas. Tente mais tarde.", + "error_teardown": "As notificações push estão desligadas neste navegador, mas não foi possível contactar o servidor de correio ou o retransmissor. Podem continuar a chegar notificações durante alguns dias. Revogue este dispositivo abaixo quando voltar a estar online." } }, "language_region": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 719df8c78..0a6094cef 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal.", "shared_mailboxes_no_push": "Căsuțele poștale pe care alții le partajează cu dvs. nu trimit notificări în fundal.", "error_forbidden": "Serverul de e-mail nu permite notificări în fundal pentru acest cont.", - "error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou." + "error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou.", + "error_blocked": "Notificările sunt blocate pentru acest site. Permite-le în setările site-ului din browser, apoi încearcă din nou.", + "error_not_granted": "Notificările nu au fost permise, deci push rămâne dezactivat.", + "error_verification_timeout": "Serverul de e-mail nu a confirmat la timp acest dispozitiv, deci push rămâne dezactivat. Încearcă din nou peste un minut.", + "error_relay": "Releul push nu poate fi accesat acum, deci push rămâne dezactivat. Încearcă din nou mai târziu.", + "error_teardown": "Push este dezactivat în acest browser, dar serverul de e-mail sau releul nu au putut fi accesate. Notificările pot continua să sosească câteva zile. Revocă acest dispozitiv mai jos când ești din nou online." }, "push_all": { "title": "All accounts", diff --git a/locales/ru/common.json b/locales/ru/common.json index 066ad844b..093056d75 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления.", "shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.", "error_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.", - "error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку." + "error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку.", + "error_blocked": "Уведомления для этого сайта заблокированы. Разрешите их в настройках сайта в браузере и попробуйте снова.", + "error_not_granted": "Уведомления не разрешены, поэтому push остаётся выключенным.", + "error_verification_timeout": "Почтовый сервер не подтвердил это устройство вовремя, поэтому push остаётся выключенным. Попробуйте снова через минуту.", + "error_relay": "Push-ретранслятор сейчас недоступен, поэтому push остаётся выключенным. Попробуйте позже.", + "error_teardown": "Push в этом браузере выключен, но почтовый сервер или ретранслятор были недоступны. Уведомления могут приходить ещё несколько дней. Когда снова будете в сети, отзовите это устройство ниже." } }, "language_region": { diff --git a/locales/sk/common.json b/locales/sk/common.json index f9f7e864a..387194376 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1802,7 +1802,12 @@ "confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí.", "shared_mailboxes_no_push": "Poštové schránky, ktoré s vami zdieľajú iní, neposielajú oznámenia na pozadí.", "error_forbidden": "Poštový server pre tento účet nepovoľuje oznámenia na pozadí.", - "error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova." + "error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova.", + "error_blocked": "Oznámenia sú pre túto stránku zablokované. Povoľte ich v nastaveniach stránky v prehliadači a skúste to znova.", + "error_not_granted": "Oznámenia neboli povolené, takže push zostáva vypnutý.", + "error_verification_timeout": "Poštový server toto zariadenie včas nepotvrdil, takže push zostáva vypnutý. Skúste to znova o minútu.", + "error_relay": "Push relay teraz nie je dostupné, takže push zostáva vypnutý. Skúste to neskôr.", + "error_teardown": "Push je v tomto prehliadači vypnutý, ale poštový server alebo relay neboli dostupné. Oznámenia môžu ešte niekoľko dní prichádzať. Keď budete online, odvolajte toto zariadenie nižšie." } }, "language_region": { diff --git a/locales/th/common.json b/locales/th/common.json index 7ac4b5423..6ba32b244 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4662,7 +4662,12 @@ "confirm_revoke_message_this": "อุปกรณ์นี้จะหยุดรับการแจ้งเตือนเบื้องหลังทันที", "shared_mailboxes_no_push": "กล่องข้อความที่ผู้อื่นแชร์กับคุณจะไม่ส่งการแจ้งเตือนเบื้องหลัง", "error_forbidden": "เมลเซิร์ฟเวอร์ไม่อนุญาตการแจ้งเตือนเบื้องหลังสำหรับบัญชีนี้", - "error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง" + "error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง", + "error_blocked": "การแจ้งเตือนถูกบล็อกสำหรับเว็บไซต์นี้ อนุญาตในการตั้งค่าเว็บไซต์ของเบราว์เซอร์ แล้วลองอีกครั้ง", + "error_not_granted": "ไม่ได้อนุญาตการแจ้งเตือน พุชจึงยังปิดอยู่", + "error_verification_timeout": "เซิร์ฟเวอร์อีเมลไม่ยืนยันอุปกรณ์นี้ทันเวลา พุชจึงยังปิดอยู่ ลองอีกครั้งในอีกหนึ่งนาที", + "error_relay": "ไม่สามารถเชื่อมต่อรีเลย์พุชได้ในขณะนี้ พุชจึงยังปิดอยู่ ลองอีกครั้งภายหลัง", + "error_teardown": "ปิดพุชในเบราว์เซอร์นี้แล้ว แต่ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์อีเมลหรือรีเลย์ได้ อาจยังได้รับการแจ้งเตือนอีกสองสามวัน เพิกถอนอุปกรณ์นี้ด้านล่างเมื่อกลับมาออนไลน์" }, "push_all": { "title": "ทุกบัญชี", diff --git a/locales/tr/common.json b/locales/tr/common.json index 053804e73..37bd1d419 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak.", "shared_mailboxes_no_push": "Başkalarının sizinle paylaştığı posta kutuları arka plan bildirimi göndermez.", "error_forbidden": "Posta sunucusu bu hesap için arka plan bildirimlerine izin vermiyor.", - "error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin." + "error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin.", + "error_blocked": "Bu site için bildirimler engellendi. Tarayıcınızın site ayarlarından izin verin ve yeniden deneyin.", + "error_not_granted": "Bildirimlere izin verilmedi, bu yüzden anlık bildirim kapalı kalıyor.", + "error_verification_timeout": "Posta sunucusu bu cihazı zamanında onaylamadı, bu yüzden anlık bildirim kapalı kalıyor. Bir dakika sonra yeniden deneyin.", + "error_relay": "Anlık bildirim aktarıcısına şu anda ulaşılamıyor, bu yüzden anlık bildirim kapalı kalıyor. Daha sonra yeniden deneyin.", + "error_teardown": "Anlık bildirim bu tarayıcıda kapatıldı, ancak posta sunucusuna veya aktarıcıya ulaşılamadı. Bildirimler birkaç gün daha gelebilir. Yeniden çevrimiçi olduğunuzda bu cihazı aşağıdan iptal edin." } }, "language_region": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 39971a583..6e75c5aa5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення.", "shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.", "error_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.", - "error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз." + "error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз.", + "error_blocked": "Сповіщення для цього сайту заблоковано. Дозвольте їх у налаштуваннях сайту в браузері та спробуйте ще раз.", + "error_not_granted": "Сповіщення не дозволено, тому push залишається вимкненим.", + "error_verification_timeout": "Поштовий сервер не підтвердив цей пристрій вчасно, тому push залишається вимкненим. Спробуйте ще раз за хвилину.", + "error_relay": "Push-ретранслятор зараз недоступний, тому push залишається вимкненим. Спробуйте пізніше.", + "error_teardown": "Push у цьому браузері вимкнено, але поштовий сервер або ретранслятор були недоступні. Сповіщення можуть надходити ще кілька днів. Коли знову будете онлайн, відкличте цей пристрій нижче." } }, "language_region": { diff --git a/locales/vi/common.json b/locales/vi/common.json index ab0ab912d..e39b10a1b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1778,7 +1778,12 @@ "confirm_revoke_message_this": "Thiết bị này sẽ ngừng nhận thông báo chạy nền ngay lập tức.", "shared_mailboxes_no_push": "Hộp thư được người khác chia sẻ với bạn không gửi thông báo chạy nền.", "error_forbidden": "Máy chủ thư không cho phép bật thông báo chạy nền cho tài khoản này.", - "error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại." + "error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại.", + "error_blocked": "Thông báo đang bị chặn cho trang này. Hãy cho phép trong cài đặt trang của trình duyệt rồi thử lại.", + "error_not_granted": "Thông báo không được cho phép nên push vẫn tắt.", + "error_verification_timeout": "Máy chủ thư không xác nhận thiết bị này kịp thời nên push vẫn tắt. Hãy thử lại sau một phút.", + "error_relay": "Hiện không thể kết nối với relay push nên push vẫn tắt. Hãy thử lại sau.", + "error_teardown": "Push đã tắt trong trình duyệt này nhưng không thể kết nối với máy chủ thư hoặc relay. Thông báo có thể tiếp tục đến trong vài ngày. Hãy thu hồi thiết bị này bên dưới khi bạn có mạng trở lại." }, "push_all": { "title": "Tất cả tài khoản", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index edc89d86b..951224637 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1766,7 +1766,12 @@ "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。", "shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。", "error_forbidden": "郵件伺服器不允許此帳號使用背景通知。", - "error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。" + "error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。", + "error_blocked": "此網站的通知已被封鎖。請在瀏覽器的網站設定中允許通知,然後再試一次。", + "error_not_granted": "未允許通知,因此推播保持關閉。", + "error_verification_timeout": "郵件伺服器未及時確認此裝置,因此推播保持關閉。請一分鐘後再試一次。", + "error_relay": "目前無法連線到推播中繼,因此推播保持關閉。請稍後再試。", + "error_teardown": "此瀏覽器的推播已關閉,但無法連線到郵件伺服器或中繼。通知可能還會持續幾天。恢復連線後,請在下方撤銷此裝置。" }, "push_all": { "title": "All accounts", diff --git a/locales/zh/common.json b/locales/zh/common.json index bc5ffd4e2..f2bcc6cda 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1799,7 +1799,12 @@ "confirm_revoke_message_this": "此设备将立即停止接收后台通知。", "shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。", "error_forbidden": "邮件服务器不允许此账户使用后台通知。", - "error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。" + "error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。", + "error_blocked": "此网站的通知已被阻止。请在浏览器的网站设置中允许通知,然后重试。", + "error_not_granted": "未允许通知,因此推送保持关闭。", + "error_verification_timeout": "邮件服务器未及时确认此设备,因此推送保持关闭。请一分钟后重试。", + "error_relay": "目前无法连接推送中继,因此推送保持关闭。请稍后重试。", + "error_teardown": "此浏览器的推送已关闭,但无法连接邮件服务器或中继。通知可能还会持续几天。恢复联网后,请在下方撤销此设备。" } }, "language_region": { From a6ef8181499b92da02a1545e6764bdb2f363eb8f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:01:41 +0100 Subject: [PATCH 15/35] fix: end a login's push registration when it signs out, is removed or wiped --- lib/__tests__/web-push.test.ts | 52 +++++++++++++++++++ lib/offline/device-wipe.ts | 1 + lib/web-push.ts | 17 ++++++ .../__tests__/auth-store-push-signout.test.ts | 46 ++++++++++++++++ stores/auth-store.ts | 26 ++++++++-- 5 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 stores/__tests__/auth-store-push-signout.test.ts diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 32dafd0a3..f1d8a1c36 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -7,6 +7,7 @@ import { buildEmailPushConfig, disableWebPush, enableWebPush, + endWebPushForSignOut, isWebPushEnabled, listPushDevices, mailAccountsWithoutPush, @@ -1724,6 +1725,57 @@ describe('disableWebPush', () => { }); }); +describe('endWebPushForSignOut', () => { + it('takes everything down for a login with push on, using the relay it registered with', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + const calls = installFetch({}); + + await endWebPushForSignOut(client); + + expect(client.destroyed).toEqual(['push-mine']); + expect(calls).toEqual([{ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }]); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('does nothing and calls nobody for a login without push', async () => { + const client = makeClient([]); + const calls = installFetch({}); + + await endWebPushForSignOut(client); + + expect(calls).toEqual([]); + expect(client.listPushSubscriptions).not.toHaveBeenCalled(); + }); + + it('never throws: signing out goes on', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); })); + (client.destroyPushSubscription as ReturnType).mockRejectedValue(new Error('offline')); + + await expect(endWebPushForSignOut(client)).resolves.toBeUndefined(); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('forgets the key material with nobody left', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const { browserSub } = installPushBrowser(); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + + await endWebPushForSignOut(client); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + }); +}); + describe('listPushDevices', () => { it('flags this device and reports the relay status of each registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index faface568..c20b329c3 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -265,6 +265,7 @@ async function endWebPushIfNobodyLeft(): Promise { const registration = await pushRegistration(); const subscription = await registration?.pushManager?.getSubscription(); await subscription?.unsubscribe(); + removeLocalKeys([], ['bulwark.push.']); } /** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */ diff --git a/lib/web-push.ts b/lib/web-push.ts index 9b7ee0473..f611ece59 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -1031,6 +1031,23 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise { + let accountId: string; + try { + accountId = client.getAccountId(); + } catch { + return; + } + if (!accountId || typeof localStorage === 'undefined' || !localStorage.getItem(subscriptionIdKey(accountId))) return; + const relayBaseUrl = localStorage.getItem(relayBaseKey(accountId)) ?? undefined; + await disableWebPush({ client, relayBaseUrl }).catch(() => undefined); +} + export interface PushDevice { // The JMAP PushSubscription id - what you destroy to revoke it. id: string; diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts new file mode 100644 index 000000000..9d98268d9 --- /dev/null +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut })); + +import { useAuthStore } from '../auth-store'; + +function fakeClient() { + return { disconnect: vi.fn(), getAccountId: () => 'jmap-a' } as unknown as NonNullable['client']>; +} + +describe('signing out ends the login\'s push registration', () => { + beforeEach(() => { + endWebPushForSignOut.mockClear(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); + }); + + it('when the user signs out', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout(); + expect(endWebPushForSignOut).toHaveBeenCalledWith(client); + }); + + it('when a wipe signs the login out', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout({ endProviderSession: false }); + expect(endWebPushForSignOut).toHaveBeenCalledWith(client); + }); + + it('not when the session only ran out: it comes back with the next sign-in', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true }); + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + }); + + it('not in demo mode', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true }); + await useAuthStore.getState().logout(); + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + useAuthStore.setState({ isDemoMode: false }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 7953d72a2..ee5e6ef21 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1625,6 +1625,17 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' }); } +/** Within the sign-out's budget: an unreachable server doesn't hold it up. */ +function endPushForSignOut(signingOut: Iterable): Promise { + return Promise.race([ + (async () => { + const { endWebPushForSignOut } = await import('@/lib/web-push'); + for (const client of signingOut) await endWebPushForSignOut(client); + })(), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); +} + function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise { return Promise.race([ Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}), @@ -2541,6 +2552,8 @@ export const useAuthStore = create()( if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return; await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData }); + // A session that only ran out comes back with the next sign-in, push included. + if (state.client && !wasDemoMode && !keepLocalData) await endPushForSignOut([state.client]); // From here on nothing may renew this slot's session; the cleanup // below reopens it once the credentials are gone. @@ -2690,12 +2703,14 @@ export const useAuthStore = create()( if (!account) return; const slot = account.cookieSlot ?? 0; const wasOAuth = account.authMode === 'oauth'; - - // Started first: the cleanup marks the slot closing right away, so a - // renewal in flight cannot bring the session back. - void clearSlotCredentials(slot, wasOAuth); - clearRefreshTimer(accountId); const client = clients.get(accountId); + // The login's push goes first, while its session still works. + const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve(); + + // The cleanup marks the slot closing right away, so a renewal in flight + // cannot bring the session back. + void pushEnded.finally(() => clearSlotCredentials(slot, wasOAuth)); + clearRefreshTimer(accountId); if (client) { forgetCalendarSubscriptions(client); try { client.disconnect(); } catch { /* noop */ } @@ -2717,6 +2732,7 @@ export const useAuthStore = create()( for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); } await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); + if (!get().isDemoMode && !keepLocalData) await endPushForSignOut(clients.values()); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; From 5c62c5765703336bc50edaf0726ae37dc30da0b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:02:32 +0100 Subject: [PATCH 16/35] fix: replace a browser subscription the push service gave up on --- lib/__tests__/web-push.test.ts | 74 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 41 +++++++++++++++++-- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index f1d8a1c36..dbb2fb478 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -741,6 +741,80 @@ describe('what enabling and disabling say when they fail', () => { }); }); +describe('an endpoint the push service gave up on', () => { + const ENDPOINT_KEY = `bulwark.push.endpoint.v1.${ACCOUNT_ID}`; + + function registered() { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/endpoint'); + return makeClient([sub('push-mine', THIS_DEVICE)]); + } + + it('gets a new subscription when the relay dropped the record for the very endpoint this browser still holds', async () => { + const { browserSub, registration } = installPushBrowser(); + const client = registered(); + installFetch({}); // The relay answers 404 for this device. + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); + + it('keeps the subscription while the relay knows the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + installFetch({ [THIS_DEVICE]: true }); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('keeps it when the relay cannot be asked', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + const calls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes('/api/push/active/')) throw new TypeError('blip'); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.some((url) => url.includes('/api/push/active/'))).toBe(true); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('only registers when another account already moved the browser to a new endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/older'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + expect(localStorage.getItem(ENDPOINT_KEY)).toBe('https://fcm.example/endpoint'); + }); + + it('does not mistake a move to another relay for a dead endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index f611ece59..11ca5a16b 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -53,6 +53,12 @@ function relayBaseKey(accountId: string): string { return RELAY_BASE_PREFIX + accountId; } +/** The browser endpoint this account last gave the relay: see relayDroppedOurEndpoint. */ +const ENDPOINT_PREFIX = 'bulwark.push.endpoint.v1.'; +function endpointKey(accountId: string): string { + return ENDPOINT_PREFIX + accountId; +} + function emailPushRefusedKey(accountId: string): string { return EMAIL_PUSH_REFUSED_PREFIX + accountId; } @@ -642,6 +648,28 @@ async function relayReportsDead( const VERIFICATION_RESEND_AFTER_MS = 62_000; const VERIFICATION_TIMEOUT_MS = 80_000; +/** + * The relay deletes a record when the push service answers 404 or 410. The browser + * doesn't get told: it keeps the subscription, and registering that endpoint again + * only lasts until the next push. Only for the endpoint this account registered: + * after another account moved the browser on, a missing record means nothing. + */ +async function relayDroppedOurEndpoint( + accountId: string, + relayBaseUrl: string, + subscriptionId: string, + endpoint: string, +): Promise { + if (localStorage.getItem(relayBaseKey(accountId)) !== relayBaseUrl) return false; + if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false; + try { + const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`)); + return res.status === 404; + } catch { + return false; + } +} + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, @@ -785,6 +813,14 @@ async function enableWebPushForThisTab( await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; } + const accountId = params.client.getAccountId(); + const deviceClientId = getOrCreateDeviceClientId(accountId); + if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) { + await pushSubscription.unsubscribe().catch(() => undefined); + pushSubscription = null; + // The other accounts' records point at the old endpoint: their turn comes too. + lastResync.clear(); + } if (!pushSubscription) { pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, @@ -792,9 +828,6 @@ async function enableWebPushForThisTab( }); } - const accountId = params.client.getAccountId(); - const deviceClientId = getOrCreateDeviceClientId(accountId); - await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, @@ -807,6 +840,7 @@ async function enableWebPushForThisTab( }, accountLabel: params.accountLabel, }); + localStorage.setItem(endpointKey(accountId), pushSubscription.endpoint); await keepRelayRegistration(accountId, { relay: relayBaseUrl, subscriptionId: deviceClientId, @@ -1001,6 +1035,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise Date: Tue, 3 Nov 2026 23:23:23 +0100 Subject: [PATCH 17/35] fix: show the real sender and subject for a push after the browser was closed --- app/api/push/action/route.ts | 5 +- app/api/push/preview/route.ts | 4 +- lib/__tests__/push-auth-context.test.ts | 65 +++++++++++++++++++ .../push-preview-route-ambiguous.test.ts | 21 ++++++ lib/stalwart/push-auth-context.ts | 31 +++++++++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/push-auth-context.test.ts create mode 100644 lib/stalwart/push-auth-context.ts diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts index 7ad419076..2e3d431d6 100644 --- a/app/api/push/action/route.ts +++ b/app/api/push/action/route.ts @@ -10,7 +10,8 @@ import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { DisallowedUrlError } from '@/lib/security/url-guard'; -import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import type { StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import type { SettingsPolicy } from '@/lib/admin/types'; import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; @@ -98,7 +99,7 @@ async function loginsNamed(login: string): Promise { const cookieStore = await cookies(); const found: StalwartAuthContext[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); } return found; diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 2c214351c..68ba99d35 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { cleanPreview } from '@/lib/utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; -import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { getStalwartCredentials, type StalwartCredentials, @@ -54,7 +54,7 @@ async function resolveTargetForAccount(accountId: string, login: string | null, const cookieStore = await cookies(); const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (!ctx) continue; const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); probes.push( diff --git a/lib/__tests__/push-auth-context.test.ts b/lib/__tests__/push-auth-context.test.ts new file mode 100644 index 000000000..33cef70d5 --- /dev/null +++ b/lib/__tests__/push-auth-context.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The context cookie lasts as long as the browser session. With every window +// closed and the browser started again by a push, only the remembered session +// is left, and the worker has to make do with that or show "New mail". +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +const maxHours = vi.hoisted(() => ({ value: 0 })); +vi.mock('@/lib/auth/session-age', () => ({ + isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000, + limitsEnforcedSince: async () => Date.now(), + maxSessionHoursFor: async () => maxHours.value, +})); +const impersonation = vi.hoisted(() => ({ master: null as string | null })); +vi.mock('@/lib/impersonation/master-config', () => ({ + readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null), +})); + +import { encryptSession } from '@/lib/auth/crypto'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; +import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context'; +import { encryptPayload } from '@/lib/auth/crypto'; + +function store(cookies: Record) { + return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never; +} + +const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt); + +beforeEach(() => { + process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789'; + maxHours.value = 0; + impersonation.master = null; +}); + +describe('readPushAuthContext', () => { + it('prefers the context cookie', async () => { + const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context'); + const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0); + expect(found?.username).toBe('ctx@example.org'); + }); + + it('falls back to the remembered session', async () => { + const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1); + expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' }); + expect(found?.authHeader).toMatch(/^Basic /); + }); + + it('finds nothing in another slot, or without either cookie', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull(); + expect(await readPushAuthContext(store({}), 0)).toBeNull(); + }); + + it('refuses a session past the organisation\'s maximum length', async () => { + maxHours.value = 1; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull(); + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull(); + }); + + it('refuses garbage and a cookie holding the impersonation master password', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull(); + impersonation.master = 'master-secret'; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull(); + }); +}); diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index 93e74d40f..eeeb41d28 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -30,9 +30,14 @@ const LOGINS = [ { serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' }, { serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' }, ]; +const ORIGINAL_LOGINS = [...LOGINS]; vi.mock('@/lib/stalwart/auth-context', () => ({ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, })); +const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } })); +vi.mock('@/lib/stalwart/push-auth-context', () => ({ + readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null), +})); vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn(), @@ -88,6 +93,22 @@ async function callRoute(accountId: string, login?: string) { const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap')); +describe('push preview after the browser was closed', () => { + it('reads the login from the remembered session when the context cookie is gone', async () => { + remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' }; + LOGINS.length = 0; + try { + SESSIONS = { 'Basic remembered': session('https://one.example', 'a') }; + const { status, body } = await callRoute('a'); + expect(status).toBe(200); + expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered'); + } finally { + remembered.value = null; + LOGINS.push(...ORIGINAL_LOGINS); + } + }); +}); + describe('push preview when the account id is in more than one login', () => { beforeEach(() => { fetchJmapServer.mockClear(); diff --git a/lib/stalwart/push-auth-context.ts b/lib/stalwart/push-auth-context.ts new file mode 100644 index 000000000..793589101 --- /dev/null +++ b/lib/stalwart/push-auth-context.ts @@ -0,0 +1,31 @@ +import { cookies } from 'next/headers'; +import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto'; +import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; + +type CookieStore = Awaited>; + +/** + * The login for a request from the service worker, which has no page to refresh + * anything. The context cookie goes with the browser session, so a browser that + * was closed and woken again by a push only has the remembered one, which a page + * would turn back into a context on load (GET /api/auth/session, with the same + * checks). Not signed in with "remember me", or by OAuth with an expired token: + * none, and the worker shows its generic notification. + */ +export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise { + const context = readStalwartAuthContextFromStore(cookieStore, slot); + if (context) return context; + const token = cookieStore.get(sessionCookieName(slot))?.value; + if (!token) return null; + const credentials = decryptSession(token); + if (!credentials) return null; + const impersonation = readImpersonationConfig(); + if (impersonation && credentials.password === impersonation.masterPassword) return null; + const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); + const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]); + if (isPastMaxAge(signedInAt, maxHours)) return null; + return { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials) }; +} From 09d6bff12a034b9c50725e4763806f1bf0298576 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:36:43 +0100 Subject: [PATCH 18/35] fix: say push isn't available in the demo instead of offering a switch that fails --- .../__tests__/notification-settings.test.tsx | 21 ++++++++++++++++++- components/settings/multi-account-push.tsx | 3 ++- components/settings/notification-settings.tsx | 7 +++++-- 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/en/common.json | 1 + locales/es/common.json | 1 + locales/et/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/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 33 files changed, 57 insertions(+), 4 deletions(-) diff --git a/components/settings/__tests__/notification-settings.test.tsx b/components/settings/__tests__/notification-settings.test.tsx index 5b6a4b5e2..0913b7204 100644 --- a/components/settings/__tests__/notification-settings.test.tsx +++ b/components/settings/__tests__/notification-settings.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { act } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import { NotificationSettings } from '../notification-settings'; import { useSettingsStore } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -137,6 +137,25 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => { }); }); +describe('NotificationSettings - demo mode', () => { + beforeEach(() => { + useAuthStore.setState({ client: fakeClient, username: 'me@example.com', isDemoMode: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + useAuthStore.setState({ isDemoMode: false }); + }); + + it('says push is not available and offers no switch that would call the relay', async () => { + const { findByText, getAllByRole } = render(); + await findByText('push.status_demo'); + const pushSwitch = getAllByRole('switch').find((el) => (el as HTMLButtonElement).disabled); + expect(pushSwitch).toBeTruthy(); + expect(webPush.enableWebPush).not.toHaveBeenCalled(); + }); +}); + describe('NotificationSettings - mailboxes shared by ACL', () => { const mailbox = (id: string, accountId: string, isShared: boolean) => ({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox; diff --git a/components/settings/multi-account-push.tsx b/components/settings/multi-account-push.tsx index 0440f2ef0..4292e9c8e 100644 --- a/components/settings/multi-account-push.tsx +++ b/components/settings/multi-account-push.tsx @@ -43,6 +43,7 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri const accounts = useAccountStore(s => s.accounts); // Re-read the client map whenever a connection appears or drops. const revision = useAuthStore(s => s.connectedAccountsRevision); + const isDemoMode = useAuthStore(s => s.isDemoMode); const [enabled, setEnabled] = useState>({}); const [chosen, setChosen] = useState>(new Set()); const [busy, setBusy] = useState(false); @@ -67,7 +68,7 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri useEffect(() => { void refresh(); }, [refresh, revision]); - if (!isWebPushSupported() || connected.length < 2) return null; + if (!isWebPushSupported() || isDemoMode || connected.length < 2) return null; const toggle = (id: string, on: boolean) => setChosen(prev => { const next = new Set(prev); diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 9f51ce1c3..45923d8d9 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -72,7 +72,10 @@ export function NotificationSettings() { const username = useAuthStore((s) => s.username); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); - const supported = typeof window !== 'undefined' && isWebPushSupported(); + // The demo client has no mail server to subscribe with. Enabling would get as + // far as a relay record and a browser subscription, then fail. + const isDemoMode = useAuthStore((s) => s.isDemoMode); + const supported = typeof window !== 'undefined' && isWebPushSupported() && !isDemoMode; // "Inbox only" is enforced by the server's emailPush filter; without that // capability (Stalwart < 0.16.16) the toggle would do nothing. const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client); @@ -145,7 +148,7 @@ export function NotificationSettings() { // In the static Lite build push is off by design (no service worker, see // components/service-worker-registration.tsx), not a browser limitation. const statusDescription = pushStatus.kind === 'unsupported' - ? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) + ? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) : noRelay && !pushEnabled ? tThirdParty('push_needs_relay') : busy diff --git a/locales/ar/common.json b/locales/ar/common.json index 9160b9d9e..1c960e8b0 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "غير مفعّل على هذا الجهاز", "status_unsupported": "هذا المتصفح لا يدعم Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "الإشعارات في الخلفية غير متاحة في النسخة التجريبية.", "status_busy": "جارٍ العمل…", "enable": "تفعيل", "reenable": "إعادة التسجيل", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0c8b0db4f..0e65b7279 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "No activat en aquest dispositiu", "status_unsupported": "Aquest navegador no admet Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Les notificacions en segon pla no estan disponibles a la demo.", "status_busy": "Treballant…", "enable": "Activa", "reenable": "Torna a registrar", diff --git a/locales/cs/common.json b/locales/cs/common.json index 00c194398..8d465618b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Není povoleno na tomto zařízení", "status_unsupported": "Tento prohlížeč nepodporuje Web Push", "status_lite": "Web Push není v Bulwark Lite k dispozici; vyžaduje server Bulwark.", + "status_demo": "Oznámení na pozadí nejsou v ukázce k dispozici.", "title": "Oznámení na pozadí", "devices_title": "Registrovaná zařízení", "devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.", diff --git a/locales/da/common.json b/locales/da/common.json index 3b6cedfea..9905f5145 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Ikke aktiveret på denne enhed", "status_unsupported": "Denne browser understøtter ikke Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Baggrundsnotifikationer er ikke tilgængelige i demoen.", "status_busy": "Arbejder…", "enable": "Aktivér", "reenable": "Genregistrér", diff --git a/locales/de/common.json b/locales/de/common.json index 8683aef99..48c693265 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Auf diesem Gerät nicht aktiviert", "status_unsupported": "Dieser Browser unterstützt Web Push nicht", "status_lite": "Web Push ist in Bulwark Lite nicht verfügbar; es benötigt den Bulwark-Server.", + "status_demo": "Hintergrundbenachrichtigungen sind in der Demo nicht verfügbar.", "title": "Hintergrundbenachrichtigungen", "devices_title": "Registrierte Geräte", "devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.", diff --git a/locales/en/common.json b/locales/en/common.json index 581b3425a..e8172a9e1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Not enabled on this device", "status_unsupported": "This browser does not support Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Background notifications aren't available in the demo.", "status_busy": "Working…", "enable": "Enable", "reenable": "Re-register", diff --git a/locales/es/common.json b/locales/es/common.json index ccdb06d71..5ae59894c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "No habilitado en este dispositivo", "status_unsupported": "Este navegador no admite Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Las notificaciones en segundo plano no están disponibles en la demo.", "title": "Notificaciones en segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.", diff --git a/locales/et/common.json b/locales/et/common.json index 7fa14e178..10037d2ab 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Selles seadmes pole lubatud", "status_unsupported": "See brauser ei toeta Web Pushi", "status_lite": "Veebi-tõukemärguanded pole Bulwark Lite'is saadaval; need vajavad Bulwarki serverit.", + "status_demo": "Taustateavitused pole demos saadaval.", "status_busy": "Töötleb…", "enable": "Luba", "reenable": "Registreeri uuesti", diff --git a/locales/fa/common.json b/locales/fa/common.json index 16da56923..71506718d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "فعال نشده", "status_unsupported": "مرورگر پشتیبانی نمی‌کند", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "اعلان‌های پس‌زمینه در نسخهٔ نمایشی در دسترس نیستند.", "status_busy": "در حال کار…", "enable": "فعال کردن", "reenable": "ثبت مجدد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 54c3a8f12..4ecfde5a1 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Non activé sur cet appareil", "status_unsupported": "Ce navigateur ne prend pas en charge Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Les notifications en arrière-plan ne sont pas disponibles dans la démo.", "title": "Notifications en arrière-plan", "devices_title": "Appareils enregistrés", "devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.", diff --git a/locales/he/common.json b/locales/he/common.json index ad4a5610d..0bf809793 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1716,6 +1716,7 @@ "status_inactive": "לא מופעל בהתקן זה", "status_unsupported": "דפדפן זה אינו תומך בדחיפה ברשת", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "התראות ברקע אינן זמינות בהדגמה.", "status_busy": "עובד…", "enable": "הפעל", "reenable": "רשום מחדש", diff --git a/locales/hu/common.json b/locales/hu/common.json index 712b314b6..e90462fd3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Nincs engedélyezve ezen az eszközön", "status_unsupported": "Ez a böngésző nem támogatja a Web Push-t", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "A háttérértesítések nem érhetők el a bemutatóban.", "status_busy": "Munka...", "enable": "Engedélyezés", "reenable": "Újraregisztráció", diff --git a/locales/it/common.json b/locales/it/common.json index 3b2f3f309..e59c76eaf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Non abilitato su questo dispositivo", "status_unsupported": "Questo browser non supporta Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Le notifiche in background non sono disponibili nella demo.", "title": "Notifiche in background", "devices_title": "Dispositivi registrati", "devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 14bd89589..4298ac9ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "このデバイスでは有効になっていません", "status_unsupported": "このブラウザは Web Push をサポートしていません", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "デモではバックグラウンド通知を利用できません。", "title": "バックグラウンド通知", "devices_title": "登録済みのデバイス", "devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9d973a353..29237df21 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "이 기기에서 활성화되지 않음", "status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "데모에서는 백그라운드 알림을 사용할 수 없습니다.", "title": "백그라운드 알림", "devices_title": "등록된 기기", "devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8212ea83d..a200234cc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Nav iespējots šajā ierīcē", "status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Fona paziņojumi demonstrācijā nav pieejami.", "title": "Fona paziņojumi", "devices_title": "Reģistrētās ierīces", "devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.", diff --git a/locales/mn/common.json b/locales/mn/common.json index d5c216da0..ab56dead7 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Энэ төхөөрөмж дээр идэвхжээгүй", "status_unsupported": "Энэ хөтөч Web Push-ийг дэмждэггүй", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Демо хувилбарт арын мэдэгдэл ашиглах боломжгүй.", "status_busy": "Ажиллаж байна…", "enable": "Идэвхжүүлэх", "reenable": "Дахин бүртгүүлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 009591c0c..6e0e2a5fc 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Ikke aktivert på denne enheten", "status_unsupported": "Denne nettleseren støtter ikke Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Bakgrunnsvarsler er ikke tilgjengelige i demoen.", "status_busy": "Behandler...", "enable": "Aktiver", "reenable": "Registrer på nytt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9fda40fb1..7efabe5fa 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Niet ingeschakeld op dit apparaat", "status_unsupported": "Deze browser ondersteunt geen Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Achtergrondmeldingen zijn niet beschikbaar in de demo.", "title": "Achtergrondmeldingen", "devices_title": "Geregistreerde apparaten", "devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 58eb0889b..b3f09c304 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Niewłączone na tym urządzeniu", "status_unsupported": "Ta przeglądarka nie obsługuje Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Powiadomienia w tle nie są dostępne w wersji demonstracyjnej.", "title": "Powiadomienia w tle", "devices_title": "Zarejestrowane urządzenia", "devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.", diff --git a/locales/pt/common.json b/locales/pt/common.json index ceef37c67..7f5cc4d26 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Não ativado neste dispositivo", "status_unsupported": "Este navegador não oferece suporte a Web Push", "status_lite": "O Web Push não está disponível no Bulwark Lite; ele requer o servidor do Bulwark.", + "status_demo": "As notificações em segundo plano não estão disponíveis na demonstração.", "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.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 0a6094cef..7691ca97c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Nu este activat pe acest dispozitiv", "status_unsupported": "Acest browser nu acceptă Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Notificările în fundal nu sunt disponibile în demonstrație.", "status_busy": "Se lucrează…", "enable": "Activați", "reenable": "Reînregistrează-te", diff --git a/locales/ru/common.json b/locales/ru/common.json index 093056d75..21ca4ae3c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Не включено на этом устройстве", "status_unsupported": "Этот браузер не поддерживает Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Фоновые уведомления недоступны в демоверсии.", "title": "Фоновые уведомления", "devices_title": "Зарегистрированные устройства", "devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 387194376..6a7f8d987 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1776,6 +1776,7 @@ "status_inactive": "Nie je povolené na tomto zariadení", "status_unsupported": "Tento prehliadač nepodporuje Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Oznámenia na pozadí nie sú v ukážke k dispozícii.", "status_busy": "Pracujem…", "enable": "Povoliť", "reenable": "Znovu zaregistrovať", diff --git a/locales/th/common.json b/locales/th/common.json index 6ba32b244..9018edf86 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4636,6 +4636,7 @@ "status_inactive": "ไม่ได้เปิดใช้งานบนอุปกรณ์นี้", "status_unsupported": "เบราว์เซอร์นี้ไม่รองรับ Web Push", "status_lite": "Web Push ไม่พร้อมใช้งานใน Bulwark Lite จำเป็นต้องใช้ Bulwark server", + "status_demo": "การแจ้งเตือนในพื้นหลังไม่พร้อมใช้งานในเดโม", "status_busy": "กำลังดำเนินการ…", "enable": "เปิดใช้งาน", "reenable": "ลงทะเบียนใหม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 37bd1d419..6460ac89d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Bu cihazda etkinleştirilmedi", "status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Arka plan bildirimleri demoda kullanılamaz.", "title": "Arka plan bildirimleri", "devices_title": "Kayıtlı cihazlar", "devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6e75c5aa5..86392652d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Не ввімкнено на цьому пристрої", "status_unsupported": "Цей браузер не підтримує Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Фонові сповіщення недоступні в демоверсії.", "title": "Фонові сповіщення", "devices_title": "Зареєстровані пристрої", "devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.", diff --git a/locales/vi/common.json b/locales/vi/common.json index e39b10a1b..e6c30e535 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1752,6 +1752,7 @@ "status_inactive": "Chưa bật trên thiết bị này", "status_unsupported": "Trình duyệt này không hỗ trợ Web Push", "status_lite": "Bulwark Lite không hỗ trợ Web Push; tính năng này cần máy chủ Bulwark.", + "status_demo": "Thông báo nền không khả dụng trong bản demo.", "status_busy": "Đang xử lý…", "enable": "Bật", "reenable": "Đăng ký lại", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 951224637..2d1ff3b32 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "在此裝置上未啟用", "status_unsupported": "此瀏覽器不支援 Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "展示版不提供背景通知。", "status_busy": "工作中…", "enable": "啟用", "reenable": "重新註冊", diff --git a/locales/zh/common.json b/locales/zh/common.json index f2bcc6cda..e4712db8f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "在此设备上未启用", "status_unsupported": "此浏览器不支持 Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "演示版不提供后台通知。", "title": "后台通知", "devices_title": "已注册的设备", "devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。", From 80084457be829aab3051b71ece1d07bfde293098 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:08:24 +0100 Subject: [PATCH 19/35] fix: end a removed login's push before its client disconnects --- .../__tests__/auth-store-push-signout.test.ts | 44 ++++++++++++++++++- stores/auth-store.ts | 28 +++++++----- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts index 9d98268d9..2c71e9486 100644 --- a/stores/__tests__/auth-store-push-signout.test.ts +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {})); vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut })); -import { useAuthStore } from '../auth-store'; +import { setClientForTests, useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; function fakeClient() { return { disconnect: vi.fn(), getAccountId: () => 'jmap-a' } as unknown as NonNullable['client']>; @@ -36,6 +37,47 @@ describe('signing out ends the login\'s push registration', () => { expect(endWebPushForSignOut).not.toHaveBeenCalled(); }); + it('removing a login ends its push while its client can still reach the server', async () => { + let accountId = 'jmap-b'; + const order: string[] = []; + const client = { + disconnect: vi.fn(() => { order.push('disconnect'); accountId = ''; }), + getAccountId: () => accountId, + } as unknown as Parameters[1]; + endWebPushForSignOut.mockImplementationOnce(async (seen: { getAccountId: () => string }) => { + await Promise.resolve(); + order.push(`push ended for ${seen.getAccountId()}`); + }); + setClientForTests('login-b', client); + useAccountStore.setState({ accounts: [{ id: 'login-b', cookieSlot: 1, username: 'b', serverUrl: 'https://x' }] as never }); + useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false }); + + useAuthStore.getState().removeAccount('login-b'); + await vi.waitFor(() => expect(order).toContain('disconnect')); + + expect(order).toEqual(['push ended for jmap-b', 'disconnect']); + }); + + it('a login the session threw out keeps its push for the next sign-in', async () => { + const client = fakeClient(); + setClientForTests('login-c', client); + useAccountStore.setState({ accounts: [{ id: 'login-c', cookieSlot: 1, username: 'c', serverUrl: 'https://x' }] as never }); + useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false }); + + useAuthStore.getState().removeAccount('login-c', 'evicted'); + await vi.waitFor(() => expect(client.disconnect).toHaveBeenCalled()); + + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + }); + + it('signs out even if the push code cannot be loaded', async () => { + const client = fakeClient(); + endWebPushForSignOut.mockRejectedValueOnce(new Error('chunk failed')); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().client).toBeNull(); + }); + it('not in demo mode', async () => { const client = fakeClient(); useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index ee5e6ef21..e14a2171e 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1631,7 +1631,7 @@ function endPushForSignOut(signingOut: Iterable): Promise { (async () => { const { endWebPushForSignOut } = await import('@/lib/web-push'); for (const client of signingOut) await endWebPushForSignOut(client); - })(), + })().catch(() => undefined), new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), ]); } @@ -1643,6 +1643,11 @@ function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = { ]); } +/** Test helper: the store's own clients come from signing in. */ +export function setClientForTests(accountId: string, client: JMAPClient): void { + clients.set(accountId, client); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -2704,17 +2709,18 @@ export const useAuthStore = create()( const slot = account.cookieSlot ?? 0; const wasOAuth = account.authMode === 'oauth'; const client = clients.get(accountId); - // The login's push goes first, while its session still works. - const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve(); - - // The cleanup marks the slot closing right away, so a renewal in flight - // cannot bring the session back. - void pushEnded.finally(() => clearSlotCredentials(slot, wasOAuth)); + // The slot is closing from here on: a renewal in flight cannot bring the + // session back. + closingSlots.add(slot); clearRefreshTimer(accountId); - if (client) { - forgetCalendarSubscriptions(client); - try { client.disconnect(); } catch { /* noop */ } - } + if (client) forgetCalendarSubscriptions(client); + // The login's push goes first: a client that disconnected has no account + // id and no API left to reach the server with. + const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve(); + void pushEnded.finally(() => { + try { client?.disconnect(); } catch { /* noop */ } + return clearSlotCredentials(slot, wasOAuth); + }); clients.delete(accountId); evictAccount(accountId); forgetAccountRows(accountId, get().client); From f0617a61b19c4268adca9121981d113de87dba58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:08:41 +0100 Subject: [PATCH 20/35] fix: only treat a server subscription as this browser's when its device id matches --- lib/__tests__/web-push.test.ts | 40 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 9 +++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index dbb2fb478..7a44d1daa 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -815,6 +815,46 @@ describe('an endpoint the push service gave up on', () => { }); }); +describe('a subscription id the server gave to another device', () => { + // Stalwart hands out the lowest free id: this browser's subscription expired, + // and the phone that registered next got the same id. + it('creates its own instead of taking over the other device\'s', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({ [OTHER_DEVICE]: true }); + + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + expect(client.destroyed).toEqual([]); + expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); + }); + + it('leaves it alone when turning off', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({}); + + await disableWebPush({ client, relayBaseUrl: RELAY }); + + expect(client.destroyed).toEqual([]); + }); + + it('leaves it alone on Re-register', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({ [OTHER_DEVICE]: true }); + + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true }); + + expect(client.destroyed).toEqual([]); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index 11ca5a16b..20c969e90 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -874,7 +874,9 @@ async function enableWebPushForThisTab( const storedServerId = localStorage.getItem(subIdKey); let replacedServerId: string | null = null; if (storedServerId) { - const match = existingSubs.find((s) => s.id === storedServerId); + // By device too: Stalwart gives the id of an expired subscription to the next + // one, which may be another device's. + const match = existingSubs.find((s) => s.id === storedServerId && s.deviceClientId === deviceClientId); if (match) { if (!params.forceRecreate) { const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush); @@ -910,7 +912,7 @@ async function enableWebPushForThisTab( // relay can't vouch for (a different relay, a non-Bulwark client, a // network blip) - is left untouched. for (const s of existingSubs) { - if (s.id === storedServerId) continue; + if (s.id === replacedServerId) continue; if (s.deviceClientId === deviceClientId) { await params.client.destroyPushSubscription(s.id).catch(() => undefined); continue; @@ -1018,7 +1020,8 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise(); - if (storedServerId) idsToDestroy.add(storedServerId); + // The stored id alone can have gone to another device since (Stalwart reuses them). + if (storedServerId && !deviceClientId) idsToDestroy.add(storedServerId); if (deviceClientId) { const existingSubs = await params.client.listPushSubscriptions().catch(() => { failed.push('listing the subscriptions'); From 226b9383ce4a1e2dfe2138787d75828220d698c6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:09:03 +0100 Subject: [PATCH 21/35] fix: show a generic notification instead of guessing which login a push is for --- app/api/push/preview/route.ts | 19 ++++++++++----- .../push-preview-route-ambiguous.test.ts | 24 +++++++++++++++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 68ba99d35..111004c01 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -46,10 +46,12 @@ interface ResolvedTarget { // Inbox, nor the message that just arrived there. // // Ids repeat across servers (and a group account is listed by each member), so -// the account id alone can leave several logins. The push's label names the login -// that registered it, which settles it among those that have the account. Without -// one that fits, answer 'ambiguous' rather than guess: a wrong guess previews -// another login's mail and opens it there. +// the account id alone can leave several logins. On one server, the push's label +// names the login that registered it and settles it. Not across servers: logins +// with the same account id share one relay record in this browser (lib/web-push.ts +// keys it by account id), so the label is only whoever registered last. Whatever +// doesn't fit answers 'ambiguous' rather than guess: a wrong guess previews another +// login's mail and opens it there. async function resolveTargetForAccount(accountId: string, login: string | null, forwarded: Record): Promise { const cookieStore = await cookies(); const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; @@ -92,8 +94,13 @@ async function resolveTargetForAccount(accountId: string, login: string | null, ); } const results = (await Promise.all(probes)).filter((r) => r !== null); - const named = login ? results.filter((r) => r.target.username.toLowerCase() === login.toLowerCase()) : []; - if (named.length === 1) return named[0].target; + if (login && results.length > 0) { + const name = login.toLowerCase(); + const named = results.filter((r) => r.target.username.toLowerCase() === name || r.target.accountName?.toLowerCase() === name); + const servers = new Set(results.map((r) => r.target.serverUrl)); + if (named.length !== 1 || servers.size > 1) return 'ambiguous'; + return named[0].target; + } const owners = results.filter((r) => r.own); if (owners.length === 1) return owners[0].target; if (results.length === 1) return results[0].target; diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index eeeb41d28..156aa9c1e 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -114,16 +114,36 @@ describe('push preview when the account id is in more than one login', () => { fetchJmapServer.mockClear(); }); - it('takes the login the push names when two servers use the same id', async () => { + it('does not go by the name when two servers use the same id: those logins share one relay record', async () => { SESSIONS = { 'Basic alice': session('https://one.example', 'a'), 'Basic bob': session('https://two.example', 'z'), 'Basic carol': session('https://one.example', 'z'), }; const { status, body } = await callRoute('z', 'BOB@two.example'); + expect(status).toBe(409); + expect(body.slot).toBeUndefined(); + }); + + it('does not answer for another login when the one the push names is gone', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'c'), + }; + const { status } = await callRoute('z', 'somebody-gone@two.example'); + expect(status).toBe(409); + }); + + it('takes the one login with the account when the push names it', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'c'), + }; + const { status, body } = await callRoute('z', 'bob@two.example'); expect(status).toBe(200); expect(body.slot).toBe(1); - expect((body.email as { subject: string }).subject).toBe('mail of Basic bob'); }); it('takes the login the push names for a group two logins belong to', async () => { From bc8653de8bc7e4f632ac73d829c181ffa0dd369f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:09:22 +0100 Subject: [PATCH 22/35] fix: only replace a browser subscription on the relay's own answer, and move the other logins along --- lib/__tests__/web-push.test.ts | 50 +++++++++++++++++++++++++++++++++- lib/pwa-notify/worker-state.ts | 17 ++++++++++-- lib/web-push.ts | 23 ++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 7a44d1daa..969504d09 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -20,6 +20,7 @@ import { import { webPushErrorKey } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; const RELAY = 'https://relay.example'; const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`; @@ -277,7 +278,7 @@ function installFetch(activeByDevice: Record) { if (url.includes('/api/push/active/')) { const device = url.split('/api/push/active/')[1]; const state = activeByDevice[device]; - if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404); + if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404); return json({ active: state }); } return json({}); @@ -763,6 +764,53 @@ describe('an endpoint the push service gave up on', () => { expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); }); + it('does not take a relay that lacks the route for one that lost the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404); + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('moves the other logins to the new endpoint, connected or not', async () => { + const { registration } = installPushBrowser(); + const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) }; + (registration.pushManager.subscribe as ReturnType).mockResolvedValue(moved); + const stored = new Map(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined), + put: async (key: string, res: Response) => { stored.set(key, await res.text()); }, + }), + }); + await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD'); + const client = registered(); + const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404); + if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) }); + return json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + const other = bodies.find((b) => b.body.subscriptionId === 'device-other'); + expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web'); + expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved'); + expect(other?.body.accountLabel).toBe('o@example.org'); + expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1); + }); + it('keeps the subscription while the relay knows the record', async () => { const { browserSub } = installPushBrowser(); const client = registered(); diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 283e7b6dd..034d4efa0 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -45,11 +45,22 @@ interface RelayRegistrations { entries: Record; } -async function readRelayRegistrations(cache: Cache, key: string): Promise { +async function readStored(cache: Cache, key: string): Promise { const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined; return stored?.entries ? stored : { entries: {} }; } +/** Every login's relay registration on this browser, by JMAP account id. */ +export async function readRelayRegistrations(): Promise> { + try { + if (typeof caches === 'undefined') return {}; + const cache = await caches.open(PUSH_STATE_CACHE); + return (await readStored(cache, relayRegistrationsKey(window.location.origin))).entries; + } catch { + return {}; + } +} + /** * Lets the worker re-register a new browser endpoint with the relay when no tab * is open (pushsubscriptionchange). None of this is secret to the browser that @@ -60,7 +71,7 @@ export async function keepRelayRegistration(accountId: string, entry: RelayRegis if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); const key = relayRegistrationsKey(window.location.origin); - const stored = await readRelayRegistrations(cache, key); + const stored = await readStored(cache, key); stored.entries[accountId] = entry; stored.applicationServerKey = applicationServerKey; await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); @@ -74,7 +85,7 @@ export async function forgetRelayRegistration(accountId: string): Promise if (typeof caches === 'undefined') return; const cache = await caches.open(PUSH_STATE_CACHE); const key = relayRegistrationsKey(window.location.origin); - const stored = await readRelayRegistrations(cache, key); + const stored = await readStored(cache, key); if (!(accountId in stored.entries)) return; delete stored.entries[accountId]; await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); diff --git a/lib/web-push.ts b/lib/web-push.ts index 20c969e90..818cbc5e6 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -14,7 +14,7 @@ import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; import { IS_LITE } from '@/lib/lite'; import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update'; -import { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; +import { forgetRelayRegistration, keepRelayRegistration, readRelayRegistrations } from '@/lib/pwa-notify/worker-state'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, @@ -664,12 +664,28 @@ async function relayDroppedOurEndpoint( if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false; try { const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`)); - return res.status === 404; + if (res.status !== 404) return false; + // An old relay without this route answers 404 for everything. + const body = (await res.json().catch(() => null)) as { error?: unknown } | null; + return body?.error === 'Unknown subscription'; } catch { return false; } } +/** The browser moved to a new endpoint: every other login's relay record has to follow, connected in this tab or not. */ +async function moveOtherLogins(accountId: string, subscription: PushSubscription): Promise { + const others = Object.entries(await readRelayRegistrations()).filter(([id]) => id !== accountId); + for (const [, entry] of others) { + await registerWithRelay({ + relayBaseUrl: entry.relay, + subscriptionId: entry.subscriptionId, + subscription: { endpoint: subscription.endpoint, keys: { p256dh: readPushKey(subscription, 'p256dh'), auth: readPushKey(subscription, 'auth') } }, + accountLabel: entry.accountLabel, + }).catch(() => undefined); + } +} + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, @@ -815,9 +831,11 @@ async function enableWebPushForThisTab( } const accountId = params.client.getAccountId(); const deviceClientId = getOrCreateDeviceClientId(accountId); + let endpointGivenUp = false; if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) { await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; + endpointGivenUp = true; // The other accounts' records point at the old endpoint: their turn comes too. lastResync.clear(); } @@ -826,6 +844,7 @@ async function enableWebPushForThisTab( userVisibleOnly: true, applicationServerKey, }); + if (endpointGivenUp) await moveOtherLogins(accountId, pushSubscription); } await registerWithRelay({ From 6dbda4424efffa043fbfde936cf5ce8f7d578af2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:09:44 +0100 Subject: [PATCH 23/35] fix: stop a stuck push setup from holding up every tab and login --- lib/__tests__/web-push.test.ts | 54 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 22 +++++++++++--- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 969504d09..e9ad7ee95 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -903,6 +903,60 @@ describe('a subscription id the server gave to another device', () => { }); }); +describe('a setup that gets stuck', () => { + afterEach(() => { + vi.useRealTimers(); + delete (navigator as unknown as { locks?: unknown }).locks; + }); + + it('fails in the end and lets the next one through', async () => { + vi.useFakeTimers(); + let tail: Promise = Promise.resolve(); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } }, + }); + const { registration } = installPushBrowser(); + (registration.pushManager.getSubscription as ReturnType).mockResolvedValueOnce(null); + (registration.pushManager.subscribe as ReturnType).mockReturnValueOnce(new Promise(() => {})); + const client = makeClient([]); + installFetch({}); + + const stuck = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed'); + await vi.advanceTimersByTimeAsync(1_000); + const second = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed'); + await vi.advanceTimersByTimeAsync(200_000); + + expect(await stuck).toBe('failed'); + expect(await second).toBe('done'); + }); + + it('turns off for the account it was asked about, even if the client disconnected while waiting', async () => { + let tail: Promise = Promise.resolve(); + let release = () => {}; + tail = new Promise((resolve) => { release = resolve; }); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } }, + }); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + let accountId = ACCOUNT_ID; + (client as unknown as { getAccountId: () => string }).getAccountId = () => accountId; + const calls = installFetch({}); + + const off = disableWebPush({ client, relayBaseUrl: RELAY }).catch(() => undefined); + accountId = ''; + release(); + await off; + + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index 818cbc5e6..7daf97d71 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -775,8 +775,21 @@ async function refreshSubscriptionExpires( // verifying. Tabs take turns, in every browser that has Web Locks. Whoever comes // second finds the first one's work and only refreshes it. function oneTabAtATime(run: () => Promise): Promise { + const limited = () => withinPushSetupLimit(run); const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined; - return locks ? locks.request('bulwark-web-push', run) as Promise : run(); + return locks ? locks.request('bulwark-web-push', limited) as Promise : limited(); +} + +// A worker that never gets ready or a subscribe() that never answers would hold up +// every tab and every login, for good. Verification alone can take 80 s. +const PUSH_SETUP_LIMIT_MS = 120_000; + +function withinPushSetupLimit(run: () => Promise): Promise { + let timer: ReturnType | undefined; + const limit = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new WebPushStepError('verification-timeout', 'Push setup took too long')), PUSH_SETUP_LIMIT_MS); + }); + return Promise.race([run(), limit]).finally(() => clearTimeout(timer)); } export async function enableWebPush( @@ -1018,12 +1031,13 @@ async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Pr // a later page load won't bring push back. But a server or relay that couldn't be // told keeps pinging this browser for days, so the caller hears about it at the end. export function disableWebPush(params: DisableWebPushParams): Promise { - return oneTabAtATime(() => disableWebPushForThisTab(params)); + // Read now: a client signing out loses it while this waits its turn. + const accountId = params.client.getAccountId(); + return oneTabAtATime(() => disableWebPushForThisTab(params, accountId)); } -async function disableWebPushForThisTab(params: DisableWebPushParams): Promise { +async function disableWebPushForThisTab(params: DisableWebPushParams, accountId: string): Promise { const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); - const accountId = params.client.getAccountId(); const subIdKey = subscriptionIdKey(accountId); const devIdKey = deviceClientIdKey(accountId); From 9bc31b54ab40319733462b90c8931d4880fdaf6e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:10:04 +0100 Subject: [PATCH 24/35] fix: keep notifications generic after the browser closed when the organisation has idle limits --- lib/__tests__/push-auth-context.test.ts | 23 +++++++++++++++++++++++ lib/stalwart/push-auth-context.ts | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/__tests__/push-auth-context.test.ts b/lib/__tests__/push-auth-context.test.ts index 33cef70d5..664a273fc 100644 --- a/lib/__tests__/push-auth-context.test.ts +++ b/lib/__tests__/push-auth-context.test.ts @@ -11,6 +11,10 @@ vi.mock('@/lib/auth/session-age', () => ({ maxSessionHoursFor: async () => maxHours.value, })); const impersonation = vi.hoisted(() => ({ master: null as string | null })); +const policy = vi.hoisted(() => ({ value: {} as Record })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => policy.value }, +})); vi.mock('@/lib/impersonation/master-config', () => ({ readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null), })); @@ -31,6 +35,7 @@ beforeEach(() => { process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789'; maxHours.value = 0; impersonation.master = null; + policy.value = {}; }); describe('readPushAuthContext', () => { @@ -57,6 +62,24 @@ describe('readPushAuthContext', () => { expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull(); }); + it('stays out when the organisation has limits only a page enforces', async () => { + const cookies = store({ [sessionCookieName(0)]: remembered() }); + policy.value = { organisation: { session: { idleMinutes: 30 } } }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 5 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + policy.value = { scopes: [{ match: { groups: ['staff@example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + // A maximum by domain the server checks itself, above. + policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).not.toBeNull(); + }); + + it('stays out for a bare user name, which policies for a domain cannot match', async () => { + const token = encryptSession('https://mail.example.org', 'john', 'pw', Date.now()); + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: token }), 0)).toBeNull(); + }); + it('refuses garbage and a cookie holding the impersonation master password', async () => { expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull(); impersonation.master = 'master-secret'; diff --git a/lib/stalwart/push-auth-context.ts b/lib/stalwart/push-auth-context.ts index 793589101..5541da924 100644 --- a/lib/stalwart/push-auth-context.ts +++ b/lib/stalwart/push-auth-context.ts @@ -1,4 +1,5 @@ import { cookies } from 'next/headers'; +import { configManager } from '@/lib/admin/config-manager'; import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto'; import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age'; import { sessionCookieName } from '@/lib/auth/session-cookie'; @@ -7,6 +8,17 @@ import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/li type CookieStore = Awaited>; +async function limitsOnlyAPageEnforces(): Promise { + await configManager.ensureLoaded?.(); + const policy = configManager.getPolicy(); + if ((policy.organisation?.session?.idleMinutes ?? 0) > 0) return true; + return (policy.scopes ?? []).some((scope) => { + const session = scope.policy?.organisation?.session; + if ((session?.idleMinutes ?? 0) > 0) return true; + return (session?.maxHours ?? 0) > 0 && (scope.match?.groups?.length ?? 0) > 0; + }); +} + /** * The login for a request from the service worker, which has no page to refresh * anything. The context cookie goes with the browser session, so a browser that @@ -14,6 +26,10 @@ type CookieStore = Awaited>; * would turn back into a context on load (GET /api/auth/session, with the same * checks). Not signed in with "remember me", or by OAuth with an expired token: * none, and the worker shows its generic notification. + * + * A page also applies limits this can't: the idle timeout and maximums by group + * end the session when it loads. With either set anywhere, a closed browser's + * notifications stay generic and its buttons open the app. */ export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise { const context = readStalwartAuthContextFromStore(cookieStore, slot); @@ -22,6 +38,10 @@ export async function readPushAuthContext(cookieStore: CookieStore, slot: number if (!token) return null; const credentials = decryptSession(token); if (!credentials) return null; + // Without a domain the login can't be matched against the device policy either, + // and "Push without sender and subject" could miss it (no account name here). + if (!credentials.username.includes('@')) return null; + if (await limitsOnlyAPageEnforces()) return null; const impersonation = readImpersonationConfig(); if (impersonation && credentials.password === impersonation.masterPassword) return null; const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); From 248f9c261b8ca24a16c27ae56cfd83e2ca7cf911 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:10:27 +0100 Subject: [PATCH 25/35] fix: take down push for logins with no connection when signing out of everything --- lib/__tests__/web-push.test.ts | 28 +++++++++++++++++++ lib/web-push.ts | 28 +++++++++++++++++++ .../__tests__/auth-store-push-signout.test.ts | 10 ++++++- stores/auth-store.ts | 10 ++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index e9ad7ee95..ea36d69ce 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -6,6 +6,7 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import { buildEmailPushConfig, disableWebPush, + dropAllWebPushRegistrations, enableWebPush, endWebPushForSignOut, isWebPushEnabled, @@ -1992,6 +1993,33 @@ describe('endWebPushForSignOut', () => { }); }); +describe('dropAllWebPushRegistrations', () => { + it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => { + const { browserSub } = installPushBrowser(); + localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x'); + localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a'); + localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY); + localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y'); + localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b'); + const calls = installFetch({}); + + await dropAllWebPushRegistrations(); + + expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull(); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull(); + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does nothing with nothing registered', async () => { + const { browserSub } = installPushBrowser(); + const calls = installFetch({}); + await dropAllWebPushRegistrations(); + expect(calls).toEqual([]); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('listPushDevices', () => { it('flags this device and reports the relay status of each registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); diff --git a/lib/web-push.ts b/lib/web-push.ts index 7daf97d71..b399c6edf 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -1119,6 +1119,34 @@ export async function endWebPushForSignOut(client: IJMAPClient): Promise { await disableWebPush({ client, relayBaseUrl }).catch(() => undefined); } +/** + * Nobody stays signed in here. A login without a connection can't reach its mail + * server, but the relay needs no login: without the record its pings go nowhere. + * Whatever is left of the keys goes, so nothing keeps the browser subscription. + */ +export async function dropAllWebPushRegistrations(): Promise { + if (typeof localStorage === 'undefined') return; + const accounts: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(SUBSCRIPTION_ID_PREFIX)) accounts.push(key.slice(SUBSCRIPTION_ID_PREFIX.length)); + } + if (accounts.length === 0) return; + for (const accountId of accounts) { + const relay = localStorage.getItem(relayBaseKey(accountId)); + const device = localStorage.getItem(deviceClientIdKey(accountId)); + if (relay && device) await dropRelayRecord(relay, device); + localStorage.removeItem(subscriptionIdKey(accountId)); + localStorage.removeItem(relayBaseKey(accountId)); + localStorage.removeItem(endpointKey(accountId)); + await forgetRelayRegistration(accountId); + } + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null); + const sub = await registration?.pushManager.getSubscription().catch(() => null); + await sub?.unsubscribe().catch(() => undefined); +} + export interface PushDevice { // The JMAP PushSubscription id - what you destroy to revoke it. id: string; diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts index 2c71e9486..eafa12111 100644 --- a/stores/__tests__/auth-store-push-signout.test.ts +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {})); -vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut })); +const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut, dropAllWebPushRegistrations })); import { setClientForTests, useAuthStore } from '../auth-store'; import { useAccountStore } from '../account-store'; @@ -78,6 +79,13 @@ describe('signing out ends the login\'s push registration', () => { expect(useAuthStore.getState().client).toBeNull(); }); + it('signing out of everything also covers logins with no connection', async () => { + dropAllWebPushRegistrations.mockClear(); + useAuthStore.setState({ isDemoMode: false }); + await useAuthStore.getState().logoutAll(); + expect(dropAllWebPushRegistrations).toHaveBeenCalledTimes(1); + }); + it('not in demo mode', async () => { const client = fakeClient(); useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e14a2171e..cd4a7420c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -2738,7 +2738,15 @@ export const useAuthStore = create()( for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); } await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); - if (!get().isDemoMode && !keepLocalData) await endPushForSignOut(clients.values()); + if (!get().isDemoMode && !keepLocalData) { + // Each connected login first, on a budget that one unreachable server can + // use up. What's left, and whoever had no connection, only needs the relay. + await endPushForSignOut(clients.values()); + await Promise.race([ + import('@/lib/web-push').then((push) => push.dropAllWebPushRegistrations()).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); + } const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; From f0517ecd08fae39b4d4b7ac81e0cfc5cb8d3ac91 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:42:55 +0100 Subject: [PATCH 26/35] test: type the sign-out push test's fake client --- stores/__tests__/auth-store-push-signout.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts index eafa12111..43aa7cc67 100644 --- a/stores/__tests__/auth-store-push-signout.test.ts +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const endWebPushForSignOut = vi.hoisted(() => vi.fn(async () => {})); +const endWebPushForSignOut = vi.hoisted(() => vi.fn(async (_client: unknown) => {})); const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {})); vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut, dropAllWebPushRegistrations })); @@ -45,9 +45,9 @@ describe('signing out ends the login\'s push registration', () => { disconnect: vi.fn(() => { order.push('disconnect'); accountId = ''; }), getAccountId: () => accountId, } as unknown as Parameters[1]; - endWebPushForSignOut.mockImplementationOnce(async (seen: { getAccountId: () => string }) => { + endWebPushForSignOut.mockImplementationOnce(async (seen: unknown) => { await Promise.resolve(); - order.push(`push ended for ${seen.getAccountId()}`); + order.push(`push ended for ${(seen as { getAccountId: () => string }).getAccountId()}`); }); setClientForTests('login-b', client); useAccountStore.setState({ accounts: [{ id: 'login-b', cookieSlot: 1, username: 'b', serverUrl: 'https://x' }] as never }); @@ -61,7 +61,7 @@ describe('signing out ends the login\'s push registration', () => { it('a login the session threw out keeps its push for the next sign-in', async () => { const client = fakeClient(); - setClientForTests('login-c', client); + setClientForTests('login-c', client as unknown as Parameters[1]); useAccountStore.setState({ accounts: [{ id: 'login-c', cookieSlot: 1, username: 'c', serverUrl: 'https://x' }] as never }); useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false }); From 8cb218ce3c930aeb331e99ad93bfc702650c1df2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:43:25 +0100 Subject: [PATCH 27/35] fix: apply email notifications off and do not disturb per login, not to every login's mail --- .../providers/notification-actions-host.tsx | 11 +++++++ lib/__tests__/push-preferences.test.ts | 33 +++++++++++++++---- lib/__tests__/sw-push-delivery.test.ts | 33 +++++++++++-------- lib/push-preferences.ts | 19 +++++++++-- public/sw.js | 17 ++++++---- stores/__tests__/settings-push-dnd.test.ts | 16 ++------- stores/settings-store.ts | 15 ++++----- 7 files changed, 94 insertions(+), 50 deletions(-) diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx index 4077951d3..7d1a004e7 100644 --- a/components/providers/notification-actions-host.tsx +++ b/components/providers/notification-actions-host.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { scheduleFlushAll } from '@/lib/offline/offline-mode'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { writeLoginQuiet } from '@/lib/push-preferences'; import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto'; import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; @@ -42,6 +43,16 @@ export function NotificationActionsHost() { }); }, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]); + // Email notifications off or do not disturb on, for the login on screen: the + // worker shows nothing for that login's mail. The label a push carries is this + // name (lib/web-push.ts), and each login has its own settings. + const username = useAuthStore((s) => s.username); + const quiet = useSettingsStore((s) => !s.emailNotificationsEnabled || s.doNotDisturb); + useEffect(() => { + if (!isAuthenticated || !username) return; + void writeLoginQuiet(username, quiet); + }, [isAuthenticated, username, quiet]); + // Which logins this tab holds: if it stays silent on a click for one of them, // the worker opens the message rather than go round its plugins. const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]); diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index 80dd45065..32b6d6c74 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -3,6 +3,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The page mirrors the notification sound setting into the service worker's // Cache store, where sw.js reads it before showing a push notification (#1289). +describe('writeLoginQuiet', () => { + it('keeps one answer per login', async () => { + const stored = new Map(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined), + put: async (key: string, res: Response) => { stored.set(key, await res.text()); }, + }), + }); + const { writeLoginQuiet } = await import('../push-preferences'); + await writeLoginQuiet('a@example.org', true); + await writeLoginQuiet('b@example.org', false); + await writeLoginQuiet('a@example.org', true); + const value = JSON.parse(stored.get(`${window.location.origin}/__push-state/quiet-logins`) ?? 'null'); + expect(value).toEqual({ 'a@example.org': true, 'b@example.org': false }); + vi.unstubAllGlobals(); + }); +}); + describe('writePushPreferences', () => { const put = vi.fn(); @@ -17,18 +36,18 @@ describe('writePushPreferences', () => { it('stores the preference under the key the worker reads', async () => { const { writePushPreferences, pushPreferencesKey } = await import('../push-preferences'); - await writePushPreferences({ sound: false, show: true }); + await writePushPreferences({ sound: false }); expect(put).toHaveBeenCalledTimes(1); const [key, response] = put.mock.calls[0] as [string, Response]; expect(key).toBe(pushPreferencesKey(window.location.origin)); expect(key.endsWith('/__push-state/prefs')).toBe(true); - expect(await response.json()).toEqual({ sound: false, show: true }); + expect(await response.json()).toEqual({ sound: false }); }); it('does nothing without a Cache store', async () => { vi.stubGlobal('caches', undefined); const { writePushPreferences } = await import('../push-preferences'); - await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined(); + await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); @@ -42,7 +61,7 @@ describe('writePushPreferences', () => { }, }); const { writePushPreferences } = await import('../push-preferences'); - await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined(); + await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined(); expect(put).not.toHaveBeenCalled(); }); }); @@ -54,11 +73,11 @@ describe('settings store push preference mirror', () => { vi.doMock('@/lib/push-preferences', () => ({ writePushPreferences })); const { useSettingsStore } = await import('@/stores/settings-store'); - expect(writePushPreferences).toHaveBeenCalledWith({ sound: true, show: true }); + expect(writePushPreferences).toHaveBeenCalledWith({ sound: true }); writePushPreferences.mockClear(); useSettingsStore.getState().updateSetting('emailNotificationSound', false); - expect(writePushPreferences).toHaveBeenCalledWith({ sound: false, show: true }); + expect(writePushPreferences).toHaveBeenCalledWith({ sound: false }); writePushPreferences.mockClear(); // Unrelated settings do not rewrite it. @@ -68,7 +87,7 @@ describe('settings store push preference mirror', () => { // Email notifications off means no notification, so no sound either. useSettingsStore.getState().updateSetting('emailNotificationSound', true); useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false }); vi.doUnmock('@/lib/push-preferences'); }); }); diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 46cd713c5..7410327dd 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -9,12 +9,13 @@ const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); type Preview = Record | null; type Shown = { title: string; body?: unknown; data?: Record; tag: string; options: Record; close: () => void }; -function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; openWindow?: (url: string) => Promise } = {}) { +function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; quiet?: Record; openWindow?: (url: string) => Promise } = {}) { const listeners = new Map void>(); const shade = new Map(); const previews: URL[] = []; const cacheStore = new Map(); if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs)); + if (extra.quiet) cacheStore.set('https://mail.example.com/__push-state/quiet-logins', JSON.stringify(extra.quiet)); const self = { location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), @@ -144,31 +145,37 @@ describe('what a notification counts', () => { }); describe('with notifications off or do not disturb on', () => { - it('announces nothing: shows and removes at once, without asking for the message', async () => { - const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { prefs: { sound: false, show: false } }); + const quiet = { 'me@example.org': true }; + + it('announces nothing for that login: shows and removes at once, without asking for the message', async () => { + const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { quiet }); await push(delivery('a', 'e1')); expect(shade.size).toBe(0); expect(previews).toHaveLength(0); }); - it('does not bring the message up later when turned on again', async () => { - const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: false } }); - await off.push(delivery('a', 'e1')); - const { push, shade } = off; - // Same store, now switched on: a redelivery stays quiet. + it('still announces mail for another login', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet: { 'someone-else@example.org': true } }); await push(delivery('a', 'e1')); - expect(shade.size).toBe(0); + expect(shade.size).toBe(1); + }); + + it('does not bring the message up later', async () => { + const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet }); + await off.push(delivery('a', 'e1')); + await off.push(delivery('a', 'e1')); + expect(off.shade.size).toBe(0); }); it('keeps showing with only the sound off', async () => { - const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: true } }); + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false } }); await push(delivery('a', 'e1')); expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false }); }); - it('shows as before for prefs written by an older page', async () => { - const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: true } }); - await push(delivery('a', 'e1')); + it('shows for a push without a label, or with nothing stored', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet }); + await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }); expect(shade.size).toBe(1); }); }); diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index 237b45d40..31c71558f 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -13,8 +13,6 @@ export const PUSH_STATE_CACHE = 'bulwark-push-state-v1'; export interface PushPreferences { /** False: show push notifications for new mail without a sound. */ sound: boolean; - /** False: "Email notifications" is off or do not disturb is on. */ - show: boolean; } /** The cache key sw.js reads the preferences from. */ @@ -22,6 +20,23 @@ export function pushPreferencesKey(origin: string): string { return `${origin}${BASE_PATH}/__push-state/prefs`; } +/** + * True for a login with "Email notifications" off or do not disturb on. The worker + * shows nothing for its mail. Per login, as settings are: the store only holds the + * active login's, and the other logins' mail must not go quiet because of them. + */ +export async function writeLoginQuiet(login: string, quiet: boolean): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = `${window.location.origin}${BASE_PATH}/__push-state/quiet-logins`; + const stored = (await (await cache.match(key))?.json().catch(() => null)) as Record | null; + await cache.put(key, new Response(JSON.stringify({ ...stored, [login]: quiet }), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // Without the store the worker shows every push. + } +} + export async function writePushPreferences(prefs: PushPreferences): Promise { if (typeof window === 'undefined') return; try { diff --git a/public/sw.js b/public/sw.js index 5540a69ac..9fb86350c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -636,7 +636,7 @@ async function notifyAccount(accountId, emailIds, accountLabel) { // Email notifications off, or do not disturb on: a browser still wants a // notification for the push, so one comes and goes with nothing on it. - if (!(await showPreference())) { + if (!(await showPreference(accountLabel))) { if (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds)); await showWithoutAlert(groupTag); return; @@ -903,13 +903,18 @@ async function soundPreference() { } } -/** The "Email notifications" switch and do not disturb, mirrored the same way. Nothing stored: show. */ -async function showPreference() { +/** + * The "Email notifications" switch and do not disturb. Each login has its own + * settings, so the page leaves one answer per login (the push's label names it). + * Nothing stored for it: show. + */ +async function showPreference(accountLabel) { + if (!accountLabel) return true; try { const cache = await caches.open(PUSH_STATE_CACHE); - const res = await cache.match(pushPreferencesKey()); - const prefs = res ? await res.json() : null; - return !prefs || prefs.show !== false; + const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/quiet-logins`); + const quiet = res ? await res.json() : null; + return !quiet || quiet[accountLabel] !== true; } catch (_) { return true; } diff --git a/stores/__tests__/settings-push-dnd.test.ts b/stores/__tests__/settings-push-dnd.test.ts index 87cd4c0a5..52df90530 100644 --- a/stores/__tests__/settings-push-dnd.test.ts +++ b/stores/__tests__/settings-push-dnd.test.ts @@ -6,22 +6,12 @@ vi.mock('@/lib/push-preferences', () => ({ writePushPreferences })); import { useSettingsStore } from '../settings-store'; describe('do not disturb and push notifications (CO-13)', () => { - it('keeps pushed notifications off screen while do not disturb is on', () => { + it('keeps pushed notifications silent while do not disturb is on', () => { useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false }); writePushPreferences.mockClear(); useSettingsStore.getState().updateSetting('doNotDisturb', true); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false }); useSettingsStore.getState().updateSetting('doNotDisturb', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true, show: true }); - }); - - it('keeps them off screen with email notifications off, and only silent with the sound off', () => { - useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false }); - writePushPreferences.mockClear(); - useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false }); - useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true); - useSettingsStore.getState().updateSetting('emailNotificationSound', false); - expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: true }); + expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index dcb1e7aff..1d771cb5f 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -2276,19 +2276,16 @@ if (typeof window !== 'undefined') { applyInterfaceLayout(store.interfaceLayout, store.density); // The service worker shows push notifications without this store, so it - // reads these from its own Cache store (#1289). - const pushShow = (s: SettingsState) => s.emailNotificationsEnabled && !s.doNotDisturb; - const pushSound = (s: SettingsState) => pushShow(s) && s.emailNotificationSound; + // reads the sound preference from its own Cache store (#1289). Whether they + // show at all goes per login (NotificationActionsHost). + const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb; let lastPushSound = pushSound(store); - let lastPushShow = pushShow(store); - void writePushPreferences({ sound: lastPushSound, show: lastPushShow }); + void writePushPreferences({ sound: lastPushSound }); useSettingsStore.subscribe((state) => { const sound = pushSound(state); - const show = pushShow(state); - if (sound === lastPushSound && show === lastPushShow) return; + if (sound === lastPushSound) return; lastPushSound = sound; - lastPushShow = show; - void writePushPreferences({ sound, show }); + void writePushPreferences({ sound }); }); // The policy arrives after this store has rehydrated (and again with the From 9432345440e0f935cc52c99af51fd414d7b9d174 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:43:46 +0100 Subject: [PATCH 28/35] fix: keep a nothing-new notification from closing or replacing a real one --- lib/__tests__/sw-push-delivery.test.ts | 36 +++++++++++++++++++++++++- public/sw.js | 16 +++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 7410327dd..fe4b57ee4 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -9,7 +9,7 @@ const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); type Preview = Record | null; type Shown = { title: string; body?: unknown; data?: Record; tag: string; options: Record; close: () => void }; -function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; quiet?: Record; openWindow?: (url: string) => Promise } = {}) { +function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; quiet?: Record; openWindow?: (url: string) => Promise; registration?: Record } = {}) { const listeners = new Map void>(); const shade = new Map(); const previews: URL[] = []; @@ -27,6 +27,7 @@ function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[] shade.set(tag, { title, body: options.body, data: options.data as Record, tag, options, close: () => shade.delete(tag) }); }, getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag), + ...extra.registration, }, }; const caches = { @@ -261,4 +262,37 @@ describe('which window a click uses', () => { }); }); +describe('nothing new to say', () => { + it('removes only what it showed itself', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 0, email: null })); + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shade.size).toBe(0); + }); + + it('still shows something when the browser cannot list its notifications', async () => { + const shown: string[] = []; + const w = loadWorker(() => ({ unreadTotal: 0, email: null }), { + registration: { + showNotification: async (title: string) => { shown.push(title); }, + getNotifications: async () => { throw new Error('nope'); }, + }, + }); + await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shown).toHaveLength(1); + }); + + it('uses its own tag, so it can never replace a real notification', async () => { + const tags: string[] = []; + const w = loadWorker(() => ({ unreadTotal: 0, email: null }), { + registration: { + showNotification: async (_title: string, options: { tag: string }) => { tags.push(options.tag); }, + getNotifications: async () => [], + }, + }); + await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(tags).toHaveLength(1); + expect(tags[0]).not.toBe('bulwark-mail:a'); + }); +}); + // -- end of cases -- diff --git a/public/sw.js b/public/sw.js index 9fb86350c..76e1a767b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -803,24 +803,32 @@ const PREVIEW_DEADLINE_MS = 10_000; */ async function showWithoutAlert(groupTag) { const options = { - tag: groupTag, icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, renotify: false, silent: true, }; + let existing = null; + try { + existing = (await self.registration.getNotifications({ tag: groupTag }))[0] || null; + } catch (_) { + // Can't tell: treated as none on screen. + } try { - const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { await showWithActions(existing.title, { ...options, + tag: groupTag, body: existing.body, data: existing.data, }); return; } - await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } }); - const shown = await self.registration.getNotifications({ tag: groupTag }); + // Under a tag of its own: a real notification for this account that comes in + // at the same moment must neither be replaced by this nor closed with it. + const emptyTag = `${groupTag}:nothing-new`; + await self.registration.showNotification("New mail", { ...options, tag: emptyTag, data: { kind: "mail-list" } }); + const shown = await self.registration.getNotifications({ tag: emptyTag }); for (const notification of shown) notification.close(); } catch (_) { // Nothing more a worker can do. From d8e2a4f42c4704be98ddeedb616b94f7cb7ae046 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:44:15 +0100 Subject: [PATCH 29/35] fix: keep a waiting re-sync from undoing what another tab turned off, and note the relay on refresh --- lib/__tests__/web-push.test.ts | 44 ++++++++++++++++++++++++++++++++++ lib/web-push.ts | 8 +++++++ 2 files changed, 52 insertions(+) diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ea36d69ce..ec5434d5a 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -958,6 +958,50 @@ describe('a setup that gets stuck', () => { }); }); +describe('a re-sync waiting its turn', () => { + afterEach(() => { delete (navigator as unknown as { locks?: unknown }).locks; }); + + it('does not bring back what another tab turned off meanwhile', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({ [THIS_DEVICE]: true }); + let turn: Promise = Promise.resolve(); + let release = () => {}; + turn = new Promise((resolve) => { release = resolve; }); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = turn.then(run, run); turn = next.catch(() => undefined); return next; } }, + }); + + const resync = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + await vi.waitFor(() => expect((navigator as unknown as { locks: unknown }).locks).toBeTruthy()); + await new Promise((resolve) => setTimeout(resolve, 0)); + localStorage.removeItem(SUB_KEY); // The other tab, holding the lock. + release(); + await resync; + + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); +}); + +describe('a registration from before the relay was remembered', () => { + it('gets the relay noted when it is refreshed', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({ [THIS_DEVICE]: true }); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); diff --git a/lib/web-push.ts b/lib/web-push.ts index b399c6edf..d583ee993 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -428,6 +428,8 @@ export interface EnableWebPushParams { // that outlives a permission change keeps pushing for mailboxes the user can // no longer read - recreating is the only client-side remedy (#841). forceRecreate?: boolean; + // For a re-sync: stop if another tab turned push off while this waited its turn. + onlyIfStillOn?: boolean; // Scope OS push notifications to Inbox-only mail. Mirrors settings-store's // pushNotifyInboxOnly. Required, not optional: it changes the server-side // delivery filter, so every caller must decide explicitly. @@ -813,6 +815,9 @@ async function enableWebPushForThisTab( params: EnableWebPushParams, relayBaseUrl: string, ): Promise { + if (params.onlyIfStillOn && !localStorage.getItem(subscriptionIdKey(params.client.getAccountId()))) { + return { subscriptionId: '' }; + } const registration = await ensureServiceWorker(); const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl); @@ -914,6 +919,8 @@ async function enableWebPushForThisTab( const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush); if (refreshed) { rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); + // Unless it moved, which goes the other way, this is where it lives. + if (!localStorage.getItem(relayBaseKey(accountId))) localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); return { subscriptionId: storedServerId }; } } @@ -1317,6 +1324,7 @@ async function runResync(accountId: string, params: ResyncWebPushParams): Promis if (!moved && !params.force && !(await resyncDue(accountId))) return false; await enableWebPush({ client: params.client, + onlyIfStillOn: true, forceRecreate: moved, relayBaseUrl: params.relayBaseUrl, accountLabel: params.accountLabel, From f39cff23773f96f7306acc51f5d9d5caa7366a23 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:44:36 +0100 Subject: [PATCH 30/35] fix: call no relay from the worker in no-third-party mode, and handle several accounts in one push side by side --- lib/__tests__/sw-push-delivery.test.ts | 22 +++++++++++++++++++ .../sw-push-subscription-change.test.ts | 22 ++++++++++++++++--- public/sw.js | 20 ++++++++++++++--- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index fe4b57ee4..005258e7d 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -295,4 +295,26 @@ describe('nothing new to say', () => { }); }); +describe('a push naming several accounts, one server slow', () => { + it('does not make the others wait out its deadline', async () => { + const started: string[] = []; + let release = () => {}; + const slow = new Promise((resolve) => { release = resolve; }); + const fetchSlow = vi.fn(async (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + const account = url.searchParams.get('accountId') ?? ''; + started.push(account); + if (account === 'slow') await slow; + return { ok: true, json: async () => ({ unreadTotal: 1, slot: 0, email: mail(`m-${account}`) }) }; + }); + const w = loadWorker(() => null, { globals: { fetch: fetchSlow } }); + const done = w.push({ kind: 'jmap-state-change', accountId: 'slow', changed: { slow: { EmailDelivery: 's' }, fast: { EmailDelivery: 's' } } }); + await vi.waitFor(() => expect(started).toEqual(['slow', 'fast'])); + await vi.waitFor(() => expect(w.shade.has('bulwark-mail:fast')).toBe(true)); + release(); + await done; + expect(w.shade.size).toBe(2); + }); +}); + // -- end of cases -- diff --git a/lib/__tests__/sw-push-subscription-change.test.ts b/lib/__tests__/sw-push-subscription-change.test.ts index 813a8fa69..50eb98290 100644 --- a/lib/__tests__/sw-push-subscription-change.test.ts +++ b/lib/__tests__/sw-push-subscription-change.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; // in a VM with a minimal ServiceWorkerGlobalScope and fire the event. const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); -function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) { +function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown, config: { noThirdPartyTraffic?: boolean } | null = {}) { const listeners = new Map void>(); const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) }; const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription); @@ -23,8 +23,12 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registratio self, atob, caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) }, - fetch: vi.fn(async (url: string, init: { body: string }) => { - relayCalls.push({ url, body: JSON.parse(init.body) }); + fetch: vi.fn(async (url: string, init?: { body: string }) => { + if (url.endsWith('/api/config')) { + if (!config) throw new TypeError('offline'); + return { ok: true, json: async () => config }; + } + relayCalls.push({ url, body: JSON.parse(init!.body) }); return { ok: true }; }), URL, URLSearchParams, Response, Date, console, Uint8Array, @@ -69,6 +73,18 @@ describe('service worker pushsubscriptionchange', () => { expect(relayCalls).toHaveLength(2); }); + it('calls no relay while third-party traffic is off: the page moves it', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, { noThirdPartyTraffic: true }); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + + it('calls no relay when it cannot find out', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, null); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + it('calls no relay for a browser where push was never on', async () => { const { fire, relayCalls, newSubscription } = loadWorker([]); await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); diff --git a/public/sw.js b/public/sw.js index 76e1a767b..47cde4b71 100644 --- a/public/sw.js +++ b/public/sw.js @@ -603,7 +603,9 @@ async function handlePush(event) { const withNewMail = emailIds.length > 0 || !changed ? [] : Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]); - for (const accountId of withNewMail.length > 0 ? withNewMail : [named]) { + // Side by side: each has its own preview deadline, and Safari wants the + // notifications within 30 s of the push. + await Promise.all((withNewMail.length > 0 ? withNewMail : [named]).map(async (accountId) => { try { await notifyAccount(accountId, emailIds, accountLabel); } catch (_) { @@ -617,7 +619,7 @@ async function handlePush(event) { data: { kind: "mail-list", accountId }, }); } - } + })); } async function notifyAccount(accountId, emailIds, accountLabel) { @@ -1000,6 +1002,18 @@ async function readRelayRegistrations() { } } +/** No-third-party mode may have come on since the page left a relay here. Unknown counts as no. */ +async function mayCallRelays() { + try { + const res = await fetch(`${BASE_PATH}/api/config`, { cache: "no-store" }); + if (!res.ok) return false; + const config = await res.json(); + return config.noThirdPartyTraffic !== true; + } catch (_) { + return false; + } +} + function applicationServerKeyOf(base64Url) { const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4); const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); @@ -1031,7 +1045,7 @@ async function handlePushSubscriptionChange(event) { } } } - if (subscription && registrations && typeof subscription.toJSON === "function") { + if (subscription && registrations && typeof subscription.toJSON === "function" && (await mayCallRelays())) { const json = subscription.toJSON(); for (const entry of Object.values(registrations.entries)) { try { From 97b3ddefaeecb3d87a0ae4a78ae9efee66899ba1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:49:00 +0100 Subject: [PATCH 31/35] test: cover what the page tells the worker about a quiet login --- .../notification-actions-host-quiet.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 components/providers/__tests__/notification-actions-host-quiet.test.tsx diff --git a/components/providers/__tests__/notification-actions-host-quiet.test.tsx b/components/providers/__tests__/notification-actions-host-quiet.test.tsx new file mode 100644 index 000000000..d7a30d756 --- /dev/null +++ b/components/providers/__tests__/notification-actions-host-quiet.test.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The worker shows nothing for a login with email notifications off or do not +// disturb on. Each login has its own settings, so the answer goes per login. + +const writeLoginQuiet = vi.fn(async (..._args: unknown[]) => {}); +let state = { isAuthenticated: true, username: 'me@example.org' as string | null, emailNotificationsEnabled: true, doNotDisturb: false }; + +vi.mock('@/lib/push-preferences', () => ({ writeLoginQuiet: (...args: unknown[]) => writeLoginQuiet(...args), PUSH_STATE_CACHE: 'x' })); +vi.mock('@/lib/pwa-notify/worker-state', () => ({ writeNotificationActionPrefs: async () => {} })); +vi.mock('@/lib/pwa-notify/page-actions', () => ({ isNotificationActionRequest: () => false, runNotificationAction: async () => {} })); +vi.mock('@/lib/pwa-notify/plugin-veto', () => ({ pluginsMayVeto: () => false })); +vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: () => {} })); +vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => false })); +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: (select: (s: typeof state) => unknown) => select(state) })); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (select: (s: typeof state & { archiveMode: string }) => unknown) => select({ ...state, archiveMode: 'single' }), +})); +vi.mock('@/stores/plugin-store', () => ({ usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: [] }) })); +vi.mock('@/stores/account-store', () => { + const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: [] }); + useAccountStore.getState = () => ({ accounts: [] }); + return { useAccountStore }; +}); + +beforeEach(() => { + writeLoginQuiet.mockClear(); + state = { isAuthenticated: true, username: 'me@example.org', emailNotificationsEnabled: true, doNotDisturb: false }; +}); + +describe('what the page tells the worker about a login', () => { + it('not quiet with everything on', () => { + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', false); + }); + + it('quiet with email notifications off', () => { + state.emailNotificationsEnabled = false; + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true); + }); + + it('quiet with do not disturb on', () => { + state.doNotDisturb = true; + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true); + }); + + it('nothing while signed out', () => { + state.isAuthenticated = false; + state.username = null; + render(); + expect(writeLoginQuiet).not.toHaveBeenCalled(); + }); +}); From 149d08d87b7bb39761f8e811c70ec6ddeddf6498 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:59:11 +0100 Subject: [PATCH 32/35] test: load the preview route once before its cases, so a slow import can't spill into them --- lib/__tests__/push-preview-route-ambiguous.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index 156aa9c1e..782aff8ce 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeAll, describe, it, expect, vi, beforeEach } from 'vitest'; // The push names only the JMAP account id, and ids repeat: Stalwart numbers // accounts alike on every install, and a group account is listed in the @@ -93,6 +93,12 @@ async function callRoute(accountId: string, login?: string) { const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap')); +// The route's first import can take longer than a test's limit on a busy machine, +// and a test that times out keeps running into the next one's state. +beforeAll(async () => { + await import('@/app/api/push/preview/route'); +}, 60_000); + describe('push preview after the browser was closed', () => { it('reads the login from the remembered session when the context cookie is gone', async () => { remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' }; From adc86cf4f79bf04f352776448d538cb8b2580b76 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:06:50 +0100 Subject: [PATCH 33/35] fix: say where the device list is when turning push off could not reach the server --- locales/ar/common.json | 2 +- locales/ca/common.json | 2 +- locales/cs/common.json | 2 +- locales/da/common.json | 2 +- locales/de/common.json | 2 +- locales/en/common.json | 2 +- locales/es/common.json | 2 +- locales/et/common.json | 2 +- locales/fa/common.json | 2 +- locales/fr/common.json | 2 +- locales/he/common.json | 2 +- locales/hu/common.json | 2 +- locales/it/common.json | 2 +- locales/ja/common.json | 2 +- locales/ko/common.json | 2 +- locales/lv/common.json | 2 +- locales/mn/common.json | 2 +- locales/nb/common.json | 2 +- locales/nl/common.json | 2 +- locales/pl/common.json | 2 +- locales/pt/common.json | 2 +- locales/ro/common.json | 2 +- locales/ru/common.json | 2 +- locales/sk/common.json | 2 +- locales/th/common.json | 2 +- locales/tr/common.json | 2 +- locales/uk/common.json | 2 +- locales/vi/common.json | 2 +- locales/zh-TW/common.json | 2 +- locales/zh/common.json | 2 +- 30 files changed, 30 insertions(+), 30 deletions(-) diff --git a/locales/ar/common.json b/locales/ar/common.json index 1c960e8b0..b83f3f03a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "لم يتم السماح بالإشعارات، لذا يبقى الإشعار الفوري متوقفًا.", "error_verification_timeout": "لم يؤكد خادم البريد هذا الجهاز في الوقت المحدد، لذا يبقى الإشعار الفوري متوقفًا. حاول مرة أخرى بعد دقيقة.", "error_relay": "لا يمكن الوصول إلى مرحّل الإشعارات الآن، لذا يبقى الإشعار الفوري متوقفًا. حاول لاحقًا.", - "error_teardown": "تم إيقاف الإشعارات الفورية في هذا المتصفح، لكن تعذر الوصول إلى خادم البريد أو المرحّل. قد تستمر الإشعارات في الوصول لبضعة أيام. ألغِ هذا الجهاز أدناه عندما تعود إلى الاتصال." + "error_teardown": "تم إيقاف الإشعارات الفورية في هذا المتصفح، لكن تعذر الوصول إلى خادم البريد أو المرحّل. قد تستمر الإشعارات في الوصول لبضعة أيام. لإيقافها أسرع، فعّل الإعدادات المتقدمة عندما تعود إلى الاتصال وألغِ هذا الجهاز ضمن الأجهزة المسجلة." }, "push_all": { "title": "All accounts", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0e65b7279..95bd0a3b3 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "No s'han permès les notificacions, així que les notificacions push continuen desactivades.", "error_verification_timeout": "El servidor de correu no ha confirmat aquest dispositiu a temps, així que les notificacions push continuen desactivades. Torna-ho a provar d'aquí a un minut.", "error_relay": "Ara mateix no es pot accedir al repetidor de notificacions, així que les notificacions push continuen desactivades. Torna-ho a provar més tard.", - "error_teardown": "Les notificacions push estan desactivades en aquest navegador, però no s'ha pogut accedir al servidor de correu o al repetidor. Encara poden arribar notificacions durant uns dies. Revoca aquest dispositiu aquí sota quan tornis a tenir connexió." + "error_teardown": "Les notificacions push estan desactivades en aquest navegador, però no s'ha pogut accedir al servidor de correu o al repetidor. Encara poden arribar notificacions durant uns dies. Per aturar-les abans, activa la configuració avançada quan tornis a tenir connexió i revoca aquest dispositiu a Dispositius registrats." }, "push_all": { "title": "All accounts", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8d465618b..b5b505433 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Oznámení nebyla povolena, takže push zůstává vypnutý.", "error_verification_timeout": "Poštovní server toto zařízení včas nepotvrdil, takže push zůstává vypnutý. Zkuste to znovu za minutu.", "error_relay": "Push relay teď není dostupné, takže push zůstává vypnutý. Zkuste to později.", - "error_teardown": "Push je v tomto prohlížeči vypnutý, ale poštovní server nebo relay nebyly dostupné. Oznámení mohou ještě několik dní přicházet. Až budete online, odvolejte toto zařízení níže." + "error_teardown": "Push je v tomto prohlížeči vypnutý, ale poštovní server nebo relay nebyly dostupné. Oznámení mohou ještě několik dní přicházet. Chcete-li je zastavit dříve, až budete online, zapněte pokročilá nastavení a odvolejte toto zařízení v části Registrovaná zařízení." } }, "language_region": { diff --git a/locales/da/common.json b/locales/da/common.json index 9905f5145..19aa5c384 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Notifikationer blev ikke tilladt, så push forbliver slået fra.", "error_verification_timeout": "Mailserveren bekræftede ikke denne enhed i tide, så push forbliver slået fra. Prøv igen om et minut.", "error_relay": "Push-relayet kan ikke nås lige nu, så push forbliver slået fra. Prøv igen senere.", - "error_teardown": "Push er slået fra i denne browser, men mailserveren eller relayet kunne ikke nås. Der kan stadig komme notifikationer i nogle dage. Tilbagekald denne enhed nedenfor, når du er online igen." + "error_teardown": "Push er slået fra i denne browser, men mailserveren eller relayet kunne ikke nås. Der kan stadig komme notifikationer i nogle dage. Vil du stoppe dem før, så slå avancerede indstillinger til, når du er online igen, og tilbagekald denne enhed under Registrerede enheder." }, "push_all": { "title": "All accounts", diff --git a/locales/de/common.json b/locales/de/common.json index 48c693265..4ded0824c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Benachrichtigungen wurden nicht erlaubt, Push bleibt deshalb aus.", "error_verification_timeout": "Der Mailserver hat dieses Gerät nicht rechtzeitig bestätigt, Push bleibt deshalb aus. Versuchen Sie es in einer Minute erneut.", "error_relay": "Das Push-Relay ist gerade nicht erreichbar, Push bleibt deshalb aus. Versuchen Sie es später erneut.", - "error_teardown": "Push ist in diesem Browser aus, aber der Mailserver oder das Relay waren nicht erreichbar. Benachrichtigungen können noch einige Tage ankommen. Widerrufen Sie dieses Gerät unten, sobald Sie wieder online sind." + "error_teardown": "Push ist in diesem Browser aus, aber der Mailserver oder das Relay waren nicht erreichbar. Benachrichtigungen können noch einige Tage ankommen. Um sie früher zu beenden, schalten Sie die erweiterten Einstellungen ein, sobald Sie wieder online sind, und widerrufen Sie dieses Gerät unter „Registrierte Geräte“." } }, "language_region": { diff --git a/locales/en/common.json b/locales/en/common.json index e8172a9e1..360ab63d6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Notifications weren't allowed, so push stays off.", "error_verification_timeout": "The mail server didn't confirm this device in time, so push stays off. Try again in a minute.", "error_relay": "The push relay can't be reached right now, so push stays off. Try again later.", - "error_teardown": "Push is off in this browser, but the mail server or the relay couldn't be reached. Notifications may keep arriving for a few days. Revoke this device below once you're back online." + "error_teardown": "Push is off in this browser, but the mail server or the relay couldn't be reached. Notifications may keep arriving for a few days. To stop them sooner, turn on advanced settings once you're back online and revoke this device under Registered devices." }, "push_all": { "title": "All accounts", diff --git a/locales/es/common.json b/locales/es/common.json index 5ae59894c..c723146ce 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "No se permitieron las notificaciones, así que las notificaciones push siguen desactivadas.", "error_verification_timeout": "El servidor de correo no confirmó este dispositivo a tiempo, así que las notificaciones push siguen desactivadas. Inténtalo de nuevo en un minuto.", "error_relay": "No se puede acceder al relé de notificaciones en este momento, así que las notificaciones push siguen desactivadas. Inténtalo más tarde.", - "error_teardown": "Las notificaciones push están desactivadas en este navegador, pero no se pudo acceder al servidor de correo o al relé. Es posible que sigan llegando notificaciones durante unos días. Revoca este dispositivo abajo cuando vuelvas a tener conexión." + "error_teardown": "Las notificaciones push están desactivadas en este navegador, pero no se pudo acceder al servidor de correo o al relé. Es posible que sigan llegando notificaciones durante unos días. Para detenerlas antes, activa los ajustes avanzados cuando vuelvas a tener conexión y revoca este dispositivo en Dispositivos registrados." } }, "language_region": { diff --git a/locales/et/common.json b/locales/et/common.json index 10037d2ab..4af42c512 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Teavitusi ei lubatud, seega jääb tõuketeavitus välja.", "error_verification_timeout": "E-posti server ei kinnitanud seda seadet õigel ajal, seega jääb tõuketeavitus välja. Proovi minuti pärast uuesti.", "error_relay": "Tõuketeavituste releed ei saa praegu kätte, seega jääb tõuketeavitus välja. Proovi hiljem uuesti.", - "error_teardown": "Tõuketeavitus on selles brauseris väljas, kuid e-posti serverit või releed ei saadud kätte. Teavitusi võib veel mõne päeva tulla. Tühista see seade allpool, kui oled taas võrgus." + "error_teardown": "Tõuketeavitus on selles brauseris väljas, kuid e-posti serverit või releed ei saadud kätte. Teavitusi võib veel mõne päeva tulla. Et need varem lõpetada, lülita võrku naastes sisse täpsemad seaded ja tühista see seade jaotises Registreeritud seadmed." }, "push_all": { "title": "Kõik kontod", diff --git a/locales/fa/common.json b/locales/fa/common.json index 71506718d..20a7d30b0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "اعلان‌ها مجاز نشدند، پس اعلان فوری خاموش می‌ماند.", "error_verification_timeout": "سرور ایمیل این دستگاه را به‌موقع تأیید نکرد، پس اعلان فوری خاموش می‌ماند. یک دقیقه دیگر دوباره امتحان کنید.", "error_relay": "در حال حاضر دسترسی به رله اعلان ممکن نیست، پس اعلان فوری خاموش می‌ماند. بعداً دوباره امتحان کنید.", - "error_teardown": "اعلان فوری در این مرورگر خاموش شد، اما دسترسی به سرور ایمیل یا رله ممکن نبود. ممکن است تا چند روز اعلان‌ها برسند. وقتی دوباره آنلاین شدید، این دستگاه را در پایین لغو کنید." + "error_teardown": "اعلان فوری در این مرورگر خاموش شد، اما دسترسی به سرور ایمیل یا رله ممکن نبود. ممکن است تا چند روز اعلان‌ها برسند. برای قطع زودتر آن‌ها، وقتی دوباره آنلاین شدید تنظیمات پیشرفته را روشن کنید و این دستگاه را در بخش دستگاه‌های ثبت‌شده لغو کنید." }, "push_all": { "title": "All accounts", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4ecfde5a1..084c53828 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Les notifications n'ont pas été autorisées, les notifications push restent donc désactivées.", "error_verification_timeout": "Le serveur de messagerie n'a pas confirmé cet appareil à temps, les notifications push restent donc désactivées. Réessayez dans une minute.", "error_relay": "Le relais de notifications est injoignable pour le moment, les notifications push restent donc désactivées. Réessayez plus tard.", - "error_teardown": "Les notifications push sont désactivées dans ce navigateur, mais le serveur de messagerie ou le relais n'a pas pu être joint. Des notifications peuvent encore arriver pendant quelques jours. Révoquez cet appareil ci-dessous une fois de nouveau en ligne." + "error_teardown": "Les notifications push sont désactivées dans ce navigateur, mais le serveur de messagerie ou le relais n'a pas pu être joint. Des notifications peuvent encore arriver pendant quelques jours. Pour les arrêter plus tôt, activez les paramètres avancés une fois de nouveau en ligne et révoquez cet appareil sous Appareils enregistrés." } }, "language_region": { diff --git a/locales/he/common.json b/locales/he/common.json index 0bf809793..aa7978bd4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1748,7 +1748,7 @@ "error_not_granted": "ההתראות לא אושרו, ולכן התראות הפוש נשארות כבויות.", "error_verification_timeout": "שרת הדואר לא אישר את המכשיר הזה בזמן, ולכן התראות הפוש נשארות כבויות. נסה שוב בעוד דקה.", "error_relay": "אי אפשר להגיע כרגע לממסר ההתראות, ולכן התראות הפוש נשארות כבויות. נסה שוב מאוחר יותר.", - "error_teardown": "התראות הפוש כבויות בדפדפן הזה, אבל לא ניתן היה להגיע לשרת הדואר או לממסר. ייתכן שהתראות ימשיכו להגיע עוד כמה ימים. בטל את המכשיר הזה למטה כשתחזור להיות מחובר." + "error_teardown": "התראות הפוש כבויות בדפדפן הזה, אבל לא ניתן היה להגיע לשרת הדואר או לממסר. ייתכן שהתראות ימשיכו להגיע עוד כמה ימים. כדי להפסיק אותן מוקדם יותר, כשתחזור להיות מחובר הפעל את ההגדרות המתקדמות ובטל את המכשיר הזה תחת מכשירים רשומים." } }, "language_region": { diff --git a/locales/hu/common.json b/locales/hu/common.json index e90462fd3..3199c931f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Az értesítések nem lettek engedélyezve, ezért a push kikapcsolva marad.", "error_verification_timeout": "A levelezőszerver nem erősítette meg időben ezt az eszközt, ezért a push kikapcsolva marad. Próbáld újra egy perc múlva.", "error_relay": "A push relay most nem érhető el, ezért a push kikapcsolva marad. Próbáld újra később.", - "error_teardown": "A push ki van kapcsolva ebben a böngészőben, de a levelezőszerver vagy a relay nem volt elérhető. Néhány napig még érkezhetnek értesítések. Ha újra online vagy, vond vissza lent ezt az eszközt." + "error_teardown": "A push ki van kapcsolva ebben a böngészőben, de a levelezőszerver vagy a relay nem volt elérhető. Néhány napig még érkezhetnek értesítések. Ha hamarabb le szeretnéd állítani őket, amint újra online vagy, kapcsold be a speciális beállításokat, és vond vissza ezt az eszközt a Regisztrált eszközök résznél." }, "push_all": { "title": "All accounts", diff --git a/locales/it/common.json b/locales/it/common.json index e59c76eaf..b39e91e28 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Le notifiche non sono state consentite, quindi le notifiche push restano disattivate.", "error_verification_timeout": "Il server di posta non ha confermato questo dispositivo in tempo, quindi le notifiche push restano disattivate. Riprova tra un minuto.", "error_relay": "Il relay delle notifiche non è raggiungibile in questo momento, quindi le notifiche push restano disattivate. Riprova più tardi.", - "error_teardown": "Le notifiche push sono disattivate in questo browser, ma il server di posta o il relay non erano raggiungibili. Potrebbero arrivare notifiche ancora per qualche giorno. Revoca questo dispositivo qui sotto quando torni online." + "error_teardown": "Le notifiche push sono disattivate in questo browser, ma il server di posta o il relay non erano raggiungibili. Potrebbero arrivare notifiche ancora per qualche giorno. Per fermarle prima, quando torni online attiva le impostazioni avanzate e revoca questo dispositivo in Dispositivi registrati." } }, "language_region": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 4298ac9ca..79735bf02 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "通知が許可されなかったため、プッシュはオフのままです。", "error_verification_timeout": "メールサーバーがこのデバイスを時間内に確認しなかったため、プッシュはオフのままです。1分後にもう一度お試しください。", "error_relay": "プッシュリレーに接続できないため、プッシュはオフのままです。後でもう一度お試しください。", - "error_teardown": "このブラウザのプッシュはオフになりましたが、メールサーバーまたはリレーに接続できませんでした。数日間は通知が届く場合があります。オンラインに戻ったら、下でこのデバイスを取り消してください。" + "error_teardown": "このブラウザのプッシュはオフになりましたが、メールサーバーまたはリレーに接続できませんでした。数日間は通知が届く場合があります。早く止めるには、オンラインに戻ったら詳細設定をオンにして、登録済みデバイスでこのデバイスを取り消してください。" } }, "language_region": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 29237df21..25cccb7c2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "알림이 허용되지 않아 푸시가 꺼진 상태로 유지됩니다.", "error_verification_timeout": "메일 서버가 이 기기를 제때 확인하지 않아 푸시가 꺼진 상태로 유지됩니다. 1분 후에 다시 시도하세요.", "error_relay": "지금은 푸시 릴레이에 연결할 수 없어 푸시가 꺼진 상태로 유지됩니다. 나중에 다시 시도하세요.", - "error_teardown": "이 브라우저에서 푸시가 꺼졌지만 메일 서버나 릴레이에 연결할 수 없었습니다. 며칠 동안 알림이 계속 올 수 있습니다. 다시 온라인 상태가 되면 아래에서 이 기기를 취소하세요." + "error_teardown": "이 브라우저에서 푸시가 꺼졌지만 메일 서버나 릴레이에 연결할 수 없었습니다. 며칠 동안 알림이 계속 올 수 있습니다. 더 빨리 중지하려면 다시 온라인 상태가 되었을 때 고급 설정을 켜고 등록된 기기에서 이 기기를 취소하세요." } }, "language_region": { diff --git a/locales/lv/common.json b/locales/lv/common.json index a200234cc..24dba1808 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Paziņojumi netika atļauti, tāpēc push paliek izslēgts.", "error_verification_timeout": "Pasta serveris laikus neapstiprināja šo ierīci, tāpēc push paliek izslēgts. Mēģiniet vēlreiz pēc minūtes.", "error_relay": "Push releju pašlaik nevar sasniegt, tāpēc push paliek izslēgts. Mēģiniet vēlreiz vēlāk.", - "error_teardown": "Push šajā pārlūkā ir izslēgts, bet pasta serveri vai releju nevarēja sasniegt. Paziņojumi vēl dažas dienas var turpināt pienākt. Kad atkal būsiet tiešsaistē, atsauciet šo ierīci zemāk." + "error_teardown": "Push šajā pārlūkā ir izslēgts, bet pasta serveri vai releju nevarēja sasniegt. Paziņojumi vēl dažas dienas var turpināt pienākt. Lai tos apturētu ātrāk, kad atkal būsiet tiešsaistē, ieslēdziet papildu iestatījumus un atsauciet šo ierīci sadaļā Reģistrētās ierīces." } }, "language_region": { diff --git a/locales/mn/common.json b/locales/mn/common.json index ab56dead7..8a3589897 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Мэдэгдлийг зөвшөөрөөгүй тул push унтраалттай хэвээр байна.", "error_verification_timeout": "Шуудангийн сервер энэ төхөөрөмжийг цагт нь баталгаажуулаагүй тул push унтраалттай хэвээр байна. Нэг минутын дараа дахин оролдоно уу.", "error_relay": "Push релейд одоогоор холбогдож чадахгүй байгаа тул push унтраалттай хэвээр байна. Дараа дахин оролдоно уу.", - "error_teardown": "Энэ хөтөч дээр push унтарсан ч шуудангийн сервер эсвэл релейд холбогдож чадсангүй. Мэдэгдэл хэдэн өдрийн турш ирсээр байж магадгүй. Онлайн болсныхоо дараа энэ төхөөрөмжийг доороос цуцална уу." + "error_teardown": "Энэ хөтөч дээр push унтарсан ч шуудангийн сервер эсвэл релейд холбогдож чадсангүй. Мэдэгдэл хэдэн өдрийн турш ирсээр байж магадгүй. Эртхэн зогсоохын тулд онлайн болсныхоо дараа нарийвчилсан тохиргоог асааж, Бүртгэлтэй төхөөрөмжүүд хэсгээс энэ төхөөрөмжийг цуцална уу." }, "push_all": { "title": "All accounts", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6e0e2a5fc..c4a421004 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Varsler ble ikke tillatt, så push forblir av.", "error_verification_timeout": "E-postserveren bekreftet ikke denne enheten i tide, så push forblir av. Prøv igjen om et minutt.", "error_relay": "Push-reléet kan ikke nås akkurat nå, så push forblir av. Prøv igjen senere.", - "error_teardown": "Push er av i denne nettleseren, men e-postserveren eller reléet kunne ikke nås. Varsler kan fortsette å komme i noen dager. Tilbakekall denne enheten nedenfor når du er på nett igjen." + "error_teardown": "Push er av i denne nettleseren, men e-postserveren eller reléet kunne ikke nås. Varsler kan fortsette å komme i noen dager. For å stoppe dem tidligere, slå på avanserte innstillinger når du er på nett igjen, og tilbakekall denne enheten under Registrerte enheter." }, "push_all": { "title": "All accounts", diff --git a/locales/nl/common.json b/locales/nl/common.json index 7efabe5fa..ab9bbf9c4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Meldingen zijn niet toegestaan, dus push blijft uit.", "error_verification_timeout": "De mailserver heeft dit apparaat niet op tijd bevestigd, dus push blijft uit. Probeer het over een minuut opnieuw.", "error_relay": "De pushrelay is nu niet bereikbaar, dus push blijft uit. Probeer het later opnieuw.", - "error_teardown": "Push staat uit in deze browser, maar de mailserver of de relay was niet bereikbaar. Er kunnen nog een paar dagen meldingen binnenkomen. Trek dit apparaat hieronder in zodra u weer online bent." + "error_teardown": "Push staat uit in deze browser, maar de mailserver of de relay was niet bereikbaar. Er kunnen nog een paar dagen meldingen binnenkomen. Wilt u ze eerder stoppen, zet dan de geavanceerde instellingen aan zodra u weer online bent en trek dit apparaat in onder Geregistreerde apparaten." } }, "language_region": { diff --git a/locales/pl/common.json b/locales/pl/common.json index b3f09c304..f483a3196 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Nie zezwolono na powiadomienia, więc push pozostaje wyłączony.", "error_verification_timeout": "Serwer poczty nie potwierdził tego urządzenia na czas, więc push pozostaje wyłączony. Spróbuj ponownie za minutę.", "error_relay": "Przekaźnik push jest teraz niedostępny, więc push pozostaje wyłączony. Spróbuj ponownie później.", - "error_teardown": "Push jest wyłączony w tej przeglądarce, ale serwer poczty lub przekaźnik był niedostępny. Powiadomienia mogą przychodzić jeszcze przez kilka dni. Gdy znów będziesz online, unieważnij to urządzenie poniżej." + "error_teardown": "Push jest wyłączony w tej przeglądarce, ale serwer poczty lub przekaźnik był niedostępny. Powiadomienia mogą przychodzić jeszcze przez kilka dni. Aby zatrzymać je wcześniej, gdy znów będziesz online, włącz ustawienia zaawansowane i unieważnij to urządzenie w sekcji Zarejestrowane urządzenia." } }, "language_region": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 7f5cc4d26..0278a2e59 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "As notificações não foram permitidas, por isso as notificações push continuam desligadas.", "error_verification_timeout": "O servidor de correio não confirmou este dispositivo a tempo, por isso as notificações push continuam desligadas. Tente novamente dentro de um minuto.", "error_relay": "Não é possível contactar o retransmissor de notificações neste momento, por isso as notificações push continuam desligadas. Tente mais tarde.", - "error_teardown": "As notificações push estão desligadas neste navegador, mas não foi possível contactar o servidor de correio ou o retransmissor. Podem continuar a chegar notificações durante alguns dias. Revogue este dispositivo abaixo quando voltar a estar online." + "error_teardown": "As notificações push estão desligadas neste navegador, mas não foi possível contactar o servidor de correio ou o retransmissor. Podem continuar a chegar notificações durante alguns dias. Para as parar mais cedo, ligue as definições avançadas quando voltar a estar online e revogue este dispositivo em Dispositivos registados." } }, "language_region": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 7691ca97c..1d78fd682 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "Notificările nu au fost permise, deci push rămâne dezactivat.", "error_verification_timeout": "Serverul de e-mail nu a confirmat la timp acest dispozitiv, deci push rămâne dezactivat. Încearcă din nou peste un minut.", "error_relay": "Releul push nu poate fi accesat acum, deci push rămâne dezactivat. Încearcă din nou mai târziu.", - "error_teardown": "Push este dezactivat în acest browser, dar serverul de e-mail sau releul nu au putut fi accesate. Notificările pot continua să sosească câteva zile. Revocă acest dispozitiv mai jos când ești din nou online." + "error_teardown": "Push este dezactivat în acest browser, dar serverul de e-mail sau releul nu au putut fi accesate. Notificările pot continua să sosească câteva zile. Pentru a le opri mai devreme, când ești din nou online activează setările avansate și revocă acest dispozitiv la Dispozitive înregistrate." }, "push_all": { "title": "All accounts", diff --git a/locales/ru/common.json b/locales/ru/common.json index 21ca4ae3c..d7ea21d6f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Уведомления не разрешены, поэтому push остаётся выключенным.", "error_verification_timeout": "Почтовый сервер не подтвердил это устройство вовремя, поэтому push остаётся выключенным. Попробуйте снова через минуту.", "error_relay": "Push-ретранслятор сейчас недоступен, поэтому push остаётся выключенным. Попробуйте позже.", - "error_teardown": "Push в этом браузере выключен, но почтовый сервер или ретранслятор были недоступны. Уведомления могут приходить ещё несколько дней. Когда снова будете в сети, отзовите это устройство ниже." + "error_teardown": "Push в этом браузере выключен, но почтовый сервер или ретранслятор были недоступны. Уведомления могут приходить ещё несколько дней. Чтобы остановить их раньше, когда снова будете в сети, включите расширенные настройки и отзовите это устройство в разделе «Зарегистрированные устройства»." } }, "language_region": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 6a7f8d987..102d70272 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1808,7 +1808,7 @@ "error_not_granted": "Oznámenia neboli povolené, takže push zostáva vypnutý.", "error_verification_timeout": "Poštový server toto zariadenie včas nepotvrdil, takže push zostáva vypnutý. Skúste to znova o minútu.", "error_relay": "Push relay teraz nie je dostupné, takže push zostáva vypnutý. Skúste to neskôr.", - "error_teardown": "Push je v tomto prehliadači vypnutý, ale poštový server alebo relay neboli dostupné. Oznámenia môžu ešte niekoľko dní prichádzať. Keď budete online, odvolajte toto zariadenie nižšie." + "error_teardown": "Push je v tomto prehliadači vypnutý, ale poštový server alebo relay neboli dostupné. Oznámenia môžu ešte niekoľko dní prichádzať. Ak ich chcete zastaviť skôr, keď budete online, zapnite rozšírené nastavenia a odvolajte toto zariadenie v časti Registrované zariadenia." } }, "language_region": { diff --git a/locales/th/common.json b/locales/th/common.json index 9018edf86..4128dff28 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4668,7 +4668,7 @@ "error_not_granted": "ไม่ได้อนุญาตการแจ้งเตือน พุชจึงยังปิดอยู่", "error_verification_timeout": "เซิร์ฟเวอร์อีเมลไม่ยืนยันอุปกรณ์นี้ทันเวลา พุชจึงยังปิดอยู่ ลองอีกครั้งในอีกหนึ่งนาที", "error_relay": "ไม่สามารถเชื่อมต่อรีเลย์พุชได้ในขณะนี้ พุชจึงยังปิดอยู่ ลองอีกครั้งภายหลัง", - "error_teardown": "ปิดพุชในเบราว์เซอร์นี้แล้ว แต่ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์อีเมลหรือรีเลย์ได้ อาจยังได้รับการแจ้งเตือนอีกสองสามวัน เพิกถอนอุปกรณ์นี้ด้านล่างเมื่อกลับมาออนไลน์" + "error_teardown": "ปิดพุชในเบราว์เซอร์นี้แล้ว แต่ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์อีเมลหรือรีเลย์ได้ อาจยังได้รับการแจ้งเตือนอีกสองสามวัน หากต้องการหยุดเร็วขึ้น เมื่อกลับมาออนไลน์ให้เปิดการตั้งค่าขั้นสูงและเพิกถอนอุปกรณ์นี้ในอุปกรณ์ที่ลงทะเบียน" }, "push_all": { "title": "ทุกบัญชี", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6460ac89d..5aba47d15 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Bildirimlere izin verilmedi, bu yüzden anlık bildirim kapalı kalıyor.", "error_verification_timeout": "Posta sunucusu bu cihazı zamanında onaylamadı, bu yüzden anlık bildirim kapalı kalıyor. Bir dakika sonra yeniden deneyin.", "error_relay": "Anlık bildirim aktarıcısına şu anda ulaşılamıyor, bu yüzden anlık bildirim kapalı kalıyor. Daha sonra yeniden deneyin.", - "error_teardown": "Anlık bildirim bu tarayıcıda kapatıldı, ancak posta sunucusuna veya aktarıcıya ulaşılamadı. Bildirimler birkaç gün daha gelebilir. Yeniden çevrimiçi olduğunuzda bu cihazı aşağıdan iptal edin." + "error_teardown": "Anlık bildirim bu tarayıcıda kapatıldı, ancak posta sunucusuna veya aktarıcıya ulaşılamadı. Bildirimler birkaç gün daha gelebilir. Daha erken durdurmak için yeniden çevrimiçi olduğunuzda gelişmiş ayarları açın ve bu cihazı Kayıtlı cihazlar bölümünden iptal edin." } }, "language_region": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 86392652d..e608479fc 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "Сповіщення не дозволено, тому push залишається вимкненим.", "error_verification_timeout": "Поштовий сервер не підтвердив цей пристрій вчасно, тому push залишається вимкненим. Спробуйте ще раз за хвилину.", "error_relay": "Push-ретранслятор зараз недоступний, тому push залишається вимкненим. Спробуйте пізніше.", - "error_teardown": "Push у цьому браузері вимкнено, але поштовий сервер або ретранслятор були недоступні. Сповіщення можуть надходити ще кілька днів. Коли знову будете онлайн, відкличте цей пристрій нижче." + "error_teardown": "Push у цьому браузері вимкнено, але поштовий сервер або ретранслятор були недоступні. Сповіщення можуть надходити ще кілька днів. Щоб зупинити їх раніше, коли знову будете онлайн, увімкніть розширені налаштування й відкличте цей пристрій у розділі «Зареєстровані пристрої»." } }, "language_region": { diff --git a/locales/vi/common.json b/locales/vi/common.json index e6c30e535..1dfd9ecce 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1784,7 +1784,7 @@ "error_not_granted": "Thông báo không được cho phép nên push vẫn tắt.", "error_verification_timeout": "Máy chủ thư không xác nhận thiết bị này kịp thời nên push vẫn tắt. Hãy thử lại sau một phút.", "error_relay": "Hiện không thể kết nối với relay push nên push vẫn tắt. Hãy thử lại sau.", - "error_teardown": "Push đã tắt trong trình duyệt này nhưng không thể kết nối với máy chủ thư hoặc relay. Thông báo có thể tiếp tục đến trong vài ngày. Hãy thu hồi thiết bị này bên dưới khi bạn có mạng trở lại." + "error_teardown": "Push đã tắt trong trình duyệt này nhưng không thể kết nối với máy chủ thư hoặc relay. Thông báo có thể tiếp tục đến trong vài ngày. Để dừng sớm hơn, khi có mạng trở lại hãy bật cài đặt nâng cao và thu hồi thiết bị này trong Thiết bị đã đăng ký." }, "push_all": { "title": "Tất cả tài khoản", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 2d1ff3b32..be4a7cf9f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1772,7 +1772,7 @@ "error_not_granted": "未允許通知,因此推播保持關閉。", "error_verification_timeout": "郵件伺服器未及時確認此裝置,因此推播保持關閉。請一分鐘後再試一次。", "error_relay": "目前無法連線到推播中繼,因此推播保持關閉。請稍後再試。", - "error_teardown": "此瀏覽器的推播已關閉,但無法連線到郵件伺服器或中繼。通知可能還會持續幾天。恢復連線後,請在下方撤銷此裝置。" + "error_teardown": "此瀏覽器的推播已關閉,但無法連線到郵件伺服器或中繼。通知可能還會持續幾天。如需盡快停止,請在恢復連線後開啟進階設定,並在已註冊裝置中撤銷此裝置。" }, "push_all": { "title": "All accounts", diff --git a/locales/zh/common.json b/locales/zh/common.json index e4712db8f..3dbaea82a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1805,7 +1805,7 @@ "error_not_granted": "未允许通知,因此推送保持关闭。", "error_verification_timeout": "邮件服务器未及时确认此设备,因此推送保持关闭。请一分钟后重试。", "error_relay": "目前无法连接推送中继,因此推送保持关闭。请稍后重试。", - "error_teardown": "此浏览器的推送已关闭,但无法连接邮件服务器或中继。通知可能还会持续几天。恢复联网后,请在下方撤销此设备。" + "error_teardown": "此浏览器的推送已关闭,但无法连接邮件服务器或中继。通知可能还会持续几天。如需尽快停止,请在恢复联网后打开高级设置,并在已注册设备中撤销此设备。" } }, "language_region": { From a9e33ec2d6f6c917b35d80b88ac0d1c5f757e513 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:11:25 +0100 Subject: [PATCH 34/35] fix: stop a page wiping another account's plugin secrets by naming it in the desktop app A wipe by name took any username and server with no session behind it. It now needs a sealed cookie the account got while it was signed in, since a dropped account has no session cookie left. The old wipe test asserted that a name alone was enough, which is the behaviour being removed. --- app/api/plugin-secrets/__tests__/wipe.test.ts | 62 ++++++++++++++++++- app/api/plugin-secrets/route.ts | 24 +++++-- lib/auth/crypto.ts | 5 +- lib/offline/evicted-secrets.ts | 7 ++- lib/plugin-secrets/wipe-proof.ts | 42 +++++++++++++ 5 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 lib/plugin-secrets/wipe-proof.ts diff --git a/app/api/plugin-secrets/__tests__/wipe.test.ts b/app/api/plugin-secrets/__tests__/wipe.test.ts index 075138ac9..cc9bcaecc 100644 --- a/app/api/plugin-secrets/__tests__/wipe.test.ts +++ b/app/api/plugin-secrets/__tests__/wipe.test.ts @@ -16,8 +16,16 @@ vi.mock('@/lib/stalwart/credentials', () => ({ vi.mock('@/lib/admin/config-manager', () => ({ configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) }, })); +vi.mock('@/lib/plugin-secrets/known-plugins', () => ({ + declaredUserCredential: async () => ({ inject: { type: 'bearer' } }), +})); +vi.mock('@/lib/plugin-secrets/proxy', () => ({ + adminCredentialOrigins: async () => [], + adminCredentialStatus: async () => [], +})); vi.mock('@/lib/plugin-secrets/vault', () => ({ VaultFullError: class extends Error {}, + userKey: (user: { username: string; serverUrl: string }) => `${Buffer.from(`${user.username}:${user.serverUrl}`).toString('hex')}0000000000000000`.slice(0, 64), deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }), deleteUserCredentials: vi.fn(async () => {}), listUserCredentials: vi.fn(async () => []), @@ -31,10 +39,23 @@ async function remove(query: string, headers: Record = { 'sec-fe } const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org'; +const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }; + +/** The proof cookie an account gets while signed in, as `name=value`. */ +async function proofFor(username: string): Promise { + vi.resetModules(); + const { setWipeProof } = await import('@/lib/plugin-secrets/wipe-proof'); + const { NextResponse } = await import('next/server'); + const answer = NextResponse.json({}); + setWipeProof(answer, { username, serverUrl: 'https://mail.example.org' }); + const cookie = answer.cookies.getAll()[0]; + return `${cookie.name}=${cookie.value}`; +} beforeEach(() => { deleted.length = 0; session.signedIn = false; + vi.stubEnv('SESSION_SECRET', 'wipe-test-session-secret-wipe-test-session-secret'); }); afterEach(() => { @@ -42,23 +63,58 @@ afterEach(() => { }); describe('DELETE /api/plugin-secrets?all=true', () => { - it('in the desktop app takes a dropped account by who it was, with no cookie', async () => { + it('in the desktop app takes a dropped account by who it was, with the proof it got while signed in', async () => { vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); - expect((await remove(EVICTED)).status).toBe(200); + const answer = await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') }); + expect(answer.status).toBe(200); expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]); + expect(answer.headers.get('set-cookie')).toMatch(/Max-Age=0/i); + }); + + it('in the desktop app won\'t take any name on its own', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await remove(EVICTED)).status).toBe(401); + expect(deleted).toEqual([]); + }); + + it('in the desktop app won\'t take another account\'s proof, or the signed-in account\'s session, for that name', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + session.signedIn = true; + const bob = await proofFor('bob'); + const aliceName = (await proofFor('alice')).split('=')[0]; + expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=${bob.split('=').slice(1).join('=')}` })).status).toBe(401); + expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=forged` })).status).toBe(401); + expect(deleted).toEqual([]); }); it('won\'t take it from another site', async () => { vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); - expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403); + const cookie = await proofFor('alice'); + expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site', cookie })).status).toBe(403); expect(deleted).toEqual([]); }); + it('gives the proof when an account lists what it has, in the desktop app only', async () => { + session.signedIn = true; + const list = async () => { + vi.resetModules(); + const vault = await import('@/lib/plugin-secrets/vault'); + vi.mocked(vault.listUserCredentials).mockResolvedValueOnce([{ pluginId: 'p' } as never]); + const { GET } = await import('../route'); + return GET(new NextRequest('https://app.local/api/plugin-secrets', { headers: SAME_ORIGIN })); + }; + expect((await list()).headers.get('set-cookie')).toBeNull(); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await list()).headers.get('set-cookie')).toMatch(/^jmap_psp_/); + }); + it('on an instance deletes nobody\'s, with a cookie or without', async () => { expect((await remove(EVICTED)).status).toBe(401); session.signedIn = true; await remove(EVICTED); expect(deleted).toEqual([]); + await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') }); + expect(deleted).toEqual([]); }); it('in the desktop app with a cookie and no name takes the signed-in account', async () => { diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index b767e0dbe..9ccce655b 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/plugin-secrets/vault'; import { storedDataUnavailableResponse } from '@/lib/stored-data-errors'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { clearWipeProof, hasWipeProof, setWipeProof } from '@/lib/plugin-secrets/wipe-proof'; const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const MAX_SECRET_LENGTH = 4096; @@ -52,7 +53,9 @@ export async function GET(request: NextRequest) { const [admin, adminOrigins] = pluginId ? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)]) : [[], []]; - return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE }); + const answer = NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE }); + if (IS_DESKTOP_BUILD && credentials.length > 0) setWipeProof(answer, user); + return answer; } /** @@ -107,21 +110,28 @@ export async function POST(request: NextRequest) { if (unavailable) return unavailable; throw error; } - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + if (IS_DESKTOP_BUILD) setWipeProof(answer, user); + return answer; } /** DELETE /api/plugin-secrets?plugin=[&credential=] - disconnect. */ export async function DELETE(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; - // An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone. + // An account dropped earlier, in the desktop app: no session cookie left, so it + // takes the proof that account got while it had one (lib/plugin-secrets/wipe-proof.ts). const { searchParams } = request.nextUrl; const owner = searchParams.get('username'); const server = searchParams.get('server'); if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) { + const named = { username: owner, serverUrl: server }; + if (!hasWipeProof(request, named)) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE }); try { - await deleteAllUserCredentials({ username: owner, serverUrl: server }); - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + await deleteAllUserCredentials(named); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + clearWipeProof(answer, named); + return answer; } catch (error) { const unavailable = storedDataUnavailableResponse(error, NO_STORE); if (unavailable) return unavailable; @@ -135,7 +145,9 @@ export async function DELETE(request: NextRequest) { if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') { try { await deleteAllUserCredentials(user); - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + clearWipeProof(answer, user); + return answer; } catch (error) { const unavailable = storedDataUnavailableResponse(error, NO_STORE); if (unavailable) return unavailable; diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index a16fd7958..102c4bd1b 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -123,7 +123,9 @@ export type PayloadPurpose = /** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */ | 'handoff-store' /** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */ - | 'custom-upstream'; + | 'custom-upstream' + /** Proof of an earlier session, for wiping a dropped account's plugin secrets (lib/plugin-secrets/wipe-proof.ts). */ + | 'plugin-wipe-proof'; /** * Whether a blob minted before purposes were bound may be re-admitted for @@ -148,6 +150,7 @@ const LEGACY_PAYLOAD_MARKERS: Record false, 'handoff-store': () => false, 'custom-upstream': () => false, + 'plugin-wipe-proof': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/offline/evicted-secrets.ts b/lib/offline/evicted-secrets.ts index 490992645..4929a7f34 100644 --- a/lib/offline/evicted-secrets.ts +++ b/lib/offline/evicted-secrets.ts @@ -1,8 +1,9 @@ /** * Plugin secrets of an account the app dropped, in the desktop app, where they sit - * on this device's own server. The account has no cookie left to ask with, so the - * route takes who it was, from what was written down at the time. On an instance - * they aren't the device's to remove, and nothing is written down. + * on this device's own server. The account has no session cookie left to ask with, so + * the route takes who it was, from what was written down at the time, with the proof + * the account got while it was signed in. On an instance they aren't the device's to + * remove, and nothing is written down. */ import { apiFetch } from '@/lib/browser-navigation'; diff --git a/lib/plugin-secrets/wipe-proof.ts b/lib/plugin-secrets/wipe-proof.ts new file mode 100644 index 000000000..cb8a32feb --- /dev/null +++ b/lib/plugin-secrets/wipe-proof.ts @@ -0,0 +1,42 @@ +/** + * Proof that this browser had a session for an account, for the desktop app. + * + * An account the app dropped has no session cookie left, and its plugin secrets + * still sit on this device until a wipe. So when a signed-in account stores or lists + * them, it also gets this sealed cookie, and a wipe by name needs the one for that + * name. Nobody can then clear an account's secrets by knowing its address. + * + * It opens nothing else, and only the desktop build writes or reads it. + */ + +import type { NextRequest, NextResponse } from 'next/server'; +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; +import { userKey, type VaultUser } from './vault'; + +/** Chromium's upper limit. A dropped account can stay dropped for a long time. */ +const MAX_AGE_SECONDS = 400 * 24 * 60 * 60; + +function cookieName(key: string): string { + return `jmap_psp_${key.slice(0, 16)}`; +} + +export function setWipeProof(response: NextResponse, user: VaultUser): void { + const key = userKey(user); + response.cookies.set(cookieName(key), encryptPayload({ u: key }, 'plugin-wipe-proof'), { ...getCookieOptions(), maxAge: MAX_AGE_SECONDS }); +} + +export function clearWipeProof(response: NextResponse, user: VaultUser): void { + response.cookies.set(cookieName(userKey(user)), '', { ...getCookieOptions(), maxAge: 0 }); +} + +export function hasWipeProof(request: NextRequest, user: VaultUser): boolean { + const key = userKey(user); + const value = request.cookies.get(cookieName(key))?.value; + if (!value) return false; + try { + return decryptPayload(value, 'plugin-wipe-proof')?.u === key; + } catch { + return false; + } +} From 466fa7f97463bb9514dff721ec63fa4b455e1419 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:15:42 +0100 Subject: [PATCH 35/35] fix: keep the desktop app's session secret and admin password out of its server's environment --- lib/admin/password.ts | 3 +- lib/auth/session-secret.ts | 4 ++ lib/desktop/__tests__/server-secrets.test.ts | 57 ++++++++++++++++++++ lib/desktop/__tests__/trust-flag.test.ts | 14 +++++ lib/desktop/server-secrets.ts | 20 +++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 lib/desktop/__tests__/server-secrets.test.ts create mode 100644 lib/desktop/server-secrets.ts diff --git a/lib/admin/password.ts b/lib/admin/password.ts index bae73d0b5..a2bfdce13 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -6,6 +6,7 @@ import { assertWritable, isConfigReadOnly } from './paths'; import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store'; import type { AdminConfigData, AdminStateData } from './types'; import { readFileEnv } from '../read-file-env'; +import { desktopServerSecret } from '@/lib/desktop/server-secrets'; const SCRYPT_KEYLEN = 64; const SCRYPT_COST = 16384; // 2^14 @@ -269,7 +270,7 @@ export async function initAdminPassword(): Promise { return true; } - const envPassword = process.env.ADMIN_PASSWORD; + const envPassword = desktopServerSecret('adminPassword') || process.env.ADMIN_PASSWORD; if (!envPassword) { if (!cache.initialized) logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)'); cache.initialized = true; diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts index cd3b6cee7..d8faeea3c 100644 --- a/lib/auth/session-secret.ts +++ b/lib/auth/session-secret.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; +import { desktopServerSecret } from '@/lib/desktop/server-secrets'; import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; import { readFileEnv } from '@/lib/read-file-env'; import { getStore, isSharedMode } from '@/lib/shared-store'; @@ -25,6 +26,9 @@ function sharedSecret(): string { /** A secret the operator set: env, file, or the setup wizard's config. */ function configuredSessionSecret(): string { + const fromShell = desktopServerSecret('sessionSecret'); + if (fromShell) return fromShell; + const fromEnv = process.env.SESSION_SECRET; if (fromEnv) return fromEnv; diff --git a/lib/desktop/__tests__/server-secrets.test.ts b/lib/desktop/__tests__/server-secrets.test.ts new file mode 100644 index 000000000..ff7016879 --- /dev/null +++ b/lib/desktop/__tests__/server-secrets.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The desktop app's server gets its secrets by message, so they stay out of its + * environment block. No other build looks there. + */ + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { get: (_key: string, fallback: unknown) => fallback }, +})); +vi.mock('@/lib/shared-store', () => ({ isSharedMode: () => false, getStore: () => ({}) })); +vi.mock('@/lib/admin/paths', () => ({ ensureStateDir: async () => {}, getStatePath: () => 'C:/nowhere/.session-secret' })); + +const KEY = Symbol.for('bulwark.desktop.serverSecrets'); +const holder = globalThis as unknown as Record; +const FROM_SHELL = 'from-the-shell-from-the-shell-from-the-shell'; + +async function sessionSecret() { + vi.resetModules(); + return (await import('@/lib/auth/session-secret')).getSessionSecret(); +} + +beforeEach(() => { + holder[KEY] = { sessionSecret: FROM_SHELL, adminPassword: 'admin-from-the-shell' }; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete holder[KEY]; +}); + +describe('the session secret', () => { + it('comes from the shell in the desktop build, with nothing in the environment', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + vi.stubEnv('SESSION_SECRET', ''); + expect(await sessionSecret()).toBe(FROM_SHELL); + }); + + it('never from there in a server build', async () => { + vi.stubEnv('SESSION_SECRET', ''); + expect(await sessionSecret()).toBe(''); + vi.stubEnv('SESSION_SECRET', 'from-the-environment'); + expect(await sessionSecret()).toBe('from-the-environment'); + }); +}); + +describe('the admin password', () => { + it('is only readable from the shell in the desktop build', async () => { + const { desktopServerSecret } = await import('../server-secrets'); + expect(desktopServerSecret('adminPassword')).toBe(''); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect(desktopServerSecret('adminPassword')).toBe('admin-from-the-shell'); + delete holder[KEY]; + expect(desktopServerSecret('adminPassword')).toBe(''); + }); +}); diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts index 0edd41532..ad56705eb 100644 --- a/lib/desktop/__tests__/trust-flag.test.ts +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -134,6 +134,20 @@ describe('the flag is fixed at build time', () => { expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER); }); + it('the secrets from the shell are read in the desktop build only', async () => { + const compile = async (flag: string) => (await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/server-secrets.ts')], + bundle: true, + write: false, + minify: true, + format: 'esm', + platform: 'node', + define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) }, + })).outputFiles[0].text; + expect(await compile('')).not.toContain('bulwark.desktop.serverSecrets'); + expect(await compile('1')).toContain('bulwark.desktop.serverSecrets'); + }); + it('no image build sets it', () => { for (const file of ['Dockerfile', 'Dockerfile.lite']) { expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP'); diff --git a/lib/desktop/server-secrets.ts b/lib/desktop/server-secrets.ts new file mode 100644 index 000000000..ee8e76189 --- /dev/null +++ b/lib/desktop/server-secrets.ts @@ -0,0 +1,20 @@ +/** + * The session secret and local admin password of the desktop app's own server. + * + * The Electron main process sends them by message, and src/server/host.ts in the + * desktop repo puts them on globalThis, which all of Next's bundles share. They stay + * out of the environment block, where a child process would inherit them. + * + * Every other build reads its secrets the usual way, and this compiles to nothing. + */ + +interface ShellSecrets { + sessionSecret?: string; + adminPassword?: string; +} + +export function desktopServerSecret(name: keyof ShellSecrets): string { + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return ''; + const holder = globalThis as unknown as Record; + return holder[Symbol.for('bulwark.desktop.serverSecrets')]?.[name] ?? ''; +}