diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index e40904a5c..29c1a6a56 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -57,6 +57,7 @@ const RESTRICTABLE_SETTINGS = [ { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' }, + { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' }, { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, ]; diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index dcae89ac9..d43492cdc 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -61,6 +61,9 @@ export function PushNotificationPrompt() { const emailNotificationsEnabled = useSettingsStore( (state) => state.emailNotificationsEnabled, ); + const pushNotifyInboxOnly = useSettingsStore( + (state) => state.pushNotifyInboxOnly, + ); const policyLoaded = usePolicyStore((state) => state.loaded); const policy = usePolicyStore((state) => state.policy); const userPushRelayUrl = useSettingsStore((state) => state.pushRelayUrl); @@ -123,6 +126,7 @@ export function PushNotificationPrompt() { client, relayBaseUrl, accountLabel: username ?? undefined, + inboxOnly: pushNotifyInboxOnly, }); }; resync(); @@ -131,7 +135,7 @@ export function PushNotificationPrompt() { const onVisible = () => { if (document.visibilityState === 'visible') resync(); }; document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); - }, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, relayBaseUrl, username]); + }, [accountId, client, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]); useEffect(() => { let cancelled = false; @@ -193,6 +197,7 @@ export function PushNotificationPrompt() { client, relayBaseUrl, accountLabel: username ?? undefined, + inboxOnly: pushNotifyInboxOnly, }); setShowPrompt(false); } catch (err) { diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 75b05bd28..d55cb0676 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -47,6 +47,7 @@ export function NotificationSettings() { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice, + pushNotifyInboxOnly, calendarNotificationsEnabled, calendarNotificationSound, calendarInvitationParsingEnabled, @@ -130,6 +131,7 @@ export function NotificationSettings() { relayBaseUrl: activeRelayUrl, accountLabel: username ?? undefined, forceRecreate, + inboxOnly: pushNotifyInboxOnly, }); setPushStatus({ kind: 'enabled' }); } catch (err) { @@ -357,6 +359,20 @@ export function NotificationSettings() { disabled={!emailNotificationsEnabled} /> + + {!isSettingHidden('pushNotifyInboxOnly') && ( + + updateSetting('pushNotifyInboxOnly', checked)} + disabled={!emailNotificationsEnabled} + /> + + )} diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index af35b9b0f..86e4d5fdb 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types'; import { + buildEmailPushConfig, disableWebPush, enableWebPush, listPushDevices, @@ -171,7 +172,7 @@ describe('enableWebPush', () => { const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); - const result = await enableWebPush({ client, relayBaseUrl: RELAY }); + const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); expect(client.createPushSubscription).not.toHaveBeenCalled(); @@ -184,7 +185,7 @@ describe('enableWebPush', () => { const client = makeClient([sub('push-old', THIS_DEVICE)]); installFetch({}); - const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true }); + const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); expect(client.destroyed).toContain('push-old'); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); @@ -198,7 +199,7 @@ describe('enableWebPush', () => { const client = makeClient([sub('push-other', OTHER_DEVICE)]); installFetch({ [OTHER_DEVICE]: 'unknown' }); - await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true }); + await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false }); expect(client.destroyed).not.toContain('push-other'); }); @@ -214,7 +215,7 @@ describe('resyncWebPush', () => { const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); - expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(client.createPushSubscription).not.toHaveBeenCalled(); expect(client.updatePushSubscription).toHaveBeenCalledWith( @@ -228,7 +229,7 @@ describe('resyncWebPush', () => { const client = makeClient([], { emailPushCapability: true }); installFetch({}); - expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.listPushSubscriptions).not.toHaveBeenCalled(); expect(client.createPushSubscription).not.toHaveBeenCalled(); @@ -241,13 +242,13 @@ describe('resyncWebPush', () => { installFetch({}); const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000); - expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); - expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(false); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(1); // A tab left open past a day renews again, before Stalwart's 7-day expiry. now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000); - expect(await resyncWebPush({ client, relayBaseUrl: RELAY })).toBe(true); + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); expect(client.listPushSubscriptions).toHaveBeenCalledTimes(2); now.mockRestore(); }); @@ -263,7 +264,7 @@ describe('resyncWebPush', () => { throw new Error('relay down'); })); - await expect(resyncWebPush({ client, relayBaseUrl: RELAY })).resolves.toBe(false); + await expect(resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).resolves.toBe(false); }); }); @@ -277,7 +278,7 @@ describe('enableWebPush delivery filter (emailPush)', () => { const client = makeClient([], { emailPushCapability: true }); installFetch({}); - await enableWebPush({ client, relayBaseUrl: RELAY }); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.createPushSubscription).toHaveBeenCalledTimes(1); const params = (client.createPushSubscription as ReturnType).mock.calls[0][0]; @@ -290,7 +291,7 @@ describe('enableWebPush delivery filter (emailPush)', () => { const client = makeClient([]); installFetch({}); - await enableWebPush({ client, relayBaseUrl: RELAY }); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); const params = (client.createPushSubscription as ReturnType).mock.calls[0][0]; expect(params).not.toHaveProperty('emailPush'); @@ -303,7 +304,7 @@ describe('enableWebPush delivery filter (emailPush)', () => { const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true }); installFetch({}); - const result = await enableWebPush({ client, relayBaseUrl: RELAY }); + const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(result.subscriptionId).toBe('push-old'); expect(client.createPushSubscription).not.toHaveBeenCalled(); @@ -332,7 +333,7 @@ describe('enableWebPush delivery filter (emailPush)', () => { ); installFetch({}); - await enableWebPush({ client, relayBaseUrl: RELAY }); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.updatePushSubscription).toHaveBeenCalledWith( 'push-old', @@ -356,12 +357,46 @@ describe('enableWebPush delivery filter (emailPush)', () => { ); installFetch({}); - await enableWebPush({ client, relayBaseUrl: RELAY }); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); expect(client.updatePushSubscription).not.toHaveBeenCalled(); }); }); +// Inbox-only mode swaps the "not in Junk" mailbox condition for a positive +// "in Inbox" one, so mail a Sieve rule files into any other folder never +// reaches the push subscription. +describe('buildEmailPushConfig inbox-only mode', () => { + it('keeps current behaviour when inboxOnly is false', async () => { + const client = makeClient([], { emailPushCapability: true }); + expect(await buildEmailPushConfig(client, false)).toEqual(EXPECTED_EMAIL_PUSH); + }); + + it('requires the message to be in each account Inbox when inboxOnly is true', async () => { + const client = makeClient([], { emailPushCapability: true }); + + const config = await buildEmailPushConfig(client, true); + + expect(config[ACCOUNT_ID].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }], + }); + expect(config[SHARED_ACCOUNT_ID].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-shared-inbox' }], + }); + }); + + it('throws when an account has no discoverable Inbox mailbox', async () => { + const client = makeClient([], { emailPushCapability: true }); + client.getAllMailboxes = vi.fn(async () => [ + { id: JUNK_ID, originalId: JUNK_ID, name: 'junk', role: 'junk', accountId: ACCOUNT_ID }, + ]) as unknown as IJMAPClient['getAllMailboxes']; + + await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/No Inbox mailbox/); + }); +}); + describe('disableWebPush', () => { it('destroys every subscription this device owns, not just the recorded id', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); diff --git a/lib/web-push.ts b/lib/web-push.ts index 0af89c82d..f193640dd 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -72,31 +72,53 @@ export function serverSupportsEmailPush(client: IJMAPClient): boolean { } /** - * The delivery filter we want on every account the subscription fans out to: - * skip anything the spam filter tagged `$junk` and anything that lives only in - * a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are - * ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades - * to keyword-only filtering rather than letting everything through. + * The delivery filter we want on every account the subscription fans out to. + * + * Default mode: skip anything the spam filter tagged `$junk` and anything + * that lives only in a Junk-role mailbox (Sieve `fileinto` doesn't set the + * keyword). The two are ANDed so a stale mailbox id - the user deleted and + * recreated Junk - degrades to keyword-only filtering rather than letting + * everything through. + * + * Inbox-only mode: same `$junk` keyword guard, but the mailbox condition + * requires the message to be IN the account's Inbox rather than merely NOT + * in Junk - mail a Sieve rule files into any other folder stays silent. */ export async function buildEmailPushConfig( client: IJMAPClient, + inboxOnly: boolean, ): Promise> { const primary = client.getAccountId(); const junkByAccount = new Map([[primary, []]]); + const inboxByAccount = new Map(); const mailboxes = await client.getAllMailboxes().catch(() => [] as Mailbox[]); for (const m of mailboxes) { const accountId = m.accountId || primary; - const junk = junkByAccount.get(accountId) ?? []; // Shared-account mailboxes carry a client-side ":" id; // the server only knows the original. - if (m.role === 'junk') junk.push(m.originalId ?? m.id); - junkByAccount.set(accountId, junk); + if (m.role === 'junk') { + const junk = junkByAccount.get(accountId) ?? []; + junk.push(m.originalId ?? m.id); + junkByAccount.set(accountId, junk); + } + if (m.role === 'inbox') inboxByAccount.set(accountId, m.originalId ?? m.id); } + if (!junkByAccount.has(primary)) junkByAccount.set(primary, []); const config: Record = {}; for (const [accountId, junkIds] of junkByAccount) { const conditions: Record[] = [{ notKeyword: '$junk' }]; - if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() }); + if (inboxOnly) { + const inboxId = inboxByAccount.get(accountId); + if (!inboxId) { + throw new Error( + `No Inbox mailbox found for account ${accountId}; cannot build an inbox-only push filter`, + ); + } + conditions.push({ inMailbox: inboxId }); + } else if (junkIds.length > 0) { + conditions.push({ inMailboxOtherThan: [...junkIds].sort() }); + } config[accountId] = { // Always the operator form: that's how the server echoes it back, so a // stored config compares equal to a freshly built one. @@ -142,6 +164,10 @@ 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; + // 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. + inboxOnly: boolean; } export interface EnableWebPushResult { @@ -451,7 +477,7 @@ export async function enableWebPush( // revoked shared-mailbox access (#841). const existingSubs = await params.client.listPushSubscriptions().catch(() => []); const emailPush = serverSupportsEmailPush(params.client) - ? await buildEmailPushConfig(params.client) + ? await buildEmailPushConfig(params.client, params.inboxOnly) : null; const subIdKey = subscriptionIdKey(accountId); const storedServerId = localStorage.getItem(subIdKey); @@ -663,6 +689,8 @@ export interface ResyncWebPushParams { client: IJMAPClient; relayBaseUrl?: string; accountLabel?: string; + // Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams. + inboxOnly: boolean; } /** @@ -689,6 +717,7 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise { }); }); + it('defaults pushNotifyInboxOnly to false and round-trips it through the sync payload', async () => { + const settings = useSettingsStore.getState(); + expect(settings.pushNotifyInboxOnly).toBe(false); + + settings.enableSync('a@example.com', 'https://mail-a.example.com'); + settings.updateSetting('pushNotifyInboxOnly', true); + + await useSettingsStore.getState().flushSync(); + + const request = apiFetch.mock.calls[0][1] as RequestInit; + expect(JSON.parse(request.body as string)).toMatchObject({ + settings: { pushNotifyInboxOnly: true }, + }); + }); + it('does not rewrite a pending account snapshot after another account becomes active', async () => { const settings = useSettingsStore.getState(); settings.enableSync('a@example.com', 'https://mail-a.example.com'); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 75d8372c6..668cd5dc9 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -396,6 +396,8 @@ interface SettingsState { emailNotificationsEnabled: boolean; emailNotificationSound: boolean; notificationSoundChoice: NotificationSoundChoice; + /** Web Push only fires for mail that lands in the Inbox; Sieve-filed mail stays silent. */ + pushNotifyInboxOnly: boolean; /** Chosen Web Push relay URL. Empty = the admin-configured default. */ pushRelayUrl: string; @@ -629,6 +631,7 @@ const DEFAULT_SETTINGS = { emailNotificationsEnabled: true, emailNotificationSound: true, notificationSoundChoice: 'default' as NotificationSoundChoice, + pushNotifyInboxOnly: false, pushRelayUrl: '', // Protocol Handlers @@ -828,6 +831,7 @@ export const useSettingsStore = create()( emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationSound: state.emailNotificationSound, notificationSoundChoice: state.notificationSoundChoice, + pushNotifyInboxOnly: state.pushNotifyInboxOnly, pushRelayUrl: state.pushRelayUrl, protocolOpenMode: state.protocolOpenMode, calendarNotificationsEnabled: state.calendarNotificationsEnabled,