/** * 结构化邮件错误 + 厂商错误 → 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}` }