mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
import type { EmailAddress } from '@/lib/jmap/types';
|
|
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
|
|
import { hasPeriod } from '@/lib/sieve/period';
|
|
import { unfoldHeader } from './quick-rules';
|
|
|
|
/**
|
|
* Running a filter rule over mail that is already there. Sieve only sees new
|
|
* mail, so this happens in the client: a JMAP query narrows the folder down,
|
|
* and `ruleMatches` decides with the same semantics the generated Sieve has.
|
|
* The JMAP text filters are full-text searches, fuzzier than Sieve in both
|
|
* directions, so the query is only ever narrowed where it is sure to return
|
|
* every message the rule matches.
|
|
*/
|
|
|
|
/** A message as the retroactive pass sees it. */
|
|
export interface RetroMessage {
|
|
id: string;
|
|
mailboxIds: Record<string, boolean>;
|
|
keywords: Record<string, boolean>;
|
|
from?: EmailAddress[] | null;
|
|
to?: EmailAddress[] | null;
|
|
cc?: EmailAddress[] | null;
|
|
/** Lower-cased header name → every occurrence, decoded and unfolded. */
|
|
headers: Record<string, string[]>;
|
|
}
|
|
|
|
const ADDRESS_FIELDS = new Set<FilterCondition['field']>(['from', 'to', 'cc']);
|
|
const FIELD_HEADER: Record<string, string> = { from: 'From', to: 'To', cc: 'Cc', subject: 'Subject' };
|
|
/**
|
|
* Headers RFC 8621 parses as addresses. Their Text form is not allowed
|
|
* (Stalwart rejects `header:From:asText` as an invalid property), so they are
|
|
* read in the Addresses form instead.
|
|
*/
|
|
const ADDRESS_HEADERS = new Set([
|
|
'from', 'sender', 'reply-to', 'to', 'cc', 'bcc',
|
|
'resent-from', 'resent-sender', 'resent-reply-to', 'resent-to', 'resent-cc', 'resent-bcc',
|
|
]);
|
|
const TEXT_COMPARATORS = new Set<FilterCondition['comparator']>([
|
|
'contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches', 'address_is', 'domain_is',
|
|
]);
|
|
/** Actions that change a message that is already delivered. */
|
|
const APPLIED_ACTIONS = new Set<FilterAction['type']>(['move', 'copy', 'mark_read', 'star', 'add_label']);
|
|
/** Actions that are no-ops afterwards; `stop` only ends the delivery-time run. */
|
|
const NEUTRAL_ACTIONS = new Set<FilterAction['type']>(['stop']);
|
|
|
|
export type RetroSupport =
|
|
| { ok: true }
|
|
/** A condition the client cannot evaluate like Sieve does (body, size, attachment). */
|
|
| { ok: false; reason: 'condition' }
|
|
/** An action that must never run on old mail (forward, reject, discard) or has no meaning there (keep). */
|
|
| { ok: false; reason: 'action' }
|
|
/** A period: the rule acts on mail as it arrives within it, which says nothing about the mail already there. */
|
|
| { ok: false; reason: 'period' };
|
|
|
|
/** A header condition the client can evaluate: which header it reads. */
|
|
function conditionHeader(condition: FilterCondition): string | null {
|
|
if (condition.field === 'header') {
|
|
const name = condition.headerName?.trim();
|
|
// Anything that cannot be a header field name is not worth a JMAP property.
|
|
return name && /^[\x21-\x39\x3b-\x7e]+$/.test(name) ? name : null;
|
|
}
|
|
return FIELD_HEADER[condition.field] ?? null;
|
|
}
|
|
|
|
function usesAddressTest(condition: FilterCondition): boolean {
|
|
return (condition.comparator === 'address_is' || condition.comparator === 'domain_is')
|
|
&& ADDRESS_FIELDS.has(condition.field);
|
|
}
|
|
|
|
export function retroactiveSupport(
|
|
rule: Pick<FilterRule, 'conditions' | 'actions' | 'activeFrom' | 'activeUntil'>,
|
|
): RetroSupport {
|
|
if (hasPeriod(rule)) return { ok: false, reason: 'period' };
|
|
if (rule.conditions.length === 0) return { ok: false, reason: 'condition' };
|
|
for (const condition of rule.conditions) {
|
|
if (!TEXT_COMPARATORS.has(condition.comparator) || !conditionHeader(condition)) {
|
|
return { ok: false, reason: 'condition' };
|
|
}
|
|
}
|
|
if (!rule.actions.some(a => APPLIED_ACTIONS.has(a.type))) return { ok: false, reason: 'action' };
|
|
for (const action of rule.actions) {
|
|
if (!APPLIED_ACTIONS.has(action.type) && !NEUTRAL_ACTIONS.has(action.type)) return { ok: false, reason: 'action' };
|
|
// A folder known only by its path cannot be addressed over JMAP.
|
|
if ((action.type === 'move' || action.type === 'copy') && !action.mailboxId) return { ok: false, reason: 'action' };
|
|
if (action.type === 'add_label' && !action.value) return { ok: false, reason: 'action' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Sieve's default comparator, i;ascii-casemap, folds ASCII letters only. */
|
|
function asciiLower(value: string): string {
|
|
return value.replace(/[A-Z]/g, ch => String.fromCharCode(ch.charCodeAt(0) + 32));
|
|
}
|
|
|
|
/** RFC 5228 §2.7.1 `:matches`: `*` any run, `?` one character, `\` escapes. */
|
|
export function sieveMatches(pattern: string, text: string): boolean {
|
|
let source = '';
|
|
for (let i = 0; i < pattern.length; i++) {
|
|
const ch = pattern[i];
|
|
if (ch === '\\' && i + 1 < pattern.length) {
|
|
source += escapeRegex(pattern[++i]);
|
|
} else if (ch === '*') {
|
|
source += '[\\s\\S]*';
|
|
} else if (ch === '?') {
|
|
source += '[\\s\\S]';
|
|
} else {
|
|
source += escapeRegex(ch);
|
|
}
|
|
}
|
|
return new RegExp(`^${source}$`, 'u').test(text);
|
|
}
|
|
|
|
// Only syntax characters may be escaped under the `u` flag.
|
|
function escapeRegex(ch: string): string {
|
|
return /[.*+?^${}()|[\]\\/]/.test(ch) ? `\\${ch}` : ch;
|
|
}
|
|
|
|
function valueList(value: string | string[]): string[] {
|
|
return (Array.isArray(value) ? value : [value]).filter(v => typeof v === 'string' && v.length > 0);
|
|
}
|
|
|
|
function textMatches(comparator: FilterCondition['comparator'], key: string, subject: string): boolean {
|
|
const k = asciiLower(key);
|
|
const s = asciiLower(subject);
|
|
switch (comparator) {
|
|
case 'is':
|
|
case 'not_is':
|
|
return s === k;
|
|
case 'starts_with':
|
|
return sieveMatches(`${k}*`, s);
|
|
case 'ends_with':
|
|
return sieveMatches(`*${k}`, s);
|
|
case 'matches':
|
|
return sieveMatches(k, s);
|
|
default:
|
|
return s.includes(k);
|
|
}
|
|
}
|
|
|
|
function conditionMatches(condition: FilterCondition, message: RetroMessage): boolean {
|
|
const values = valueList(condition.value);
|
|
if (values.length === 0) return false;
|
|
|
|
if (usesAddressTest(condition)) {
|
|
const addresses = (message[condition.field as 'from' | 'to' | 'cc'] ?? [])
|
|
.map(a => (a.email ?? '').trim())
|
|
.filter(Boolean);
|
|
const parts = condition.comparator === 'domain_is'
|
|
? addresses.map(a => (a.includes('@') ? a.slice(a.lastIndexOf('@') + 1) : ''))
|
|
: addresses;
|
|
const wanted = new Set(values.map(asciiLower));
|
|
return parts.some(p => wanted.has(asciiLower(p)));
|
|
}
|
|
|
|
const header = conditionHeader(condition);
|
|
const occurrences = header ? message.headers[header.toLowerCase()] ?? [] : [];
|
|
// The generator writes an address comparator on a non-address field as
|
|
// `header :contains`, so read it the same way.
|
|
const comparator = condition.comparator === 'address_is' || condition.comparator === 'domain_is'
|
|
? 'contains'
|
|
: condition.comparator;
|
|
const positive = occurrences.some(o => values.some(v => textMatches(comparator, v, o)));
|
|
return comparator === 'not_contains' || comparator === 'not_is' ? !positive : positive;
|
|
}
|
|
|
|
/** Move/copy rules skip mail the server called spam unless they opt in (see the generator's spam guard). */
|
|
function skipsSpam(rule: FilterRule): boolean {
|
|
return !rule.includeSpam && rule.actions.some(a => a.type === 'move' || a.type === 'copy');
|
|
}
|
|
|
|
/**
|
|
* Whether the rule's Sieve would act on `message`. The spam guard reads the
|
|
* server's spam verdict, which a delivered message only carries as the
|
|
* `$junk` keyword, so that is what stands in for it here.
|
|
*/
|
|
export function ruleMatches(rule: FilterRule, message: RetroMessage): boolean {
|
|
if (rule.conditions.length === 0) return false;
|
|
const results = rule.conditions.map(c => conditionMatches(c, message));
|
|
const matched = rule.matchType === 'any' ? results.some(Boolean) : results.every(Boolean);
|
|
if (!matched) return false;
|
|
return !(skipsSpam(rule) && message.keywords?.$junk);
|
|
}
|
|
|
|
/**
|
|
* The JMAP filter for the rule's candidates in `mailboxId`: always a superset
|
|
* of what the rule matches. Only exact-address and exact-domain conditions
|
|
* narrow it. Substring and pattern conditions would need a full-text match,
|
|
* which can miss what a Sieve substring finds, and the `header` filter finds
|
|
* nothing at all on Stalwart 0.16 (not even a header every message has), so
|
|
* header conditions are left to `ruleMatches`.
|
|
*/
|
|
export function retroQueryFilter(rule: FilterRule, mailboxId: string): Record<string, unknown> {
|
|
const narrow = (condition: FilterCondition): Record<string, unknown> | null => {
|
|
if (!usesAddressTest(condition)) return null;
|
|
const values = valueList(condition.value);
|
|
if (values.length === 0) return null;
|
|
const each = values.map(v => ({ [condition.field]: v }));
|
|
return each.length === 1 ? each[0] : { operator: 'OR', conditions: each };
|
|
};
|
|
|
|
const narrowed = rule.conditions.map(narrow);
|
|
const inMailbox = { inMailbox: mailboxId };
|
|
if (rule.matchType === 'any') {
|
|
if (narrowed.some(n => n === null) || narrowed.length === 0) return inMailbox;
|
|
const parts = narrowed as Record<string, unknown>[];
|
|
return { operator: 'AND', conditions: [inMailbox, parts.length === 1 ? parts[0] : { operator: 'OR', conditions: parts }] };
|
|
}
|
|
const parts = narrowed.filter((n): n is Record<string, unknown> => n !== null);
|
|
return parts.length === 0 ? inMailbox : { operator: 'AND', conditions: [inMailbox, ...parts] };
|
|
}
|
|
|
|
/** The Email properties `ruleMatches` needs for this rule. */
|
|
export function retroProperties(rule: FilterRule): string[] {
|
|
const properties = new Set<string>(['mailboxIds', 'keywords']);
|
|
for (const condition of rule.conditions) {
|
|
if (usesAddressTest(condition)) {
|
|
properties.add(condition.field);
|
|
} else {
|
|
const header = conditionHeader(condition);
|
|
// The raw form backs up the text form, which Stalwart answers with null
|
|
// for headers it parses as structured (List-Id).
|
|
if (header && ADDRESS_HEADERS.has(header.toLowerCase())) {
|
|
properties.add(`header:${header}:asAddresses:all`);
|
|
} else if (header) {
|
|
properties.add(`header:${header}:asText:all`);
|
|
properties.add(`header:${header}:all`);
|
|
}
|
|
}
|
|
}
|
|
return [...properties];
|
|
}
|
|
|
|
/** Turn an Email/get record with `retroProperties` into a RetroMessage. */
|
|
export function toRetroMessage(record: Record<string, unknown>): RetroMessage {
|
|
const headers: Record<string, string[]> = {};
|
|
const asList = (value: unknown): unknown[] => (Array.isArray(value) ? value : value == null ? [] : [value]);
|
|
const formatAddress = (a: unknown): string | null => {
|
|
const { name, email } = (a ?? {}) as Partial<EmailAddress>;
|
|
if (!email) return name || null;
|
|
return name ? `${name} <${email}>` : email;
|
|
};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
const addresses = /^header:(.+):asAddresses:all$/.exec(key);
|
|
if (addresses) {
|
|
// One entry per occurrence, each read back as `Name <email>, ...`.
|
|
headers[addresses[1].toLowerCase()] = asList(value)
|
|
.map(list => asList(list).map(formatAddress).filter((v): v is string => v !== null).join(', '));
|
|
continue;
|
|
}
|
|
const m = /^header:(.+):asText:all$/.exec(key);
|
|
if (!m) continue;
|
|
const raw = asList(record[`header:${m[1]}:all`]);
|
|
headers[m[1].toLowerCase()] = asList(value)
|
|
.map((text, i) => (typeof text === 'string' ? text : unfoldHeader(raw[i])))
|
|
.filter((v): v is string => typeof v === 'string');
|
|
}
|
|
return {
|
|
id: String(record.id),
|
|
mailboxIds: (record.mailboxIds as Record<string, boolean> | undefined) ?? {},
|
|
keywords: (record.keywords as Record<string, boolean> | undefined) ?? {},
|
|
from: record.from as EmailAddress[] | null | undefined,
|
|
to: record.to as EmailAddress[] | null | undefined,
|
|
cc: record.cc as EmailAddress[] | null | undefined,
|
|
headers,
|
|
};
|
|
}
|
|
|
|
/** One bulk change of the pass, in the order Sieve would apply them. */
|
|
export type RetroStep =
|
|
| { kind: 'mark_read'; ids: string[] }
|
|
| { kind: 'keyword'; ids: string[]; keyword: string }
|
|
| { kind: 'copy'; ids: string[]; mailboxId: string }
|
|
| { kind: 'move'; ids: string[]; mailboxId: string };
|
|
|
|
export interface RetroPlan {
|
|
/** Messages at least one step changes: the "N existing messages". */
|
|
ids: string[];
|
|
steps: RetroStep[];
|
|
}
|
|
|
|
/**
|
|
* What the rule would change on `messages`. Flags come first, as in the
|
|
* generated script; a message already in the target folder is not moved,
|
|
* and a message the rule would not change is not counted. Forward, reject
|
|
* and discard never make it into a plan.
|
|
*/
|
|
export function planRetroactive(rule: FilterRule, messages: RetroMessage[]): RetroPlan {
|
|
if (!retroactiveSupport(rule).ok) return { ids: [], steps: [] };
|
|
const matched = messages.filter(m => ruleMatches(rule, m));
|
|
const changed = new Set<string>();
|
|
const steps: RetroStep[] = [];
|
|
const add = (step: RetroStep) => {
|
|
if (step.ids.length === 0) return;
|
|
steps.push(step);
|
|
for (const id of step.ids) changed.add(id);
|
|
};
|
|
|
|
const flagActions = rule.actions.filter(a => a.type === 'mark_read' || a.type === 'star' || a.type === 'add_label');
|
|
for (const action of flagActions) {
|
|
if (action.type === 'mark_read') {
|
|
add({ kind: 'mark_read', ids: matched.filter(m => !m.keywords.$seen).map(m => m.id) });
|
|
continue;
|
|
}
|
|
const keyword = action.type === 'star' ? '$flagged' : `$label:${action.value}`;
|
|
add({ kind: 'keyword', ids: matched.filter(m => !m.keywords[keyword]).map(m => m.id), keyword });
|
|
}
|
|
|
|
// Sieve files a message into every fileinto target and nowhere else. The
|
|
// first move replaces the message's folders, so it runs before the copies,
|
|
// and every further target (a second move, a copy) adds a folder.
|
|
const fileActions = rule.actions.filter(a => (a.type === 'move' || a.type === 'copy') && a.mailboxId);
|
|
const firstMove = fileActions.find(a => a.type === 'move');
|
|
const notIn = (mailboxId: string) => matched.filter(m => !m.mailboxIds[mailboxId]).map(m => m.id);
|
|
if (firstMove) add({ kind: 'move', ids: notIn(firstMove.mailboxId!), mailboxId: firstMove.mailboxId! });
|
|
for (const action of fileActions) {
|
|
if (action === firstMove) continue;
|
|
const mailboxId = action.mailboxId!;
|
|
add({ kind: 'copy', ids: notIn(mailboxId), mailboxId });
|
|
}
|
|
|
|
return { ids: [...changed], steps };
|
|
}
|