feat: add an inbox-only switch for push notifications (bulwarkmail/webmail#983)

This commit is contained in:
Linus Rath
2026-10-31 04:45:07 +01:00
parent 67732348c2
commit 1821fda728
6 changed files with 81 additions and 4 deletions
+3 -1
View File
@@ -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<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
@@ -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 }));
@@ -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<PushTransport>('fcm');
@@ -557,6 +560,18 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{inboxOnlyAvailable && (
<SettingItem
label={t('settings.notifications.email.inbox_only', 'Inbox only')}
description={t('settings.notifications.email.inbox_only_desc', 'Only notify for mail that lands in the Inbox; skip mail a filter files into another folder')}
>
<ToggleSwitch
checked={inboxOnly}
onChange={(v) => update('pushNotifyInboxOnly', v)}
accessibilityLabel={t('settings.notifications.email.inbox_only', 'Inbox only')}
/>
</SettingItem>
)}
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
@@ -92,6 +92,34 @@ describe('push filter folders', () => {
});
});
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 = [
+23 -3
View File
@@ -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<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
const inboxByAccount = new Map<string, string>();
// 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<string, EmailPushConfig> = {};
@@ -391,7 +400,15 @@ export async function buildEmailPushConfig(
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
const overrides = mailboxOverridesForAccount(mutes, accountId);
const conditions: Record<string, unknown>[] = [{ 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);
+3
View File
@@ -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 {