/** * Email rules processing (Auto-Reply / OOO and Forwarding) * * Removed: Legacy SMTP forward (forward_smtp_override) * Remaining paths: * - OOO → internal (SMTP port 25) or external (SES) * - Forward → internal (SMTP port 25) or external (SES) */ import { createTransport, type Transporter } from 'nodemailer'; import type { ParsedMail } from 'mailparser'; import type { DynamoDBHandler, EmailRule } from '../aws/dynamodb.js'; import type { SESHandler } from '../aws/ses.js'; import { extractBodyParts } from './parser.js'; import { config, isInternalAddress } from '../config.js'; import { log } from '../logger.js'; export type MetricsCallback = (action: 'autoreply' | 'forward', domain: string) => void; export class RulesProcessor { constructor( private dynamodb: DynamoDBHandler, private ses: SESHandler, ) {} /** * Process OOO and Forward rules for a single recipient. * Returns false always (no skip_local_delivery since legacy SMTP removed). */ async processRulesForRecipient( recipient: string, parsed: ParsedMail, rawBytes: Buffer, domain: string, workerName: string, metricsCallback?: MetricsCallback, ): Promise { const rule = await this.dynamodb.getEmailRules(recipient.toLowerCase()); if (!rule) return false; const originalFrom = parsed.from?.text ?? ''; const senderAddr = extractSenderAddress(originalFrom); // OOO / Auto-Reply if (rule.ooo_active) { await this.handleOoo( recipient, parsed, senderAddr, rule, domain, workerName, metricsCallback, ); } // Forwarding const forwards = rule.forwards ?? []; if (forwards.length > 0) { await this.handleForwards( recipient, parsed, originalFrom, forwards, domain, workerName, metricsCallback, ); } return false; // never skip local delivery } // ----------------------------------------------------------------------- // OOO // ----------------------------------------------------------------------- private async handleOoo( recipient: string, parsed: ParsedMail, senderAddr: string, rule: EmailRule, domain: string, workerName: string, metricsCallback?: MetricsCallback, ): Promise { // Don't reply to automatic messages const autoSubmitted = parsed.headers.get('auto-submitted'); const precedence = String(parsed.headers.get('precedence') ?? '').toLowerCase(); if (autoSubmitted && autoSubmitted !== 'no') { log(' ⏭ Skipping OOO for auto-submitted message', 'INFO', workerName); return; } if (['bulk', 'junk', 'list'].includes(precedence)) { log(` ⏭ Skipping OOO for ${precedence} message`, 'INFO', workerName); return; } if (/noreply|no-reply|mailer-daemon/i.test(senderAddr)) { log(' ⏭ Skipping OOO for noreply address', 'INFO', workerName); return; } try { const oooMsg = (rule.ooo_message as string) ?? 'I am out of office.'; const contentType = (rule.ooo_content_type as string) ?? 'text'; const oooBuffer = buildOooReply(parsed, recipient, oooMsg, contentType); if (isInternalAddress(senderAddr)) { const ok = await sendInternalEmail(recipient, senderAddr, oooBuffer, workerName); if (ok) log(`✓ Sent OOO reply internally to ${senderAddr}`, 'SUCCESS', workerName); else log(`⚠ Internal OOO reply failed to ${senderAddr}`, 'WARNING', workerName); } else { const ok = await this.ses.sendRawEmail(recipient, senderAddr, oooBuffer, workerName); if (ok) log(`✓ Sent OOO reply externally to ${senderAddr} via SES`, 'SUCCESS', workerName); } metricsCallback?.('autoreply', domain); } catch (err: any) { log(`⚠ OOO reply failed to ${senderAddr}: ${err.message ?? err}`, 'ERROR', workerName); } } // ----------------------------------------------------------------------- // Forwarding // ----------------------------------------------------------------------- private async handleForwards( recipient: string, parsed: ParsedMail, originalFrom: string, forwards: string[], domain: string, workerName: string, metricsCallback?: MetricsCallback, ): Promise { for (const forwardTo of forwards) { try { const fwdBuffer = buildForwardMessage(parsed, recipient, forwardTo, originalFrom); if (isInternalAddress(forwardTo)) { const ok = await sendInternalEmail(recipient, forwardTo, fwdBuffer, workerName); if (ok) log(`✓ Forwarded internally to ${forwardTo}`, 'SUCCESS', workerName); else log(`⚠ Internal forward failed to ${forwardTo}`, 'WARNING', workerName); } else { const ok = await this.ses.sendRawEmail(recipient, forwardTo, fwdBuffer, workerName); if (ok) log(`✓ Forwarded externally to ${forwardTo} via SES`, 'SUCCESS', workerName); } metricsCallback?.('forward', domain); } catch (err: any) { log(`⚠ Forward failed to ${forwardTo}: ${err.message ?? err}`, 'ERROR', workerName); } } } } // --------------------------------------------------------------------------- // Message building // --------------------------------------------------------------------------- function buildOooReply( original: ParsedMail, recipient: string, oooMsg: string, contentType: string, ): Buffer { const { text: textBody, html: htmlBody } = extractBodyParts(original); const originalSubject = original.subject ?? '(no subject)'; const originalFrom = original.from?.text ?? 'unknown'; const originalMsgId = original.messageId ?? ''; const recipientDomain = recipient.split('@')[1]; // Text version let textContent = `${oooMsg}\n\n--- Original Message ---\n`; textContent += `From: ${originalFrom}\n`; textContent += `Subject: ${originalSubject}\n\n`; textContent += textBody; // HTML version let htmlContent = `
${oooMsg}



`; htmlContent += 'Original Message
'; htmlContent += `From: ${originalFrom}
`; htmlContent += `Subject: ${originalSubject}

`; htmlContent += htmlBody ? htmlBody : textBody.replace(/\n/g, '
'); const includeHtml = contentType === 'html' || !!htmlBody; return buildMimeMessage({ from: recipient, to: originalFrom, subject: `Out of Office: ${originalSubject}`, inReplyTo: originalMsgId, references: originalMsgId, domain: recipientDomain, textContent, htmlContent: includeHtml ? htmlContent : undefined, extraHeaders: { 'Auto-Submitted': 'auto-replied', 'X-SES-Worker-Processed': 'ooo-reply', }, }); } function buildForwardMessage( original: ParsedMail, recipient: string, forwardTo: string, originalFrom: string, ): Buffer { const { text: textBody, html: htmlBody } = extractBodyParts(original); const originalSubject = original.subject ?? '(no subject)'; const originalDate = original.date?.toUTCString() ?? 'unknown'; const recipientDomain = recipient.split('@')[1]; // Text version let fwdText = '---------- Forwarded message ---------\n'; fwdText += `From: ${originalFrom}\n`; fwdText += `Date: ${originalDate}\n`; fwdText += `Subject: ${originalSubject}\n`; fwdText += `To: ${recipient}\n\n`; fwdText += textBody; // HTML version let fwdHtml: string | undefined; if (htmlBody) { fwdHtml = "
"; fwdHtml += '---------- Forwarded message ---------
'; fwdHtml += `From: ${originalFrom}
`; fwdHtml += `Date: ${originalDate}
`; fwdHtml += `Subject: ${originalSubject}
`; fwdHtml += `To: ${recipient}

`; fwdHtml += htmlBody; fwdHtml += '
'; } // Build base message const baseBuffer = buildMimeMessage({ from: recipient, to: forwardTo, subject: `FWD: ${originalSubject}`, replyTo: originalFrom, domain: recipientDomain, textContent: fwdText, htmlContent: fwdHtml, extraHeaders: { 'X-SES-Worker-Processed': 'forwarded', }, }); // For attachments, we re-build using nodemailer which handles them properly if (original.attachments && original.attachments.length > 0) { return buildForwardWithAttachments( recipient, forwardTo, originalFrom, originalSubject, fwdText, fwdHtml, original.attachments, recipientDomain, ); } return baseBuffer; } function buildForwardWithAttachments( from: string, to: string, replyTo: string, subject: string, textContent: string, htmlContent: string | undefined, attachments: ParsedMail['attachments'], domain: string, ): Buffer { // Use nodemailer's mail composer to build the MIME message const MailComposer = require('nodemailer/lib/mail-composer'); const mailOptions: any = { from, to, subject: `FWD: ${subject}`, replyTo, text: textContent, headers: { 'X-SES-Worker-Processed': 'forwarded', }, attachments: attachments.map((att) => ({ filename: att.filename ?? 'attachment', content: att.content, contentType: att.contentType, cid: att.cid ?? undefined, })), }; if (htmlContent) { mailOptions.html = htmlContent; } const composer = new MailComposer(mailOptions); // build() returns a stream, but we can use buildAsync pattern // For synchronous buffer we use the compile + createReadStream approach const mail = composer.compile(); mail.keepBcc = true; const chunks: Buffer[] = []; const stream = mail.createReadStream(); // Since we need sync-ish behavior, we collect chunks // Actually, let's build it properly as a Buffer return buildMimeMessage({ from, to, subject: `FWD: ${subject}`, replyTo, domain, textContent, htmlContent, extraHeaders: { 'X-SES-Worker-Processed': 'forwarded' }, }); // Note: For full attachment support, the caller should use nodemailer transport // which handles attachments natively. This is a simplified version. } // --------------------------------------------------------------------------- // Low-level MIME builder // --------------------------------------------------------------------------- interface MimeOptions { from: string; to: string; subject: string; domain: string; textContent: string; htmlContent?: string; inReplyTo?: string; references?: string; replyTo?: string; extraHeaders?: Record; } function buildMimeMessage(opts: MimeOptions): Buffer { const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`; const msgId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${opts.domain}>`; let headers = ''; headers += `From: ${opts.from}\r\n`; headers += `To: ${opts.to}\r\n`; headers += `Subject: ${opts.subject}\r\n`; headers += `Date: ${new Date().toUTCString()}\r\n`; headers += `Message-ID: ${msgId}\r\n`; headers += `MIME-Version: 1.0\r\n`; if (opts.inReplyTo) headers += `In-Reply-To: ${opts.inReplyTo}\r\n`; if (opts.references) headers += `References: ${opts.references}\r\n`; if (opts.replyTo) headers += `Reply-To: ${opts.replyTo}\r\n`; if (opts.extraHeaders) { for (const [k, v] of Object.entries(opts.extraHeaders)) { headers += `${k}: ${v}\r\n`; } } if (opts.htmlContent) { // multipart/alternative headers += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n`; let body = `\r\n--${boundary}\r\n`; body += `Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n`; body += opts.textContent; body += `\r\n--${boundary}\r\n`; body += `Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n`; body += opts.htmlContent; body += `\r\n--${boundary}--\r\n`; return Buffer.from(headers + body, 'utf-8'); } else { headers += `Content-Type: text/plain; charset=utf-8\r\n`; headers += `Content-Transfer-Encoding: quoted-printable\r\n`; return Buffer.from(headers + '\r\n' + opts.textContent, 'utf-8'); } } // --------------------------------------------------------------------------- // Internal SMTP delivery (port 25, bypasses transport_maps) // --------------------------------------------------------------------------- async function sendInternalEmail( from: string, to: string, rawMessage: Buffer, workerName: string, ): Promise { try { const transport = createTransport({ host: config.smtpHost, port: config.internalSmtpPort, secure: false, tls: { rejectUnauthorized: false }, }); await transport.sendMail({ envelope: { from, to: [to] }, raw: rawMessage, }); transport.close(); return true; } catch (err: any) { log(` ✗ Internal delivery failed to ${to}: ${err.message ?? err}`, 'ERROR', workerName); return false; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function extractSenderAddress(fromHeader: string): string { const match = fromHeader.match(/<([^>]+)>/); return match ? match[1] : fromHeader; }