map-error.js
3.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
* 结构化邮件错误 + 厂商错误 → 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}`
}