map-error.js 3.64 KB
/**
 * 结构化邮件错误 + 厂商错误 → EmailError 的纯函数归一。
 *
 * reason 取值固定为 §3 声明的集合:
 *   'invalidRecipient' | 'badTemplate' | 'transient' | 'auth' | 'unknown'
 * retryable 规则:限流/超时/5xx = transient+retryable;鉴权失败 = auth 不重试;
 *   非法收件人 = invalidRecipient 不重试。
 */

/** @typedef {'invalidRecipient'|'badTemplate'|'transient'|'auth'|'unknown'} EmailReason */

export class EmailError extends Error {
  /**
   * @param {Object} info
   * @param {string} info.provider
   * @param {EmailReason} info.reason
   * @param {boolean} info.retryable
   * @param {unknown} [info.cause]
   * @param {string} [info.message]
   */
  constructor({ provider, reason, retryable, cause, message }) {
    super(message ?? `email failed: ${reason}`)
    this.name = 'EmailError'
    this.provider = provider
    this.reason = reason
    this.retryable = retryable
    this.cause = cause
  }
}

const RETRYABLE = new Set(['transient'])

/**
 * 把任意厂商错误归一成 EmailError。已是 EmailError 的原样透传。
 * @param {unknown} err
 * @param {string} provider
 * @returns {EmailError}
 */
export function mapError(err, provider) {
  if (err instanceof EmailError) return err
  const reason = classify(err)
  return new EmailError({
    provider,
    reason,
    retryable: RETRYABLE.has(reason),
    cause: err,
    message: extractMessage(err, reason),
  })
}

/**
 * 厂商错误 → reason 分类(纯函数,全表可测)。
 * 优先看显式标识(SMTP `responseCode`、SES `Code`/`statusCode`、`code`),
 * 再退化到消息文本启发式。
 * @param {any} err
 * @returns {EmailReason}
 */
export function classify(err) {
  if (!err || typeof err !== 'object') return 'unknown'

  const status = numOf(err.statusCode) ?? numOf(err.responseCode)
  if (status != null) {
    if (status === 429) return 'transient'
    if (status >= 500) return 'transient'
    if (status === 401 || status === 403) return 'auth'
  }

  // Nodemailer / SMTP 错误码
  const code = typeof err.code === 'string' ? err.code : undefined
  if (code === 'EAUTH') return 'auth'
  if (code === 'EENVELOPE' || code === 'ENVELOPE') return 'invalidRecipient'
  if (code === 'ETIMEDOUT' || code === 'ECONNECTION' || code === 'ESOCKET' || code === 'ECONNRESET') {
    return 'transient'
  }

  // AWS SES 错误名(err.name / err.Code)
  const name = typeof err.name === 'string' ? err.name : (typeof err.Code === 'string' ? err.Code : undefined)
  if (name) {
    if (/Throttl|TooManyRequests|ServiceUnavailable|InternalFailure|RequestTimeout/i.test(name)) {
      return 'transient'
    }
    if (/InvalidClientTokenId|SignatureDoesNotMatch|UnrecognizedClient|AccessDenied|AuthFailure/i.test(name)) {
      return 'auth'
    }
    if (/InvalidParameterValue.*destination|MessageRejected.*address|InvalidRecipient/i.test(name)) {
      return 'invalidRecipient'
    }
  }

  const msg = typeof err.message === 'string' ? err.message : ''
  if (/timeout|timed out|temporarily|rate limit|throttl/i.test(msg)) return 'transient'
  if (/auth|credential|unauthor|access denied|signature/i.test(msg)) return 'auth'
  if (/recipient|invalid.*address|no recipients/i.test(msg)) return 'invalidRecipient'

  return 'unknown'
}

/** @param {unknown} v @returns {number|undefined} */
function numOf(v) {
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}

/** @param {any} err @param {string} reason @returns {string} */
function extractMessage(err, reason) {
  const m = err && typeof err === 'object' && typeof err.message === 'string' ? err.message : ''
  return m ? `email failed (${reason}): ${m}` : `email failed: ${reason}`
}