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
JavaScript
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 };