@blamejs/core 0.11.25 → 0.11.27

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.11.x
10
10
 
11
+ - v0.11.27 (2026-05-20) — **IMAP CONDSTORE (RFC 7162) — modseq-aware FETCH + STORE on `b.mail.server.imap`.** The IMAP listener advertises and honours the CONDSTORE extension. Clients that issue `ENABLE CONDSTORE` get MODSEQ attributes in every untagged FETCH response; FETCH parses the `(CHANGEDSINCE <modseq>)` modifier and forwards it to the operator's backend so the backend can prune unchanged rows server-side; STORE parses the `(UNCHANGEDSINCE <modseq>)` conditional-update modifier and surfaces the backend's MODIFIED conflict set in the tagged OK response (`OK [MODIFIED <set>] STORE completed`). The backend interface picks up four new opts on the existing `fetchRange` / `storeFlags` calls: `changedSince`, `includeVanished`, `includeModseq`, `unchangedSince`. Backends MAY return modseq on each row; the listener injects `MODSEQ (<n>)` into the payload when present and CONDSTORE is enabled. QRESYNC (RFC 7162 §3.2) is deferred — accepted in ENABLE but no vanished-set surface is exposed yet. **Added:** *CAPABILITY advertises `CONDSTORE` unconditionally* — Per RFC 7162 §3 servers advertise CONDSTORE; clients ENABLE before relying on MODSEQ in untagged FETCH responses. The advertisement is unconditional (state-independent) so clients that issue CAPABILITY pre-LOGIN see CONDSTORE in the same untagged-response shape they'll see post-LOGIN. The old SELECT-side `HIGHESTMODSEQ` emission keeps working. · *`ENABLE CONDSTORE` handler flips `state.enabledCondStore`* — Replaces the no-op `OK ENABLED` shortcut with a real handler that parses the requested capability set, flips `state.enabledCondStore = true` on CONDSTORE, and replies with `ENABLED CONDSTORE` + `OK ENABLE completed`. Unknown extensions are silently ignored per RFC 5161 §3.1. QRESYNC is recognised but accepted only when a v1+ backend exposes the vanished-set surface. · *FETCH parses `(CHANGEDSINCE <modseq>)` + injects MODSEQ in responses* — When the FETCH args carry a trailing `(CHANGEDSINCE <n>)` modifier (RFC 7162 §3.1.4) the listener strips it from the fetch-att spec and forwards `opts.changedSince` to `mailStore.fetchRange`. The backend can prune unchanged messages server-side. When CONDSTORE is enabled (or the client explicitly requested MODSEQ as a fetch-att), each untagged FETCH response includes `MODSEQ (<n>)` — synthesised from `row.modseq` if the backend supplies it and the payload doesn't already contain it. Also recognises the QRESYNC `VANISHED` modifier (flag forwarded as `opts.includeVanished`); the vanished-set emission is the backend's responsibility for now. · *STORE parses `(UNCHANGEDSINCE <modseq>)` + emits `[MODIFIED <set>]` on conflict* — Per RFC 7162 §3.1.3 the conditional STORE refuses to update messages whose modseq advanced past `unchangedSince` since the client last fetched. The listener parses the modifier between the seq-set and the FLAGS op, forwards `opts.unchangedSince` to `mailStore.storeFlags`, and accepts either the legacy `rows: [...]` shape or a structured `{ rows, modified }` shape. When `modified` is non-empty, the tagged response carries `OK [MODIFIED <set>] STORE completed` so the client knows which messages need re-fetching before retry. Untagged FETCH responses also include `MODSEQ (<n>)` when STORE accepted updates under CONDSTORE. **Security:** *Modifier parsing is bounded + non-greedy* — The CHANGEDSINCE / UNCHANGEDSINCE matchers use `[^)]*` rather than `.*` so a malformed modifier can't consume the entire fetch-att spec. Both modifiers parse `\d+` only — non-integer / negative / Infinity values are silently dropped (the modifier becomes a no-op), so a client cannot ride the modifier to inject arbitrary fragments into the backend opts. · *Modseq attribute is opt-in* — MODSEQ injection into untagged FETCH responses ONLY happens when (a) CONDSTORE is enabled OR (b) the client's fetch-att spec contains the `MODSEQ` keyword. Pre-CONDSTORE clients see exactly the responses they saw before this release. The IMAP wire-format compatibility line is unchanged for the IMAP4rev1 / IMAP4rev2 cohorts that never issue `ENABLE CONDSTORE`. **References:** [RFC 7162 (IMAP4 CONDSTORE / QRESYNC)](https://www.rfc-editor.org/rfc/rfc7162.html) · [RFC 9051 (IMAP4rev2)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 5161 (IMAP ENABLE Extension)](https://www.rfc-editor.org/rfc/rfc5161.html) · [RFC 4315 (IMAP UIDPLUS Extension)](https://www.rfc-editor.org/rfc/rfc4315.html)
12
+
13
+ - v0.11.26 (2026-05-20) — **`b.mail.server.submission` — CHUNKING / BDAT (RFC 3030).** The outbound submission listener now advertises and accepts the RFC 3030 BDAT (binary data) command, the chunked-upload alternative to DATA. Operators with large outbound payloads (attachments, MIME multipart bodies, encoded HTML) no longer pay the dot-stuffing cost of DATA; clients can stream chunks of arbitrary size and finalise with a `BDAT 0 LAST` (or `BDAT N LAST` for the final chunk). Mixing DATA + BDAT within one transaction is refused per RFC 3030 §3. Same `agent.handoff` contract — the body bytes arrive at the agent layer in their canonical SMTP form, no dot-stuffing applied (BDAT payloads are opaque). **Added:** *EHLO advertises `CHUNKING` + new `BDAT <chunk-size> [LAST]` command* — The EHLO 250-line list now includes `CHUNKING` (RFC 3030 §2.1). A new `BDAT` command handler accepts `BDAT <chunk-size> [LAST]` after MAIL FROM + RCPT TO; the server reads exactly `<chunk-size>` bytes from the socket — no dot-stuffing, no end-of-data marker — and acknowledges with `250 2.0.0 <octets> octets received`. Multiple BDAT chunks accumulate into the message body; the final chunk carries the `LAST` keyword and triggers the same agent-handoff path as DATA. A `BDAT 0 LAST` finalises an empty trailer when the last chunk's byte count is unknown in advance. · *Cumulative size cap honoured up-front* — `BDAT <large-size>` is refused with `552 5.3.4 BDAT cumulative size <total> exceeds maxMessageBytes (<cap>)` BEFORE the server begins reading bytes off the wire. A misconfigured client that pipelines `BDAT 999999999 LAST` and 1 GB of body is rejected at the command line, not after the byte stream lands. The collector bound on the receive side enforces the same cap as a backstop. · *Mid-segment payload drainage* — When `BDAT N LAST\r\n<payload>` arrives in one TCP segment (typical for pipelined small messages), the line-loop drains the post-`\r\n` bytes from the command buffer straight into the BDAT collector before returning. Any tail bytes after the BDAT chunk completes get re-fed as the next command. Operators get pipelining + chunking with no extra round-trip cost. **Security:** *BDAT state cleared on every STARTTLS upgrade* — Same threat model as CVE-2021-38371 (Exim) / CVE-2021-33515 (Dovecot): pre-handshake bytes a malicious peer pipelined MUST NOT reach the post-TLS state machine. The STARTTLS handler clears `inBdatChunk` / `bdatRemaining` / `bdatCollector` / `bdatTotalBytes` alongside the existing line-buffer + DATA-collector reset, so a smuggled `BDAT <n>` + body that lands before the TLS upgrade can't bleed into the encrypted transaction. · *Refusal on BDAT outside transaction* — BDAT before MAIL FROM / with zero RCPT returns `503 5.5.1` and does not enter chunk-collection mode. A misbehaving client that issues BDAT eagerly cannot leak state into the next transaction; the RSET reset path also clears all BDAT-side state. · *Pipelining race gate mirrors DATA* — If the operator's `recipientPolicy` is async and not all RCPT verdicts have resolved, BDAT returns `451 4.5.0 RCPT TO verdicts pending; reissue BDAT after recipient replies` — same shape as the DATA pipelining-race gate. The transaction never commits with a partially-resolved recipient set. **References:** [RFC 3030 (SMTP Service Extensions — CHUNKING / BDAT / BINARYMIME)](https://www.rfc-editor.org/rfc/rfc3030.html) · [RFC 5321 (SMTP)](https://www.rfc-editor.org/rfc/rfc5321.html) · [RFC 6409 (Message Submission for Mail)](https://www.rfc-editor.org/rfc/rfc6409.html) · [RFC 8314 (Cleartext considered obsolete — submission ports)](https://www.rfc-editor.org/rfc/rfc8314.html) · [CVE-2021-38371 (Exim STARTTLS injection)](https://nvd.nist.gov/vuln/detail/CVE-2021-38371) · [CVE-2021-33515 (Dovecot STARTTLS pre-handshake state leak)](https://nvd.nist.gov/vuln/detail/CVE-2021-33515)
14
+
11
15
  - v0.11.25 (2026-05-20) — **Five new primitives: sealed-token mail FTS + Stripe HMAC-SHA256 webhook verify + `b.money` + `b.fsm` + `b.auth.botChallenge`.** Five additive primitives in one release. Every consumer building on the framework — mail UIs, billing pipelines, e-commerce backends, multi-step business workflows, public web endpoints — now finds the substrate in-tree instead of reimplementing it. None of these replaces a default; every primitive is opt-in at the call site. Mail full-text search hashes tokens with the per-deployment vault salt before any byte hits the SQLite FTS5 index, so the index is searchable but a database dump leaks nothing readable. The Stripe-shaped HMAC-SHA-256 verifier sits next to the existing PQC and SHA3-512 webhook signers; the `whsec_` prefix stays in the key bytes per Stripe's spec; signatures are constant-time-compared and replay-defended via an operator-supplied nonce store. `b.money` arithmetic is BigInt-only across 40 ISO 4217 currencies with largest-remainder allocation and banker's-rounding FX conversion. `b.fsm` is the in-process sibling of `b.agent.saga` — declarative state + transition definition, guards, async on-enter/on-exit, drop-silent audit on every transition, with a transition-serialisation lock that makes concurrent `.transition()` calls deterministic. `b.auth.botChallenge` composes `b.httpClient` for siteverify against Cloudflare Turnstile (default), hCaptcha, and reCAPTCHA-v3 — the secret bytes never appear in any audit metadata. **Added:** *`b.mailStore.fts` — sealed-token full-text index + `b.mailStore.create().search(folder, filter)`* — Every `appendMessage` now inserts a row into a SQLite FTS5 virtual table whose subject / address / body columns hold space-separated 16-char hex hashes — vault-salted SHA3 over each NFC-normalised token. The salt is the same `b.vault.getDerivedHashSalt()` `b.cryptoField` uses for sealed-column derived hashes, so rotating the vault salt invalidates the FTS index in step with every other sealed-row hash. Query terms go through the same tokenize → hash transform on the search path and issue FTS5 MATCH against the hash-token rows — the index is fully searchable without ever materialising the plaintext on disk. `b.mail.agent.search({ folder, filter })` now accepts `text` / `subject` / `body` / `from` / `to` filter keys; the agent layer hands them to the store. `hardExpunge` deletes the FTS row inside the same transaction as the canonical message row so the two cannot drift. Limits: 8192 tokens / row / field, 2..64 codepoint length band, 8 MiB input cap per field. Exact-token only (no prefix wildcard, no stemmer) — the cost of sealed-at-rest. · *`b.webhook.verify({ alg: 'hmac-sha256-stripe', secret, header, body, toleranceMs?, nonceStore? })` + `b.webhook.sign(...)`* — Stripe-spec inbound webhook signature verifier (Paddle + Shopify use the same shape). Parses `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>...]`, validates the timestamp is within the tolerance window (default 5 min; refuses below 30s), HMAC-SHA-256s `<t>.<body>` with the `whsec_...` secret bytes verbatim (the prefix IS the key — `b.webhook.verify` never strips it), and walks the v1 entries with `b.crypto.timingSafeEqual` so a rotation grace window doesn't leak which entry matched. `b.webhook.sign` is the round-trip companion for outbound Stripe-shaped emission and round-trip tests. Optional `nonceStore: { has(k), set(k, ttlMs) }` records the accepted signature so a replay within the tolerance window refuses. The header value is hard-capped at 4096 bytes and per-`v1` hex at 256 chars (anti-DoS). · *`b.money` — decimal-safe money + 40-currency ISO 4217 catalog* — BigInt minor units throughout — Number is refused at construction. Currency exponents covering 0 (JPY/KRW), 2 (USD/EUR/GBP/…), 3 (KWD/BHD/JOD/OMR/TND), 4 (CLF) are pre-populated. Surface: `b.money.of(amount, code)` / `b.money.fromMinorUnits(bigint, code)` / `b.money.parse('12.50 USD')` / `b.money.zero(code)` plus instance methods `.add` / `.subtract` / `.multiply` / `.allocate(weights[])` / `.negate` / `.abs` / `.equals` / comparison family / `.toMinorUnits()` / `.toString()` / `.toJSON()` / `.format(locale?)`. `b.money.convert(money, toCurrency, fxRateProvider)` rounds half-to-even (banker's rounding) by default. Cross-currency arithmetic throws; `0.10 + 0.20 === 0.30` exactly. Allocation uses largest-remainder so $10 / 3 = [$3.34, $3.33, $3.33] with sum preserved. · *`b.fsm` — in-process state machine (sibling of `b.agent.saga`)* — `b.fsm.define({ name, initial, states, transitions })` returns a frozen machine factory. `Machine.create({ initialContext })` returns an instance with `.state` / `.context` / `.history` / `.allowed()` / `.can(name)` / `.transition(name, opts)` / `.toJSON()`. Transitions carry an optional guard (predicate; refusal throws `fsm/guard-refused`) and trigger per-state `onEnter` / `onExit` side-effects (sync or returned-Promise — `.transition` awaits). Every transition emits a `fsm.<machineName>.transition` audit event via `b.audit.safeEmit` (drop-silent on hot path), including the actor + metadata the caller passed in. A transition lock serialises concurrent `.transition()` calls — the test suite exercises five parallel transitions and verifies only the legal path runs. State + transition names are identifier-shape only (`safeSql.DEFAULT_IDENTIFIER_RE`) so a name never lands in SQL / HTML un-validated. `Machine.restore(snapshot)` rebuilds an instance from a prior `.toJSON()` snapshot — state + history + context survive a process restart. · *`b.auth.botChallenge.create({ secret, provider?, ... }) → { verify(token, opts?) }`* — Server-side challenge-response verifier for Cloudflare Turnstile (default), hCaptcha, and reCAPTCHA-v3. Composes `b.httpClient` for the outbound `/siteverify` POST — every request rides the framework's SSRF guard + DNS pinning + retry policy; the operator's secret never appears in a URL or any audit metadata field. Body encoding is `application/x-www-form-urlencoded` per the provider specs. Returns `{ ok, provider, hostname, action, challengeTs, score?, raw }` on success; throws `BotChallengeError` with structured codes (`invalid-token`, `timeout`, `hostname-mismatch`, `action-mismatch`, `provider-error`) on failure, with the upstream `error-codes` array exposed at `err.errorCodes`. Per-factory `allowedHostnames` + `allowedActions` allowlists; per-call `expectedHostname` + `expectedAction` overrides. Tokens are refused at the factory boundary if empty / non-string / > 4096 bytes, before any outbound call. **Security:** *Mail FTS index leaks zero plaintext (sealed-at-rest invariant extended)* — Pre-v0.11.25, the only operator-facing search path on `b.mail.agent` was a modseq cursor + post-fetch unseal — fast for cursoring but useless for text content discovery. Plaintext-FTS would have broken the sealed-at-rest invariant. The new index hashes every indexed token with the per-deployment vault salt before insert; a `sqlite3 .dump` produces ASCII-hex tokens indistinguishable from random. The same vault salt protects every `b.cryptoField`-sealed column, so adding the FTS index does NOT widen the cryptographic trust boundary. · *Stripe verifier defends the documented attack surface* — Constant-time HMAC compare (timing-safe across the v1 rotation list — operators don't leak which entry matched). Per-`v1` 256-hex anti-DoS cap. Tolerance-window minimum 30s — refuses operator misconfiguration that would accept hour-old signatures. The `whsec_` prefix preservation is encoded as a `codebase-patterns` detector (`stripe-hmac-sha256-no-strip-whsec-prefix`) so re-introducing the strip-bug is impossible without tripping a release gate. · *Bot-challenge secret never reaches audit / logs* — `b.auth.botChallenge` audit emits `{ provider, hostname, ok, errorCodes }` — the operator's secret bytes never appear in any metadata key. A `codebase-patterns` detector (`bot-challenge-secret-not-in-audit`) scans `lib/auth/bot-challenge.js` for any `audit.*emit` window that references `secret` so a future refactor cannot regress. **Detectors:** *Five new `codebase-patterns` detectors encode the shape-specific bug classes* — `stripe-hmac-sha256-no-strip-whsec-prefix` flags `secret.replace(/^whsec_/, '')` / `secret.slice(6)` near an HMAC call (the `whsec_` prefix IS the key). `no-number-money-arithmetic` flags `b.money.of(<Number>, ...)` and Number / Money arithmetic (precision lost; only BigInt / string OK at construction). `fsm-transition-without-await` flags `<fsm>.transition(...)` without `await` or `.then` in `lib/` (the transition is async; sync misuse swallows errors). `bot-challenge-secret-not-in-audit` is file-scoped to `lib/auth/bot-challenge.js` and flags any `audit.*emit` window or `metadata: { ... }` object literal referencing `secret`. Each detector is wired into `codebase-patterns.test.js`'s `run()` so every future commit re-checks the shape. **References:** [RFC 2104 (HMAC)](https://www.rfc-editor.org/rfc/rfc2104.html) · [RFC 6234 (US Secure Hash Algorithms — SHA-256)](https://www.rfc-editor.org/rfc/rfc6234.html) · [Stripe webhook signature spec](https://docs.stripe.com/webhooks/signature) · [Paddle webhook signature verification](https://developer.paddle.com/webhooks/signature-verification) · [Shopify webhooks — HMAC verification](https://shopify.dev/docs/apps/webhooks/configuration/https) · [ISO 4217 (Currency codes + minor unit catalog)](https://www.iso.org/iso-4217-currency-codes.html) · [IEEE 754 (the float-precision problem `b.money` avoids)](https://standards.ieee.org/standard/754-2019.html) · [Cloudflare Turnstile — server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) · [hCaptcha — verify the user response server-side](https://docs.hcaptcha.com/) · [reCAPTCHA-v3 — server-side verification](https://developers.google.com/recaptcha/docs/v3) · [OWASP ASVS v5 §11.5 — bot defense controls](https://owasp.org/www-project-application-security-verification-standard/) · [RFC 9051 (IMAP4rev2 — SEARCH semantics)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/query)](https://www.rfc-editor.org/rfc/rfc8621.html) · [SQLite FTS5](https://www.sqlite.org/fts5.html) · [UML State Machine (OMG UML 2.5.1 §14)](https://www.omg.org/spec/UML/2.5.1)
12
16
 
13
17
  - 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)
