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