feat(notifications): add toggleable inbox-only push notifications ✨

This commit is contained in:
Aaron Guise
2026-09-23 11:07:58 +12:00
parent f086d21d84
commit 887b9e7f92
34 changed files with 211 additions and 52 deletions
+1
View File
@@ -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' },
];
+6 -1
View File
@@ -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) {
@@ -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}
/>
</SettingItem>
{!isSettingHidden('pushNotifyInboxOnly') && (
<SettingItem
label={t('email.inbox_only')}
description={t('email.inbox_only_desc')}
locked={isSettingLocked('pushNotifyInboxOnly')}
>
<ToggleSwitch
checked={pushNotifyInboxOnly}
onChange={(checked) => updateSetting('pushNotifyInboxOnly', checked)}
disabled={!emailNotificationsEnabled}
/>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title={t('calendar.title')} description={t('calendar.description')}>
+49 -14
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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);
+39 -10
View File
@@ -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<Record<string, EmailPushConfig>> {
const primary = client.getAccountId();
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const inboxByAccount = new Map<string, string>();
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 "<account>:<id>" 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<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
const conditions: Record<string, unknown>[] = [{ 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<boolea
client: params.client,
relayBaseUrl: params.relayBaseUrl,
accountLabel: params.accountLabel,
inboxOnly: params.inboxOnly,
});
return true;
} catch {
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "إشعارات البريد",
"enabled_desc": "إظهار الإشعارات عند وصول رسائل جديدة",
"sound": "صوت الإشعار",
"sound_desc": "تشغيل تنبيه صوتي عند وصول رسائل جديدة"
"sound_desc": "تشغيل تنبيه صوتي عند وصول رسائل جديدة",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "إشعارات التقويم",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "Notificacions de correu",
"enabled_desc": "Mostra notificacions quan arribin correus nous",
"sound": "So de notificació",
"sound_desc": "Reprodueix un avís sonor quan arribin correus nous"
"sound_desc": "Reprodueix un avís sonor quan arribin correus nous",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notificacions de calendari",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "E-mailová oznámení",
"enabled_desc": "Zobrazovat oznámení při příchodu nových e-mailů",
"sound": "Zvuk oznámení",
"sound_desc": "Přehrát zvukové upozornění při příchodu nových e-mailů"
"sound_desc": "Přehrát zvukové upozornění při příchodu nových e-mailů",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Oznámení kalendáře",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "E-mail-notifikationer",
"enabled_desc": "Vis notifikationer, når nye e-mails ankommer",
"sound": "Notifikationslyd",
"sound_desc": "Afspil en lydadvarsel, når nye e-mails ankommer"
"sound_desc": "Afspil en lydadvarsel, når nye e-mails ankommer",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Kalendernotifikationer",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "E-Mail-Benachrichtigungen",
"enabled_desc": "Benachrichtigungen anzeigen, wenn neue E-Mails eintreffen",
"sound": "Benachrichtigungston",
"sound_desc": "Einen Ton abspielen, wenn neue E-Mails eintreffen"
"sound_desc": "Einen Ton abspielen, wenn neue E-Mails eintreffen",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Kalender-Benachrichtigungen",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "Email notifications",
"enabled_desc": "Show notifications when new emails arrive",
"sound": "Notification sound",
"sound_desc": "Play an audio alert when new emails arrive"
"sound_desc": "Play an audio alert when new emails arrive",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Calendar Notifications",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Notificaciones de correo",
"enabled_desc": "Mostrar notificaciones cuando lleguen nuevos correos",
"sound": "Sonido de notificación",
"sound_desc": "Reproducir un sonido cuando lleguen nuevos correos"
"sound_desc": "Reproducir un sonido cuando lleguen nuevos correos",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notificaciones de calendario",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "اعلان‌های ایمیل",
"enabled_desc": "نمایش اعلان هنگام ایمیل جدید",
"sound": "صدای اعلان",
"sound_desc": "پخش هشدار صوتی"
"sound_desc": "پخش هشدار صوتی",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "اعلان‌های تقویم",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Notifications par e-mail",
"enabled_desc": "Afficher des notifications à l'arrivée de nouveaux e-mails",
"sound": "Son de notification",
"sound_desc": "Jouer un son à l'arrivée de nouveaux e-mails"
"sound_desc": "Jouer un son à l'arrivée de nouveaux e-mails",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notifications de calendrier",
+3 -1
View File
@@ -1127,7 +1127,9 @@
"enabled": "התראות באימייל",
"enabled_desc": "הצג התראות כשמגיעות אימיילים חדשים",
"sound": "צליל התראה",
"sound_desc": "הפעל התראת שמע כשמגיעות הודעות דוא\"ל חדשות"
"sound_desc": "הפעל התראת שמע כשמגיעות הודעות דוא\"ל חדשות",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "הודעות לוח שנה",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "E-mail értesítések",
"enabled_desc": "Értesítések megjelenítése új e-mailek érkezésekor",
"sound": "Értesítési hang",
"sound_desc": "Hangjelzés lejátszása új e-mailek érkezésekor"
"sound_desc": "Hangjelzés lejátszása új e-mailek érkezésekor",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Naptár értesítések",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Notifiche e-mail",
"enabled_desc": "Mostra notifiche all'arrivo di nuove e-mail",
"sound": "Suono di notifica",
"sound_desc": "Riproduci un suono all'arrivo di nuove e-mail"
"sound_desc": "Riproduci un suono all'arrivo di nuove e-mail",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notifiche calendario",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "メール通知",
"enabled_desc": "新しいメールが届いたときに通知を表示",
"sound": "通知音",
"sound_desc": "新しいメールが届いたときに音を鳴らす"
"sound_desc": "新しいメールが届いたときに音を鳴らす",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "カレンダー通知",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "이메일 알림",
"enabled_desc": "새 이메일이 도착하면 알림을 보여줘요",
"sound": "알림음",
"sound_desc": "새 이메일이 도착하면 소리로 알려줘요"
"sound_desc": "새 이메일이 도착하면 소리로 알려줘요",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "캘린더 알림",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "E-pasta paziņojumi",
"enabled_desc": "Rādīt paziņojumus, saņemot jaunas vēstules",
"sound": "Paziņojuma skaņa",
"sound_desc": "Atskaņot skaņas signālu, saņemot jaunas vēstules"
"sound_desc": "Atskaņot skaņas signālu, saņemot jaunas vēstules",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Kalendāra paziņojumi",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "И-мэйл мэдэгдэл",
"enabled_desc": "Шинэ имэйл ирэх үед мэдэгдлийг харуулах",
"sound": "Мэдэгдэлийн дуу",
"sound_desc": "Шинэ имэйл ирэх үед аудио дохиог тоглуулах"
"sound_desc": "Шинэ имэйл ирэх үед аудио дохиог тоглуулах",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Календарийн мэдэгдэл",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "E-postvarsler",
"enabled_desc": "Vis varsler når nye e-poster kommer",
"sound": "Varslingslyd",
"sound_desc": "Spill av et lydvarsel når nye e-poster kommer"
"sound_desc": "Spill av et lydvarsel når nye e-poster kommer",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Kalendervarsler",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "E-mailmeldingen",
"enabled_desc": "Meldingen tonen wanneer nieuwe e-mails binnenkomen",
"sound": "Meldingsgeluid",
"sound_desc": "Een geluid afspelen wanneer nieuwe e-mails binnenkomen"
"sound_desc": "Een geluid afspelen wanneer nieuwe e-mails binnenkomen",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Agendameldingen",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Powiadomienia e-mail",
"enabled_desc": "Pokazuj powiadomienia po nadejściu nowych wiadomości e-mail",
"sound": "Dźwięk powiadomienia",
"sound_desc": "Odtwarzaj sygnał dźwiękowy po nadejściu nowych wiadomości e-mail"
"sound_desc": "Odtwarzaj sygnał dźwiękowy po nadejściu nowych wiadomości e-mail",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Powiadomienia kalendarza",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Notificações de e-mail",
"enabled_desc": "Mostrar notificações quando novos e-mails chegarem",
"sound": "Som de notificação",
"sound_desc": "Reproduzir um som quando novos e-mails chegarem"
"sound_desc": "Reproduzir um som quando novos e-mails chegarem",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notificações de calendário",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "Notificări prin e-mail",
"enabled_desc": "Afișați notificări la primirea de e-mailuri noi",
"sound": "Sunet de notificare",
"sound_desc": "Redă o alertă audio la sosirea unor e-mailuri noi"
"sound_desc": "Redă o alertă audio la sosirea unor e-mailuri noi",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Notificări din calendar",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Уведомления о почте",
"enabled_desc": "Показывать уведомления при получении новых писем",
"sound": "Звук уведомления",
"sound_desc": "Воспроизводить звуковой сигнал при получении новых писем"
"sound_desc": "Воспроизводить звуковой сигнал при получении новых писем",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Уведомления календаря",
+3 -1
View File
@@ -1165,7 +1165,9 @@
"enabled": "E-mailové oznámenia",
"enabled_desc": "Zobrazovať oznámenia pri príchode nových e-mailov",
"sound": "Zvuk oznámenia",
"sound_desc": "Prehrať zvukové upozornenie pri príchode nových e-mailov"
"sound_desc": "Prehrať zvukové upozornenie pri príchode nových e-mailov",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Oznámenia kalendára",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "E-posta bildirimleri",
"enabled_desc": "Yeni e-postalar geldiğinde bildirim göster",
"sound": "Bildirim sesi",
"sound_desc": "Yeni e-postalar geldiğinde sesli uyarı çal"
"sound_desc": "Yeni e-postalar geldiğinde sesli uyarı çal",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Takvim Bildirimleri",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "Сповіщення електронною поштою",
"enabled_desc": "Показувати сповіщення про надходження нових електронних листів",
"sound": "Звук сповіщення",
"sound_desc": "Відтворення звукового сповіщення про надходження нових електронних листів"
"sound_desc": "Відтворення звукового сповіщення про надходження нових електронних листів",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "Сповіщення календаря",
+3 -1
View File
@@ -1201,7 +1201,9 @@
"enabled": "郵件通知",
"enabled_desc": "新郵件到達時顯示通知",
"sound": "通知聲音",
"sound_desc": "新郵件到達時播放音訊提醒"
"sound_desc": "新郵件到達時播放音訊提醒",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "行事曆通知",
+3 -1
View File
@@ -1162,7 +1162,9 @@
"enabled": "邮件通知",
"enabled_desc": "新邮件到达时显示通知",
"sound": "通知声音",
"sound_desc": "新邮件到达时播放音频提醒"
"sound_desc": "新邮件到达时播放音频提醒",
"inbox_only": "Inbox only",
"inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder"
},
"calendar": {
"title": "日历通知",
@@ -35,6 +35,21 @@ describe('settings sync debounce', () => {
});
});
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');
+4
View File
@@ -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<SettingsState>()(
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
notificationSoundChoice: state.notificationSoundChoice,
pushNotifyInboxOnly: state.pushNotifyInboxOnly,
pushRelayUrl: state.pushRelayUrl,
protocolOpenMode: state.protocolOpenMode,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,