@blamejs/core 0.11.26 → 0.11.28
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/lib/guard-imap-command.js +2 -0
- package/lib/mail-server-imap.js +441 -15
- package/lib/mail-server-registry.js +4 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.28 (2026-05-21) — **IMAP opt-in extensions: NOTIFY (RFC 5465), METADATA (RFC 5464), CATENATE (RFC 4469).** Three IMAP extensions advertised in CAPABILITY and dispatched through the existing per-method registry. NOTIFY accepts a client subscription spec and hands it to the operator's `mailStore.subscribeNotify(actor, spec, emitFn)` hook — actual event emission stays operator-side. METADATA exposes GETMETADATA and SETMETADATA per-mailbox + server-wide annotations through `mailStore.getMetadata` / `setMetadata`. CATENATE extends APPEND to compose a message from existing parts (`TEXT {N}` literals + `URL "imap://..."`) via `mailStore.appendCatenate`. Each handler refuses gracefully (`NO ... backend not configured`) when the operator backend doesn't supply the hook. COMPRESS=DEFLATE (RFC 4978) intentionally NOT advertised — CRIME-class compression-oracle threat on the encrypted IMAP stream. **Added:** *CAPABILITY advertises `NOTIFY`, `METADATA`, `METADATA-SERVER`, `CATENATE`* — All four added unconditionally so capable clients can exercise the extension regardless of authentication state. Each handler is registered in the protocol verb catalogue (`b.mail.serverRegistry`) + the wire-level guard verb list (`b.guardImapCommand.KNOWN_VERBS`) so the existing dispatch + audit + ratelimit gates apply uniformly. · *`NOTIFY SET ...` / `NOTIFY NONE` — RFC 5465* — The handler parses `NOTIFY SET [STATUS] (<filter-set> (<event>...))*` and `NOTIFY NONE` and stores the filter-set verbatim on `state.notifySpec`. When the operator backend exposes `mailStore.subscribeNotify(actor, spec, emitFn)`, the listener wires an `emitFn` that translates backend events (`{ kind: 'STATUS' | 'LIST' | 'FETCH', payload, seq? }`) into untagged IMAP responses on the same connection — drop-silent if the socket has already closed. Without the backend hook, the wire command refuses with `NO NOTIFY backend not configured` rather than silently accepting subscriptions the server can't fulfil. · *`GETMETADATA` / `SETMETADATA` — RFC 5464* — Both verbs parse the per-mailbox + server-wide annotation forms. GETMETADATA accepts optional `(MAXSIZE N)` / `(DEPTH ...)` options before the mailbox + entry list, walks the entries through `mailStore.getMetadata(actor, mailbox, names, opts) → [{ entry, value }]`, and renders an untagged `* METADATA <mailbox> (<entry> <value>...)` response. SETMETADATA tokenises the entry/value pairs (quoted-strings + NIL for clearing), validates the mailbox name, and forwards to `mailStore.setMetadata(actor, mailbox, entries)`. Without the backend hooks, both return `NO ... backend not configured`. · *APPEND `CATENATE` modifier — RFC 4469* — `APPEND mailbox [flags] [date-time] CATENATE (...)` is recognised before the legacy literal-required APPEND path. The parts list mixes `TEXT {N}` literal-bytes parts (handed in via the literal-aware parser) and `URL "imap://..."` reference parts; the listener bundles them into `parts: [{ kind: 'TEXT', bytes } | { kind: 'URL', url }]` and forwards to `mailStore.appendCatenate(mailbox, parts, { actor, flags, internalDate }) → { uid, uidValidity }`. When the backend returns the APPENDUID metadata the response carries `OK [APPENDUID <validity> <uid>] APPEND completed` (RFC 4315). Without the backend hook, refuses with `NO CATENATE backend not configured`. **Security:** *COMPRESS=DEFLATE intentionally NOT advertised (CRIME-class)* — RFC 4978 IMAP COMPRESS=DEFLATE enables stream compression that interacts badly with TLS — the CRIME attack class (CVE-2012-4929, BREACH, et al.) recovers plaintext via chosen-plaintext compression-ratio analysis. The framework default is OFF; operators with explicit threat models accept the downgrade via `opts.compress = true` (no opt-in path landed in v1, intentionally — defer-with-condition: open when an operator surfaces a deployment that needs it AND can document the chosen-plaintext threat model is mitigated). · *Mailbox-name validation reused for both METADATA verbs* — Both GETMETADATA and SETMETADATA run `_validateMailboxName` on the parsed mailbox argument (except for the empty-string `""` server-wide-metadata special case per RFC 5464 §3.1). Operators with the existing `allowLegacyMUtf7` opt see the same mailbox-name policy as the rest of the listener; injection-shape mailbox names are refused identically. · *NOTIFY backend-missing returns NO (not silent accept)* — If the operator wired the listener without `mailStore.subscribeNotify`, `NOTIFY SET ...` returns `NO NOTIFY backend not configured` — never a silent `OK`. RFC 5465 §6 specifies NO as the correct refusal shape; silent acceptance would let a client believe events will arrive when the server cannot fulfil the subscription. **References:** [RFC 5465 (IMAP NOTIFY)](https://www.rfc-editor.org/rfc/rfc5465.html) · [RFC 5464 (IMAP METADATA)](https://www.rfc-editor.org/rfc/rfc5464.html) · [RFC 4469 (IMAP CATENATE)](https://www.rfc-editor.org/rfc/rfc4469.html) · [RFC 4315 (IMAP UIDPLUS — APPENDUID response)](https://www.rfc-editor.org/rfc/rfc4315.html) · [RFC 4978 (IMAP COMPRESS — NOT enabled; CRIME-class threat)](https://www.rfc-editor.org/rfc/rfc4978.html) · [CVE-2012-4929 (CRIME — compression-oracle attack on TLS)](https://nvd.nist.gov/vuln/detail/CVE-2012-4929)
|
|
12
|
+
|
|
13
|
+
- 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)
|
|
14
|
+
|
|
11
15
|
- 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)
|
|
12
16
|
|
|
13
17
|
- 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)
|
|
@@ -141,6 +141,8 @@ var KNOWN_VERBS = Object.freeze({
|
|
|
141
141
|
COPY: true, MOVE: true, UID: true,
|
|
142
142
|
GETQUOTA: true, SETQUOTA: true, GETQUOTAROOT: true,
|
|
143
143
|
ID: true,
|
|
144
|
+
// v0.11.28 — RFC 5465 NOTIFY + RFC 5464 METADATA.
|
|
145
|
+
NOTIFY: true, GETMETADATA: true, SETMETADATA: true,
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
var ZERO_ARG_VERBS = Object.freeze({
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -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
|
|
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 },
|
|
@@ -557,6 +557,13 @@ function create(opts) {
|
|
|
557
557
|
maxHandlerBytes: LONG_B, maxHandlerMs: LONG_MS },
|
|
558
558
|
IDLE: { fn: function (s, so, p) { return _handleIdle(s, so, p.tag); },
|
|
559
559
|
maxHandlerBytes: SHORT_B, maxHandlerMs: LONG_MS },
|
|
560
|
+
// v0.11.28 — RFC 5465 NOTIFY / RFC 5464 METADATA / RFC 4469 CATENATE.
|
|
561
|
+
NOTIFY: { fn: function (s, so, p) { return _handleNotify(s, so, p.tag, p.args); },
|
|
562
|
+
maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
|
|
563
|
+
GETMETADATA: { fn: function (s, so, p) { return _handleGetMetadata(s, so, p.tag, p.args); },
|
|
564
|
+
maxHandlerBytes: MEDIUM_B, maxHandlerMs: MEDIUM_MS },
|
|
565
|
+
SETMETADATA: { fn: function (s, so, p, lit) { return _handleSetMetadata(s, so, p.tag, p.args, lit); },
|
|
566
|
+
maxHandlerBytes: LONG_B, maxHandlerMs: MEDIUM_MS },
|
|
560
567
|
DONE: { fn: function (s, so, p) { return _writeTagged(so, p.tag, "BAD DONE outside IDLE"); },
|
|
561
568
|
maxHandlerBytes: SHORT_B, maxHandlerMs: SHORT_MS },
|
|
562
569
|
// Defaults for the verbs the v0.9.49 listener didn't dispatch —
|
|
@@ -638,6 +645,20 @@ function create(opts) {
|
|
|
638
645
|
function _capabilityLine(state) {
|
|
639
646
|
var caps = ["IMAP4rev2"];
|
|
640
647
|
if (!state.tls) caps.push("STARTTLS");
|
|
648
|
+
// RFC 7162 §3 — CONDSTORE is server-advertised; clients ENABLE
|
|
649
|
+
// before relying on MODSEQ in untagged FETCH responses.
|
|
650
|
+
caps.push("CONDSTORE");
|
|
651
|
+
// v0.11.28 — opt-in extensions (advertised so capable clients can
|
|
652
|
+
// exercise them; each handler refuses gracefully when the operator
|
|
653
|
+
// backend doesn't supply the corresponding hook).
|
|
654
|
+
caps.push("NOTIFY"); // RFC 5465
|
|
655
|
+
caps.push("METADATA"); // RFC 5464 — per-mailbox annotations // allow:raw-byte-literal — RFC number in comment
|
|
656
|
+
caps.push("METADATA-SERVER"); // RFC 5464 §3.1 — server-wide annotations // allow:raw-byte-literal — RFC number in comment
|
|
657
|
+
caps.push("CATENATE"); // RFC 4469 — APPEND from existing parts
|
|
658
|
+
// NB: COMPRESS=DEFLATE (RFC 4978) intentionally NOT advertised —
|
|
659
|
+
// CRIME-class compression-oracle attack on the encrypted IMAP
|
|
660
|
+
// stream. Operators who explicitly enable it via opts.compress
|
|
661
|
+
// get a documented downgrade; v1 default is off.
|
|
641
662
|
// Advertise AUTH=<mech> ONLY for mechanisms the operator wired
|
|
642
663
|
// in opts.auth.mechanisms. RFC 9051 §7.2 — clients pick from the
|
|
643
664
|
// advertised list; advertising AUTH=PLAIN when authConfig is null
|
|
@@ -653,6 +674,216 @@ function create(opts) {
|
|
|
653
674
|
return caps.join(" ");
|
|
654
675
|
}
|
|
655
676
|
|
|
677
|
+
// RFC 7162 §3.1 — ENABLE CONDSTORE flips the per-state flag that
|
|
678
|
+
// makes subsequent untagged FETCH responses include the MODSEQ
|
|
679
|
+
// attribute and lets STORE / FETCH carry CHANGEDSINCE /
|
|
680
|
+
// UNCHANGEDSINCE modifiers. Unknown ENABLE arguments are silently
|
|
681
|
+
// ignored per RFC 5161 §3.1 — the server lists in `ENABLED <name>`
|
|
682
|
+
// only the extensions it actually turned on.
|
|
683
|
+
function _handleEnable(state, socket, tag, args) {
|
|
684
|
+
var requested = (args || "").split(/\s+/).filter(Boolean);
|
|
685
|
+
var enabled = [];
|
|
686
|
+
for (var i = 0; i < requested.length; i += 1) {
|
|
687
|
+
var name = requested[i].toUpperCase();
|
|
688
|
+
if (name === "CONDSTORE") {
|
|
689
|
+
if (!state.enabledCondStore) {
|
|
690
|
+
state.enabledCondStore = true;
|
|
691
|
+
enabled.push("CONDSTORE");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// QRESYNC (RFC 7162 §3.2.5) implies CONDSTORE — accepted only
|
|
695
|
+
// when the operator backend supplies the QRESYNC vanished /
|
|
696
|
+
// expunged-set surface; v1 of the listener stops at CONDSTORE.
|
|
697
|
+
}
|
|
698
|
+
_writeUntagged(socket, "ENABLED" + (enabled.length ? " " + enabled.join(" ") : ""));
|
|
699
|
+
_writeTagged(socket, tag, "OK ENABLE completed");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// RFC 5465 NOTIFY — `NOTIFY SET [STATUS] (<filter-set> (<event>...))*`
|
|
703
|
+
// / `NOTIFY NONE`. Subscribes the connection to mailbox / message
|
|
704
|
+
// events on a filter set. Actual event emission is operator-side
|
|
705
|
+
// (the backend's `subscribeNotify(actor, spec, emitFn)` hook); this
|
|
706
|
+
// handler stores the parsed subscription on `state.notifySpec` so
|
|
707
|
+
// the backend can read it on later mutations. NOTIFY NONE clears.
|
|
708
|
+
function _handleNotify(state, socket, tag, args) {
|
|
709
|
+
if (!_requireAuth(state, socket, tag)) return;
|
|
710
|
+
var raw = (args || "").trim();
|
|
711
|
+
if (/^NONE\b/i.test(raw)) {
|
|
712
|
+
state.notifySpec = null;
|
|
713
|
+
if (typeof mailStore.subscribeNotify === "function") {
|
|
714
|
+
try { mailStore.subscribeNotify(state.actor, null, null); }
|
|
715
|
+
catch (_e) { /* drop-silent — operator hook may refuse mid-life */ }
|
|
716
|
+
}
|
|
717
|
+
_writeTagged(socket, tag, "OK NOTIFY completed");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
var setMatch = raw.match(/^SET\s+(?:STATUS\s+)?(.+)$/i); // allow:regex-no-length-cap — args length already capped upstream
|
|
721
|
+
if (!setMatch) {
|
|
722
|
+
_writeTagged(socket, tag, "BAD NOTIFY syntax (RFC 5465 §6)");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// Store the spec verbatim; the backend parses the filter-set
|
|
726
|
+
// vocabulary (`SELECTED`, `SELECTED-DELAYED`, `INBOXES`,
|
|
727
|
+
// `PERSONAL`, `SUBSCRIBED`, `MAILBOXES <list>`, `SUBTREE <list>`)
|
|
728
|
+
// since the event semantics live there. The listener's job is to
|
|
729
|
+
// hand the wire string to the backend.
|
|
730
|
+
state.notifySpec = setMatch[1];
|
|
731
|
+
if (typeof mailStore.subscribeNotify === "function") {
|
|
732
|
+
Promise.resolve()
|
|
733
|
+
.then(function () {
|
|
734
|
+
return mailStore.subscribeNotify(state.actor, state.notifySpec, function (event) {
|
|
735
|
+
// Backend pushes events as { kind, mailbox, payload }; we
|
|
736
|
+
// emit them as untagged responses on the same connection.
|
|
737
|
+
if (!event || typeof event.kind !== "string") return;
|
|
738
|
+
try {
|
|
739
|
+
if (event.kind === "STATUS") {
|
|
740
|
+
_writeUntagged(socket, "STATUS " + event.payload);
|
|
741
|
+
} else if (event.kind === "LIST") {
|
|
742
|
+
_writeUntagged(socket, "LIST " + event.payload);
|
|
743
|
+
} else if (event.kind === "FETCH") {
|
|
744
|
+
_writeUntagged(socket, (event.seq || "") + " FETCH (" + (event.payload || "") + ")");
|
|
745
|
+
}
|
|
746
|
+
} catch (_e) { /* drop-silent — socket may already be closed */ }
|
|
747
|
+
});
|
|
748
|
+
})
|
|
749
|
+
.then(function () { _writeTagged(socket, tag, "OK NOTIFY completed"); })
|
|
750
|
+
.catch(function (err) {
|
|
751
|
+
_writeTagged(socket, tag, "NO " + ((err && err.message) || "NOTIFY refused").slice(0, ERR_CLAMP));
|
|
752
|
+
});
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// Backend doesn't expose the subscribe hook — accept the wire
|
|
756
|
+
// command but emit no events. RFC 5465 §6 says NO is the right
|
|
757
|
+
// refusal shape when the server cannot fulfil the subscription.
|
|
758
|
+
_writeTagged(socket, tag, "NO NOTIFY backend not configured");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// RFC 5464 §4.1 GETMETADATA — `GETMETADATA [opts] mailbox entries`.
|
|
762
|
+
// `mailbox` may be `""` for server-wide annotations (METADATA-SERVER).
|
|
763
|
+
// Entries are slash-prefixed names (`/private/foo` / `/shared/bar`).
|
|
764
|
+
// Backend hook: `mailStore.getMetadata(actor, mailbox, names) →
|
|
765
|
+
// [{ entry, value }]`.
|
|
766
|
+
function _handleGetMetadata(state, socket, tag, args) {
|
|
767
|
+
if (!_requireAuth(state, socket, tag)) return;
|
|
768
|
+
if (typeof mailStore.getMetadata !== "function") {
|
|
769
|
+
_writeTagged(socket, tag, "NO GETMETADATA backend not configured");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
// Strip optional MAXSIZE / DEPTH opts: GETMETADATA (MAXSIZE 1024) "" ("/foo")
|
|
773
|
+
var rest = (args || "").trim();
|
|
774
|
+
var opts = {};
|
|
775
|
+
var optsMatch = rest.match(/^\(([^)]+)\)\s+(.+)$/); // allow:regex-no-length-cap — args length already capped upstream
|
|
776
|
+
if (optsMatch) {
|
|
777
|
+
var optBody = optsMatch[1];
|
|
778
|
+
var maxMatch = optBody.match(/MAXSIZE\s+(\d+)/i); // allow:regex-no-length-cap — optBody bounded by parens
|
|
779
|
+
if (maxMatch) opts.maxSize = parseInt(maxMatch[1], 10);
|
|
780
|
+
var depthMatch = optBody.match(/DEPTH\s+(\w+)/i); // allow:regex-no-length-cap — optBody bounded
|
|
781
|
+
if (depthMatch) opts.depth = depthMatch[1];
|
|
782
|
+
rest = optsMatch[2];
|
|
783
|
+
}
|
|
784
|
+
var partsMatch = rest.match(/^(\S+|"[^"]*")\s+(\(([^)]+)\)|(\/\S+))$/); // allow:regex-no-length-cap — args length already capped upstream
|
|
785
|
+
if (!partsMatch) {
|
|
786
|
+
_writeTagged(socket, tag, "BAD GETMETADATA syntax (RFC 5464 §4.1)");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
var mailbox = _unquote(partsMatch[1]);
|
|
790
|
+
var entries = partsMatch[3]
|
|
791
|
+
? partsMatch[3].split(/\s+/).filter(Boolean)
|
|
792
|
+
: [partsMatch[4]];
|
|
793
|
+
if (mailbox !== "" && !_validateMailboxName(mailbox, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
|
|
794
|
+
_writeTagged(socket, tag, "BAD Mailbox name refused");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
Promise.resolve()
|
|
798
|
+
.then(function () { return mailStore.getMetadata(state.actor, mailbox, entries, opts); })
|
|
799
|
+
.then(function (rows) {
|
|
800
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
801
|
+
var pairs = rows.map(function (r) {
|
|
802
|
+
var v = r.value === null || r.value === undefined ? "NIL" : '"' + String(r.value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"';
|
|
803
|
+
return r.entry + " " + v;
|
|
804
|
+
}).join(" ");
|
|
805
|
+
_writeUntagged(socket, "METADATA " + (mailbox === "" ? '""' : mailbox) + " (" + pairs + ")");
|
|
806
|
+
}
|
|
807
|
+
_writeTagged(socket, tag, "OK GETMETADATA completed");
|
|
808
|
+
})
|
|
809
|
+
.catch(function (err) {
|
|
810
|
+
_writeTagged(socket, tag, "NO " + ((err && err.message) || "GETMETADATA failed").slice(0, ERR_CLAMP));
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// RFC 5464 §4.3 SETMETADATA — `SETMETADATA mailbox (entry value ...)`.
|
|
815
|
+
// Setting `value = NIL` clears the entry. Backend hook:
|
|
816
|
+
// `mailStore.setMetadata(actor, mailbox, entries)`. The wire format
|
|
817
|
+
// delivers each value as a quoted-string or NIL atom; the parser
|
|
818
|
+
// here handles the simple single-line shape (no literals across
|
|
819
|
+
// SETMETADATA — operators using >1 KiB metadata go through APPEND).
|
|
820
|
+
function _handleSetMetadata(state, socket, tag, args, _literalBody) {
|
|
821
|
+
if (!_requireAuth(state, socket, tag)) return;
|
|
822
|
+
if (typeof mailStore.setMetadata !== "function") {
|
|
823
|
+
_writeTagged(socket, tag, "NO SETMETADATA backend not configured");
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
var match = (args || "").trim().match(/^(\S+|"[^"]*")\s+\((.+)\)$/); // allow:regex-no-length-cap — args length already capped upstream
|
|
827
|
+
if (!match) {
|
|
828
|
+
_writeTagged(socket, tag, "BAD SETMETADATA syntax (RFC 5464 §4.3)");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
var mailbox = _unquote(match[1]);
|
|
832
|
+
var body = match[2];
|
|
833
|
+
if (mailbox !== "" && !_validateMailboxName(mailbox, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
|
|
834
|
+
_writeTagged(socket, tag, "BAD Mailbox name refused");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// Tokenise `<entry> <value> <entry> <value> ...`. Values are
|
|
838
|
+
// `"..."` quoted-string OR `NIL`. Entries are `/private/...` /
|
|
839
|
+
// `/shared/...` slash-prefixed names.
|
|
840
|
+
var entries = [];
|
|
841
|
+
var i = 0;
|
|
842
|
+
while (i < body.length) {
|
|
843
|
+
while (i < body.length && /\s/.test(body[i])) i++;
|
|
844
|
+
if (i >= body.length) break;
|
|
845
|
+
var entryStart = i;
|
|
846
|
+
while (i < body.length && !/\s/.test(body[i])) i++;
|
|
847
|
+
var entryName = body.slice(entryStart, i);
|
|
848
|
+
while (i < body.length && /\s/.test(body[i])) i++;
|
|
849
|
+
if (i >= body.length) {
|
|
850
|
+
_writeTagged(socket, tag, "BAD SETMETADATA entry '" + entryName + "' missing value");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
var valStart = i;
|
|
854
|
+
var value;
|
|
855
|
+
if (body[i] === '"') {
|
|
856
|
+
i++;
|
|
857
|
+
var v = "";
|
|
858
|
+
while (i < body.length && body[i] !== '"') {
|
|
859
|
+
if (body[i] === "\\" && i + 1 < body.length) { v += body[i + 1]; i += 2; }
|
|
860
|
+
else { v += body[i]; i++; }
|
|
861
|
+
}
|
|
862
|
+
if (body[i] !== '"') {
|
|
863
|
+
_writeTagged(socket, tag, "BAD SETMETADATA unterminated quoted value");
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
i++;
|
|
867
|
+
value = v;
|
|
868
|
+
} else {
|
|
869
|
+
while (i < body.length && !/\s/.test(body[i])) i++;
|
|
870
|
+
var tok = body.slice(valStart, i);
|
|
871
|
+
value = tok.toUpperCase() === "NIL" ? null : tok;
|
|
872
|
+
}
|
|
873
|
+
entries.push({ entry: entryName, value: value });
|
|
874
|
+
}
|
|
875
|
+
if (entries.length === 0) {
|
|
876
|
+
_writeTagged(socket, tag, "BAD SETMETADATA empty entry list");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
Promise.resolve()
|
|
880
|
+
.then(function () { return mailStore.setMetadata(state.actor, mailbox, entries); })
|
|
881
|
+
.then(function () { _writeTagged(socket, tag, "OK SETMETADATA completed"); })
|
|
882
|
+
.catch(function (err) {
|
|
883
|
+
_writeTagged(socket, tag, "NO " + ((err && err.message) || "SETMETADATA failed").slice(0, ERR_CLAMP));
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
656
887
|
function _handleCapability(state, socket, tag) {
|
|
657
888
|
_writeUntagged(socket, "CAPABILITY " + _capabilityLine(state));
|
|
658
889
|
_writeTagged(socket, tag, "OK CAPABILITY completed");
|
|
@@ -1025,6 +1256,112 @@ function create(opts) {
|
|
|
1025
1256
|
|
|
1026
1257
|
function _handleAppend(state, socket, tag, args, literalBody) {
|
|
1027
1258
|
if (!_requireAuth(state, socket, tag)) return;
|
|
1259
|
+
// RFC 4469 CATENATE — `APPEND mailbox [(flags)] [date-time] CATENATE
|
|
1260
|
+
// (TEXT {literal} URL "imap://...")`. The CATENATE keyword turns the
|
|
1261
|
+
// command body into a list of parts the server stitches into a
|
|
1262
|
+
// single message; backends supply the `appendCatenate(actor,
|
|
1263
|
+
// mailbox, parts, opts) → meta` hook. Without CATENATE, fall
|
|
1264
|
+
// through to the bare APPEND path that already exists.
|
|
1265
|
+
var catenateMatch = args.match(/^(\S+|"[^"]+")(?:\s+\(([^)]*)\))?(?:\s+("[^"]+"))?\s+CATENATE\s+(.+)$/i); // allow:regex-no-length-cap — args length already capped upstream
|
|
1266
|
+
if (catenateMatch) {
|
|
1267
|
+
if (typeof mailStore.appendCatenate !== "function") {
|
|
1268
|
+
_writeTagged(socket, tag, "NO CATENATE backend not configured");
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
var catMailbox = _unquote(catenateMatch[1]);
|
|
1272
|
+
var catFlags = catenateMatch[2] ? catenateMatch[2].split(/\s+/).filter(Boolean) : [];
|
|
1273
|
+
var catDateArg = catenateMatch[3] ? _unquote(catenateMatch[3]) : null;
|
|
1274
|
+
var catInternalDate = null;
|
|
1275
|
+
if (catDateArg) {
|
|
1276
|
+
catInternalDate = _parseImapDateTime(catDateArg);
|
|
1277
|
+
if (catInternalDate === null) {
|
|
1278
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE date-time invalid");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (!_validateMailboxName(catMailbox, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
|
|
1283
|
+
_writeTagged(socket, tag, "BAD Mailbox name refused");
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// Validate the parens are well-formed BEFORE we touch the
|
|
1287
|
+
// backend. The wire-format parts list MUST start with `(` and
|
|
1288
|
+
// end with `)`; a truncated list (e.g. `(TEXT {3}` arriving as
|
|
1289
|
+
// a single literal-completion before the rest of the parts
|
|
1290
|
+
// streams in) is refused. Order-preserving left-to-right token
|
|
1291
|
+
// walk replaces the prior URL-then-TEXT split — CATENATE
|
|
1292
|
+
// semantics depend on the SEQUENCE of parts.
|
|
1293
|
+
var partsBodyRaw = catenateMatch[4];
|
|
1294
|
+
if (partsBodyRaw[0] !== "(" || partsBodyRaw[partsBodyRaw.length - 1] !== ")") {
|
|
1295
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE parts list missing parens (RFC 4469 §3)");
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
var partsBody = partsBodyRaw.slice(1, -1);
|
|
1299
|
+
var parts = [];
|
|
1300
|
+
var hadTextPart = false;
|
|
1301
|
+
// Tokenise sequentially. Each part is one of:
|
|
1302
|
+
// URL "imap://..."
|
|
1303
|
+
// TEXT {<n>} (literal — multi-literal CATENATE deferred to a
|
|
1304
|
+
// later slice; defer-with-condition: refused
|
|
1305
|
+
// with NO until the multi-literal protocol path
|
|
1306
|
+
// lands).
|
|
1307
|
+
var pi = 0;
|
|
1308
|
+
while (pi < partsBody.length) {
|
|
1309
|
+
while (pi < partsBody.length && /\s/.test(partsBody[pi])) pi += 1;
|
|
1310
|
+
if (pi >= partsBody.length) break;
|
|
1311
|
+
if (/^URL\b/i.test(partsBody.slice(pi))) {
|
|
1312
|
+
pi += 3; // allow:raw-byte-literal — length of literal "URL" keyword
|
|
1313
|
+
while (pi < partsBody.length && /\s/.test(partsBody[pi])) pi += 1;
|
|
1314
|
+
if (partsBody[pi] !== "\"") {
|
|
1315
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE URL value must be quoted-string");
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
pi += 1;
|
|
1319
|
+
var urlStart = pi;
|
|
1320
|
+
while (pi < partsBody.length && partsBody[pi] !== "\"") pi += 1;
|
|
1321
|
+
if (partsBody[pi] !== "\"") {
|
|
1322
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE URL value unterminated quoted-string");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
parts.push({ kind: "URL", url: partsBody.slice(urlStart, pi) });
|
|
1326
|
+
pi += 1;
|
|
1327
|
+
} else if (/^TEXT\b/i.test(partsBody.slice(pi))) {
|
|
1328
|
+
hadTextPart = true;
|
|
1329
|
+
break;
|
|
1330
|
+
} else {
|
|
1331
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE unknown part (RFC 4469 §3 only URL/TEXT)");
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (hadTextPart) {
|
|
1336
|
+
// Multi-literal CATENATE TEXT parts need a streaming-literal
|
|
1337
|
+
// protocol path the listener doesn't currently expose. RFC
|
|
1338
|
+
// 4469 §3 explicitly permits servers to refuse parts they
|
|
1339
|
+
// can't honour; refusing is correct (better than reordering
|
|
1340
|
+
// and corrupting the message body the client requested).
|
|
1341
|
+
_writeTagged(socket, tag, "NO CATENATE TEXT-literal parts not yet implemented; use APPEND with a single literal");
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (parts.length === 0) {
|
|
1345
|
+
_writeTagged(socket, tag, "BAD APPEND CATENATE empty parts list");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
Promise.resolve()
|
|
1349
|
+
.then(function () {
|
|
1350
|
+
return mailStore.appendCatenate(catMailbox, parts, {
|
|
1351
|
+
actor: state.actor, flags: catFlags, internalDate: catInternalDate });
|
|
1352
|
+
})
|
|
1353
|
+
.then(function (meta) {
|
|
1354
|
+
var ok = "OK APPEND completed";
|
|
1355
|
+
if (meta && meta.uid && meta.uidValidity) {
|
|
1356
|
+
ok = "OK [APPENDUID " + meta.uidValidity + " " + meta.uid + "] APPEND completed";
|
|
1357
|
+
}
|
|
1358
|
+
_writeTagged(socket, tag, ok);
|
|
1359
|
+
})
|
|
1360
|
+
.catch(function (err) {
|
|
1361
|
+
_writeTagged(socket, tag, "NO " + ((err && err.message) || "CATENATE failed").slice(0, ERR_CLAMP));
|
|
1362
|
+
});
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1028
1365
|
if (!literalBody) {
|
|
1029
1366
|
_writeTagged(socket, tag, "BAD APPEND requires a literal {N} message");
|
|
1030
1367
|
return;
|
|
@@ -1162,23 +1499,57 @@ function create(opts) {
|
|
|
1162
1499
|
}
|
|
1163
1500
|
var seqSet = match[1];
|
|
1164
1501
|
var partsSpec = match[2];
|
|
1502
|
+
// RFC 7162 §3.1.4 — FETCH may carry a CHANGEDSINCE modifier in a
|
|
1503
|
+
// trailing parenthesised list:
|
|
1504
|
+
// FETCH 1:* (FLAGS) (CHANGEDSINCE 12345)
|
|
1505
|
+
// and/or VANISHED (QRESYNC) which is deferred to a later slice.
|
|
1506
|
+
// The modifier list is parsed off the END of partsSpec; what
|
|
1507
|
+
// remains is handed to the backend as the fetch-att spec.
|
|
1508
|
+
var changedSince = null;
|
|
1509
|
+
var includeVanished = false;
|
|
1510
|
+
var modMatch = partsSpec.match(/\s*\(([^)]*)\)\s*$/); // allow:regex-no-length-cap — partsSpec already bounded upstream
|
|
1511
|
+
if (modMatch && /\b(CHANGEDSINCE|VANISHED)\b/i.test(modMatch[1])) {
|
|
1512
|
+
var modBody = modMatch[1];
|
|
1513
|
+
var changedMatch = modBody.match(/CHANGEDSINCE\s+(\d+)/i); // allow:regex-no-length-cap — modBody already bounded
|
|
1514
|
+
if (changedMatch) {
|
|
1515
|
+
var csN = parseInt(changedMatch[1], 10);
|
|
1516
|
+
if (isFinite(csN) && csN >= 0) changedSince = csN;
|
|
1517
|
+
}
|
|
1518
|
+
includeVanished = /\bVANISHED\b/i.test(modBody);
|
|
1519
|
+
partsSpec = partsSpec.slice(0, partsSpec.length - modMatch[0].length).trim();
|
|
1520
|
+
}
|
|
1521
|
+
// RFC 7162 §3.1.2 — any FETCH that uses CHANGEDSINCE implicitly
|
|
1522
|
+
// engages CONDSTORE for the session; the client expects MODSEQ
|
|
1523
|
+
// in responses even without a prior `ENABLE CONDSTORE`. RFC 7162
|
|
1524
|
+
// §3.1.4.1 — when CONDSTORE is engaged (explicit ENABLE OR
|
|
1525
|
+
// implicit via CHANGEDSINCE) OR the client requested MODSEQ as a
|
|
1526
|
+
// fetch-att, every untagged FETCH response includes the MODSEQ
|
|
1527
|
+
// attribute. Engaging CONDSTORE via CHANGEDSINCE also sticks for
|
|
1528
|
+
// the rest of the session.
|
|
1529
|
+
if (changedSince !== null && !state.enabledCondStore) {
|
|
1530
|
+
state.enabledCondStore = true;
|
|
1531
|
+
}
|
|
1532
|
+
var includeModseq = state.enabledCondStore === true ||
|
|
1533
|
+
changedSince !== null ||
|
|
1534
|
+
/\bMODSEQ\b/i.test(partsSpec);
|
|
1165
1535
|
Promise.resolve()
|
|
1166
1536
|
.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
1537
|
return mailStore.fetchRange(state.actor, state.selectedMailbox, seqSet, partsSpec,
|
|
1173
|
-
{ useUid: useUid === true
|
|
1538
|
+
{ useUid: useUid === true, changedSince: changedSince, includeVanished: includeVanished,
|
|
1539
|
+
includeModseq: includeModseq });
|
|
1174
1540
|
})
|
|
1175
1541
|
.then(function (rows) {
|
|
1176
1542
|
var rs = rows || [];
|
|
1177
1543
|
_emit("mail.server.imap.fetch_bulk",
|
|
1178
|
-
{ connectionId: state.id, mailbox: state.selectedMailbox, count: rs.length
|
|
1544
|
+
{ connectionId: state.id, mailbox: state.selectedMailbox, count: rs.length,
|
|
1545
|
+
changedSince: changedSince, condStore: state.enabledCondStore === true });
|
|
1179
1546
|
for (var i = 0; i < rs.length; i += 1) {
|
|
1180
1547
|
var r = rs[i];
|
|
1181
|
-
|
|
1548
|
+
var payload = r.payload || "";
|
|
1549
|
+
if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(payload)) {
|
|
1550
|
+
payload = (payload ? payload + " " : "") + "MODSEQ (" + r.modseq + ")";
|
|
1551
|
+
}
|
|
1552
|
+
_writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
|
|
1182
1553
|
}
|
|
1183
1554
|
_writeTagged(socket, tag, "OK FETCH completed");
|
|
1184
1555
|
})
|
|
@@ -1204,6 +1575,20 @@ function create(opts) {
|
|
|
1204
1575
|
_writeTagged(socket, tag, "BAD STORE backend not configured");
|
|
1205
1576
|
return;
|
|
1206
1577
|
}
|
|
1578
|
+
// RFC 7162 §3.1.3 — STORE may carry a parenthesised UNCHANGEDSINCE
|
|
1579
|
+
// modifier between the sequence-set and the FLAGS op:
|
|
1580
|
+
// STORE 1:* (UNCHANGEDSINCE 12345) +FLAGS (\Deleted)
|
|
1581
|
+
// The backend's response shape is { rows, modified } — `modified`
|
|
1582
|
+
// is the seq-set string of message ids whose modseq advanced past
|
|
1583
|
+
// unchangedSince before this STORE ran. We surface those via
|
|
1584
|
+
// [MODIFIED <set>] OK response (RFC 7162 §3.1.3).
|
|
1585
|
+
var unchangedSince = null;
|
|
1586
|
+
var unchangedMatch = args.match(/^(\S+)\s+\(UNCHANGEDSINCE\s+(\d+)\)\s+(.+)$/i); // allow:regex-no-length-cap — args length already capped upstream
|
|
1587
|
+
if (unchangedMatch) {
|
|
1588
|
+
var usN = parseInt(unchangedMatch[2], 10);
|
|
1589
|
+
if (isFinite(usN) && usN >= 0) unchangedSince = usN;
|
|
1590
|
+
args = unchangedMatch[1] + " " + unchangedMatch[3];
|
|
1591
|
+
}
|
|
1207
1592
|
var match = args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)$/i); // allow:regex-no-length-cap — args length already capped upstream
|
|
1208
1593
|
if (!match) {
|
|
1209
1594
|
_writeTagged(socket, tag, "BAD STORE expects seq-set FLAGS (...)");
|
|
@@ -1214,20 +1599,61 @@ function create(opts) {
|
|
|
1214
1599
|
var flagsArr = match[3].split(/\s+/).filter(Boolean);
|
|
1215
1600
|
var silent = /\.SILENT$/i.test(op);
|
|
1216
1601
|
var mode = op[0] === "+" ? "add" : op[0] === "-" ? "remove" : "replace";
|
|
1602
|
+
// RFC 7162 §3.1.2 — UNCHANGEDSINCE in STORE engages CONDSTORE for
|
|
1603
|
+
// the session (same implicit-enable rule as FETCH CHANGEDSINCE).
|
|
1604
|
+
if (unchangedSince !== null && !state.enabledCondStore) {
|
|
1605
|
+
state.enabledCondStore = true;
|
|
1606
|
+
}
|
|
1607
|
+
var includeModseqStore = state.enabledCondStore === true || unchangedSince !== null;
|
|
1217
1608
|
Promise.resolve()
|
|
1218
1609
|
.then(function () {
|
|
1219
1610
|
return mailStore.storeFlags(state.actor, state.selectedMailbox, seqSet, mode, flagsArr,
|
|
1220
|
-
{ useUid: useUid === true });
|
|
1611
|
+
{ useUid: useUid === true, unchangedSince: unchangedSince, includeModseq: includeModseqStore });
|
|
1221
1612
|
})
|
|
1222
|
-
.then(function (
|
|
1223
|
-
|
|
1224
|
-
|
|
1613
|
+
.then(function (result) {
|
|
1614
|
+
// Backend may return either an array of rows (legacy shape)
|
|
1615
|
+
// OR an object `{ rows, modified }`. Normalise.
|
|
1616
|
+
var rs, modifiedSet;
|
|
1617
|
+
if (Array.isArray(result)) { rs = result; modifiedSet = null; }
|
|
1618
|
+
else if (result && typeof result === "object") {
|
|
1619
|
+
rs = result.rows || [];
|
|
1620
|
+
modifiedSet = result.modified || null;
|
|
1621
|
+
} else { rs = []; modifiedSet = null; }
|
|
1622
|
+
// RFC 7162 §3.1.3 — under CONDSTORE / UNCHANGEDSINCE, the
|
|
1623
|
+
// server MUST emit a FETCH response carrying the new MODSEQ
|
|
1624
|
+
// for every successfully-updated message EVEN UNDER .SILENT.
|
|
1625
|
+
// Without it, CONDSTORE clients cannot refresh their local
|
|
1626
|
+
// modseq state and drift out of sync. Under non-CONDSTORE
|
|
1627
|
+
// .SILENT, the legacy behaviour stays (no untagged FETCH).
|
|
1628
|
+
var emitFlags = !silent;
|
|
1629
|
+
var emitModseqOnly = silent && includeModseqStore;
|
|
1630
|
+
if (emitFlags || emitModseqOnly) {
|
|
1225
1631
|
for (var i = 0; i < rs.length; i += 1) {
|
|
1226
1632
|
var r = rs[i];
|
|
1227
|
-
|
|
1633
|
+
var payload;
|
|
1634
|
+
if (emitFlags) {
|
|
1635
|
+
payload = "FLAGS (" + (r.flags || []).join(" ") + ")";
|
|
1636
|
+
if (includeModseqStore && r.modseq !== undefined) {
|
|
1637
|
+
payload = payload + " MODSEQ (" + r.modseq + ")";
|
|
1638
|
+
}
|
|
1639
|
+
} else if (r.modseq !== undefined) {
|
|
1640
|
+
// SILENT + CONDSTORE — emit MODSEQ alone (no FLAGS).
|
|
1641
|
+
payload = "MODSEQ (" + r.modseq + ")";
|
|
1642
|
+
} else {
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
_writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
|
|
1228
1646
|
}
|
|
1229
1647
|
}
|
|
1230
|
-
|
|
1648
|
+
var okTag = "OK STORE completed";
|
|
1649
|
+
// RFC 7162 §3.1.3 — MODIFIED carries the set of ids the
|
|
1650
|
+
// conditional STORE refused to update because their modseq
|
|
1651
|
+
// advanced past unchangedSince. Clients re-issue FETCH against
|
|
1652
|
+
// the set to refresh state before retry.
|
|
1653
|
+
if (modifiedSet && String(modifiedSet).length > 0) {
|
|
1654
|
+
okTag = "OK [MODIFIED " + modifiedSet + "] STORE completed";
|
|
1655
|
+
}
|
|
1656
|
+
_writeTagged(socket, tag, okTag);
|
|
1231
1657
|
})
|
|
1232
1658
|
.catch(function (err) {
|
|
1233
1659
|
_writeTagged(socket, tag, "NO " + ((err && err.message) || "Store failed").slice(0, ERR_CLAMP));
|
|
@@ -54,6 +54,10 @@ var IMAP_VERBS = Object.freeze({
|
|
|
54
54
|
NAMESPACE: 1, STATUS: 1, APPEND: 1, IDLE: 1, CHECK: 1, CLOSE: 1,
|
|
55
55
|
UNSELECT: 1, EXPUNGE: 1, SEARCH: 1, FETCH: 1, STORE: 1, COPY: 1,
|
|
56
56
|
MOVE: 1, UID: 1, DONE: 1,
|
|
57
|
+
// v0.11.28 — RFC 5465 NOTIFY / RFC 5464 METADATA / RFC 4469 CATENATE.
|
|
58
|
+
// CATENATE is an APPEND modifier and stays under APPEND in dispatch;
|
|
59
|
+
// METADATA gets GETMETADATA + SETMETADATA verbs.
|
|
60
|
+
NOTIFY: 1, GETMETADATA: 1, SETMETADATA: 1,
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
var MANAGESIEVE_VERBS = Object.freeze({
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:e497b463-3374-4f8a-84c2-9686c0567a23",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-21T14:49:20.894Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.28",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.28",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.28",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.28",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|