BugFixes
This commit is contained in:
parent
16469de068
commit
b321e6d2ec
|
|
@ -1,15 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* Structured logging for email worker with daily rotation
|
* Structured logging for email worker with daily rotation AND retention
|
||||||
*
|
*
|
||||||
* Uses pino for high-performance JSON logging.
|
* Uses pino for high-performance JSON logging.
|
||||||
* Console output is human-readable via pino-pretty in dev,
|
* Includes logic to delete logs older than X days.
|
||||||
* and JSON in production (for Docker json-file driver).
|
|
||||||
*
|
|
||||||
* File logging uses a simple daily rotation approach.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { existsSync, mkdirSync, createWriteStream, type WriteStream } from 'node:fs';
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
createWriteStream,
|
||||||
|
type WriteStream,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
unlinkSync
|
||||||
|
} from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -17,9 +22,10 @@ import { join } from 'node:path';
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const LOG_DIR = '/var/log/email-worker';
|
const LOG_DIR = '/var/log/email-worker';
|
||||||
const LOG_FILE_PREFIX = 'worker';
|
const LOG_FILE_PREFIX = 'worker';
|
||||||
|
const RETENTION_DAYS = 14; // Logs älter als 14 Tage löschen
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// File stream (best-effort, never crashes the worker)
|
// File stream & Retention Logic
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
let fileStream: WriteStream | null = null;
|
let fileStream: WriteStream | null = null;
|
||||||
let currentDateStr = '';
|
let currentDateStr = '';
|
||||||
|
|
@ -28,15 +34,63 @@ function getDateStr(): string {
|
||||||
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht alte Log-Dateien basierend auf RETENTION_DAYS
|
||||||
|
*/
|
||||||
|
function cleanUpOldLogs(): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(LOG_DIR)) return;
|
||||||
|
|
||||||
|
const files = readdirSync(LOG_DIR);
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAgeMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Prüfen ob es eine unserer Log-Dateien ist
|
||||||
|
if (!file.startsWith(LOG_FILE_PREFIX) || !file.endsWith('.log')) continue;
|
||||||
|
|
||||||
|
const filePath = join(LOG_DIR, file);
|
||||||
|
try {
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
const ageMs = now - stats.mtimeMs;
|
||||||
|
|
||||||
|
if (ageMs > maxAgeMs) {
|
||||||
|
unlinkSync(filePath);
|
||||||
|
// Einmalig auf stdout loggen, damit man sieht, dass aufgeräumt wurde
|
||||||
|
process.stdout.write(`[INFO] Deleted old log file: ${file}\n`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignorieren, falls Datei gerade gelöscht wurde oder Zugriff verweigert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`[WARN] Failed to clean up old logs: ${err}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureFileStream(): WriteStream | null {
|
function ensureFileStream(): WriteStream | null {
|
||||||
const today = getDateStr();
|
const today = getDateStr();
|
||||||
|
|
||||||
|
// Wenn wir bereits einen Stream für heute haben, zurückgeben
|
||||||
if (fileStream && currentDateStr === today) return fileStream;
|
if (fileStream && currentDateStr === today) return fileStream;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Wenn sich das Datum geändert hat (oder beim ersten Start): Aufräumen
|
||||||
|
if (currentDateStr !== today) {
|
||||||
|
cleanUpOldLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alten Stream schließen, falls vorhanden
|
||||||
|
if (fileStream) {
|
||||||
|
fileStream.end();
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = join(LOG_DIR, `${LOG_FILE_PREFIX}.${today}.log`);
|
const filePath = join(LOG_DIR, `${LOG_FILE_PREFIX}.${today}.log`);
|
||||||
fileStream = createWriteStream(filePath, { flags: 'a' });
|
fileStream = createWriteStream(filePath, { flags: 'a' });
|
||||||
currentDateStr = today;
|
currentDateStr = today;
|
||||||
|
|
||||||
return fileStream;
|
return fileStream;
|
||||||
} catch {
|
} catch {
|
||||||
// Silently continue without file logging (e.g. permission issue)
|
// Silently continue without file logging (e.g. permission issue)
|
||||||
|
|
@ -55,12 +109,10 @@ const logger = pino({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
timestamp: pino.stdTimeFunctions.isoTime,
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
// In production Docker we write plain JSON to stdout;
|
|
||||||
// pino-pretty can be used during dev via `pino-pretty` pipe.
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Log level mapping (matches Python worker levels)
|
// Log level mapping
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
type LogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | 'SUCCESS';
|
type LogLevel = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | 'SUCCESS';
|
||||||
|
|
||||||
|
|
@ -74,7 +126,7 @@ const LEVEL_MAP: Record<LogLevel, keyof pino.Logger> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API – mirrors Python's log(message, level, worker_name)
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export function log(
|
export function log(
|
||||||
message: string,
|
message: string,
|
||||||
|
|
@ -84,11 +136,11 @@ export function log(
|
||||||
const prefix = level === 'SUCCESS' ? '[SUCCESS] ' : '';
|
const prefix = level === 'SUCCESS' ? '[SUCCESS] ' : '';
|
||||||
const formatted = `[${workerName}] ${prefix}${message}`;
|
const formatted = `[${workerName}] ${prefix}${message}`;
|
||||||
|
|
||||||
// Pino
|
// Pino (stdout/json)
|
||||||
const method = LEVEL_MAP[level] ?? 'info';
|
const method = LEVEL_MAP[level] ?? 'info';
|
||||||
(logger as any)[method](formatted);
|
(logger as any)[method](formatted);
|
||||||
|
|
||||||
// File (best-effort)
|
// File (plain text)
|
||||||
const stream = ensureFileStream();
|
const stream = ensureFileStream();
|
||||||
if (stream) {
|
if (stream) {
|
||||||
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
|
||||||
|
|
@ -312,17 +312,38 @@ export class MessageProcessor {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add X-SES-Worker-Processed header to raw email bytes.
|
* Add X-SES-Worker-Processed header to raw email bytes using Buffer manipulation.
|
||||||
|
* More robust and memory efficient than toString().
|
||||||
*/
|
*/
|
||||||
function addProcessedHeader(raw: Buffer): Buffer {
|
function addProcessedHeader(raw: Buffer): Buffer {
|
||||||
const str = raw.toString('utf-8');
|
// Wir suchen nach dem Ende der Header: Double Newline (\r\n\r\n oder \n\n)
|
||||||
const sep = str.match(/\r?\n\r?\n/);
|
let headerEndIndex = -1;
|
||||||
if (!sep || sep.index === undefined) return raw;
|
|
||||||
|
|
||||||
const before = str.slice(0, sep.index);
|
// Effiziente Suche im Buffer
|
||||||
const after = str.slice(sep.index);
|
for (let i = 0; i < raw.length - 3; i++) {
|
||||||
return Buffer.from(
|
// Check für \r\n\r\n
|
||||||
`${before}\r\nX-SES-Worker-Processed: delivered${after}`,
|
if (raw[i] === 0x0d && raw[i+1] === 0x0a && raw[i+2] === 0x0d && raw[i+3] === 0x0a) {
|
||||||
'utf-8',
|
headerEndIndex = i;
|
||||||
);
|
break;
|
||||||
|
}
|
||||||
|
// Check für \n\n (Unix style, seltener bei E-Mail aber möglich)
|
||||||
|
if (raw[i] === 0x0a && raw[i+1] === 0x0a) {
|
||||||
|
headerEndIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls keine Header-Trennung gefunden wurde (kaputte Mail?), hängen wir es einfach vorne an
|
||||||
|
if (headerEndIndex === -1) {
|
||||||
|
const headerLine = Buffer.from('X-SES-Worker-Processed: delivered\r\n', 'utf-8');
|
||||||
|
return Buffer.concat([headerLine, raw]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wir fügen den Header VOR der leeren Zeile ein
|
||||||
|
const before = raw.subarray(0, headerEndIndex);
|
||||||
|
const after = raw.subarray(headerEndIndex);
|
||||||
|
|
||||||
|
const newHeader = Buffer.from('\r\nX-SES-Worker-Processed: delivered', 'utf-8');
|
||||||
|
|
||||||
|
return Buffer.concat([before, newHeader, after]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* Email rules processing (Auto-Reply / OOO and Forwarding)
|
* Email rules processing (Auto-Reply / OOO and Forwarding)
|
||||||
*
|
* * CLEANED UP & FIXED:
|
||||||
* Removed: Legacy SMTP forward (forward_smtp_override)
|
* - Uses MailComposer for ALL message generation (safer MIME handling)
|
||||||
* Remaining paths:
|
* - Fixes broken attachment forwarding
|
||||||
* - OOO → internal (SMTP port 25) or external (SES)
|
* - Removed legacy SMTP forwarding
|
||||||
* - Forward → internal (SMTP port 25) or external (SES)
|
* - Removed manual string concatenation for MIME boundaries
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createTransport, type Transporter } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import type { ParsedMail } from 'mailparser';
|
import type { ParsedMail } from 'mailparser';
|
||||||
import type { DynamoDBHandler, EmailRule } from '../aws/dynamodb.js';
|
import type { DynamoDBHandler, EmailRule } from '../aws/dynamodb.js';
|
||||||
import type { SESHandler } from '../aws/ses.js';
|
import type { SESHandler } from '../aws/ses.js';
|
||||||
import { extractBodyParts } from './parser.js';
|
import { extractBodyParts } from './parser.js';
|
||||||
import { config, isInternalAddress } from '../config.js';
|
import { config, isInternalAddress } from '../config.js';
|
||||||
import { log } from '../logger.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 type MetricsCallback = (action: 'autoreply' | 'forward', domain: string) => void;
|
||||||
|
|
||||||
|
|
@ -25,7 +27,6 @@ export class RulesProcessor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process OOO and Forward rules for a single recipient.
|
* Process OOO and Forward rules for a single recipient.
|
||||||
* Returns false always (no skip_local_delivery since legacy SMTP removed).
|
|
||||||
*/
|
*/
|
||||||
async processRulesForRecipient(
|
async processRulesForRecipient(
|
||||||
recipient: string,
|
recipient: string,
|
||||||
|
|
@ -103,7 +104,9 @@ export class RulesProcessor {
|
||||||
try {
|
try {
|
||||||
const oooMsg = (rule.ooo_message as string) ?? 'I am out of office.';
|
const oooMsg = (rule.ooo_message as string) ?? 'I am out of office.';
|
||||||
const contentType = (rule.ooo_content_type as string) ?? 'text';
|
const contentType = (rule.ooo_content_type as string) ?? 'text';
|
||||||
const oooBuffer = buildOooReply(parsed, recipient, oooMsg, contentType);
|
|
||||||
|
// FIX: Use MailComposer via await
|
||||||
|
const oooBuffer = await buildOooReply(parsed, recipient, oooMsg, contentType);
|
||||||
|
|
||||||
if (isInternalAddress(senderAddr)) {
|
if (isInternalAddress(senderAddr)) {
|
||||||
const ok = await sendInternalEmail(recipient, senderAddr, oooBuffer, workerName);
|
const ok = await sendInternalEmail(recipient, senderAddr, oooBuffer, workerName);
|
||||||
|
|
@ -134,7 +137,8 @@ export class RulesProcessor {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const forwardTo of forwards) {
|
for (const forwardTo of forwards) {
|
||||||
try {
|
try {
|
||||||
const fwdBuffer = buildForwardMessage(parsed, recipient, forwardTo, originalFrom);
|
// FIX: Correctly await the composer result
|
||||||
|
const fwdBuffer = await buildForwardMessage(parsed, recipient, forwardTo, originalFrom);
|
||||||
|
|
||||||
if (isInternalAddress(forwardTo)) {
|
if (isInternalAddress(forwardTo)) {
|
||||||
const ok = await sendInternalEmail(recipient, forwardTo, fwdBuffer, workerName);
|
const ok = await sendInternalEmail(recipient, forwardTo, fwdBuffer, workerName);
|
||||||
|
|
@ -154,15 +158,15 @@ export class RulesProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Message building
|
// Message building (Using Nodemailer MailComposer for Safety)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildOooReply(
|
async function buildOooReply(
|
||||||
original: ParsedMail,
|
original: ParsedMail,
|
||||||
recipient: string,
|
recipient: string,
|
||||||
oooMsg: string,
|
oooMsg: string,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Buffer {
|
): Promise<Buffer> {
|
||||||
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
||||||
const originalSubject = original.subject ?? '(no subject)';
|
const originalSubject = original.subject ?? '(no subject)';
|
||||||
const originalFrom = original.from?.text ?? 'unknown';
|
const originalFrom = original.from?.text ?? 'unknown';
|
||||||
|
|
@ -184,32 +188,33 @@ function buildOooReply(
|
||||||
|
|
||||||
const includeHtml = contentType === 'html' || !!htmlBody;
|
const includeHtml = contentType === 'html' || !!htmlBody;
|
||||||
|
|
||||||
return buildMimeMessage({
|
const composer = new MailComposer({
|
||||||
from: recipient,
|
from: recipient,
|
||||||
to: originalFrom,
|
to: originalFrom,
|
||||||
subject: `Out of Office: ${originalSubject}`,
|
subject: `Out of Office: ${originalSubject}`,
|
||||||
inReplyTo: originalMsgId,
|
inReplyTo: originalMsgId,
|
||||||
references: originalMsgId,
|
references: [originalMsgId], // Nodemailer wants array
|
||||||
domain: recipientDomain,
|
text: textContent,
|
||||||
textContent,
|
html: includeHtml ? htmlContent : undefined,
|
||||||
htmlContent: includeHtml ? htmlContent : undefined,
|
headers: {
|
||||||
extraHeaders: {
|
|
||||||
'Auto-Submitted': 'auto-replied',
|
'Auto-Submitted': 'auto-replied',
|
||||||
'X-SES-Worker-Processed': 'ooo-reply',
|
'X-SES-Worker-Processed': 'ooo-reply',
|
||||||
},
|
},
|
||||||
|
messageId: `<${Date.now()}.${Math.random().toString(36).slice(2)}@${recipientDomain}>`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return composer.compile().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildForwardMessage(
|
async function buildForwardMessage(
|
||||||
original: ParsedMail,
|
original: ParsedMail,
|
||||||
recipient: string,
|
recipient: string,
|
||||||
forwardTo: string,
|
forwardTo: string,
|
||||||
originalFrom: string,
|
originalFrom: string,
|
||||||
): Buffer {
|
): Promise<Buffer> {
|
||||||
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
const { text: textBody, html: htmlBody } = extractBodyParts(original);
|
||||||
const originalSubject = original.subject ?? '(no subject)';
|
const originalSubject = original.subject ?? '(no subject)';
|
||||||
const originalDate = original.date?.toUTCString() ?? 'unknown';
|
const originalDate = original.date?.toUTCString() ?? 'unknown';
|
||||||
const recipientDomain = recipient.split('@')[1];
|
|
||||||
|
|
||||||
// Text version
|
// Text version
|
||||||
let fwdText = '---------- Forwarded message ---------\n';
|
let fwdText = '---------- Forwarded message ---------\n';
|
||||||
|
|
@ -232,144 +237,32 @@ function buildForwardMessage(
|
||||||
fwdHtml += '</div>';
|
fwdHtml += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build base message
|
// Config object for MailComposer
|
||||||
const baseBuffer = buildMimeMessage({
|
const mailOptions: any = {
|
||||||
from: recipient,
|
from: recipient,
|
||||||
to: forwardTo,
|
to: forwardTo,
|
||||||
subject: `FWD: ${originalSubject}`,
|
subject: `FWD: ${originalSubject}`,
|
||||||
replyTo: originalFrom,
|
replyTo: originalFrom,
|
||||||
domain: recipientDomain,
|
text: fwdText,
|
||||||
textContent: fwdText,
|
html: fwdHtml,
|
||||||
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: {
|
headers: {
|
||||||
'X-SES-Worker-Processed': 'forwarded',
|
'X-SES-Worker-Processed': 'forwarded',
|
||||||
},
|
},
|
||||||
attachments: attachments.map((att) => ({
|
};
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
if (original.attachments && original.attachments.length > 0) {
|
||||||
|
mailOptions.attachments = original.attachments.map((att) => ({
|
||||||
filename: att.filename ?? 'attachment',
|
filename: att.filename ?? 'attachment',
|
||||||
content: att.content,
|
content: att.content,
|
||||||
contentType: att.contentType,
|
contentType: att.contentType,
|
||||||
cid: att.cid ?? undefined,
|
cid: att.cid ?? undefined,
|
||||||
})),
|
contentDisposition: att.contentDisposition || 'attachment'
|
||||||
};
|
}));
|
||||||
|
|
||||||
if (htmlContent) {
|
|
||||||
mailOptions.html = htmlContent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const composer = new MailComposer(mailOptions);
|
const composer = new MailComposer(mailOptions);
|
||||||
// build() returns a stream, but we can use buildAsync pattern
|
return composer.compile().build();
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue