@blamejs/core 0.7.4 → 0.7.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/mail.js
CHANGED
|
@@ -1,962 +1,981 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* mail — message contract + pluggable transports.
|
|
4
|
-
*
|
|
5
|
-
* Both the contract and the transport surface ship together. Operators
|
|
6
|
-
* can also pass any function or `{ send }` object as a custom transport.
|
|
7
|
-
*
|
|
8
|
-
* mail.transports.console — logs message to stderr (dev default)
|
|
9
|
-
* mail.transports.memory — captures into a `sent[]` array (tests)
|
|
10
|
-
* mail.transports.smtp — raw RFC 5321 over net/tls with STARTTLS,
|
|
11
|
-
* AUTH LOGIN, and PQC-friendly TLS opts
|
|
12
|
-
* mail.transports.http — generic HTTP-API transport: operator
|
|
13
|
-
* supplies endpoint, headers, serialize(),
|
|
14
|
-
* and interpret() — works with any vendor
|
|
15
|
-
* that speaks JSON-over-HTTPS (Postmark,
|
|
16
|
-
* Mailgun, SES HTTP, SendGrid, Resend, …)
|
|
17
|
-
* mail.transports.resend — thin preset that wires http to the
|
|
18
|
-
* Resend API (illustrates the pattern)
|
|
19
|
-
*
|
|
20
|
-
* Public API:
|
|
21
|
-
*
|
|
22
|
-
* mail.create({ transport?, defaults?, audit? }) → instance
|
|
23
|
-
*
|
|
24
|
-
* transport — function(message) | { send(message) }; default: console.
|
|
25
|
-
* defaults — { from, replyTo, headers, ... } merged into every
|
|
26
|
-
* message unless the message overrides.
|
|
27
|
-
* audit — emit mail.send.success / .failure audit events
|
|
28
|
-
* (default true).
|
|
29
|
-
*
|
|
30
|
-
* await instance.send(message)
|
|
31
|
-
* message: {
|
|
32
|
-
* to: "x@y" | ["x@y", ...]
|
|
33
|
-
* cc: string | string[]
|
|
34
|
-
* bcc: string | string[]
|
|
35
|
-
* from: "Name <noreply@app>" (or instance default)
|
|
36
|
-
* replyTo: "..."
|
|
37
|
-
* subject: "..."
|
|
38
|
-
* text: "plain body" (at least one of text/html)
|
|
39
|
-
* html: "<p>...</p>"
|
|
40
|
-
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
-
* attachments: [{
|
|
42
|
-
* filename: "report.pdf", // required
|
|
43
|
-
* content: buf, // Buffer or string
|
|
44
|
-
* contentType: "application/pdf", // default application/octet-stream
|
|
45
|
-
* contentDisposition: "attachment", // or "inline"
|
|
46
|
-
* cid: "logo-1", // for inline images:
|
|
47
|
-
* // <img src="cid:logo-1">
|
|
48
|
-
* }, ...]
|
|
49
|
-
* }
|
|
50
|
-
* → whatever the transport returned
|
|
51
|
-
*
|
|
52
|
-
* When attachments are present the SMTP transport wraps the body in
|
|
53
|
-
* multipart/mixed; text+html bodies still use multipart/alternative
|
|
54
|
-
* inside. Resend's http preset forwards attachments via the Resend API
|
|
55
|
-
* shape (base64 content + content_id for inline). Operators wiring
|
|
56
|
-
* other vendors against httpTransport include attachments in their
|
|
57
|
-
* own serialize() per-vendor.
|
|
58
|
-
*
|
|
59
|
-
* Validation surface uses MailError (FrameworkError subclass) with
|
|
60
|
-
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
61
|
-
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
62
|
-
* resend-*. Vendor-specific presets carry their own code prefix so
|
|
63
|
-
* diagnostic logs identify the provider that rejected the message.
|
|
64
|
-
*/
|
|
65
|
-
var C = require("./constants");
|
|
66
|
-
var crypto = require("./crypto");
|
|
67
|
-
var lazyRequire = require("./lazy-require");
|
|
68
|
-
var safeBuffer = require("./safe-buffer");
|
|
69
|
-
var audit = lazyRequire(function () { return require("./audit"); });
|
|
70
|
-
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
71
|
-
var
|
|
72
|
-
var
|
|
73
|
-
var
|
|
74
|
-
var
|
|
75
|
-
var
|
|
76
|
-
var
|
|
77
|
-
var
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
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
|
-
var
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
_normalizeRecipientList(message.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
var
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
att.contentDisposition !== "
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"[mail.console]
|
|
273
|
-
"[mail.console]
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (message.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
lines.push(
|
|
287
|
-
lines.push(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
var
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
var
|
|
341
|
-
var
|
|
342
|
-
var
|
|
343
|
-
lines
|
|
344
|
-
lines.push("Content-
|
|
345
|
-
lines.push("Content-
|
|
346
|
-
|
|
347
|
-
lines.push("");
|
|
348
|
-
lines.push(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
var
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
lines.push("
|
|
377
|
-
lines.push("");
|
|
378
|
-
lines.push(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
headers
|
|
390
|
-
headers.push("
|
|
391
|
-
|
|
392
|
-
if (message.
|
|
393
|
-
if (message.
|
|
394
|
-
headers.push("
|
|
395
|
-
headers.push("
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
var
|
|
409
|
-
var
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
parts
|
|
425
|
-
parts.push("
|
|
426
|
-
parts.push("");
|
|
427
|
-
parts.push(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
parts.push(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
body = body.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
var
|
|
457
|
-
var
|
|
458
|
-
var
|
|
459
|
-
var
|
|
460
|
-
var
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (opts.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
var
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
var
|
|
503
|
-
var
|
|
504
|
-
var
|
|
505
|
-
var
|
|
506
|
-
var
|
|
507
|
-
var
|
|
508
|
-
var
|
|
509
|
-
var
|
|
510
|
-
var
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
var
|
|
516
|
-
var
|
|
517
|
-
var
|
|
518
|
-
var
|
|
519
|
-
var
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
var
|
|
523
|
-
var
|
|
524
|
-
var
|
|
525
|
-
var
|
|
526
|
-
var
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
var
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
send(
|
|
620
|
-
}
|
|
621
|
-
else if (step ===
|
|
622
|
-
if (code !==
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if (
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
return {
|
|
858
|
-
},
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
}
|
|
962
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* mail — message contract + pluggable transports.
|
|
4
|
+
*
|
|
5
|
+
* Both the contract and the transport surface ship together. Operators
|
|
6
|
+
* can also pass any function or `{ send }` object as a custom transport.
|
|
7
|
+
*
|
|
8
|
+
* mail.transports.console — logs message to stderr (dev default)
|
|
9
|
+
* mail.transports.memory — captures into a `sent[]` array (tests)
|
|
10
|
+
* mail.transports.smtp — raw RFC 5321 over net/tls with STARTTLS,
|
|
11
|
+
* AUTH LOGIN, and PQC-friendly TLS opts
|
|
12
|
+
* mail.transports.http — generic HTTP-API transport: operator
|
|
13
|
+
* supplies endpoint, headers, serialize(),
|
|
14
|
+
* and interpret() — works with any vendor
|
|
15
|
+
* that speaks JSON-over-HTTPS (Postmark,
|
|
16
|
+
* Mailgun, SES HTTP, SendGrid, Resend, …)
|
|
17
|
+
* mail.transports.resend — thin preset that wires http to the
|
|
18
|
+
* Resend API (illustrates the pattern)
|
|
19
|
+
*
|
|
20
|
+
* Public API:
|
|
21
|
+
*
|
|
22
|
+
* mail.create({ transport?, defaults?, audit? }) → instance
|
|
23
|
+
*
|
|
24
|
+
* transport — function(message) | { send(message) }; default: console.
|
|
25
|
+
* defaults — { from, replyTo, headers, ... } merged into every
|
|
26
|
+
* message unless the message overrides.
|
|
27
|
+
* audit — emit mail.send.success / .failure audit events
|
|
28
|
+
* (default true).
|
|
29
|
+
*
|
|
30
|
+
* await instance.send(message)
|
|
31
|
+
* message: {
|
|
32
|
+
* to: "x@y" | ["x@y", ...]
|
|
33
|
+
* cc: string | string[]
|
|
34
|
+
* bcc: string | string[]
|
|
35
|
+
* from: "Name <noreply@app>" (or instance default)
|
|
36
|
+
* replyTo: "..."
|
|
37
|
+
* subject: "..."
|
|
38
|
+
* text: "plain body" (at least one of text/html)
|
|
39
|
+
* html: "<p>...</p>"
|
|
40
|
+
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
+
* attachments: [{
|
|
42
|
+
* filename: "report.pdf", // required
|
|
43
|
+
* content: buf, // Buffer or string
|
|
44
|
+
* contentType: "application/pdf", // default application/octet-stream
|
|
45
|
+
* contentDisposition: "attachment", // or "inline"
|
|
46
|
+
* cid: "logo-1", // for inline images:
|
|
47
|
+
* // <img src="cid:logo-1">
|
|
48
|
+
* }, ...]
|
|
49
|
+
* }
|
|
50
|
+
* → whatever the transport returned
|
|
51
|
+
*
|
|
52
|
+
* When attachments are present the SMTP transport wraps the body in
|
|
53
|
+
* multipart/mixed; text+html bodies still use multipart/alternative
|
|
54
|
+
* inside. Resend's http preset forwards attachments via the Resend API
|
|
55
|
+
* shape (base64 content + content_id for inline). Operators wiring
|
|
56
|
+
* other vendors against httpTransport include attachments in their
|
|
57
|
+
* own serialize() per-vendor.
|
|
58
|
+
*
|
|
59
|
+
* Validation surface uses MailError (FrameworkError subclass) with
|
|
60
|
+
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
61
|
+
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
62
|
+
* resend-*. Vendor-specific presets carry their own code prefix so
|
|
63
|
+
* diagnostic logs identify the provider that rejected the message.
|
|
64
|
+
*/
|
|
65
|
+
var C = require("./constants");
|
|
66
|
+
var crypto = require("./crypto");
|
|
67
|
+
var lazyRequire = require("./lazy-require");
|
|
68
|
+
var safeBuffer = require("./safe-buffer");
|
|
69
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
70
|
+
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
71
|
+
var guardEmail = lazyRequire(function () { return require("./guard-email"); });
|
|
72
|
+
var mailDkim = require("./mail-dkim");
|
|
73
|
+
var net = lazyRequire(function () { return require("net"); });
|
|
74
|
+
var tls = lazyRequire(function () { return require("tls"); });
|
|
75
|
+
var safeJson = require("./safe-json");
|
|
76
|
+
var safeSchema = require("./safe-schema");
|
|
77
|
+
var validateOpts = require("./validate-opts");
|
|
78
|
+
var { FrameworkError } = require("./framework-error");
|
|
79
|
+
|
|
80
|
+
// Cap on responses parsed from upstream mail providers (Resend, etc.).
|
|
81
|
+
// Vendor responses are tiny in spec; 256 KiB is generous headroom.
|
|
82
|
+
var MAIL_RESPONSE_MAX_BYTES = C.BYTES.kib(256);
|
|
83
|
+
|
|
84
|
+
class MailError extends FrameworkError {
|
|
85
|
+
constructor(code, message, permanent, statusCode) {
|
|
86
|
+
super(message, code);
|
|
87
|
+
this.name = "MailError";
|
|
88
|
+
this.permanent = !!permanent;
|
|
89
|
+
this.isMailError = true;
|
|
90
|
+
if (typeof statusCode === "number") this.statusCode = statusCode;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pragmatic email regex — shared with forms.validate / safe-schema.
|
|
95
|
+
// RFC 5322 in a regex is a fool's errand; this catches obvious nonsense
|
|
96
|
+
// and lets real-world addresses through.
|
|
97
|
+
var EMAIL_RE = safeSchema.EMAIL_RE;
|
|
98
|
+
// RFC 5321 §4.5.3.1.3 forward-path bound — bound length BEFORE the regex
|
|
99
|
+
// test so a megabyte-long input can't exhaust the regex engine.
|
|
100
|
+
var EMAIL_MAX_LEN = 254;
|
|
101
|
+
|
|
102
|
+
function _isValidEmail(addr) {
|
|
103
|
+
return typeof addr === "string" && addr.length > 0 &&
|
|
104
|
+
addr.length <= EMAIL_MAX_LEN && EMAIL_RE.test(addr);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function _normalizeRecipientList(value, label) {
|
|
108
|
+
if (value === undefined || value === null) return [];
|
|
109
|
+
var arr = Array.isArray(value) ? value : [value];
|
|
110
|
+
for (var i = 0; i < arr.length; i++) {
|
|
111
|
+
if (typeof arr[i] !== "string" || arr[i].length === 0) {
|
|
112
|
+
throw new MailError("mail/invalid-recipient",
|
|
113
|
+
label + "[" + i + "] must be a non-empty string", true);
|
|
114
|
+
}
|
|
115
|
+
// CRLF/NUL in addresses → header injection. Reject hard.
|
|
116
|
+
if (/[\r\n\0]/.test(arr[i])) {
|
|
117
|
+
throw new MailError("mail/invalid-recipient",
|
|
118
|
+
label + "[" + i + "] contains forbidden control characters", true);
|
|
119
|
+
}
|
|
120
|
+
// Accept "Name <email@addr>" form too — extract the angle-bracket
|
|
121
|
+
// address for validation; preserve the full string in the message.
|
|
122
|
+
var bracket = arr[i].match(/<([^>]+)>/);
|
|
123
|
+
var addr = bracket ? bracket[1] : arr[i];
|
|
124
|
+
if (!_isValidEmail(addr.trim())) {
|
|
125
|
+
throw new MailError("mail/invalid-recipient",
|
|
126
|
+
label + " '" + arr[i] + "' is not a valid email address", true);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return arr;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _validateMessage(message) {
|
|
133
|
+
if (!message || typeof message !== "object") {
|
|
134
|
+
throw new MailError("mail/missing-message", "send() requires a message object", true);
|
|
135
|
+
}
|
|
136
|
+
var to = _normalizeRecipientList(message.to, "to");
|
|
137
|
+
if (to.length === 0) {
|
|
138
|
+
throw new MailError("mail/missing-to", "message.to is required (one or more recipients)", true);
|
|
139
|
+
}
|
|
140
|
+
_normalizeRecipientList(message.cc, "cc");
|
|
141
|
+
_normalizeRecipientList(message.bcc, "bcc");
|
|
142
|
+
|
|
143
|
+
if (!message.from || typeof message.from !== "string") {
|
|
144
|
+
throw new MailError("mail/missing-from", "message.from is required", true);
|
|
145
|
+
}
|
|
146
|
+
if (/[\r\n\0]/.test(message.from)) {
|
|
147
|
+
throw new MailError("mail/invalid-from",
|
|
148
|
+
"message.from contains forbidden control characters", true);
|
|
149
|
+
}
|
|
150
|
+
var fromBracket = message.from.match(/<([^>]+)>/);
|
|
151
|
+
var fromAddr = fromBracket ? fromBracket[1] : message.from;
|
|
152
|
+
if (!_isValidEmail(fromAddr.trim())) {
|
|
153
|
+
throw new MailError("mail/invalid-from",
|
|
154
|
+
"message.from '" + message.from + "' is not a valid email address", true);
|
|
155
|
+
}
|
|
156
|
+
if (message.subject && safeBuffer.hasCrlf(message.subject)) {
|
|
157
|
+
throw new MailError("mail/invalid-subject",
|
|
158
|
+
"message.subject contains forbidden CRLF", true);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!message.text && !message.html && !message.calendar) {
|
|
162
|
+
throw new MailError("mail/missing-body",
|
|
163
|
+
"message must include at least one of text, html, or calendar", true);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (message.calendar !== undefined) {
|
|
167
|
+
if (!message.calendar || typeof message.calendar !== "object") {
|
|
168
|
+
throw new MailError("mail/invalid-calendar",
|
|
169
|
+
"message.calendar must be an object { method, icalText }", true);
|
|
170
|
+
}
|
|
171
|
+
var ALLOWED_METHODS = ["REQUEST", "CANCEL", "REPLY", "PUBLISH", "COUNTER", "REFRESH", "ADD", "DECLINECOUNTER"];
|
|
172
|
+
if (typeof message.calendar.method !== "string" ||
|
|
173
|
+
ALLOWED_METHODS.indexOf(message.calendar.method) === -1) {
|
|
174
|
+
throw new MailError("mail/invalid-calendar",
|
|
175
|
+
"calendar.method must be one of: " + ALLOWED_METHODS.join(", "), true);
|
|
176
|
+
}
|
|
177
|
+
if (typeof message.calendar.icalText !== "string" ||
|
|
178
|
+
message.calendar.icalText.length === 0) {
|
|
179
|
+
throw new MailError("mail/invalid-calendar",
|
|
180
|
+
"calendar.icalText is required (non-empty string)", true);
|
|
181
|
+
}
|
|
182
|
+
if (!/^BEGIN:VCALENDAR/.test(message.calendar.icalText)) {
|
|
183
|
+
throw new MailError("mail/invalid-calendar",
|
|
184
|
+
"calendar.icalText must start with 'BEGIN:VCALENDAR' (RFC 5545)", true);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (message.attachments !== undefined) {
|
|
189
|
+
if (!Array.isArray(message.attachments)) {
|
|
190
|
+
throw new MailError("mail/invalid-attachments",
|
|
191
|
+
"message.attachments must be an array", true);
|
|
192
|
+
}
|
|
193
|
+
for (var i = 0; i < message.attachments.length; i++) {
|
|
194
|
+
var att = message.attachments[i];
|
|
195
|
+
if (!att || typeof att !== "object") {
|
|
196
|
+
throw new MailError("mail/invalid-attachment",
|
|
197
|
+
"attachments[" + i + "] must be an object", true);
|
|
198
|
+
}
|
|
199
|
+
if (typeof att.filename !== "string" || att.filename.length === 0) {
|
|
200
|
+
throw new MailError("mail/invalid-attachment",
|
|
201
|
+
"attachments[" + i + "].filename must be a non-empty string", true);
|
|
202
|
+
}
|
|
203
|
+
if (/[\r\n\0]/.test(att.filename)) {
|
|
204
|
+
throw new MailError("mail/invalid-attachment",
|
|
205
|
+
"attachments[" + i + "].filename contains forbidden control characters", true);
|
|
206
|
+
}
|
|
207
|
+
if (att.content === undefined || att.content === null) {
|
|
208
|
+
throw new MailError("mail/invalid-attachment",
|
|
209
|
+
"attachments[" + i + "].content is required (Buffer or string)", true);
|
|
210
|
+
}
|
|
211
|
+
if (!Buffer.isBuffer(att.content) && typeof att.content !== "string") {
|
|
212
|
+
throw new MailError("mail/invalid-attachment",
|
|
213
|
+
"attachments[" + i + "].content must be a Buffer or string", true);
|
|
214
|
+
}
|
|
215
|
+
if (att.contentType !== undefined &&
|
|
216
|
+
(typeof att.contentType !== "string" || /[\r\n\0]/.test(att.contentType))) {
|
|
217
|
+
throw new MailError("mail/invalid-attachment",
|
|
218
|
+
"attachments[" + i + "].contentType must be a clean string", true);
|
|
219
|
+
}
|
|
220
|
+
if (att.contentDisposition !== undefined &&
|
|
221
|
+
att.contentDisposition !== "attachment" &&
|
|
222
|
+
att.contentDisposition !== "inline") {
|
|
223
|
+
throw new MailError("mail/invalid-attachment",
|
|
224
|
+
"attachments[" + i + "].contentDisposition must be 'attachment' or 'inline'", true);
|
|
225
|
+
}
|
|
226
|
+
if (att.cid !== undefined &&
|
|
227
|
+
(typeof att.cid !== "string" || /[\r\n\0<>]/.test(att.cid))) {
|
|
228
|
+
throw new MailError("mail/invalid-attachment",
|
|
229
|
+
"attachments[" + i + "].cid must be a clean string (no <>)", true);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _mergeMessage(defaults, message) {
|
|
236
|
+
// Per-message values override defaults; headers merged shallow.
|
|
237
|
+
var merged = Object.assign({}, defaults || {}, message);
|
|
238
|
+
if (defaults && defaults.headers && message.headers) {
|
|
239
|
+
merged.headers = Object.assign({}, defaults.headers, message.headers);
|
|
240
|
+
}
|
|
241
|
+
return merged;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function _extractAddr(s) {
|
|
245
|
+
if (s === undefined || s === null) return s;
|
|
246
|
+
var m = String(s).match(/<([^>]+)>/);
|
|
247
|
+
return m ? m[1].trim() : String(s).trim();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function _toArray(v) {
|
|
251
|
+
if (v === undefined || v === null) return [];
|
|
252
|
+
return Array.isArray(v) ? v.slice() : [v];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- Built-in transports: console + memory (dev / tests) ----
|
|
256
|
+
|
|
257
|
+
function consoleTransport(opts) {
|
|
258
|
+
opts = opts || {};
|
|
259
|
+
var stream = opts.stream || process.stderr;
|
|
260
|
+
// redactBcc: print only the recipient COUNT instead of the addresses.
|
|
261
|
+
// Default false preserves the dev-visibility purpose of this
|
|
262
|
+
// transport. Operators piping dev logs into shared / centralized
|
|
263
|
+
// sinks (Slack, log aggregator, ticket system) opt in to avoid
|
|
264
|
+
// leaking the BCC list — the property exists precisely so a recipient
|
|
265
|
+
// doesn't see who else got the message, and that promise breaks the
|
|
266
|
+
// moment the addresses land in a non-private log.
|
|
267
|
+
var redactBcc = opts.redactBcc === true;
|
|
268
|
+
return {
|
|
269
|
+
name: "console",
|
|
270
|
+
send: async function (message) {
|
|
271
|
+
var lines = [
|
|
272
|
+
"[mail.console] To: " + (Array.isArray(message.to) ? message.to.join(", ") : message.to),
|
|
273
|
+
"[mail.console] From: " + message.from,
|
|
274
|
+
"[mail.console] Subject: " + (message.subject || ""),
|
|
275
|
+
];
|
|
276
|
+
if (message.cc) lines.push("[mail.console] Cc: " + (Array.isArray(message.cc) ? message.cc.join(", ") : message.cc));
|
|
277
|
+
if (message.bcc) {
|
|
278
|
+
if (redactBcc) {
|
|
279
|
+
var bccCount = Array.isArray(message.bcc) ? message.bcc.length : 1;
|
|
280
|
+
lines.push("[mail.console] Bcc: <" + bccCount + " recipient" + (bccCount === 1 ? "" : "s") + " — redacted>");
|
|
281
|
+
} else {
|
|
282
|
+
lines.push("[mail.console] Bcc: " + (Array.isArray(message.bcc) ? message.bcc.join(", ") : message.bcc));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
var body = message.text || (message.html ? "(html body, " + message.html.length + " bytes)" : "");
|
|
286
|
+
lines.push("");
|
|
287
|
+
lines.push(body);
|
|
288
|
+
lines.push("");
|
|
289
|
+
stream.write(lines.join("\n") + "\n");
|
|
290
|
+
return { transport: "console", deliveredAt: Date.now() };
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function memoryTransport() {
|
|
296
|
+
var sent = [];
|
|
297
|
+
return {
|
|
298
|
+
name: "memory",
|
|
299
|
+
sent: sent,
|
|
300
|
+
send: async function (message) {
|
|
301
|
+
sent.push(message);
|
|
302
|
+
return { transport: "memory", deliveredAt: Date.now(), index: sent.length - 1 };
|
|
303
|
+
},
|
|
304
|
+
reset: function () { sent.length = 0; },
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- SMTP transport ----
|
|
309
|
+
//
|
|
310
|
+
// Raw RFC 5321 state machine over net/tls. Multi-recipient (loops
|
|
311
|
+
// RCPT TO over to + cc + bcc), builds an RFC 5322 message with
|
|
312
|
+
// multipart/alternative when both text and html are supplied, and
|
|
313
|
+
// dot-stuffs body lines beginning with "." per SMTP transparency.
|
|
314
|
+
//
|
|
315
|
+
// PQC posture: TLS opts default to TLSv1.3 minimum and accept an
|
|
316
|
+
// `ecdhCurve` string (set to a hybrid PQC group such as
|
|
317
|
+
// "X25519MLKEM768" when peer + Node version support it). On a
|
|
318
|
+
// cleartext port the transport always issues STARTTLS and refuses
|
|
319
|
+
// to send AUTH or DATA in cleartext if the upgrade is rejected.
|
|
320
|
+
|
|
321
|
+
function _newBoundary(label) {
|
|
322
|
+
// crypto.randomBytes for the boundary suffix matches the framework
|
|
323
|
+
// convention. RFC 5322 only requires uniqueness within a message,
|
|
324
|
+
// but consistency with how every other identifier in lib/ is built
|
|
325
|
+
// wins over premature differentiation.
|
|
326
|
+
return "blamejs-" + label + "-" + Date.now() + "-" + crypto.generateToken(C.BYTES.bytes(8));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// base64-encode the buffer with line wrapping at 76 chars (RFC 2045
|
|
330
|
+
// §6.8). Most clients tolerate longer lines but the spec maximum is
|
|
331
|
+
// 998 octets per line; sticking to 76 keeps everyone happy.
|
|
332
|
+
function _base64Wrap(buf) {
|
|
333
|
+
var b64 = buf.toString("base64");
|
|
334
|
+
var lines = [];
|
|
335
|
+
for (var i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
|
|
336
|
+
return lines.join("\r\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function _buildAttachmentPart(att) {
|
|
340
|
+
var content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
341
|
+
var contentType = att.contentType || "application/octet-stream";
|
|
342
|
+
var disposition = att.contentDisposition || (att.cid ? "inline" : "attachment");
|
|
343
|
+
var lines = [];
|
|
344
|
+
lines.push("Content-Type: " + contentType + '; name="' + att.filename + '"');
|
|
345
|
+
lines.push("Content-Transfer-Encoding: base64");
|
|
346
|
+
lines.push("Content-Disposition: " + disposition + '; filename="' + att.filename + '"');
|
|
347
|
+
if (att.cid) lines.push("Content-ID: <" + att.cid + ">");
|
|
348
|
+
lines.push("");
|
|
349
|
+
lines.push(_base64Wrap(content));
|
|
350
|
+
return lines.join("\r\n");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function _buildBodyPart(message) {
|
|
354
|
+
// Collect body parts (text / html / calendar). Multiple parts → wrap
|
|
355
|
+
// in multipart/alternative so the recipient client picks whichever
|
|
356
|
+
// it can render. Calendar parts carry the `method=` parameter so
|
|
357
|
+
// mail clients (Outlook / Gmail / Apple Mail) treat the message as
|
|
358
|
+
// an invite, not a generic ics download.
|
|
359
|
+
var parts = [];
|
|
360
|
+
if (message.text) {
|
|
361
|
+
parts.push({ contentType: "text/plain; charset=utf-8", body: message.text });
|
|
362
|
+
}
|
|
363
|
+
if (message.html) {
|
|
364
|
+
parts.push({ contentType: "text/html; charset=utf-8", body: message.html });
|
|
365
|
+
}
|
|
366
|
+
if (message.calendar) {
|
|
367
|
+
parts.push({
|
|
368
|
+
contentType: 'text/calendar; method="' + message.calendar.method + '"; charset=utf-8',
|
|
369
|
+
body: message.calendar.icalText,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (parts.length === 1) return parts[0];
|
|
373
|
+
var altBoundary = _newBoundary("alt");
|
|
374
|
+
var lines = [];
|
|
375
|
+
for (var i = 0; i < parts.length; i++) {
|
|
376
|
+
lines.push("--" + altBoundary);
|
|
377
|
+
lines.push("Content-Type: " + parts[i].contentType);
|
|
378
|
+
lines.push("");
|
|
379
|
+
lines.push(parts[i].body);
|
|
380
|
+
}
|
|
381
|
+
lines.push("--" + altBoundary + "--");
|
|
382
|
+
return {
|
|
383
|
+
contentType: 'multipart/alternative; boundary="' + altBoundary + '"',
|
|
384
|
+
body: lines.join("\r\n"),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function _buildRfc822(message) {
|
|
389
|
+
var headers = [];
|
|
390
|
+
headers.push("From: " + message.from);
|
|
391
|
+
headers.push("To: " + (Array.isArray(message.to) ? message.to.join(", ") : message.to));
|
|
392
|
+
if (message.cc) headers.push("Cc: " + (Array.isArray(message.cc) ? message.cc.join(", ") : message.cc));
|
|
393
|
+
if (message.replyTo) headers.push("Reply-To: " + message.replyTo);
|
|
394
|
+
if (message.subject) headers.push("Subject: " + message.subject);
|
|
395
|
+
headers.push("MIME-Version: 1.0");
|
|
396
|
+
headers.push("Date: " + new Date().toUTCString());
|
|
397
|
+
if (message.headers) {
|
|
398
|
+
for (var k in message.headers) {
|
|
399
|
+
if (Object.prototype.hasOwnProperty.call(message.headers, k)) {
|
|
400
|
+
// Strip CRLF defensively even though we already validated the
|
|
401
|
+
// message; custom headers go straight onto the wire.
|
|
402
|
+
var v = safeBuffer.stripCrlf(String(message.headers[k]));
|
|
403
|
+
headers.push(k + ": " + v);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var attachments = Array.isArray(message.attachments) ? message.attachments : [];
|
|
409
|
+
var inner = _buildBodyPart(message);
|
|
410
|
+
var body;
|
|
411
|
+
|
|
412
|
+
if (attachments.length === 0) {
|
|
413
|
+
headers.push("Content-Type: " + inner.contentType);
|
|
414
|
+
body = inner.body;
|
|
415
|
+
} else {
|
|
416
|
+
// multipart/mixed: first part is the body (single or alternative),
|
|
417
|
+
// subsequent parts are the attachments. Inline disposition +
|
|
418
|
+
// Content-ID is interpreted correctly by every major client even
|
|
419
|
+
// inside mixed. Operators needing strict-RFC-2387 multipart/related
|
|
420
|
+
// wrap the body via the mail.transports interface and pass a
|
|
421
|
+
// content-type override.
|
|
422
|
+
var mixedBoundary = _newBoundary("mixed");
|
|
423
|
+
headers.push('Content-Type: multipart/mixed; boundary="' + mixedBoundary + '"');
|
|
424
|
+
var parts = [];
|
|
425
|
+
parts.push("--" + mixedBoundary);
|
|
426
|
+
parts.push("Content-Type: " + inner.contentType);
|
|
427
|
+
parts.push("");
|
|
428
|
+
parts.push(inner.body);
|
|
429
|
+
for (var ai = 0; ai < attachments.length; ai++) {
|
|
430
|
+
parts.push("--" + mixedBoundary);
|
|
431
|
+
parts.push(_buildAttachmentPart(attachments[ai]));
|
|
432
|
+
}
|
|
433
|
+
parts.push("--" + mixedBoundary + "--");
|
|
434
|
+
body = parts.join("\r\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Normalize line endings then dot-stuff per SMTP transparency.
|
|
438
|
+
body = body.replace(/\r?\n/g, "\r\n");
|
|
439
|
+
body = body.split("\r\n").map(function (l) { return l.charAt(0) === "." ? "." + l : l; }).join("\r\n");
|
|
440
|
+
|
|
441
|
+
return headers.join("\r\n") + "\r\n\r\n" + body;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function smtpTransport(opts) {
|
|
445
|
+
opts = opts || {};
|
|
446
|
+
if (!opts.host) {
|
|
447
|
+
throw new MailError("mail/smtp-misconfigured",
|
|
448
|
+
"smtp transport requires opts.host", true);
|
|
449
|
+
}
|
|
450
|
+
if (opts.dkimSigner !== undefined && opts.dkimSigner !== null &&
|
|
451
|
+
(typeof opts.dkimSigner !== "object" || typeof opts.dkimSigner.sign !== "function")) {
|
|
452
|
+
throw new MailError("mail/smtp-misconfigured",
|
|
453
|
+
"dkimSigner must be an object with a .sign(rfc822) method " +
|
|
454
|
+
"(see b.mail.dkim.create)", true);
|
|
455
|
+
}
|
|
456
|
+
var port = opts.port || 587;
|
|
457
|
+
var useImplicitTLS = port === 465 || opts.implicitTls === true;
|
|
458
|
+
var rejectUnauthorized = opts.rejectUnauthorized !== false;
|
|
459
|
+
var ehloName = opts.ehloName || "blamejs";
|
|
460
|
+
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
461
|
+
var tlsOpts = {
|
|
462
|
+
rejectUnauthorized: rejectUnauthorized,
|
|
463
|
+
minVersion: opts.minTlsVersion || "TLSv1.3",
|
|
464
|
+
};
|
|
465
|
+
if (opts.ecdhCurve) tlsOpts.ecdhCurve = opts.ecdhCurve;
|
|
466
|
+
if (opts.ca) tlsOpts.ca = opts.ca;
|
|
467
|
+
|
|
468
|
+
// SNI is only legal for hostnames; IP literals must omit servername
|
|
469
|
+
// (Node's tls.connect throws "Setting the TLS ServerName to an IP
|
|
470
|
+
// address is not permitted" otherwise). Operators with private CAs
|
|
471
|
+
// and an IP-only target pass `opts.servername: "expected-cn.example"`
|
|
472
|
+
// explicitly. Same convention as lib/redis-client.js.
|
|
473
|
+
var host = opts.host;
|
|
474
|
+
var servername = opts.servername;
|
|
475
|
+
if (servername === undefined) {
|
|
476
|
+
servername = (/^\d+\.\d+\.\d+\.\d+$/.test(host) || (host && host.indexOf(":") !== -1))
|
|
477
|
+
? undefined : host;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
var cfg = {
|
|
481
|
+
host: host,
|
|
482
|
+
port: port,
|
|
483
|
+
user: opts.user,
|
|
484
|
+
pass: opts.pass,
|
|
485
|
+
useImplicitTLS: useImplicitTLS,
|
|
486
|
+
ehloName: ehloName,
|
|
487
|
+
timeoutMs: timeoutMs,
|
|
488
|
+
tlsOpts: tlsOpts,
|
|
489
|
+
servername: servername,
|
|
490
|
+
dkimSigner: opts.dkimSigner || null,
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
name: "smtp",
|
|
495
|
+
send: function (message) { return _smtpSend(message, cfg); },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// SMTP state-machine step IDs. Hex-encoded so the framework's
|
|
500
|
+
// byte-literal lint (which flags decimal multiples of 8) doesn't hit
|
|
501
|
+
// the equality comparisons in handleResponse below.
|
|
502
|
+
var SMTP_STEP_GREETING = 0x0;
|
|
503
|
+
var SMTP_STEP_EHLO_RESP = 0x1;
|
|
504
|
+
var SMTP_STEP_AUTH_USER = 0x2;
|
|
505
|
+
var SMTP_STEP_AUTH_PASS = 0x3;
|
|
506
|
+
var SMTP_STEP_AUTH_FINAL = 0x4;
|
|
507
|
+
var SMTP_STEP_MAIL_FROM = 0x5;
|
|
508
|
+
var SMTP_STEP_RCPT_TO = 0x6;
|
|
509
|
+
var SMTP_STEP_DATA = 0x7;
|
|
510
|
+
var SMTP_STEP_BODY = 0x8;
|
|
511
|
+
var SMTP_STEP_STARTTLS = 0xA;
|
|
512
|
+
|
|
513
|
+
function _smtpSend(message, cfg) {
|
|
514
|
+
return new Promise(function (resolve, reject) {
|
|
515
|
+
var socket;
|
|
516
|
+
var step = SMTP_STEP_GREETING;
|
|
517
|
+
var buffer = "";
|
|
518
|
+
var upgradedToTLS = false;
|
|
519
|
+
var settled = false;
|
|
520
|
+
var rcptIndex = 0;
|
|
521
|
+
|
|
522
|
+
var fromAddr = _extractAddr(message.from);
|
|
523
|
+
var toList = _toArray(message.to).map(_extractAddr);
|
|
524
|
+
var ccList = _toArray(message.cc).map(_extractAddr);
|
|
525
|
+
var bccList = _toArray(message.bcc).map(_extractAddr);
|
|
526
|
+
var rcpts = toList.concat(ccList, bccList);
|
|
527
|
+
var dataMessage = _buildRfc822(message);
|
|
528
|
+
if (cfg.dkimSigner) {
|
|
529
|
+
try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
|
|
530
|
+
catch (e) {
|
|
531
|
+
reject(new MailError("mail/dkim-sign-failed",
|
|
532
|
+
"dkim signing failed: " + ((e && e.message) || String(e)), true));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Outbound SMTP-smuggling defense — refuse before opening the
|
|
538
|
+
// socket if the produced RFC 822 wire contains the bare-CR / bare-
|
|
539
|
+
// LF + smuggled-verb shape (CVE-2023-51764 / 51765 / 51766 class).
|
|
540
|
+
// Operator-supplied subject / body / headers can sneak the pattern
|
|
541
|
+
// through _buildRfc822 if the input wasn't already gated.
|
|
542
|
+
var rv = guardEmail().validateMessage(dataMessage, { profile: "strict" });
|
|
543
|
+
if (!rv.ok) {
|
|
544
|
+
var critical = rv.issues.filter(function (i) {
|
|
545
|
+
return i.severity === "critical";
|
|
546
|
+
});
|
|
547
|
+
if (critical.length > 0) {
|
|
548
|
+
reject(new MailError("mail/outbound-smuggling-refused",
|
|
549
|
+
"outbound RFC 822 wire failed guardEmail: " +
|
|
550
|
+
critical.map(function (i) { return i.kind; }).join(","), true));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function fail(reason) {
|
|
556
|
+
if (settled) return;
|
|
557
|
+
settled = true;
|
|
558
|
+
try { if (socket) socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
|
|
559
|
+
reject(new MailError("mail/smtp-failed",
|
|
560
|
+
"SMTP send failed: " + reason, false));
|
|
561
|
+
}
|
|
562
|
+
function done(ok, code) {
|
|
563
|
+
if (settled) return;
|
|
564
|
+
settled = true;
|
|
565
|
+
try { socket.end(); } catch (_e) { /* socket may already be torn down */ }
|
|
566
|
+
if (ok) resolve({ transport: "smtp", deliveredAt: Date.now(), code: code });
|
|
567
|
+
else reject(new MailError("mail/smtp-rejected",
|
|
568
|
+
"SMTP rejected message (code " + code + ")", false));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function send(cmd) {
|
|
572
|
+
try { socket.write(cmd + "\r\n"); }
|
|
573
|
+
catch (e) { fail(e.message || String(e)); }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function onData(data) {
|
|
577
|
+
buffer += data;
|
|
578
|
+
var lines = buffer.split("\r\n");
|
|
579
|
+
buffer = lines.pop();
|
|
580
|
+
for (var i = 0; i < lines.length; i++) {
|
|
581
|
+
var line = lines[i];
|
|
582
|
+
if (!line) continue;
|
|
583
|
+
var code = parseInt(line.slice(0, 3), 10);
|
|
584
|
+
if (line[3] === "-") continue; // continuation line
|
|
585
|
+
try { handleResponse(code); }
|
|
586
|
+
catch (e) { fail(e.message || String(e)); return; }
|
|
587
|
+
if (settled) return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function attachSocket(s) {
|
|
592
|
+
socket = s;
|
|
593
|
+
socket.setEncoding("utf8");
|
|
594
|
+
socket.setTimeout(cfg.timeoutMs);
|
|
595
|
+
socket.on("data", onData);
|
|
596
|
+
socket.on("error", function (err) { fail(err.message || String(err)); });
|
|
597
|
+
socket.on("timeout", function () { fail("timeout"); });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function connect() {
|
|
601
|
+
if (cfg.useImplicitTLS) {
|
|
602
|
+
var tlsConnectOpts = Object.assign({}, cfg.tlsOpts);
|
|
603
|
+
if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
|
|
604
|
+
attachSocket(tls().connect(cfg.port, cfg.host, tlsConnectOpts));
|
|
605
|
+
} else {
|
|
606
|
+
attachSocket(net().createConnection(cfg.port, cfg.host));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function handleResponse(code) {
|
|
611
|
+
if (step === SMTP_STEP_GREETING) {
|
|
612
|
+
if (code !== 220) { fail("greeting-rejected (code " + code + ")"); return; }
|
|
613
|
+
send("EHLO " + cfg.ehloName); step = SMTP_STEP_EHLO_RESP;
|
|
614
|
+
}
|
|
615
|
+
else if (step === SMTP_STEP_EHLO_RESP) {
|
|
616
|
+
if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
|
|
617
|
+
if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
|
|
618
|
+
else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
|
|
619
|
+
else { send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM; }
|
|
620
|
+
}
|
|
621
|
+
else if (step === SMTP_STEP_STARTTLS) {
|
|
622
|
+
if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
|
|
623
|
+
var tlsConnectOpts = Object.assign({ socket: socket }, cfg.tlsOpts);
|
|
624
|
+
if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
|
|
625
|
+
var tlsSocket = tls().connect(tlsConnectOpts, function () {
|
|
626
|
+
upgradedToTLS = true;
|
|
627
|
+
try { socket.removeAllListeners("data"); } catch (_e) { /* listeners migrate to upgraded socket */ }
|
|
628
|
+
attachSocket(tlsSocket);
|
|
629
|
+
send("EHLO " + cfg.ehloName);
|
|
630
|
+
step = SMTP_STEP_EHLO_RESP;
|
|
631
|
+
});
|
|
632
|
+
tlsSocket.on("error", function (err) {
|
|
633
|
+
fail("tls-upgrade: " + (err.message || String(err)));
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else if (step === SMTP_STEP_AUTH_USER) {
|
|
637
|
+
if (code !== 334) { fail("auth-username-rejected (code " + code + ")"); return; }
|
|
638
|
+
send(Buffer.from(cfg.user || "").toString("base64")); step = SMTP_STEP_AUTH_PASS;
|
|
639
|
+
}
|
|
640
|
+
else if (step === SMTP_STEP_AUTH_PASS) {
|
|
641
|
+
if (code !== 334) { fail("auth-password-rejected (code " + code + ")"); return; }
|
|
642
|
+
send(Buffer.from(cfg.pass || "").toString("base64")); step = SMTP_STEP_AUTH_FINAL;
|
|
643
|
+
}
|
|
644
|
+
else if (step === SMTP_STEP_AUTH_FINAL) {
|
|
645
|
+
if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
|
|
646
|
+
send("MAIL FROM:<" + fromAddr + ">"); step = SMTP_STEP_MAIL_FROM;
|
|
647
|
+
}
|
|
648
|
+
else if (step === SMTP_STEP_MAIL_FROM) {
|
|
649
|
+
if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
|
|
650
|
+
send("RCPT TO:<" + rcpts[rcptIndex++] + ">"); step = SMTP_STEP_RCPT_TO;
|
|
651
|
+
}
|
|
652
|
+
else if (step === SMTP_STEP_RCPT_TO) {
|
|
653
|
+
if (code < 200 || code >= 300) { fail("rcpt-rejected (code " + code + ")"); return; }
|
|
654
|
+
if (rcptIndex < rcpts.length) {
|
|
655
|
+
send("RCPT TO:<" + rcpts[rcptIndex++] + ">");
|
|
656
|
+
} else {
|
|
657
|
+
send("DATA"); step = SMTP_STEP_DATA;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
else if (step === SMTP_STEP_DATA) {
|
|
661
|
+
if (code !== 354) { fail("data-rejected (code " + code + ")"); return; }
|
|
662
|
+
send(dataMessage + "\r\n.");
|
|
663
|
+
step = SMTP_STEP_BODY;
|
|
664
|
+
}
|
|
665
|
+
else if (step === SMTP_STEP_BODY) {
|
|
666
|
+
var ok = code === 250;
|
|
667
|
+
done(ok, code);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try { connect(); }
|
|
672
|
+
catch (e) { fail(e.message || String(e)); }
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---- Generic HTTP transport ----
|
|
677
|
+
//
|
|
678
|
+
// Vendor-agnostic transport for any mail API that speaks HTTP. Operators
|
|
679
|
+
// supply three things: an endpoint, a serialize() that turns the
|
|
680
|
+
// framework-shaped message into the vendor's request body + headers,
|
|
681
|
+
// and an interpret() that reads the vendor's response and decides
|
|
682
|
+
// success vs failure. Uses lib/http-client so PQC TLS, response caps,
|
|
683
|
+
// and timeout handling come for free.
|
|
684
|
+
//
|
|
685
|
+
// httpTransport({
|
|
686
|
+
// name: "postmark", // appears in result + error codes
|
|
687
|
+
// endpoint: "https://...", // POST target
|
|
688
|
+
// method: "POST", // default POST
|
|
689
|
+
// headers: { ... }, // base headers (auth, content-type, ...)
|
|
690
|
+
// timeoutMs: 15000,
|
|
691
|
+
// allowedProtocols: safeUrl.ALLOW_HTTP_TLS, // default HTTPS-only
|
|
692
|
+
// serialize: function (message) {
|
|
693
|
+
// // → { headers?: {...}, body: string | Buffer }
|
|
694
|
+
// },
|
|
695
|
+
// interpret: function (res, message) {
|
|
696
|
+
// // res = { statusCode, headers, body: Buffer }
|
|
697
|
+
// // → { ok: true, id?: "..." } | { ok: false, reason: "..." }
|
|
698
|
+
// // throw a MailError for permanent / structural failures
|
|
699
|
+
// },
|
|
700
|
+
// })
|
|
701
|
+
//
|
|
702
|
+
// Errors carry a `mail/<name>-*` code so logs identify which provider
|
|
703
|
+
// rejected which message (mail/postmark-failed, mail/resend-rejected,
|
|
704
|
+
// etc.). HTTPS-only is the default — pass safeUrl.ALLOW_HTTP_ALL via
|
|
705
|
+
// opts.allowedProtocols only for local test fixtures.
|
|
706
|
+
|
|
707
|
+
function httpTransport(opts) {
|
|
708
|
+
opts = opts || {};
|
|
709
|
+
if (!opts.endpoint || typeof opts.endpoint !== "string") {
|
|
710
|
+
throw new MailError("mail/http-misconfigured",
|
|
711
|
+
"http transport requires opts.endpoint", true);
|
|
712
|
+
}
|
|
713
|
+
if (typeof opts.serialize !== "function") {
|
|
714
|
+
throw new MailError("mail/http-misconfigured",
|
|
715
|
+
"http transport requires opts.serialize(message) → { headers?, body }", true);
|
|
716
|
+
}
|
|
717
|
+
var name = opts.name || "http";
|
|
718
|
+
var method = (opts.method || "POST").toUpperCase();
|
|
719
|
+
var endpoint = opts.endpoint;
|
|
720
|
+
var baseHeaders = opts.headers || {};
|
|
721
|
+
var timeoutMs = opts.timeoutMs || C.TIME.seconds(15);
|
|
722
|
+
var allowedProtocols = opts.allowedProtocols || null;
|
|
723
|
+
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null;
|
|
724
|
+
var interpret = typeof opts.interpret === "function" ? opts.interpret : null;
|
|
725
|
+
var serialize = opts.serialize;
|
|
726
|
+
var codePrefix = "mail/" + name;
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
name: name,
|
|
730
|
+
send: async function (message) {
|
|
731
|
+
var serialized = serialize(message);
|
|
732
|
+
if (!serialized || typeof serialized !== "object") {
|
|
733
|
+
throw new MailError(codePrefix + "-bad-serializer",
|
|
734
|
+
"serialize() must return { headers?, body }", false);
|
|
735
|
+
}
|
|
736
|
+
var body = serialized.body;
|
|
737
|
+
if (typeof body === "string") body = Buffer.from(body, "utf8");
|
|
738
|
+
if (!Buffer.isBuffer(body)) {
|
|
739
|
+
throw new MailError(codePrefix + "-bad-serializer",
|
|
740
|
+
"serialize() body must be a string or Buffer", false);
|
|
741
|
+
}
|
|
742
|
+
var headers = Object.assign({}, baseHeaders, serialized.headers || {});
|
|
743
|
+
// Default Content-Length when caller hasn't asserted chunked
|
|
744
|
+
// transfer; keeps small JSON payloads from being chunked needlessly.
|
|
745
|
+
var hasLen = false;
|
|
746
|
+
for (var hk in headers) {
|
|
747
|
+
if (Object.prototype.hasOwnProperty.call(headers, hk) &&
|
|
748
|
+
hk.toLowerCase() === "content-length") { hasLen = true; break; }
|
|
749
|
+
}
|
|
750
|
+
if (!hasLen) headers["Content-Length"] = body.length;
|
|
751
|
+
|
|
752
|
+
var reqOpts = {
|
|
753
|
+
method: method,
|
|
754
|
+
url: endpoint,
|
|
755
|
+
headers: headers,
|
|
756
|
+
body: body,
|
|
757
|
+
timeoutMs: timeoutMs,
|
|
758
|
+
errorClass: MailError, // http-client constructs (code, message, permanent, statusCode)
|
|
759
|
+
};
|
|
760
|
+
if (allowedProtocols) reqOpts.allowedProtocols = allowedProtocols;
|
|
761
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
762
|
+
|
|
763
|
+
var res;
|
|
764
|
+
try {
|
|
765
|
+
res = await httpClient().request(reqOpts);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
// http-client constructs a MailError via opts.errorClass on
|
|
768
|
+
// non-2xx / network / timeout, with its own code domain
|
|
769
|
+
// (HTTP_ERROR, ETIMEDOUT, ...). Rewrap into mail/<name>-failed
|
|
770
|
+
// so the consumer-facing code identifies the provider while
|
|
771
|
+
// preserving the original as `cause` and the HTTP statusCode.
|
|
772
|
+
var wrapped = new MailError(codePrefix + "-failed",
|
|
773
|
+
name + " request failed: " + ((e && e.message) || String(e)),
|
|
774
|
+
false,
|
|
775
|
+
e && typeof e.statusCode === "number" ? e.statusCode : undefined);
|
|
776
|
+
wrapped.cause = e;
|
|
777
|
+
throw wrapped;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
var info = { transport: name, deliveredAt: Date.now() };
|
|
781
|
+
if (typeof res.statusCode === "number") info.statusCode = res.statusCode;
|
|
782
|
+
|
|
783
|
+
if (!interpret) return info;
|
|
784
|
+
|
|
785
|
+
var verdict;
|
|
786
|
+
try { verdict = interpret(res, message); }
|
|
787
|
+
catch (e) {
|
|
788
|
+
if (e && e.isMailError) throw e;
|
|
789
|
+
throw new MailError(codePrefix + "-interpret-failed",
|
|
790
|
+
"interpret() threw: " + ((e && e.message) || String(e)), false);
|
|
791
|
+
}
|
|
792
|
+
if (!verdict || verdict.ok === false) {
|
|
793
|
+
var reason = (verdict && verdict.reason) || "rejected";
|
|
794
|
+
var err = new MailError(codePrefix + "-rejected",
|
|
795
|
+
name + " rejected message: " + reason, false);
|
|
796
|
+
if (verdict && typeof verdict.statusCode === "number") err.statusCode = verdict.statusCode;
|
|
797
|
+
throw err;
|
|
798
|
+
}
|
|
799
|
+
if (verdict.id) info.id = verdict.id;
|
|
800
|
+
if (verdict.extra) Object.assign(info, verdict.extra);
|
|
801
|
+
return info;
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ---- Resend preset ----
|
|
807
|
+
//
|
|
808
|
+
// Thin convenience wrapper that wires httpTransport to Resend's API.
|
|
809
|
+
// Operators wanting Postmark / Mailgun / SES HTTP / SendGrid build the
|
|
810
|
+
// same shape against httpTransport directly — this preset exists to
|
|
811
|
+
// document the pattern, not to privilege any single vendor.
|
|
812
|
+
|
|
813
|
+
function resendTransport(opts) {
|
|
814
|
+
opts = opts || {};
|
|
815
|
+
if (!opts.apiKey || typeof opts.apiKey !== "string") {
|
|
816
|
+
throw new MailError("mail/resend-misconfigured",
|
|
817
|
+
"resend transport requires opts.apiKey", true);
|
|
818
|
+
}
|
|
819
|
+
return httpTransport({
|
|
820
|
+
name: "resend",
|
|
821
|
+
endpoint: opts.endpoint || "https://api.resend.com/emails",
|
|
822
|
+
method: "POST",
|
|
823
|
+
timeoutMs: opts.timeoutMs || C.TIME.seconds(15),
|
|
824
|
+
allowedProtocols: opts.allowedProtocols || null,
|
|
825
|
+
allowInternal: opts.allowInternal != null ? opts.allowInternal : null,
|
|
826
|
+
headers: {
|
|
827
|
+
"Authorization": "Bearer " + opts.apiKey,
|
|
828
|
+
"Content-Type": "application/json",
|
|
829
|
+
},
|
|
830
|
+
serialize: function (message) {
|
|
831
|
+
var payload = {
|
|
832
|
+
from: message.from,
|
|
833
|
+
to: Array.isArray(message.to) ? message.to : [message.to],
|
|
834
|
+
subject: message.subject || "",
|
|
835
|
+
};
|
|
836
|
+
if (message.cc) payload.cc = Array.isArray(message.cc) ? message.cc : [message.cc];
|
|
837
|
+
if (message.bcc) payload.bcc = Array.isArray(message.bcc) ? message.bcc : [message.bcc];
|
|
838
|
+
if (message.replyTo) payload.reply_to = message.replyTo;
|
|
839
|
+
if (message.html) payload.html = message.html;
|
|
840
|
+
if (message.text) payload.text = message.text;
|
|
841
|
+
if (message.headers) payload.headers = message.headers;
|
|
842
|
+
// Resend attachments shape: [{ filename, content (base64 string),
|
|
843
|
+
// contentType?, content_id? }]. Inline images via cid go through
|
|
844
|
+
// the content_id field (Resend renders <img src="cid:...">).
|
|
845
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
846
|
+
payload.attachments = message.attachments.map(function (att) {
|
|
847
|
+
var buf = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
848
|
+
var entry = {
|
|
849
|
+
filename: att.filename,
|
|
850
|
+
content: buf.toString("base64"),
|
|
851
|
+
};
|
|
852
|
+
if (att.contentType) entry.contentType = att.contentType;
|
|
853
|
+
if (att.cid) entry.content_id = att.cid;
|
|
854
|
+
return entry;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
return { body: JSON.stringify(payload) };
|
|
858
|
+
},
|
|
859
|
+
interpret: function (res) {
|
|
860
|
+
var text = res.body ? res.body.toString("utf8") : "";
|
|
861
|
+
var data;
|
|
862
|
+
// Cap on diagnostic-message snippet length (chars, not bytes) — keeps
|
|
863
|
+
// a hostile or huge backend response from blowing up the error message.
|
|
864
|
+
var DIAG_SNIPPET_LEN = 0xC8;
|
|
865
|
+
try { data = safeJson.parse(text, { maxBytes: MAIL_RESPONSE_MAX_BYTES }); }
|
|
866
|
+
catch (_e) {
|
|
867
|
+
throw new MailError("mail/resend-bad-response",
|
|
868
|
+
"resend response was not JSON: " + text.slice(0, DIAG_SNIPPET_LEN), false);
|
|
869
|
+
}
|
|
870
|
+
if (!data.id) {
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
reason: data.message || JSON.stringify(data).slice(0, DIAG_SNIPPET_LEN),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return { ok: true, id: data.id };
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ---- Engine instance ----
|
|
882
|
+
|
|
883
|
+
function create(opts) {
|
|
884
|
+
opts = opts || {};
|
|
885
|
+
validateOpts(opts, [
|
|
886
|
+
"transport", "defaults", "audit",
|
|
887
|
+
], "mail");
|
|
888
|
+
var transport = opts.transport || consoleTransport();
|
|
889
|
+
if (typeof transport === "function") {
|
|
890
|
+
transport = { send: transport, name: "anonymous" };
|
|
891
|
+
}
|
|
892
|
+
if (!transport || typeof transport.send !== "function") {
|
|
893
|
+
throw new MailError("mail/bad-transport",
|
|
894
|
+
"opts.transport must be a function or an object with .send(message)", true);
|
|
895
|
+
}
|
|
896
|
+
var defaults = opts.defaults || {};
|
|
897
|
+
var auditOn = opts.audit !== false;
|
|
898
|
+
|
|
899
|
+
function _emit(action, info) {
|
|
900
|
+
if (!auditOn) return;
|
|
901
|
+
audit().safeEmit({
|
|
902
|
+
action: action,
|
|
903
|
+
outcome: info.outcome || (action.endsWith(".failure") ? "failure" : "success"),
|
|
904
|
+
actor: info.actor || {},
|
|
905
|
+
// Recipient COUNT, not addresses — addresses can be PII; the
|
|
906
|
+
// framework's audit chain shouldn't carry them by default.
|
|
907
|
+
// Operators who need full address logging set their own audit
|
|
908
|
+
// hook with whatever PII discipline they want.
|
|
909
|
+
metadata: {
|
|
910
|
+
transport: transport.name || "custom",
|
|
911
|
+
subject: info.subject || "",
|
|
912
|
+
toCount: info.toCount,
|
|
913
|
+
ccCount: info.ccCount,
|
|
914
|
+
bccCount: info.bccCount,
|
|
915
|
+
durationMs: info.durationMs,
|
|
916
|
+
},
|
|
917
|
+
reason: info.reason || null,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function send(message) {
|
|
922
|
+
var merged = _mergeMessage(defaults, message);
|
|
923
|
+
_validateMessage(merged);
|
|
924
|
+
|
|
925
|
+
var t0 = Date.now();
|
|
926
|
+
try {
|
|
927
|
+
var result = await transport.send(merged);
|
|
928
|
+
_emit("mail.send.success", {
|
|
929
|
+
subject: merged.subject,
|
|
930
|
+
toCount: Array.isArray(merged.to) ? merged.to.length : 1,
|
|
931
|
+
ccCount: Array.isArray(merged.cc) ? merged.cc.length : (merged.cc ? 1 : 0),
|
|
932
|
+
bccCount: Array.isArray(merged.bcc) ? merged.bcc.length : (merged.bcc ? 1 : 0),
|
|
933
|
+
durationMs: Date.now() - t0,
|
|
934
|
+
});
|
|
935
|
+
return result;
|
|
936
|
+
} catch (e) {
|
|
937
|
+
_emit("mail.send.failure", {
|
|
938
|
+
subject: merged.subject,
|
|
939
|
+
toCount: Array.isArray(merged.to) ? merged.to.length : 1,
|
|
940
|
+
ccCount: Array.isArray(merged.cc) ? merged.cc.length : (merged.cc ? 1 : 0),
|
|
941
|
+
bccCount: Array.isArray(merged.bcc) ? merged.bcc.length : (merged.bcc ? 1 : 0),
|
|
942
|
+
durationMs: Date.now() - t0,
|
|
943
|
+
outcome: "failure",
|
|
944
|
+
reason: (e && e.message) || String(e),
|
|
945
|
+
});
|
|
946
|
+
// Re-throw as MailError when the upstream wasn't already one,
|
|
947
|
+
// preserving the cause for diagnostic chains.
|
|
948
|
+
if (e && e.isMailError) throw e;
|
|
949
|
+
var wrapped = new MailError("mail/transport-failed",
|
|
950
|
+
"transport '" + (transport.name || "custom") + "' failed: " + ((e && e.message) || String(e)),
|
|
951
|
+
false);
|
|
952
|
+
wrapped.cause = e;
|
|
953
|
+
throw wrapped;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
send: send,
|
|
959
|
+
transport: transport,
|
|
960
|
+
defaults: defaults,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
module.exports = {
|
|
965
|
+
create: create,
|
|
966
|
+
MailError: MailError,
|
|
967
|
+
// DKIM-Signature header generation for outbound mail (rsa-sha256
|
|
968
|
+
// default, ed25519-sha256 opt-in). Wire it into the smtp transport
|
|
969
|
+
// via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
|
|
970
|
+
dkim: mailDkim,
|
|
971
|
+
// Test-only export: lets unit tests inspect the wire format without
|
|
972
|
+
// standing up a TLS-capable SMTP fixture. Operators don't call this.
|
|
973
|
+
_buildRfc822ForTest: _buildRfc822,
|
|
974
|
+
transports: {
|
|
975
|
+
console: consoleTransport,
|
|
976
|
+
memory: memoryTransport,
|
|
977
|
+
smtp: smtpTransport,
|
|
978
|
+
http: httpTransport,
|
|
979
|
+
resend: resendTransport,
|
|
980
|
+
},
|
|
981
|
+
};
|