diff --git a/src/api/__tests__/sieve-rules.test.ts b/src/api/__tests__/sieve-rules.test.ts new file mode 100644 index 00000000..c9f3f3de --- /dev/null +++ b/src/api/__tests__/sieve-rules.test.ts @@ -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(); + }); +}); diff --git a/src/api/sieve-rules.ts b/src/api/sieve-rules.ts new file mode 100644 index 00000000..454799b9 --- /dev/null +++ b/src/api/sieve-rules.ts @@ -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 { + // 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; +} diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index 12f71d1e..85344896 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -49,6 +49,7 @@ const TRANSLATED_PANES: Record = { 'settings/device-sync/status.ts': [], 'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'], 'filters/SieveEditorSheet.tsx': [], + 'filters/QuickRuleSheet.tsx': [], }; describe('translated settings panes', () => { diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 6461fd15..503cf800 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -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'); diff --git a/src/components/filters/QuickRuleSheet.tsx b/src/components/filters/QuickRuleSheet.tsx new file mode 100644 index 00000000..1bb0f924 --- /dev/null +++ b/src/components/filters/QuickRuleSheet.tsx @@ -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({ 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + onPress: () => setStep({ kind: 'editor', rule: buildPrefillRule(subject, t, generateUUID()) }), + }); + + const tagItems: ActionSheetItem[] = keywords.map((kw) => ({ + key: kw.id, + label: kw.label, + icon: , + 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 ( + <> + s.email).join(', ') || undefined} + items={items} + onClose={finish} + /> + setStep({ kind: 'menu' })} + mailboxes={targets} + currentMailboxId={null} + onPick={pickFolder} + /> + setStep({ kind: 'menu' })} + /> + void saveEditorRule(rule)} + onClose={finish} + /> + + ); +} + diff --git a/src/lib/sieve/__tests__/quick-rules.test.ts b/src/lib/sieve/__tests__/quick-rules.test.ts new file mode 100644 index 00000000..f56d1971 --- /dev/null +++ b/src/lib/sieve/__tests__/quick-rules.test.ts @@ -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) => + fallback.replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? '')); + +const mailbox = (id: string, name: string, extra: Partial = {}): 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 ' }], +} as Pick; + +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'); + }); +}); diff --git a/src/lib/sieve/quick-rules.ts b/src/lib/sieve/quick-rules.ts new file mode 100644 index 00000000..ea732b39 --- /dev/null +++ b/src/lib/sieve/quick-rules.ts @@ -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; + +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>, ownAddresses: ReadonlySet): RuleSender[] { + const byAddress = new Map(); + 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 " 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): 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>, + ownAddresses: ReadonlySet, +): 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 { + 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(); + 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 }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index e9f9d752..9dc5e0ed 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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(null); // Per-message override of the light/dark rendering (More sheet toggle). const [themeOverrides, setThemeOverrides] = React.useState>({}); @@ -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 /> setAddressSheet(null)} /> + setRulesOpen(false)} /> ); } @@ -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={} /> )} + {onRules && ( + } + label={t('context_menu.rules.title', 'Rules')} + onPress={onRules} + trailing={} + /> + )} {canExport && ( } diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts index 0268af15..03a22472 100644 --- a/src/stores/filter-store.ts +++ b/src/stores/filter-store.ts @@ -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,