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

This commit is contained in:
Linus Rath
2026-10-19 13:11:12 +02:00
parent 083ec958a0
commit 5f5ffdaddb
11 changed files with 430 additions and 12 deletions
+38
View File
@@ -39,6 +39,9 @@ import {
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -684,6 +687,41 @@ export default function App() {
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify(notificationMutes);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, 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, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
+2
View File
@@ -996,6 +996,8 @@
"email": {
"app_icon_badge": "Unread count on app icon",
"app_icon_badge_desc": "Show the inbox unread count as a badge on the app icon.",
"mute_account": "Mute this account",
"mute_account_desc": "No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.",
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
},
"push": {
+1
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
+76 -3
View File
@@ -9,7 +9,7 @@ import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -40,6 +40,11 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
type NotificationMutes,
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -157,6 +162,8 @@ interface SidebarRowProps {
showTotal: boolean;
hasChildren: boolean;
isExpanded: boolean;
/** New mail here does not notify (#78). */
muted?: boolean;
onPress: () => void;
onLongPress?: () => void;
onToggleExpand: () => void;
@@ -165,7 +172,7 @@ interface SidebarRowProps {
function SidebarRow({
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread,
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -215,6 +222,11 @@ function SidebarRow({
>
{label}
</Text>
{muted && (
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
<BellOff size={12} color={c.textMuted} />
</View>
)}
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
</Pressable>
);
@@ -334,6 +346,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -459,6 +473,41 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
id: mb.originalId ?? mb.id,
accountId: mb.isShared ? mb.accountId : undefined,
});
// Notification mutes (#78). Muting only takes effect through the server's
// emailPush filter, so the actions show only where that exists. The
// settings are keyed by the server's ids: a folder of the own account may
// carry no accountId, so the client's stands in.
let primaryJmapAccountId: string | null = null;
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
const muteActionsForAccount = (accountId: string): SheetAction[] => {
const muted = isAccountMuted(notificationMutes, accountId);
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
const actions: SheetAction[] = [{
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
}];
if (overrides.muted.length + overrides.unmuted.length > 0) {
actions.push({
key: 'reset-mutes',
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
icon: RotateCcw,
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
});
}
return actions;
};
const openOwnAccountSheet = () => {
if (!canMute) return;
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
};
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
setBusy(true);
try {
@@ -499,6 +548,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
if (subtree.length > 1) {
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
}
if (canMute) {
const accountId = muteAccountOf(mb.accountId);
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
actions.push({
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
});
}
if (!mb.isShared) {
actions.push({
key: 'all',
@@ -622,6 +683,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
},
];
if (canMute) actions.push(...muteActionsForAccount(accountId));
if (canCreate) {
actions.push({
key: 'new',
@@ -1036,7 +1098,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
<Pressable
style={styles.sectionHeader}
onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}
// The own account's notification mutes (#78), like the webmail's
// menu on this header.
onLongPress={canMute ? openOwnAccountSheet : undefined}
delayLongPress={350}
>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -1090,6 +1159,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
muted={canMute && (node.isAccountNode
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
@@ -1458,6 +1530,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
fontSize: 13,
lineHeight: 18,
+2
View File
@@ -29,6 +29,7 @@ const G = {
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
@@ -190,6 +191,7 @@ export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
@@ -27,6 +27,7 @@ import {
PushSetupError,
readPushAccountIds,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
setupPushNotifications,
getStoredPushTransport,
@@ -42,6 +43,8 @@ import {
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
import { canBadgeAppIcon } from '../../lib/app-badge';
import { isAccountMuted, setAccountMuted } from '../../lib/notification-mutes';
import { jmapClient } from '../../api/jmap-client';
type PushStatus =
| { kind: 'idle' }
@@ -78,6 +81,14 @@ export function NotificationSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// Muting works through the server's emailPush filter (#78): offered only
// where it exists, for the account this screen shows. Single folders are
// muted from the sidebar.
let ownJmapAccountId: string | null = null;
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -467,6 +478,22 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
description={t(
'settings.notifications.email.mute_account_desc',
'No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.',
)}
>
<ToggleSwitch
checked={accountMuted}
onChange={(v) => {
if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v));
}}
/>
</SettingItem>
)}
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_NOTIFICATION_MUTES,
isMailboxMuted,
mailboxOverridesForAccount,
normalizeNotificationMutes,
resetMailboxMutes,
setAccountMuted,
setMailboxMuted,
} from '../notification-mutes';
describe('notification mutes (#78)', () => {
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 } });
});
});
@@ -19,6 +19,7 @@ vi.mock('../../api/email', () => ({
}));
import { getMailboxes, getSharedMailboxes } from '../../api/email';
import { EMPTY_NOTIFICATION_MUTES, type NotificationMutes } from '../notification-mutes';
import { buildEmailPushConfig } from '../push-notifications';
import { provideLoadedMailboxes } from '../mailbox-source';
import { generateAccountId } from '../account-utils';
@@ -65,3 +66,72 @@ describe('push filter folders', () => {
});
});
});
// Muted accounts and folders (#78) are left out by the server-side filter.
describe('push filter with muted folders and accounts (#78)', () => {
const folders = [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'renovate', accountId: 'jmap-primary' },
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
];
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}): NotificationMutes => ({ accounts, mailboxes });
const junkOnly = (ids: string[]) => ({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ids }] });
beforeEach(() => {
provideLoadedMailboxes(async () => folders as never);
});
it('changes nothing without mutes', async () => {
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES);
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const config = await buildEmailPushConfig(mutes([], { 'jmap-primary/renovate': true, 'jmap-primary/gone': true }));
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk', 'renovate']));
// Another account's folders are untouched.
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('silences a muted account entirely', async () => {
const config = await buildEmailPushConfig(mutes(['team']));
expect(config.team.filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
});
it('lets a muted account notify for the folders unmuted again', async () => {
const one = await buildEmailPushConfig(mutes(['jmap-primary'], { 'jmap-primary/renovate': false }));
expect(one['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'renovate' }],
});
const two = await buildEmailPushConfig(mutes(['jmap-primary'], {
'jmap-primary/renovate': false,
'jmap-primary/inbox': false,
// Gone from the server: must not end up in the filter.
'jmap-primary/deleted': false,
// Unmuting Junk does not bring spam back.
'jmap-primary/junk': false,
}));
expect(two['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'inbox' }, { inMailbox: 'renovate' }] },
],
});
});
it('fails instead of dropping the mutes when the folders cannot be read', async () => {
provideLoadedMailboxes(async () => null);
vi.mocked(getMailboxes).mockRejectedValueOnce(new Error('JMAP down'));
await expect(buildEmailPushConfig(mutes(['jmap-primary']))).rejects.toThrow(/JMAP down/);
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* Which accounts and folders stay silent when new mail arrives (#78,
* webmail #1169). Port of the webmail's lib/notification-mutes.ts - keep in sync.
*
* 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 };
}
+50 -9
View File
@@ -14,6 +14,13 @@ import { JMAPMethodError, requireMethodResult } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
mailboxOverridesForAccount,
type NotificationMutes,
} from './notification-mutes';
import { useSettingsStore } from '../stores/settings-store';
import { t } from '../stores/locale-store';
import {
authorityOfType,
@@ -296,32 +303,62 @@ export function serverSupportsEmailPush(): boolean {
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
* to keyword-only filtering rather than letting everything through.
*
* Muted accounts and folders (#78) are cut out of the same filter: 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.
*/
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
export async function buildEmailPushConfig(
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
// The mail store loads the same folders at the same moment (sign-in,
// start): reuse its list rather than a second Mailbox/get of our own.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null;
const all = loaded ?? [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
];
// With mutes a swallowed fetch failure would look like "no such folder"
// and unmute everything, so let the real error surface; otherwise the
// filter can still degrade to keyword-only on a transient miss.
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
const all = loaded ?? (hasMutes
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
: [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
]);
for (const m of all) {
const accountId = m.accountId || primary;
const junk = junkByAccount.get(accountId) ?? [];
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
if (m.role === 'junk') junk.push(m.originalId ?? m.id);
const serverId = m.originalId ?? m.id;
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
ids.add(serverId);
mailboxIdsByAccount.set(accountId, ids);
const junk = junkByAccount.get(accountId) ?? [];
if (m.role === 'junk') junk.push(serverId);
junkByAccount.set(accountId, junk);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
// 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 (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
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
// stored config compares equal to a freshly built one.
@@ -333,6 +370,10 @@ export async function buildEmailPushConfig(): Promise<Record<string, EmailPushCo
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);
@@ -980,7 +1021,7 @@ async function setupPushNotificationsInner(
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
const refusedBefore = await readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush()
? withoutAccounts(await buildEmailPushConfig(), refusedBefore)
? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore)
: null;
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
+7
View File
@@ -4,6 +4,7 @@ import type { Identity } from '../api/types';
import { getIdentities as fetchIdentities } from '../api/identity';
import { jmapClient } from '../api/jmap-client';
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
@@ -260,6 +261,9 @@ interface PersistedSettings {
// Notifications. Sound/vibration live in the Android notification channel
// (the OS owns them after channel creation), so there are no sound keys.
emailNotificationsEnabled: boolean;
// Accounts and folders whose new mail does not notify (#78), keyed by the
// server's JMAP ids; see lib/notification-mutes.
notificationMutes: NotificationMutes;
calendarNotificationsEnabled: boolean;
calendarInvitationParsingEnabled: boolean;
// Inbox unread count on the app icon (iOS, see lib/app-badge). Same
@@ -413,6 +417,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filesShowHiddenFiles: false,
emailNotificationsEnabled: true,
notificationMutes: EMPTY_NOTIFICATION_MUTES,
calendarNotificationsEnabled: true,
calendarInvitationParsingEnabled: true,
appIconUnreadBadge: true,
@@ -605,6 +610,8 @@ export function mergeWithDefaults(parsed: Partial<PersistedSettings>): Persisted
if (validator && !validator(v)) continue;
if (k === 'bottomQuickActions') {
out[k] = normalizeBottomQuickActions(v);
} else if (k === 'notificationMutes') {
out[k] = normalizeNotificationMutes(v);
} else if (Array.isArray(def)) {
if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') {