/** * apps/worker — minimal SMTP client (node:net only, no deps). * * Implements the bare RFC 5321 dialogue needed for outbound mail: * * connect → 220 → EHLO factory.local → 250 (+capabilities) * → [STARTTLS → 220 → EHLO again] when STARTTLS is offered and enabled * → MAIL FROM: → 250 * → RCPT TO: → 250 * → DATA → 354 * → \r\n\r\n.\r\n → 250 * → QUIT → 221 * * - dot-stuffing: a body line beginning with "." is prefixed with an extra * "." (RFC 5321 §4.5.2) and the message terminates with a lone ".\r\n", * - responses are matched by 3-digit code; multi-line replies join on the * space-before-the-code boundary, * - timeouts on every command (default 30s) — a hung SMTP server must not * wedge the email queue forever (the job still retries via BullMQ). * * The client is exported separately so the command sequence is unit-testable * against a fake socket (see __tests__/smtp.test.ts). */ import { connect, type Socket } from "node:net"; import { connect as tlsConnect, type TLSSocket } from "node:tls"; export interface SmtpSendOptions { host: string; port: number; user: string; pass: string; from: string; to: string[]; subject: string; html?: string; text?: string; /** Force STARTTLS when the server advertises it (default true). */ startTls?: boolean; localName?: string; commandTimeoutMs?: number; } export class SmtpError extends Error { constructor(message: string, public code?: number) { super(message); this.name = "SmtpError"; } } interface Pending { resolve: (line: string) => void; reject: (err: Error) => void; timer: NodeJS.Timeout; } /** * Stateful line reader: buffers socket data, splits CRLF lines, resolves the * next pending command promise per line. Multi-line 250/4xx replies are * joined by the caller (each physical line arrives separately; we resolve on * the LAST line of a reply — detected by the space-vs-hyphen convention: * continuation lines have a "-" in column 4). */ export class SmtpSession { private buf = Buffer.alloc(0); private pending: Pending | null = null; private lastReply = ""; private closed = false; constructor(private socket: Socket | TLSSocket) { socket.on("data", (chunk) => this.onData(chunk)); socket.on("error", (err) => this.fail(err)); socket.on("close", () => this.fail(new SmtpError("smtp connection closed"))); } private onData(chunk: Buffer): void { this.buf = Buffer.concat([this.buf, chunk]); let idx: number; while ((idx = this.buf.indexOf(0x0a)) >= 0) { const line = this.buf.subarray(0, idx).toString("utf8").replace(/\r$/, ""); this.buf = this.buf.subarray(idx + 1); this.handleLine(line); } } private handleLine(line: string): void { // RFC 5321: continuation lines are "NNN-text", final lines "NNN text". const isContinuation = line.length > 3 && line[3] === "-"; if (isContinuation) { this.lastReply += line + "\n"; return; } this.lastReply += line + "\n"; if (this.pending) { const p = this.pending; this.pending = null; clearTimeout(p.timer); p.resolve(this.lastReply.trimEnd()); this.lastReply = ""; } } /** Send a raw command line (CRLF added). */ write(line: string): void { this.socket.write(line + "\r\n"); } /** Write arbitrary bytes (used for the DATA payload). */ writeRaw(data: string): void { this.socket.write(data); } private fail(err: Error): void { if (this.closed) return; this.closed = true; if (this.pending) { const p = this.pending; this.pending = null; clearTimeout(p.timer); p.reject(err); } } /** Wait for the next complete reply. */ expect(commandTimeoutMs: number): Promise { if (this.pending) { return Promise.reject(new SmtpError("command already in flight")); } if (this.lastReply) { const r = this.lastReply.trimEnd(); this.lastReply = ""; return Promise.resolve(r); } return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending = null; reject(new SmtpError("smtp command timeout")); }, commandTimeoutMs); this.pending = { resolve, reject, timer }; }); } codeOf(reply: string): number { return Number(reply.slice(0, 3)); } end(): void { this.closed = true; this.socket.end(); } destroy(): void { this.closed = true; this.socket.destroy(); } } function requireCode(session: SmtpSession, reply: string, expected: number[], what: string): void { const code = session.codeOf(reply); if (!expected.includes(code)) { throw new SmtpError(`smtp ${what} rejected: ${reply}`, code); } } /** * Send one email. Resolves on 250-after-DATA; throws SmtpError on any * protocol/transport failure (BullMQ retries the job). */ export async function smtpSend(opts: SmtpSendOptions): Promise { const timeout = opts.commandTimeoutMs ?? 30_000; const localName = opts.localName ?? "factory.local"; await new Promise((resolve, reject) => { const socket: Socket = connect({ host: opts.host, port: opts.port }); let session: SmtpSession | null = null; let done = false; let upgraded = false; const fail = (err: Error): void => { if (done) return; done = true; socket.destroy(); reject(err); }; socket.setTimeout(timeout, () => fail(new SmtpError("smtp connect timeout"))); socket.once("error", (err) => fail(err)); socket.once("connect", async () => { try { session = new SmtpSession(socket); const banner = await session.expect(timeout); requireCode(session, banner, [220], "banner"); // EHLO let ehloReply = await ehlo(session, localName, timeout); if ((opts.startTls ?? true) && /STARTTLS/i.test(ehloReply) && !upgraded) { upgraded = true; // 1) ask, 2) get "220 Ready to start TLS", 3) upgrade the socket. await cmd(session, "STARTTLS", timeout, [220], "STARTTLS"); const secure = await new Promise((res, rej) => { const t = tlsConnect({ socket, servername: opts.host }, () => res(t)); t.once("error", rej); }); session = new SmtpSession(secure); ehloReply = await ehlo(session, localName, timeout); } // AUTH LOGIN (only when the server advertises it and we have creds) if (opts.user && /AUTH\s+LOGIN/i.test(ehloReply)) { const r1 = await cmd(session, `AUTH LOGIN`, timeout, [334], "AUTH LOGIN"); void r1; const r2 = await cmd(session, Buffer.from(opts.user, "utf8").toString("base64"), timeout, [334], "auth user"); void r2; await cmd(session, Buffer.from(opts.pass, "utf8").toString("base64"), timeout, [235], "auth pass"); } // MAIL FROM / RCPT TO await cmd(session, `MAIL FROM:<${opts.from}>`, timeout, [250], "MAIL FROM"); for (const to of opts.to) { await cmd(session, `RCPT TO:<${to}>`, timeout, [250, 251], "RCPT TO"); } // DATA await cmd(session, "DATA", timeout, [354], "DATA"); const body = buildMessage(opts); const stuffed = dotStuff(body); session.writeRaw(stuffed + "\r\n.\r\n"); const dataOk = await session.expect(timeout); requireCode(session, dataOk, [250], "DATA accept"); await cmd(session, "QUIT", timeout, [221], "QUIT"); done = true; socket.end(); resolve(); } catch (err) { fail(err instanceof Error ? err : new SmtpError(String(err))); } }); }); } async function ehlo( session: SmtpSession, localName: string, timeout: number ): Promise { const reply = await cmd(session, `EHLO ${localName}`, timeout, [250], "EHLO"); return reply; } async function cmd( session: SmtpSession, line: string, timeout: number, expected: number[], what: string ): Promise { session.write(line); const reply = await session.expect(timeout); requireCode(session, reply, expected, what); return reply; } /** Build the full RFC 5322 message (headers + multipart body). */ export function buildMessage(opts: { from: string; to: string[]; subject: string; html?: string; text?: string; messageId?: string; }): string { const boundary = "----factory-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const lines: string[] = [ `From: ${opts.from}`, `To: ${opts.to.join(", ")}`, `Subject: ${encodeHeader(opts.subject)}`, "MIME-Version: 1.0", "Content-Type: multipart/alternative; boundary=" + boundary, `Message-ID: ${opts.messageId ?? "<" + Date.now() + "@factory.local>"}`, "Date: " + new Date().toUTCString(), "" ]; const parts: string[] = []; if (opts.text) { parts.push( `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${toBase64Wrapped(opts.text)}` ); } if (opts.html ?? opts.text) { const html = opts.html ?? `
${escapeHtml(opts.text ?? "")}
`; parts.push( `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${toBase64Wrapped(html)}` ); } lines.push(...parts, `--${boundary}--`, ""); return lines.join("\r\n"); } /** RFC 5321 §4.5.2 dot-stuffing. */ export function dotStuff(message: string): string { return message .split("\r\n") .map((line) => (line.startsWith(".") ? "." + line : line)) .join("\r\n"); } function toBase64Wrapped(s: string): string { const b64 = Buffer.from(s, "utf8").toString("base64"); // 76-char lines per RFC 2045 return b64.match(/.{1,76}/g)?.join("\r\n") ?? ""; } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Encode a UTF-8 header value (Subject) with RFC 2047 words. */ export function encodeHeader(value: string): string { if (/^[\x20-\x7e]*$/.test(value)) return value; const b64 = Buffer.from(value, "utf8").toString("base64"); return b64 .match(/.{1,75}/g) ?.map((chunk, i, arr) => (i === arr.length - 1 ? `=?utf-8?B?${chunk}?=` : `=?utf-8?B?${chunk}?=`)) ?.join("") ?? value; }