UNPKG

unemail

Version:

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

354 lines (351 loc) 12.4 kB
import { Buffer } from 'node:buffer'; import * as crypto from 'node:crypto'; import * as https from 'node:https'; import { validateEmailOptions, createError, createRequiredError } from 'unemail/utils'; import { defineProvider } from './base.mjs'; const PROVIDER_NAME = "aws-ses"; const defaultOptions = { region: "us-east-1", maxAttempts: 3, apiVersion: "2010-12-01" }; const awsSesProvider = defineProvider((opts = {}) => { const options = { ...defaultOptions, ...opts }; const debug = (message, ...args) => { if (options.debug) { console.log(`[AWS-SES] ${message}`, ...args); } }; const createCanonicalRequest = (method, path, query, headers, payload) => { const canonicalQueryString = Object.keys(query).sort().map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`).join("&"); const canonicalHeaders = `${Object.keys(headers).sort().map((key) => `${key.toLowerCase()}:${headers[key]}`).join("\n")} `; const signedHeaders = Object.keys(headers).sort().map((key) => key.toLowerCase()).join(";"); const payloadHash = crypto.createHash("sha256").update(payload).digest("hex"); return [ method, path, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash ].join("\n"); }; const createStringToSign = (timestamp, region, canonicalRequest) => { const date = timestamp.substring(0, 8); const hash = crypto.createHash("sha256").update(canonicalRequest).digest("hex"); return [ "AWS4-HMAC-SHA256", timestamp, `${date}/${region}/ses/aws4_request`, hash ].join("\n"); }; const calculateSignature = (secretKey, timestamp, region, stringToSign) => { const date = timestamp.substring(0, 8); const kDate = crypto.createHmac("sha256", `AWS4${secretKey}`).update(date).digest(); const kRegion = crypto.createHmac("sha256", kDate).update(region).digest(); const kService = crypto.createHmac("sha256", kRegion).update("ses").digest(); const kSigning = crypto.createHmac("sha256", kService).update("aws4_request").digest(); return crypto.createHmac("sha256", kSigning).update(stringToSign).digest("hex"); }; const createAuthHeader = (accessKeyId, timestamp, region, headers, signature) => { const date = timestamp.substring(0, 8); const signedHeaders = Object.keys(headers).sort().map((key) => key.toLowerCase()).join(";"); return [ `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${date}/${region}/ses/aws4_request`, `SignedHeaders=${signedHeaders}`, `Signature=${signature}` ].join(", "); }; const makeRequest = (action, params) => { if (!options.accessKeyId || !options.secretAccessKey) { debug("Missing required credentials: accessKeyId or secretAccessKey"); throw createRequiredError(PROVIDER_NAME, ["accessKeyId", "secretAccessKey"]); } return new Promise((resolve, reject) => { try { const region = options.region || defaultOptions.region; const apiVersion = options.apiVersion || defaultOptions.apiVersion; const host = options.endpoint || `email.${region}.amazonaws.com`; const path = "/"; const method = "POST"; debug("Making request to AWS SES:", { action, region, host }); const body = new URLSearchParams(); body.append("Action", action); body.append("Version", apiVersion); Object.entries(params).forEach(([key, value]) => { if (value !== void 0 && value !== null) { body.append(key, String(value)); } }); const bodyString = body.toString(); debug("Request body:", bodyString); const now = /* @__PURE__ */ new Date(); const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); const _date = amzDate.substring(0, 8); const headers = { "Host": host, "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(bodyString).toString(), "X-Amz-Date": amzDate }; if (options.sessionToken) { headers["X-Amz-Security-Token"] = options.sessionToken; } debug("Request headers:", headers); const canonicalRequest = createCanonicalRequest( method, path, {}, headers, bodyString ); const stringToSign = createStringToSign( amzDate, region, canonicalRequest ); const signature = calculateSignature( options.secretAccessKey, amzDate, region, stringToSign ); headers.Authorization = createAuthHeader( options.accessKeyId, amzDate, region, headers, signature ); debug("Making HTTPS request to:", `https://${host}${path}`); const req = https.request( { host, path, method, headers }, (res) => { let data = ""; debug("Response status:", res.statusCode); debug("Response headers:", res.headers); res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { debug("Response data:", data); if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { const result = {}; if (action === "SendRawEmail") { const messageIdMatch = data.match(/<MessageId>(.*?)<\/MessageId>/); if (messageIdMatch && messageIdMatch[1]) { result.MessageId = messageIdMatch[1]; debug("Extracted MessageId:", result.MessageId); } } else if (action === "GetSendQuota") { const maxMatch = data.match(/<Max24HourSend>(.*?)<\/Max24HourSend>/); if (maxMatch && maxMatch[1]) { result.Max24HourSend = Number.parseFloat(maxMatch[1]); debug("Extracted Max24HourSend:", result.Max24HourSend); } } resolve(result); } else { const errorMatch = data.match(/<Message>(.*?)<\/Message>/); const errorMessage = errorMatch ? errorMatch[1] : "Unknown AWS SES error"; debug("AWS SES Error:", errorMessage); reject(new Error(`AWS SES API Error: ${errorMessage}`)); } }); } ); req.on("error", (error) => { debug("Request error:", error.message); reject(error); }); req.write(bodyString); req.end(); } catch (error) { debug("makeRequest exception:", error.message); reject(error); } }); }; const formatEmailAddress = (address) => { return address.name ? `${address.name} <${address.email}>` : address.email; }; const generateMimeMessage = (options2) => { const boundary = `----=${crypto.randomUUID().replace(/-/g, "")}`; const now = (/* @__PURE__ */ new Date()).toString(); const messageId = `<${crypto.randomUUID().replace(/-/g, "")}@${options2.from.email.split("@")[1]}>`; let message = ""; message += `From: ${formatEmailAddress(options2.from)}\r `; if (Array.isArray(options2.to)) { message += `To: ${options2.to.map(formatEmailAddress).join(", ")}\r `; } else { message += `To: ${formatEmailAddress(options2.to)}\r `; } if (options2.cc) { if (Array.isArray(options2.cc)) { message += `Cc: ${options2.cc.map(formatEmailAddress).join(", ")}\r `; } else { message += `Cc: ${formatEmailAddress(options2.cc)}\r `; } } if (options2.bcc) { if (Array.isArray(options2.bcc)) { message += `Bcc: ${options2.bcc.map(formatEmailAddress).join(", ")}\r `; } else { message += `Bcc: ${formatEmailAddress(options2.bcc)}\r `; } } message += `Subject: ${options2.subject}\r `; message += `Date: ${now}\r `; message += `Message-ID: ${messageId}\r `; message += "MIME-Version: 1.0\r\n"; if (options2.headers) { for (const [name, value] of Object.entries(options2.headers)) { message += `${name}: ${value}\r `; } } message += `Content-Type: multipart/alternative; boundary="${boundary}"\r \r `; if (options2.text) { message += `--${boundary}\r `; message += "Content-Type: text/plain; charset=UTF-8\r\n"; message += "Content-Transfer-Encoding: quoted-printable\r\n\r\n"; message += `${options2.text.replace(/([=\r\n])/g, "=$1")}\r \r `; } if (options2.html) { message += `--${boundary}\r `; message += "Content-Type: text/html; charset=UTF-8\r\n"; message += "Content-Transfer-Encoding: quoted-printable\r\n\r\n"; message += `${options2.html.replace(/([=\r\n])/g, "=$1")}\r \r `; } message += `--${boundary}--\r `; return message; }; return { name: PROVIDER_NAME, features: { attachments: false, // Not implemented in this version html: true, templates: false, tracking: false, customHeaders: true, batchSending: false, tagging: false, // Explicitly state that tagging is not supported scheduling: false, // Explicitly state that scheduling is not supported replyTo: false // Explicitly state that reply-to is not supported }, options, /** * Initialize the AWS SES provider */ initialize() { debug("Initializing AWS SES provider with options:", { region: options.region, accessKeyId: options.accessKeyId ? `***${options.accessKeyId.slice(-4)}` : void 0, secretAccessKey: options.secretAccessKey ? "***" : void 0, endpoint: options.endpoint }); }, /** * Check if AWS SES is available */ async isAvailable() { try { const response = await makeRequest("GetSendQuota", {}); return !!response.Max24HourSend; } catch { return false; } }, /** * Validate AWS SES credentials */ async validateCredentials() { return this.isAvailable(); }, /** * Send email using AWS SES with the Raw Email API * This avoids issues with the complex XML structure of the regular SendEmail API */ async sendEmail(options2) { try { const validationErrors = validateEmailOptions(options2); if (validationErrors.length > 0) { throw createError(PROVIDER_NAME, `Invalid email options: ${validationErrors.join(", ")}`); } const params = {}; if (options2.configurationSetName) { params.ConfigurationSetName = options2.configurationSetName; } if (options2.sourceArn) { params.SourceArn = options2.sourceArn; } if (options2.returnPath) { params.ReturnPath = options2.returnPath; } if (options2.returnPathArn) { params.ReturnPathArn = options2.returnPathArn; } if (options2.messageTags && Object.keys(options2.messageTags).length > 0) { Object.entries(options2.messageTags).forEach(([name, value], index) => { params[`Tags.member.${index + 1}.Name`] = name; params[`Tags.member.${index + 1}.Value`] = value; }); } const rawMessage = generateMimeMessage(options2); const encodedMessage = Buffer.from(rawMessage).toString("base64"); params["RawMessage.Data"] = encodedMessage; const response = await makeRequest("SendRawEmail", params); return { success: true, data: { messageId: response.MessageId || "", sent: true, timestamp: /* @__PURE__ */ new Date(), provider: PROVIDER_NAME, response } }; } catch (error) { return { success: false, error: createError(PROVIDER_NAME, `Failed to send email: ${error.message}`, { cause: error }) }; } }, /** * Get provider instance - returns null since we don't use AWS SDK */ getInstance: () => null }; }); export { awsSesProvider as default };