@upyo/smtp
Version:
SMTP transport for Upyo email library
714 lines (709 loc) • 23.9 kB
JavaScript
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 };