/** * Tina4 Messenger — SMTP email client using Node.js built-in modules only. * * Sends email via raw SMTP socket communication (net/tls). * No nodemailer, no external dependencies. * * Unified .env-driven configuration with constructor override. * Priority: constructor params > .env (TINA4_MAIL_* with SMTP_* fallback) > sensible defaults * * // .env * // TINA4_MAIL_HOST=smtp.gmail.com * // TINA4_MAIL_PORT=587 * // TINA4_MAIL_USERNAME=user@gmail.com * // TINA4_MAIL_PASSWORD=app-password * // TINA4_MAIL_FROM=noreply@myapp.com * // TINA4_MAIL_ENCRYPTION=tls * // TINA4_MAIL_IMAP_HOST=imap.gmail.com * // TINA4_MAIL_IMAP_PORT=993 * * import { Messenger } from "@tina4/core"; * * const mail = new Messenger(); // reads from .env * const mail = new Messenger({ host: "smtp.office365.com", port: 587 }); // override * await mail.send("user@test.com", "Welcome", "

Hello!

", true, "Hello!"); */ import net from "node:net"; import tls from "node:tls"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; import { randomUUID } from "node:crypto"; import { isTruthy } from "./dotenv.js"; import { DevMailbox } from "./devMailbox.js"; import { Log } from "./logger.js"; /** * TLS certificate validation defaults to SECURE (rejectUnauthorized: true). * Set TINA4_MAIL_TLS_INSECURE=true to disable validation for dev / self-signed * certificates ONLY — never in production. Previously this was hard-coded to * `rejectUnauthorized: false`, silently disabling certificate validation for * every TLS connection (a man-in-the-middle risk). */ function tlsRejectUnauthorized(): boolean { return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE); } /** * Parse TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): comma-separated addresses, each * trimmed, blanks dropped. Unset/empty -> [] (redirect off, no behaviour * change). Read fresh on every send() call — same style as shouldCapture()'s * TINA4_MAIL_CAPTURE read — so a changed env is honoured without restart. */ function parseMailRedirectList(raw: string | undefined): string[] { if (!raw) return []; return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0); } // ── Types ──────────────────────────────────────────────────── export interface SendResult { success: boolean; message: string; /** * The real Message-ID on success, `null` on failure — but ALWAYS present, so a * caller reading `result.id` gets one shape from both branches (G6). It used to * be omitted on the failure path, handing back `undefined` there and a string on * success. */ id: string | null; } /** * Raised when an IMAP read fails to connect, authenticate, or speak the * protocol (a `NO`/`BAD` tagged response, a refused/reset socket, a TLS or * DNS failure). Distinct from a SUCCESSFUL fetch that simply has no messages — * that still returns an empty result ([] / 0 / {}), NOT an error. * * inbox()/read()/unread()/search()/folders() LOG and then RAISE this on a * connection/protocol failure so a dead mailbox is never silently mistaken for * an empty one. send() is unchanged — it keeps returning { success, error }. */ export class MessengerConnectionError extends Error { constructor(message: string) { super(message); this.name = "MessengerConnectionError"; } } export interface EmailMessage { id: string; type: "inbox" | "outbox"; from: string; to: string[]; cc: string[]; bcc: string[]; reply_to?: string; subject: string; body: string; /** Plain-text alternative. Carried on the dev path too, so the captured message * is the message: a mailbox that shows you something other than what you wrote * is worse than no mailbox. */ text?: string; html: boolean; attachments: string[]; date: string; read: boolean; } interface MessengerOptions { host?: string; port?: number; username?: string; password?: string; fromAddress?: string; fromName?: string; encryption?: string; /** @deprecated Use encryption instead */ useTls?: boolean; imapHost?: string; imapPort?: number; imapUser?: string; imapPass?: string; /** IMAP transport security: "tls" (default), "starttls", or "none". */ imapEncryption?: string; } export interface ImapMessage { uid: string; subject: string; from: string; to: string; date: string; snippet: string; seen: boolean; } /** * An attachment from a read() message. `content` is the RAW DECODED BYTES of the * part (transfer-decoded from base64 / quoted-printable), the SAME convention as * req.files[x].content — raw bytes, not base64 — so an attachment is downloadable * as-is; `size` is that decoded byte length. Parity with Python's read() * attachment dict {filename, content_type, size, content}, in Node's idiomatic * camelCase (ADR-0008 / G5). #69 folded the bytes in HERE — there is no separate * carrier (Python retired its attachments_data in 3.13.96). */ export interface ImapAttachment { filename: string; contentType: string; size: number; content: Buffer; } export interface ImapFullMessage { uid: string; subject: string; from: string; to: string; cc: string; /** ISO-8601, parsed from the Date header (parity with Python's _iso_date). */ date: string; bodyText: string; bodyHtml: string; /** Attachments, each carrying its decoded bytes. Empty when the message has none (G5 / #69). */ attachments: ImapAttachment[]; headers: Record; } interface SendOptions { to: string | string[]; subject: string; body: string; html?: boolean; text?: string; cc?: string | string[]; bcc?: string | string[]; replyTo?: string; attachments?: string[]; headers?: Record; } type SmtpSocket = net.Socket | tls.TLSSocket; interface MailRecipients { toList: string[]; ccList: string[]; bccList: string[]; allRecipients: string[]; } class SmtpCommandError extends Error { constructor(message: string) { super(message); this.name = "SmtpCommandError"; } } // ── SMTP helpers ───────────────────────────────────────────── /** * Read a single SMTP response line (or multiline continuation). * Returns the status code and full response text. */ function readResponse(socket: net.Socket | tls.TLSSocket): Promise<{ code: number; text: string }> { return new Promise((resolve, reject) => { let buffer = ""; const onData = (chunk: Buffer) => { buffer += chunk.toString("utf-8"); // SMTP multiline: "250-..." continuation, "250 ..." final const lines = buffer.split("\r\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.length < 3) continue; const code = parseInt(line.substring(0, 3), 10); // Final line has a space after the code if (line.length >= 4 && line[3] === " ") { socket.removeListener("data", onData); socket.removeListener("error", onError); resolve({ code, text: buffer.trim() }); return; } } }; const onError = (err: Error) => { socket.removeListener("data", onData); reject(err); }; socket.on("data", onData); socket.on("error", onError); }); } /** * Send a command and read the response. */ function sendCommand( socket: net.Socket | tls.TLSSocket, command: string, ): Promise<{ code: number; text: string }> { return new Promise((resolve, reject) => { socket.write(command + "\r\n", "utf-8", (err) => { if (err) return reject(err); readResponse(socket).then(resolve, reject); }); }); } /** * Build an RFC 2822 MIME message. */ function buildMimeMessage(options: { from: string; fromName?: string; to: string[]; cc: string[]; subject: string; body: string; html: boolean; text?: string; replyTo?: string; attachments?: string[]; headers?: Record; messageId: string; }): string { const boundary = `----=_Tina4_${Date.now()}_${Math.random().toString(36).substring(2)}`; const altBoundary = `----=_Tina4Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`; const hasAttachments = options.attachments && options.attachments.length > 0; const hasTextAlt = options.text !== undefined && options.html; const lines: string[] = []; // Headers const fromHeader = options.fromName ? `"${options.fromName}" <${options.from}>` : options.from; lines.push(`From: ${fromHeader}`); lines.push(`To: ${options.to.join(", ")}`); if (options.cc.length > 0) { lines.push(`Cc: ${options.cc.join(", ")}`); } lines.push(`Subject: ${options.subject}`); lines.push(`Date: ${new Date().toUTCString()}`); lines.push(`Message-ID: <${options.messageId}>`); lines.push("MIME-Version: 1.0"); if (options.replyTo) { lines.push(`Reply-To: ${options.replyTo}`); } // Custom headers if (options.headers) { for (const [key, value] of Object.entries(options.headers)) { lines.push(`${key}: ${value}`); } } if (hasAttachments) { lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); lines.push(""); lines.push(`--${boundary}`); // Body part (with optional text alternative) if (hasTextAlt) { lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); lines.push(""); lines.push(`--${altBoundary}`); lines.push("Content-Type: text/plain; charset=UTF-8"); lines.push("Content-Transfer-Encoding: 7bit"); lines.push(""); lines.push(options.text!); lines.push(""); lines.push(`--${altBoundary}`); lines.push("Content-Type: text/html; charset=UTF-8"); lines.push("Content-Transfer-Encoding: 7bit"); lines.push(""); lines.push(options.body); lines.push(""); lines.push(`--${altBoundary}--`); } else { const contentType = options.html ? "text/html" : "text/plain"; lines.push(`Content-Type: ${contentType}; charset=UTF-8`); lines.push("Content-Transfer-Encoding: 7bit"); lines.push(""); lines.push(options.body); } // Attachments for (const filePath of options.attachments!) { const fileName = basename(filePath); const fileData = readFileSync(filePath); const base64Data = fileData.toString("base64"); lines.push(""); lines.push(`--${boundary}`); lines.push(`Content-Type: application/octet-stream; name="${fileName}"`); lines.push("Content-Transfer-Encoding: base64"); lines.push(`Content-Disposition: attachment; filename="${fileName}"`); lines.push(""); // Split base64 into 76-char lines per RFC 2045 for (let i = 0; i < base64Data.length; i += 76) { lines.push(base64Data.substring(i, i + 76)); } } lines.push(""); lines.push(`--${boundary}--`); } else if (hasTextAlt) { // Text alternative without attachments lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); lines.push(""); lines.push(`--${altBoundary}`); lines.push("Content-Type: text/plain; charset=UTF-8"); lines.push("Content-Transfer-Encoding: 7bit"); lines.push(""); lines.push(options.text!); lines.push(""); lines.push(`--${altBoundary}`); lines.push("Content-Type: text/html; charset=UTF-8"); lines.push("Content-Transfer-Encoding: 7bit"); lines.push(""); lines.push(options.body); lines.push(""); lines.push(`--${altBoundary}--`); } else { // Simple message const contentType = options.html ? "text/html" : "text/plain"; lines.push(`Content-Type: ${contentType}; charset=UTF-8`); lines.push(""); lines.push(options.body); } return lines.join("\r\n"); } // ── Messenger ──────────────────────────────────────────────── export class Messenger { private host: string; private port: number; private username: string; private password: string; private fromAddress: string; private fromName: string; private encryption: string; private useTls: boolean; /** Whether an SMTP host was actually configured (see the constructor). */ private smtpConfigured: boolean = false; /** The local mailbox, present only when this messenger captures. */ public devMailbox: DevMailbox | null = null; private imapHost: string; private imapPort: number; private imapUser: string; private imapPass: string; private imapEncryption: string; constructor(options?: MessengerOptions) { // Priority: constructor > TINA4_MAIL_* > sensible default. // Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them. // Whether a host was actually CONFIGURED, which is not the same as this.host // being set: it falls back to "localhost", so it is never empty and cannot // answer "can this messenger send?". The capture gate needs that answer, so // record it here while the real inputs are still in scope. this.smtpConfigured = Boolean(options?.host ?? process.env.TINA4_MAIL_HOST); this.host = options?.host ?? process.env.TINA4_MAIL_HOST ?? "localhost"; this.port = options?.port ?? parseInt(process.env.TINA4_MAIL_PORT ?? "587", 10); this.username = options?.username ?? process.env.TINA4_MAIL_USERNAME ?? ""; this.password = options?.password ?? process.env.TINA4_MAIL_PASSWORD ?? ""; this.fromAddress = options?.fromAddress ?? process.env.TINA4_MAIL_FROM ?? (this.username || "noreply@localhost"); this.fromName = options?.fromName ?? process.env.TINA4_MAIL_FROM_NAME ?? ""; // Encryption: constructor > .env > backward-compat useTls > default "tls" const envEncryption = options?.encryption ?? process.env.TINA4_MAIL_ENCRYPTION; if (envEncryption) { this.encryption = envEncryption.toLowerCase(); } else if (options?.useTls !== undefined) { this.encryption = options.useTls ? "tls" : "none"; } else { this.encryption = "tls"; } this.useTls = ["tls", "starttls"].includes(this.encryption); this.imapHost = options?.imapHost ?? process.env.TINA4_MAIL_IMAP_HOST ?? ""; this.imapPort = options?.imapPort ?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10); this.imapUser = options?.imapUser ?? process.env.TINA4_MAIL_IMAP_USERNAME ?? this.username; this.imapPass = options?.imapPass ?? process.env.TINA4_MAIL_IMAP_PASSWORD ?? this.password; // IMAP encryption — separate from SMTP encryption because IMAP almost // always uses port 993 + implicit TLS while SMTP toggles between 587 // (STARTTLS) and 465 (implicit TLS). Default is "tls" to match // industry-standard IMAPS port 993 behaviour. this.imapEncryption = (options?.imapEncryption ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION ?? "tls").toLowerCase(); } /** * Read-only IMAP encryption mode for inspection / tests. * Returns one of "tls", "starttls", "none", "ssl". */ getImapEncryption(): string { return this.imapEncryption; } /** * Send an email via SMTP. */ /** * Should send() capture locally instead of talking to SMTP? * * Availability decides, not verbosity. With no SMTP host configured sending is * impossible, so simulate it into a folder rather than failing -- that is what * makes a laptop with no mail server usable. TINA4_MAIL_CAPTURE forces capture * even when a host IS configured. * * TINA4_DEBUG deliberately does NOT gate this, and neither does NODE_ENV. Debug * must still be able to send, and the old `NODE_ENV !== "production"` clause * silently swallowed every staging email. */ private shouldCapture(): boolean { if (isTruthy(process.env.TINA4_MAIL_CAPTURE)) return true; return !this.smtpConfigured; } /** The local mailbox, created on first capture and reused after. */ private getDevMailbox(): DevMailbox { if (this.devMailbox === null) { this.devMailbox = new DevMailbox(); } return this.devMailbox; } private prepareRecipients(options: SendOptions, redirect = false): MailRecipients { let toList = Array.isArray(options.to) ? options.to : [options.to]; let ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []); let bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []); let allRecipients = [...toList, ...ccList, ...bccList]; const redirectTo = redirect ? parseMailRedirectList(process.env.TINA4_MAIL_REDIRECT_TO) : []; if (redirectTo.length > 0) { const originalTo = allRecipients.join(", "); toList = redirectTo; ccList = []; bccList = []; allRecipients = [...toList]; options.headers = { ...(options.headers ?? {}), "X-Tina4-Original-To": originalTo }; } return { toList, ccList, bccList, allRecipients }; } private async connectSmtpSocket(): Promise { if (this.port === 465) { const socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() }); await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); }); return socket; } const socket = net.createConnection({ host: this.host, port: this.port }); await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); }); return socket; } private async requireSmtpResponse(socket: SmtpSocket, command: string, expected: number, failure: string): Promise<{ code: number; text: string }> { const response = command === "" ? await readResponse(socket) : await sendCommand(socket, command); if (response.code !== expected) throw new SmtpCommandError(`${failure}: ${response.text}`); return response; } private async openSmtpSession(): Promise { let socket = await this.connectSmtpSocket(); try { await this.requireSmtpResponse(socket, "", 220, "SMTP greeting failed"); const ehlo = await this.requireSmtpResponse(socket, `EHLO ${this.host}`, 250, "EHLO failed"); if (this.useTls && this.port !== 465 && ehlo.text.includes("STARTTLS")) { await this.requireSmtpResponse(socket, "STARTTLS", 220, "STARTTLS failed"); const plainSocket = socket as net.Socket; const secureSocket = tls.connect({ socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() }); await new Promise((resolve, reject) => { secureSocket.once("secureConnect", resolve); secureSocket.once("error", reject); }); socket = secureSocket; await this.requireSmtpResponse(socket, `EHLO ${this.host}`, 250, "EHLO after STARTTLS failed"); } return socket; } catch (error) { socket.destroy(); throw error; } } private async authenticateSmtp(socket: SmtpSocket): Promise { if (!this.username || !this.password) return; await this.requireSmtpResponse(socket, "AUTH LOGIN", 334, "AUTH LOGIN failed"); await this.requireSmtpResponse(socket, Buffer.from(this.username).toString("base64"), 334, "AUTH username failed"); await this.requireSmtpResponse(socket, Buffer.from(this.password).toString("base64"), 235, "AUTH password failed"); } private async sendSmtpEnvelope(socket: SmtpSocket, recipients: string[]): Promise { await this.requireSmtpResponse(socket, `MAIL FROM:<${this.fromAddress}>`, 250, "MAIL FROM failed"); for (const recipient of recipients) { const response = await sendCommand(socket, `RCPT TO:<${recipient}>`); if (response.code !== 250 && response.code !== 251) throw new SmtpCommandError(`RCPT TO <${recipient}> failed: ${response.text}`); } } private async sendSmtpMessage(socket: SmtpSocket, options: SendOptions, recipients: MailRecipients, messageId: string): Promise { await this.requireSmtpResponse(socket, "DATA", 354, "DATA failed"); const mimeMessage = buildMimeMessage({ from: this.fromAddress, fromName: this.fromName, to: recipients.toList, cc: recipients.ccList, subject: options.subject, body: options.body, html: options.html ?? false, text: options.text, replyTo: options.replyTo, attachments: options.attachments, headers: options.headers, messageId, }); await this.requireSmtpResponse(socket, mimeMessage + "\r\n.", 250, "Message delivery failed"); await sendCommand(socket, "QUIT"); } private async sendSmtp(options: SendOptions, recipients: MailRecipients, messageId: string): Promise { let socket: SmtpSocket | null = null; try { socket = await this.openSmtpSession(); await this.authenticateSmtp(socket); await this.sendSmtpEnvelope(socket, recipients.allRecipients); await this.sendSmtpMessage(socket, options, recipients, messageId); socket.destroy(); return { success: true, message: "Email sent successfully", id: messageId }; } catch (err) { socket?.destroy(); if (err instanceof SmtpCommandError) return { success: false, message: err.message, id: null }; const errMsg = err instanceof Error ? err.message : String(err); return { success: false, message: `SMTP error: ${errMsg}`, id: null }; } } async send( to: string | string[], subject: string, body: string, html: boolean = false, text?: string, cc?: string | string[], bcc?: string | string[], replyTo?: string, attachments?: string[], headers?: Record, ): Promise { const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers }; const capturedRecipients = this.prepareRecipients(options); // Dev capture is a BRANCH here, not a different object returned by the factory. // createMessenger() used to hand back a DevMailbox, which has capture() and no // send(), so the documented call threw TypeError (nodejs#41). if (this.shouldCapture()) { return this.getDevMailbox().capture( to, subject, body, html, text, capturedRecipients.ccList, capturedRecipients.bccList, replyTo, attachments, this.fromAddress || undefined, ); } const recipients = this.prepareRecipients(options, true); const messageId = `${randomUUID()}@${this.host}`; if (recipients.allRecipients.length === 0) { return { success: false, message: "No recipients specified", id: null }; } if (!this.fromAddress) { return { success: false, message: "No from address configured", id: null }; } return this.sendSmtp(options, recipients, messageId); } /** * Render a Frond template STRING and send it as an HTML email (G7, parity with * Python's send_template). Extra send() options (cc, bcc, replyTo, attachments, * headers) pass through. If the Frond package cannot be loaded the raw template * is sent verbatim (matches Python's ImportError fallback) rather than failing. */ async sendTemplate( to: string | string[], subject: string, template: string, data: Record = {}, cc?: string | string[], bcc?: string | string[], replyTo?: string, attachments?: string[], headers?: Record, ): Promise { let body = template; try { // Same sibling-package specifier server.ts uses for Frond (resolves in dev // under tsx and in the built dist). Core never hard-depends on Frond. const { Frond } = (await import("../../frond/src/engine.js")) as { Frond: new (dir?: string) => { renderString(t: string, d?: Record): string }; }; body = new Frond().renderString(template, data); } catch { // Frond unavailable — send the template text as-is (Python parity). } return this.send(to, subject, body, true, undefined, cc, bcc, replyTo, attachments, headers); } /** * Test the SMTP connection without sending an email. */ async testConnection(): Promise<{ success: boolean; message: string }> { try { let socket: net.Socket | tls.TLSSocket; if (this.port === 465) { socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() }); await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); }); } else { socket = net.createConnection({ host: this.host, port: this.port }); await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); }); } const greeting = await readResponse(socket); if (greeting.code !== 220) { socket.destroy(); return { success: false, message: `SMTP greeting failed: ${greeting.text}` }; } const ehlo = await sendCommand(socket, `EHLO ${this.host}`); if (ehlo.code !== 250) { socket.destroy(); return { success: false, message: `EHLO failed: ${ehlo.text}` }; } await sendCommand(socket, "QUIT"); socket.destroy(); return { success: true, message: `Connected to ${this.host}:${this.port}` }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); return { success: false, message: `Connection failed: ${errMsg}` }; } } // ── IMAP (Read) ──────────────────────────────────────────── /** * Connect to the IMAP server via raw TCP/TLS. * Returns the socket and reads the greeting. */ private async imapConnect(): Promise { if (!this.imapHost) { throw new Error("IMAP host not configured (set imapHost or IMAP_HOST env)"); } let socket: net.Socket | tls.TLSSocket; // Honour TINA4_MAIL_IMAP_ENCRYPTION when set; otherwise infer from port. // "tls" / "ssl" → implicit TLS connect; anything else → plain connect. const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl" || (this.imapEncryption === "" && this.imapPort === 993); if (useTls) { socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: tlsRejectUnauthorized() }); await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); }); } else { socket = net.createConnection({ host: this.imapHost, port: this.imapPort }); await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); }); } // Read server greeting await imapReadLine(socket); // Login if (this.imapUser && this.imapPass) { const loginResp = await imapCommand(socket, `LOGIN ${imapQuote(this.imapUser)} ${imapQuote(this.imapPass)}`); if (!loginResp.includes("OK")) { socket.destroy(); throw new Error(`IMAP login failed: ${loginResp}`); } } return socket; } /** * Disconnect from IMAP cleanly. */ private async imapDisconnect(socket: net.Socket | tls.TLSSocket): Promise { try { await imapCommand(socket, "LOGOUT"); } catch { /* ignore */ } socket.destroy(); } /** * Fetch latest messages from a folder. * Returns list of message summaries. */ async inbox(folder: string = "INBOX", limit: number = 20, offset: number = 0): Promise { let socket: net.Socket | tls.TLSSocket; try { socket = await this.imapConnect(); } catch (err) { throw imapFail("inbox", err); } try { // Select folder await imapCommand(socket, `SELECT ${imapQuote(folder)}`); // Search for all messages const searchResp = await imapCommand(socket, "UID SEARCH ALL"); const uids = parseSearchResponse(searchResp); if (uids.length === 0) return []; // Latest first uids.reverse(); const selected = uids.slice(offset, offset + limit); if (selected.length === 0) return []; const messages: ImapMessage[] = []; for (const uid of selected) { // Fetch the WHOLE message (PEEK — never mark seen) so the snippet is // built from real, transfer-decoded body text (G3), not the empty string // a header-only fetch could ever produce. const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY.PEEK[])`); messages.push(parseSummary(uid, fetchResp)); } return messages; } catch (err) { throw imapFail("inbox", err); } finally { await this.imapDisconnect(socket); } } /** * Read a single message by its IMAP UID. */ async read(uid: string, folder: string = "INBOX"): Promise { let socket: net.Socket | tls.TLSSocket; try { socket = await this.imapConnect(); } catch (err) { throw imapFail("read", err); } try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY[])`); // A genuinely missing UID is a tagged OK with no message body literal — // that is NOT an error, so it must not throw. It returns null: FALSY, so // `if (!msg)` detects it. This used to return emptyFullMessage(uid) under // a comment claiming "parity with Python's {}" -- but that object is // TRUTHY, while Python's {} , PHP's null and Ruby's nil are all falsy, so // Node was the one framework where a caller could not tell "no such // message" from a real one without inspecting individual fields. if (!/\{\d+\}/.test(fetchResp)) { return null; } // Mark as seen await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Seen)`); return parseFullMessage(uid, fetchResp); } catch (err) { throw imapFail("read", err); } finally { await this.imapDisconnect(socket); } } /** * Search messages using IMAP search criteria. */ async search( folder: string = "INBOX", subject?: string, sender?: string, since?: string, before?: string, unseenOnly: boolean = false, limit: number = 50, ): Promise { // Build IMAP SEARCH criteria from structured params const criteria: string[] = ["ALL"]; if (subject) criteria.push(`SUBJECT "${subject}"`); if (sender) criteria.push(`FROM "${sender}"`); if (since) criteria.push(`SINCE ${since}`); if (before) criteria.push(`BEFORE ${before}`); if (unseenOnly) criteria.push("UNSEEN"); const query = criteria.join(" "); let socket: net.Socket | tls.TLSSocket; try { socket = await this.imapConnect(); } catch (err) { throw imapFail("search", err); } try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); const searchResp = await imapCommand(socket, `UID SEARCH ${query}`); const uids = parseSearchResponse(searchResp); if (uids.length === 0) return []; uids.reverse(); const messages: ImapMessage[] = []; for (const uid of uids.slice(0, limit)) { const fetchResp = await imapCommand(socket, `UID FETCH ${uid} (FLAGS BODY.PEEK[])`); messages.push(parseSummary(uid, fetchResp)); } return messages; } catch (err) { throw imapFail("search", err); } finally { await this.imapDisconnect(socket); } } /** * Delete a message by UID (mark \Deleted, then EXPUNGE). * * `delete` is the one cross-framework name (python/php/ruby/node all spell it * `delete`). `deleteMessage` remains as a DEPRECATED alias for one release. */ async delete(uid: string, folder: string = "INBOX"): Promise { const socket = await this.imapConnect(); try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Deleted)`); await imapCommand(socket, "EXPUNGE"); } finally { await this.imapDisconnect(socket); } } /** @deprecated Use {@link delete} — kept as an alias for one release (G7). */ async deleteMessage(uid: string, folder: string = "INBOX"): Promise { return this.delete(uid, folder); } /** * Mark a message as read (+FLAGS \Seen). */ async markRead(uid: string, folder: string = "INBOX"): Promise { const socket = await this.imapConnect(); try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); await imapCommand(socket, `UID STORE ${uid} +FLAGS (\\Seen)`); } finally { await this.imapDisconnect(socket); } } /** * Mark a message as unread (-FLAGS \Seen) — the inverse of markRead (G7, * parity with Python's mark_unread). */ async markUnread(uid: string, folder: string = "INBOX"): Promise { const socket = await this.imapConnect(); try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); await imapCommand(socket, `UID STORE ${uid} -FLAGS (\\Seen)`); } finally { await this.imapDisconnect(socket); } } /** * Count unseen messages in a folder. */ async unread(folder: string = "INBOX"): Promise { let socket: net.Socket | tls.TLSSocket; try { socket = await this.imapConnect(); } catch (err) { throw imapFail("unread", err); } try { await imapCommand(socket, `SELECT ${imapQuote(folder)}`); const searchResp = await imapCommand(socket, "UID SEARCH UNSEEN"); return parseSearchResponse(searchResp).length; } catch (err) { throw imapFail("unread", err); } finally { await this.imapDisconnect(socket); } } /** * List available IMAP folders/mailboxes. */ async folders(): Promise { let socket: net.Socket | tls.TLSSocket; try { socket = await this.imapConnect(); } catch (err) { throw imapFail("folders", err); } try { const resp = await imapCommand(socket, 'LIST "" "*"'); const result: string[] = []; for (const line of resp.split("\r\n")) { // Parse LIST response: * LIST (\flags) "/" "FolderName" const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i); if (m) result.push(m[1]); } return result; } catch (err) { throw imapFail("folders", err); } finally { await this.imapDisconnect(socket); } } /** * Test IMAP connectivity without reading. */ async testImapConnection(): Promise<{ success: boolean; message: string }> { try { const socket = await this.imapConnect(); await this.imapDisconnect(socket); return { success: true, message: `Connected to ${this.imapHost}:${this.imapPort}` }; } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); return { success: false, message: `IMAP connection failed: ${errMsg}` }; } } } // ── IMAP helpers (raw TCP protocol) ────────────────────────── let imapTagCounter = 0; function imapQuote(s: string): string { if (/^[a-zA-Z0-9_./-]+$/.test(s)) return s; return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } function imapReadLine(socket: net.Socket | tls.TLSSocket): Promise { return new Promise((resolve, reject) => { let buffer = ""; const onData = (chunk: Buffer) => { buffer += chunk.toString("utf-8"); const nlIndex = buffer.indexOf("\r\n"); if (nlIndex !== -1) { socket.removeListener("data", onData); socket.removeListener("error", onError); resolve(buffer); } }; const onError = (err: Error) => { socket.removeListener("data", onData); reject(err); }; socket.on("data", onData); socket.on("error", onError); }); } function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promise { return new Promise((resolve, reject) => { imapTagCounter++; const tag = `T${imapTagCounter}`; const fullCommand = `${tag} ${command}\r\n`; let buffer = ""; const onData = (chunk: Buffer) => { buffer += chunk.toString("utf-8"); // Tagged OK = success. if (buffer.includes(`${tag} OK`)) { socket.removeListener("data", onData); socket.removeListener("error", onError); resolve(buffer); return; } // Tagged NO / BAD = protocol failure — fail loud (mirrors Python's // non-OK status raise). A genuinely empty mailbox is a tagged OK with an // empty SEARCH result, which still resolves above. if (buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) { socket.removeListener("data", onData); socket.removeListener("error", onError); reject(new MessengerConnectionError(`IMAP command failed: ${command.split(" ")[0]} → ${buffer.trim()}`)); } }; const onError = (err: Error) => { socket.removeListener("data", onData); reject(err); }; socket.on("data", onData); socket.on("error", onError); socket.write(fullCommand, "utf-8"); }); } /** * Log an IMAP connection/protocol failure and return the error to throw. * A genuinely empty mailbox is NOT an error and never reaches here. */ function imapFail(method: string, err: unknown): MessengerConnectionError { const e = err instanceof Error ? err : new Error(String(err)); Log.error(`Messenger IMAP ${method}() failed: ${e.name}: ${e.message}`); if (e instanceof MessengerConnectionError) return e; return new MessengerConnectionError(`IMAP ${method} failed: ${e.message}`); } function parseSearchResponse(response: string): string[] { // SEARCH response: * SEARCH 1 2 3 4 5 const match = response.match(/\* SEARCH (.+)/); if (!match) return []; return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s)); } /** * The RFC822 message octets from a FETCH response. IMAP frames the body as a * `{N}` literal followed by exactly N octets, so slice N — that cleanly drops the * trailing `)\r\nTAG OK ...` the server appends after the literal (which the old * regex-then-strip path had to chase). For the ASCII bodies these methods see, * octet length equals string length; a response with no literal falls back to * itself. */ function extractRawMessage(response: string): string { const m = response.match(/\{(\d+)\}\r\n/); if (!m) return response; const start = (m.index ?? 0) + m[0].length; return response.slice(start, start + parseInt(m[1], 10)); } /** Parse a header block into a lower-cased map, honouring folded continuations. */ function parseMimeHeaders(section: string): Record { const headers: Record = {}; let currentKey = ""; for (const line of section.split(/\r\n/)) { if (/^\s/.test(line) && currentKey) { headers[currentKey] += " " + line.trim(); } else { const idx = line.indexOf(":"); if (idx > 0) { currentKey = line.substring(0, idx).trim().toLowerCase(); headers[currentKey] = line.substring(idx + 1).trim(); } } } return headers; } /** * Transfer-decode a part body per its Content-Transfer-Encoding. base64 and * quoted-printable become readable text; 7bit/8bit/binary/absent pass through. * This is what turns the snippet from raw base64 into real words (G3). */ function decodeTransfer(body: string, encoding: string): string { const enc = encoding.toLowerCase().trim(); if (enc === "base64") { try { return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf-8"); } catch { return body; } } if (enc === "quoted-printable") { return body .replace(/=\r?\n/g, "") // soft line breaks .replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))); } return body; } /** * Transfer-decode an attachment part body to its RAW BYTES (#69). base64 and * quoted-printable become the original octets; 7bit/8bit/binary/absent pass * through as their own bytes. This is the byte-level sibling of decodeTransfer() * (which returns text for the message bodies): an attachment must round-trip * byte-for-byte to be downloadable, so it returns a Buffer, never a string. * Mirrors Python's part.get_payload(decode=True). */ function decodeAttachmentBytes(body: string, encoding: string): Buffer { const enc = encoding.toLowerCase().trim(); if (enc === "base64") { // The transport wraps base64 at 76 cols (RFC 2045); strip ALL whitespace so // the wrapped lines rejoin into exactly the original bytes. return Buffer.from(body.replace(/\s+/g, ""), "base64"); } // RFC 2046: the CRLF immediately before the boundary delimiter belongs to the // boundary, not the part body — drop exactly that one trailing CRLF. const trimmed = body.replace(/\r\n$/, ""); if (enc === "quoted-printable") { const collapsed = trimmed.replace(/=\r?\n/g, ""); // soft line breaks const bytes: number[] = []; for (let i = 0; i < collapsed.length; i++) { const hex = collapsed.substring(i + 1, i + 3); if (collapsed[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(hex)) { bytes.push(parseInt(hex, 16)); i += 2; } else { bytes.push(collapsed.charCodeAt(i) & 0xff); } } return Buffer.from(bytes); } return Buffer.from(trimmed, "utf-8"); } /** filename="..." from Content-Disposition, else name="..." from Content-Type. */ function attachmentFilename(disposition: string, contentType: string): string { const d = disposition.match(/filename="?([^";\r\n]+)"?/i); if (d) return d[1].trim(); const c = contentType.match(/name="?([^";\r\n]+)"?/i); if (c) return c[1].trim(); return "attachment"; } /** * A decoded, tag-stripped, whitespace-collapsed 200-char preview (G3). Prefers * the plain-text body, falls back to the HTML with its tags removed. The inputs * are already transfer-decoded, so this is real readable text — never the raw * base64 a header-only / undecoded fetch used to emit. */ function makeSnippet(bodyText: string, bodyHtml: string): string { return (bodyText || bodyHtml || "") .replace(/<[^>]+>/g, " ") // strip HTML tags .replace(/\s+/g, " ") // collapse whitespace .trim() .slice(0, 200); } /** The Date header as ISO-8601 (G4); the raw string if unparseable, "" if absent. */ function toIsoDate(raw: string): string { if (!raw) return ""; const d = new Date(raw); return Number.isNaN(d.getTime()) ? raw : d.toISOString(); } interface ParsedMessage { headers: Record; bodyText: string; bodyHtml: string; attachments: ImapAttachment[]; } /** * Walk a FETCH response into headers + transfer-decoded bodies + attachment * metadata. Shared by parseSummary() (inbox/search) and parseFullMessage() * (read) so the snippet and the read() bodies can never drift. */ function parseMessage(response: string): ParsedMessage { const raw = extractRawMessage(response); const headerEnd = raw.indexOf("\r\n\r\n"); const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw; const bodySection = headerEnd >= 0 ? raw.substring(headerEnd + 4) : ""; const headers = parseMimeHeaders(headerSection); const contentType = headers["content-type"] ?? "text/plain"; let bodyText = ""; let bodyHtml = ""; const attachments: ImapAttachment[] = []; if (contentType.includes("multipart")) { const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/); if (boundaryMatch) { const boundary = "--" + boundaryMatch[1]; for (const part of bodySection.split(boundary)) { const trimmed = part.trim(); if (trimmed === "" || trimmed === "--") continue; const pEnd = part.indexOf("\r\n\r\n"); if (pEnd < 0) continue; const pHeaders = parseMimeHeaders(part.substring(0, pEnd)); const pBody = part.substring(pEnd + 4); const cte = pHeaders["content-transfer-encoding"] ?? ""; const pType = pHeaders["content-type"] ?? "text/plain"; const disposition = pHeaders["content-disposition"] ?? ""; if (/attachment/i.test(disposition)) { // The RAW DECODED BYTES of the part (#69) — transfer-decoded so the // attachment is downloadable byte-for-byte (same convention as // req.files[x].content); size is that decoded byte length. const content = decodeAttachmentBytes(pBody, cte); attachments.push({ filename: attachmentFilename(disposition, pType), contentType: pType.split(";")[0].trim(), size: content.length, content, }); } else if (pType.includes("text/html")) { bodyHtml = decodeTransfer(pBody, cte).trim(); } else if (pType.includes("text/plain")) { bodyText = decodeTransfer(pBody, cte).trim(); } } } } else if (contentType.includes("text/html")) { bodyHtml = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim(); } else { bodyText = decodeTransfer(bodySection, headers["content-transfer-encoding"] ?? "").trim(); } return { headers, bodyText, bodyHtml, attachments }; } /** The inbox()/search() listing row: {uid, subject, from, to, date, snippet, seen} (G3/G4). */ function parseSummary(uid: string, response: string): ImapMessage { const { headers, bodyText, bodyHtml } = parseMessage(response); return { uid, subject: headers["subject"] ?? "", from: headers["from"] ?? "", to: headers["to"] ?? "", date: toIsoDate(headers["date"] ?? ""), snippet: makeSnippet(bodyText, bodyHtml), seen: /\\Seen/i.test(response), }; } function parseFullMessage(uid: string, response: string): ImapFullMessage { const { headers, bodyText, bodyHtml, attachments } = parseMessage(response); return { uid, subject: headers["subject"] ?? "", from: headers["from"] ?? "", to: headers["to"] ?? "", cc: headers["cc"] ?? "", date: toIsoDate(headers["date"] ?? ""), bodyText, bodyHtml, attachments, headers, }; } /** * Create a Messenger configured for the current environment. * * Returns ONE concrete type, always. It used to return `Messenger | DevMailbox`, * and those two shared NO sending method -- DevMailbox has capture(), Messenger has * send() -- so the documented call threw TypeError whenever the dev branch was * taken. That is nodejs#41. Capture is now a branch inside Messenger.send(), so the * object you get back has one send() with one signature either way. * * The gate is availability, not verbosity: * - no TINA4_MAIL_HOST -> capture (sending is impossible, so simulate it) * - TINA4_MAIL_CAPTURE truthy -> capture even with SMTP configured * - otherwise -> send, EVEN WITH TINA4_DEBUG ON * * TINA4_DEBUG no longer forces capture: debug must still be able to send real mail. * The `NODE_ENV !== "production"` clause is also gone -- it captured even with SMTP * configured and debug off, which silently ate every staging email. */ export function createMessenger(): Messenger { return new Messenger(); }