ses.js
5.61 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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
* 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
}
}