@@ -524,7 +524,7 @@ function create(opts) {
524
524
  maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
525
525
  LOGIN: { fn: function (s, so, p) { return _handleLogin(s, so, p.tag, p.args); },
526
526
  maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
527
- ENABLE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "OK ENABLED"); },
527
+ ENABLE: { fn: function (s, so, p) { return _handleEnable(s, so, p.tag, p.args); },
528
528
  maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
529
529
  SELECT: { fn: function (s, so, p) { return _handleSelect(s, so, p.tag, p.args, false); },
530
530
  maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
@@ -638,6 +638,9 @@ function create(opts) {
638
638
  function _capabilityLine(state) {
639
639
  var caps = ["IMAP4rev2"];
640
640
  if (!state.tls) caps.push("STARTTLS");
641
+ // RFC 7162 §3 — CONDSTORE is server-advertised; clients ENABLE
642
+ // before relying on MODSEQ in untagged FETCH responses.
643
+ caps.push("CONDSTORE");
641
644
  // Advertise AUTH=<mech> ONLY for mechanisms the operator wired
642
645
  // in opts.auth.mechanisms. RFC 9051 §7.2 — clients pick from the
643
646
  // advertised list; advertising AUTH=PLAIN when authConfig is null
@@ -653,6 +656,31 @@ function create(opts) {
653
656
  return caps.join(" ");
654
657
  }
655
658
 
659
+ // RFC 7162 §3.1 — ENABLE CONDSTORE flips the per-state flag that
660
+ // makes subsequent untagged FETCH responses include the MODSEQ
661
+ // attribute and lets STORE / FETCH carry CHANGEDSINCE /
662
+ // UNCHANGEDSINCE modifiers. Unknown ENABLE arguments are silently
663
+ // ignored per RFC 5161 §3.1 — the server lists in `ENABLED <name>`
664
+ // only the extensions it actually turned on.
665
+ function _handleEnable(state, socket, tag, args) {
666
+ var requested = (args || "").split(/\s+/).filter(Boolean);
667
+ var enabled = [];
668
+ for (var i = 0; i < requested.length; i += 1) {
669
+ var name = requested[i].toUpperCase();
670
+ if (name === "CONDSTORE") {
671
+ if (!state.enabledCondStore) {
672
+ state.enabledCondStore = true;
673
+ enabled.push("CONDSTORE");
674
+ }
675
+ }
676
+ // QRESYNC (RFC 7162 §3.2.5) implies CONDSTORE — accepted only
677
+ // when the operator backend supplies the QRESYNC vanished /
678
+ // expunged-set surface; v1 of the listener stops at CONDSTORE.
679
+ }
680
+ _writeUntagged(socket, "ENABLED" + (enabled.length ? " " + enabled.join(" ") : ""));
681
+ _writeTagged(socket, tag, "OK ENABLE completed");
682
+ }
683
+
656
684
  function _handleCapability(state, socket, tag) {
657
685
  _writeUntagged(socket, "CAPABILITY " + _capabilityLine(state));
658
686
  _writeTagged(socket, tag, "OK CAPABILITY completed");
@@ -1162,23 +1190,57 @@ function create(opts) {
1162
1190
  }
1163
1191
  var seqSet = match[1];
1164
1192
  var partsSpec = match[2];
1193
+ // RFC 7162 §3.1.4 — FETCH may carry a CHANGEDSINCE modifier in a
1194
+ // trailing parenthesised list:
1195
+ // FETCH 1:* (FLAGS) (CHANGEDSINCE 12345)
1196
+ // and/or VANISHED (QRESYNC) which is deferred to a later slice.
1197
+ // The modifier list is parsed off the END of partsSpec; what
1198
+ // remains is handed to the backend as the fetch-att spec.
1199
+ var changedSince = null;
1200
+ var includeVanished = false;
1201
+ var modMatch = partsSpec.match(/\s*\(([^)]*)\)\s*$/); // allow:regex-no-length-cap — partsSpec already bounded upstream
1202
+ if (modMatch && /\b(CHANGEDSINCE|VANISHED)\b/i.test(modMatch[1])) {
1203
+ var modBody = modMatch[1];
1204
+ var changedMatch = modBody.match(/CHANGEDSINCE\s+(\d+)/i); // allow:regex-no-length-cap — modBody already bounded
1205
+ if (changedMatch) {
1206
+ var csN = parseInt(changedMatch[1], 10);
1207
+ if (isFinite(csN) && csN >= 0) changedSince = csN;
1208
+ }
1209
+ includeVanished = /\bVANISHED\b/i.test(modBody);
1210
+ partsSpec = partsSpec.slice(0, partsSpec.length - modMatch[0].length).trim();
1211
+ }
1212
+ // RFC 7162 §3.1.2 — any FETCH that uses CHANGEDSINCE implicitly
1213
+ // engages CONDSTORE for the session; the client expects MODSEQ
1214
+ // in responses even without a prior `ENABLE CONDSTORE`. RFC 7162
1215
+ // §3.1.4.1 — when CONDSTORE is engaged (explicit ENABLE OR
1216
+ // implicit via CHANGEDSINCE) OR the client requested MODSEQ as a
1217
+ // fetch-att, every untagged FETCH response includes the MODSEQ
1218
+ // attribute. Engaging CONDSTORE via CHANGEDSINCE also sticks for
1219
+ // the rest of the session.
1220
+ if (changedSince !== null && !state.enabledCondStore) {
1221
+ state.enabledCondStore = true;
1222
+ }
1223
+ var includeModseq = state.enabledCondStore === true ||
1224
+ changedSince !== null ||
1225
+ /\bMODSEQ\b/i.test(partsSpec);
1165
1226
  Promise.resolve()
1166
1227
  .then(function () {
1167
- // useUid: true tells the backend to interpret seqSet as UIDs
1168
- // per RFC 9051 §6.4.9 — distinct from message-sequence-numbers
1169
- // under the SELECT context. UID FETCH responses MUST include
1170
- // the UID in the parts list per §6.4.9 ("the server SHOULD also
1171
- // include UID information in its response").
1172
1228
  return mailStore.fetchRange(state.actor, state.selectedMailbox, seqSet, partsSpec,
1173
- { useUid: useUid === true });
1229
+ { useUid: useUid === true, changedSince: changedSince, includeVanished: includeVanished,
1230
+ includeModseq: includeModseq });
1174
1231
  })
1175
1232
  .then(function (rows) {
1176
1233
  var rs = rows || [];
1177
1234
  _emit("mail.server.imap.fetch_bulk",
1178
- { connectionId: state.id, mailbox: state.selectedMailbox, count: rs.length });
1235
+ { connectionId: state.id, mailbox: state.selectedMailbox, count: rs.length,
1236
+ changedSince: changedSince, condStore: state.enabledCondStore === true });
1179
1237
  for (var i = 0; i < rs.length; i += 1) {
1180
1238
  var r = rs[i];
1181
- _writeUntagged(socket, r.seq + " FETCH (" + (r.payload || "") + ")");
1239
+ var payload = r.payload || "";
1240
+ if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(payload)) {
1241
+ payload = (payload ? payload + " " : "") + "MODSEQ (" + r.modseq + ")";
1242
+ }
1243
+ _writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
1182
1244
  }
1183
1245
  _writeTagged(socket, tag, "OK FETCH completed");
1184
1246
  })
@@ -1204,6 +1266,20 @@ function create(opts) {
1204
1266
  _writeTagged(socket, tag, "BAD STORE backend not configured");
1205
1267
  return;
1206
1268
  }
1269
+ // RFC 7162 §3.1.3 — STORE may carry a parenthesised UNCHANGEDSINCE
1270
+ // modifier between the sequence-set and the FLAGS op:
1271
+ // STORE 1:* (UNCHANGEDSINCE 12345) +FLAGS (\Deleted)
1272
+ // The backend's response shape is { rows, modified } — `modified`
1273
+ // is the seq-set string of message ids whose modseq advanced past
1274
+ // unchangedSince before this STORE ran. We surface those via
1275
+ // [MODIFIED <set>] OK response (RFC 7162 §3.1.3).
1276
+ var unchangedSince = null;
1277
+ var unchangedMatch = args.match(/^(\S+)\s+\(UNCHANGEDSINCE\s+(\d+)\)\s+(.+)$/i); // allow:regex-no-length-cap — args length already capped upstream
1278
+ if (unchangedMatch) {
1279
+ var usN = parseInt(unchangedMatch[2], 10);
1280
+ if (isFinite(usN) && usN >= 0) unchangedSince = usN;
1281
+ args = unchangedMatch[1] + " " + unchangedMatch[3];
1282
+ }
1207
1283
  var match = args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)$/i); // allow:regex-no-length-cap — args length already capped upstream
