/** * AWS SES provider —— 直接对 SESv2 `SendEmail` REST 接口做 SigV4 签名投递, * 不引入 AWS SDK(保持包薄)。client 状态全在工厂闭包内构造,**非模块顶层**、不读 env。 * * 仅用 Node 内置 `crypto` 与全局 `fetch`(Node 24)。 */ import { createHash, createHmac } from 'node:crypto' import { EmailError } from '../map-error.js' const SERVICE = 'ses' /** * @param {Object} credentials * @param {string} credentials.region * @param {string} credentials.accessKeyId * @param {string} credentials.accessKeySecret * @param {string} credentials.defaultFrom * @returns {import('../index.js').Provider} */ export function createSesProvider({ region, accessKeyId, accessKeySecret, defaultFrom }) { if (!region || !accessKeyId || !accessKeySecret || !defaultFrom) { throw new EmailError({ provider: 'ses', reason: 'auth', retryable: false, message: 'ses credentials require { region, accessKeyId, accessKeySecret, defaultFrom }', }) } const host = `email.${region}.amazonaws.com` const endpoint = `https://${host}/v2/email/outbound-emails` return { async send(msg) { const toAddrs = Array.isArray(msg.to) ? msg.to : [msg.to] const payload = { FromEmailAddress: msg.from ?? defaultFrom, Destination: { ToAddresses: toAddrs, CcAddresses: msg.cc, BccAddresses: msg.bcc, }, ReplyToAddresses: msg.reply_to ? [msg.reply_to] : undefined, Content: { Simple: { Subject: { Data: msg.subject, Charset: 'UTF-8' }, Body: { Html: msg.html ? { Data: msg.html, Charset: 'UTF-8' } : undefined, Text: msg.text ? { Data: msg.text, Charset: 'UTF-8' } : undefined, }, }, }, } const body = JSON.stringify(stripUndefined(payload)) const headers = signRequest({ method: 'POST', host, path: '/v2/email/outbound-emails', body, region, accessKeyId, accessKeySecret, }) let res try { res = await fetch(endpoint, { method: 'POST', headers, body }) } catch (err) { // 网络层失败 = transient throw new EmailError({ provider: 'ses', reason: 'transient', retryable: true, cause: err, message: 'ses request failed' }) } const respText = await res.text() if (!res.ok) { // 把 HTTP 状态喂给 email.js 的 mapError 归一(429/5xx=transient、401/403=auth) const parsed = safeJson(respText) const err = Object.assign(new Error(parsed?.message ?? respText ?? `SES HTTP ${res.status}`), { statusCode: res.status, name: parsed?.__type ?? parsed?.type, Code: parsed?.__type, }) throw err } const json = safeJson(respText) ?? {} return { message_id: json.MessageId ?? '', provider: 'ses', to: msg.to } }, } } /** * SigV4 签名,返回完整请求头(含 Authorization)。 * @param {{ method: string, host: string, path: string, body: string, region: string, accessKeyId: string, accessKeySecret: string }} args * @returns {Record} */ function signRequest({ method, host, path, body, region, accessKeyId, accessKeySecret }) { const now = new Date() const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '') // YYYYMMDDTHHMMSSZ const dateStamp = amzDate.slice(0, 8) const payloadHash = sha256Hex(body) const canonicalHeaders = `content-type:application/json\n` + `host:${host}\n` + `x-amz-content-sha256:${payloadHash}\n` + `x-amz-date:${amzDate}\n` const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date' const canonicalRequest = [ method, path, '', // canonical query string (none) canonicalHeaders, signedHeaders, payloadHash, ].join('\n') const scope = `${dateStamp}/${region}/${SERVICE}/aws4_request` const stringToSign = [ 'AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest), ].join('\n') const signingKey = getSignatureKey(accessKeySecret, dateStamp, region, SERVICE) const signature = hmac(signingKey, stringToSign).toString('hex') const authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}` return { 'Content-Type': 'application/json', Host: host, 'X-Amz-Content-Sha256': payloadHash, 'X-Amz-Date': amzDate, Authorization: authorization, } } /** @param {string} secret @param {string} dateStamp @param {string} region @param {string} service */ function getSignatureKey(secret, dateStamp, region, service) { const kDate = hmac(`AWS4${secret}`, dateStamp) const kRegion = hmac(kDate, region) const kService = hmac(kRegion, service) return hmac(kService, 'aws4_request') } /** @param {import('node:crypto').BinaryLike} key @param {string} data @returns {Buffer} */ function hmac(key, data) { return createHmac('sha256', key).update(data, 'utf8').digest() } /** @param {string} data @returns {string} */ function sha256Hex(data) { return createHash('sha256').update(data, 'utf8').digest('hex') } /** @param {any} obj @returns {any} */ function stripUndefined(obj) { if (Array.isArray(obj)) return obj.map(stripUndefined) if (obj && typeof obj === 'object') { const out = {} for (const [k, v] of Object.entries(obj)) { if (v === undefined) continue out[k] = stripUndefined(v) } return out } return obj } /** @param {string} text @returns {any} */ function safeJson(text) { try { return JSON.parse(text) } catch { return null } }