@blamejs/core 0.11.23 → 0.11.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/index.js +3 -0
- package/lib/mail-send-deliver.js +629 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.24 (2026-05-20) — **`b.mail.send.deliver` — turnkey outbound SMTP composer (MX → MTA-STS → DANE → REQUIRETLS → DSN).** One factory wires together the full outbound mail chain. The operator hands the primitive an envelope (`{ from, to, rfc822 }`) and gets back per-recipient outcomes: `delivered`, `deferred` (with retry budget), or `failed` (with the corresponding RFC 3464 DSN already composed and the configured DSN sink invoked). Per-recipient handling is independent — one recipient permanently failing does not interfere with another's delivery or retry. MX records are resolved live (operator may inject a resolver for testing), MTA-STS policy (RFC 8461) is fetched + matched against the resolved MX before TLS, DANE TLSA records (RFC 7672) are consulted when present, REQUIRETLS (RFC 8689) is honored, and the per-host SMTP transport is the framework's `b.mail.smtpTransport`. SMTP outcomes are classified deterministically: hard 5xx + null-MX → permanent (with DSN); soft 4xx + DNS / connect / TLS errors → transient (with backoff budget). Recipient cap (1000 per call) and per-host / per-lookup timeout caps are baked in. **Added:** *`b.mail.send.deliver.create(opts) → async deliver(envelope)` — composed outbound delivery* — Factory returns a `deliver(envelope)` async function. `envelope = { from, to[], rfc822 }`. Returns `{ delivered: [{...}], deferred: [{...}], failed: [{...}] }`. Composes `b.network.smtp.policy.mtaSts.fetch` + `.matchMx`, `b.network.smtp.policy.dane.tlsa` + `.verifyChain`, `b.mail.smtpTransport.create`, and `b.audit.safeEmit`. Required opts: `hostname` (EHLO + MAIL FROM identity). Optional: `resolver` (object exposing `resolveMx` / `resolve` — defaults to node:dns/promises); `policy.mtaSts.enabled` (default true); `policy.dane.enabled` (default true); `retry.maxAttempts` (default 5); `retry.backoffMs` (default exponential 60s/5m/30m/2h/12h); `dsn.from` + `dsn.onPermanentFailure(messageBuffer, ctx)` (required only when DSN delivery is desired); `timeouts.mxLookupMs` (default 5s); `timeouts.perHostMs` (default 60s); `transportFactory` (test-injection override). · *Per-recipient outcome classifier (`_classifySmtpOutcome`)* — Maps SMTP reply codes + Node socket / TLS errors onto the `permanent` / `transient` axis. Permanent: SMTP 5xx (any subclass), null-MX RFC 7505 sentinel `.`, no-MX-records, RFC 5321 §3.6.2 unrouteable. Transient: SMTP 4xx (any subclass), TCP connection refused / reset / timeout, TLS handshake failure (when MTA-STS / DANE is not enforce-mode), DNS NXDOMAIN at lookup time. Once `retry.maxAttempts` is exhausted, transient is escalated to permanent with reason `retry exhausted (after N attempts)`. · *RFC 3464 DSN composer (`_buildDsnMessage`)* — Builds a `multipart/report; report-type=delivery-status` message with three parts: human-readable explanation, `message/delivery-status` block (Reporting-MTA, Original-Recipient, Final-Recipient, Action: failed, Status: 5.x.x), and a `message/rfc822-headers` block carrying the failed message's headers. Boundary token is generated via `b.crypto.generateToken(12)` so it can't collide with attacker-chosen header / body bytes. The composed DSN is passed to the operator-supplied `dsn.onPermanentFailure(message, ctx)` sink — the primitive does not itself send the DSN, keeping the operator in control of which transport carries DSNs (typically the same submission service). · *MX failover + per-host audit* — MX records are walked in priority order. A failed connect / TLS / 4xx on the first host falls over to the next; only when every MX has been tried does the recipient receive its final outcome. Each per-host failure emits a structured audit event (`mail.send.deliver.host-fail` with `recipient` + `mxHost` + `priority` + `reason`); the final outcome emits `mail.send.deliver.delivered` / `.deferred` / `.permanent-fail`. Audit is drop-silent on the hot path (catch + ignore). · *Recipient + envelope hard caps* — `MAX_RECIPIENTS_PER_CALL = 1000` refuses oversized fan-out at the factory boundary (5xx-style DoS prevention). `envelope.rfc822` accepts a Buffer or UTF-8 string — strings are converted at the boundary so downstream byte-level reasoning (DKIM, REQUIRETLS, length headers) sees stable bytes. Bad-envelope refusals carry `DeliverError` codes `deliver/bad-envelope`, `/bad-envelope-from`, `/bad-envelope-to`, `/too-many-recipients`, `/bad-envelope-rfc822` — operators get structured surface for each refusal class. **Security:** *MTA-STS enforcement before TLS handshake (RFC 8461)* — When `policy.mtaSts.enabled` (default), MTA-STS policy is fetched for the recipient domain. If the policy mode is `enforce` and a resolved MX hostname does not match an `mx:` entry, the host is refused without attempting TLS. This closes the MX-substitution attack window (`b.mail.send.deliver` cannot be diverted to an attacker-controlled MX even when DNS is hijacked, as long as the recipient publishes MTA-STS). · *DANE TLSA verification when present (RFC 7672)* — When `policy.dane.enabled` (default) and the recipient domain publishes TLSA records, the SMTP transport's TLS certificate chain is verified against the TLSA hash before the SMTP command pipeline starts. TLSA records take precedence over PKIX trust roots for SMTP — RFC 7672 §2.2. · *Boundary token unguessable (DSN boundary-injection defense)* — MIME boundary token in composed DSNs is 12 random bytes hex-encoded via `b.crypto.generateToken` (SHAKE256 over OS-RNG) — not `Date.now()` + `Math.random()`. The boundary appears verbatim in the message; a predictable boundary would let an attacker who controls the failed message's headers craft byte sequences that close the boundary early + inject MIME parts. **Detectors:** *`per-recipient-loop-fallthrough-to-failed` (codebase-patterns)* — A new detector flags per-recipient delivery loops where the `delivered` branch does not exit the iteration before falling into the permanent-failure / DSN-emit path. Encodes the bug class that was caught during this slice's bring-up — a recipient delivering successfully also being added to `failed[]` because the `if (delivered)` branch lacked an explicit `continue`. **References:** [RFC 5321 (Simple Mail Transfer Protocol)](https://www.rfc-editor.org/rfc/rfc5321.html) · [RFC 3464 (Extensible Message Format for Delivery Status Notifications)](https://www.rfc-editor.org/rfc/rfc3464.html) · [RFC 7505 (Null MX no-service resource record)](https://www.rfc-editor.org/rfc/rfc7505.html) · [RFC 8461 (SMTP MTA Strict Transport Security — MTA-STS)](https://www.rfc-editor.org/rfc/rfc8461.html) · [RFC 7672 (SMTP Security via Opportunistic DANE TLS)](https://www.rfc-editor.org/rfc/rfc7672.html) · [RFC 8689 (SMTP REQUIRETLS option)](https://www.rfc-editor.org/rfc/rfc8689.html) · [RFC 3463 (Enhanced Mail System Status Codes)](https://www.rfc-editor.org/rfc/rfc3463.html)
|
|
12
|
+
|
|
11
13
|
- v0.11.23 (2026-05-20) — **`b.mail.agent.expunge` — hard EXPUNGE with legal-hold + retention-floor refusal gates.** Operators (and the future IMAP EXPUNGE + JMAP Email/set destroyed wire-protocol adapters) get a single canonical path for permanent message removal that refuses to delete anything currently under legal hold or still inside the regulator-mandated retention window. The gate runs per-message; refused ids carry an explicit reason (`legal-hold` / `retention-floor` / `not-in-folder`) plus the floor + age + posture metadata that drove the refusal — wire adapters mirror those reasons to operators verbatim. The destructive SQL runs only on the surviving id set, inside a backend transaction that also bumps folder modseq + decrements quota atomically. **Added:** *`b.mail.agent.expunge({ actor, folder, objectIds })` — hard EXPUNGE primitive* — Composes two refusal gates before the destructive SQL runs. (1) Legal-hold gate: any message whose `legal_hold` flag is set refuses with reason `legal-hold`. The mail-store layer surfaces the flag in per-row metadata; this layer maps it to the operator-facing refusal. (2) Retention-floor gate: under a configured compliance posture (`hipaa` / `pci-dss` / `gdpr` / `soc2`), the regulator-mandated minimum retention TTL is read from `b.retention.COMPLIANCE_RETENTION_FLOOR_MS[posture]` and any message whose age (`now - receivedAt`) is below the floor refuses with reason `retention-floor` plus `floorMs` + `ageMs` + `posture` metadata. Returns `{ deleted: <ids>, refused: [{ id, reason, ... }] }`. Audit event `mail.agent.expunge.success` carries the requested / deleted / refused counts and a reason histogram so dashboards can spot abnormal refusal patterns without parsing per-id detail. · *`b.mailStore.create(...).hardExpunge(folder, objectIds)` — destructive SQL primitive* — Removes messages permanently from a folder inside a single backend transaction: deletes the message row + its flag rows, bumps the folder modseq, decrements the per-folder quota by the freed bytes / count. Returns `{ rows, deleted, refused }` where `refused` carries `{ id, reason: 'legal-hold' | 'not-in-folder' }` for each id the SQL gate refused (legal-hold is mirrored from the column; not-in-folder catches stale ids). The agent layer (`b.mail.agent.expunge`) is responsible for the retention-floor gate; this primitive is the wire-protocol-shaped backend surface. · *`b.mailStore.create(...).fetchByObjectId` returns `legalHold: boolean`* — Pre-existing fetch path now consistently exposes the legal-hold flag in its return shape. Previously the field existed in the returned object via a separate path; this commit consolidates the duplicate exports into a single canonical `legalHold` boolean derived from the SQLite `legal_hold` INTEGER column. **References:** [RFC 9051 (IMAP4rev2 — EXPUNGE semantics, §6.4.3)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/set destroyed)](https://www.rfc-editor.org/rfc/rfc8621.html) · [45 CFR §164.316 (HIPAA — retention of records)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.316) · [PCI-DSS v4.0.1 §3.5.1.1 (retention of cardholder data)](https://www.pcisecuritystandards.org/document_library) · [GDPR Art. 17 (right to erasure — operator-side accountability)](https://gdpr-info.eu/art-17-gdpr/)
|
|
12
14
|
|
|
13
15
|
- v0.11.22 (2026-05-20) — **`b.cert.create` — turnkey TLS-certificate manager composing ACME + sealed persistence + renewal scheduler + SNI + key escrow.** Operators wiring TLS no longer have to glue ACME + key generation + cert persistence + renewal scheduling + SNI dispatch + escrow by hand. `b.cert.create({ storage, acme, certs, renew, ocsp, audit, compliance })` accepts a declarative manifest of certificates plus the operator's choice of ACME challenge solver, and the manager owns the rest of the lifecycle: ACME RFC 8555 order + RFC 9773 ARI-aware renewal, leaf key rotation on renew, OCSP refresh scheduling, sealed-disk persistence via `b.vault.seal`, optional break-glass key escrow encrypted to an operator-supplied recipient via `b.crypto.encryptEnvelope`, and SNI dispatch for `https.createServer({ SNICallback })`. Composes existing primitives — `b.acme.create` (extended this release with per-challenge methods), `b.vault.seal`, `b.safeAsync.repeating`, `b.network.tls`, `b.audit` — so the operator-facing surface is one factory call. **Added:** *`b.cert.create(opts)` — turnkey certificate manager* — New top-level primitive. Storage backend: `sealed-disk` (default; sealed via `b.vault.seal`). ACME: directory URL + contact + auto-generated account key (sealed on disk; operator can override with explicit key material). Certs manifest: per-cert name + domains + keyAlg (ecdsa-p256 / ecdsa-p384 / rsa-2048 / rsa-3072 / rsa-4096) + challenge `{ type, provision, cleanup }` callbacks (http-01 / dns-01 / tls-alpn-01). Renewal: ARI-respecting scheduler with configurable `intervalMs` + `minDaysBeforeExpiry` thresholds. OCSP: `stapling: true` schedules periodic OCSP refresh via `b.network.tls.ocsp`. Key escrow: optional `keyEscrow: { recipient }` encrypts each renewed private key to the recipient public key via `b.crypto.encryptEnvelope` and persists alongside the sealed key — break-glass-only recovery path, NOT routine access. Surface: `start()` / `stop()` / `getContext(name)` / `sniCallback` / `refresh(name)` / event-emitter `on('cert.issued' | 'cert.renewed' | 'cert.renew-failed', handler)`. · *`b.acme.create.fetchAuthorization(authUrl)` — RFC 8555 §7.5 authorization GET* — POST-as-GET an authorization URL; returns the parsed authorization object with the challenge array. The challenge entries carry `{ type, url, token, status }` for each offered challenge type. Required by the cert manager's per-challenge flow but useful to operators implementing custom ACME wrappers. · *`b.acme.create.notifyChallengeReady(challengeUrl)` — RFC 8555 §7.5.1 ready-notification* — POST an empty JSON object to a challenge URL to signal the operator has provisioned the response. Returns the updated challenge object; the CA's validation runs asynchronously and the operator polls via `waitForAuthorization` afterwards. · *`b.acme.create.waitForAuthorization(authUrl, opts?)` — authorization status polling* — Polls an authorization until `status === 'valid'` (success) or `status === 'invalid'` (CA refused). Honors the client's `pollIntervalMs` + `pollMaxMs` defaults; per-call `opts.intervalMs` + `opts.timeoutMs` override available. Throws typed `acme/auth-invalid` on CA refusal and `acme/auth-timeout` on poll-budget exhaustion. · *`b.acme.create.buildCsr({ privateKey, publicKey, domains })` — RFC 2986 PKCS#10 CSR builder* — Builds a CertificationRequest signed with the leaf private key. Subject `CN=<first domain>`, all domains as `dNSName` entries in the SubjectAltName extension. Supports ECDSA P-256 (signed sha256), ECDSA P-384 (signed sha384), RSA 2048 / 3072 / 4096 (signed sha256). Ed25519 is rejected at the CSR layer because CA support is uneven — operators wanting Ed25519 certs build the CSR externally. PEM-encoded output ready to feed to `finalize(order, csrPem)`. · *`b.asn1Der` write primitives — `writeBitString` / `writeSet` / `writeUtf8String` / `writePrintableString` / `writeIa5String` / `writeBoolean` / `writeContextImplicit`* — ASN.1 DER encoder extensions needed for PKCS#10 CSR construction. PrintableString refuses input outside the RFC 5280 character set; IA5String refuses non-ASCII; UTF8String refuses non-string input. SET-OF encoding sorts children by their encoded bytes per DER. Internal-only today (consumed by `b.acme.create.buildCsr`); operators with their own ASN.1 needs can compose them. **Changed:** *`b.audit.FRAMEWORK_NAMESPACES` adds `cert`* — The cert-manager lifecycle emits `cert.account.generated` / `cert.issued` / `cert.renewed` / `cert.renew-failed` / `cert.challenge-cleanup` audit events; the audit-namespace coverage check at smoke-time now recognizes the `cert` namespace. **References:** [RFC 8555 (ACME)](https://www.rfc-editor.org/rfc/rfc8555.html) · [RFC 9773 (ACME Renewal Information / ARI)](https://www.rfc-editor.org/rfc/rfc9773.html) · [RFC 2986 (PKCS#10 Certification Request Syntax)](https://www.rfc-editor.org/rfc/rfc2986.html) · [RFC 5280 (X.509 Internet PKI)](https://www.rfc-editor.org/rfc/rfc5280.html) · [RFC 8737 (TLS-ALPN-01 challenge)](https://www.rfc-editor.org/rfc/rfc8737.html) · [OpenSSL CSR roundtrip — local OpenSSL validation](https://www.openssl.org/docs/man3.5/man1/openssl-req.html)
|
package/index.js
CHANGED
|
@@ -275,6 +275,9 @@ var uuid = require("./lib/uuid");
|
|
|
275
275
|
var mail = require("./lib/mail");
|
|
276
276
|
mail.rbl = require("./lib/mail-rbl");
|
|
277
277
|
mail.serverRegistry = require("./lib/mail-server-registry");
|
|
278
|
+
mail.send = mail.send || {};
|
|
279
|
+
mail.send.deliver = require("./lib/mail-send-deliver").create;
|
|
280
|
+
mail.send.deliver.DeliverError = require("./lib/mail-send-deliver").DeliverError;
|
|
278
281
|
mail.greylist = require("./lib/mail-greylist");
|
|
279
282
|
mail.helo = require("./lib/mail-helo");
|
|
280
283
|
mail.deploy = require("./lib/mail-deploy");
|
|
@@ -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
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:f66cc3af-cd05-47b5-b147-bc1e05a046f1",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-21T03:43:16.022Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.24",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.24",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.24",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.24",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|