This commit is contained in:
Linus Rath
2026-09-23 05:48:12 +02:00
35 changed files with 397 additions and 56 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) {
@@ -0,0 +1,105 @@
import { render, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotificationSettings } from '../notification-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import * as webPush from '@/lib/web-push';
// next-intl's useTranslations -> identity so labels are their keys.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Keep the section wrapper out of the way.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/lib/web-push', () => ({
WebPushUnsupportedError: class WebPushUnsupportedError extends Error {},
isWebPushSupported: () => true,
isWebPushEnabled: vi.fn(async () => true),
enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })),
disableWebPush: vi.fn(async () => undefined),
listPushDevices: vi.fn(async () => []),
revokePushDevice: vi.fn(async () => undefined),
}));
const fakeClient = { getAccountId: () => 'acct-1' } as unknown as ReturnType<
typeof useAuthStore.getState
>['client'];
describe('NotificationSettings - pushNotifyInboxOnly', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
});
it('does not re-sync push on mount', async () => {
render(<NotificationSettings />);
// Let the "is push enabled?" effect settle.
await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled());
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
it('re-runs enableWebPush with the new flag when the toggle flips while push is on', async () => {
const { findByText } = render(<NotificationSettings />);
await findByText('push.status_active');
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
await waitFor(() => expect(webPush.enableWebPush).toHaveBeenCalledTimes(1));
expect(webPush.enableWebPush).toHaveBeenCalledWith(
expect.objectContaining({ client: fakeClient, inboxOnly: true }),
);
// forceRecreate stays off - a filter PATCH, not a destroy/recreate.
expect(
(webPush.enableWebPush as ReturnType<typeof vi.fn>).mock.calls[0][0],
).not.toHaveProperty('forceRecreate', true);
});
it('does not get stuck on "busy" after the re-sync resolves', async () => {
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('push.status_active');
const devicesLoadsBefore = (webPush.listPushDevices as ReturnType<typeof vi.fn>).mock.calls.length;
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
// enableWebPush resolves -> status returns to active, device list refreshes.
await findByText('push.status_active');
expect(queryByText('push.status_busy')).toBeNull();
await waitFor(() =>
expect(
(webPush.listPushDevices as ReturnType<typeof vi.fn>).mock.calls.length,
).toBeGreaterThan(devicesLoadsBefore),
);
});
it('does not re-sync when push is disabled on this device', async () => {
(webPush.isWebPushEnabled as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
render(<NotificationSettings />);
await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled());
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
// Give any effect a chance to fire.
await Promise.resolve();
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
});
+59 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
@@ -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) {
@@ -146,6 +148,48 @@ export function NotificationSettings() {
}
};
// Flipping "Inbox only" only writes the setting; the server-side JMAP push
// filter stays stale until the next app launch runs resyncWebPush. When push
// is already on for this device, re-run the enable flow now so the toggle
// takes effect immediately instead of "on next restart". The ref seeds to the
// mounted value so this never fires on mount or on an unrelated re-render.
const lastSyncedInboxOnly = useRef(pushNotifyInboxOnly);
useEffect(() => {
if (lastSyncedInboxOnly.current === pushNotifyInboxOnly) return;
lastSyncedInboxOnly.current = pushNotifyInboxOnly;
// Push off on this device: enabling it later builds the filter from the
// current setting, so there's nothing to re-sync now.
if (!client || pushStatus.kind !== 'enabled') return;
let cancelled = false;
setPushStatus({ kind: 'busy' });
void enableWebPush({
client,
relayBaseUrl: activeRelayUrl,
accountLabel: username ?? undefined,
inboxOnly: pushNotifyInboxOnly,
})
.then(() => { if (!cancelled) setPushStatus({ kind: 'enabled' }); })
.catch((err) => {
if (cancelled) return;
if (err instanceof WebPushUnsupportedError) {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to update push filter',
});
})
.finally(() => { if (!cancelled) void refreshDevices(); });
return () => { cancelled = true; };
// Only the setting flip should trigger a re-sync. client / relay / username
// are read live from the recreated closure; depending on pushStatus.kind
// here would re-fire the effect on the busy->enabled transition it causes
// and cancel its own in-flight call (leaving the row stuck on "Working").
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pushNotifyInboxOnly]);
const handleDisablePush = async () => {
if (!client) return;
const confirmed = await confirmDialog({
@@ -357,6 +401,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')}>
+74 -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,71 @@ 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/);
});
it('rethrows a mailbox-fetch failure in inbox-only mode instead of reporting no Inbox', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => {
throw new Error('JMAP down');
}) as unknown as IJMAPClient['getAllMailboxes'];
await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/JMAP down/);
});
it('still emits a filter for an account that has an Inbox but no Junk mailbox', async () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID },
{ id: `${SHARED_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: SHARED_ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
const config = await buildEmailPushConfig(client, false);
expect(Object.keys(config).sort()).toEqual([ACCOUNT_ID, SHARED_ACCOUNT_ID].sort());
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }],
});
});
});
describe('disableWebPush', () => {
it('destroys every subscription this device owns, not just the recorded id', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+52 -13
View File
@@ -72,31 +72,63 @@ 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 mailboxes = await client.getAllMailboxes().catch(() => [] as Mailbox[]);
// Every account that has any mailbox gets a filter entry - a secondary/shared
// account without a Junk-role mailbox still needs one so its subscription
// isn't left unfiltered.
const accountIds = new Set<string>([primary]);
const junkByAccount = new Map<string, string[]>();
const inboxByAccount = new Map<string, string>();
// In inbox-only mode a swallowed fetch failure would masquerade as "no Inbox
// mailbox" below, so let the real error surface; the default mode can still
// degrade to keyword-only filtering on a transient miss.
const mailboxes = inboxOnly
? await client.getAllMailboxes()
: await client.getAllMailboxes().catch(() => [] as Mailbox[]);
for (const m of mailboxes) {
const accountId = m.accountId || primary;
const junk = junkByAccount.get(accountId) ?? [];
accountIds.add(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);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
for (const accountId of accountIds) {
const junkIds = junkByAccount.get(accountId) ?? [];
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 +174,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 +487,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 +699,8 @@ export interface ResyncWebPushParams {
client: IJMAPClient;
relayBaseUrl?: string;
accountLabel?: string;
// Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams.
inboxOnly: boolean;
}
/**
@@ -689,6 +727,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,