import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; import { PERIOD_REQUIRES, hasPeriod, periodTests } from '@/lib/sieve/period'; import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from '@/lib/sieve/vacation-forward'; import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience'; const HEADER_MAP: Record = { from: 'From', to: 'To', cc: 'Cc', subject: 'Subject', }; /** Fields whose header holds addresses, so the `address` test applies. */ const ADDRESS_FIELDS = new Set(['from', 'to', 'cc']); function escapeString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } /** * Header names Stalwart's Sieve compiler accepts (sieve-rs, 0.16 and 1.0). * Anything else - a space, a dot, a colon - fails the whole script with * "Invalid header name". */ const SIEVE_HEADER_NAME_RE = /^[A-Za-z0-9_-]+$/; export function isValidSieveHeaderName(name: string): boolean { return SIEVE_HEADER_NAME_RE.test(name); } /** The header a condition tests; a header condition without a name tests X-Unknown. */ function conditionHeaderName(condition: FilterCondition): string { return condition.field === 'header' ? (condition.headerName?.trim() || 'X-Unknown') : HEADER_MAP[condition.field]; } // Normalise the condition value to a non-empty string array. Single-value // conditions stay one-element; arrays are filtered for empty strings. function toValueList(value: string | string[]): string[] { const arr = Array.isArray(value) ? value : [value]; return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0); } // Render one or many strings as a Sieve string-literal-or-list. Sieve treats // `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within // the condition); the single-string form is emitted unchanged when len === 1 // so existing scripts and tests stay byte-identical. function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string { if (values.length === 1) { return `"${escapeString(transform(values[0]))}"`; } return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`; } function generateCondition(condition: FilterCondition): string { const { field, comparator, value } = condition; // Every message: there is nothing to compare. if (field === 'all') return 'true'; if (field === 'size') { // Size is numeric, single value only. It is written unquoted, so // anything but a number (with an optional K/M/G quantifier) would be // Sieve source; fall back to 0. const raw = String((Array.isArray(value) ? value[0] : value) ?? '').trim(); const sizeValue = /^\d+[KMG]?$/i.test(raw) ? raw : '0'; const op = comparator === 'greater_than' ? ':over' : ':under'; return `size ${op} ${sizeValue}`; } const values = toValueList(value); if (field === 'body') { const matchType = comparator === 'is' ? ':is' : ':contains'; return `body ${matchType} ${formatStringArg(values)}`; } if (field === 'attachment') { // RFC 5703: :mime :anychild matches against headers of any MIME part. // has_any tests Content-Disposition for "attachment"; has_type matches // the file extension against the filename across BOTH Content-Disposition // (filename= parameter) and Content-Type (name= parameter) - many older // senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only // in Content-Type and leave Content-Disposition without a filename. // RFC 5228 §5.7 allows a string-list for header names; the test passes // if any listed header matches. Wildcard "*.*" catches quoted, // unquoted, and RFC-2231-encoded forms alike since "." appears as // a literal substring in all of them. // Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"] // = OR within the condition (any item matches → test passes). if (comparator === 'has_any') { return `header :mime :anychild :contains "Content-Disposition" "attachment"`; } const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean); return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`; } const headerName = escapeString(conditionHeaderName(condition)); // RFC 5228 §5.1: `address` compares the parsed address, so the display // name and the angle brackets play no part. `header :contains` would let // "anna@acme.com" match joanna@acme.com, and `header :matches "*@acme.com"` // never matches "Anna " because of the closing ">". if ((comparator === 'address_is' || comparator === 'domain_is') && ADDRESS_FIELDS.has(field)) { const part = comparator === 'domain_is' ? ':domain ' : ''; return `address ${part}:is "${headerName}" ${formatStringArg(values)}`; } switch (comparator) { case 'contains': return `header :contains "${headerName}" ${formatStringArg(values)}`; case 'not_contains': return `not header :contains "${headerName}" ${formatStringArg(values)}`; case 'is': return `header :is "${headerName}" ${formatStringArg(values)}`; case 'not_is': return `not header :is "${headerName}" ${formatStringArg(values)}`; case 'starts_with': return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`; case 'ends_with': return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`; case 'matches': return `header :matches "${headerName}" ${formatStringArg(values)}`; default: return `header :contains "${headerName}" ${formatStringArg(values)}`; } } const FLAG_ACTIONS = new Set(['mark_read', 'star', 'add_label']); function fileintoTarget(action: FilterAction, useMailboxId: boolean): string { const path = `"${escapeString(action.value || '')}"`; // RFC 9042: the path is the fallback when the id no longer exists. return useMailboxId && action.mailboxId ? `:mailboxid "${escapeString(action.mailboxId)}" ${path}` : path; } function generateActions(actions: FilterAction[], useMailboxId: boolean): string[] { // fileinto (and the implicit keep) store the flags set at that moment, so a // flag action placed after a move in the UI would be lost. Set flags first. const ordered = [ ...actions.filter(a => FLAG_ACTIONS.has(a.type)), ...actions.filter(a => !FLAG_ACTIONS.has(a.type)), ]; return ordered.map(action => { switch (action.type) { case 'move': return `fileinto ${fileintoTarget(action, useMailboxId)};`; case 'copy': return `fileinto :copy ${fileintoTarget(action, useMailboxId)};`; case 'forward': return `redirect ${action.keepCopy ? ':copy ' : ''}"${escapeString(action.value || '')}";`; case 'mark_read': return 'addflag "\\\\Seen";'; case 'star': return 'addflag "\\\\Flagged";'; case 'add_label': return `addflag "$label:${escapeString(action.value || '')}";`; case 'discard': return 'discard;'; case 'reject': return `reject "${escapeString(action.value || '')}";`; case 'keep': // A bare `keep;` delivers to the "default place", which on Stalwart is // Junk for a message its spam filter has already classified, so an // allow-list rule made with "Keep" would change nothing. An explicit // `fileinto "INBOX"` is honored over the spam verdict (#1027), but // only from Stalwart 0.16.22; older versions still move it to Junk. return 'fileinto "INBOX";'; case 'stop': return 'stop;'; } }); } // Stalwart maps its spam verdict to 100 % (0.16.0) or to a graded value where // 50 % is the spam threshold (0.16.19+), so ">= 50" matches "is spam" on both. const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"'; const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric']; export function supportsSpamGuard(extensions: string[] | undefined): boolean { return ['spamtestplus', 'relational'].every(e => extensions?.includes(e)); } /** * A folder move takes a message out of the Junk folder the server would have * put it in, so move/copy rules skip spam unless the rule opts in. "Keep" * rules are allow-lists and stay unguarded. */ function needsSpamGuard(rule: FilterRule, extensions: string[] | undefined): boolean { return !rule.includeSpam && rule.actions.some(a => a.type === 'move' || a.type === 'copy') && supportsSpamGuard(extensions); } function computeRequires( rules: FilterRule[], vacation: VacationSieveConfig | undefined, useMailboxId: boolean, serverExtensions: string[] | undefined, ): string[] { const extensions = new Set(); const enabledRules = rules.filter(r => r.enabled); if (vacation?.isEnabled) { extensions.add('vacation'); } for (const rule of enabledRules) { if (needsSpamGuard(rule, serverExtensions)) { for (const e of SPAM_GUARD_REQUIRES) extensions.add(e); } if (periodTests(rule)?.length) { for (const e of PERIOD_REQUIRES) extensions.add(e); } for (const condition of rule.conditions) { if (condition.field === 'body') extensions.add('body'); if (condition.field === 'attachment') extensions.add('mime'); } for (const action of rule.actions) { switch (action.type) { case 'move': case 'copy': extensions.add('fileinto'); if (action.type === 'copy') extensions.add('copy'); if (useMailboxId && action.mailboxId) { // Stalwart rejects :mailboxid unless "mailbox" is declared too. extensions.add('mailbox'); extensions.add('mailboxid'); } break; case 'keep': extensions.add('fileinto'); break; case 'forward': if (action.keepCopy) extensions.add('copy'); break; case 'mark_read': case 'star': case 'add_label': extensions.add('imap4flags'); break; case 'reject': extensions.add('reject'); break; } } } return [...extensions]; } function stripRuleForMetadata(r: FilterRule): Omit { return { id: r.id, name: r.name, enabled: r.enabled, matchType: r.matchType, conditions: r.conditions, actions: r.actions, stopProcessing: r.stopProcessing, ...(r.includeSpam ? { includeSpam: true } : {}), ...(r.activeFrom !== undefined ? { activeFrom: r.activeFrom } : {}), ...(r.activeUntil !== undefined ? { activeUntil: r.activeUntil } : {}), }; } export interface GenerateOptions { /** * Require extensions used by external (non-Bulwark) rules that we must * preserve in the top-level `require` directive. Duplicates with Bulwark's * own requires are deduplicated. */ externalRequires?: string[]; /** * Run the server-managed "vacation" script (VacationResponse) from this * one. Only one script can be active, so this keeps the auto-reply working * while the filters stay active. */ includeVacation?: boolean; /** * Forwarding from the vacation card. Always kept in the metadata; it runs * while it is switched on, with or without the auto-reply. */ vacationForward?: VacationForward | null; /** * Who gets the auto-reply when not everyone should. Always kept in the * metadata; it narrows the vacation include (`includeVacation`). */ vacationAudience?: VacationAudience | null; /** * The server's `sieveExtensions`. Folder moves use `:mailboxid` when it * lists "mailboxid"; without it they target the folder path only. */ extensions?: string[]; } /** Name of the script a server builds for VacationResponse (RFC 9661). */ export const VACATION_SCRIPT_NAME = 'vacation'; export function generateScript( rules: FilterRule[], vacation?: VacationSieveConfig, options: GenerateOptions = {}, ): string { // Partition rules by origin. Treat missing origin as 'bulwark' for back-compat. const bulwarkRules: FilterRule[] = []; const externalRules: FilterRule[] = []; for (const r of rules) { if (r.origin && r.origin !== 'bulwark') externalRules.push(r); else bulwarkRules.push(r); } const metadata: FilterMetadata = { version: 1, rules: bulwarkRules.map(stripRuleForMetadata) as FilterRule[], }; if (vacation?.isEnabled) { metadata.vacation = vacation; } if (options.includeVacation) { metadata.includeVacation = true; } if (options.vacationForward) { if (isValidVacationForward(options.vacationForward)) { metadata.vacationForward = normalizeVacationForward(options.vacationForward); } else { debug.warn('filters', 'Dropping unusable vacation forwarding'); } } if (options.vacationAudience) { if (isValidVacationAudience(options.vacationAudience)) { metadata.vacationAudience = normalizeVacationAudience(options.vacationAudience); } else { debug.warn('filters', 'Dropping unusable vacation audience'); } } // An older build would take a version 1 script as its own and write it back // without what it does not know: a rule without its period forwards for // good, the auto-reply goes to everyone. It leaves version 2 alone. A // forward that is off only loses its remembered address that way, so it // stays version 1: the native app still edits version 1 only. if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) { metadata.version = 2; } // The JSON sits inside a /* ... */ comment: a "*/" in any string (a rule // name, a condition value) would end the comment and turn the rest into // live Sieve. JSON reads "\/" back as "/", so the metadata is unchanged. const metadataJson = JSON.stringify(metadata).replace(/\*\//g, '*\\/'); const lines: string[] = []; lines.push('/* @metadata:begin'); lines.push(metadataJson); lines.push('@metadata:end */'); lines.push(''); const useMailboxId = options.extensions?.includes('mailboxid') ?? false; const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions); if (options.includeVacation) bulwarkRequires.push('include'); if (options.includeVacation && metadata.vacationAudience) bulwarkRequires.push('envelope'); // Forwarding from the vacation card runs whenever it is switched on, with // or without the auto-reply, ahead of every rule. A period it cannot read // would let it forward for good, so then it does not run. const forward = metadata.vacationForward?.enabled ? metadata.vacationForward : null; const forwardPeriod = forward ? periodTests(forward) : null; const forwardSpamGuard = supportsSpamGuard(options.extensions); if (forward && forwardPeriod) { if (forward.keepCopy) bulwarkRequires.push('copy'); if (forwardPeriod.length > 0) bulwarkRequires.push(...PERIOD_REQUIRES); if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES); } const externalRequires = options.externalRequires ?? []; const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort(); if (allRequires.length > 0) { lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`); } if (options.includeVacation) { // The "# Vacation auto-reply" comment marks the block as Bulwark's, so // the parser does not re-import it as an external rule. lines.push(''); lines.push('# Vacation auto-reply'); const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`; const audience = metadata.vacationAudience; if (audience) { // Only senders from (or not from) the account's own domains. The reply // goes to the envelope sender, so that is the address to judge: a From // header can name a colleague on mail from anywhere, and the reply // meant for colleagues would go to whoever sent it. const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', '); lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`); lines.push(` ${include}`); lines.push('}'); } else { lines.push(include); } } if (vacation?.isEnabled) { lines.push(''); lines.push('# Vacation auto-reply'); const vacationParts: string[] = []; if (vacation.subject) { vacationParts.push(`:subject "${escapeString(vacation.subject)}"`); } vacationParts.push(`"${escapeString(vacation.textBody || '')}"`); lines.push(`vacation ${vacationParts.join(' ')};`); } if (forward && forwardPeriod) { // Spam is not passed on: it stays here for the server's own handling. const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])]; const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`; lines.push(''); lines.push(VACATION_FORWARD_MARKER); lines.push(`if ${condition} {`); lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`); // Without a copy kept here, no rule below may file it either. if (!forward.keepCopy) lines.push(' stop;'); lines.push('}'); } const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled); for (const rule of enabledBulwarkRules) { if (rule.conditions.length === 0 || rule.actions.length === 0) { debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`); continue; } // The server would refuse the whole script, every other rule with it. // The rule stays in the metadata, so it can still be corrected. const badHeader = rule.conditions .filter(c => c.field === 'header') .map(conditionHeaderName) .find(name => !isValidSieveHeaderName(name)); if (badHeader !== undefined) { debug.warn('filters', `Skipping rule "${rule.name}": invalid header name "${badHeader}"`); continue; } // Without its period a rule would act all the time - a forwarding rule // would forward for good - so one whose period is unusable is dropped. const period = periodTests(rule); if (period === null) { debug.warn('filters', `Skipping rule "${rule.name}": unusable period`); continue; } lines.push(''); // A line break in the name would end the comment and start a command. lines.push(`# Rule: ${rule.name.replace(/\s+/g, ' ')}`); const conditions = rule.conditions.map(generateCondition); let conditionStr: string; if (conditions.length === 0) { conditionStr = 'true'; } else if (conditions.length === 1) { conditionStr = conditions[0]; } else { const wrapper = rule.matchType === 'all' ? 'allof' : 'anyof'; conditionStr = `${wrapper}(${conditions.join(', ')})`; } if (needsSpamGuard(rule, options.extensions)) { conditionStr = conditions.length > 1 && rule.matchType === 'all' ? `allof(${conditions.join(', ')}, ${SPAM_GUARD})` : `allof(${conditionStr}, ${SPAM_GUARD})`; } // The period comes first: outside it, nothing else is evaluated. if (period.length > 0) { conditionStr = `allof(${[...period, conditionStr].join(', ')})`; } const actionLines = generateActions(rule.actions, useMailboxId); // discard and reject only cancel the implicit keep (RFC 5228 4.4, // RFC 5429); the script goes on, and the rules below would still act on // the message. So "stop processing" always writes a stop, unless the // block has one already: it runs straight through, so any stop in it ends // the script. if (rule.stopProcessing && !actionLines.includes('stop;')) { actionLines.push('stop;'); } lines.push(`if ${conditionStr} {`); for (const actionLine of actionLines) { lines.push(` ${actionLine}`); } lines.push('}'); } // Append preserved external rules verbatim. Each rawBlock already carries its // own leading comments and trailing whitespace from the source script. if (externalRules.length > 0) { lines.push(''); lines.push('# --- External rules (managed outside Bulwark) ---'); for (const ext of externalRules) { if (!ext.rawBlock) continue; lines.push(ext.rawBlock.replace(/\s+$/, '')); } } lines.push(''); return lines.join('\n'); }