// Self-contained SMTP transport for server-to-server (queued) email. // // Extracted from the proven conversation in `send-smtp-email` so the automated // email dispatcher (`process-email-queue`) can send through the same SMTP path // the app already uses — no third-party email API required. // // `sendSmtpMessage` throws on any failure so the caller's existing retry / DLQ // logic can record and reschedule the message. export type SmtpSender = { host: string; port: number; username: string; password: string; use_tls?: boolean; use_ssl?: boolean; fromEmail: string; fromName?: string; }; export type SmtpMessage = { to: string; subject: string; html: string; text?: string; /** When set, adds List-Unsubscribe + one-click headers (bulk deliverability). */ unsubscribeUrl?: string; }; const SMTP_CONNECT_TIMEOUT_MS = 20000; const SMTP_COMMAND_TIMEOUT_MS = 30000; function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { let timeoutId: number | undefined; return new Promise((resolve, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); promise.then(resolve).catch(reject).finally(() => { if (timeoutId !== undefined) clearTimeout(timeoutId); }); }); } function sanitizeHeaderValue(value: string): string { return String(value ?? "").replace(/[\r\n]+/g, " ").trim(); } function formatAddressHeader(email: string, displayName?: string): string { const safeEmail = sanitizeHeaderValue(email); const safeName = sanitizeHeaderValue(displayName || ""); if (!safeName) return safeEmail; const escaped = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const needsQuotes = /[",;<>@\[\]\(\):]/.test(escaped); return needsQuotes ? `"${escaped}" <${safeEmail}>` : `${escaped} <${safeEmail}>`; } function toBase64Utf8(value: string): string { const bytes = new TextEncoder().encode(value); let binary = ""; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } function chunkBase64(value: string, lineLength = 76): string { const lines: string[] = []; for (let i = 0; i < value.length; i += lineLength) lines.push(value.slice(i, i + lineLength)); return lines.join("\r\n"); } function dotStuff(content: string): string { return content.replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1.."); } function htmlToPlainText(html: string): string { return html .replace(/<\s*br\s*\/?>/gi, "\n") .replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|tr|section)\s*>/gi, "\n") .replace(//gi, "") .replace(//gi, "") .replace(/<[^>]+>/g, "") .replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">") .replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); } export async function sendSmtpMessage(sender: SmtpSender, msg: SmtpMessage): Promise { const { host, password, username } = sender; const port = Number(sender.port || 587); const use_ssl = sender.use_ssl ?? port === 465; const use_tls = sender.use_tls ?? port === 587; if (!host || !username || !password || !sender.fromEmail) { throw new Error("Incomplete SMTP sender configuration (host, username, password, fromEmail required)"); } const to = sanitizeHeaderValue(msg.to); if (!to) throw new Error("Missing recipient"); const fromHeader = formatAddressHeader(sender.fromEmail, sender.fromName); const subject = sanitizeHeaderValue(msg.subject); const html = String(msg.html ?? "").trim() || ``; const text = msg.text && msg.text.trim() ? msg.text : (htmlToPlainText(html) || subject); const domain = sender.fromEmail.includes("@") ? sender.fromEmail.split("@")[1] : host; const messageId = `<${crypto.randomUUID()}@${domain}>`; const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, "")}`; const headers: string[] = [ `From: ${fromHeader}`, `To: ${to}`, `Reply-To: ${sanitizeHeaderValue(sender.fromEmail)}`, `Subject: ${subject}`, `Date: ${new Date().toUTCString()}`, `Message-ID: ${messageId}`, `MIME-Version: 1.0`, ]; if (msg.unsubscribeUrl) { headers.push(`List-Unsubscribe: <${sanitizeHeaderValue(msg.unsubscribeUrl)}>`); headers.push(`List-Unsubscribe-Post: List-Unsubscribe=One-Click`); } headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); let mime = headers.join("\r\n") + "\r\n\r\n"; mime += `--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`; mime += chunkBase64(toBase64Utf8(text)) + "\r\n\r\n"; mime += `--${boundary}\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`; mime += chunkBase64(toBase64Utf8(html)) + "\r\n\r\n"; mime += `--${boundary}--\r\n`; const conn: Deno.Conn = use_ssl ? await withTimeout(Deno.connectTls({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting (SSL) to ${host}:${port}`) : await withTimeout(Deno.connect({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting to ${host}:${port}`); const encoder = new TextEncoder(); const decoder = new TextDecoder(); const read = async (c: Deno.Conn): Promise => { const buf = new Uint8Array(4096); const n = await withTimeout(c.read(buf), SMTP_COMMAND_TIMEOUT_MS, `Timed out awaiting SMTP response from ${host}`); if (n === null) throw new Error("SMTP server closed the connection unexpectedly"); return decoder.decode(buf.subarray(0, n)); }; const cmd = async (c: Deno.Conn, command: string): Promise => { await withTimeout(c.write(encoder.encode(command + "\r\n")), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending SMTP command to ${host}`); return await read(c); }; try { await read(conn); let active: Deno.Conn = conn; const ehlo = await cmd(active, "EHLO localhost"); if (!use_ssl && use_tls && ehlo.includes("STARTTLS")) { const st = await cmd(active, "STARTTLS"); if (!st.startsWith("220")) throw new Error(`STARTTLS not available: ${st.trim()}`); active = await withTimeout(Deno.startTls(active as Deno.TcpConn, { hostname: host }), SMTP_COMMAND_TIMEOUT_MS, `Timed out on STARTTLS upgrade for ${host}`); await cmd(active, "EHLO localhost"); } await cmd(active, "AUTH LOGIN"); await cmd(active, btoa(username)); const authResp = await cmd(active, btoa(password)); if (!authResp.startsWith("235")) throw new Error(`SMTP authentication failed for ${username}: ${authResp.trim()}`); const mailFrom = await cmd(active, `MAIL FROM:<${sanitizeHeaderValue(sender.fromEmail)}>`); if (!mailFrom.startsWith("250")) throw new Error(`MAIL FROM rejected: ${mailFrom.trim()}`); const rcpt = await cmd(active, `RCPT TO:<${to}>`); if (!rcpt.startsWith("250") && !rcpt.startsWith("251")) throw new Error(`RCPT TO rejected for ${to}: ${rcpt.trim()}`); const dataResp = await cmd(active, "DATA"); if (!dataResp.startsWith("354")) throw new Error(`DATA rejected: ${dataResp.trim()}`); const payload = dotStuff(mime) + "\r\n.\r\n"; const CHUNK = 4096; for (let off = 0; off < payload.length; off += CHUNK) { await withTimeout(active.write(encoder.encode(payload.slice(off, off + CHUNK))), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending message body to ${host}`); } const finalResp = await read(active); try { await cmd(active, "QUIT"); } catch (_) { /* ignore */ } if (!finalResp.startsWith("250")) throw new Error(`Message rejected by ${host}: ${finalResp.trim()}`); return finalResp.trim(); } finally { try { conn.close(); } catch (_) { /* already closed */ } } }