@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.
- package/CHANGELOG.md +4 -0
- package/index.js +8 -0
- package/lib/auth/bot-challenge.js +573 -0
- package/lib/framework-error.js +6 -0
- package/lib/fsm.js +469 -0
- package/lib/guard-mail-query.js +14 -0
- package/lib/mail-agent.js +24 -10
- package/lib/mail-send-deliver.js +629 -0
- package/lib/mail-store-fts.js +394 -0
- package/lib/mail-store.js +142 -4
- package/lib/money.js +699 -0
- package/lib/webhook.js +229 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
+
};
|