/** * Email rules processing (Auto-Reply / OOO and Forwarding) * * CLEANED UP & FIXED: * - Uses MailComposer for ALL message generation (safer MIME handling) * - Fixes broken attachment forwarding * - Removed legacy SMTP forwarding * - Removed manual string concatenation for MIME boundaries */ import { createTransport } 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'; // Wir nutzen MailComposer direkt für das Erstellen der Raw Bytes import MailComposer from 'nodemailer/lib/mail-composer/index.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. */ 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'; // FIX: Use MailComposer via await const oooBuffer = await 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 { // FIX: Correctly await the composer result const fwdBuffer = await 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 (Using Nodemailer MailComposer for Safety) // --------------------------------------------------------------------------- async function buildOooReply( original: ParsedMail, recipient: string, oooMsg: string, contentType: string, ): Promise { 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; const composer = new MailComposer({ from: recipient, to: originalFrom, subject: `Out of Office: ${originalSubject}`, inReplyTo: originalMsgId, references: [originalMsgId], // Nodemailer wants array text: textContent, html: includeHtml ? htmlContent : undefined, headers: { 'Auto-Submitted': 'auto-replied', 'X-SES-Worker-Processed': 'ooo-reply', }, messageId: `<${Date.now()}.${Math.random().toString(36).slice(2)}@${recipientDomain}>` }); return composer.compile().build(); } async function buildForwardMessage( original: ParsedMail, recipient: string, forwardTo: string, originalFrom: string, ): Promise { const { text: textBody, html: htmlBody } = extractBodyParts(original); const originalSubject = original.subject ?? '(no subject)'; const originalDate = original.date?.toUTCString() ?? 'unknown'; // 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 += '
'; } // Config object for MailComposer const mailOptions: any = { from: recipient, to: forwardTo, subject: `FWD: ${originalSubject}`, replyTo: originalFrom, text: fwdText, html: fwdHtml, headers: { 'X-SES-Worker-Processed': 'forwarded', }, }; // Attachments if (original.attachments && original.attachments.length > 0) { mailOptions.attachments = original.attachments.map((att) => ({ filename: att.filename ?? 'attachment', content: att.content, contentType: att.contentType, cid: att.cid ?? undefined, contentDisposition: att.contentDisposition || 'attachment' })); } const composer = new MailComposer(mailOptions); return composer.compile().build(); } // --------------------------------------------------------------------------- // 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; }