191 lines
5.8 KiB
TypeScript
191 lines
5.8 KiB
TypeScript
/**
|
|
* 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<BounceResult> {
|
|
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(/^</, '')
|
|
.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, '\\$&');
|
|
}
|