feat: create filter rules from a message's sender, domain or mailing list

This commit is contained in:
Linus Rath
2026-10-29 03:41:18 +01:00
parent 0fd14a837a
commit d546d50923
9 changed files with 923 additions and 12 deletions
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const client = vi.hoisted(() => ({
isConnected: true,
username: 'me@example.org',
serverUrl: 'https://mail.example.org',
}));
vi.mock('../jmap-client', () => ({ jmapClient: client }));
vi.mock('../sieve', () => ({
createSieveScript: vi.fn(async () => ({ id: 'new', name: 'filters', blobId: 'b', isActive: true })),
getSieveAccountId: vi.fn(() => 'own'),
getSieveCapabilities: vi.fn(() => ({
implementation: 't', maxSizeScript: 1e6, notificationMethods: [], externalLists: [],
sieveExtensions: ['fileinto', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'mailbox', 'mailboxid'],
})),
getSieveScriptContent: vi.fn(),
getSieveScripts: vi.fn(),
updateSieveScript: vi.fn(async () => undefined),
}));
import * as sieve from '../sieve';
import { FiltersLoginChangedError, OpaqueFiltersError, updateAccountFilters } from '../sieve-rules';
import { generateScript } from '../../lib/sieve/generator';
import { parseScript } from '../../lib/sieve/parser';
import type { FilterRule } from '../../lib/sieve/types';
const api = vi.mocked(sieve);
const rule = (id: string): FilterRule => ({
id, name: id, enabled: true, matchType: 'all', stopProcessing: true,
conditions: [{ field: 'from', comparator: 'address_is', value: `${id}@example.com` }],
actions: [{ type: 'mark_read' }],
});
beforeEach(() => {
vi.clearAllMocks();
client.username = 'me@example.org';
});
describe('updateAccountFilters', () => {
it('adds the rule to the script the server has and keeps its forwarding and audience', async () => {
const stored = generateScript([rule('old')], undefined, {
includeVacation: true,
vacationForward: { enabled: true, to: 'backup@example.org', keepCopy: true },
vacationAudience: { only: 'internal', domains: ['example.org'] },
});
api.getSieveScripts.mockResolvedValue([
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
{ id: 'v', name: 'vacation', blobId: 'bv', isActive: false },
]);
api.getSieveScriptContent.mockResolvedValue(stored);
expect(await updateAccountFilters((rules) => [rule('new'), ...rules], 'own')).toBe(true);
const [id, content, activate, accountId] = api.updateSieveScript.mock.calls[0];
expect([id, activate, accountId]).toEqual(['s1', true, 'own']);
const written = parseScript(content as string);
expect(written.rules.map((r) => r.id)).toEqual(['new', 'old']);
expect(written.includeVacation).toBe(true);
expect(written.vacationForward?.to).toBe('backup@example.org');
expect(written.vacationAudience?.only).toBe('internal');
});
it('creates the filters script when there is none', async () => {
api.getSieveScripts.mockResolvedValue([]);
await updateAccountFilters(() => [rule('new')], 'own');
expect(api.createSieveScript).toHaveBeenCalledWith('filters', expect.stringContaining('# Rule: new'), true, 'own');
});
it('refuses a hand-edited script', async () => {
api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
// Metadata from a version this build does not know reads as opaque.
api.getSieveScriptContent.mockResolvedValue('/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */\nkeep;\n');
await expect(updateAccountFilters(() => [rule('new')], 'own')).rejects.toBeInstanceOf(OpaqueFiltersError);
expect(api.updateSieveScript).not.toHaveBeenCalled();
});
it('writes nothing when the rule changes nothing', async () => {
api.getSieveScripts.mockResolvedValue([]);
expect(await updateAccountFilters(() => null, 'own')).toBe(false);
expect(api.createSieveScript).not.toHaveBeenCalled();
});
// The client serves one login; ids repeat across servers.
it('writes nothing once another login took over', async () => {
api.getSieveScripts.mockImplementation(async () => {
client.username = 'other@example.net';
return [];
});
await expect(updateAccountFilters(() => [rule('new')], 'own')).rejects.toBeInstanceOf(FiltersLoginChangedError);
expect(api.createSieveScript).not.toHaveBeenCalled();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { jmapClient } from './jmap-client';
import {
createSieveScript,
getSieveAccountId,
getSieveCapabilities,
getSieveScriptContent,
getSieveScripts,
updateSieveScript,
} from './sieve';
import { parseScript, type ParseResult } from '../lib/sieve/parser';
import { generateScript, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
import type { FilterRule } from '../lib/sieve/types';
import { servedLoginOf } from '../lib/draft-login';
// One account's filters script, read and written on its own (webmail
// lib/filters/account-filters.ts). A rule made from a message goes through
// here, not through the filter store: the store holds whichever account the
// Filters settings show, and its copy may be stale.
/** The script was edited by hand and the builder cannot read it back. */
export class OpaqueFiltersError extends Error {
constructor() {
super('The filters script was edited by hand');
this.name = 'OpaqueFiltersError';
}
}
/** Another login took over while the rule was being saved; nothing was written. */
export class FiltersLoginChangedError extends Error {
constructor() {
super('The account changed while the rule was being saved');
this.name = 'FiltersLoginChangedError';
}
}
/**
* Read the account's script from the server, let `modify` change its rules,
* and write the result back as the active script, keeping the vacation
* include, its forwarding and audience and the external rules. `modify`
* returns null when there is nothing to write. A hand-edited script is
* refused: it is never rewritten from a rule. Resolves to whether it wrote.
*/
export async function updateAccountFilters(
modify: (rules: FilterRule[]) => FilterRule[] | null,
accountId: string = getSieveAccountId(),
): Promise<boolean> {
// The client serves one login at a time; a switch while the script is read
// would write these rules into the other login's account.
const login = servedLoginOf(jmapClient);
const capabilities = getSieveCapabilities(accountId);
const all = await getSieveScripts(accountId);
const scripts = all.filter((s) => s.name !== VACATION_SCRIPT_NAME);
const vacationActive =
all.some((s) => s.name === VACATION_SCRIPT_NAME && s.isActive) &&
!!capabilities?.sieveExtensions?.includes('include');
const script = scripts.find((s) => s.isActive) || scripts[0] || null;
const parsed: ParseResult = script
? parseScript(await getSieveScriptContent(script.blobId, accountId))
: { rules: [], isOpaque: false, externalRequires: [] };
if (parsed.isOpaque) throw new OpaqueFiltersError();
const rules = modify(parsed.rules);
if (!rules) return false;
const content = generateScript(rules, parsed.vacation, {
externalRequires: parsed.externalRequires,
includeVacation: !!parsed.includeVacation || vacationActive,
vacationForward: parsed.vacationForward,
vacationAudience: parsed.vacationAudience,
extensions: capabilities?.sieveExtensions,
});
if (servedLoginOf(jmapClient) !== login) throw new FiltersLoginChangedError();
if (script) await updateSieveScript(script.id, content, true, accountId);
else await createSieveScript('filters', content, true, accountId);
return true;
}
@@ -49,6 +49,7 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/device-sync/status.ts': [],
'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'],
'filters/SieveEditorSheet.tsx': [],
'filters/QuickRuleSheet.tsx': [],
};
describe('translated settings panes', () => {
+6 -2
View File
@@ -65,7 +65,10 @@ function makeEmptyAction(): FilterAction {
interface FilterRuleModalProps {
visible: boolean;
/** The rule being edited. */
rule?: FilterRule;
/** A new rule that starts from these values ("Create rule..." on a message). */
initialRule?: FilterRule;
mailboxes: Mailbox[];
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
periodsSupported?: boolean;
@@ -74,13 +77,14 @@ interface FilterRuleModalProps {
}
export function FilterRuleModal({
visible, rule, mailboxes, periodsSupported = false, onSave, onClose,
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
}: FilterRuleModalProps) {
const c = useColors();
const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const keywords = useKeywordsStore((s) => s.keywords);
const isEdit = !!rule;
const isEdit = !!editedRule;
const rule = editedRule ?? initialRule;
const [name, setName] = useState(rule?.name || '');
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
+237
View File
@@ -0,0 +1,237 @@
import React from 'react';
import { ArrowRight, Ban, MailOpen, Plus, Tag } from '../icons';
import { ActionSheet, type ActionSheetItem } from '../email/ActionSheet';
import { MoveSheet } from '../MoveSheet';
import { FilterRuleModal } from './FilterRuleModal';
import type { Email, Mailbox } from '../../api/types';
import { getSieveAccountId, getSieveCapabilities } from '../../api/sieve';
import { OpaqueFiltersError, updateAccountFilters } from '../../api/sieve-rules';
import { supportsPeriods } from '../../lib/sieve/period';
import {
applyQuickRule,
buildPrefillRule,
buildPresetRule,
findJunkMailbox,
insertRuleAtTop,
normalizeAddress,
quickRuleSubject,
ruleTargetMailboxes,
toRuleMailbox,
type QuickRuleOutcome,
type QuickRulePreset,
} from '../../lib/sieve/quick-rules';
import type { FilterRule } from '../../lib/sieve/types';
import { generateUUID } from '../../lib/uuid';
import { useFilterStore } from '../../stores/filter-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import { toast } from '../../stores/toast-store';
import { useColors } from '../../theme/colors';
type Step =
| { kind: 'menu' }
| { kind: 'folder'; preset: 'move_sender' | 'move_domain' | 'move_list' }
| { kind: 'tag' }
| { kind: 'editor'; rule: FilterRule };
interface QuickRuleSheetProps {
visible: boolean;
/** The message the rule is made from; it belongs to the user's own account. */
email: Email | null;
/** The own account's folders. */
mailboxes: Mailbox[];
onClose: () => void;
}
/**
* "Rules" on a message (webmail components/email/rules-menu.tsx): one tap
* makes a rule from its sender, the sender's domain or its List-Id, or opens
* the rule editor prefilled. The rule goes to the top of the account's
* filters, or into an existing rule that does the same for other senders.
*/
export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRuleSheetProps) {
const c = useColors();
const t = useLocaleStore((s) => s.t);
const identities = useSettingsStore((s) => s.identities);
const keywords = useKeywordsStore((s) => s.keywords);
const [step, setStep] = React.useState<Step>({ kind: 'menu' });
React.useEffect(() => {
if (visible) setStep({ kind: 'menu' });
}, [visible]);
const ownAddresses = React.useMemo(
() => new Set(identities.map((i) => normalizeAddress(i.email))),
[identities],
);
const subject = React.useMemo(
() => quickRuleSubject(email ? [email] : [], ownAddresses),
[email, ownAddresses],
);
const targets = React.useMemo(() => ruleTargetMailboxes(mailboxes), [mailboxes]);
const junk = React.useMemo(() => findJunkMailbox(mailboxes), [mailboxes]);
const periodsSupported = supportsPeriods(getSieveCapabilities()?.sieveExtensions);
const [sender] = subject.senders;
const senderLabel = subject.senders.length === 1
? (sender.name || sender.email)
: t('context_menu.rules.senders_count', '{count, plural, one {# sender} other {# senders}}', { count: subject.senders.length });
const finish = () => {
setStep({ kind: 'menu' });
onClose();
};
/** Write `modify`'s rules into the own account's script and say how it went. */
const write = async (modify: (rules: FilterRule[]) => FilterRule[] | null) => {
const accountId = getSieveAccountId();
try {
await updateAccountFilters(modify, accountId);
} catch (err) {
if (err instanceof OpaqueFiltersError) {
toast.error(t('context_menu.rules.opaque_hint', 'Your filters were edited by hand. Open Filters settings'));
} else {
toast.error(t('notifications.filters_save_failed', 'Failed to save filters'), err instanceof Error ? err.message : undefined);
}
return false;
}
// The Filters settings show what was written when they show this account.
const store = useFilterStore.getState();
if (store.selectedAccountId === accountId) void store.fetchFilters(accountId);
return true;
};
const savePreset = async (preset: QuickRulePreset) => {
finish();
const candidate = buildPresetRule(preset, subject, t, generateUUID());
let outcome: QuickRuleOutcome | null = null;
const ok = await write((rules) => {
outcome = applyQuickRule(rules, candidate);
return outcome.kind === 'covered' ? null : outcome.rules;
});
if (!ok || !outcome) return;
const result = outcome as QuickRuleOutcome;
if (result.kind === 'covered') {
toast.info(t('notifications.rule_already_covered', 'Already covered by rule “{name}”', { name: result.rule.name }));
} else if (result.kind === 'merged') {
toast.success(t('notifications.rule_merged', 'Added to rule “{name}”', { name: result.rule.name }));
} else {
toast.success(t('notifications.rule_created', 'Rule created'), result.rule.name);
}
};
const saveEditorRule = async (rule: FilterRule) => {
finish();
// As built in the editor: at the top, never merged into another rule.
if (await write((rules) => insertRuleAtTop(rules, rule))) {
toast.success(t('notifications.rule_created', 'Rule created'), rule.name);
}
};
const items: ActionSheetItem[] = [];
if (subject.senders.length > 0) {
items.push({
key: 'move_sender',
label: t('context_menu.rules.move_from', 'Always move messages from {sender}', { sender: senderLabel }),
icon: <ArrowRight size={18} color={c.textSecondary} />,
onPress: () => setStep({ kind: 'folder', preset: 'move_sender' }),
});
}
if (subject.domain) {
items.push({
key: 'move_domain',
label: t('context_menu.rules.move_from_domain', 'Always move messages from @{domain}', { domain: subject.domain }),
icon: <ArrowRight size={18} color={c.textSecondary} />,
onPress: () => setStep({ kind: 'folder', preset: 'move_domain' }),
});
}
if (subject.listId) {
items.push({
key: 'move_list',
label: t('context_menu.rules.move_from_list', 'Always move messages from this list'),
icon: <ArrowRight size={18} color={c.textSecondary} />,
onPress: () => setStep({ kind: 'folder', preset: 'move_list' }),
});
}
if (subject.senders.length > 0) {
items.push({
key: 'mark_read',
label: t('context_menu.rules.mark_read_from', 'Always mark as read from {sender}', { sender: senderLabel }),
icon: <MailOpen size={18} color={c.textSecondary} />,
onPress: () => void savePreset({ kind: 'mark_read' }),
});
if (keywords.length > 0) {
items.push({
key: 'tag',
label: t('context_menu.rules.tag_with', 'Always tag with'),
icon: <Tag size={18} color={c.textSecondary} />,
onPress: () => setStep({ kind: 'tag' }),
});
}
items.push({
key: 'block',
label: junk
? t('context_menu.rules.block', '{count, plural, one {Block sender} other {Block # senders}}', { count: subject.senders.length })
: t('context_menu.rules.no_junk', 'This account has no Junk folder'),
icon: <Ban size={18} color={junk ? c.error : c.textMuted} />,
destructive: !!junk,
disabled: !junk,
onPress: () => { if (junk) void savePreset({ kind: 'block', junk: toRuleMailbox(mailboxes, junk) }); },
});
}
items.push({
key: 'create',
label: t('context_menu.rules.create', 'Create rule…'),
icon: <Plus size={18} color={c.textSecondary} />,
onPress: () => setStep({ kind: 'editor', rule: buildPrefillRule(subject, t, generateUUID()) }),
});
const tagItems: ActionSheetItem[] = keywords.map((kw) => ({
key: kw.id,
label: kw.label,
icon: <Tag size={18} color={c.textSecondary} />,
onPress: () => void savePreset({ kind: 'tag', tagId: kw.id, tagName: kw.label }),
}));
const pickFolder = (id: string) => {
if (step.kind !== 'folder') return;
const mailbox = mailboxes.find((m) => m.id === id);
if (!mailbox) return;
void savePreset({ kind: step.preset, mailbox: toRuleMailbox(mailboxes, mailbox) });
};
return (
<>
<ActionSheet
visible={visible && step.kind === 'menu'}
title={t('context_menu.rules.title', 'Rules')}
subtitle={subject.senders.map((s) => s.email).join(', ') || undefined}
items={items}
onClose={finish}
/>
<MoveSheet
visible={visible && step.kind === 'folder'}
onClose={() => setStep({ kind: 'menu' })}
mailboxes={targets}
currentMailboxId={null}
onPick={pickFolder}
/>
<ActionSheet
visible={visible && step.kind === 'tag'}
title={t('context_menu.rules.tag_with', 'Always tag with')}
items={tagItems}
onClose={() => setStep({ kind: 'menu' })}
/>
<FilterRuleModal
visible={visible && step.kind === 'editor'}
initialRule={step.kind === 'editor' ? step.rule : undefined}
mailboxes={mailboxes}
periodsSupported={periodsSupported}
onSave={(rule) => void saveEditorRule(rule)}
onClose={finish}
/>
</>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import {
applyQuickRule,
buildPrefillRule,
buildPresetRule,
extractListId,
quickRuleSubject,
ruleTargetMailboxes,
toRuleMailbox,
} from '../quick-rules';
import { generateScript } from '../generator';
import type { Email, Mailbox } from '../../../api/types';
import type { FilterRule } from '../types';
const t = (_key: string, fallback: string, params?: Record<string, string | number>) =>
fallback.replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
const mailbox = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox =>
({ id, name, role: null, parentId: null, myRights: { mayAddItems: true }, ...extra }) as unknown as Mailbox;
const MAILBOXES = [
mailbox('inbox', 'Inbox', { role: 'inbox' }),
mailbox('news', 'Newsletters', { parentId: 'inbox' }),
mailbox('junk', 'Junk', { role: 'junk' }),
mailbox('drafts', 'Drafts', { role: 'drafts' }),
];
const email = {
from: [{ name: 'Anna', email: 'Anna@Acme.com' }],
headers: [{ name: 'List-Id', value: 'Weekly news\r\n <news.acme.com>' }],
} as Pick<Email, 'from' | 'headers'>;
describe('quick rules from a message', () => {
const subject = quickRuleSubject([email], new Set(['me@example.org']));
it('takes the sender, its domain and the List-Id from the message', () => {
expect(subject).toEqual({
senders: [{ email: 'anna@acme.com', name: 'Anna' }],
domain: 'acme.com',
listId: 'news.acme.com',
});
});
it('leaves the user\'s own address out', () => {
expect(quickRuleSubject([{ from: [{ email: 'me@example.org' }] } as Email], new Set(['me@example.org'])).senders).toEqual([]);
});
it('reads a List-Id without brackets whole and refuses one with spaces', () => {
expect(extractListId('list.example.org')).toBe('list.example.org');
expect(extractListId('two words')).toBeNull();
});
// The rules match the exact address or domain, so a save keeps them exact.
it('moves from the exact sender into the folder by id and path', () => {
const rule = buildPresetRule({ kind: 'move_sender', mailbox: toRuleMailbox(MAILBOXES, MAILBOXES[1]) }, subject, t, 'r1');
expect(rule).toMatchObject({
name: 'Move from anna@acme.com to Newsletters',
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
actions: [{ type: 'move', value: 'INBOX/Newsletters', mailboxId: 'news' }],
stopProcessing: true,
});
expect(generateScript([rule])).toContain('if address :is "From" "anna@acme.com" {');
});
it('moves a whole domain or list', () => {
const target = toRuleMailbox(MAILBOXES, MAILBOXES[1]);
expect(buildPresetRule({ kind: 'move_domain', mailbox: target }, subject, t, 'r').conditions)
.toEqual([{ field: 'from', comparator: 'domain_is', value: 'acme.com' }]);
expect(buildPresetRule({ kind: 'move_list', mailbox: target }, subject, t, 'r').conditions)
.toEqual([{ field: 'header', headerName: 'List-Id', comparator: 'contains', value: 'news.acme.com' }]);
});
it('blocks into Junk without the spam guard', () => {
const rule = buildPresetRule({ kind: 'block', junk: toRuleMailbox(MAILBOXES, MAILBOXES[2]) }, subject, t, 'r');
expect(rule.includeSpam).toBe(true);
expect(rule.actions).toEqual([{ type: 'move', value: 'Junk', mailboxId: 'junk' }]);
});
it('offers every folder that takes mail but Drafts', () => {
expect(ruleTargetMailboxes(MAILBOXES).map((m) => m.id)).toEqual(['inbox', 'news', 'junk']);
});
it('opens the editor on the senders with a move to pick', () => {
expect(buildPrefillRule(subject, t, 'p')).toMatchObject({
name: 'Mail from anna@acme.com',
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
actions: [{ type: 'move', value: '' }],
});
});
});
describe('applyQuickRule', () => {
const target = toRuleMailbox(MAILBOXES, MAILBOXES[1]);
const make = (address: string, id: string): FilterRule =>
buildPresetRule({ kind: 'move_sender', mailbox: target }, { senders: [{ email: address }], domain: null, listId: null }, t, id);
it('puts a new rule at the top', () => {
const other = make('x@y.z', 'old');
const outcome = applyQuickRule([{ ...other, actions: [{ type: 'mark_read' }] }], make('a@b.c', 'new'));
expect(outcome.kind).toBe('created');
if (outcome.kind !== 'covered') expect(outcome.rules[0].id).toBe('new');
});
it('adds the sender to a rule that already does the same', () => {
const outcome = applyQuickRule([make('x@y.z', 'old')], make('a@b.c', 'new'));
expect(outcome.kind).toBe('merged');
if (outcome.kind === 'merged') {
expect(outcome.rules).toHaveLength(1);
expect(outcome.rule.conditions[0].value).toEqual(['x@y.z', 'a@b.c']);
}
});
it('writes nothing when a rule covers the sender already', () => {
expect(applyQuickRule([make('A@b.c', 'old')], make('a@b.c', 'new')).kind).toBe('covered');
});
it('never merges into a rule with a period', () => {
const timed = { ...make('x@y.z', 'old'), activeUntil: '2030-01-01T00:00:00.000Z' };
expect(applyQuickRule([timed], make('a@b.c', 'new')).kind).toBe('created');
});
});
+369
View File
@@ -0,0 +1,369 @@
import type { Email, Mailbox } from '../../api/types';
import type { FilterAction, FilterCondition, FilterRule } from './types';
import { hasPeriod } from './period';
// Rules made from a message (webmail lib/filters/quick-rules.ts): "Always move
// messages from Anna to...", "Block sender", and the rule "Create rule..."
// opens with. Pure functions; the account plumbing is in api/sieve-rules.ts.
export interface RuleSender {
/** Lower-cased address. */
email: string;
name?: string;
}
/** A folder a rule files into: the JMAP id plus the Sieve path fallback. */
export interface RuleMailbox {
id: string;
path: string;
name: string;
}
export type QuickRulePreset =
| { kind: 'move_sender'; mailbox: RuleMailbox }
| { kind: 'move_domain'; mailbox: RuleMailbox }
| { kind: 'move_list'; mailbox: RuleMailbox }
| { kind: 'mark_read' }
| { kind: 'tag'; tagId: string; tagName: string }
| { kind: 'block'; junk: RuleMailbox };
/** What the presets are made from. */
export interface QuickRuleSubject {
senders: RuleSender[];
/** The one domain every sender shares. */
domain: string | null;
/** The one List-Id every message carries. */
listId: string | null;
}
/** The settings.filters translator, as the rule names use it. */
export type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string;
export function normalizeAddress(address: string | null | undefined): string {
return (address ?? '').trim().toLowerCase();
}
export function addressDomain(address: string): string | null {
const at = address.lastIndexOf('@');
if (at <= 0 || at === address.length - 1) return null;
return address.slice(at + 1).toLowerCase();
}
/**
* Every distinct sender of `emails`, compared case-insensitively, with the
* first display name seen for each. The user's own addresses are dropped: a
* rule against yourself would file your own replies away.
*/
export function collectSenders(emails: Array<Pick<Email, 'from'>>, ownAddresses: ReadonlySet<string>): RuleSender[] {
const byAddress = new Map<string, RuleSender>();
for (const email of emails) {
for (const from of email.from ?? []) {
const address = normalizeAddress(from.email);
if (!address || !address.includes('@') || ownAddresses.has(address)) continue;
const existing = byAddress.get(address);
const name = from.name?.trim() || undefined;
if (!existing) byAddress.set(address, { email: address, ...(name ? { name } : {}) });
else if (!existing.name && name) existing.name = name;
}
}
return [...byAddress.values()];
}
/** The domain every sender shares, or null when they differ (or there are none). */
export function sharedDomain(senders: RuleSender[]): string | null {
let domain: string | null = null;
for (const sender of senders) {
const d = addressDomain(sender.email);
if (!d) return null;
if (domain === null) domain = d;
else if (d !== domain) return null;
}
return domain;
}
/**
* The list identifier of a List-Id header (RFC 2919): the part inside the
* angle brackets, so "Weekly news <news.example.org>" gives
* "news.example.org". A header without brackets is taken whole.
*/
export function extractListId(raw: unknown): string | null {
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string') return null;
const unfolded = value.replace(/\r?\n[ \t]+/g, ' ').trim();
const bracketed = unfolded.match(/<([^<>]+)>\s*$/);
const id = (bracketed ? bracketed[1] : unfolded).trim();
if (!id || /\s/.test(id)) return null;
return id;
}
/** The List-Id of a message, from its raw header list. */
export function emailListId(email: Pick<Email, 'headers'>): string | null {
const header = email.headers?.find((h) => h.name.toLowerCase() === 'list-id');
return header ? extractListId(header.value) : null;
}
/** What the presets for `emails` are made from. */
export function quickRuleSubject(
emails: Array<Pick<Email, 'from' | 'headers'>>,
ownAddresses: ReadonlySet<string>,
): QuickRuleSubject {
const senders = collectSenders(emails, ownAddresses);
return { senders, domain: sharedDomain(senders), listId: sharedListId(emails.map(emailListId)) };
}
/** The List-Id every message has, or null when one lacks it or they differ. */
export function sharedListId(listIds: Array<string | null | undefined>): string | null {
let shared: string | null = null;
for (const id of listIds) {
if (!id) return null;
if (shared === null) shared = id;
else if (shared.toLowerCase() !== id.toLowerCase()) return null;
}
return shared;
}
/**
* The Sieve path of a folder, as the rule editor writes it: parent names
* joined with "/", with the inbox as the IMAP-canonical "INBOX".
*/
export function mailboxSievePath(mailboxes: Mailbox[], mailboxId: string): string {
const byId = new Map(mailboxes.map((m) => [m.id, m]));
const segments: string[] = [];
const seen = new Set<string>();
let current = byId.get(mailboxId);
while (current && !seen.has(current.id)) {
seen.add(current.id);
segments.unshift(current.role === 'inbox' ? 'INBOX' : current.name);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return segments.join('/');
}
export function toRuleMailbox(mailboxes: Mailbox[], mailbox: Mailbox): RuleMailbox {
return { id: mailbox.originalId || mailbox.id, path: mailboxSievePath(mailboxes, mailbox.id), name: mailbox.name };
}
/**
* Folders an "Always move" rule can file into: the account's own folders
* that accept mail, without Drafts. Unlike "Move to", the current folder and
* the Inbox stay in.
*/
export function ruleTargetMailboxes(mailboxes: Mailbox[]): Mailbox[] {
return mailboxes.filter((m) =>
!m.isShared && !m.id.startsWith('shared-') && m.role !== 'drafts' && m.myRights?.mayAddItems !== false);
}
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
return mailboxes.find((m) => !m.isShared && m.role === 'junk');
}
function senderValue(senders: RuleSender[]): string | string[] {
const addresses = senders.map((s) => s.email);
return addresses.length === 1 ? addresses[0] : addresses;
}
/** The condition "from one of these senders", matched on the exact address. */
export function senderCondition(senders: RuleSender[]): FilterCondition {
return { field: 'from', comparator: 'address_is', value: senderValue(senders) };
}
export function domainCondition(domain: string): FilterCondition {
return { field: 'from', comparator: 'domain_is', value: domain };
}
export function listIdCondition(listId: string): FilterCondition {
return { field: 'header', headerName: 'List-Id', comparator: 'contains', value: listId };
}
/** How rule names refer to the senders: the address, or "3 senders". */
export function senderNameFor(senders: RuleSender[], t: Translate): string {
return senders.length === 1
? senders[0].email
: t('settings.filters.quick_rule_names.senders', '{count, plural, one {# sender} other {# senders}}', { count: senders.length });
}
function moveAction(mailbox: RuleMailbox): FilterAction {
return { type: 'move', value: mailbox.path, mailboxId: mailbox.id };
}
/** The rule a one-click preset saves, named for what it does. */
export function buildPresetRule(
preset: QuickRulePreset,
subject: QuickRuleSubject,
t: Translate,
id: string,
): FilterRule {
const sender = senderNameFor(subject.senders, t);
const base = { id, enabled: true, matchType: 'all' as const, stopProcessing: true };
switch (preset.kind) {
case 'move_sender':
return {
...base,
name: t('settings.filters.quick_rule_names.move_sender', 'Move from {sender} to {folder}', { sender, folder: preset.mailbox.name }),
conditions: [senderCondition(subject.senders)],
actions: [moveAction(preset.mailbox)],
};
case 'move_domain':
return {
...base,
name: t('settings.filters.quick_rule_names.move_domain', 'Move from @{domain} to {folder}', { domain: subject.domain ?? '', folder: preset.mailbox.name }),
conditions: [domainCondition(subject.domain ?? '')],
actions: [moveAction(preset.mailbox)],
};
case 'move_list':
return {
...base,
name: t('settings.filters.quick_rule_names.move_list', 'Move list {list} to {folder}', { list: subject.listId ?? '', folder: preset.mailbox.name }),
conditions: [listIdCondition(subject.listId ?? '')],
actions: [moveAction(preset.mailbox)],
};
case 'mark_read':
return {
...base,
name: t('settings.filters.quick_rule_names.mark_read', 'Mark as read from {sender}', { sender }),
conditions: [senderCondition(subject.senders)],
actions: [{ type: 'mark_read' }],
};
case 'tag':
return {
...base,
name: t('settings.filters.quick_rule_names.tag', 'Tag as {tag} from {sender}', { sender, tag: preset.tagName }),
conditions: [senderCondition(subject.senders)],
actions: [{ type: 'add_label', value: preset.tagId }],
};
case 'block':
// The spam guard would skip exactly the mail this rule is for.
return {
...base,
name: t('settings.filters.quick_rule_names.block', 'Block {sender}', { sender }),
conditions: [senderCondition(subject.senders)],
actions: [moveAction(preset.junk)],
includeSpam: true,
};
}
}
/**
* The rule "Create rule..." opens with: From = the senders, one Move with no
* folder chosen yet, stop after it.
*/
export function buildPrefillRule(subject: QuickRuleSubject, t: Translate, id: string): FilterRule {
const hasSenders = subject.senders.length > 0;
return {
id,
name: hasSenders
? t('settings.filters.quick_rule_names.custom', 'Mail from {sender}', { sender: senderNameFor(subject.senders, t) })
: t('settings.filters.quick_rule_names.custom_plain', 'New rule'),
enabled: true,
matchType: 'all',
conditions: hasSenders ? [senderCondition(subject.senders)] : [],
actions: [{ type: 'move', value: '' }],
stopProcessing: true,
};
}
export function isBulwarkRule(rule: FilterRule): boolean {
return !rule.origin || rule.origin === 'bulwark';
}
/**
* New rules made from a message go first, so an older, broader rule does not
* catch the mail before them. The generator writes the vacation block and the
* external/opaque blocks where they always go, so the front of the list is
* the top of Bulwark's own section.
*/
export function insertRuleAtTop(rules: FilterRule[], rule: FilterRule): FilterRule[] {
return [rule, ...rules];
}
/** Replace the rule with the same id in place, or add it at the top. */
export function replaceOrInsertRule(rules: FilterRule[], rule: FilterRule): FilterRule[] {
return rules.some((r) => r.id === rule.id)
? rules.map((r) => (r.id === rule.id ? rule : r))
: insertRuleAtTop(rules, rule);
}
function valueList(value: string | string[]): string[] {
return (Array.isArray(value) ? value : [value]).filter((v) => typeof v === 'string' && v.length > 0);
}
function sameConditionKind(a: FilterCondition, b: FilterCondition): boolean {
return a.field === b.field
&& a.comparator === b.comparator
&& (a.headerName ?? '').toLowerCase() === (b.headerName ?? '').toLowerCase();
}
function sameAction(a: FilterAction, b: FilterAction): boolean {
if (a.type !== b.type) return false;
switch (a.type) {
case 'move':
case 'copy':
return a.mailboxId && b.mailboxId ? a.mailboxId === b.mailboxId : (a.value ?? '') === (b.value ?? '');
case 'add_label':
case 'forward':
case 'reject':
return (a.value ?? '') === (b.value ?? '');
default:
return true;
}
}
function sameActions(a: FilterAction[], b: FilterAction[]): boolean {
if (a.length !== b.length) return false;
const unmatched = [...b];
for (const action of a) {
const index = unmatched.findIndex((other) => sameAction(action, other));
if (index === -1) return false;
unmatched.splice(index, 1);
}
return true;
}
export type QuickRuleOutcome =
/** No rule did this yet: `rule` is new, at the top. */
| { kind: 'created'; rules: FilterRule[]; rule: FilterRule }
/** `rule` already did this for other values and now covers these too. */
| { kind: 'merged'; rules: FilterRule[]; rule: FilterRule }
/** `rule` already covers every value; nothing to write. */
| { kind: 'covered'; rule: FilterRule };
/**
* Save a one-click rule without piling up near-duplicates: an enabled
* Bulwark rule with a single condition of the same kind and the same actions
* takes the new values into its value list and keeps its name and place. A
* rule with a period only acts within it, and the new values would too, so
* it is never the one.
*/
export function applyQuickRule(rules: FilterRule[], candidate: FilterRule): QuickRuleOutcome {
const condition = candidate.conditions[0];
const target = candidate.conditions.length === 1
? rules.find((r) =>
isBulwarkRule(r)
&& r.enabled
&& !hasPeriod(r)
&& r.conditions.length === 1
&& sameConditionKind(r.conditions[0], condition)
&& sameActions(r.actions, candidate.actions))
: undefined;
if (!target) {
return { kind: 'created', rules: insertRuleAtTop(rules, candidate), rule: candidate };
}
const existing = valueList(target.conditions[0].value);
const known = new Set(existing.map((v) => v.toLowerCase()));
const added: string[] = [];
for (const value of valueList(condition.value)) {
const key = value.toLowerCase();
if (known.has(key)) continue;
known.add(key);
added.push(value);
}
if (added.length === 0) return { kind: 'covered', rule: target };
const merged: FilterRule = {
...target,
conditions: [{ ...target.conditions[0], value: [...existing, ...added] }],
};
return { kind: 'merged', rules: rules.map((r) => (r.id === target.id ? merged : r)), rule: merged };
}
+18 -2
View File
@@ -11,8 +11,10 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus,
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
} from '../components/icons';
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
import { isSieveSupported } from '../api/sieve';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors';
import { MoveSheet } from '../components/MoveSheet';
@@ -171,6 +173,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const [rulesOpen, setRulesOpen] = React.useState(false);
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
// Per-message override of the light/dark rendering (More sheet toggle).
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
@@ -893,6 +896,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
if (from) setAddressSheet(from);
}}
onForwardAsAttachment={onForwardAsAttachment}
// Rules go into the own account's filters; a shared account's are not the user's to set.
onRules={!ownerAccountId && email && isSieveSupported() ? () => { setMoreMenuOpen(false); setRulesOpen(true); } : undefined}
onViewSource={() => {
setMoreMenuOpen(false);
if (email?.blobId) {
@@ -937,6 +942,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
/>
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
<QuickRuleSheet visible={rulesOpen} email={email ?? null} mailboxes={scopedMailboxes} onClose={() => setRulesOpen(false)} />
</SafeAreaView>
);
}
@@ -1309,6 +1315,8 @@ interface MoreMenuSheetProps {
onToggleTheme: () => void;
onSenderActions: () => void;
onForwardAsAttachment: () => void;
/** Make a filter rule from the message; absent where none can be made. */
onRules?: () => void;
onViewSource: () => void;
onExport: () => void;
}
@@ -1317,7 +1325,7 @@ function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
onForwardAsAttachment, onViewSource, onExport,
onForwardAsAttachment, onRules, onViewSource, onExport,
}: MoreMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -1434,6 +1442,14 @@ function MoreMenuSheet({
trailing={<ChevronRight size={16} color={c.textMuted} />}
/>
)}
{onRules && (
<MoreMenuItem
icon={<Filter size={18} color={c.textSecondary} />}
label={t('context_menu.rules.title', 'Rules')}
onPress={onRules}
trailing={<ChevronRight size={16} color={c.textMuted} />}
/>
)}
{canExport && (
<MoreMenuItem
icon={<FileInput size={18} color={c.textSecondary} />}
+3 -8
View File
@@ -17,6 +17,9 @@ import {
updateSieveScript,
validateSieveScript,
} from '../api/sieve';
import { OpaqueFiltersError } from '../api/sieve-rules';
export { OpaqueFiltersError };
// Ported from the webmail's stores/filter-store.ts. The mobile client is a
// singleton (api/sieve drives `jmapClient` directly), so the actions drop the
@@ -341,14 +344,6 @@ export interface VacationFilters {
notRunning: boolean;
}
/** The script was edited by hand and the builder cannot read it back. */
export class OpaqueFiltersError extends Error {
constructor() {
super('The filters script was edited by hand');
this.name = 'OpaqueFiltersError';
}
}
/** See VacationFilters.notRunning. */
function storedButIdle(
filtersActive: boolean,