ses.js 5.61 KB
/**
 * 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<string,string>}
 */
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
  }
}