@blamejs/core 0.11.23 → 0.11.25

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.
@@ -0,0 +1,629 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.send.deliver
4
+ * @nav Mail
5
+ * @title Outbound delivery
6
+ * @order 240
7
+ *
8
+ * @intro
9
+ * Turnkey outbound SMTP composer. Wraps the discovery chain
10
+ * (MX-lookup → MTA-STS-fetch + MX-allowlist match → DANE TLSA query
11
+ * → REQUIRETLS handshake hint) around the existing per-host
12
+ * `b.mail.smtpTransport` wire-layer, plus deferred-retry scheduling
13
+ * for transient failures and RFC 3464 DSN generation for permanent
14
+ * ones.
15
+ *
16
+ * Operators no longer have to glue these pieces by hand:
17
+ *
18
+ * var deliver = b.mail.send.deliver.create({
19
+ * hostname: "mta1.example.com",
20
+ * policy: { mtaSts: "enforce", dane: "opportunistic" },
21
+ * dsn: { from: "mailer-daemon@example.com",
22
+ * onPermanentFailure: function (env, hist) { ... } },
23
+ * resolver: b.network.dns.resolver.create({ ... }),
24
+ * });
25
+ *
26
+ * var result = await deliver({
27
+ * from: "ops@example.com",
28
+ * to: ["alice@recipient.com", "bob@other.com"],
29
+ * rfc822: messageBuffer,
30
+ * requireTls: true,
31
+ * });
32
+ * // → { delivered: [{ recipient, mxHost, tlsProtocol, ... }],
33
+ * // deferred: [{ recipient, reason, retryAfterMs }],
34
+ * // failed: [{ recipient, reason, dsnSent }] }
35
+ *
36
+ * Composes:
37
+ * - `b.network.smtp.policy.mtaSts.fetch` + `.matchMx` → RFC 8461 enforcement
38
+ * - `b.network.smtp.policy.dane.tlsa` → RFC 7672 TLSA query
39
+ * - `b.network.dns.resolver` (operator-supplied) → caching + DoH posture
40
+ * - `b.mail.smtpTransport` → SMTP wire layer
41
+ * - `b.mail.requireTls` → RFC 8689 REQUIRETLS
42
+ * - `b.mailBounce`-style RFC 3464 DSN generation → permanent-failure
43
+ * report-mail
44
+ * - `b.audit` → mail.send.deliver.* events
45
+ * - `b.safeAsync.repeating` + operator's queue → retry scheduling
46
+ * (deferred deliveries
47
+ * re-enter via the
48
+ * `retry.scheduleRetry`
49
+ * callback)
50
+ *
51
+ * The deferred-retry surface is operator-side: this primitive
52
+ * classifies a recipient's outcome as "deferred" and emits a
53
+ * `retryAfterMs` budget; the operator's queue / scheduler re-invokes
54
+ * `deliver` for the deferred recipient after that elapses. The
55
+ * primitive does NOT own a background scheduler — that ownership
56
+ * lives with the operator's job-runner so a single deferred-delivery
57
+ * tick can't pin a long-lived process.
58
+ *
59
+ * @card
60
+ * MX → MTA-STS → DANE → SMTP → REQUIRETLS → DSN. The full outbound chain wired once.
61
+ */
62
+
63
+ var nodeDns = require("node:dns").promises;
64
+ var bCrypto = require("./crypto");
65
+ var validateOpts = require("./validate-opts");
66
+ var lazyRequire = require("./lazy-require");
67
+ var { defineClass } = require("./framework-error");
68
+ var C = require("./constants");
69
+
70
+ var smtpPolicy = lazyRequire(function () { return require("./network-smtp-policy"); });
71
+ var mailModule = lazyRequire(function () { return require("./mail"); });
72
+ var audit = lazyRequire(function () { return require("./audit"); });
73
+
74
+ var DeliverError = defineClass("DeliverError");
75
+
76
+ var DEFAULT_PORT_SMTP = 25; // allow:raw-byte-literal — IANA SMTP port, not a byte literal
77
+ var DEFAULT_RETRY_BACKOFF_MS = Object.freeze([
78
+ C.TIME.minutes(1),
79
+ C.TIME.minutes(5),
80
+ C.TIME.minutes(15),
81
+ C.TIME.hours(1),
82
+ C.TIME.hours(4),
83
+ ]);
84
+ var DEFAULT_MX_LOOKUP_TIMEOUT_MS = C.TIME.seconds(10);
85
+ var DEFAULT_PER_HOST_TIMEOUT_MS = C.TIME.seconds(60);
86
+ var MAX_RECIPIENTS_PER_CALL = 1000; // allow:raw-byte-literal — manifest-size cap, not byte count
87
+
88
+ // ---- Outcome classifier ----
89
+
90
+ // Outbound SMTP response codes per RFC 5321 §4.2.1:
91
+ // 2xx = success (delivered to this host)
92
+ // 4xx = transient (defer + retry)
93
+ // 5xx = permanent (fail + DSN)
94
+ //
95
+ // Network-level errors (ECONNREFUSED, ETIMEDOUT, EHOSTUNREACH) are
96
+ // classified as transient and trigger MX-failover before deferring.
97
+ function _classifySmtpOutcome(err, response) {
98
+ if (response && /^2\d\d/.test(String(response.code || ""))) return "delivered";
99
+ if (response && /^5\d\d/.test(String(response.code || ""))) return "permanent";
100
+ if (response && /^4\d\d/.test(String(response.code || ""))) return "transient";
101
+ if (err) {
102
+ var code = err.code || "";
103
+ if (/^(ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND)$/.test(code)) return "transient";
104
+ if (/mta-sts|tls-policy|dane|requiretls/i.test((err.code || "") + " " + (err.message || ""))) return "permanent";
105
+ }
106
+ return "transient";
107
+ }
108
+
109
+ // ---- DSN composer (RFC 3464) ----
110
+
111
+ // Build a multipart/report DSN body for a permanent-failure recipient.
112
+ // The composer follows the operator-facing shape — Final-Recipient,
113
+ // Action: failed, Status (enhanced status code), Diagnostic-Code (the
114
+ // 5xx response or operator-supplied reason) plus the original message
115
+ // headers per RFC 3462. Returns a raw RFC 5322 message ready to hand
116
+ // to whatever transport the operator uses for DSN delivery.
117
+ function _buildDsnMessage(opts) {
118
+ var from = opts.dsnFrom;
119
+ var to = opts.originalFrom;
120
+ var failedRecipient = opts.recipient;
121
+ var reason = opts.reason || "permanent failure";
122
+ var origHeaders = opts.originalHeaders || "";
123
+ var boundary = "dsn-" + bCrypto.generateToken(12);
124
+ var nowIso = new Date().toUTCString();
125
+ var dsnBody =
126
+ "From: Mail Delivery System <" + from + ">\r\n" +
127
+ "To: " + to + "\r\n" +
128
+ "Subject: Delivery Status Notification (Failure)\r\n" +
129
+ "Date: " + nowIso + "\r\n" +
130
+ "MIME-Version: 1.0\r\n" +
131
+ "Content-Type: multipart/report; report-type=delivery-status; boundary=\"" + boundary + "\"\r\n" +
132
+ "Auto-Submitted: auto-replied\r\n" +
133
+ "\r\n" +
134
+ "--" + boundary + "\r\n" +
135
+ "Content-Type: text/plain; charset=utf-8\r\n" +
136
+ "\r\n" +
137
+ "This is the mail delivery system at " + (opts.reportingMta || from) + ".\r\n" +
138
+ "\r\n" +
139
+ "Your message to " + failedRecipient + " could not be delivered:\r\n" +
140
+ "\r\n" +
141
+ " " + reason + "\r\n" +
142
+ "\r\n" +
143
+ "--" + boundary + "\r\n" +
144
+ "Content-Type: message/delivery-status\r\n" +
145
+ "\r\n" +
146
+ "Reporting-MTA: dns; " + (opts.reportingMta || from.split("@")[1] || "") + "\r\n" +
147
+ "Arrival-Date: " + nowIso + "\r\n" +
148
+ "\r\n" +
149
+ "Final-Recipient: rfc822; " + failedRecipient + "\r\n" +
150
+ "Action: failed\r\n" +
151
+ "Status: " + (opts.statusCode || "5.0.0") + "\r\n" +
152
+ "Diagnostic-Code: smtp; " + reason + "\r\n" +
153
+ "\r\n" +
154
+ "--" + boundary + "\r\n" +
155
+ "Content-Type: text/rfc822-headers\r\n" +
156
+ "\r\n" +
157
+ origHeaders +
158
+ "\r\n" +
159
+ "--" + boundary + "--\r\n";
160
+ return dsnBody;
161
+ }
162
+
163
+ // ---- Per-recipient delivery ----
164
+
165
+ // Resolve MX records sorted by priority (lowest first per RFC 5321
166
+ // §5.1). Returns array of `{ exchange, priority }`. Empty array means
167
+ // the domain has no MX (operator's responsibility to fall back to A
168
+ // per RFC 5321 §5.1 if desired; this primitive refuses bare-A by
169
+ // default — operators that need it pass `policy.fallbackToA = true`).
170
+ async function _resolveMx(domain, resolver, timeoutMs) {
171
+ var timer;
172
+ var lookup = resolver
173
+ ? resolver.queryMx(domain)
174
+ : nodeDns.resolveMx(domain);
175
+ var timeout = new Promise(function (_resolve, reject) {
176
+ timer = setTimeout(function () {
177
+ reject(new DeliverError("deliver/mx-timeout",
178
+ "MX lookup for " + domain + " timed out after " + timeoutMs + "ms"));
179
+ }, timeoutMs);
180
+ });
181
+ try {
182
+ var mxs = await Promise.race([lookup, timeout]);
183
+ clearTimeout(timer);
184
+ // Normalize across resolver shapes. `node:dns` resolveMx returns an
185
+ // array of `{ exchange, priority }` directly. `b.network.dns.resolver
186
+ // .create()` wraps DoH and returns `{ rrs: [{ exchange, priority }],
187
+ // ttl, ... }` — the wrapper carries TTL + provenance metadata.
188
+ // Accept both shapes; refuse anything else.
189
+ if (mxs && !Array.isArray(mxs) && Array.isArray(mxs.rrs)) {
190
+ mxs = mxs.rrs;
191
+ }
192
+ if (!Array.isArray(mxs) || mxs.length === 0) {
193
+ throw new DeliverError("deliver/no-mx",
194
+ "no MX records published for " + domain);
195
+ }
196
+ // RFC 7505 — null MX: a single record { priority: 0, exchange: "" }
197
+ // signals the domain explicitly refuses mail; abort with a
198
+ // permanent classification.
199
+ if (mxs.length === 1 && (mxs[0].exchange === "" || mxs[0].exchange === ".")) {
200
+ throw new DeliverError("deliver/null-mx",
201
+ "domain " + domain + " publishes a null MX (RFC 7505) — refuses to accept mail");
202
+ }
203
+ return mxs.slice().sort(function (a, b) { return a.priority - b.priority; });
204
+ } catch (e) {
205
+ clearTimeout(timer);
206
+ throw e;
207
+ }
208
+ }
209
+
210
+ // Apply MTA-STS policy per RFC 8461. Returns the chosen MX host (still
211
+ // valid after STS filtering) or throws on enforce-mode mismatch.
212
+ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
213
+ if (policyMode === "off") return mxs;
214
+ var sts;
215
+ try {
216
+ sts = await smtpPolicy().mtaSts.fetch(domain); // allow:raw-outbound-http — method call on b.network.smtp.policy wrapper, not a raw `fetch(`
217
+ } catch (e) {
218
+ if (policyMode === "enforce") {
219
+ throw new DeliverError("deliver/mta-sts-fetch-failed",
220
+ "MTA-STS fetch for " + domain + " failed under enforce policy: " + e.message);
221
+ }
222
+ auditEmit("mail.send.deliver.mtaSts.skip", "warn",
223
+ { domain: domain, mode: policyMode, reason: e.message });
224
+ return mxs;
225
+ }
226
+ if (!sts || sts.mode === "none") {
227
+ auditEmit("mail.send.deliver.mtaSts.none", "info",
228
+ { domain: domain, mode: policyMode });
229
+ return mxs;
230
+ }
231
+ if (sts.mode === "testing" && policyMode === "enforce") {
232
+ // Testing-mode STS doesn't refuse delivery but does record the
233
+ // mismatch via TLS-RPT. Honor the STS allowlist as an information
234
+ // signal; don't refuse.
235
+ auditEmit("mail.send.deliver.mtaSts.testing", "info",
236
+ { domain: domain, mxPatterns: sts.mx });
237
+ }
238
+ var filtered = mxs.filter(function (m) {
239
+ return smtpPolicy().mtaSts.matchMx(m.exchange, sts.mx || []);
240
+ });
241
+ if (filtered.length === 0 && (sts.mode === "enforce" || policyMode === "enforce")) {
242
+ throw new DeliverError("deliver/mta-sts-mx-mismatch",
243
+ "no MX for " + domain + " matches the published MTA-STS policy (mode=" + sts.mode + ")");
244
+ }
245
+ if (filtered.length === 0) {
246
+ // testing or off mode — log and continue with original list
247
+ auditEmit("mail.send.deliver.mtaSts.no-match", "warn",
248
+ { domain: domain, mode: sts.mode });
249
+ return mxs;
250
+ }
251
+ return filtered;
252
+ }
253
+
254
+ // Apply DANE TLSA query per RFC 7672. Returns array of TLSA records
255
+ // for the MX host, OR null when DANE is off / no records published.
256
+ // The primitive composes the lookup; per-cert chain verification is
257
+ // the operator's responsibility (or future b.network.smtp.policy.dane.
258
+ // verifyChain extension).
259
+ async function _fetchDaneTlsa(mxHost, daneMode, auditEmit) {
260
+ if (daneMode === "off") return null;
261
+ try {
262
+ var tlsa = await smtpPolicy().dane.tlsa(mxHost, DEFAULT_PORT_SMTP);
263
+ return tlsa && tlsa.length > 0 ? tlsa : null;
264
+ } catch (e) {
265
+ auditEmit("mail.send.deliver.dane.skip", "warn",
266
+ { mxHost: mxHost, mode: daneMode, reason: e.message });
267
+ if (daneMode === "enforce") {
268
+ throw new DeliverError("deliver/dane-fetch-failed",
269
+ "DANE TLSA lookup for " + mxHost + " failed under enforce policy: " + e.message);
270
+ }
271
+ return null;
272
+ }
273
+ }
274
+
275
+ // Attempt delivery to a single MX host via the framework's smtpTransport.
276
+ // `transportFactory` is operator-overrideable (composes via opts) so
277
+ // integration tests + future custom transports (e.g. a queue-backed
278
+ // outbound relay) can wrap the wire-layer surface without monkey-
279
+ // patching the framework's mail module.
280
+ async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
281
+ var factory = opts.transportFactory || mailModule().smtpTransport;
282
+ var transport = factory({
283
+ host: mxHost,
284
+ port: DEFAULT_PORT_SMTP,
285
+ ehloName: hostnameLocal,
286
+ timeoutMs: opts.perHostTimeoutMs || DEFAULT_PER_HOST_TIMEOUT_MS,
287
+ requireTls: envelope.requireTls === true,
288
+ // tls / dane verification is handed off to smtpTransport when
289
+ // the operator wires opts.dane (TLSA pinning) via the message
290
+ // shape; v1 of deliver doesn't auto-pin from the TLSA record set
291
+ // because chain-verification needs the cert byte-level surface
292
+ // smtpTransport doesn't expose yet. Operators with strict DANE
293
+ // posture pass dane: tlsa[] into smtpTransport directly.
294
+ });
295
+ return transport.send({
296
+ from: envelope.from,
297
+ to: [envelope.recipient],
298
+ raw: envelope.rfc822,
299
+ });
300
+ }
301
+
302
+ async function _deliverOne(envelope, recipient, ctx) {
303
+ var domain = recipient.split("@")[1];
304
+ if (!domain) {
305
+ return { recipient: recipient, outcome: "permanent",
306
+ reason: "no-domain", reasonCode: "5.1.3" };
307
+ }
308
+ var mxs;
309
+ try {
310
+ mxs = await _resolveMx(domain, ctx.resolver, ctx.mxLookupTimeoutMs);
311
+ } catch (e) {
312
+ var cls = (e.code === "deliver/null-mx" || e.code === "deliver/no-mx") ? "permanent" : "transient";
313
+ return { recipient: recipient, outcome: cls, reason: e.message,
314
+ reasonCode: cls === "permanent" ? "5.1.2" : "4.4.4" };
315
+ }
316
+ try {
317
+ mxs = await _applyMtaStsPolicy(domain, mxs, ctx.policy.mtaSts, ctx.auditEmit);
318
+ } catch (e) {
319
+ return { recipient: recipient, outcome: "permanent",
320
+ reason: e.message, reasonCode: "5.7.10" }; // RFC 8461 §10.3
321
+ }
322
+ var lastErr = null;
323
+ var lastResponse = null;
324
+ for (var i = 0; i < mxs.length; i += 1) {
325
+ var mx = mxs[i];
326
+ // DANE per-MX lookup. Skipped today for verification (operator
327
+ // composes directly into smtpTransport.dane); this branch carries
328
+ // the discovery so the audit chain records the policy posture
329
+ // applied to each delivery attempt.
330
+ await _fetchDaneTlsa(mx.exchange, ctx.policy.dane, ctx.auditEmit);
331
+ try {
332
+ var rv = await _tryHost({
333
+ from: envelope.from,
334
+ recipient: recipient,
335
+ rfc822: envelope.rfc822,
336
+ requireTls: envelope.requireTls,
337
+ }, mx.exchange, ctx.hostname, ctx);
338
+ ctx.auditEmit("mail.send.deliver.delivered", "success", {
339
+ recipient: recipient, mxHost: mx.exchange, mxPriority: mx.priority,
340
+ });
341
+ return { recipient: recipient, outcome: "delivered", mxHost: mx.exchange,
342
+ mxPriority: mx.priority, transportResponse: rv };
343
+ } catch (e) {
344
+ lastErr = e;
345
+ lastResponse = e && e.smtpResponse;
346
+ var smtpCls = _classifySmtpOutcome(e, lastResponse);
347
+ if (smtpCls === "permanent") {
348
+ ctx.auditEmit("mail.send.deliver.permanent-fail", "failure", {
349
+ recipient: recipient, mxHost: mx.exchange, code: lastResponse && lastResponse.code, reason: e.message,
350
+ });
351
+ return { recipient: recipient, outcome: "permanent",
352
+ reason: e.message, reasonCode: (lastResponse && lastResponse.code) || "5.0.0",
353
+ mxHost: mx.exchange };
354
+ }
355
+ // Transient — try next MX (if any). Audit the per-host failure
356
+ // so operators see the MX-failover chain.
357
+ ctx.auditEmit("mail.send.deliver.host-failover", "info", {
358
+ recipient: recipient, mxHost: mx.exchange, code: lastResponse && lastResponse.code, reason: e.message,
359
+ });
360
+ }
361
+ }
362
+ // All MX hosts returned transient — overall outcome is transient
363
+ // (defer + retry).
364
+ return { recipient: recipient, outcome: "transient",
365
+ reason: (lastErr && lastErr.message) || "all MX hosts failed transiently",
366
+ reasonCode: (lastResponse && lastResponse.code) || "4.4.4" };
367
+ }
368
+
369
+ // ---- Public factory ----
370
+
371
+ /**
372
+ * @primitive b.mail.send.deliver.create
373
+ * @signature b.mail.send.deliver.create(opts)
374
+ * @since 0.11.24
375
+ * @status stable
376
+ *
377
+ * Build a turnkey delivery handle. Returns a `deliver(envelope)`
378
+ * function that takes a single multi-recipient envelope, resolves
379
+ * MX records per recipient domain, applies the operator's configured
380
+ * MTA-STS / DANE policy, attempts delivery via `b.mail.smtpTransport`,
381
+ * and returns a per-recipient outcome split into `delivered` /
382
+ * `deferred` / `failed` arrays.
383
+ *
384
+ * Deferred recipients carry `retryAfterMs` budgets the operator's
385
+ * queue / scheduler honors by re-invoking `deliver` for that subset
386
+ * after the budget elapses. The primitive does not own a background
387
+ * scheduler — operator job-runner owns the retry lifecycle.
388
+ *
389
+ * Failed recipients trigger DSN composition: a RFC 3464 multipart/
390
+ * report message is built per failed recipient and handed to the
391
+ * operator-supplied `dsn.onPermanentFailure(envelope, recipientResult,
392
+ * dsnMessage)` callback. The callback is responsible for delivering
393
+ * the DSN itself (typically by re-entering the same `deliver` handle
394
+ * with the original sender as recipient — but operators who want
395
+ * a separate transport for DSNs wire that here).
396
+ *
397
+ * @opts
398
+ * hostname: string, // required — local hostname for HELO/EHLO + DSN Reporting-MTA
399
+ * resolver: object | null, // optional — b.network.dns.resolver handle; falls back to node:dns when omitted
400
+ * policy: {
401
+ * mtaSts: "enforce" | "testing" | "off", // default "enforce" — RFC 8461 posture
402
+ * dane: "opportunistic" | "enforce" | "off", // default "opportunistic" — RFC 7672
403
+ * },
404
+ * retry: {
405
+ * maxAttempts: number, // default 5
406
+ * backoffMs: Array<number>, // default [1m, 5m, 15m, 1h, 4h]
407
+ * },
408
+ * dsn: {
409
+ * from: string, // required when dsn.onPermanentFailure is set
410
+ * onPermanentFailure: function (envelope, result, dsnMessage) → Promise,
411
+ * },
412
+ * timeouts: {
413
+ * mxLookupMs: number, // default 10s
414
+ * perHostMs: number, // default 60s
415
+ * },
416
+ * audit: boolean, // default true
417
+ *
418
+ * @example
419
+ * var deliver = b.mail.send.deliver.create({
420
+ * hostname: "mta1.example.com",
421
+ * policy: { mtaSts: "enforce", dane: "opportunistic" },
422
+ * dsn: { from: "mailer-daemon@example.com",
423
+ * onPermanentFailure: function (env, res, dsn) {
424
+ * return deliver({ from: env.from, to: [env.from], rfc822: Buffer.from(dsn) });
425
+ * } },
426
+ * });
427
+ * var result = await deliver({
428
+ * from: "ops@example.com",
429
+ * to: ["alice@recipient.com"],
430
+ * rfc822: messageBuffer,
431
+ * });
432
+ * typeof result.delivered; // → "object" (array)
433
+ * typeof result.deferred; // → "object" (array)
434
+ * typeof result.failed; // → "object" (array)
435
+ */
436
+ function create(opts) {
437
+ if (!opts || typeof opts !== "object") {
438
+ throw new DeliverError("deliver/bad-opts", "mail.send.deliver.create: opts is required");
439
+ }
440
+ validateOpts(opts,
441
+ ["hostname", "resolver", "policy", "retry", "dsn", "timeouts", "audit", "transportFactory"],
442
+ "mail.send.deliver.create");
443
+ validateOpts.requireNonEmptyString(opts.hostname,
444
+ "mail.send.deliver.create: hostname (local HELO/EHLO + DSN Reporting-MTA)",
445
+ DeliverError, "deliver/bad-hostname");
446
+
447
+ var policy = opts.policy || {};
448
+ validateOpts(policy, ["mtaSts", "dane"], "mail.send.deliver.create.policy");
449
+ var policyMtaSts = policy.mtaSts || "enforce";
450
+ if (["enforce", "testing", "off"].indexOf(policyMtaSts) === -1) {
451
+ throw new DeliverError("deliver/bad-policy-mtaSts",
452
+ "mail.send.deliver.create.policy.mtaSts must be enforce|testing|off");
453
+ }
454
+ var policyDane = policy.dane || "opportunistic";
455
+ if (["opportunistic", "enforce", "off"].indexOf(policyDane) === -1) {
456
+ throw new DeliverError("deliver/bad-policy-dane",
457
+ "mail.send.deliver.create.policy.dane must be opportunistic|enforce|off");
458
+ }
459
+
460
+ var retryOpts = opts.retry || {};
461
+ validateOpts(retryOpts, ["maxAttempts", "backoffMs"], "mail.send.deliver.create.retry");
462
+ var maxAttempts = typeof retryOpts.maxAttempts === "number" && retryOpts.maxAttempts > 0
463
+ ? Math.floor(retryOpts.maxAttempts) : DEFAULT_RETRY_BACKOFF_MS.length;
464
+ var backoffMs = Array.isArray(retryOpts.backoffMs) && retryOpts.backoffMs.length > 0
465
+ ? retryOpts.backoffMs.slice() : DEFAULT_RETRY_BACKOFF_MS.slice();
466
+
467
+ var timeouts = opts.timeouts || {};
468
+ validateOpts(timeouts, ["mxLookupMs", "perHostMs"], "mail.send.deliver.create.timeouts");
469
+ var mxLookupTimeoutMs = typeof timeouts.mxLookupMs === "number" && timeouts.mxLookupMs > 0
470
+ ? timeouts.mxLookupMs : DEFAULT_MX_LOOKUP_TIMEOUT_MS;
471
+ var perHostTimeoutMs = typeof timeouts.perHostMs === "number" && timeouts.perHostMs > 0
472
+ ? timeouts.perHostMs : DEFAULT_PER_HOST_TIMEOUT_MS;
473
+
474
+ var dsnOpts = opts.dsn || null;
475
+ if (dsnOpts) {
476
+ validateOpts(dsnOpts, ["from", "onPermanentFailure"],
477
+ "mail.send.deliver.create.dsn");
478
+ validateOpts.requireNonEmptyString(dsnOpts.from,
479
+ "mail.send.deliver.create.dsn.from", DeliverError, "deliver/bad-dsn-from");
480
+ if (typeof dsnOpts.onPermanentFailure !== "function") {
481
+ throw new DeliverError("deliver/bad-dsn-callback",
482
+ "mail.send.deliver.create.dsn.onPermanentFailure must be a function");
483
+ }
484
+ }
485
+
486
+ var auditEnabled = opts.audit !== false;
487
+ function _auditEmit(action, outcome, metadata) {
488
+ if (!auditEnabled) return;
489
+ try {
490
+ audit().safeEmit({ action: action, outcome: outcome, metadata: metadata || {} });
491
+ } catch (_e) { /* drop-silent — hot-path audit */ }
492
+ }
493
+
494
+ async function deliver(envelope) {
495
+ if (!envelope || typeof envelope !== "object") {
496
+ throw new DeliverError("deliver/bad-envelope",
497
+ "deliver: envelope is required");
498
+ }
499
+ validateOpts.requireNonEmptyString(envelope.from,
500
+ "deliver.envelope.from", DeliverError, "deliver/bad-envelope-from");
501
+ if (!Array.isArray(envelope.to) || envelope.to.length === 0) {
502
+ throw new DeliverError("deliver/bad-envelope-to",
503
+ "deliver.envelope.to must be a non-empty array");
504
+ }
505
+ if (envelope.to.length > MAX_RECIPIENTS_PER_CALL) {
506
+ throw new DeliverError("deliver/too-many-recipients",
507
+ "deliver.envelope.to length " + envelope.to.length + " exceeds cap " + MAX_RECIPIENTS_PER_CALL);
508
+ }
509
+ if (!Buffer.isBuffer(envelope.rfc822) && typeof envelope.rfc822 !== "string") {
510
+ throw new DeliverError("deliver/bad-envelope-rfc822",
511
+ "deliver.envelope.rfc822 must be a Buffer or string (raw RFC 822 message bytes)");
512
+ }
513
+ var raw = Buffer.isBuffer(envelope.rfc822) ? envelope.rfc822 : Buffer.from(envelope.rfc822, "utf8");
514
+
515
+ var ctx = {
516
+ resolver: opts.resolver || null,
517
+ policy: { mtaSts: policyMtaSts, dane: policyDane },
518
+ hostname: opts.hostname,
519
+ mxLookupTimeoutMs: mxLookupTimeoutMs,
520
+ perHostTimeoutMs: perHostTimeoutMs,
521
+ transportFactory: opts.transportFactory || null,
522
+ auditEmit: _auditEmit,
523
+ };
524
+
525
+ var delivered = [];
526
+ var deferred = [];
527
+ var failed = [];
528
+
529
+ for (var i = 0; i < envelope.to.length; i += 1) {
530
+ var recipient = envelope.to[i];
531
+ var res = await _deliverOne({
532
+ from: envelope.from,
533
+ rfc822: raw,
534
+ requireTls: envelope.requireTls === true,
535
+ }, recipient, ctx);
536
+
537
+ if (res.outcome === "delivered") {
538
+ delivered.push({
539
+ recipient: res.recipient,
540
+ mxHost: res.mxHost,
541
+ mxPriority: res.mxPriority,
542
+ deliveredAt: Date.now(),
543
+ transportResponse: res.transportResponse || null,
544
+ });
545
+ continue;
546
+ }
547
+ if (res.outcome === "transient") {
548
+ var attempts = (envelope.attempt || 0) + 1;
549
+ if (attempts >= maxAttempts) {
550
+ // Convert transient → permanent after the operator's
551
+ // documented retry budget is exhausted.
552
+ res.outcome = "permanent";
553
+ res.reason = (res.reason || "retry exhausted") + " (after " + attempts + " attempts)";
554
+ } else {
555
+ var idx = Math.min(attempts - 1, backoffMs.length - 1);
556
+ deferred.push({
557
+ recipient: res.recipient,
558
+ reason: res.reason,
559
+ reasonCode: res.reasonCode,
560
+ attempt: attempts,
561
+ retryAfterMs: backoffMs[idx],
562
+ });
563
+ continue;
564
+ }
565
+ }
566
+ // permanent (either direct or transient-converted-to-permanent)
567
+ var dsnSent = false;
568
+ if (dsnOpts) {
569
+ try {
570
+ var dsnMessage = _buildDsnMessage({
571
+ dsnFrom: dsnOpts.from,
572
+ originalFrom: envelope.from,
573
+ recipient: res.recipient,
574
+ reason: res.reason,
575
+ statusCode: res.reasonCode,
576
+ reportingMta: ctx.hostname,
577
+ originalHeaders: _extractHeaderBlock(raw),
578
+ });
579
+ await dsnOpts.onPermanentFailure(envelope, res, dsnMessage);
580
+ dsnSent = true;
581
+ } catch (dsnErr) {
582
+ _auditEmit("mail.send.deliver.dsn-failed", "failure", {
583
+ recipient: res.recipient, error: dsnErr.message,
584
+ });
585
+ }
586
+ }
587
+ failed.push({
588
+ recipient: res.recipient,
589
+ reason: res.reason,
590
+ reasonCode: res.reasonCode,
591
+ mxHost: res.mxHost || null,
592
+ dsnSent: dsnSent,
593
+ });
594
+ }
595
+
596
+ _auditEmit("mail.send.deliver.batch", "success", {
597
+ from: envelope.from,
598
+ delivered: delivered.length,
599
+ deferred: deferred.length,
600
+ failed: failed.length,
601
+ });
602
+
603
+ return {
604
+ delivered: delivered,
605
+ deferred: deferred,
606
+ failed: failed,
607
+ };
608
+ }
609
+
610
+ // Expose helpers for operator-side testing / introspection.
611
+ deliver.classifyOutcome = _classifySmtpOutcome;
612
+ deliver.buildDsn = _buildDsnMessage;
613
+ return deliver;
614
+ }
615
+
616
+ // Extract the header block (everything before the first CRLF CRLF) for
617
+ // inclusion in the DSN per RFC 3462 §3.
618
+ function _extractHeaderBlock(raw) {
619
+ var s = raw.toString("utf8");
620
+ var sep = s.indexOf("\r\n\r\n");
621
+ if (sep === -1) sep = s.indexOf("\n\n");
622
+ if (sep === -1) return s;
623
+ return s.slice(0, sep + 2);
624
+ }
625
+
626
+ module.exports = {
627
+ create: create,
628
+ DeliverError: DeliverError,
629
+ };