diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index fd71589b1..b9f0e4d20 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -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 }, + }, 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(); + await advancePromptDelay(); + mocks.resyncWebPush.mockClear(); + + // An equal object from a settings sync is no change. + mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} }; + rerender(); + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(mocks.resyncWebPush).not.toHaveBeenCalled(); + + const muted = { accounts: ["account-2"], mailboxes: {} }; + mocks.settingsState.notificationMutes = muted; + rerender(); + 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(); diff --git a/components/layout/__tests__/sidebar-notification-mutes.test.tsx b/components/layout/__tests__/sidebar-notification-mutes.test.tsx new file mode 100644 index 000000000..68cebd5d3 --- /dev/null +++ b/components/layout/__tests__/sidebar-notification-mutes.test.tsx @@ -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 = {}) => ({ + 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(); + 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(); + + 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(); + + fireEvent.contextMenu(row('renovate')); + expect(screen.queryByTestId('mailbox-mute')).toBeNull(); + }); +}); diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 015590e9b..bb2f8d34a 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -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 ( + <> + + handleAction(() => notifications.setAccountMuted(target.accountId, !accountMuted))} + testId="mailbox-mute-account" + /> + {notifications.hasFolderSettings(target.accountId) && ( + handleAction(() => notifications.resetFolderSettings(target.accountId))} + testId="mailbox-reset-folder-mutes" + /> + )} + + ); + })()} {target.accountId && onHideSharedAccount && ( <> @@ -231,6 +273,17 @@ export function MailboxContextMenu({ /> )} + {notifications && (() => { + const muted = notifications.isMailboxMuted(mailbox); + return ( + handleAction(() => notifications.setMailboxMuted(mailbox, !muted))} + testId="mailbox-mute" + /> + ); + })()} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 4a951bd25..4852587a9 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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 && ( <> {shortenedLabel} + {muted && ( + + )} 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(() => { + 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 (
setRailHovered(true) : undefined} @@ -1718,6 +1773,7 @@ function SidebarImpl({ onImportEmail={onImportEmail} onRefresh={onRefreshMailboxes} onHideSharedAccount={hideSharedAccount} + notifications={notificationActions} />
); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index fae24ee61..a26c58774 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -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 | null = null; @@ -244,6 +279,7 @@ export function PushNotificationPrompt() { relayBaseUrl, accountLabel: username ?? undefined, inboxOnly: pushNotifyInboxOnly, + mutes: useSettingsStore.getState().notificationMutes, }); setShowPrompt(false); } catch (err) { diff --git a/components/settings/multi-account-push.tsx b/components/settings/multi-account-push.tsx index 9df3bfa4b..0110fe2cb 100644 --- a/components/settings/multi-account-push.tsx +++ b/components/settings/multi-account-push.tsx @@ -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 }) diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 488c42c29..0e9aae22e 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -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) => { diff --git a/lib/__tests__/notification-mutes.test.ts b/lib/__tests__/notification-mutes.test.ts new file mode 100644 index 000000000..db2295274 --- /dev/null +++ b/lib/__tests__/notification-mutes.test.ts @@ -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 } }); + }); +}); diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 94cbdf5a9..48d2d7b99 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -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 = {}) => ({ 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); diff --git a/lib/notification-mutes.ts b/lib/notification-mutes.ts new file mode 100644 index 000000000..35acad153 --- /dev/null +++ b/lib/notification-mutes.ts @@ -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; +} + +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 = {}; + if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) { + for (const [key, muted] of Object.entries(raw.mailboxes as Record)) { + 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 }; +} diff --git a/lib/web-push.ts b/lib/web-push.ts index 4614b79ec..3efa0a20c 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -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> { 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([primary]); const junkByAccount = new Map(); const inboxByAccount = new Map(); + const mailboxIdsByAccount = new Map>(); // 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 ":" id; // the server only knows the original. + const serverId = m.originalId ?? m.id; + const ids = mailboxIdsByAccount.get(accountId) ?? new Set(); + 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 = {}; 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(); + const overrides = mailboxOverridesForAccount(mutes, accountId); const conditions: Record[] = [{ 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 { 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) { diff --git a/locales/ar/common.json b/locales/ar/common.json index 968049efc..9f90c6c4e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "حذف المجلد", diff --git a/locales/ca/common.json b/locales/ca/common.json index eccc0aa81..3c1579919 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index f899dbddb..6dcf726c3 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index fbbf0ce1d..e1ad038fe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 3a594ea72..2ec14d56f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 4e9de0c46..1f343151d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 3fa407970..451b491f7 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 7f48db283..ec27d7830 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "حذف پوشه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8a455eee5..28d34564f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 989f495c9..99c51e1ad 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "מחק תיקייה", diff --git a/locales/hu/common.json b/locales/hu/common.json index e194011f2..4b394e3da 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index b105b1967..f44ddadff 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index a6f5508ee..18a3dd5d0 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "フォルダーを削除", diff --git a/locales/ko/common.json b/locales/ko/common.json index eb0f59305..768350435 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "폴더 삭제", diff --git a/locales/lv/common.json b/locales/lv/common.json index 34ae3d488..43694b519 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 439d91890..674429870 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Хавтас устгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 4b4258b96..f5c9516cb 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6185127f7..6c6f02656 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index e65a28bad..5aba9c438 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 29cf6f142..33636b6a6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index d0910b0ed..73e4d11f8 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 51fc6cb43..754f17e6a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Удалить папку", diff --git a/locales/sk/common.json b/locales/sk/common.json index bf789b23d..72603df9e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index be75c33a0..ae8163d85 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index aab213403..329ead394 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Видалити папку", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5dcdfd143..800c9b426 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "刪除信件匣", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6aeb9a007..70bba3984 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "删除文件夹", diff --git a/stores/__tests__/email-store-muted-inbox.test.ts b/stores/__tests__/email-store-muted-inbox.test.ts new file mode 100644 index 000000000..b1117ae2a --- /dev/null +++ b/stores/__tests__/email-store-muted-inbox.test.ts @@ -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'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index cabd5ecfc..1f2f03c6d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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((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. diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 99ce6ba2b..4b952f1a3 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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()( 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()( 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()( 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';