new node.js impl., removed old stuff
This commit is contained in:
190
email-worker-nodejs/bounce-handler.ts
Normal file
190
email-worker-nodejs/bounce-handler.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user