@blamejs/core 0.18.53 → 0.18.54
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 +118 -0
- package/NOTICE +1 -1
- package/README.md +3 -3
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +33 -32
- package/lib/guard-managesieve-command.js +49 -9
- package/lib/guard-regex.js +3 -5
- package/lib/guard-yaml.js +60 -15
- package/lib/mail-agent.js +23 -9
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +75 -20
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +121 -55
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +168 -25
- package/lib/mail-server-mx.js +76 -4
- package/lib/mail-server-net.js +126 -0
- package/lib/mail-server-pop3.js +73 -22
- package/lib/mail-server-submission.js +21 -2
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +278 -40
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-send-deliver.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Turnkey outbound SMTP composer. Wraps the discovery chain
|
|
12
12
|
* (MX-lookup → MTA-STS-fetch + MX-allowlist match → DANE TLSA query
|
|
13
13
|
* → REQUIRETLS handshake hint) around the existing per-host
|
|
14
|
-
* `b.mail.
|
|
14
|
+
* `b.mail.transports.smtp` wire-layer, plus deferred-retry scheduling
|
|
15
15
|
* for transient failures and RFC 3464 DSN generation for permanent
|
|
16
16
|
* ones.
|
|
17
17
|
*
|
|
@@ -32,14 +32,18 @@
|
|
|
32
32
|
* requireTls: true,
|
|
33
33
|
* });
|
|
34
34
|
* // → { delivered: [{ recipient, mxHost, tlsProtocol, ... }],
|
|
35
|
-
* // deferred: [{ recipient, reason, retryAfterMs }],
|
|
35
|
+
* // deferred: [{ recipient, mxHost, reason, retryAfterMs }],
|
|
36
36
|
* // failed: [{ recipient, reason, dsnSent }] }
|
|
37
|
+
* // deferred.mxHost is the receiver that refused, or null when the
|
|
38
|
+
* // deferral happened before any host was reached (an MX lookup that
|
|
39
|
+
* // failed). A queue view without it cannot say which peer to chase.
|
|
37
40
|
*
|
|
38
41
|
* Composes:
|
|
39
42
|
* - `b.network.smtp.policy.mtaSts.fetch` + `.matchMx` → RFC 8461 enforcement
|
|
40
|
-
* - `b.network.smtp.policy.dane.tlsa`
|
|
43
|
+
* - `b.network.smtp.policy.dane.tlsa` + `.verifyChain` → RFC 7672 TLSA query
|
|
44
|
+
* and peer authentication
|
|
41
45
|
* - `b.network.dns.resolver` (operator-supplied) → caching + DoH posture
|
|
42
|
-
* - `b.mail.
|
|
46
|
+
* - `b.mail.transports.smtp` → SMTP wire layer
|
|
43
47
|
* - `b.mail.requireTls` → RFC 8689 REQUIRETLS
|
|
44
48
|
* - `b.mailBounce`-style RFC 3464 DSN generation → permanent-failure
|
|
45
49
|
* report-mail
|
|
@@ -104,7 +108,16 @@ function _classifySmtpOutcome(err, response) {
|
|
|
104
108
|
if (err) {
|
|
105
109
|
var code = err.code || "";
|
|
106
110
|
if (/^(ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND)$/.test(code)) return "transient";
|
|
107
|
-
|
|
111
|
+
// A DANE failure is about THIS HOST, not this recipient: the certificate
|
|
112
|
+
// did not match the records, or the records could not be used. The ordinary
|
|
113
|
+
// cause is a rollover part-way through DNS propagation, which resolves
|
|
114
|
+
// itself — and a DSN does not. Classified permanent it also skipped every
|
|
115
|
+
// remaining MX, so one host mid-rollover bounced mail the next host would
|
|
116
|
+
// have taken. RFC 7672 §2.2: try the other hosts, defer if none
|
|
117
|
+
// authenticates. Matched before the policy class below, which keeps its
|
|
118
|
+
// permanent verdict.
|
|
119
|
+
if (/\bdane\b/i.test((err.code || "") + " " + (err.message || ""))) return "transient";
|
|
120
|
+
if (/mta-sts|tls-policy|requiretls/i.test((err.code || "") + " " + (err.message || ""))) return "permanent";
|
|
108
121
|
}
|
|
109
122
|
return "transient";
|
|
110
123
|
}
|
|
@@ -202,8 +215,22 @@ async function _resolveMx(domain, resolver, timeoutMs) {
|
|
|
202
215
|
// .create()` wraps DoH and returns `{ rrs: [{ exchange, priority }],
|
|
203
216
|
// ttl, ... }` — the wrapper carries TTL + provenance metadata.
|
|
204
217
|
// Accept both shapes; refuse anything else.
|
|
218
|
+
//
|
|
219
|
+
// The resolver's records are NOT flat. b.network.dns decodes an MX RR into
|
|
220
|
+
// `{ name, type, typeName, ttl, decoded: { preference, exchange } }`, so
|
|
221
|
+
// reading `.exchange` off the record itself yields undefined — every MX
|
|
222
|
+
// host became undefined and the first thing to touch one failed. The
|
|
223
|
+
// comment above this normalisation asserted the flat shape, and no test
|
|
224
|
+
// caught the disagreement because every one of them passes a hand-written
|
|
225
|
+
// resolver returning exactly what the comment claimed. Only a query against
|
|
226
|
+
// a real DNS response shows the difference.
|
|
205
227
|
if (mxs && !Array.isArray(mxs) && Array.isArray(mxs.rrs)) {
|
|
206
|
-
mxs = mxs.rrs
|
|
228
|
+
mxs = mxs.rrs.map(function (rr) {
|
|
229
|
+
if (rr && rr.decoded && typeof rr.decoded.exchange === "string") {
|
|
230
|
+
return { exchange: rr.decoded.exchange, priority: rr.decoded.preference };
|
|
231
|
+
}
|
|
232
|
+
return rr; // already flat
|
|
233
|
+
});
|
|
207
234
|
}
|
|
208
235
|
if (!Array.isArray(mxs) || mxs.length === 0) {
|
|
209
236
|
throw new DeliverError("deliver/no-mx",
|
|
@@ -277,15 +304,26 @@ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
|
|
|
277
304
|
return filtered;
|
|
278
305
|
}
|
|
279
306
|
|
|
280
|
-
// Apply DANE TLSA query per RFC 7672. Returns array of TLSA records
|
|
281
|
-
//
|
|
282
|
-
// The
|
|
283
|
-
// the
|
|
284
|
-
//
|
|
285
|
-
async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
|
|
307
|
+
// Apply DANE TLSA query per RFC 7672. Returns the array of TLSA records for
|
|
308
|
+
// the MX host, or null when DANE is off, the records are unusable, or the peer
|
|
309
|
+
// publishes none. The records are handed to the transport, which authenticates
|
|
310
|
+
// the peer's certificate chain against them via dane.verifyChain — the lookup
|
|
311
|
+
// on its own is discovery, and discovery is not what "enforce" promises.
|
|
312
|
+
async function _fetchDaneTlsa(mxHost, port, daneMode, dnssecValidated, resolver, auditEmit) {
|
|
286
313
|
if (daneMode === "off") return null;
|
|
314
|
+
// RFC 7672 §1.3: records that were not DNSSEC-validated MUST NOT be used.
|
|
315
|
+
// Under opportunistic with no assertion they are therefore unusable before
|
|
316
|
+
// they are fetched, so asking for them buys nothing and costs a DNS round
|
|
317
|
+
// trip per MX plus a warn on every delivery. `enforce` cannot reach here
|
|
318
|
+
// without the assertion — create() refuses that combination outright.
|
|
319
|
+
if (!dnssecValidated) return null;
|
|
287
320
|
try {
|
|
288
|
-
|
|
321
|
+
// The operator's resolver goes with the assertion: `dnssecValidated` is a
|
|
322
|
+
// statement about THEIR resolver, so the records have to come from it. A
|
|
323
|
+
// lookup that quietly used node:dns instead would let a non-validating
|
|
324
|
+
// system resolver supply spoofed TLSA data under a validated banner.
|
|
325
|
+
var tlsa = await smtpPolicy().dane.tlsa(mxHost, port || DEFAULT_PORT_SMTP,
|
|
326
|
+
{ dnssecValidated: true, resolver: resolver || undefined });
|
|
289
327
|
return tlsa && tlsa.length > 0 ? tlsa : null;
|
|
290
328
|
} catch (e) {
|
|
291
329
|
auditEmit("mail.send.deliver.dane.skip", "warn",
|
|
@@ -304,19 +342,25 @@ async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
|
|
|
304
342
|
// outbound relay) can wrap the wire-layer surface without monkey-
|
|
305
343
|
// patching the framework's mail module.
|
|
306
344
|
async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
|
|
307
|
-
|
|
345
|
+
// `transports.smtp`, which is the name `lib/mail.js` actually exports. It was
|
|
346
|
+
// reached for as `smtpTransport` — a name nothing defines — so the default was
|
|
347
|
+
// `undefined`, calling it threw, the throw was classified as a transient peer
|
|
348
|
+
// problem, and an operator who did not pass `transportFactory` got every
|
|
349
|
+
// recipient deferred 4.4.4 forever with no socket ever opened. The option is
|
|
350
|
+
// documented as an override, so the default is the path most callers take.
|
|
351
|
+
var factory = opts.transportFactory || mailModule().transports.smtp;
|
|
308
352
|
var transport = factory({
|
|
309
353
|
host: mxHost,
|
|
310
354
|
port: opts.port || DEFAULT_PORT_SMTP,
|
|
311
355
|
ehloName: hostnameLocal,
|
|
312
356
|
timeoutMs: opts.perHostTimeoutMs || DEFAULT_PER_HOST_TIMEOUT_MS,
|
|
313
357
|
requireTls: envelope.requireTls === true,
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
358
|
+
// The TLSA records fetched for THIS MX host. The transport matches the
|
|
359
|
+
// peer's certificate chain against them once the handshake completes and
|
|
360
|
+
// refuses the send on a mismatch (RFC 7672 §2.2). Absent when the peer
|
|
361
|
+
// publishes none, or when DANE is off — the transport then behaves as it
|
|
362
|
+
// always did.
|
|
363
|
+
dane: envelope.tlsa || undefined,
|
|
320
364
|
});
|
|
321
365
|
return transport.send({
|
|
322
366
|
from: envelope.from,
|
|
@@ -356,14 +400,21 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
356
400
|
}
|
|
357
401
|
var lastErr = null;
|
|
358
402
|
var lastResponse = null;
|
|
403
|
+
// The host the deferral is about. Null until one is tried, so a recipient
|
|
404
|
+
// deferred before any peer was reached reports "no host" rather than
|
|
405
|
+
// omitting the field — a consumer can then tell that apart from a host that
|
|
406
|
+
// simply was not recorded.
|
|
407
|
+
var lastMxHost = null;
|
|
359
408
|
for (var i = 0; i < mxs.length; i += 1) {
|
|
360
409
|
var mx = mxs[i];
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
410
|
+
lastMxHost = mx.exchange;
|
|
411
|
+
// DANE per-MX lookup. The records go to the transport, which matches the
|
|
412
|
+
// peer's certificate chain against them during the handshake.
|
|
413
|
+
var mxTlsa = null;
|
|
365
414
|
try {
|
|
366
|
-
await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane,
|
|
415
|
+
mxTlsa = await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane,
|
|
416
|
+
ctx.policy.dnssecValidated, ctx.resolver,
|
|
417
|
+
ctx.auditEmit);
|
|
367
418
|
} catch (daneErr) {
|
|
368
419
|
// DANE "enforce": a TLSA lookup failure means this MX host cannot
|
|
369
420
|
// be used for authenticated delivery (RFC 7672 §2.2). Fail this
|
|
@@ -385,6 +436,7 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
385
436
|
recipient: recipient,
|
|
386
437
|
rfc822: envelope.rfc822,
|
|
387
438
|
requireTls: envelope.requireTls,
|
|
439
|
+
tlsa: mxTlsa,
|
|
388
440
|
}, mx.exchange, ctx.hostname, ctx);
|
|
389
441
|
ctx.auditEmit("mail.send.deliver.delivered", "success", {
|
|
390
442
|
recipient: recipient, mxHost: mx.exchange, mxPriority: mx.priority,
|
|
@@ -411,8 +463,13 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
411
463
|
}
|
|
412
464
|
}
|
|
413
465
|
// All MX hosts returned transient — overall outcome is transient
|
|
414
|
-
// (defer + retry).
|
|
466
|
+
// (defer + retry). `mxHost` names the last host tried, because a queue an
|
|
467
|
+
// operator reads on a bad day has to answer WHICH receiver refused: a domain
|
|
468
|
+
// with several MX hosts defers on one of them, and that decides whether to
|
|
469
|
+
// wait, to contact that receiver, or to look at one's own transport security
|
|
470
|
+
// against that host.
|
|
415
471
|
return { recipient: recipient, outcome: "transient",
|
|
472
|
+
mxHost: lastMxHost,
|
|
416
473
|
reason: (lastErr && lastErr.message) || "all MX hosts failed transiently",
|
|
417
474
|
reasonCode: (lastResponse && lastResponse.code) || "4.4.4" };
|
|
418
475
|
}
|
|
@@ -428,7 +485,7 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
428
485
|
* Build a turnkey delivery handle. Returns a `deliver(envelope)`
|
|
429
486
|
* function that takes a single multi-recipient envelope, resolves
|
|
430
487
|
* MX records per recipient domain, applies the operator's configured
|
|
431
|
-
* MTA-STS / DANE policy, attempts delivery via `b.mail.
|
|
488
|
+
* MTA-STS / DANE policy, attempts delivery via `b.mail.transports.smtp`,
|
|
432
489
|
* and returns a per-recipient outcome split into `delivered` /
|
|
433
490
|
* `deferred` / `failed` arrays.
|
|
434
491
|
*
|
|
@@ -437,6 +494,23 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
437
494
|
* after the budget elapses. The primitive does not own a background
|
|
438
495
|
* scheduler — operator job-runner owns the retry lifecycle.
|
|
439
496
|
*
|
|
497
|
+
* DANE (RFC 7672) authenticates the peer, not just its DNS. When the peer
|
|
498
|
+
* publishes TLSA records, they are fetched and the certificate chain it
|
|
499
|
+
* presents during the handshake is matched against them; a peer whose chain
|
|
500
|
+
* matches none of its own records is refused and the next MX is tried.
|
|
501
|
+
*
|
|
502
|
+
* The TLSA lookup goes through `opts.resolver` when one is supplied, because
|
|
503
|
+
* the DNSSEC assertion below is a statement about THAT resolver. A resolver
|
|
504
|
+
* that cannot answer TLSA is refused rather than bypassed.
|
|
505
|
+
*
|
|
506
|
+
* Because RFC 7672 §1.3 forbids using records that were not DNSSEC-validated
|
|
507
|
+
* and node:dns does not expose the AD bit, only the operator can say whether
|
|
508
|
+
* their resolver validates. `policy.dnssecValidated: true` is that statement,
|
|
509
|
+
* and `dane: "enforce"` requires it: without it every peer that publishes TLSA
|
|
510
|
+
* would be refused while every peer that publishes none was delivered to.
|
|
511
|
+
* Under `"opportunistic"` the records are unusable without it, so none are
|
|
512
|
+
* fetched.
|
|
513
|
+
*
|
|
440
514
|
* Failed recipients trigger DSN composition: a RFC 3464 multipart/
|
|
441
515
|
* report message is built per failed recipient and handed to the
|
|
442
516
|
* operator-supplied `dsn.onPermanentFailure(envelope, recipientResult,
|
|
@@ -452,6 +526,7 @@ async function _deliverOne(envelope, recipient, ctx) {
|
|
|
452
526
|
* policy: {
|
|
453
527
|
* mtaSts: "enforce" | "testing" | "off", // default "enforce" — RFC 8461 posture
|
|
454
528
|
* dane: "opportunistic" | "enforce" | "off", // default "opportunistic" — RFC 7672
|
|
529
|
+
* dnssecValidated: boolean, // default false — assert the resolver DNSSEC-validates; required by dane "enforce"
|
|
455
530
|
* },
|
|
456
531
|
* retry: {
|
|
457
532
|
* maxAttempts: number, // default 5
|
|
@@ -521,7 +596,7 @@ function create(opts) {
|
|
|
521
596
|
var port = opts.port || DEFAULT_PORT_SMTP;
|
|
522
597
|
|
|
523
598
|
var policy = opts.policy || {};
|
|
524
|
-
validateOpts(policy, ["mtaSts", "dane"], "mail.send.deliver.create.policy");
|
|
599
|
+
validateOpts(policy, ["mtaSts", "dane", "dnssecValidated"], "mail.send.deliver.create.policy");
|
|
525
600
|
var policyMtaSts = policy.mtaSts || "enforce";
|
|
526
601
|
if (["enforce", "testing", "off"].indexOf(policyMtaSts) === -1) {
|
|
527
602
|
throw new DeliverError("deliver/bad-policy-mtaSts",
|
|
@@ -532,6 +607,25 @@ function create(opts) {
|
|
|
532
607
|
throw new DeliverError("deliver/bad-policy-dane",
|
|
533
608
|
"mail.send.deliver.create.policy.dane must be opportunistic|enforce|off");
|
|
534
609
|
}
|
|
610
|
+
// RFC 7672 §1.3 forbids using TLSA records that were not DNSSEC-validated,
|
|
611
|
+
// and node:dns does not surface the AD bit, so only the operator can say
|
|
612
|
+
// whether their resolver validates. Without that assertion `enforce` refuses
|
|
613
|
+
// every peer that publishes TLSA while delivering normally to every peer that
|
|
614
|
+
// publishes none — the sender's misconfiguration charged to the recipient who
|
|
615
|
+
// did the work. It is a property of this sender's resolver, so it is settled
|
|
616
|
+
// once at create() rather than surfacing per-peer as a delivery failure.
|
|
617
|
+
if (policy.dnssecValidated !== undefined && typeof policy.dnssecValidated !== "boolean") {
|
|
618
|
+
throw new DeliverError("deliver/bad-policy-dnssec",
|
|
619
|
+
"mail.send.deliver.create.policy.dnssecValidated must be a boolean when set");
|
|
620
|
+
}
|
|
621
|
+
var daneDnssecValidated = policy.dnssecValidated === true;
|
|
622
|
+
if (policyDane === "enforce" && !daneDnssecValidated) {
|
|
623
|
+
throw new DeliverError("deliver/dane-no-dnssec",
|
|
624
|
+
"policy.dane \"enforce\" requires policy.dnssecValidated: true — TLSA records " +
|
|
625
|
+
"must be DNSSEC-validated before use (RFC 7672 §1.3), and without that " +
|
|
626
|
+
"assertion enforce can only refuse the peers that publish them. Set it when " +
|
|
627
|
+
"the resolver validates DNSSEC; otherwise use \"opportunistic\" or \"off\"");
|
|
628
|
+
}
|
|
535
629
|
|
|
536
630
|
var retryOpts = opts.retry || {};
|
|
537
631
|
validateOpts(retryOpts, ["maxAttempts", "backoffMs"], "mail.send.deliver.create.retry");
|
|
@@ -578,8 +672,22 @@ function create(opts) {
|
|
|
578
672
|
throw new DeliverError("deliver/bad-envelope",
|
|
579
673
|
"deliver: envelope is required");
|
|
580
674
|
}
|
|
581
|
-
|
|
582
|
-
|
|
675
|
+
// The EMPTY string is a value here, not a missing one: RFC 5321 §4.5.5
|
|
676
|
+
// requires the null reverse path `MAIL FROM:<>` for a delivery status
|
|
677
|
+
// notification, and `b.mail.transports.smtp` already turns `""` into
|
|
678
|
+
// exactly that. Refusing it as "non-empty required" meant this composer
|
|
679
|
+
// could not send the one message shape the spec reserves a syntax for, and
|
|
680
|
+
// its own documented DSN example put a real address in the reverse path —
|
|
681
|
+
// so a bounce that failed would bounce back to the bounce's sender.
|
|
682
|
+
//
|
|
683
|
+
// The framework's other readers of an envelope sender already know this:
|
|
684
|
+
// `b.mail.spf.verify` treats an empty `mailFrom` as the null sender and
|
|
685
|
+
// falls back to the HELO identity, which is RFC 7208 §2.4 exactly.
|
|
686
|
+
if (typeof envelope.from !== "string") {
|
|
687
|
+
throw new DeliverError("deliver/bad-envelope-from",
|
|
688
|
+
"deliver.envelope.from must be a string — use \"\" for the null " +
|
|
689
|
+
"reverse path (MAIL FROM:<>) that RFC 5321 requires of a DSN");
|
|
690
|
+
}
|
|
583
691
|
if (!Array.isArray(envelope.to) || envelope.to.length === 0) {
|
|
584
692
|
throw new DeliverError("deliver/bad-envelope-to",
|
|
585
693
|
"deliver.envelope.to must be a non-empty array");
|
|
@@ -596,7 +704,8 @@ function create(opts) {
|
|
|
596
704
|
|
|
597
705
|
var ctx = {
|
|
598
706
|
resolver: opts.resolver || null,
|
|
599
|
-
policy: { mtaSts: policyMtaSts, dane: policyDane
|
|
707
|
+
policy: { mtaSts: policyMtaSts, dane: policyDane,
|
|
708
|
+
dnssecValidated: daneDnssecValidated },
|
|
600
709
|
hostname: opts.hostname,
|
|
601
710
|
port: port,
|
|
602
711
|
mxLookupTimeoutMs: mxLookupTimeoutMs,
|
|
@@ -638,6 +747,10 @@ function create(opts) {
|
|
|
638
747
|
var idx = Math.min(attempts - 1, backoffMs.length - 1);
|
|
639
748
|
deferred.push({
|
|
640
749
|
recipient: res.recipient,
|
|
750
|
+
// The receiver the deferral is about, or null when none was
|
|
751
|
+
// reached. A queue that records the reason and the retry time and
|
|
752
|
+
// not the peer cannot answer the first question an operator asks.
|
|
753
|
+
mxHost: res.mxHost === undefined ? null : res.mxHost,
|
|
641
754
|
reason: res.reason,
|
|
642
755
|
reasonCode: res.reasonCode,
|
|
643
756
|
attempt: attempts,
|
|
@@ -648,7 +761,13 @@ function create(opts) {
|
|
|
648
761
|
}
|
|
649
762
|
// permanent (either direct or transient-converted-to-permanent)
|
|
650
763
|
var dsnSent = false;
|
|
651
|
-
|
|
764
|
+
// A message sent with the NULL reverse path gets no DSN. RFC 5321 §4.5.5
|
|
765
|
+
// is explicit, and the reason is the whole point of the null sender: the
|
|
766
|
+
// DSN's `To:` is the original `from`, so bouncing a bounce addresses it
|
|
767
|
+
// to nobody and, where the peer is less careful, to itself. This is the
|
|
768
|
+
// loop the empty reverse path exists to stop, and it is reachable the
|
|
769
|
+
// moment `""` is accepted above.
|
|
770
|
+
if (dsnOpts && envelope.from !== "") {
|
|
652
771
|
try {
|
|
653
772
|
var dsnMessage = _buildDsnMessage({
|
|
654
773
|
dsnFrom: dsnOpts.from,
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -147,6 +147,7 @@ var codepointClass = require("./codepoint-class");
|
|
|
147
147
|
// Centralized so the marker lives in one place
|
|
148
148
|
// and the per-call sites read cleanly.
|
|
149
149
|
var ERR_CLAMP = 200; // protocol-reply error-message clamp
|
|
150
|
+
var CRLF_BYTES = Buffer.from("\r\n", "latin1"); // RFC 9051 §2.2 line terminator, as octets
|
|
150
151
|
var LINE_PREVIEW = 80; // audit-line preview clamp
|
|
151
152
|
|
|
152
153
|
// RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
|
|
@@ -237,6 +238,15 @@ function _validateMailboxName(name, opts) {
|
|
|
237
238
|
* rateLimit: b.mail.server.rateLimit handle | opts | false,
|
|
238
239
|
* audit: b.audit // optional
|
|
239
240
|
*
|
|
241
|
+
* `fetchRange` may return each row's `payload` as a Buffer, and should whenever
|
|
242
|
+
* the row carries message content. RFC 9051 §4.3 makes a literal a counted
|
|
243
|
+
* sequence of octets, and a string cannot hold one: a message octet that is not
|
|
244
|
+
* valid UTF-8 does not survive being encoded on the way to the socket, and the
|
|
245
|
+
* count the response announced stops matching the number of octets it wrote —
|
|
246
|
+
* which is what tells a client where the response ends. A Buffer payload is
|
|
247
|
+
* framed and written as the octets it holds. A string payload is unchanged and
|
|
248
|
+
* remains right for the rows that carry only attributes, such as `FLAGS (\Seen)`.
|
|
249
|
+
*
|
|
240
250
|
* @example
|
|
241
251
|
* var imap = b.mail.server.imap.create({
|
|
242
252
|
* tlsContext: b.mail.server.tls.context({ certFile, keyFile }).secureContext,
|
|
@@ -450,7 +460,14 @@ function create(opts) {
|
|
|
450
460
|
synchronizing: !parsed.literalNonSync,
|
|
451
461
|
};
|
|
452
462
|
if (!parsed.literalNonSync) {
|
|
453
|
-
|
|
463
|
+
// RFC 9051 §7.5 — a synchronizing literal is answered with a command
|
|
464
|
+
// continuation request: a line that BEGINS with `+`. Written as an
|
|
465
|
+
// untagged response, the wire carried `* + Ready for literal data`,
|
|
466
|
+
// which is not one, so a conforming client waited for a `+` line that
|
|
467
|
+
// never came and APPEND could not complete. The non-synchronizing
|
|
468
|
+
// route is no escape either: the strict profile's guard refuses
|
|
469
|
+
// LITERAL+ and CAPABILITY does not advertise it.
|
|
470
|
+
_writeContinuation(socket, "Ready for literal data");
|
|
454
471
|
}
|
|
455
472
|
return;
|
|
456
473
|
}
|
|
@@ -731,7 +748,7 @@ function create(opts) {
|
|
|
731
748
|
} else if (event.kind === "LIST") {
|
|
732
749
|
_writeUntagged(socket, "LIST " + event.payload);
|
|
733
750
|
} else if (event.kind === "FETCH") {
|
|
734
|
-
_writeUntagged(socket, (event.seq || ""
|
|
751
|
+
_writeUntagged(socket, _fetchResponse(event.seq || "", event.payload));
|
|
735
752
|
}
|
|
736
753
|
} catch (_e) { /* drop-silent — socket may already be closed */ }
|
|
737
754
|
});
|
|
@@ -967,56 +984,49 @@ function create(opts) {
|
|
|
967
984
|
}
|
|
968
985
|
_emit("mail.server.imap.auth_attempt",
|
|
969
986
|
{ connectionId: state.id, mechanism: mechName, remoteAddress: state.remoteAddress });
|
|
970
|
-
state.authPending = {
|
|
987
|
+
state.authPending = { mech: mechName, tag: tag, step: 0 };
|
|
971
988
|
_runAuthStep(state, socket, initialResp);
|
|
972
989
|
}
|
|
973
990
|
|
|
974
991
|
function _runAuthStep(state, socket, clientResp) {
|
|
975
992
|
var pending = state.authPending;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
{ connectionId: state.id, mechanism: pending.mechanism,
|
|
999
|
-
tenantId: result.actor.tenantId || null });
|
|
1000
|
-
_writeTagged(socket, savedTag, "OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
var failTag = pending.tag;
|
|
1004
|
-
state.authPending = null;
|
|
1005
|
-
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
1006
|
-
_emit("mail.server.imap.auth_failed",
|
|
1007
|
-
{ connectionId: state.id, mechanism: pending.mechanism,
|
|
1008
|
-
reason: (result && result.reason) || "verify-returned-fail" }, "denied");
|
|
1009
|
-
_writeTagged(socket, failTag, "NO Authentication credentials invalid");
|
|
1010
|
-
})
|
|
1011
|
-
.catch(function (err) {
|
|
1012
|
-
var failTag = pending.tag;
|
|
993
|
+
function _fail(reason, outcome, reply) {
|
|
994
|
+
var failTag = pending.tag;
|
|
995
|
+
state.authPending = null;
|
|
996
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
997
|
+
_emit("mail.server.imap.auth_failed",
|
|
998
|
+
{ connectionId: state.id, mechanism: pending.mech, reason: reason }, outcome);
|
|
999
|
+
_writeTagged(socket, failTag, reply);
|
|
1000
|
+
}
|
|
1001
|
+
mailServerNet.runSaslStep({
|
|
1002
|
+
exchange: pending,
|
|
1003
|
+
verify: authConfig.verify,
|
|
1004
|
+
credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
|
|
1005
|
+
clientResponse: clientResp,
|
|
1006
|
+
// Server-side challenge — `+ <base64>` per RFC 9051 §6.2.2.
|
|
1007
|
+
writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
|
|
1008
|
+
onChallengeUnsafe: function () {
|
|
1009
|
+
_fail("challenge-contains-line-terminator", "denied", "NO Authentication failed");
|
|
1010
|
+
},
|
|
1011
|
+
onSuccess: function (result) {
|
|
1012
|
+
state.actor = result.actor;
|
|
1013
|
+
state.stage = "authenticated";
|
|
1014
|
+
var savedTag = pending.tag;
|
|
1013
1015
|
state.authPending = null;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
}
|
|
1016
|
+
_emit("mail.server.imap.auth_success",
|
|
1017
|
+
{ connectionId: state.id, mechanism: pending.mech,
|
|
1018
|
+
tenantId: result.actor.tenantId || null });
|
|
1019
|
+
_writeTagged(socket, savedTag,
|
|
1020
|
+
"OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
|
|
1021
|
+
},
|
|
1022
|
+
onFailure: function (result) {
|
|
1023
|
+
_fail((result && result.reason) || "verify-returned-fail", "denied",
|
|
1024
|
+
"NO Authentication credentials invalid");
|
|
1025
|
+
},
|
|
1026
|
+
onError: function (err) {
|
|
1027
|
+
_fail((err && err.message) || String(err), "failure", "NO Authentication failed");
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1020
1030
|
}
|
|
1021
1031
|
|
|
1022
1032
|
function _handleLogin(state, socket, tag, args) {
|
|
@@ -1582,10 +1592,20 @@ function create(opts) {
|
|
|
1582
1592
|
for (var i = 0; i < rs.length; i += 1) {
|
|
1583
1593
|
var r = rs[i];
|
|
1584
1594
|
var payload = r.payload || "";
|
|
1585
|
-
|
|
1586
|
-
|
|
1595
|
+
// A Buffer payload is the backend saying "these are the message's
|
|
1596
|
+
// own octets". It is assembled and written as octets end to end, so
|
|
1597
|
+
// the count in the literal header is the count that reaches the wire.
|
|
1598
|
+
// Concatenating it into a string instead re-encoded it as UTF-8, and
|
|
1599
|
+
// the response then announced one length and sent another.
|
|
1600
|
+
var octets = Buffer.isBuffer(payload);
|
|
1601
|
+
var asText = octets ? payload.toString("latin1") : String(payload);
|
|
1602
|
+
if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(asText)) {
|
|
1603
|
+
var modseqAttr = (asText ? " " : "") + "MODSEQ (" + r.modseq + ")";
|
|
1604
|
+
payload = octets
|
|
1605
|
+
? Buffer.concat([payload, Buffer.from(modseqAttr, "latin1")])
|
|
1606
|
+
: asText + modseqAttr;
|
|
1587
1607
|
}
|
|
1588
|
-
_writeUntagged(socket, r.seq
|
|
1608
|
+
_writeUntagged(socket, _fetchResponse(r.seq, payload));
|
|
1589
1609
|
}
|
|
1590
1610
|
_writeTagged(socket, tag, "OK FETCH completed");
|
|
1591
1611
|
})
|
|
@@ -1754,17 +1774,63 @@ function create(opts) {
|
|
|
1754
1774
|
state.idle = { tag: tag, timer: timer };
|
|
1755
1775
|
}
|
|
1756
1776
|
|
|
1777
|
+
// One line onto the socket, as OCTETS.
|
|
1778
|
+
//
|
|
1779
|
+
// RFC 9051 §4.3 makes a literal a counted sequence of octets, so a response
|
|
1780
|
+
// carrying message content has to reach the wire as the octets the backend
|
|
1781
|
+
// holds. `socket.write(string)` encodes as UTF-8, which replaces every
|
|
1782
|
+
// sequence that is not valid UTF-8 and changes the length — so the count the
|
|
1783
|
+
// response announced and the number of octets it wrote disagreed, and a
|
|
1784
|
+
// client, which uses that count to find the end of the response, read the
|
|
1785
|
+
// next response as part of this one.
|
|
1786
|
+
//
|
|
1787
|
+
// A Buffer `msg` is written through untouched. A string keeps its UTF-8
|
|
1788
|
+
// encoding, which is what RFC 9051 §5.1 asks for in the one place a response
|
|
1789
|
+
// string is not ASCII: a mailbox name, once the client has enabled UTF8=ACCEPT.
|
|
1790
|
+
// Encoding those latin1 instead would keep the low byte of each character and
|
|
1791
|
+
// corrupt every name outside Latin-1 — the same defect this fixes, moved.
|
|
1792
|
+
function _writeLine(socket, prefix, msg) {
|
|
1793
|
+
try {
|
|
1794
|
+
if (Buffer.isBuffer(msg)) {
|
|
1795
|
+
socket.write(Buffer.concat([Buffer.from(prefix, "latin1"), msg, CRLF_BYTES]));
|
|
1796
|
+
} else {
|
|
1797
|
+
socket.write(prefix + msg + "\r\n");
|
|
1798
|
+
}
|
|
1799
|
+
} catch (_e) { /* socket may be down */ }
|
|
1800
|
+
}
|
|
1801
|
+
// One untagged FETCH response. A Buffer payload is the backend saying "these
|
|
1802
|
+
// are the message's own octets", and the response is assembled as octets so
|
|
1803
|
+
// the count in the literal header is the count that reaches the wire; a
|
|
1804
|
+
// string payload is attributes and stays a string.
|
|
1805
|
+
//
|
|
1806
|
+
// Two places build one of these — the FETCH command and a NOTIFY push — and
|
|
1807
|
+
// a second copy of this is how the octet handling ends up right in one and
|
|
1808
|
+
// wrong in the other, which is what it was.
|
|
1809
|
+
function _fetchResponse(seq, payload) {
|
|
1810
|
+
if (Buffer.isBuffer(payload)) {
|
|
1811
|
+
return Buffer.concat([
|
|
1812
|
+
Buffer.from(seq + " FETCH (", "latin1"), payload, Buffer.from(")", "latin1"),
|
|
1813
|
+
]);
|
|
1814
|
+
}
|
|
1815
|
+
return seq + " FETCH (" + (payload || "") + ")";
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1757
1818
|
function _writeTagged(socket, tag, msg) {
|
|
1758
|
-
|
|
1759
|
-
catch (_e) { /* socket may be down */ }
|
|
1819
|
+
_writeLine(socket, tag + " ", msg);
|
|
1760
1820
|
}
|
|
1761
1821
|
function _writeUntagged(socket, msg) {
|
|
1762
|
-
|
|
1763
|
-
catch (_e) { /* socket may be down */ }
|
|
1822
|
+
_writeLine(socket, "* ", msg);
|
|
1764
1823
|
}
|
|
1824
|
+
// RFC 9051 §7.5 continuation. Returns false when the operator's challenge
|
|
1825
|
+
// carries CR / LF / NUL: those end the line early and the remainder is read
|
|
1826
|
+
// by the client as a second server response. Not hypothetical for a SCRAM or
|
|
1827
|
+
// CRAM mechanism, whose challenge is composed partly from the client's own
|
|
1828
|
+
// nonce, so client bytes reach this write.
|
|
1765
1829
|
function _writeContinuation(socket, msg) {
|
|
1766
|
-
|
|
1767
|
-
|
|
1830
|
+
var safe = mailServerNet.saslChallengeOrNull(msg);
|
|
1831
|
+
if (safe === null) return false;
|
|
1832
|
+
_writeLine(socket, "+ ", safe);
|
|
1833
|
+
return true;
|
|
1768
1834
|
}
|
|
1769
1835
|
function _close(socket, state) {
|
|
1770
1836
|
// The drain loop's `if (state.stage === "closed") return;` guard
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -158,6 +158,11 @@ void C;
|
|
|
158
158
|
* // capabilities the server advertises beyond core
|
|
159
159
|
* accountsFor: async function (actor) → { primaryAccounts, accounts },
|
|
160
160
|
* // operator-supplied accountId enumeration
|
|
161
|
+
* webSocket: boolean, // default true — false stops advertising the
|
|
162
|
+
* // RFC 8887 WebSocket transport (capability and the
|
|
163
|
+
* // top-level webSocketUrl alias), for a deployment
|
|
164
|
+
* // that has not wired the upgrade handler
|
|
165
|
+
* webSocketUrl: string, // default "/jmap/ws" — where the upgrade lives
|
|
161
166
|
* profile: "strict" | "balanced" | "permissive",
|
|
162
167
|
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
163
168
|
* audit: b.audit // optional
|
|
@@ -199,6 +204,20 @@ function create(opts) {
|
|
|
199
204
|
var profile = opts.profile || DEFAULT_PROFILE;
|
|
200
205
|
var posture = opts.posture || null;
|
|
201
206
|
var serverCapabilities = opts.serverCapabilities || {};
|
|
207
|
+
// RFC 8887 §3 — whether this deployment offers the WebSocket transport. It
|
|
208
|
+
// was advertised unconditionally: the capability was injected when the
|
|
209
|
+
// consumer omitted the key, and merged back in when they supplied one, so a
|
|
210
|
+
// deployment that had not wired the upgrade handler still sent conforming
|
|
211
|
+
// clients to an endpoint that could not upgrade. The only value that
|
|
212
|
+
// suppressed it was `undefined`, and only because JSON.stringify drops such
|
|
213
|
+
// keys — an accident of serialization rather than an answer. `false` is now
|
|
214
|
+
// that answer, and a value that is neither true nor false is a
|
|
215
|
+
// misconfiguration rather than a silent default back to advertising.
|
|
216
|
+
validateOpts.optionalBoolean(opts.webSocket,
|
|
217
|
+
"mail.server.jmap.create: opts.webSocket (false stops advertising the RFC 8887 " +
|
|
218
|
+
"WebSocket transport, for a deployment that has not wired the upgrade handler)",
|
|
219
|
+
MailServerJmapError, "mail-server-jmap/bad-websocket");
|
|
220
|
+
var webSocketEnabled = opts.webSocket !== false;
|
|
202
221
|
|
|
203
222
|
// JMAP method registry. Wrap operator-supplied `opts.methods` map
|
|
204
223
|
// through `b.mail.serverRegistry` so per-handler resource budgets
|
|
@@ -569,14 +588,19 @@ function create(opts) {
|
|
|
569
588
|
var defaultCaps = { "urn:ietf:params:jmap:core": {} };
|
|
570
589
|
var hasOperatorWsCap = Object.prototype.hasOwnProperty.call(
|
|
571
590
|
serverCapabilities, "urn:ietf:params:jmap:websocket");
|
|
572
|
-
if (!hasOperatorWsCap) {
|
|
591
|
+
if (webSocketEnabled && !hasOperatorWsCap) {
|
|
573
592
|
defaultCaps["urn:ietf:params:jmap:websocket"] = {
|
|
574
593
|
url: opts.webSocketUrl || "/jmap/ws",
|
|
575
594
|
supportsPush: true,
|
|
576
595
|
};
|
|
577
596
|
}
|
|
597
|
+
var caps = Object.assign({}, defaultCaps, serverCapabilities);
|
|
598
|
+
// `webSocket: false` is the deployment's answer, so it outranks a
|
|
599
|
+
// capability the consumer also listed in serverCapabilities — otherwise
|
|
600
|
+
// the merge above would put back exactly what was declined.
|
|
601
|
+
if (!webSocketEnabled) delete caps["urn:ietf:params:jmap:websocket"];
|
|
578
602
|
var session = {
|
|
579
|
-
capabilities:
|
|
603
|
+
capabilities: caps,
|
|
580
604
|
accounts: info.accounts || {},
|
|
581
605
|
primaryAccounts: info.primaryAccounts || {},
|
|
582
606
|
username: actor.username || actor.id || "unknown",
|
|
@@ -587,10 +611,13 @@ function create(opts) {
|
|
|
587
611
|
// RFC 8887 §3 — `webSocketUrl` advertises the JMAP WS
|
|
588
612
|
// endpoint. Operator overrides via opts.webSocketUrl; default
|
|
589
613
|
// mounts at `/jmap/ws`.
|
|
590
|
-
urlEndpointResolution: serverCapabilities["urn:ietf:params:jmap:websocket"]
|
|
614
|
+
urlEndpointResolution: (webSocketEnabled && serverCapabilities["urn:ietf:params:jmap:websocket"])
|
|
591
615
|
? { useEndpoint: opts.webSocketUrl || "/jmap/ws", urlPrefix: "" }
|
|
592
616
|
: undefined,
|
|
593
|
-
|
|
617
|
+
// The top-level alias goes with the capability. Leaving it behind
|
|
618
|
+
// would still point a client at an endpoint that cannot upgrade,
|
|
619
|
+
// which is the whole thing `webSocket: false` is declining.
|
|
620
|
+
webSocketUrl: webSocketEnabled ? (opts.webSocketUrl || "/jmap/ws") : undefined,
|
|
594
621
|
state: sessionState,
|
|
595
622
|
};
|
|
596
623
|
res.statusCode = 200;
|