mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: create filter rules from a message's sender, domain or mailing list
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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': [],
|
'settings/device-sync/status.ts': [],
|
||||||
'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'],
|
'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'],
|
||||||
'filters/SieveEditorSheet.tsx': [],
|
'filters/SieveEditorSheet.tsx': [],
|
||||||
|
'filters/QuickRuleSheet.tsx': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('translated settings panes', () => {
|
describe('translated settings panes', () => {
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ function makeEmptyAction(): FilterAction {
|
|||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
/** The rule being edited. */
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
|
/** A new rule that starts from these values ("Create rule..." on a message). */
|
||||||
|
initialRule?: FilterRule;
|
||||||
mailboxes: Mailbox[];
|
mailboxes: Mailbox[];
|
||||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||||
periodsSupported?: boolean;
|
periodsSupported?: boolean;
|
||||||
@@ -74,13 +77,14 @@ interface FilterRuleModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterRuleModal({
|
export function FilterRuleModal({
|
||||||
visible, rule, mailboxes, periodsSupported = false, onSave, onClose,
|
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
|
||||||
}: FilterRuleModalProps) {
|
}: FilterRuleModalProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = useMemo(() => makeStyles(c), [c]);
|
const styles = useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const keywords = useKeywordsStore((s) => s.keywords);
|
const keywords = useKeywordsStore((s) => s.keywords);
|
||||||
const isEdit = !!rule;
|
const isEdit = !!editedRule;
|
||||||
|
const rule = editedRule ?? initialRule;
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || '');
|
const [name, setName] = useState(rule?.name || '');
|
||||||
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||||
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
||||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus,
|
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
|
||||||
} from '../components/icons';
|
} 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 { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import { MoveSheet } from '../components/MoveSheet';
|
import { MoveSheet } from '../components/MoveSheet';
|
||||||
@@ -171,6 +173,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||||
|
const [rulesOpen, setRulesOpen] = React.useState(false);
|
||||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
// Per-message override of the light/dark rendering (More sheet toggle).
|
||||||
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
|
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);
|
if (from) setAddressSheet(from);
|
||||||
}}
|
}}
|
||||||
onForwardAsAttachment={onForwardAsAttachment}
|
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={() => {
|
onViewSource={() => {
|
||||||
setMoreMenuOpen(false);
|
setMoreMenuOpen(false);
|
||||||
if (email?.blobId) {
|
if (email?.blobId) {
|
||||||
@@ -937,6 +942,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
|
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
|
||||||
|
<QuickRuleSheet visible={rulesOpen} email={email ?? null} mailboxes={scopedMailboxes} onClose={() => setRulesOpen(false)} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1315,8 @@ interface MoreMenuSheetProps {
|
|||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
onSenderActions: () => void;
|
onSenderActions: () => void;
|
||||||
onForwardAsAttachment: () => void;
|
onForwardAsAttachment: () => void;
|
||||||
|
/** Make a filter rule from the message; absent where none can be made. */
|
||||||
|
onRules?: () => void;
|
||||||
onViewSource: () => void;
|
onViewSource: () => void;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
@@ -1317,7 +1325,7 @@ function MoreMenuSheet({
|
|||||||
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
||||||
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
|
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
|
||||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
||||||
onForwardAsAttachment, onViewSource, onExport,
|
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
@@ -1434,6 +1442,14 @@ function MoreMenuSheet({
|
|||||||
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
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 && (
|
{canExport && (
|
||||||
<MoreMenuItem
|
<MoreMenuItem
|
||||||
icon={<FileInput size={18} color={c.textSecondary} />}
|
icon={<FileInput size={18} color={c.textSecondary} />}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
updateSieveScript,
|
updateSieveScript,
|
||||||
validateSieveScript,
|
validateSieveScript,
|
||||||
} from '../api/sieve';
|
} 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
|
// 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
|
// singleton (api/sieve drives `jmapClient` directly), so the actions drop the
|
||||||
@@ -341,14 +344,6 @@ export interface VacationFilters {
|
|||||||
notRunning: boolean;
|
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. */
|
/** See VacationFilters.notRunning. */
|
||||||
function storedButIdle(
|
function storedButIdle(
|
||||||
filtersActive: boolean,
|
filtersActive: boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user