@blamejs/core 0.7.18 → 0.7.19

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.
Files changed (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
package/lib/mail.js CHANGED
@@ -1,981 +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 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
- };
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
+ };