/** * Bounce detection and header rewriting * * When Amazon SES returns a bounce, the From header is * mailer-daemon@amazonses.com. We look up the original sender * in DynamoDB and rewrite the headers so the bounce appears * to come from the actual bounced recipient. */ import type { ParsedMail } from 'mailparser'; import type { DynamoDBHandler } from '../aws/dynamodb.js'; import { isSesBounceNotification, getHeader } from './parser.js'; import { log } from '../logger.js'; export interface BounceResult { /** Updated raw bytes (headers rewritten if bounce was detected) */ rawBytes: Buffer; /** Whether bounce was detected and headers were modified */ modified: boolean; /** Whether this email is a bounce notification at all */ isBounce: boolean; /** The effective From address (rewritten or original) */ fromAddr: string; } export class BounceHandler { constructor(private dynamodb: DynamoDBHandler) {} /** * Detect SES bounce, look up original sender in DynamoDB, * and rewrite headers in the raw buffer. * * We operate on the raw Buffer because we need to preserve * the original MIME structure exactly, only swapping specific * header lines. mailparser's ParsedMail is read-only. */ async applyBounceLogic( parsed: ParsedMail, rawBytes: Buffer, subject: string, workerName = 'unified', ): Promise { if (!isSesBounceNotification(parsed)) { return { rawBytes, modified: false, isBounce: false, fromAddr: parsed.from?.text ?? '', }; } log('🔍 Detected SES MAILER-DAEMON bounce notification', 'INFO', workerName); // Extract Message-ID from the bounce notification header const rawMessageId = getHeader(parsed, 'message-id') .replace(/^$/, '') .split('@')[0]; if (!rawMessageId) { log('⚠ Could not extract Message-ID from bounce notification', 'WARNING', workerName); return { rawBytes, modified: false, isBounce: true, fromAddr: parsed.from?.text ?? '', }; } log(` Looking up Message-ID: ${rawMessageId}`, 'INFO', workerName); const bounceInfo = await this.dynamodb.getBounceInfo(rawMessageId, workerName); if (!bounceInfo) { return { rawBytes, modified: false, isBounce: true, fromAddr: parsed.from?.text ?? '', }; } // Log bounce details log(`✓ Found bounce info:`, 'INFO', workerName); log(` Original sender: ${bounceInfo.original_source}`, 'INFO', workerName); log(` Bounce type: ${bounceInfo.bounceType}/${bounceInfo.bounceSubType}`, 'INFO', workerName); log(` Bounced recipients: ${bounceInfo.bouncedRecipients}`, 'INFO', workerName); if (!bounceInfo.bouncedRecipients.length) { log('⚠ No bounced recipients found in bounce info', 'WARNING', workerName); return { rawBytes, modified: false, isBounce: true, fromAddr: parsed.from?.text ?? '', }; } const newFrom = bounceInfo.bouncedRecipients[0]; // Rewrite headers in raw bytes let modifiedBytes = rawBytes; const originalFrom = getHeader(parsed, 'from'); // Replace From header modifiedBytes = replaceHeader(modifiedBytes, 'From', newFrom); // Add diagnostic headers modifiedBytes = addHeader(modifiedBytes, 'X-Original-SES-From', originalFrom); modifiedBytes = addHeader( modifiedBytes, 'X-Bounce-Type', `${bounceInfo.bounceType}/${bounceInfo.bounceSubType}`, ); // Add Reply-To if not present if (!getHeader(parsed, 'reply-to')) { modifiedBytes = addHeader(modifiedBytes, 'Reply-To', newFrom); } // Adjust subject for generic delivery status notifications const subjectLower = subject.toLowerCase(); if ( subjectLower.includes('delivery status notification') || subjectLower.includes('thanks for your submission') ) { modifiedBytes = replaceHeader( modifiedBytes, 'Subject', `Delivery Status: ${newFrom}`, ); } log(`✓ Rewritten FROM: ${newFrom}`, 'SUCCESS', workerName); return { rawBytes: modifiedBytes, modified: true, isBounce: true, fromAddr: newFrom, }; } } // --------------------------------------------------------------------------- // Raw header manipulation helpers // --------------------------------------------------------------------------- /** * Replace a header value in raw MIME bytes. * Handles multi-line (folded) headers. */ function replaceHeader(raw: Buffer, name: string, newValue: string): Buffer { const str = raw.toString('utf-8'); // Match header including potential folded continuation lines const regex = new RegExp( `^(${escapeRegex(name)}:\\s*).*?(\\r?\\n(?=[^ \\t])|\\r?\\n$)`, 'im', ); // Also need to consume folded lines const foldedRegex = new RegExp( `^${escapeRegex(name)}:[ \\t]*[^\\r\\n]*(?:\\r?\\n[ \\t]+[^\\r\\n]*)*`, 'im', ); const match = foldedRegex.exec(str); if (!match) return raw; const before = str.slice(0, match.index); const after = str.slice(match.index + match[0].length); const replaced = `${before}${name}: ${newValue}${after}`; return Buffer.from(replaced, 'utf-8'); } /** * Add a new header line right before the header/body separator. */ function addHeader(raw: Buffer, name: string, value: string): Buffer { const str = raw.toString('utf-8'); // Find the header/body boundary (first blank line) const sep = str.match(/\r?\n\r?\n/); if (!sep || sep.index === undefined) return raw; const before = str.slice(0, sep.index); const after = str.slice(sep.index); return Buffer.from(`${before}\r\n${name}: ${value}${after}`, 'utf-8'); } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }