fix: apply email notifications off and do not disturb per login, not to every login's mail

This commit is contained in:
Linus Rath
2026-11-04 03:13:59 +01:00
parent f0517ecd08
commit 8cb218ce3c
7 changed files with 94 additions and 50 deletions
@@ -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]);
+26 -7
View File
@@ -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<string, string>();
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');
});
});
+20 -13
View File
@@ -9,12 +9,13 @@ const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
type Preview = Record<string, unknown> | null;
type Shown = { title: string; body?: unknown; data?: Record<string, unknown>; tag: string; options: Record<string, unknown>; close: () => void };
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; openWindow?: (url: string) => Promise<unknown> } = {}) {
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; quiet?: Record<string, boolean>; openWindow?: (url: string) => Promise<unknown> } = {}) {
const listeners = new Map<string, (event: unknown) => void>();
const shade = new Map<string, Shown>();
const previews: URL[] = [];
const cacheStore = new Map<string, string>();
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);
});
});
+17 -2
View File
@@ -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<void> {
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<string, boolean> | 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<void> {
if (typeof window === 'undefined') return;
try {
+11 -6
View File
@@ -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;
}
+3 -13
View File
@@ -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 });
});
});
+6 -9
View File
@@ -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