UNPKG

unemail

Version:

A modern TypeScript email library with zero dependencies, supporting multiple providers including AWS SES, Resend, MailCrab, and HTTP APIs

644 lines (641 loc) 22.7 kB
import { Buffer } from 'node:buffer'; import * as crypto from 'node:crypto'; import * as net from 'node:net'; import * as tls from 'node:tls'; import { createRequiredError, validateEmailOptions, createError, buildMimeMessage, generateMessageId, isPortAvailable } from 'unemail/utils'; import { defineProvider } from './base.mjs'; const PROVIDER_NAME = "smtp"; const DEFAULT_PORT = 25; const DEFAULT_SECURE_PORT = 465; const DEFAULT_TIMEOUT = 1e4; const DEFAULT_SECURE = false; const DEFAULT_MAX_CONNECTIONS = 5; const DEFAULT_POOL_WAIT_TIMEOUT = 3e4; const smtpProvider = defineProvider((opts = {}) => { if (!opts.host) { throw createRequiredError(PROVIDER_NAME, "host"); } const options = { host: opts.host, port: opts.port !== void 0 ? opts.port : opts.secure ? DEFAULT_SECURE_PORT : DEFAULT_PORT, secure: opts.secure ?? DEFAULT_SECURE, user: opts.user, password: opts.password, rejectUnauthorized: opts.rejectUnauthorized ?? true, pool: opts.pool ?? false, maxConnections: opts.maxConnections ?? DEFAULT_MAX_CONNECTIONS, timeout: opts.timeout ?? DEFAULT_TIMEOUT, authMethod: opts.authMethod || "LOGIN", // Assign default to avoid undefined oauth2: opts.oauth2, dkim: opts.dkim }; let isInitialized = false; const connectionPool = []; const connectionQueue = []; const sanitizeHeaderValue = (value) => { return value.replace(/[\r\n\t\v\f]/g, " ").trim(); }; const parseEhloResponse = (response) => { const lines = response.split("\r\n"); const capabilities = {}; for (const line of lines) { if (line.startsWith("250-") || line.startsWith("250 ")) { const capLine = line.substring(4).trim(); const parts = capLine.split(" "); const key = parts[0]; if (key) { capabilities[key] = parts.slice(1); } } } return capabilities; }; const sendSmtpCommand = async (socket, command, expectedCode) => { return new Promise((resolve, reject) => { const expectedCodes = Array.isArray(expectedCode) ? expectedCode : [expectedCode]; let responseBuffer = ""; let lastLineCode = ""; let timeoutHandle; let onData; let onError; const cleanup = () => { socket.removeListener("data", onData); socket.removeListener("error", onError); if (timeoutHandle) { clearTimeout(timeoutHandle); } }; onError = (err) => { cleanup(); reject(createError(PROVIDER_NAME, `Socket error: ${err.message}`, { cause: err })); }; onData = (data) => { responseBuffer += data.toString(); const lines = responseBuffer.split("\r\n").filter(Boolean); if (lines.length > 0) { const lastLine = lines[lines.length - 1]; const match = lastLine.match(/^(\d{3})[\s-]/); if (match) { lastLineCode = match[1]; if (lastLine[3] === " ") { cleanup(); if (expectedCodes.includes(lastLineCode)) { resolve(responseBuffer); } else { reject(createError(PROVIDER_NAME, `Expected ${expectedCodes.join(" or ")}, got ${lastLineCode}: ${responseBuffer.trim()}`)); } } } } }; timeoutHandle = setTimeout(() => { cleanup(); reject(createError(PROVIDER_NAME, `Command timeout after ${options.timeout}ms: ${command?.substring(0, 50)}...`)); }, options.timeout); socket.on("data", onData); socket.on("error", onError); if (command) { socket.write(`${command}\r `); } }); }; const createSmtpConnection = async () => { if (options.pool && connectionPool.length > 0) { const socket = connectionPool.pop(); if (socket && !socket.destroyed) { return socket; } } if (options.pool && connectionPool.length + 1 >= options.maxConnections) { return new Promise((resolve, reject) => { const queueItem = { resolve, reject }; queueItem.timeout = setTimeout(() => { const index = connectionQueue.indexOf(queueItem); if (index !== -1) { connectionQueue.splice(index, 1); } reject(createError(PROVIDER_NAME, `Connection queue timeout after ${DEFAULT_POOL_WAIT_TIMEOUT}ms`)); }, DEFAULT_POOL_WAIT_TIMEOUT); connectionQueue.push(queueItem); }); } return new Promise((resolve, reject) => { try { const socket = options.secure ? tls.connect({ host: options.host, port: options.port, rejectUnauthorized: options.rejectUnauthorized }) : net.createConnection(options.port, options.host); socket.setTimeout(options.timeout); socket.on("timeout", () => { socket.destroy(); reject(createError(PROVIDER_NAME, `Connection timeout to ${options.host}:${options.port} after ${options.timeout}ms`)); }); socket.on("error", (err) => { reject(createError(PROVIDER_NAME, `Connection error: ${err.message}`, { cause: err })); }); socket.once("data", (data) => { const greeting = data.toString(); const code = greeting.substring(0, 3); if (code === "220") { resolve(socket); } else { socket.destroy(); reject(createError(PROVIDER_NAME, `Unexpected server greeting: ${greeting.trim()}`)); } }); } catch (err) { reject(createError(PROVIDER_NAME, `Failed to create connection: ${err.message}`, { cause: err })); } }); }; const upgradeToTLS = async (socket) => { return new Promise((resolve, reject) => { try { const tlsOptions = { socket, host: options.host, rejectUnauthorized: options.rejectUnauthorized }; const tlsSocket = tls.connect(tlsOptions); tlsSocket.setTimeout(options.timeout); tlsSocket.on("error", (err) => { reject(createError(PROVIDER_NAME, `TLS connection error: ${err.message}`, { cause: err })); }); tlsSocket.on("timeout", () => { tlsSocket.destroy(); reject(createError(PROVIDER_NAME, `TLS connection timeout after ${options.timeout}ms`)); }); tlsSocket.once("secure", () => { resolve(tlsSocket); }); } catch (err) { reject(createError(PROVIDER_NAME, `Failed to upgrade to TLS: ${err.message}`, { cause: err })); } }); }; const releaseConnection = (socket) => { if (socket.destroyed || !options.pool) { try { socket.destroy(); } catch { } return; } if (connectionQueue.length > 0) { const next = connectionQueue.shift(); if (next) { clearTimeout(next.timeout); next.resolve(socket); return; } } connectionPool.push(socket); }; const closeConnection = async (socket, release = false) => { return new Promise((resolve) => { try { if (release) { socket.write("RSET\r\n"); releaseConnection(socket); resolve(); return; } socket.write("QUIT\r\n"); socket.end(); socket.once("close", () => resolve()); } catch { resolve(); } }); }; const authenticate = async (socket) => { if (!options.user) { return; } const ehloResponse = await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); const capabilities = parseEhloResponse(ehloResponse); const authCapability = Object.keys(capabilities).find((key) => key.toUpperCase() === "AUTH"); if (!authCapability && (options.user || options.password)) { throw createError(PROVIDER_NAME, "Server does not support authentication"); } const supportedMethods = authCapability ? capabilities[authCapability] || [] : []; const authMethod = options.authMethod || (supportedMethods.includes("CRAM-MD5") ? "CRAM-MD5" : supportedMethods.includes("LOGIN") ? "LOGIN" : supportedMethods.includes("PLAIN") ? "PLAIN" : null); if (!authMethod) { throw createError(PROVIDER_NAME, "No supported authentication methods"); } if (authMethod === "OAUTH2" && options.oauth2) { try { const { user, accessToken } = options.oauth2; const auth = `user=${user}auth=Bearer ${accessToken}`; const authBase64 = Buffer.from(auth).toString("base64"); await sendSmtpCommand(socket, `AUTH XOAUTH2 ${authBase64}`, "235"); return; } catch (error) { const errorMessage = error.message; if (errorMessage.includes("535") || errorMessage.includes("Authentication failed")) { throw createError(PROVIDER_NAME, "Authentication failed: Invalid OAuth2 credentials"); } throw error; } } if (authMethod === "CRAM-MD5" && options.password) { try { const response = await sendSmtpCommand(socket, "AUTH CRAM-MD5", "334"); const challenge = Buffer.from(response.split(" ")[1], "base64").toString("utf-8"); const hmac = crypto.createHmac("md5", options.password); hmac.update(challenge); const digest = hmac.digest("hex"); const cramResponse = `${options.user} ${digest}`; await sendSmtpCommand( socket, Buffer.from(cramResponse).toString("base64"), "235" ); return; } catch (error) { const errorMessage = error.message; if (errorMessage.includes("535") || errorMessage.includes("Authentication failed")) { throw createError(PROVIDER_NAME, "Authentication failed: Invalid username or password"); } throw error; } } if (authMethod === "LOGIN" && options.password) { try { await sendSmtpCommand(socket, "AUTH LOGIN", "334"); await sendSmtpCommand( socket, Buffer.from(options.user).toString("base64"), "334" ); await sendSmtpCommand( socket, Buffer.from(options.password).toString("base64"), "235" ); return; } catch (error) { const errorMessage = error.message; if (errorMessage.includes("535") || errorMessage.includes("Authentication failed")) { throw createError(PROVIDER_NAME, "Authentication failed: Invalid username or password"); } throw error; } } if (authMethod === "PLAIN" && options.password) { try { const authPlain = Buffer.from(`\0${options.user}\0${options.password}`).toString("base64"); await sendSmtpCommand( socket, `AUTH PLAIN ${authPlain}`, "235" ); return; } catch (error) { const errorMessage = error.message; if (errorMessage.includes("535") || errorMessage.includes("Authentication failed")) { throw createError(PROVIDER_NAME, "Authentication failed: Invalid username or password"); } throw error; } } throw createError(PROVIDER_NAME, "Authentication failed - no valid credentials or method"); }; const signWithDkim = (message) => { if (!options.dkim) { return message; } const { domainName, keySelector, privateKey } = options.dkim; try { const [headersPart, bodyPart] = message.split("\r\n\r\n"); const headers = headersPart.split("\r\n"); const canonicalize = (str) => str.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim(); const canonicalizedBody = canonicalize(bodyPart); const bodyHash = crypto.createHash("sha256").update(canonicalizedBody).digest("base64"); const headerNames = ["from", "to", "subject", "date"]; const headersToSign = headers.filter((h) => headerNames.some((n) => h.toLowerCase().startsWith(`${n}:`))); const dkimHeaderList = headersToSign.map((h) => h.split(":")[0].toLowerCase()).join(":"); const now = Math.floor(Date.now() / 1e3); const dkimFields = { v: "1", a: "rsa-sha256", c: "relaxed/relaxed", d: domainName, s: keySelector, t: now.toString(), bh: bodyHash, h: dkimHeaderList }; const dkimHeader = `DKIM-Signature: ${Object.entries(dkimFields).map(([k, v]) => `${k}=${v}`).join("; ")}; b=`; const headersForSign = [...headersToSign, dkimHeader].map(canonicalize).join("\r\n"); const signer = crypto.createSign("RSA-SHA256"); signer.update(headersForSign); const signature = signer.sign(privateKey, "base64"); const finalDkimHeader = `${dkimHeader}${signature}`; return `${finalDkimHeader}\r ${headers.join("\r\n")}\r \r ${bodyPart}`; } catch (error) { console.error(`[${PROVIDER_NAME}] DKIM signing error:`, error); return message; } }; return { name: PROVIDER_NAME, features: { attachments: true, html: true, templates: false, tracking: false, customHeaders: true, batchSending: options.pool, // Now supported with pooling tagging: false, scheduling: false, replyTo: true }, options, /** * Initialize the SMTP provider */ async initialize() { if (isInitialized) { return; } try { if (!await this.isAvailable()) { throw createError( PROVIDER_NAME, `SMTP server not available at ${options.host}:${options.port}` ); } isInitialized = true; } catch (error) { throw createError( PROVIDER_NAME, `Failed to initialize: ${error.message}`, { cause: error } ); } }, /** * Check if SMTP server is available */ async isAvailable() { try { const portAvailable = await isPortAvailable(options.host, options.port); if (!portAvailable) { return false; } const socket = await createSmtpConnection(); await closeConnection(socket); return true; } catch { return false; } }, /** * Send email through SMTP */ async sendEmail(emailOpts) { try { const validationErrors = validateEmailOptions(emailOpts); if (validationErrors.length > 0) { return { success: false, error: createError( PROVIDER_NAME, `Invalid email options: ${validationErrors.join(", ")}` ) }; } if (!isInitialized) { await this.initialize(); } let socket = await createSmtpConnection(); try { await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); if (!options.secure) { try { const ehloResponse = await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); const capabilities = parseEhloResponse(ehloResponse); if (Object.keys(capabilities).includes("STARTTLS")) { await sendSmtpCommand(socket, "STARTTLS", "220"); const tlsSocket = await upgradeToTLS(socket); socket = tlsSocket; await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); } } catch (error) { if (options.rejectUnauthorized !== false) { throw createError( PROVIDER_NAME, `STARTTLS failed or not supported: ${error.message}`, { cause: error } ); } } } await authenticate(socket); await sendSmtpCommand( socket, `MAIL FROM:<${emailOpts.from.email}>`, "250" ); const recipients = []; if (Array.isArray(emailOpts.to)) { recipients.push(...emailOpts.to.map((r) => r.email)); } else { recipients.push(emailOpts.to.email); } if (emailOpts.cc) { if (Array.isArray(emailOpts.cc)) { recipients.push(...emailOpts.cc.map((r) => r.email)); } else { recipients.push(emailOpts.cc.email); } } if (emailOpts.bcc) { if (Array.isArray(emailOpts.bcc)) { recipients.push(...emailOpts.bcc.map((r) => r.email)); } else { recipients.push(emailOpts.bcc.email); } } for (const recipient of recipients) { await sendSmtpCommand( socket, `RCPT TO:<${recipient}>`, "250" ); } await sendSmtpCommand(socket, "DATA", "354"); let mimeMessage = buildMimeMessage(emailOpts); const additionalHeaders = []; if (emailOpts.dsn) { const dsnOptions = []; if (emailOpts.dsn.success) dsnOptions.push("SUCCESS"); if (emailOpts.dsn.failure) dsnOptions.push("FAILURE"); if (emailOpts.dsn.delay) dsnOptions.push("DELAY"); if (dsnOptions.length > 0) { additionalHeaders.push(`X-DSN-NOTIFY: ${dsnOptions.join(",")}`); } } if (emailOpts.priority) { let priorityValue = ""; switch (emailOpts.priority) { case "high": priorityValue = "1 (Highest)"; additionalHeaders.push("Importance: High"); break; case "normal": priorityValue = "3 (Normal)"; additionalHeaders.push("Importance: Normal"); break; case "low": priorityValue = "5 (Lowest)"; additionalHeaders.push("Importance: Low"); break; } additionalHeaders.push(`X-Priority: ${priorityValue}`); } if (emailOpts.inReplyTo) { additionalHeaders.push(`In-Reply-To: ${sanitizeHeaderValue(emailOpts.inReplyTo)}`); } if (emailOpts.references) { const refs = Array.isArray(emailOpts.references) ? emailOpts.references.map(sanitizeHeaderValue).join(" ") : sanitizeHeaderValue(emailOpts.references); additionalHeaders.push(`References: ${refs}`); } if (emailOpts.listUnsubscribe) { let unsubValue; if (Array.isArray(emailOpts.listUnsubscribe)) { unsubValue = emailOpts.listUnsubscribe.map((val) => `<${sanitizeHeaderValue(val)}>`).join(", "); } else { unsubValue = `<${sanitizeHeaderValue(emailOpts.listUnsubscribe)}>`; } additionalHeaders.push(`List-Unsubscribe: ${unsubValue}`); } if (emailOpts.googleMailHeaders) { const { googleMailHeaders } = emailOpts; if (googleMailHeaders.feedbackId) { additionalHeaders.push( `Feedback-ID: ${sanitizeHeaderValue(googleMailHeaders.feedbackId)}` ); } if (googleMailHeaders.promotionalContent) { additionalHeaders.push("X-Google-Promotion: promotional"); } if (googleMailHeaders.category) { additionalHeaders.push(`X-Gmail-Labels: ${googleMailHeaders.category}`); } } if (additionalHeaders.length > 0) { const splitIndex = mimeMessage.indexOf("\r\n\r\n"); if (splitIndex !== -1) { const headerPart = mimeMessage.slice(0, splitIndex); const bodyPart = mimeMessage.slice(splitIndex + 4); mimeMessage = `${headerPart}\r ${additionalHeaders.join("\r\n")}\r \r ${bodyPart}`; } } if (options.dkim && (emailOpts.useDkim || emailOpts.useDkim === void 0)) { mimeMessage = signWithDkim(mimeMessage); } await sendSmtpCommand(socket, `${mimeMessage}\r .`, "250"); const messageId = generateMessageId(); await closeConnection(socket, options.pool); return { success: true, data: { messageId, sent: true, timestamp: /* @__PURE__ */ new Date(), provider: PROVIDER_NAME, response: "Message accepted" } }; } catch (error) { try { await closeConnection(socket); } catch { } throw error; } } catch (error) { return { success: false, error: createError( PROVIDER_NAME, `Failed to send email: ${error.message}`, { cause: error } ) }; } }, /** * Validate SMTP credentials */ async validateCredentials() { try { if (!await this.isAvailable()) { return false; } const socket = await createSmtpConnection(); try { await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); if (!options.secure) { try { const ehloResponse = await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); const capabilities = parseEhloResponse(ehloResponse); if (Object.keys(capabilities).includes("STARTTLS")) { await sendSmtpCommand(socket, "STARTTLS", "220"); const tlsSocket = await upgradeToTLS(socket); Object.assign(socket, tlsSocket); await sendSmtpCommand(socket, `EHLO ${options.host}`, "250"); } } catch { if (options.rejectUnauthorized !== false) { return false; } } } await authenticate(socket); await closeConnection(socket); return true; } catch { await closeConnection(socket); return false; } } catch { return false; } }, /** * Cleanly shut down the provider and release resources */ async shutdown() { for (const socket of connectionPool) { try { await closeConnection(socket); } catch { } } connectionPool.length = 0; for (const queueItem of connectionQueue) { clearTimeout(queueItem.timeout); queueItem.reject(new Error("Provider shutdown")); } connectionQueue.length = 0; } }; }); export { smtpProvider as default, smtpProvider };