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] 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