mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat(notifications): add toggleable inbox-only push notifications ✨
This commit is contained in:
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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": "إشعارات التقويم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "اعلانهای تقویم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "הודעות לוח שנה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "カレンダー通知",
|
||||
|
||||
@@ -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": "캘린더 알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Календарийн мэдэгдэл",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уведомления календаря",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сповіщення календаря",
|
||||
|
||||
@@ -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": "行事曆通知",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user