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, '\\"'); } function generateCondition(condition: FilterCondition): string { const { field, comparator, value } = condition; if (field === 'size') { const op = comparator === 'greater_than' ? ':over' : ':under'; return `size ${op} ${value}`; } if (field === 'body') { const matchType = comparator === 'is' ? ':is' : ':contains'; return `body ${matchType} "${escapeString(value)}"`; } const headerName = field === 'header' ? (condition.headerName || 'X-Unknown') : HEADER_MAP[field]; const escaped = escapeString(value); switch (comparator) { case 'contains': return `header :contains "${headerName}" "${escaped}"`; case 'not_contains': return `not header :contains "${headerName}" "${escaped}"`; case 'is': return `header :is "${headerName}" "${escaped}"`; case 'not_is': return `not header :is "${headerName}" "${escaped}"`; case 'starts_with': return `header :matches "${headerName}" "${escaped}*"`; case 'ends_with': return `header :matches "${headerName}" "*${escaped}"`; case 'matches': return `header :matches "${headerName}" "${escaped}"`; default: return `header :contains "${headerName}" "${escaped}"`; } } 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 "$${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'); } 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].sort(); } export function generateScript(rules: FilterRule[], vacation?: VacationSieveConfig): string { const metadata: FilterMetadata = { version: 1, rules }; 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 requires = computeRequires(rules, vacation); if (requires.length > 0) { lines.push(`require [${requires.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 enabledRules = rules.filter(r => r.enabled); for (const rule of enabledRules) { 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('}'); } lines.push(''); return lines.join('\n'); }