feat: mute notifications for single folders or whole accounts #1169

This commit is contained in:
Linus Rath
2026-10-15 21:50:08 +02:00
parent c349e8e288
commit dd2104cdda
41 changed files with 836 additions and 19 deletions
@@ -23,7 +23,12 @@ const mocks = vi.hoisted(() => ({
getAllConnectedClients: () => new Map(mocks.authState.connected),
},
accounts: [] as { id: string; username: string }[],
settingsState: { emailNotificationsEnabled: true, pushRelayUrl: "" },
settingsState: {
emailNotificationsEnabled: true,
pushRelayUrl: "",
pushNotifyInboxOnly: false,
notificationMutes: { accounts: [] as string[], mailboxes: {} as Record<string, boolean> },
},
policyState: {
loaded: true,
policy: {
@@ -51,9 +56,11 @@ vi.mock("@/stores/account-store", () => ({
}));
vi.mock("@/stores/settings-store", () => ({
useSettingsStore: (
selector: (state: typeof mocks.settingsState) => unknown,
) => selector(mocks.settingsState),
useSettingsStore: Object.assign(
(selector: (state: typeof mocks.settingsState) => unknown) =>
selector(mocks.settingsState),
{ getState: () => mocks.settingsState },
),
}));
vi.mock("@/stores/policy-store", () => ({
@@ -102,6 +109,7 @@ describe("PushNotificationPrompt", () => {
mocks.authState.isDemoMode = false;
mocks.settingsState.emailNotificationsEnabled = true;
mocks.settingsState.pushRelayUrl = "";
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
mocks.policyState.loaded = true;
mocks.policyState.policy.pushRelayUrl =
"https://notifications.relay.bulwarkmail.org";
@@ -139,6 +147,8 @@ describe("PushNotificationPrompt", () => {
client: mocks.authState.client,
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
accountLabel: "alice@example.com",
inboxOnly: false,
mutes: { accounts: [], mailboxes: {} },
});
expect(screen.queryByText("title")).not.toBeInTheDocument();
});
@@ -260,6 +270,31 @@ describe("PushNotificationPrompt", () => {
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("sends a changed mute to every login right away, past the daily limit", async () => {
const bob = { getAccountId: () => "account-2" };
mocks.authState.connected.set("bob@mail.example.com", bob);
const { rerender } = render(<PushNotificationPrompt />);
await advancePromptDelay();
mocks.resyncWebPush.mockClear();
// An equal object from a settings sync is no change.
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
rerender(<PushNotificationPrompt />);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
const muted = { accounts: ["account-2"], mailboxes: {} };
mocks.settingsState.notificationMutes = muted;
rerender(<PushNotificationPrompt />);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
for (const [params] of mocks.resyncWebPush.mock.calls) {
expect(params).toMatchObject({ mutes: muted, force: true });
}
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
});
it("does not touch push in demo mode", async () => {
mocks.authState.isDemoMode = true;
render(<PushNotificationPrompt />);
@@ -0,0 +1,108 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// #1169: folders and whole accounts can be muted from their context menu; a
// folder's own setting wins over its account's.
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/contexts/drag-drop-context', () => ({
useDragDropContext: () => ({ isDragging: false }),
}));
vi.mock('@/hooks/use-mailbox-drop', () => ({
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
}));
vi.mock('@/hooks/use-tag-drop', () => ({
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
}));
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
const { Sidebar } = await import('../sidebar');
const { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const box = (id: string, name: string, role: string | null, extra: Partial<Mailbox> = {}) => ({
id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
}) as unknown as Mailbox;
const mailboxes: Mailbox[] = [
box('inbox', 'Inbox', 'inbox'),
box('renovate', 'Renovate', null),
box('g1:g1-inbox', 'Inbox', 'inbox', { originalId: 'g1-inbox', accountId: 'g1', accountName: 'Postmaster', isShared: true }),
];
function client(emailPush: boolean) {
return {
getAccountId: () => 'me',
getCapabilities: () => (emailPush ? { 'urn:ietf:params:jmap:emailpush': {} } : {}),
};
}
const row = (mailboxId: string) =>
screen.getAllByTestId('folder-row').find((el) => el.getAttribute('data-mailbox-id') === mailboxId)!;
const mutes = () => useSettingsStore.getState().notificationMutes;
describe('Sidebar notification mutes (#1169)', () => {
beforeEach(() => {
localStorage.setItem('sidebarSharedExpanded', 'true');
localStorage.setItem('sidebarExpandedSharedAccounts', JSON.stringify(['shared-account-g1']));
useAuthStore.setState({ activeAccountId: 'login-a', client: client(true) } as never);
useSettingsStore.setState({
hideAccountSwitcher: true,
showSharedMailAccounts: true,
hiddenSharedMailAccounts: {},
notificationMutes: { accounts: [], mailboxes: {} },
} as never);
});
it('mutes and unmutes one folder from its context menu', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
expect(row('renovate').querySelector('[data-testid="folder-muted"]')).toBeNull();
fireEvent.contextMenu(row('renovate'));
fireEvent.click(screen.getByTestId('mailbox-mute'));
expect(mutes().mailboxes).toEqual({ 'me/renovate': true });
expect(row('renovate').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
expect(row('inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
fireEvent.contextMenu(row('renovate'));
fireEvent.click(screen.getByTestId('mailbox-mute'));
expect(mutes().mailboxes).toEqual({});
});
it('mutes a shared account, unmutes one of its folders and resets that again', () => {
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
fireEvent.contextMenu(screen.getAllByTestId('section-shared-account')[0]);
expect(screen.queryByTestId('mailbox-reset-folder-mutes')).toBeNull();
fireEvent.click(screen.getByTestId('mailbox-mute-account'));
expect(mutes().accounts).toEqual(['g1']);
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
// The own account is not affected.
expect(row('inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
// The folder's own setting wins over the muted account, keyed by the server id.
fireEvent.contextMenu(row('g1:g1-inbox'));
fireEvent.click(screen.getByTestId('mailbox-mute'));
expect(mutes().mailboxes).toEqual({ 'g1/g1-inbox': false });
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
fireEvent.contextMenu(screen.getAllByTestId('section-shared-account')[0]);
fireEvent.click(screen.getByTestId('mailbox-reset-folder-mutes'));
expect(mutes().mailboxes).toEqual({});
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
});
it('offers no mute without the server push filter', () => {
useAuthStore.setState({ client: client(false) } as never);
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
fireEvent.contextMenu(row('renovate'));
expect(screen.queryByTestId('mailbox-mute')).toBeNull();
});
});
@@ -23,6 +23,9 @@ import {
Upload,
Users,
EyeOff,
Bell,
BellOff,
RotateCcw,
} from "@/components/icons";
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
@@ -73,6 +76,20 @@ function renderShortenedPath(fullPath: string): React.ReactNode {
]);
}
/**
* Per-account and per-folder notification mutes (#1169). An `accountId` of
* undefined is the login's own account, as on the folders-section target.
*/
export interface MailboxNotificationActions {
isMailboxMuted: (mailbox: Mailbox) => boolean;
isAccountMuted: (accountId?: string) => boolean;
/** True when some folder of the account has its own setting. */
hasFolderSettings: (accountId?: string) => boolean;
setMailboxMuted: (mailbox: Mailbox, muted: boolean) => void;
setAccountMuted: (accountId: string | undefined, muted: boolean) => void;
resetFolderSettings: (accountId?: string) => void;
}
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
@@ -94,6 +111,8 @@ interface MailboxContextMenuProps {
onRefresh?: () => void;
/** Hide a shared account's mail folders from the sidebar (#1228). */
onHideSharedAccount?: (accountId: string) => void;
/** Omitted when muting has no effect (no emailPush filter on the server). */
notifications?: MailboxNotificationActions;
}
export function MailboxContextMenu({
@@ -115,6 +134,7 @@ export function MailboxContextMenu({
onImportEmail,
onRefresh,
onHideSharedAccount,
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
@@ -152,6 +172,28 @@ export function MailboxContextMenu({
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
/>
{notifications && (() => {
const accountMuted = notifications.isAccountMuted(target.accountId);
return (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={accountMuted ? Bell : BellOff}
label={accountMuted ? t("unmute_account_notifications") : t("mute_account_notifications")}
onClick={() => handleAction(() => notifications.setAccountMuted(target.accountId, !accountMuted))}
testId="mailbox-mute-account"
/>
{notifications.hasFolderSettings(target.accountId) && (
<ContextMenuItem
icon={RotateCcw}
label={t("reset_folder_notifications")}
onClick={() => handleAction(() => notifications.resetFolderSettings(target.accountId))}
testId="mailbox-reset-folder-mutes"
/>
)}
</>
);
})()}
{target.accountId && onHideSharedAccount && (
<>
<ContextMenuSeparator />
@@ -231,6 +273,17 @@ export function MailboxContextMenu({
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
{notifications && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
<ContextMenuItem
icon={muted ? Bell : BellOff}
label={muted ? t("unmute_notifications") : t("mute_notifications")}
onClick={() => handleAction(() => notifications.setMailboxMuted(mailbox, !muted))}
testId="mailbox-mute"
/>
);
})()}
<ContextMenuSeparator />
+57 -1
View File
@@ -55,7 +55,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { useShallow } from "zustand/react/shallow";
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
@@ -78,6 +78,16 @@ import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } fro
import { useTour } from "@/components/tour/tour-provider";
import { toUnicodeEmail } from "@/lib/idn";
import { hiddenSharedMailIds, isSharedMailAccountVisible, setSharedMailAccountHidden } from "@/lib/shared-mail-visibility";
import {
isAccountMuted,
isMailboxMuted,
mailboxOverridesForAccount,
resetMailboxMutes,
setAccountMuted,
setMailboxMuted,
type NotificationMutes,
} from "@/lib/notification-mutes";
import { serverSupportsEmailPush } from "@/lib/web-push";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -298,6 +308,8 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** New mail here does not notify (#1169). */
muted?: boolean;
/** Stable identifiers for integration tests (not user-visible). */
testRole?: string | null;
testName?: string;
@@ -324,6 +336,7 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
muted = false,
testRole,
testName,
testMailboxId,
@@ -402,6 +415,13 @@ function SidebarRow({
{!isCollapsed && (
<>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
{muted && (
<BellOff
className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground"
aria-label={t('notifications_muted')}
data-testid="folder-muted"
/>
)}
<SidebarRowCounts
unread={unread}
total={total}
@@ -502,6 +522,15 @@ function SidebarSectionHeader({
);
}
/** Whether new mail in this folder of the active login stays silent (#1169). */
function useMutedMailbox(node: MailboxNode | null): boolean {
const client = useAuthStore((s) => s.client);
return useSettingsStore((s) => {
if (!node || !client) return false;
return isMailboxMuted(s.notificationMutes, node.accountId || client.getAccountId(), node.originalId || node.id);
});
}
function MailboxTreeItem({
node,
selectedMailbox,
@@ -536,6 +565,9 @@ function MailboxTreeItem({
const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id;
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
// Only for the active login's folders: its client tells a missing
// accountId (own mailbox) apart.
const muted = useMutedMailbox(isVirtualNode || dragAccountId ? null : node);
const isEmbedded = useIsEmbedded();
// Pro shell only: folder rows are drag sources so a folder can be dropped
@@ -607,6 +639,7 @@ function MailboxTreeItem({
isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
muted={muted}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
@@ -1277,6 +1310,28 @@ function SidebarImpl({
}
: undefined;
// Muting only takes effect through the server's emailPush filter, so the
// menu offers it only where that exists, like the "Inbox only" setting.
const authClient = useAuthStore((s) => s.client);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const notificationActions = useMemo<MailboxNotificationActions | undefined>(() => {
if (!authClient || !serverSupportsEmailPush(authClient)) return undefined;
const accountOf = (accountId?: string) => accountId || authClient.getAccountId();
const mailboxRef = (mailbox: Mailbox) => [accountOf(mailbox.accountId), mailbox.originalId || mailbox.id] as const;
const save = (next: NotificationMutes) => useSettingsStore.getState().updateSetting('notificationMutes', next);
return {
isMailboxMuted: (mailbox) => isMailboxMuted(notificationMutes, ...mailboxRef(mailbox)),
isAccountMuted: (accountId) => isAccountMuted(notificationMutes, accountOf(accountId)),
hasFolderSettings: (accountId) => {
const { muted, unmuted } = mailboxOverridesForAccount(notificationMutes, accountOf(accountId));
return muted.length + unmuted.length > 0;
},
setMailboxMuted: (mailbox, muted) => save(setMailboxMuted(notificationMutes, ...mailboxRef(mailbox), muted)),
setAccountMuted: (accountId, muted) => save(setAccountMuted(notificationMutes, accountOf(accountId), muted)),
resetFolderSettings: (accountId) => save(resetMailboxMutes(notificationMutes, accountOf(accountId))),
};
}, [authClient, notificationMutes]);
return (
<div
onMouseEnter={mvShell && sidebarCollapsed ? () => setRailHovered(true) : undefined}
@@ -1718,6 +1773,7 @@ function SidebarImpl({
onImportEmail={onImportEmail}
onRefresh={onRefreshMailboxes}
onHideSharedAccount={hideSharedAccount}
notifications={notificationActions}
/>
</div>
);
+37 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Bell, Loader2, X } from "@/components/icons";
@@ -22,6 +22,7 @@ import {
const DISMISSED_KEY_PREFIX = "bulwark.push.onboarding.dismissed.v1.";
export const PUSH_NOTIFICATION_PROMPT_DELAY_MS = 1_000;
export const MUTES_RESYNC_DELAY_MS = 1_000;
function dismissedKey(accountId: string): string {
return `${DISMISSED_KEY_PREFIX}${accountId}`;
@@ -169,6 +170,7 @@ export function PushNotificationPrompt() {
relayBaseUrl,
accountLabel: target.accountLabel,
inboxOnly: pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
});
}
};
@@ -183,6 +185,39 @@ export function PushNotificationPrompt() {
};
}, [accountId, client, connectedAccountsRevision, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
// Muting a folder or account changes the server-side push filter, which the
// daily renewal above would only send tomorrow. Send it to every login with
// push on now, after a short pause so a few clicks in a row make one update.
// Compared by content: a settings sync hands back an equal but new object.
const notificationMutes = useSettingsStore((state) => state.notificationMutes);
const mutesKey = JSON.stringify(notificationMutes);
const lastMutesKey = useRef(mutesKey);
useEffect(() => {
if (lastMutesKey.current === mutesKey) return;
lastMutesKey.current = mutesKey;
if (!policyLoaded || !isAuthenticated || !client || isDemoMode) return;
let cancelled = false;
const timer = setTimeout(async () => {
for (const target of resyncTargets(client, username ?? undefined)) {
if (cancelled) return;
await resyncWebPush({
client: target.client,
relayBaseUrl,
accountLabel: target.accountLabel,
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
force: true,
});
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey]);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -244,6 +279,7 @@ export function PushNotificationPrompt() {
relayBaseUrl,
accountLabel: username ?? undefined,
inboxOnly: pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
});
setShowPrompt(false);
} catch (err) {
+5 -1
View File
@@ -76,7 +76,11 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
if (action === 'enable') {
const outcome = await enableWebPushForAccounts(
targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })),
{ relayBaseUrl, inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly },
{
relayBaseUrl,
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
},
);
setNotice(outcome.failed.length
? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length })
@@ -157,6 +157,7 @@ export function NotificationSettings() {
accountLabel: username ?? undefined,
forceRecreate,
inboxOnly: pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
});
setPushStatus({ kind: 'enabled' });
} catch (err) {
@@ -193,6 +194,7 @@ export function NotificationSettings() {
relayBaseUrl: activeRelayUrl,
accountLabel: username ?? undefined,
inboxOnly: pushNotifyInboxOnly,
mutes: useSettingsStore.getState().notificationMutes,
})
.then(() => { if (!cancelled) setPushStatus({ kind: 'enabled' }); })
.catch((err) => {
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_NOTIFICATION_MUTES,
isMailboxMuted,
mailboxOverridesForAccount,
normalizeNotificationMutes,
resetMailboxMutes,
setAccountMuted,
setMailboxMuted,
} from '@/lib/notification-mutes';
describe('notification mutes (#1169)', () => {
it('lets a folder follow its account until it has its own setting', () => {
const accountMuted = setAccountMuted(EMPTY_NOTIFICATION_MUTES, 'a', true);
expect(isMailboxMuted(accountMuted, 'a', 'reports')).toBe(true);
expect(isMailboxMuted(accountMuted, 'b', 'reports')).toBe(false);
const unmutedFolder = setMailboxMuted(accountMuted, 'a', 'inbox', false);
expect(isMailboxMuted(unmutedFolder, 'a', 'inbox')).toBe(false);
expect(isMailboxMuted(unmutedFolder, 'a', 'reports')).toBe(true);
// The folder's own setting wins over the account in both directions.
const accountBack = setAccountMuted(unmutedFolder, 'a', false);
const mutedFolder = setMailboxMuted(accountBack, 'a', 'renovate', true);
expect(isMailboxMuted(mutedFolder, 'a', 'renovate')).toBe(true);
expect(isMailboxMuted(mutedFolder, 'a', 'reports')).toBe(false);
});
it('drops the folder setting when it is set back to what the account says', () => {
const muted = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'renovate', true);
const unmuted = setMailboxMuted(muted, 'a', 'renovate', false);
expect(unmuted.mailboxes).toEqual({});
// So muting the account later reaches this folder too.
expect(isMailboxMuted(setAccountMuted(unmuted, 'a', true), 'a', 'renovate')).toBe(true);
});
it('resets the folder settings of one account only', () => {
let mutes = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'x', true);
mutes = setMailboxMuted(mutes, 'ab', 'y', true);
mutes = resetMailboxMutes(mutes, 'a');
expect(mailboxOverridesForAccount(mutes, 'a')).toEqual({ muted: [], unmuted: [] });
// An account id that merely starts with "a" keeps its settings.
expect(mailboxOverridesForAccount(mutes, 'ab')).toEqual({ muted: ['y'], unmuted: [] });
});
it('repairs malformed stored values', () => {
expect(normalizeNotificationMutes(null)).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes(['a'])).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes({ accounts: ['a', 3, ''], mailboxes: { 'a/x': true, 'a/y': 'yes' } }))
.toEqual({ accounts: ['a'], mailboxes: { 'a/x': true } });
});
});
+78
View File
@@ -564,6 +564,84 @@ describe('buildEmailPushConfig inbox-only mode', () => {
});
});
// Muted accounts and folders (#1169) are left out by the server-side filter.
describe('buildEmailPushConfig with muted folders and accounts', () => {
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}) => ({ accounts, mailboxes });
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, false, mutes([], { [`${ACCOUNT_ID}/mb-archive`]: true }));
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID, 'mb-archive'].sort() }],
});
// Another account's folders are untouched.
expect(config[SHARED_ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[SHARED_ACCOUNT_ID]);
});
it('silences a muted account entirely', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, false, mutes([SHARED_ACCOUNT_ID]));
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config[ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[ACCOUNT_ID]);
});
it('lets a muted account notify for the folders unmuted again', async () => {
const client = makeClient([], { emailPushCapability: true });
const one = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], { [`${ACCOUNT_ID}/mb-archive`]: false }));
expect(one[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-archive' }],
});
const two = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], {
[`${ACCOUNT_ID}/mb-inbox`]: false,
[`${ACCOUNT_ID}/mb-archive`]: false,
// Gone from the server: must not end up in the filter.
[`${ACCOUNT_ID}/mb-deleted`]: false,
}));
expect(two[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'mb-archive' }, { inMailbox: 'mb-inbox' }] },
],
});
});
it('silences the account in inbox-only mode when its Inbox is muted', async () => {
const client = makeClient([], { emailPushCapability: true });
const config = await buildEmailPushConfig(client, true, mutes([], { [`${SHARED_ACCOUNT_ID}/mb-shared-inbox`]: true }));
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config[ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
});
it('fails instead of dropping the mutes when the mailboxes cannot be read', 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, false, mutes([ACCOUNT_ID]))).rejects.toThrow(/JMAP down/);
});
});
describe('disableWebPush', () => {
it('destroys every subscription this device owns, not just the recorded id', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+103
View File
@@ -0,0 +1,103 @@
/**
* Which accounts and folders stay silent when new mail arrives (#1169).
*
* An account is either muted or not. A folder can follow its account or carry
* its own setting, and its own setting always wins: mute one folder in a
* normal account, or mute a whole account and unmute the folders that still
* matter. Ids are the server's JMAP account and mailbox ids, which is what the
* push filter needs; shared-account mailboxes must pass their originalId.
*/
export interface NotificationMutes {
/** JMAP account ids muted as a whole. */
accounts: string[];
/** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */
mailboxes: Record<string, boolean>;
}
export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} };
function mailboxKey(accountId: string, mailboxId: string): string {
return `${accountId}/${mailboxId}`;
}
/** Repairs a value from storage or another build into a usable mutes object. */
export function normalizeNotificationMutes(value: unknown): NotificationMutes {
if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES;
const raw = value as { accounts?: unknown; mailboxes?: unknown };
const accounts = Array.isArray(raw.accounts)
? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '')
: [];
const mailboxes: Record<string, boolean> = {};
if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) {
for (const [key, muted] of Object.entries(raw.mailboxes as Record<string, unknown>)) {
if (typeof muted === 'boolean') mailboxes[key] = muted;
}
}
return { accounts, mailboxes };
}
export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean {
return mutes.accounts.includes(accountId);
}
/** The folder's own setting, or undefined when it follows its account. */
export function mailboxMuteOverride(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
): boolean | undefined {
return mutes.mailboxes[mailboxKey(accountId, mailboxId)];
}
export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean {
return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId);
}
/** Mailbox ids of one account with their own setting, split by that setting. */
export function mailboxOverridesForAccount(
mutes: NotificationMutes,
accountId: string,
): { muted: string[]; unmuted: string[] } {
const prefix = mailboxKey(accountId, '');
const muted: string[] = [];
const unmuted: string[] = [];
for (const [key, value] of Object.entries(mutes.mailboxes)) {
if (!key.startsWith(prefix)) continue;
(value ? muted : unmuted).push(key.slice(prefix.length));
}
return { muted, unmuted };
}
export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes {
const accounts = mutes.accounts.filter((id) => id !== accountId);
if (muted) accounts.push(accountId);
return { ...mutes, accounts };
}
/**
* Mutes or unmutes one folder. Setting it back to what the account says drops
* the folder's own setting, so a folder muted and unmuted again follows the
* account later instead of staying pinned to "unmuted".
*/
export function setMailboxMuted(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
muted: boolean,
): NotificationMutes {
const mailboxes = { ...mutes.mailboxes };
const key = mailboxKey(accountId, mailboxId);
if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key];
else mailboxes[key] = muted;
return { ...mutes, mailboxes };
}
/** Drops every folder setting of one account so all its folders follow the account again. */
export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes {
const prefix = mailboxKey(accountId, '');
const mailboxes = Object.fromEntries(
Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)),
);
return { ...mutes, mailboxes };
}
+56 -11
View File
@@ -8,6 +8,13 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { IS_LITE } from '@/lib/lite';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
isMailboxMuted,
mailboxOverridesForAccount,
type NotificationMutes,
} from '@/lib/notification-mutes';
// Per-account keys: a single browser may be signed in to multiple accounts,
// each with its own JMAP PushSubscription and its own relay record. Scoping
@@ -86,10 +93,16 @@ export function serverSupportsEmailPush(client: IJMAPClient): boolean {
* account with no Inbox we can see (someone shared a single folder with us)
* gets a filter that never matches: leaving it out of the map would make the
* server fall back to unfiltered pushes for that account.
*
* Muted accounts and folders (#1169) are cut out of the same filter. In the
* default mode a muted folder joins Junk in the "not only in" list; a muted
* account only lets through mail in the folders the user unmuted again, or
* nothing at all. In inbox-only mode a muted Inbox silences the account.
*/
export async function buildEmailPushConfig(
client: IJMAPClient,
inboxOnly: boolean,
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
): Promise<Record<string, EmailPushConfig>> {
const primary = client.getAccountId();
// Every account that has any mailbox gets a filter entry - a secondary/shared
@@ -98,10 +111,13 @@ export async function buildEmailPushConfig(
const accountIds = new Set<string>([primary]);
const junkByAccount = new Map<string, string[]>();
const inboxByAccount = new Map<string, string>();
const mailboxIdsByAccount = new Map<string, Set<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
// mailbox" below, and with mutes it would drop them all and unmute
// everything, so let the real error surface; otherwise the default mode can
// still degrade to keyword-only filtering on a transient miss.
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
const mailboxes = inboxOnly || hasMutes
? await client.getAllMailboxes()
: await client.getAllMailboxes().catch(() => [] as Mailbox[]);
for (const m of mailboxes) {
@@ -109,17 +125,24 @@ export async function buildEmailPushConfig(
accountIds.add(accountId);
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
const serverId = m.originalId ?? m.id;
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
ids.add(serverId);
mailboxIdsByAccount.set(accountId, ids);
if (m.role === 'junk') {
const junk = junkByAccount.get(accountId) ?? [];
junk.push(m.originalId ?? m.id);
junk.push(serverId);
junkByAccount.set(accountId, junk);
}
if (m.role === 'inbox') inboxByAccount.set(accountId, m.originalId ?? m.id);
if (m.role === 'inbox') inboxByAccount.set(accountId, serverId);
}
const config: Record<string, EmailPushConfig> = {};
for (const accountId of accountIds) {
const junkIds = junkByAccount.get(accountId) ?? [];
// Settings for folders that no longer exist are left out.
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
const overrides = mailboxOverridesForAccount(mutes, accountId);
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
if (inboxOnly) {
const inboxId = inboxByAccount.get(accountId);
@@ -128,9 +151,19 @@ export async function buildEmailPushConfig(
if (!inboxId && accountId === primary) {
throw new Error(`No Inbox mailbox found for account ${accountId}; cannot build an inbox-only push filter`);
}
conditions.push(inboxId ? { inMailbox: inboxId } : { hasKeyword: '$junk' });
} else if (junkIds.length > 0) {
conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId)
? { inMailbox: inboxId }
: NEVER_MATCH);
} else if (isAccountMuted(mutes, accountId)) {
const allowed = overrides.unmuted
.filter((id) => existing.has(id) && !junkIds.includes(id))
.sort();
if (allowed.length === 0) conditions.push(NEVER_MATCH);
else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] });
else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) });
} else {
const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]);
if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() });
}
config[accountId] = {
// Always the operator form: that's how the server echoes it back, so a
@@ -143,6 +176,10 @@ export async function buildEmailPushConfig(
return config;
}
// Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND
// never matches: the account stays in the map but never pushes.
const NEVER_MATCH = { hasKeyword: '$junk' } as const;
function normalizeEmailPush(value: unknown): string {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
@@ -181,6 +218,8 @@ export interface EnableWebPushParams {
// pushNotifyInboxOnly. Required, not optional: it changes the server-side
// delivery filter, so every caller must decide explicitly.
inboxOnly: boolean;
// Muted accounts and folders, settings-store's notificationMutes (#1169).
mutes?: NotificationMutes;
}
export interface EnableWebPushResult {
@@ -513,7 +552,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, params.inboxOnly)
? await buildEmailPushConfig(params.client, params.inboxOnly, params.mutes)
: null;
const subIdKey = subscriptionIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
@@ -731,6 +770,10 @@ export interface ResyncWebPushParams {
accountLabel?: string;
// Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams.
inboxOnly: boolean;
mutes?: NotificationMutes;
// Skip the once-a-day limit: the filter itself changed (a folder was
// muted) and has to reach the server now.
force?: boolean;
}
/**
@@ -749,7 +792,7 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
}
if (!accountId) return false;
const last = lastResyncAt.get(accountId);
if (last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
if (!params.force && last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
try {
// The browser may have lost its endpoint while our saved opt-in and
// server registration survived. Recreate it through the normal enable
@@ -763,6 +806,7 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
relayBaseUrl: params.relayBaseUrl,
accountLabel: params.accountLabel,
inboxOnly: params.inboxOnly,
mutes: params.mutes,
});
return true;
} catch {
@@ -804,7 +848,7 @@ export interface BulkWebPushResult {
export async function enableWebPushForAccounts(
targets: BulkWebPushTarget[],
// inboxOnly mirrors settings-store's pushNotifyInboxOnly, as for a single account.
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean },
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean; mutes?: NotificationMutes },
): Promise<BulkWebPushResult> {
const result: BulkWebPushResult = { enabled: [], failed: [] };
let browserRefusal: unknown;
@@ -820,6 +864,7 @@ export async function enableWebPushForAccounts(
accountLabel: target.accountLabel,
forceRecreate: options.forceRecreate,
inboxOnly: options.inboxOnly,
mutes: options.mutes,
});
result.enabled.push(target.accountId);
} catch (error) {
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "أقل",
"shared": "مشترك",
"shared_account_hidden": "تم الإخفاء من الشريط الجانبي. أعد إظهاره من الإعدادات › التخطيط.",
"notifications_muted": "الإشعارات مكتومة",
"mail": "البريد",
"nav_label": "التنقل",
"add_app": "التطبيقات",
@@ -2549,6 +2550,11 @@
"delete_folder": "حذف المجلد",
"refresh": "تحديث",
"hide_shared_account": "إخفاء من الشريط الجانبي",
"mute_notifications": "كتم الإشعارات",
"unmute_notifications": "إلغاء كتم الإشعارات",
"mute_account_notifications": "كتم إشعارات هذا الحساب",
"unmute_account_notifications": "إلغاء كتم إشعارات هذا الحساب",
"reset_folder_notifications": "إعادة تعيين إشعارات المجلدات",
"mark_all_confirm_title": "تمييز جميع المجلدات كمقروءة",
"mark_all_confirm_message": "هل تريد تمييز كل رسالة غير مقروءة في حسابك الشخصي كمقروءة؟",
"delete_confirm_title": "حذف المجلد",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Menys",
"shared": "Compartit",
"shared_account_hidden": "Amagat de la barra lateral. Torna'l a mostrar a Configuració › Disposició.",
"notifications_muted": "Notificacions silenciades",
"mail": "Correu",
"nav_label": "Navegació",
"add_app": "Aplicacions",
@@ -2549,6 +2550,11 @@
"delete_folder": "Suprimeix la carpeta",
"refresh": "Actualitza",
"hide_shared_account": "Amaga de la barra lateral",
"mute_notifications": "Silencia les notificacions",
"unmute_notifications": "Activa les notificacions",
"mute_account_notifications": "Silencia les notificacions d'aquest compte",
"unmute_account_notifications": "Activa les notificacions d'aquest compte",
"reset_folder_notifications": "Restableix les notificacions de les carpetes",
"mark_all_confirm_title": "Marca totes les carpetes com a llegides",
"mark_all_confirm_message": "Voleu marcar tots els missatges no llegits del vostre compte personal com a llegits?",
"delete_confirm_title": "Suprimeix la carpeta",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Méně",
"shared": "Sdílené",
"shared_account_hidden": "Skryto z postranního panelu. Znovu zobrazíte v Nastavení › Vzhled.",
"notifications_muted": "Oznámení ztlumena",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace",
@@ -2549,6 +2550,11 @@
"delete_folder": "Smazat složku",
"refresh": "Obnovit",
"hide_shared_account": "Skrýt z postranního panelu",
"mute_notifications": "Ztlumit oznámení",
"unmute_notifications": "Zapnout oznámení",
"mute_account_notifications": "Ztlumit oznámení tohoto účtu",
"unmute_account_notifications": "Zapnout oznámení tohoto účtu",
"reset_folder_notifications": "Obnovit oznámení složek",
"mark_all_confirm_title": "Označit všechny složky jako přečtené",
"mark_all_confirm_message": "Označit všechny nepřečtené zprávy ve vašem osobním účtu jako přečtené?",
"delete_confirm_title": "Smazat složku",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Mindre",
"shared": "Delt",
"shared_account_hidden": "Skjult i sidepanelet. Vis den igen under Indstillinger › Layout.",
"notifications_muted": "Notifikationer slået fra",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
@@ -2549,6 +2550,11 @@
"delete_folder": "Slet mappe",
"refresh": "Opdatér",
"hide_shared_account": "Skjul i sidepanelet",
"mute_notifications": "Slå notifikationer fra",
"unmute_notifications": "Slå notifikationer til",
"mute_account_notifications": "Slå notifikationer fra for denne konto",
"unmute_account_notifications": "Slå notifikationer til for denne konto",
"reset_folder_notifications": "Nulstil mappernes notifikationer",
"mark_all_confirm_title": "Markér alle mapper som læst",
"mark_all_confirm_message": "Markér alle ulæste beskeder på din personlige konto som læst?",
"delete_confirm_title": "Slet mappe",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Apps",
"shared": "Geteilt",
"shared_account_hidden": "In der Seitenleiste ausgeblendet. Unter Einstellungen › Layout wieder einblenden.",
"notifications_muted": "Benachrichtigungen stummgeschaltet",
"scheduled": "Geplant",
"unified_all_unread": "Alle ungelesenen",
"unified_all_starred": "Alle markierten",
@@ -2549,6 +2550,11 @@
"delete_folder": "Ordner löschen",
"refresh": "Aktualisieren",
"hide_shared_account": "In Seitenleiste ausblenden",
"mute_notifications": "Benachrichtigungen stummschalten",
"unmute_notifications": "Benachrichtigungen einschalten",
"mute_account_notifications": "Benachrichtigungen für dieses Konto stummschalten",
"unmute_account_notifications": "Benachrichtigungen für dieses Konto einschalten",
"reset_folder_notifications": "Ordner-Benachrichtigungen zurücksetzen",
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Less",
"shared": "Shared",
"shared_account_hidden": "Hidden from the sidebar. Turn it back on in Settings › Layout.",
"notifications_muted": "Notifications muted",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
@@ -2549,6 +2550,11 @@
"delete_folder": "Delete folder",
"refresh": "Refresh",
"hide_shared_account": "Hide from sidebar",
"mute_notifications": "Mute notifications",
"unmute_notifications": "Unmute notifications",
"mute_account_notifications": "Mute notifications for this account",
"unmute_account_notifications": "Unmute notifications for this account",
"reset_folder_notifications": "Reset folder notification settings",
"mark_all_confirm_title": "Mark all folders as read",
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
"delete_confirm_title": "Delete folder",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Apps",
"shared": "Compartido",
"shared_account_hidden": "Oculta en la barra lateral. Vuelve a mostrarla en Configuración › Diseño.",
"notifications_muted": "Notificaciones silenciadas",
"scheduled": "Programados",
"unified_all_unread": "Todo no leído",
"unified_all_starred": "Todo destacado",
@@ -2549,6 +2550,11 @@
"delete_folder": "Eliminar carpeta",
"refresh": "Actualizar",
"hide_shared_account": "Ocultar de la barra lateral",
"mute_notifications": "Silenciar notificaciones",
"unmute_notifications": "Activar notificaciones",
"mute_account_notifications": "Silenciar notificaciones de esta cuenta",
"unmute_account_notifications": "Activar notificaciones de esta cuenta",
"reset_folder_notifications": "Restablecer notificaciones de carpetas",
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
"delete_confirm_title": "Eliminar carpeta",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "کمتر",
"shared": "اشتراکی",
"shared_account_hidden": "از نوار کناری پنهان شد. از تنظیمات › چیدمان دوباره نمایش دهید.",
"notifications_muted": "اعلان‌ها بی‌صدا",
"mail": "ایمیل",
"nav_label": "ناوبری",
"add_app": "برنامه‌ها",
@@ -2549,6 +2550,11 @@
"delete_folder": "حذف پوشه",
"refresh": "بازنشانی",
"hide_shared_account": "پنهان کردن از نوار کناری",
"mute_notifications": "بی‌صدا کردن اعلان‌ها",
"unmute_notifications": "فعال کردن اعلان‌ها",
"mute_account_notifications": "بی‌صدا کردن اعلان‌های این حساب",
"unmute_account_notifications": "فعال کردن اعلان‌های این حساب",
"reset_folder_notifications": "بازنشانی اعلان‌های پوشه‌ها",
"mark_all_confirm_title": "علامت‌گذاری همه",
"mark_all_confirm_message": "همه پیام‌های خوانده نشده علامت‌گذاری شوند؟",
"delete_confirm_title": "حذف پوشه",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Apps",
"shared": "Partagé",
"shared_account_hidden": "Masqué de la barre latérale. Réaffichez-le dans Paramètres › Mise en page.",
"notifications_muted": "Notifications désactivées",
"scheduled": "Planifiés",
"unified_all_unread": "Tous les non lus",
"unified_all_starred": "Tous les favoris",
@@ -2549,6 +2550,11 @@
"delete_folder": "Supprimer le dossier",
"refresh": "Actualiser",
"hide_shared_account": "Masquer de la barre latérale",
"mute_notifications": "Désactiver les notifications",
"unmute_notifications": "Réactiver les notifications",
"mute_account_notifications": "Désactiver les notifications de ce compte",
"unmute_account_notifications": "Réactiver les notifications de ce compte",
"reset_folder_notifications": "Réinitialiser les notifications des dossiers",
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
"delete_confirm_title": "Supprimer le dossier",
+6
View File
@@ -148,6 +148,7 @@
"all_accounts_loading": "טוען חשבונות: {loaded} מתוך {total}",
"shared": "משותף",
"shared_account_hidden": "הוסתר מסרגל הצד. אפשר להציג שוב בהגדרות › פריסה.",
"notifications_muted": "ההתראות מושתקות",
"scheduled": "מתוכננים",
"unified_all_unread": "הכל שלא נקראו",
"unified_all_starred": "הכל מסומנים",
@@ -3861,6 +3862,11 @@
"delete_folder": "מחק תיקייה",
"refresh": "רענן",
"hide_shared_account": "הסתרה מסרגל הצד",
"mute_notifications": "השתקת התראות",
"unmute_notifications": "ביטול השתקת התראות",
"mute_account_notifications": "השתקת התראות לחשבון זה",
"unmute_account_notifications": "ביטול השתקת התראות לחשבון זה",
"reset_folder_notifications": "איפוס התראות התיקיות",
"mark_all_confirm_title": "סמן את כל התיקיות כקרויות",
"mark_all_confirm_message": "סמן כל הודעה שלא קרויה בחשבון האישי שלך כקרויה?",
"delete_confirm_title": "מחק תיקייה",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Kevesebb",
"shared": "Megosztott",
"shared_account_hidden": "Elrejtve az oldalsávról. A Beállítások › Elrendezés alatt jelenítheted meg újra.",
"notifications_muted": "Értesítések némítva",
"mail": "Levelek",
"nav_label": "Navigáció",
"add_app": "Alkalmazások",
@@ -2549,6 +2550,11 @@
"delete_folder": "Mappa törlése",
"refresh": "Frissítés",
"hide_shared_account": "Elrejtés az oldalsávról",
"mute_notifications": "Értesítések némítása",
"unmute_notifications": "Értesítések bekapcsolása",
"mute_account_notifications": "Fiók értesítéseinek némítása",
"unmute_account_notifications": "Fiók értesítéseinek bekapcsolása",
"reset_folder_notifications": "Mappák értesítési beállításainak visszaállítása",
"mark_all_confirm_title": "Összes mappa olvasottnak jelölése",
"mark_all_confirm_message": "Minden olvasatlan üzenetet olvasottnak jelölsz a személyes fiókodban?",
"delete_confirm_title": "Mappa törlése",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "App",
"shared": "Condiviso",
"shared_account_hidden": "Nascosto dalla barra laterale. Puoi mostrarlo di nuovo in Impostazioni › Layout.",
"notifications_muted": "Notifiche disattivate",
"scheduled": "Programmate",
"unified_all_unread": "Tutti i non letti",
"unified_all_starred": "Tutti gli speciali",
@@ -2549,6 +2550,11 @@
"delete_folder": "Elimina cartella",
"refresh": "Aggiorna",
"hide_shared_account": "Nascondi dalla barra laterale",
"mute_notifications": "Disattiva notifiche",
"unmute_notifications": "Riattiva notifiche",
"mute_account_notifications": "Disattiva notifiche per questo account",
"unmute_account_notifications": "Riattiva notifiche per questo account",
"reset_folder_notifications": "Ripristina notifiche delle cartelle",
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
"delete_confirm_title": "Elimina cartella",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "アプリ",
"shared": "共有",
"shared_account_hidden": "サイドバーから非表示にしました。設定 › レイアウトで再表示できます。",
"notifications_muted": "通知ミュート中",
"scheduled": "予約済み",
"unified_all_unread": "すべての未読",
"unified_all_starred": "すべてのスター付き",
@@ -2549,6 +2550,11 @@
"delete_folder": "フォルダーを削除",
"refresh": "更新",
"hide_shared_account": "サイドバーから非表示",
"mute_notifications": "通知をミュート",
"unmute_notifications": "通知のミュートを解除",
"mute_account_notifications": "このアカウントの通知をミュート",
"unmute_account_notifications": "このアカウントの通知のミュートを解除",
"reset_folder_notifications": "フォルダーの通知設定をリセット",
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
"delete_confirm_title": "フォルダーを削除",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "앱",
"shared": "공유됨",
"shared_account_hidden": "사이드바에서 숨겼습니다. 설정 › 레이아웃에서 다시 표시할 수 있습니다.",
"notifications_muted": "알림 꺼짐",
"scheduled": "예약됨",
"unified_all_unread": "모든 읽지 않음",
"unified_all_starred": "모든 별표",
@@ -2549,6 +2550,11 @@
"delete_folder": "폴더 삭제",
"refresh": "새로 고침",
"hide_shared_account": "사이드바에서 숨기기",
"mute_notifications": "알림 끄기",
"unmute_notifications": "알림 켜기",
"mute_account_notifications": "이 계정의 알림 끄기",
"unmute_account_notifications": "이 계정의 알림 켜기",
"reset_folder_notifications": "폴더 알림 설정 초기화",
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
"delete_confirm_title": "폴더 삭제",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Lietotnes",
"shared": "Koplietots",
"shared_account_hidden": "Paslēpts no sānjoslas. Atkal parādiet sadaļā Iestatījumi › Izkārtojums.",
"notifications_muted": "Paziņojumi izslēgti",
"scheduled": "Ieplānots",
"unified_all_unread": "Visi nelasītie",
"unified_all_starred": "Visi ar zvaigzni",
@@ -2549,6 +2550,11 @@
"delete_folder": "Dzēst mapi",
"refresh": "Atjaunināt",
"hide_shared_account": "Paslēpt no sānjoslas",
"mute_notifications": "Izslēgt paziņojumus",
"unmute_notifications": "Ieslēgt paziņojumus",
"mute_account_notifications": "Izslēgt šī konta paziņojumus",
"unmute_account_notifications": "Ieslēgt šī konta paziņojumus",
"reset_folder_notifications": "Atiestatīt mapju paziņojumus",
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
"delete_confirm_title": "Dzēst mapi",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Бага",
"shared": "Хуваалцсан",
"shared_account_hidden": "Хажуугийн самбараас нуусан. Тохиргоо › Байршил хэсгээс дахин харуулна уу.",
"notifications_muted": "Мэдэгдэл хаалттай",
"mail": "Мэйл",
"nav_label": "Навигаци",
"add_app": "Програмууд",
@@ -2549,6 +2550,11 @@
"delete_folder": "Хавтас устгах",
"refresh": "Сэргээх",
"hide_shared_account": "Хажуугийн самбараас нуух",
"mute_notifications": "Мэдэгдлийг хаах",
"unmute_notifications": "Мэдэгдлийг нээх",
"mute_account_notifications": "Энэ бүртгэлийн мэдэгдлийг хаах",
"unmute_account_notifications": "Энэ бүртгэлийн мэдэгдлийг нээх",
"reset_folder_notifications": "Хавтасны мэдэгдлийн тохиргоог сэргээх",
"mark_all_confirm_title": "Бүх хавтсыг уншсан гэж тэмдэглэх",
"mark_all_confirm_message": "Хувийн бүртгэлийнхээ уншаагүй мессеж бүрийг уншсан гэж тэмдэглэх үү?",
"delete_confirm_title": "Хавтас устгах",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Mindre",
"shared": "Delt",
"shared_account_hidden": "Skjult fra sidepanelet. Vis den igjen under Innstillinger › Oppsett.",
"notifications_muted": "Varsler dempet",
"mail": "E-post",
"nav_label": "Navigasjon",
"add_app": "Apper",
@@ -2549,6 +2550,11 @@
"delete_folder": "Slett mappe",
"refresh": "Oppdater",
"hide_shared_account": "Skjul fra sidepanelet",
"mute_notifications": "Demp varsler",
"unmute_notifications": "Slå på varsler",
"mute_account_notifications": "Demp varsler for denne kontoen",
"unmute_account_notifications": "Slå på varsler for denne kontoen",
"reset_folder_notifications": "Tilbakestill varsler for mapper",
"mark_all_confirm_title": "Merk alle mapper som lest",
"mark_all_confirm_message": "Vil du merke alle uleste meldinger på den personlige kontoen din som lest?",
"delete_confirm_title": "Slett mappe",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Apps",
"shared": "Gedeeld",
"shared_account_hidden": "Verborgen in de zijbalk. Zet het weer aan via Instellingen › Indeling.",
"notifications_muted": "Meldingen gedempt",
"scheduled": "Gepland",
"unified_all_unread": "Alle ongelezen",
"unified_all_starred": "Alle met ster",
@@ -2549,6 +2550,11 @@
"delete_folder": "Map verwijderen",
"refresh": "Vernieuwen",
"hide_shared_account": "Verbergen in zijbalk",
"mute_notifications": "Meldingen dempen",
"unmute_notifications": "Meldingen inschakelen",
"mute_account_notifications": "Meldingen voor dit account dempen",
"unmute_account_notifications": "Meldingen voor dit account inschakelen",
"reset_folder_notifications": "Meldingen van mappen herstellen",
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
"delete_confirm_title": "Map verwijderen",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Aplikacje",
"shared": "Udostępnione",
"shared_account_hidden": "Ukryto na pasku bocznym. Przywrócisz je w Ustawienia › Układ.",
"notifications_muted": "Powiadomienia wyciszone",
"scheduled": "Zaplanowane",
"unified_all_unread": "Wszystkie nieprzeczytane",
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
@@ -2549,6 +2550,11 @@
"delete_folder": "Usuń folder",
"refresh": "Odśwież",
"hide_shared_account": "Ukryj na pasku bocznym",
"mute_notifications": "Wycisz powiadomienia",
"unmute_notifications": "Włącz powiadomienia",
"mute_account_notifications": "Wycisz powiadomienia tego konta",
"unmute_account_notifications": "Włącz powiadomienia tego konta",
"reset_folder_notifications": "Resetuj powiadomienia folderów",
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
"delete_confirm_title": "Usuń folder",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Apps",
"shared": "Compartilhado",
"shared_account_hidden": "Oculto da barra lateral. Mostre de novo em Configurações › Layout.",
"notifications_muted": "Notificações silenciadas",
"scheduled": "Agendados",
"unified_all_unread": "Tudo não lido",
"unified_all_starred": "Tudo com estrela",
@@ -2549,6 +2550,11 @@
"delete_folder": "Excluir pasta",
"refresh": "Atualizar",
"hide_shared_account": "Ocultar da barra lateral",
"mute_notifications": "Silenciar notificações",
"unmute_notifications": "Reativar notificações",
"mute_account_notifications": "Silenciar notificações desta conta",
"unmute_account_notifications": "Reativar notificações desta conta",
"reset_folder_notifications": "Redefinir notificações das pastas",
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
"delete_confirm_title": "Excluir pasta",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Mai puține",
"shared": "Partajat",
"shared_account_hidden": "Ascuns din bara laterală. Îl poți afișa din nou în Setări › Aspect.",
"notifications_muted": "Notificări dezactivate",
"mail": "E-mail",
"nav_label": "Navigare",
"add_app": "Aplicații",
@@ -2549,6 +2550,11 @@
"delete_folder": "Șterge dosarul",
"refresh": "Reîmprospătează",
"hide_shared_account": "Ascunde din bara laterală",
"mute_notifications": "Dezactivează notificările",
"unmute_notifications": "Activează notificările",
"mute_account_notifications": "Dezactivează notificările pentru acest cont",
"unmute_account_notifications": "Activează notificările pentru acest cont",
"reset_folder_notifications": "Resetează notificările dosarelor",
"mark_all_confirm_title": "Marcați toate folderele ca citite",
"mark_all_confirm_message": "Doriți să marcați toate mesajele necitite din contul dvs. personal ca fiind citite?",
"delete_confirm_title": "Șterge dosarul",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "Приложения",
"shared": "Общие",
"shared_account_hidden": "Скрыто с боковой панели. Вернуть можно в Настройки › Макет.",
"notifications_muted": "Уведомления отключены",
"scheduled": "Запланировано",
"unified_all_unread": "Все непрочитанные",
"unified_all_starred": "Все помеченные",
@@ -2549,6 +2550,11 @@
"delete_folder": "Удалить папку",
"refresh": "Обновить",
"hide_shared_account": "Скрыть с боковой панели",
"mute_notifications": "Отключить уведомления",
"unmute_notifications": "Включить уведомления",
"mute_account_notifications": "Отключить уведомления для этого аккаунта",
"unmute_account_notifications": "Включить уведомления для этого аккаунта",
"reset_folder_notifications": "Сбросить уведомления папок",
"mark_all_confirm_title": "Отметить все папки как прочитанные",
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
"delete_confirm_title": "Удалить папку",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Menej",
"shared": "Zdieľané",
"shared_account_hidden": "Skryté z bočného panela. Znova zobrazíte v Nastavenia › Rozloženie.",
"notifications_muted": "Upozornenia stlmené",
"mail": "Pošta",
"nav_label": "Navigácia",
"add_app": "Aplikácie",
@@ -2549,6 +2550,11 @@
"delete_folder": "Zmazať priečinok",
"refresh": "Obnoviť",
"hide_shared_account": "Skryť z bočného panela",
"mute_notifications": "Stlmiť upozornenia",
"unmute_notifications": "Zapnúť upozornenia",
"mute_account_notifications": "Stlmiť upozornenia tohto účtu",
"unmute_account_notifications": "Zapnúť upozornenia tohto účtu",
"reset_folder_notifications": "Obnoviť upozornenia priečinkov",
"mark_all_confirm_title": "Označiť všetky priečinky ako prečítané",
"mark_all_confirm_message": "Označiť každú neprečítanú správu vo vašom osobnom účte ako prečítanú?",
"delete_confirm_title": "Zmazať priečinok",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "Daha az",
"shared": "Paylaşılan",
"shared_account_hidden": "Kenar çubuğundan gizlendi. Ayarlar › Düzen bölümünden yeniden gösterebilirsiniz.",
"notifications_muted": "Bildirimler sessizde",
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar",
@@ -2549,6 +2550,11 @@
"delete_folder": "Klasörü sil",
"refresh": "Yenile",
"hide_shared_account": "Kenar çubuğundan gizle",
"mute_notifications": "Bildirimleri sessize al",
"unmute_notifications": "Bildirimleri aç",
"mute_account_notifications": "Bu hesabın bildirimlerini sessize al",
"unmute_account_notifications": "Bu hesabın bildirimlerini aç",
"reset_folder_notifications": "Klasör bildirim ayarlarını sıfırla",
"mark_all_confirm_title": "Tüm klasörleri okundu olarak işaretle",
"mark_all_confirm_message": "Kişisel hesabınızdaki tüm okunmamış iletiler okundu olarak işaretlensin mi?",
"delete_confirm_title": "Klasörü sil",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "програми",
"shared": "Спільні",
"shared_account_hidden": "Приховано з бічної панелі. Повернути можна в Налаштування › Макет.",
"notifications_muted": "Сповіщення вимкнено",
"scheduled": "Заплановано",
"unified_all_unread": "Усі непрочитані",
"unified_all_starred": "Усі позначені",
@@ -2549,6 +2550,11 @@
"delete_folder": "Видалити папку",
"refresh": "Оновити",
"hide_shared_account": "Приховати з бічної панелі",
"mute_notifications": "Вимкнути сповіщення",
"unmute_notifications": "Увімкнути сповіщення",
"mute_account_notifications": "Вимкнути сповіщення для цього облікового запису",
"unmute_account_notifications": "Увімкнути сповіщення для цього облікового запису",
"reset_folder_notifications": "Скинути сповіщення папок",
"mark_all_confirm_title": "Позначити всі папки як прочитані",
"mark_all_confirm_message": "Позначити всі непрочитані повідомлення у вашому особистому акаунті як прочитані?",
"delete_confirm_title": "Видалити папку",
+6
View File
@@ -146,6 +146,7 @@
"show_fewer": "收合",
"shared": "共用",
"shared_account_hidden": "已從側邊欄隱藏。可在 設定 › 版面配置 中重新顯示。",
"notifications_muted": "通知已靜音",
"mail": "郵件",
"nav_label": "導覽",
"add_app": "應用程式",
@@ -2549,6 +2550,11 @@
"delete_folder": "刪除信件匣",
"refresh": "重新整理",
"hide_shared_account": "從側邊欄隱藏",
"mute_notifications": "靜音通知",
"unmute_notifications": "取消靜音通知",
"mute_account_notifications": "靜音此帳號的通知",
"unmute_account_notifications": "取消靜音此帳號的通知",
"reset_folder_notifications": "重設資料夾通知設定",
"mark_all_confirm_title": "將所有信件匣標示為已讀",
"mark_all_confirm_message": "要將您個人帳號中的所有未讀郵件標示為已讀嗎?",
"delete_confirm_title": "刪除信件匣",
+6
View File
@@ -149,6 +149,7 @@
"add_app": "应用",
"shared": "共享",
"shared_account_hidden": "已从侧边栏隐藏。可在 设置 › 布局 中重新显示。",
"notifications_muted": "通知已静音",
"scheduled": "已计划",
"unified_all_unread": "全部未读",
"unified_all_starred": "全部加星标",
@@ -2549,6 +2550,11 @@
"delete_folder": "删除文件夹",
"refresh": "刷新",
"hide_shared_account": "从侧边栏隐藏",
"mute_notifications": "静音通知",
"unmute_notifications": "取消静音通知",
"mute_account_notifications": "静音此账户的通知",
"unmute_account_notifications": "取消静音此账户的通知",
"reset_folder_notifications": "重置文件夹通知设置",
"mark_all_confirm_title": "将所有文件夹标记为已读",
"mark_all_confirm_message": "将您个人账户中的所有未读邮件标记为已读?",
"delete_confirm_title": "删除文件夹",
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useSettingsStore } from '../settings-store';
import { useAuthStore } from '../auth-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// #1169: new mail in a muted Inbox, or an Inbox of a muted account, does not
// raise the in-app new-mail notification (its sound).
const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox;
function email(id: string, receivedAt: string): Email {
return {
id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: {},
subject: id, receivedAt, from: [{ email: 'sender@example.com' }], to: [],
preview: '', size: 1, hasAttachment: false,
} as Email;
}
function client(rows: Email[]) {
return {
getEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false, state: 's' })),
getThreads: vi.fn(async () => []),
getAccountId: () => 'me',
} as unknown as IJMAPClient;
}
async function arrive() {
const old = email('old', '2026-10-01T00:00:00Z');
useEmailStore.setState({ emails: [old], totalEmails: 1 });
await useEmailStore.getState().refreshCurrentMailbox(client([email('new', '2026-10-15T00:00:00Z'), old]));
return useEmailStore.getState().newEmailNotification?.id ?? null;
}
describe('new-mail notification in a muted Inbox (#1169)', () => {
beforeEach(() => {
useAuthStore.setState(useAuthStore.getInitialState());
useSettingsStore.setState({
emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
notificationMutes: { accounts: [], mailboxes: {} },
});
useEmailStore.setState({
...useEmailStore.getInitialState(),
selectedMailbox: 'inbox', mailboxes: [inbox],
});
});
it('notifies for new Inbox mail by default', async () => {
expect(await arrive()).toBe('new');
});
it('stays quiet when the Inbox is muted', async () => {
useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'me/inbox': true } } });
expect(await arrive()).toBeNull();
});
it('stays quiet when the account is muted, unless the Inbox is unmuted again', async () => {
useSettingsStore.setState({ notificationMutes: { accounts: ['me'], mailboxes: {} } });
expect(await arrive()).toBeNull();
useEmailStore.setState({ newEmailNotification: null });
useSettingsStore.setState({ notificationMutes: { accounts: ['me'], mailboxes: { 'me/inbox': false } } });
expect(await arrive()).toBe('new');
});
});
+7 -1
View File
@@ -5,6 +5,7 @@ import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interfac
import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet";
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
import { isMailboxMuted } from "@/lib/notification-mutes";
import { useCalendarStore } from "@/stores/calendar-store";
import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
@@ -5237,7 +5238,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
!currentEmails.some(e => e.id === newFirst.id) &&
(!keepOpenThread || insertedCount > 0)
) {
get().handleNewEmailNotification(newFirst);
// A muted Inbox (or account) stays quiet in the app too (#1169).
const accountId = mailbox.accountId || resolveActionClient(client).getAccountId();
const muted = isMailboxMuted(
useSettingsStore.getState().notificationMutes, accountId, mailbox.originalId || mailbox.id,
);
if (!muted) get().handleNewEmailNotification(newFirst);
}
// Merge the refreshed first page with the existing loaded emails.
+10
View File
@@ -4,6 +4,7 @@ import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store';
import type { EmailTemplate } from '@/lib/template-types';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes';
import { apiFetch } from '@/lib/browser-navigation';
import { settingsHooks } from '@/lib/plugin-hooks';
import { generateAccountId } from '@/lib/account-utils';
@@ -551,6 +552,8 @@ interface SettingsState {
notificationSoundChoice: NotificationSoundChoice;
/** Web Push only fires for mail that lands in the Inbox; Sieve-filed mail stays silent. */
pushNotifyInboxOnly: boolean;
/** Accounts and folders that never notify (#1169); see lib/notification-mutes. */
notificationMutes: NotificationMutes;
/** Chosen Web Push relay URL. Empty = the admin-configured default. */
pushRelayUrl: string;
@@ -817,6 +820,7 @@ const DEFAULT_SETTINGS = {
emailNotificationSound: true,
notificationSoundChoice: 'default' as NotificationSoundChoice,
pushNotifyInboxOnly: false,
notificationMutes: EMPTY_NOTIFICATION_MUTES,
pushRelayUrl: '',
// Protocol Handlers
@@ -1044,6 +1048,7 @@ export const useSettingsStore = create<SettingsState>()(
emailNotificationSound: state.emailNotificationSound,
notificationSoundChoice: state.notificationSoundChoice,
pushNotifyInboxOnly: state.pushNotifyInboxOnly,
notificationMutes: state.notificationMutes,
pushRelayUrl: state.pushRelayUrl,
protocolOpenMode: state.protocolOpenMode,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
@@ -1196,6 +1201,10 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'hiddenSharedMailAccounts' && !isPlainRecord(settings[key])) {
return;
}
if (key === 'notificationMutes') {
put(key, { notificationMutes: normalizeNotificationMutes(settings[key]) });
return;
}
// The list order is sent to the server as a sort array, so an
// unknown criterion from a newer/older client must never get
// through (an unsupportedSort refusal empties the folder).
@@ -1528,6 +1537,7 @@ export const useSettingsStore = create<SettingsState>()(
if (!isPlainRecord(state.hiddenSharedMailAccounts)) {
state.hiddenSharedMailAccounts = {};
}
state.notificationMutes = normalizeNotificationMutes(state.notificationMutes);
state.messageListOrder = sanitizeSortLevels(state.messageListOrder);
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
state.messageListOrderScope = 'inbox';