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,