mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
import type { Email, Mailbox } from '@/lib/jmap/types';
|
|
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
|
|
|
|
/**
|
|
* Rules made from a message: "Always move messages from Anna to…", "Block
|
|
* sender", and the values the rule editor starts from. Pure functions; the
|
|
* account plumbing lives in quick-rule-flow.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;
|
|
}
|
|
|
|
export type Translate = (key: string, values?: 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 bracketed = value.match(/<([^<>]+)>\s*$/);
|
|
const id = (bracketed ? bracketed[1] : value).trim();
|
|
if (!id || /\s/.test(id)) return null;
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* A raw header value (JMAP `header:Name` without a form) as one line: folding
|
|
* undone, surrounding whitespace dropped. Encoded words stay encoded.
|
|
*/
|
|
export function unfoldHeader(raw: unknown): string | null {
|
|
if (typeof raw !== 'string') return null;
|
|
return raw.replace(/\r?\n[ \t]+/g, ' ').trim();
|
|
}
|
|
|
|
/** 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 value of `name` in a parsed header record, whatever its case. */
|
|
export function headerValue(headers: Email['headers'], name: string): string | string[] | undefined {
|
|
if (!headers) return undefined;
|
|
const wanted = name.toLowerCase();
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === wanted) return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const SUBJECT_PREFIX = /^\s*(?:re|fwd?|aw|wg)\s*(?:\[\d+\])?\s*:\s*/i;
|
|
|
|
/** The subject without its reply/forward prefixes (Re:, Fwd:, AW:, WG:). */
|
|
export function stripSubjectPrefixes(subject: string | null | undefined): string {
|
|
let s = (subject ?? '').trim();
|
|
let previous;
|
|
do {
|
|
previous = s;
|
|
s = s.replace(SUBJECT_PREFIX, '');
|
|
} while (s !== previous);
|
|
return s.trim();
|
|
}
|
|
|
|
/**
|
|
* 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 ruleTargetMailboxIds(mailboxes: Mailbox[]): Set<string> {
|
|
return new Set(
|
|
mailboxes
|
|
.filter(m => !m.isShared && !m.id.startsWith('shared-') && m.role !== 'drafts' && m.myRights?.mayAddItems)
|
|
.map(m => m.id),
|
|
);
|
|
}
|
|
|
|
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('quick_rule_names.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. `t` is the settings.filters translator,
|
|
* which names it ("Move from anna@acme.com to Newsletters").
|
|
*/
|
|
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('quick_rule_names.move_sender', { sender, folder: preset.mailbox.name }),
|
|
conditions: [senderCondition(subject.senders)],
|
|
actions: [moveAction(preset.mailbox)],
|
|
};
|
|
case 'move_domain':
|
|
return {
|
|
...base,
|
|
name: t('quick_rule_names.move_domain', { domain: subject.domain ?? '', folder: preset.mailbox.name }),
|
|
conditions: [domainCondition(subject.domain ?? '')],
|
|
actions: [moveAction(preset.mailbox)],
|
|
};
|
|
case 'move_list':
|
|
return {
|
|
...base,
|
|
name: t('quick_rule_names.move_list', { list: subject.listId ?? '', folder: preset.mailbox.name }),
|
|
conditions: [listIdCondition(subject.listId ?? '')],
|
|
actions: [moveAction(preset.mailbox)],
|
|
};
|
|
case 'mark_read':
|
|
return {
|
|
...base,
|
|
name: t('quick_rule_names.mark_read', { sender }),
|
|
conditions: [senderCondition(subject.senders)],
|
|
actions: [{ type: 'mark_read' }],
|
|
};
|
|
case 'tag':
|
|
return {
|
|
...base,
|
|
name: t('quick_rule_names.tag', { 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('quick_rule_names.block', { 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('quick_rule_names.custom', { sender: senderNameFor(subject.senders, t) })
|
|
: t('quick_rule_names.custom_plain'),
|
|
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; added: FilterRule }
|
|
/** `rule` already did this for other values and now covers these too. */
|
|
| { kind: 'merged'; rules: FilterRule[]; rule: FilterRule; added: 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.
|
|
* `added` is the part of the rule that is new (the candidate itself, or the
|
|
* merged rule narrowed to the added values), which is what the "apply to
|
|
* existing messages" pass runs.
|
|
*/
|
|
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
|
|
&& 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, added: 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,
|
|
added: {
|
|
...merged,
|
|
conditions: [{ ...merged.conditions[0], value: added.length === 1 ? added[0] : added }],
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Where a message's rule would be saved, as far as the menu needs to know. */
|
|
export interface RuleAccountInfo {
|
|
/** Identifies the login + JMAP account, so a selection can be compared. */
|
|
key: string;
|
|
/** A shared or group account: its filters are not the user's to set. */
|
|
shared: boolean;
|
|
supportsSieve: boolean;
|
|
}
|
|
|
|
export type RulesMenuAvailability = 'hidden' | 'cross_account' | 'available';
|
|
|
|
/**
|
|
* Whether the Rules entry shows. A shared account or one without Sieve gets
|
|
* none; a selection that spans accounts gets a disabled entry, because one
|
|
* rule can only go into one account's script.
|
|
*/
|
|
export function rulesMenuAvailability(accounts: Array<RuleAccountInfo | null>): RulesMenuAvailability {
|
|
if (accounts.length === 0 || accounts.some(a => a === null)) return 'hidden';
|
|
const known = accounts as RuleAccountInfo[];
|
|
if (new Set(known.map(a => a.key)).size > 1) return 'cross_account';
|
|
const [account] = known;
|
|
if (account.shared || !account.supportsSieve) return 'hidden';
|
|
return 'available';
|
|
}
|
|
|
|
/** A condition the rule editor offers as a one-click chip. */
|
|
export interface RuleSuggestion {
|
|
id: 'subject' | 'to' | 'list' | 'domain';
|
|
label: string;
|
|
condition: FilterCondition;
|
|
/** Put the condition in place of the first one this matches, instead of adding it. */
|
|
replaces?: (condition: FilterCondition) => boolean;
|
|
}
|
|
|
|
/**
|
|
* The chips "Create rule…" offers for `anchor`, the message it was opened on:
|
|
* its subject (without Re:/Fwd:), the own address it was delivered to, its
|
|
* List-Id, and the senders' shared domain in place of the sender condition.
|
|
* `t` is the settings.filters translator.
|
|
*/
|
|
export function buildSuggestions(
|
|
anchor: Pick<Email, 'subject' | 'to' | 'cc'>,
|
|
subject: QuickRuleSubject,
|
|
ownAddresses: ReadonlySet<string>,
|
|
t: Translate,
|
|
): RuleSuggestion[] {
|
|
const suggestions: RuleSuggestion[] = [];
|
|
|
|
const topic = stripSubjectPrefixes(anchor.subject);
|
|
if (topic) {
|
|
suggestions.push({
|
|
id: 'subject',
|
|
label: t('suggest_subject', { subject: topic.length > 40 ? `${topic.slice(0, 39)}…` : topic }),
|
|
// The editor reads a comma in a plain value as a list separator.
|
|
condition: { field: 'subject', comparator: 'contains', value: topic.includes(',') ? [topic] : topic },
|
|
});
|
|
}
|
|
|
|
const inTo = (anchor.to ?? []).find(a => ownAddresses.has(normalizeAddress(a.email)));
|
|
const inCc = inTo ? undefined : (anchor.cc ?? []).find(a => ownAddresses.has(normalizeAddress(a.email)));
|
|
const delivered = inTo ?? inCc;
|
|
if (delivered) {
|
|
const address = normalizeAddress(delivered.email);
|
|
suggestions.push({
|
|
id: 'to',
|
|
label: t(inTo ? 'suggest_to' : 'suggest_cc', { address }),
|
|
condition: { field: inTo ? 'to' : 'cc', comparator: 'address_is', value: address },
|
|
});
|
|
}
|
|
|
|
if (subject.listId) {
|
|
suggestions.push({ id: 'list', label: t('suggest_list'), condition: listIdCondition(subject.listId) });
|
|
}
|
|
|
|
if (subject.domain && subject.senders.length > 0) {
|
|
suggestions.push({
|
|
id: 'domain',
|
|
label: t('suggest_domain', { domain: subject.domain }),
|
|
condition: domainCondition(subject.domain),
|
|
replaces: (c) => c.field === 'from' && c.comparator === 'address_is',
|
|
});
|
|
}
|
|
|
|
return suggestions;
|
|
}
|