From 1821fda72894d21c14e842e5848a195ff3242845 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:45:07 +0100 Subject: [PATCH] feat: add an inbox-only switch for push notifications (bulwarkmail/webmail#983) --- App.tsx | 4 ++- src/api/__tests__/live-stalwart-push.test.ts | 9 ++++++ .../settings/NotificationSettings.tsx | 15 ++++++++++ .../__tests__/push-config-mailboxes.test.ts | 28 +++++++++++++++++++ src/lib/push-notifications.ts | 26 +++++++++++++++-- src/stores/settings-store.ts | 3 ++ 6 files changed, 81 insertions(+), 4 deletions(-) diff --git a/App.tsx b/App.tsx index 545786ff..4f459a38 100644 --- a/App.tsx +++ b/App.tsx @@ -708,8 +708,10 @@ export default function App() { // content, since hydration hands back an equal but new object; the value // seen first after hydration is the baseline, not a change. const notificationMutes = useSettingsStore((s) => s.notificationMutes); + // The Inbox-only switch changes the same filter (webmail #983). + const pushInboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly); const settingsHydrated = useSettingsStore((s) => s.hydrated); - const mutesKey = JSON.stringify(notificationMutes); + const mutesKey = JSON.stringify([notificationMutes, pushInboxOnly]); const lastMutesKey = React.useRef(null); React.useEffect(() => { if (!settingsHydrated) return; diff --git a/src/api/__tests__/live-stalwart-push.test.ts b/src/api/__tests__/live-stalwart-push.test.ts index d06bea96..7545619c 100644 --- a/src/api/__tests__/live-stalwart-push.test.ts +++ b/src/api/__tests__/live-stalwart-push.test.ts @@ -234,6 +234,15 @@ live('live Stalwart push subscriptions', () => { } }, 60_000); + it('takes the Inbox-only filter and echoes it so it compares equal (bulwarkmail/webmail#983)', async () => { + const me = client.accountId; + const emailPush = await buildEmailPushConfig(undefined, true); + expect(emailPush[me].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: inbox }] }); + const { id } = await create('inbox-only', { emailPush }); + const listed = (await listPushSubscriptions()).find((s) => s.id === id); + expect(sameEmailPush(listed?.emailPush, emailPush), JSON.stringify(listed?.emailPush)).toBe(true); + }, 30_000); + it('refuses push addresses that are not public: http and private IPs everywhere, local names on 1.0', async () => { for (const url of ['http://notifications.relay.bulwarkmail.org/api/push/jmap/x', 'https://192.168.1.20/api/push/jmap/x', 'https://10.0.0.5:8443/api/push/jmap/x']) { const err = await refusal(create('private-ip', { url })); diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 35d2a222..78dd9925 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -94,11 +94,14 @@ export function NotificationSettings() { try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; } const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush(); const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId); + // The Inbox-only filter needs the server's emailPush filter too (webmail #983). + const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge); const supported = isPushSupported(); + const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(); const upSupported = isUnifiedPushSupported(); const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); const [transport, setTransport] = useState('fcm'); @@ -557,6 +560,18 @@ export function NotificationSettings() { > update('emailNotificationsEnabled', v)} /> + {inboxOnlyAvailable && ( + + update('pushNotifyInboxOnly', v)} + accessibilityLabel={t('settings.notifications.email.inbox_only', 'Inbox only')} + /> + + )} {canMuteAccount && ( { }); }); +describe('push filter for the Inbox only (bulwarkmail/webmail#983)', () => { + it("lets through only mail that lands in each account's Inbox", async () => { + provideLoadedMailboxes(async () => [ + { id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }, + { id: 'junk', role: 'junk', accountId: 'jmap-primary' }, + { id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true }, + ] as never); + + const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true); + + expect(config['jmap-primary'].filter).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$junk' }, { inMailbox: 'inbox' }], + }); + expect(config.team.filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'in' }] }); + }); + + it('silences an account whose Inbox is muted, and refuses to build without the own Inbox', async () => { + provideLoadedMailboxes(async () => [{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }] as never); + const muted: NotificationMutes = { accounts: [], mailboxes: { 'jmap-primary/inbox': true } }; + const config = await buildEmailPushConfig(muted, true); + expect(config['jmap-primary'].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }] }); + + provideLoadedMailboxes(async () => [{ id: 'junk', role: 'junk', accountId: 'jmap-primary' }] as never); + await expect(buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true)).rejects.toThrow(/No Inbox/); + }); +}); + // Muted accounts and folders (#78) are left out by the server-side filter. describe('push filter with muted folders and accounts (#78)', () => { const folders = [ diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 16e9148a..defeda52 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -17,6 +17,7 @@ import { generateAccountId } from './account-utils'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, + isMailboxMuted, mailboxOverridesForAccount, type NotificationMutes, } from './notification-mutes'; @@ -349,13 +350,19 @@ function emailPushCapabilityIsExact(): boolean { * Muted accounts and folders (#78) are cut out of the same filter: a muted * folder joins Junk in the "not only in" list; a muted account only lets * through mail in the folders the user unmuted again, or nothing at all. + * + * `inboxOnly` (webmail #983, Settings > Notifications > Inbox only) lets + * through only mail that lands in each account's Inbox; a muted Inbox, or an + * account without one, then never pushes. */ export async function buildEmailPushConfig( mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES, + inboxOnly = false, ): Promise> { const primary = jmapClient.accountId; const junkByAccount = new Map([[primary, []]]); const mailboxIdsByAccount = new Map>(); + const inboxByAccount = new Map(); // The mail store loads the same folders at the same moment (sign-in, // start): reuse its list rather than a second Mailbox/get of our own. const username = jmapClient.username; @@ -365,7 +372,8 @@ export async function buildEmailPushConfig( // and unmute everything, so let the real error surface; otherwise the // filter can still degrade to keyword-only on a transient miss. const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0; - const all = loaded ?? (hasMutes + // In inbox-only mode a swallowed failure would read as "no Inbox" too. + const all = loaded ?? (hasMutes || inboxOnly ? [...(await getMailboxes()), ...(await getSharedMailboxes())] : [ ...(await getMailboxes().catch(() => [] as Mailbox[])), @@ -383,6 +391,7 @@ export async function buildEmailPushConfig( const junk = junkByAccount.get(accountId) ?? []; if (m.role === 'junk') junk.push(serverId); junkByAccount.set(accountId, junk); + if (m.role === 'inbox') inboxByAccount.set(accountId, serverId); } const config: Record = {}; @@ -391,7 +400,15 @@ export async function buildEmailPushConfig( const existing = mailboxIdsByAccount.get(accountId) ?? new Set(); const overrides = mailboxOverridesForAccount(mutes, accountId); const conditions: Record[] = [{ notKeyword: '$junk' }]; - if (isAccountMuted(mutes, accountId)) { + if (inboxOnly) { + const inboxId = inboxByAccount.get(accountId); + // The own account always has an Inbox; missing it means the folder + // list is incomplete, and silencing the account would be worse. + if (!inboxId && accountId === primary) { + throw new Error(`No Inbox found for account ${accountId}; cannot build an inbox-only push filter`); + } + conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId) ? { inMailbox: inboxId } : NEVER_MATCH); + } else if (isAccountMuted(mutes, accountId)) { const allowed = overrides.unmuted .filter((id) => existing.has(id) && !junkIds.includes(id)) .sort(); @@ -1357,7 +1374,10 @@ async function setupPushNotificationsInner( // a group out. const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush() - ? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore) + ? withoutAccounts( + await buildEmailPushConfig(useSettingsStore.getState().notificationMutes, useSettingsStore.getState().pushNotifyInboxOnly), + refusedBefore, + ) : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 29ab8a10..c364c4a7 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -317,6 +317,8 @@ export interface PersistedSettings { // until closed); -1 = automatic: 5 s, or 20 s while a screen reader is // on (lib/toast-duration). toastDuration: number; + // Push only for mail that lands in the Inbox (webmail #983, same key). + pushNotifyInboxOnly: boolean; } const DEFAULT_PERSISTED: PersistedSettings = { @@ -474,6 +476,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { // platform: toastDuration: -1, + pushNotifyInboxOnly: false, }; export interface SettingsState extends PersistedSettings {