414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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 = `<div>${oooMsg}</div><br><hr><br>`;
|
|
htmlContent += '<strong>Original Message</strong><br>';
|
|
htmlContent += `<strong>From:</strong> ${originalFrom}<br>`;
|
|
htmlContent += `<strong>Subject:</strong> ${originalSubject}<br><br>`;
|
|
htmlContent += htmlBody ? htmlBody : textBody.replace(/\n/g, '<br>');
|
|
|
|
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 = "<div style='border-left:3px solid #ccc;padding-left:10px;'>";
|
|
fwdHtml += '<strong>---------- Forwarded message ---------</strong><br>';
|
|
fwdHtml += `<strong>From:</strong> ${originalFrom}<br>`;
|
|
fwdHtml += `<strong>Date:</strong> ${originalDate}<br>`;
|
|
fwdHtml += `<strong>Subject:</strong> ${originalSubject}<br>`;
|
|
fwdHtml += `<strong>To:</strong> ${recipient}<br><br>`;
|
|
fwdHtml += htmlBody;
|
|
fwdHtml += '</div>';
|
|
}
|
|
|
|
// 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<string, string>;
|
|
}
|
|
|
|
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<boolean> {
|
|
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;
|
|
}
|