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