import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; const HEADER_MAP: Record = { from: 'From', to: 'To', cc: 'Cc', subject: 'Subject', }; function escapeString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } // 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; if (field === 'size') { // Size is numeric, single value only. const sizeValue = Array.isArray(value) ? value[0] : value; 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 = field === 'header' ? (condition.headerName || 'X-Unknown') : HEADER_MAP[field]; 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)}`; } } function generateActions(actions: FilterAction[]): string[] { return actions.map(action => { switch (action.type) { case 'move': return `fileinto "${escapeString(action.value || '')}";`; case 'copy': return `fileinto :copy "${escapeString(action.value || '')}";`; case 'forward': return `redirect "${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': return 'keep;'; case 'stop': return 'stop;'; } }); } function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): string[] { const extensions = new Set(); const enabledRules = rules.filter(r => r.enabled); if (vacation?.isEnabled) { extensions.add('vacation'); } for (const rule of enabledRules) { 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': extensions.add('fileinto'); break; case 'copy': extensions.add('fileinto'); 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, }; } 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[]; } 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; } const metadataJson = JSON.stringify(metadata); const lines: string[] = []; lines.push('/* @metadata:begin'); lines.push(metadataJson); lines.push('@metadata:end */'); lines.push(''); const bulwarkRequires = computeRequires(bulwarkRules, vacation); const externalRequires = options.externalRequires ?? []; const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort(); if (allRequires.length > 0) { lines.push(`require [${allRequires.map(r => `"${r}"`).join(', ')}];`); } 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(' ')};`); } 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; } lines.push(''); lines.push(`# Rule: ${rule.name}`); 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(', ')})`; } const actionLines = generateActions(rule.actions); if (rule.stopProcessing) { const lastAction = rule.actions[rule.actions.length - 1]; if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) { 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'); }