UNPKG

@upyo/smtp

Version:

SMTP transport for Upyo email library

714 lines (709 loc) 23.9 kB
import { Socket } from "node:net"; import { TLSSocket, connect } from "node:tls"; import { Buffer } from "node:buffer"; //#region src/config.ts /** * Creates a resolved SMTP configuration by applying default values to optional fields. * * This function takes a partial SMTP configuration and returns a complete * configuration with all optional fields filled with sensible defaults. * It is used internally by the SMTP transport. * * @param config - The SMTP configuration with optional fields * @returns A resolved configuration with all defaults applied * @internal */ function createSmtpConfig(config) { return { host: config.host, port: config.port ?? 587, secure: config.secure ?? true, auth: config.auth, tls: config.tls, connectionTimeout: config.connectionTimeout ?? 6e4, socketTimeout: config.socketTimeout ?? 6e4, localName: config.localName ?? "localhost", pool: config.pool ?? true, poolSize: config.poolSize ?? 5 }; } //#endregion //#region src/smtp-connection.ts var SmtpConnection = class { socket = null; config; authenticated = false; capabilities = []; constructor(config) { this.config = createSmtpConfig(config); } connect(signal) { if (this.socket) throw new Error("Connection already established"); signal?.throwIfAborted(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.socket?.destroy(); reject(/* @__PURE__ */ new Error("Connection timeout")); }, this.config.connectionTimeout); const onConnect = () => { clearTimeout(timeout); resolve(); }; const onError = (error) => { clearTimeout(timeout); reject(error); }; if (this.config.secure) this.socket = connect({ host: this.config.host, port: this.config.port, rejectUnauthorized: this.config.tls?.rejectUnauthorized ?? true, ca: this.config.tls?.ca, key: this.config.tls?.key, cert: this.config.tls?.cert, minVersion: this.config.tls?.minVersion, maxVersion: this.config.tls?.maxVersion }); else { this.socket = new Socket(); this.socket.connect(this.config.port, this.config.host); } this.socket.setTimeout(this.config.socketTimeout); this.socket.once("connect", onConnect); this.socket.once("error", onError); this.socket.once("timeout", () => { clearTimeout(timeout); this.socket?.destroy(); reject(/* @__PURE__ */ new Error("Socket timeout")); }); }); } sendCommand(command, signal) { if (!this.socket) throw new Error("Not connected"); signal?.throwIfAborted(); return new Promise((resolve, reject) => { let buffer = ""; const timeout = setTimeout(() => { reject(/* @__PURE__ */ new Error("Command timeout")); }, this.config.socketTimeout); const onData = (data) => { buffer += data.toString(); const lines = buffer.split("\r\n"); const incompleteLine = lines.pop() || ""; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.length >= 4 && line[3] === " ") { const code = parseInt(line.substring(0, 3), 10); const message = line.substring(4); const fullResponse = lines.slice(0, i + 1).join("\r\n"); cleanup(); resolve({ code, message, raw: fullResponse }); return; } } buffer = incompleteLine; }; const onError = (error) => { cleanup(); reject(error); }; const cleanup = () => { clearTimeout(timeout); this.socket?.off("data", onData); this.socket?.off("error", onError); }; this.socket.on("data", onData); this.socket.on("error", onError); this.socket.write(command + "\r\n"); }); } greeting(signal) { if (!this.socket) throw new Error("Not connected"); signal?.throwIfAborted(); return new Promise((resolve, reject) => { let buffer = ""; const timeout = setTimeout(() => { reject(/* @__PURE__ */ new Error("Greeting timeout")); }, this.config.socketTimeout); const onData = (data) => { buffer += data.toString(); const lines = buffer.split("\r\n"); for (const line of lines) if (line.length >= 4 && line[3] === " ") { const code = parseInt(line.substring(0, 3), 10); const message = line.substring(4); cleanup(); resolve({ code, message, raw: buffer }); return; } }; const onError = (error) => { cleanup(); reject(error); }; const cleanup = () => { clearTimeout(timeout); this.socket?.off("data", onData); this.socket?.off("error", onError); }; this.socket.on("data", onData); this.socket.on("error", onError); }); } async ehlo(signal) { const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal); if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`); this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0); } async starttls(signal) { if (!this.socket) throw new Error("Not connected"); if (this.socket instanceof TLSSocket) throw new Error("Connection is already using TLS"); signal?.throwIfAborted(); const response = await this.sendCommand("STARTTLS", signal); if (response.code !== 220) throw new Error(`STARTTLS failed: ${response.message}`); signal?.throwIfAborted(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.socket?.destroy(); reject(/* @__PURE__ */ new Error("STARTTLS upgrade timeout")); }, this.config.connectionTimeout); const plainSocket = this.socket; const tlsSocket = connect({ socket: plainSocket, host: this.config.host, rejectUnauthorized: this.config.tls?.rejectUnauthorized ?? true, ca: this.config.tls?.ca, key: this.config.tls?.key, cert: this.config.tls?.cert, minVersion: this.config.tls?.minVersion, maxVersion: this.config.tls?.maxVersion }); const onSecureConnect = () => { clearTimeout(timeout); this.socket = tlsSocket; this.socket.setTimeout(this.config.socketTimeout); resolve(); }; const onError = (error) => { clearTimeout(timeout); tlsSocket.destroy(); reject(error); }; tlsSocket.once("secureConnect", onSecureConnect); tlsSocket.once("error", onError); tlsSocket.once("timeout", () => { clearTimeout(timeout); tlsSocket.destroy(); reject(/* @__PURE__ */ new Error("TLS upgrade timeout")); }); }); } async authenticate(signal) { if (!this.config.auth) return; if (this.authenticated) return; const authMethod = this.config.auth.method ?? "plain"; if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new Error("Server does not support authentication"); switch (authMethod) { case "plain": await this.authPlain(signal); break; case "login": await this.authLogin(signal); break; default: throw new Error(`Unsupported authentication method: ${authMethod}`); } this.authenticated = true; } async authPlain(signal) { const { user, pass } = this.config.auth; const credentials = btoa(`\0${user}\0${pass}`); const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal); if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`); } async authLogin(signal) { const { user, pass } = this.config.auth; let response = await this.sendCommand("AUTH LOGIN", signal); if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`); response = await this.sendCommand(btoa(user), signal); if (response.code !== 334) throw new Error(`Username authentication failed: ${response.message}`); response = await this.sendCommand(btoa(pass), signal); if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`); } async sendMessage(message, signal) { const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal); if (mailResponse.code !== 250) throw new Error(`MAIL FROM failed: ${mailResponse.message}`); for (const recipient of message.envelope.to) { signal?.throwIfAborted(); const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal); if (rcptResponse.code !== 250) throw new Error(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`); } const dataResponse = await this.sendCommand("DATA", signal); if (dataResponse.code !== 354) throw new Error(`DATA failed: ${dataResponse.message}`); const content = message.raw.replace(/\n\./g, "\n.."); const finalResponse = await this.sendCommand(`${content}\r\n.`, signal); if (finalResponse.code !== 250) throw new Error(`Message send failed: ${finalResponse.message}`); const messageId = this.extractMessageId(finalResponse.message); return messageId; } extractMessageId(response) { const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i); return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } async quit() { if (!this.socket) return; try { await this.sendCommand("QUIT"); } catch {} this.socket.destroy(); this.socket = null; this.authenticated = false; this.capabilities = []; } async reset(signal) { if (!this.socket) throw new Error("Not connected"); const response = await this.sendCommand("RSET", signal); if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`); } }; //#endregion //#region src/message-converter.ts async function convertMessage(message) { const envelope = { from: message.sender.address, to: [ ...message.recipients.map((r) => r.address), ...message.ccRecipients.map((r) => r.address), ...message.bccRecipients.map((r) => r.address) ] }; const raw = await buildRawMessage(message); return { envelope, raw }; } async function buildRawMessage(message) { const lines = []; const boundary = generateBoundary(); const hasAttachments = message.attachments.length > 0; const hasHtml = "html" in message.content; const hasText = "text" in message.content; const isMultipart = hasAttachments || hasHtml && hasText; lines.push(`From: ${encodeAddress(message.sender)}`); lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`); if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`); if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`); lines.push(`Subject: ${encodeHeaderValue(message.subject)}`); lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`); lines.push(`Message-ID: <${generateMessageId()}>`); if (message.priority !== "normal") { const priorityValue = message.priority === "high" ? "1" : "5"; lines.push(`X-Priority: ${priorityValue}`); lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`); } for (const [key, value] of message.headers) lines.push(`${key}: ${encodeHeaderValue(value)}`); lines.push("MIME-Version: 1.0"); if (isMultipart) { lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); lines.push(""); lines.push("This is a multi-part message in MIME format."); lines.push(""); lines.push(`--${boundary}`); if (hasHtml && hasText) { const contentBoundary = generateBoundary(); lines.push(`Content-Type: multipart/alternative; boundary="${contentBoundary}"`); lines.push(""); lines.push(`--${contentBoundary}`); lines.push("Content-Type: text/plain; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.text)); lines.push(""); lines.push(`--${contentBoundary}`); lines.push("Content-Type: text/html; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.html)); lines.push(""); lines.push(`--${contentBoundary}--`); } else if (hasHtml) { lines.push("Content-Type: text/html; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.html)); } else { lines.push("Content-Type: text/plain; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.text)); } for (const attachment of message.attachments) { lines.push(""); lines.push(`--${boundary}`); lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`); lines.push("Content-Transfer-Encoding: base64"); if (attachment.inline) { lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`); lines.push(`Content-ID: <${attachment.contentId}>`); } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`); lines.push(""); lines.push(encodeBase64(await attachment.content)); } lines.push(""); lines.push(`--${boundary}--`); } else if (hasHtml) { lines.push("Content-Type: text/html; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.html)); } else { lines.push("Content-Type: text/plain; charset=utf-8"); lines.push("Content-Transfer-Encoding: quoted-printable"); lines.push(""); lines.push(encodeQuotedPrintable(message.content.text)); } return lines.join("\r\n"); } function generateBoundary() { return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } function generateMessageId() { const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 9); return `${timestamp}.${random}@upyo.local`; } function encodeAddress(address) { if (address.name == null) return address.address; const encodedDisplayName = encodeHeaderValue(address.name); return `${encodedDisplayName} <${address.address}>`; } function encodeHeaderValue(value) { if (!/^[\x20-\x7E]*$/.test(value)) { const utf8Bytes = new TextEncoder().encode(value); const base64 = Buffer.from(utf8Bytes).toString("base64"); const maxEncodedLength = 75; const encodedWord = `=?UTF-8?B?${base64}?=`; if (encodedWord.length <= maxEncodedLength) return encodedWord; const words = []; let currentBase64 = ""; for (let i = 0; i < base64.length; i += 4) { const chunk = base64.slice(i, i + 4); const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`; if (testWord.length <= maxEncodedLength) currentBase64 += chunk; else { if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`); currentBase64 = chunk; } } if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`); return words.join(" "); } return value; } function encodeQuotedPrintable(text) { const utf8Bytes = new TextEncoder().encode(text); let result = ""; let lineLength = 0; const maxLineLength = 76; for (let i = 0; i < utf8Bytes.length; i++) { const byte = utf8Bytes[i]; let encoded = ""; if (byte < 32 || byte > 126 || byte === 61 || byte === 46 && lineLength === 0) encoded = `=${byte.toString(16).toUpperCase().padStart(2, "0")}`; else encoded = String.fromCharCode(byte); if (lineLength + encoded.length > maxLineLength) { result += "=\r\n"; lineLength = 0; } result += encoded; lineLength += encoded.length; if (byte === 13 && i + 1 < utf8Bytes.length && utf8Bytes[i + 1] === 10) { i++; result += String.fromCharCode(10); lineLength = 0; } else if (byte === 10 && (i === 0 || utf8Bytes[i - 1] !== 13)) lineLength = 0; } return result; } function encodeBase64(data) { const base64 = Buffer.from(data).toString("base64"); return base64.replace(/(.{76})/g, "$1\r\n").trim(); } //#endregion //#region src/smtp-transport.ts /** * SMTP transport implementation for sending emails via SMTP protocol. * * This transport provides efficient email delivery with connection pooling, * support for authentication, TLS/SSL encryption, and batch sending capabilities. * * @example * ```typescript * import { SmtpTransport } from '@upyo/smtp'; * * // Automatic resource cleanup with using statement * await using transport = new SmtpTransport({ * host: 'smtp.gmail.com', * port: 465, * secure: true, // Use TLS from start * auth: { * user: 'user@gmail.com', * pass: 'app-password' * } * }); * * const receipt = await transport.send(message); * // Connections are automatically closed here * * // Or manual management * const transport2 = new SmtpTransport(config); * try { * await transport2.send(message); * } finally { * await transport2.closeAllConnections(); * } * ``` */ var SmtpTransport = class { /** * The SMTP configuration used by this transport. */ config; /** * The maximum number of connections in the pool. */ poolSize; connectionPool = []; /** * Creates a new SMTP transport instance. * * @param config SMTP configuration including server details, authentication, * and options. */ constructor(config) { this.config = config; this.poolSize = config.poolSize ?? 5; } /** * Sends a single email message via SMTP. * * This method converts the message to SMTP format, establishes a connection * to the SMTP server, sends the message, and returns a receipt with the result. * * @example * ```typescript * const receipt = await transport.send({ * sender: { address: 'from@example.com' }, * recipients: [{ address: 'to@example.com' }], * subject: 'Hello', * content: { text: 'Hello World!' } * }); * * if (receipt.successful) { * console.log('Message sent with ID:', receipt.messageId); * } * ``` * * @param message The email message to send. * @param options Optional transport options including `AbortSignal` for * cancellation. * @returns A promise that resolves to a receipt indicating success or * failure. */ async send(message, options) { options?.signal?.throwIfAborted(); const connection = await this.getConnection(options?.signal); try { options?.signal?.throwIfAborted(); const smtpMessage = await convertMessage(message); options?.signal?.throwIfAborted(); const messageId = await connection.sendMessage(smtpMessage, options?.signal); await this.returnConnection(connection); return { successful: true, messageId }; } catch (error) { await this.discardConnection(connection); return { successful: false, errorMessages: [error instanceof Error ? error.message : String(error)] }; } } /** * Sends multiple email messages efficiently using a single SMTP connection. * * This method is optimized for bulk email sending by reusing a single SMTP * connection for all messages, which significantly improves performance * compared to sending each message individually. * * @example * ```typescript * const messages = [ * { subject: 'Message 1', recipients: [{ address: 'user1@example.com' }], ... }, * { subject: 'Message 2', recipients: [{ address: 'user2@example.com' }], ... } * ]; * * for await (const receipt of transport.sendMany(messages)) { * if (receipt.successful) { * console.log('Sent:', receipt.messageId); * } else { * console.error('Failed:', receipt.errorMessages); * } * } * ``` * * @param messages An iterable or async iterable of messages to send. * @param options Optional transport options including `AbortSignal` for * cancellation. * @returns An async iterable of receipts, one for each message. */ async *sendMany(messages, options) { options?.signal?.throwIfAborted(); const connection = await this.getConnection(options?.signal); let connectionValid = true; try { const isAsyncIterable = Symbol.asyncIterator in messages; if (isAsyncIterable) for await (const message of messages) { options?.signal?.throwIfAborted(); if (!connectionValid) { yield { successful: false, errorMessages: ["Connection is no longer valid"] }; continue; } try { const smtpMessage = await convertMessage(message); options?.signal?.throwIfAborted(); const messageId = await connection.sendMessage(smtpMessage, options?.signal); yield { successful: true, messageId }; } catch (error) { connectionValid = false; yield { successful: false, errorMessages: [error instanceof Error ? error.message : String(error)] }; } } else for (const message of messages) { options?.signal?.throwIfAborted(); if (!connectionValid) { yield { successful: false, errorMessages: ["Connection is no longer valid"] }; continue; } try { const smtpMessage = await convertMessage(message); options?.signal?.throwIfAborted(); const messageId = await connection.sendMessage(smtpMessage, options?.signal); yield { successful: true, messageId }; } catch (error) { connectionValid = false; yield { successful: false, errorMessages: [error instanceof Error ? error.message : String(error)] }; } } if (connectionValid) await this.returnConnection(connection); else await this.discardConnection(connection); } catch (error) { await this.discardConnection(connection); throw error; } } async getConnection(signal) { signal?.throwIfAborted(); if (this.connectionPool.length > 0) return this.connectionPool.pop(); const connection = new SmtpConnection(this.config); await this.connectAndSetup(connection, signal); return connection; } async connectAndSetup(connection, signal) { signal?.throwIfAborted(); await connection.connect(signal); signal?.throwIfAborted(); const greeting = await connection.greeting(signal); if (greeting.code !== 220) throw new Error(`Server greeting failed: ${greeting.message}`); signal?.throwIfAborted(); await connection.ehlo(signal); signal?.throwIfAborted(); if (!this.config.secure && connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS"))) { await connection.starttls(signal); signal?.throwIfAborted(); await connection.ehlo(signal); signal?.throwIfAborted(); } await connection.authenticate(signal); } async returnConnection(connection) { if (!this.config.pool) { await connection.quit(); return; } if (this.connectionPool.length < this.poolSize) try { await connection.reset(); this.connectionPool.push(connection); } catch { await this.discardConnection(connection); } else await connection.quit(); } async discardConnection(connection) { try { await connection.quit(); } catch {} } /** * Closes all active SMTP connections in the connection pool. * * This method should be called when shutting down the application * to ensure all connections are properly closed and resources are freed. * * @example * ```typescript * // At application shutdown * await transport.closeAllConnections(); * ``` */ async closeAllConnections() { const connections = [...this.connectionPool]; this.connectionPool = []; await Promise.all(connections.map((connection) => this.discardConnection(connection))); } /** * Implements AsyncDisposable interface for automatic resource cleanup. * * This method is called automatically when using the `using` keyword, * ensuring that all SMTP connections are properly closed when the * transport goes out of scope. * * @example * ```typescript * // Automatic cleanup with using statement * await using transport = new SmtpTransport(config); * await transport.send(message); * // Connections are automatically closed here * ``` */ async [Symbol.asyncDispose]() { await this.closeAllConnections(); } }; //#endregion export { SmtpTransport };