1208
1284
  if (!match) {
1209
1285
  _writeTagged(socket, tag, "BAD STORE expects seq-set FLAGS (...)");
@@ -1214,20 +1290,61 @@ function create(opts) {
1214
1290
  var flagsArr = match[3].split(/\s+/).filter(Boolean);
1215
1291
  var silent = /\.SILENT$/i.test(op);
1216
1292
  var mode = op[0] === "+" ? "add" : op[0] === "-" ? "remove" : "replace";
1293
+ // RFC 7162 §3.1.2 — UNCHANGEDSINCE in STORE engages CONDSTORE for
1294
+ // the session (same implicit-enable rule as FETCH CHANGEDSINCE).
1295
+ if (unchangedSince !== null && !state.enabledCondStore) {
1296
+ state.enabledCondStore = true;
1297
+ }
1298
+ var includeModseqStore = state.enabledCondStore === true || unchangedSince !== null;
1217
1299
  Promise.resolve()
1218
1300
  .then(function () {
1219
1301
  return mailStore.storeFlags(state.actor, state.selectedMailbox, seqSet, mode, flagsArr,
1220
- { useUid: useUid === true });
1302
+ { useUid: useUid === true, unchangedSince: unchangedSince, includeModseq: includeModseqStore });
1221
1303
  })
1222
- .then(function (rows) {
1223
- if (!silent) {
1224
- var rs = rows || [];
1304
+ .then(function (result) {
1305
+ // Backend may return either an array of rows (legacy shape)
1306
+ // OR an object `{ rows, modified }`. Normalise.
1307
+ var rs, modifiedSet;
1308
+ if (Array.isArray(result)) { rs = result; modifiedSet = null; }
1309
+ else if (result && typeof result === "object") {
1310
+ rs = result.rows || [];
1311
+ modifiedSet = result.modified || null;
1312
+ } else { rs = []; modifiedSet = null; }
1313
+ // RFC 7162 §3.1.3 — under CONDSTORE / UNCHANGEDSINCE, the
1314
+ // server MUST emit a FETCH response carrying the new MODSEQ
1315
+ // for every successfully-updated message EVEN UNDER .SILENT.
1316
+ // Without it, CONDSTORE clients cannot refresh their local
1317
+ // modseq state and drift out of sync. Under non-CONDSTORE
1318
+ // .SILENT, the legacy behaviour stays (no untagged FETCH).
1319
+ var emitFlags = !silent;
1320
+ var emitModseqOnly = silent && includeModseqStore;
1321
+ if (emitFlags || emitModseqOnly) {
1225
1322
  for (var i = 0; i < rs.length; i += 1) {
1226
1323
  var r = rs[i];
1227
- _writeUntagged(socket, r.seq + " FETCH (FLAGS (" + (r.flags || []).join(" ") + "))");
1324
+ var payload;
1325
+ if (emitFlags) {
1326
+ payload = "FLAGS (" + (r.flags || []).join(" ") + ")";
1327
+ if (includeModseqStore && r.modseq !== undefined) {
1328
+ payload = payload + " MODSEQ (" + r.modseq + ")";
1329
+ }
1330
+ } else if (r.modseq !== undefined) {
1331
+ // SILENT + CONDSTORE — emit MODSEQ alone (no FLAGS).
1332
+ payload = "MODSEQ (" + r.modseq + ")";
1333
+ } else {
1334
+ continue;
1335
+ }
1336
+ _writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
1228
1337
  }
1229
1338
  }
1230
- _writeTagged(socket, tag, "OK STORE completed");
1339
+ var okTag = "OK STORE completed";
1340
+ // RFC 7162 §3.1.3 — MODIFIED carries the set of ids the
1341
+ // conditional STORE refused to update because their modseq
1342
+ // advanced past unchangedSince. Clients re-issue FETCH against
1343
+ // the set to refresh state before retry.
1344
+ if (modifiedSet && String(modifiedSet).length > 0) {
1345
+ okTag = "OK [MODIFIED " + modifiedSet + "] STORE completed";
1346
+ }
1347
+ _writeTagged(socket, tag, okTag);
1231
1348
  })
1232
1349
  .catch(function (err) {
1233
1350
  _writeTagged(socket, tag, "NO " + ((err && err.message) || "Store failed").slice(0, ERR_CLAMP));
@@ -360,7 +360,11 @@ function create(opts) {
360
360
  lastDataByteTime: 0,
361
361
  };
362
362
 
363
- var lineBuffer = "";
363
+ // Raw byte buffer (NOT a string) — DATA bodies under 8BITMIME may
364
+ // carry bytes that are invalid UTF-8; round-tripping through a
365
+ // string decode would replace them with U+FFFD and corrupt the
366
+ // message. Decode to string only for the per-command line parse.
367
+ var lineBuffer = Buffer.alloc(0);
364
368
  var bodyCollector = null;
365
369
  var inDataBody = false;
366
370
 
@@ -447,8 +451,8 @@ function create(opts) {
447
451
  return;
448
452
  }
449
453
 
450
- // Command phase — line-buffered.
451
- lineBuffer += chunk.toString("utf8");
454
+ // Command phase — byte-buffered (8BITMIME-safe).
455
+ lineBuffer = lineBuffer.length === 0 ? chunk : Buffer.concat([lineBuffer, chunk]);
452
456
  if (lineBuffer.length > maxLineBytes * 4) {
453
457
  _writeReply(socket, REPLY_500_SYNTAX,
454
458
  "5.5.6 Line too long (>" + maxLineBytes + " bytes)");
@@ -456,9 +460,10 @@ function create(opts) {
456
460
  return;
457
461
  }
458
462
  var crlf;
459
- while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
460
- var line = lineBuffer.slice(0, crlf);
461
- lineBuffer = lineBuffer.slice(crlf + 2);
463
+ var crlfNeedle = Buffer.from("\r\n", "ascii");
464
+ while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
465
+ var line = lineBuffer.subarray(0, crlf).toString("utf8");
466
+ lineBuffer = lineBuffer.subarray(crlf + 2);
462
467
  _handleCommand(state, socket, line);
463
468
  if (inDataBody) return;
464
469
  }
@@ -584,7 +589,7 @@ function create(opts) {
584
589
  // (RFC 2920) pre-handshake cannot reach the post-TLS state
585
590
  // machine. Listener-removal + idle-timeout re-arm live in the
586
591
  // shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
587
- lineBuffer = "";
592
+ lineBuffer = Buffer.alloc(0);
588
593
  bodyCollector = null;
589
594
  inDataBody = false;
590
595
  mailServerTls.upgradeSocket({
@@ -428,9 +428,27 @@ function create(opts) {
428
428
  authPending: null,
429
429
  };
430
430
 
431
- var lineBuffer = "";
431
+ // RAW byte buffer — NOT a string. The BDAT-CHUNKING path (RFC 3030)
432
+ // requires lossless byte preservation when the BDAT command line +
433
+ // payload arrive in the same TCP segment, and DATA-body 8BITMIME
434
+ // payloads can contain bytes that are invalid UTF-8. Decoding the
435
+ // socket-bytes through a string layer replaces invalid sequences
436
+ // with U+FFFD and corrupts the body. Keep the raw bytes; decode to
437
+ // string only for the per-command parse.
438
+ var lineBuffer = Buffer.alloc(0);
432
439
  var bodyCollector = null;
433
440
  var inDataBody = false;
441
+ // RFC 3030 CHUNKING — state for the BDAT command. `bdatCollector`
442
+ // accumulates the message body across multiple BDAT chunks; it lives
443
+ // for the lifetime of the SMTP transaction (i.e., between MAIL FROM
444
+ // and the BDAT ... LAST that finalises). `bdatRemaining` counts down
445
+ // bytes still owed by the current BDAT chunk; `bdatIsLast` flags
446
+ // whether the current chunk is the terminator.
447
+ var inBdatChunk = false;
448
+ var bdatRemaining = 0;
449
+ var bdatIsLast = false;
450
+ var bdatCollector = null;
451
+ var bdatTotalBytes = 0;
434
452
 
435
453
  socket.setTimeout(idleTimeoutMs);
436
454
  socket.on("timeout", function () {
@@ -465,6 +483,57 @@ function create(opts) {
465
483
  });
466
484
 
467
485
  function _ingestBytes(state, socket, chunk) {
486
+ // RFC 3030 — when a BDAT chunk is in progress we consume exactly
487
+ // `bdatRemaining` bytes off the wire, no dot-stuffing, no end-of-
488
+ // data marker. Any excess bytes in the chunk after the BDAT
489
+ // payload completes get fed back through the command line buffer
490
+ // (typical when a pipelined `BDAT N LAST\r\n<payload>\r\nNOOP\r\n`
491
+ // arrives in a single TCP segment).
492
+ if (inBdatChunk) {
493
+ var consumeN = Math.min(chunk.length, bdatRemaining);
494
+ var consumed = chunk.subarray(0, consumeN);
495
+ try { bdatCollector.push(consumed); }
496
+ catch (_e) {
497
+ _emit("mail.server.submission.bdat_refused",
498
+ { connectionId: state.id, reason: "body-too-large", maxBytes: maxMessageBytes },
499
+ "denied");
500
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
501
+ "5.3.4 BDAT body exceeds maxMessageBytes (" + maxMessageBytes + " bytes)");
502
+ _resetTransaction(state);
503
+ inBdatChunk = false; bdatCollector = null; bdatRemaining = 0; bdatTotalBytes = 0;
504
+ return;
505
+ }
506
+ bdatRemaining -= consumeN;
507
+ bdatTotalBytes += consumeN;
508
+ if (bdatRemaining === 0) {
509
+ var wasLast = bdatIsLast;
510
+ inBdatChunk = false;
511
+ if (wasLast) {
512
+ // RFC 3030 §2.2 — ONE reply per BDAT command. When LAST,
513
+ // the single reply is the "message queued" finalize reply
514
+ // (emitted from _finalizeAcceptedBody), not the per-chunk
515
+ // "<N> octets received" reply. Emitting both would
516
+ // desynchronise the client (the second 250 would be
517
+ // consumed as the response to the next command).
518
+ // No dot-unstuff for BDAT — RFC 3030 §3 explicitly defines
519
+ // BDAT payloads as opaque byte streams.
520
+ var bdatBody = bdatCollector.result();
521
+ bdatCollector = null;
522
+ bdatTotalBytes = 0;
523
+ _finalizeAcceptedBody(state, socket, bdatBody, "BDAT");
524
+ } else {
525
+ // Non-final chunk — per-chunk acknowledgement only.
526
+ _writeReply(socket, REPLY_250_OK,
527
+ "2.0.0 " + bdatTotalBytes + " octets received");
528
+ }
529
+ // Any tail bytes after this BDAT chunk get re-fed as commands.
530
+ if (consumeN < chunk.length) {
531
+ var tail = chunk.subarray(consumeN);
532
+ _ingestBytes(state, socket, tail);
533
+ }
534
+ }
535
+ return;
536
+ }
468
537
  if (inDataBody) {
469
538
  try { bodyCollector.push(chunk); }
470
539
  catch (_e) {
@@ -491,13 +560,15 @@ function create(opts) {
491
560
  var endIdx = safeSmtp.findDotTerminator(collected);
492
561
  if (endIdx !== -1) {
493
562
  var body = collected.subarray(0, endIdx);
494
- _finalizeDataBody(state, socket, body);
563
+ // DATA path dot-unstuffs here; BDAT path skips this step.
564
+ var dedotted = safeSmtp.dotUnstuff(body);
565
+ _finalizeAcceptedBody(state, socket, dedotted, "DATA");
495
566
  inDataBody = false; bodyCollector = null;
496
567
  }
497
568
  return;
498
569
  }
499
570
 
500
- lineBuffer += chunk.toString("utf8");
571
+ lineBuffer = lineBuffer.length === 0 ? chunk : Buffer.concat([lineBuffer, chunk]);
501
572
  if (lineBuffer.length > maxLineBytes * 4) {
502
573
  _writeReply(socket, REPLY_500_SYNTAX,
503
574
  "5.5.6 Line too long (>" + maxLineBytes + " bytes)");
@@ -505,11 +576,29 @@ function create(opts) {
505
576
  return;
506
577
  }
507
578
  var crlf;
508
- while ((crlf = lineBuffer.indexOf("\r\n")) !== -1) {
509
- var line = lineBuffer.slice(0, crlf);
510
- lineBuffer = lineBuffer.slice(crlf + 2);
579
+ var crlfNeedle = Buffer.from("\r\n", "ascii");
580
+ while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
581
+ // Decode just the per-command line to a string — keeps the
582
+ // wire-protocol parser working in UTF-8 while leaving the
583
+ // RAW lineBuffer intact for any binary payload that follows.
584
+ var line = lineBuffer.subarray(0, crlf).toString("utf8");
585
+ lineBuffer = lineBuffer.subarray(crlf + 2);
511
586
  _handleCommand(state, socket, line);
512
587
  if (inDataBody) return;
588
+ if (inBdatChunk) {
589
+ // RFC 3030 — `BDAT <N> [LAST]\r\n` is immediately followed by
590
+ // exactly <N> raw bytes (no dot-stuffing, no terminator). When
591
+ // those bytes arrived in the SAME TCP segment as the BDAT
592
+ // command, drain them straight from the raw byte buffer
593
+ // (NOT through a UTF-8 string round-trip — would corrupt
594
+ // 8-bit / binary payloads).
595
+ if (lineBuffer.length > 0) {
596
+ var pendingBytes = lineBuffer;
597
+ lineBuffer = Buffer.alloc(0);
598
+ _ingestBytes(state, socket, pendingBytes);
599
+ }
600
+ return;
601
+ }
513
602
  }
514
603
  }
515
604
 
@@ -555,6 +644,8 @@ function create(opts) {
555
644
  return _handleRcptTo(state, socket, line);
556
645
  case "DATA":
557
646
  return _handleData(state, socket);
647
+ case "BDAT":
648
+ return _handleBdat(state, socket, line);
558
649
  case "NOOP":
559
650
  return _writeReply(socket, REPLY_250_OK, "2.0.0 OK");
560
651
  case "RSET":
@@ -592,7 +683,7 @@ function create(opts) {
592
683
  state.helo = helo;
593
684
  state.stage = "ehlo";
594
685
  if (verb === "EHLO") {
595
- var caps = ["PIPELINING", "SIZE " + maxMessageBytes, "8BITMIME", "ENHANCEDSTATUSCODES"];
686
+ var caps = ["PIPELINING", "SIZE " + maxMessageBytes, "8BITMIME", "ENHANCEDSTATUSCODES", "CHUNKING"];
596
687
  // STARTTLS advertised only on explicit-STARTTLS port (587),
597
688
  // not on implicit-TLS (465 already wrapped). RFC 8314 §3.3.
598
689
  if (!state.tls && !implicitTls) caps.unshift("STARTTLS");
@@ -627,7 +718,12 @@ function create(opts) {
627
718
  // body collector AND strip the plain-socket "data" listener
628
719
  // before wrapping in TLSSocket so bytes the peer pipelined
629
720
  // pre-handshake cannot reach the post-TLS state machine.
630
- lineBuffer = ""; bodyCollector = null; inDataBody = false;
721
+ lineBuffer = Buffer.alloc(0); bodyCollector = null; inDataBody = false;
722
+ // BDAT-side state cleared on STARTTLS upgrade too — same threat
723
+ // model as CVE-2021-38371 (Exim) / CVE-2021-33515 (Dovecot):
724
+ // pre-handshake bytes the peer pipelined MUST NOT reach the
725
+ // post-TLS state machine via the BDAT collector either.
726
+ inBdatChunk = false; bdatRemaining = 0; bdatCollector = null; bdatTotalBytes = 0;
631
727
  mailServerTls.upgradeSocket({
632
728
  plainSocket: socket,
633
729
  secureContext: opts.tlsContext,
@@ -1033,8 +1129,7 @@ function create(opts) {
1033
1129
  });
1034
1130
  }
1035
1131
 
1036
- function _finalizeDataBody(state, socket, body) {
1037
- var dedotted = safeSmtp.dotUnstuff(body);
1132
+ function _finalizeAcceptedBody(state, socket, dedotted, source) {
1038
1133
 
1039
1134
  // Outbound DKIM-required gate. Scan the header block for a
1040
1135
  // `DKIM-Signature:` line; under `self` mode also require at
@@ -1108,16 +1203,109 @@ function create(opts) {
1108
1203
  }
1109
1204
  _emit("mail.server.submission.data_accepted",
1110
1205
  { connectionId: state.id, mailFrom: state.mailFrom,
1111
- rcptCount: state.rcpts.length, sizeBytes: dedotted.length });
1206
+ rcptCount: state.rcpts.length, sizeBytes: dedotted.length, source: source || "DATA" });
1112
1207
  _writeReply(socket, REPLY_250_OK, "2.6.0 Message queued (audit-only)");
1113
1208
  _resetTransaction(state);
1114
1209
  }
1115
1210
 
1211
+ // RFC 3030 §2 — BDAT <chunk-size> [LAST]. Reads exactly chunk-size
1212
+ // bytes off the wire (no dot-stuffing, no end-of-data marker). The
1213
+ // size is a non-negative integer; LAST keyword (case-insensitive)
1214
+ // terminates the message body. Mixing DATA + BDAT within the same
1215
+ // transaction is forbidden — the server returns 503 once the first
1216
+ // BDAT lands and forces the client to RSET.
1217
+ function _handleBdat(state, socket, line) {
1218
+ if (state.stage !== "rcpt" && state.stage !== "bdat") {
1219
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 BDAT requires MAIL FROM + RCPT TO");
1220
+ return;
1221
+ }
1222
+ if (state.rcpts.length === 0) {
1223
+ _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 No valid recipients");
1224
+ return;
1225
+ }
1226
+ // Pipelining race — same gate as DATA.
1227
+ if ((state.rcptsPending || 0) > 0) {
1228
+ _emit("mail.server.submission.pipelining_bdat_race", {
1229
+ connectionId: state.id, rcptsPending: state.rcptsPending,
1230
+ rcptsCommitted: state.rcpts.length,
1231
+ }, "denied");
1232
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
1233
+ "4.5.0 RCPT TO verdicts pending; reissue BDAT after recipient replies");
1234
+ return;
1235
+ }
1236
+ // Parse `BDAT <size>[ LAST]`.
1237
+ var parts = line.split(/\s+/);
1238
+ if (parts.length < 2 || parts.length > 3) {
1239
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 BDAT requires <chunk-size> [LAST]");
1240
+ return;
1241
+ }
1242
+ var sizeStr = parts[1];
1243
+ var sizeN = parseInt(sizeStr, 10);
1244
+ if (!/^\d+$/.test(sizeStr) || !isFinite(sizeN) || sizeN < 0) {
1245
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 BDAT chunk-size must be a non-negative integer");
1246
+ return;
1247
+ }
1248
+ var isLast = parts.length === 3 && parts[2].toUpperCase() === "LAST";
1249
+ if (parts.length === 3 && !isLast) {
1250
+ _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 BDAT third arg must be 'LAST' (RFC 3030 §2)");
1251
+ return;
1252
+ }
1253
+ // Cumulative-size cap. The collector is bounded too, but checking
1254
+ // up-front lets us refuse the chunk before reading bytes off the
1255
+ // socket — important when sizeN >> maxMessageBytes.
1256
+ if (bdatTotalBytes + sizeN > maxMessageBytes) {
1257
+ _emit("mail.server.submission.bdat_refused",
1258
+ { connectionId: state.id, reason: "body-too-large",
1259
+ requestedTotal: bdatTotalBytes + sizeN, maxBytes: maxMessageBytes }, "denied");
1260
+ _writeReply(socket, REPLY_552_SIZE_EXCEEDED,
1261
+ "5.3.4 BDAT cumulative size " + (bdatTotalBytes + sizeN) +
1262
+ " exceeds maxMessageBytes (" + maxMessageBytes + ")");
1263
+ _resetTransaction(state);
1264
+ bdatCollector = null; bdatTotalBytes = 0;
1265
+ return;
1266
+ }
1267
+ if (!bdatCollector) {
1268
+ bdatCollector = safeBuffer.boundedChunkCollector({
1269
+ maxBytes: maxMessageBytes,
1270
+ errorClass: MailServerSubmissionError,
1271
+ sizeCode: "mail-server-submission/body-too-large",
1272
+ sizeMessage: "BDAT body exceeded maxMessageBytes (" + maxMessageBytes + ")",
1273
+ });
1274
+ }
1275
+ state.stage = "bdat";
1276
+ bdatRemaining = sizeN;
1277
+ bdatIsLast = isLast;
1278
+ // size=0 + LAST is a valid sequence — finalises the message
1279
+ // body (the LAST chunk may carry zero bytes when the prior chunk
1280
+ // was the final payload). RFC 3030 §2.2 — ONE reply per command:
1281
+ // emit the "0 octets" ack for size=0 NOT-LAST, but defer to
1282
+ // _finalizeAcceptedBody for size=0 LAST.
1283
+ if (sizeN === 0) {
1284
+ if (isLast) {
1285
+ var emptyBody = bdatCollector ? bdatCollector.result() : Buffer.alloc(0);
1286
+ bdatCollector = null; bdatTotalBytes = 0;
1287
+ _finalizeAcceptedBody(state, socket, emptyBody, "BDAT");
1288
+ } else {
1289
+ _writeReply(socket, REPLY_250_OK, "2.0.0 0 octets received");
1290
+ }
1291
+ return;
1292
+ }
1293
+ inBdatChunk = true;
1294
+ }
1295
+
1116
1296
  function _resetTransaction(state) {
1117
1297
  state.mailFrom = null;
1118
1298
  state.rcpts = [];
1119
1299
  state.rcptsPending = 0;
1120
1300
  state.stage = "ehlo";
1301
+ // BDAT-side state lives at the connection level, not on `state`.
1302
+ // Reset it here so a RSET / failed BDAT can't leak collected
1303
+ // bytes into the next transaction.
1304
+ inBdatChunk = false;
1305
+ bdatRemaining = 0;
1306
+ bdatIsLast = false;
1307
+ bdatCollector = null;
1308
+ bdatTotalBytes = 0;
1121
1309
  }
1122
1310
  }
1123
1311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.25",
3
+ "version": "0.11.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:6b814cb5-e99f-4bcb-a349-6b597ea780a6",
5
+ "serialNumber": "urn:uuid:15b60ad9-cf33-4ad4-a874-947682ac8125",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-21T05:38:05.971Z",
8
+ "timestamp": "2026-05-21T13:46:21.069Z",
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.25",
22
+ "bom-ref": "@blamejs/core@0.11.27",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.25",
25
+ "version": "0.11.27",
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.25",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.27",
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.25",
57
+ "ref": "@blamejs/core@0.11.27",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]