@blamejs/core 0.11.29 → 0.11.30

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.30 (2026-05-21) — **JMAP blob upload + download handlers on `b.mail.server.jmap` (RFC 8620 §6).** The JMAP listener now exposes turnkey HTTP handlers for blob upload (`POST /jmap/upload/{accountId}`) and blob download (`GET /jmap/download/{accountId}/{blobId}/{name}`). Upload streams the request body into `b.safeBuffer.boundedChunkCollector` (default cap 50 MiB; operator-tunable via `opts.maxBlobBytes`), then calls the operator backend's `mailStore.uploadBlob(actor, accountId, contentType, bytes) → { blobId, type?, size? }` and returns the JSON descriptor clients pass to subsequent `Email/set` / `Email/import` calls. Download walks the operator backend's `mailStore.downloadBlob(actor, accountId, blobId) → { bytes, type }` and pipes the bytes through with the right `Content-Type` + `Content-Disposition` headers. Both handlers refuse 503 when the corresponding backend hook is missing — never silent OK.
12
+
13
+ EmailSubmission (RFC 8621 §7) was already in the JMAP method catalogue at `lib/mail-server-registry.js`; operators wire `EmailSubmission/get` / `EmailSubmission/set` through the existing `opts.methods` dispatch and compose `b.mail.send.deliver` (shipped v0.11.24) for the actual outbound send. No additional framework wiring is required. **Added:** *`uploadHandler(req, res)` — POST `/jmap/upload/{accountId}`* — Streams the request body through `b.safeBuffer.boundedChunkCollector` so a runaway upload refuses with `413 maxSizeUpload` instead of OOM'ing the process. Default cap 50 MiB; tune via `b.mail.server.jmap.create({ maxBlobBytes })`. The `accountId` path segment is identifier-shape-validated (`/^[A-Za-z0-9_-]{1,64}$/`) — path-traversal shapes are refused at the boundary. Calls `mailStore.uploadBlob(actor, accountId, contentType, bytes) → { blobId, type?, size? }`; the listener echoes `accountId` + `blobId` + `type` + `size` back as a `201 Created` JSON response per RFC 8620 §6.1. · *`downloadHandler(req, res)` — GET `/jmap/download/{accountId}/{blobId}/{name}`* — Parses the path's `accountId` / `blobId` / `name` segments + optional `?accept=<type>` query, calls `mailStore.downloadBlob(actor, accountId, blobId) → { bytes, type } | Buffer | null`, and pipes the bytes through with `Content-Type` (backend-supplied OR query-`accept` OR `application/octet-stream` fallback) + `Content-Length` + `Content-Disposition: attachment; filename="<safe-name>"` (only when the filename matches the safe `[A-Za-z0-9._-]{1,200}` shape — anti header-injection). Missing blob → `404 invalidArguments`. · *Both handlers exposed on the listener handle* — `b.mail.server.jmap.create(opts)` returns `{ apiHandler, sessionHandler, discoveryHandler, eventSourceHandler, uploadHandler, downloadHandler, MailServerJmapError }`. Operators mount each at the path their HTTP router exposes; the `session.uploadUrl` / `session.downloadUrl` already advertise the canonical paths. · *EmailSubmission methods continue through the existing dispatch path* — RFC 8621 §7 — `EmailSubmission/get` / `EmailSubmission/changes` / `EmailSubmission/query` / `EmailSubmission/queryChanges` / `EmailSubmission/set` are already in the JMAP method catalogue at `lib/mail-server-registry.js`. Operators wire them through `b.mail.server.jmap.create({ methods: { 'EmailSubmission/set': async function (actor, args) { ... b.mail.send.deliver(...) ... } } })`. No additional framework wiring is required for v0.11.30; the substrate composition (JMAP method → `b.mail.send.deliver` → SMTP MX → DSN) was already complete after v0.11.24. **Security:** *AccountId path-traversal refusal at the boundary* — Both handlers validate the `accountId` URL segment against `/^[A-Za-z0-9_-]{1,64}$/`. URL-encoded path-traversal payloads (`%2E%2E%2F`, `..%2F`, `/`) refuse with `400 invalidArguments` before any backend call. Operator-side validation in `mailStore.uploadBlob` / `mailStore.downloadBlob` is a defense-in-depth second layer. · *Upload size cap via `safeBuffer.boundedChunkCollector` (cap-bounded)* — Replaces the prior hand-rolled `Buffer.concat(chunks, received)` shape with the framework's bounded-collector primitive. The collector throws `mail-server-jmap/blob-too-large` on overflow; the handler converts it into a `413 maxSizeUpload` JSON error per RFC 8620 §6.1. A misbehaving client cannot stream a multi-gigabyte payload past the limit — the collector enforces the cap byte-by-byte. · *`Content-Disposition` filename is identifier-shape only* — Download responses only set the `Content-Disposition` header when the URL's `name` segment matches `/^[A-Za-z0-9._-]{1,200}$/`. Filenames with `;` / `"` / CRLF cannot be smuggled into the header — RFC 6266 §4.3 + CVE-2023-46604-class header-injection defense. **References:** [RFC 8620 (JMAP Core — §6 Blob upload/download)](https://www.rfc-editor.org/rfc/rfc8620.html) · [RFC 8621 (JMAP Mail — §7 EmailSubmission)](https://www.rfc-editor.org/rfc/rfc8621.html) · [RFC 6266 (Content-Disposition in HTTP)](https://www.rfc-editor.org/rfc/rfc6266.html) · [RFC 5987 (Encoding-aware filename* parameter)](https://www.rfc-editor.org/rfc/rfc5987.html)
14
+
11
15
  - v0.11.29 (2026-05-21) — **JMAP Push — EventSource SSE handler on `b.mail.server.jmap` (RFC 8620 §7.3).** The JMAP listener now exposes a real EventSource handler at `/jmap/eventsource`. The session-resource's `eventSourceUrl` becomes a live push channel: clients connect with `?types=*|<csv>&closeafter=no|state&ping=<seconds>`, the listener subscribes via the operator backend's `mailStore.subscribePush(actor, types, emitFn)` hook, and StateChange events arrive as `event: state` SSE frames with the `{ "@type": "StateChange", changed: {...} }` payload per RFC 8620 §7.4. Keepalive `event: ping` frames default to 30 s (operator-tunable 5-900 s), `closeafter=state` closes after one event for poll-like clients, and the SSE response carries the headers proxies expect (`Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`) so nginx-fronted deployments don't buffer the stream. Without the backend subscribe hook the handler refuses with `503 serverUnavailable`, never silent OK. **Added:** *`eventSourceHandler(req, res)` exposed on the JMAP listener handle* — `b.mail.server.jmap.create(opts).eventSourceHandler` mounts on the operator's HTTP router at whatever path the session-resource's `eventSourceUrl` points to (default `/jmap/eventsource`). Authentication is delegated to the surrounding HTTP middleware (the handler expects `req.user` / `req.actor` to be set); unauthenticated requests return `401 forbidden` per RFC 8620 §1.5. Query-string params parse `types=` (CSV or `*` wildcard), `closeafter=` (`no` | `state`), `ping=` (seconds, clamped 5..900) per §7.3. · *Operator backend hook — `mailStore.subscribePush(actor, types, emitFn)`* — Backends opt in by exposing a `subscribePush` method that accepts (1) the authenticated actor, (2) a parsed `types` list (or `null` for the wildcard `*`), and (3) an `emitFn(event)` callback the backend calls when a state change occurs. The expected event shape is `{ kind: 'StateChange', changed: { <accountId>: { <typeName>: <stateString>, ... }, ... }, pushed?: {...} }`; the listener formats it into the SSE `event: state\ndata: <JSON>\n\n` wire shape. The subscribe call may return either `void` or an `unsubscribe()` function the listener invokes on disconnect. · *SSE wire-correct headers + initial-state hint* — Response headers: `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no` (nginx-specific — disables response buffering on the stream so per-event frames flush immediately). The initial bytes carry `retry: 5000\n\n` (HTML5 SSE reconnect hint — 5 s) + a `: connected\n\n` comment so clients can confirm the channel is alive before the first state event. **Security:** *Push backend missing returns `503 serverUnavailable` (no silent accept)* — If the operator wired the listener without `mailStore.subscribePush`, the handler returns `503 urn:ietf:params:jmap:error:serverUnavailable` with a `description` pointing at the missing hook. RFC 8620 §7.3 expects the server to honour subscriptions or refuse explicitly — silently accepting would let a client believe events will arrive when the server cannot deliver. · *`closeafter` accepts only `no` | `state` (RFC 8620 §7.3)* — Any other value returns `400 invalidArguments` before the subscribe hook fires. Prevents an operator-supplied query string from steering the handler into an undocumented mode. · *Ping interval clamped 5..900 seconds* — `ping=<seconds>` below 5 s or above 900 s clamps to the bounds rather than refusing — a misconfigured client cannot DoS the listener via 1 ms ping floods, and a 24 h ping won't outlast intermediate proxy idle timeouts (typical 60-120 s). The clamped default (30 s) matches the RFC 8620 §7.3 example. · *Ping timer is `.unref()`'d* — Background timers without `.unref()` pin the Node process — graceful shutdown waits indefinitely. The interval is unref'd immediately after creation; per-connection cleanup (`req.on('close')` / `req.on('error')`) clears the timer + invokes the backend's optional `unsubscribe()` + ends the response. **References:** [RFC 8620 (JMAP Core — §7 Push / §7.3 EventSource / §7.4 StateChange)](https://www.rfc-editor.org/rfc/rfc8620.html) · [RFC 8621 (JMAP Mail)](https://www.rfc-editor.org/rfc/rfc8621.html) · [RFC 8887 (JMAP WebSocket transport — opt-in, deferred to a later slice)](https://www.rfc-editor.org/rfc/rfc8887.html) · [HTML5 Server-Sent Events (EventSource)](https://html.spec.whatwg.org/multipage/server-sent-events.html)
12
16
 
13
17
  - 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)
@@ -121,6 +121,7 @@ var lazyRequire = require("./lazy-require");
121
121
  var C = require("./constants");
122
122
  var bCrypto = require("./crypto");
123
123
  var safeJson = require("./safe-json");
124
+ var safeBuffer = require("./safe-buffer");
124
125
  var validateOpts = require("./validate-opts");
125
126
  var guardJmap = require("./guard-jmap");
126
127
  var mailServerRegistry = require("./mail-server-registry");
@@ -670,6 +671,306 @@ function create(opts) {
670
671
  req.on("error", _cleanup);
671
672
  }
672
673
 
674
+ // RFC 8620 §6.1 — blob upload. Operators POST raw bytes to
675
+ // `/jmap/upload/{accountId}` with `Content-Type` set to the
676
+ // blob MIME type. The handler streams the request body into a
677
+ // bounded buffer, calls `mailStore.uploadBlob(actor, accountId,
678
+ // contentType, bytes)`, and returns the JSON descriptor
679
+ // `{ accountId, blobId, type, size }` the client uses in
680
+ // subsequent Email/set / Email/import calls.
681
+ //
682
+ // Path parameters are extracted from the URL; the operator-side
683
+ // HTTP router MUST mount this handler at a prefix that exposes
684
+ // the accountId segment (e.g. `/jmap/upload/:accountId`). The
685
+ // handler defensively re-parses the URL in case the router didn't
686
+ // populate `req.params`.
687
+ var DEFAULT_MAX_BLOB_BYTES = opts.maxBlobBytes || (50 * 1024 * 1024); // allow:raw-byte-literal — 50 MiB default blob upload cap
688
+ // RFC 8620 §1.2 — JMAP `Id` is a non-empty string of < 256 octets in
689
+ // `[A-Za-z0-9_-]`. The earlier shape capped at 64 chars which refused
690
+ // legitimate-shape accounts; widen to the full spec maximum.
691
+ var MAX_JMAP_ID_LEN = 255; // allow:raw-byte-literal — RFC 8620 §1.2 Id max length
692
+ var JMAP_ID_RE = /^[A-Za-z0-9_-]{1,255}$/;
693
+ // Anti-polynomial: bound the URL length BEFORE any regex / split runs
694
+ // (CodeQL flags `\/+` on uncontrolled input). Headers + URL paths in
695
+ // practice stay well under 8 KiB; over-long URLs refuse outright.
696
+ var MAX_URL_LEN = 8192; // allow:raw-byte-literal — 8 KiB URL cap
697
+
698
+ // Strip a query string + walk the path producing non-empty segments,
699
+ // WITHOUT any unbounded regex. Returns an empty array when the URL
700
+ // is over the cap so the caller can refuse with 400.
701
+ function _splitPathSegments(rawUrl) {
702
+ if (typeof rawUrl !== "string" || rawUrl.length === 0 || rawUrl.length > MAX_URL_LEN) {
703
+ return [];
704
+ }
705
+ var qIdx = rawUrl.indexOf("?");
706
+ var pathOnly = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
707
+ var out = [];
708
+ var cur = "";
709
+ for (var i = 0; i < pathOnly.length; i += 1) {
710
+ var ch = pathOnly.charCodeAt(i);
711
+ if (ch === 0x2f) { // allow:raw-byte-literal — '/' (0x2f)
712
+ if (cur.length > 0) { out.push(cur); cur = ""; }
713
+ } else {
714
+ cur += pathOnly[i];
715
+ }
716
+ }
717
+ if (cur.length > 0) out.push(cur);
718
+ return out;
719
+ }
720
+
721
+ function uploadHandler(req, res) {
722
+ var actor = req.user || (req.actor || null);
723
+ if (!actor) {
724
+ res.statusCode = 401;
725
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
726
+ res.end(JSON.stringify({
727
+ type: "urn:ietf:params:jmap:error:forbidden",
728
+ description: "Authentication required",
729
+ }));
730
+ return;
731
+ }
732
+ if (typeof opts.mailStore.uploadBlob !== "function") {
733
+ res.statusCode = 503;
734
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
735
+ res.end(JSON.stringify({
736
+ type: "urn:ietf:params:jmap:error:serverUnavailable",
737
+ description: "Upload backend not configured (mailStore.uploadBlob)",
738
+ }));
739
+ return;
740
+ }
741
+ // Extract accountId from URL path. The mount path is
742
+ // `/jmap/upload/{accountId}`; the operator's router may strip
743
+ // the `/jmap/upload/` prefix (so segments == [accountId]) OR
744
+ // pass through the full path. Either shape gives the trailing
745
+ // segment as accountId — but the WHOLE URL must split cleanly
746
+ // (`_splitPathSegments` refuses over-long input).
747
+ var segments = _splitPathSegments(req.url);
748
+ if (segments.length === 0) {
749
+ res.statusCode = 400;
750
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
751
+ res.end(JSON.stringify({
752
+ type: "urn:ietf:params:jmap:error:invalidArguments",
753
+ description: "Upload URL is empty or exceeds the " + MAX_URL_LEN + "-byte cap",
754
+ }));
755
+ return;
756
+ }
757
+ var accountId = (req.params && req.params.accountId) || segments[segments.length - 1] || "";
758
+ if (!accountId || accountId.length > MAX_JMAP_ID_LEN || !JMAP_ID_RE.test(accountId)) {
759
+ res.statusCode = 400;
760
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
761
+ res.end(JSON.stringify({
762
+ type: "urn:ietf:params:jmap:error:invalidArguments",
763
+ description: "Upload URL missing or malformed accountId path segment (JMAP Id: [A-Za-z0-9_-]{1," + MAX_JMAP_ID_LEN + "})",
764
+ }));
765
+ return;
766
+ }
767
+ var contentType = req.headers && req.headers["content-type"]
768
+ ? String(req.headers["content-type"]).split(";")[0].trim()
769
+ : "application/octet-stream";
770
+ var collector = safeBuffer.boundedChunkCollector({
771
+ maxBytes: DEFAULT_MAX_BLOB_BYTES,
772
+ errorClass: MailServerJmapError,
773
+ sizeCode: "mail-server-jmap/blob-too-large",
774
+ sizeMessage: "Blob exceeds maxSizeUpload (" + DEFAULT_MAX_BLOB_BYTES + " bytes)",
775
+ });
776
+ var refused = false;
777
+
778
+ req.on("data", function (chunk) {
779
+ if (refused) return;
780
+ try { collector.push(chunk); }
781
+ catch (_e) {
782
+ refused = true;
783
+ res.statusCode = 413;
784
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
785
+ res.end(JSON.stringify({
786
+ type: "urn:ietf:params:jmap:error:limit",
787
+ limit: "maxSizeUpload",
788
+ description: "Blob exceeds maxSizeUpload (" + DEFAULT_MAX_BLOB_BYTES + " bytes)",
789
+ }));
790
+ try { req.destroy(); } catch (_e2) { /* silent-catch: socket already torn down */ }
791
+ }
792
+ });
793
+ req.on("end", function () {
794
+ if (refused) return;
795
+ var bytes = collector.result();
796
+ Promise.resolve()
797
+ .then(function () { return opts.mailStore.uploadBlob(actor, accountId, contentType, bytes); })
798
+ .then(function (meta) {
799
+ if (!meta || typeof meta !== "object" || typeof meta.blobId !== "string") {
800
+ throw new MailServerJmapError("mail-server-jmap/bad-upload-result",
801
+ "uploadBlob backend MUST return { blobId, type?, size? }");
802
+ }
803
+ res.statusCode = 201; // allow:raw-byte-literal — HTTP 201 Created
804
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
805
+ res.end(JSON.stringify({
806
+ accountId: accountId,
807
+ blobId: meta.blobId,
808
+ type: meta.type || contentType,
809
+ size: typeof meta.size === "number" ? meta.size : bytes.length,
810
+ }));
811
+ })
812
+ .catch(function (err) {
813
+ _emit("mail.server.jmap.upload_threw",
814
+ { accountId: accountId, error: (err && err.message) || String(err) }, "failure");
815
+ res.statusCode = 500;
816
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
817
+ res.end(JSON.stringify({
818
+ type: "urn:ietf:params:jmap:error:serverFail",
819
+ description: "Upload failed",
820
+ }));
821
+ });
822
+ });
823
+ req.on("error", function () {
824
+ if (!refused) {
825
+ refused = true;
826
+ try { res.statusCode = 400; res.end(); } // allow:raw-byte-literal — HTTP 400
827
+ catch (_e) { /* silent-catch: socket already torn down */ }
828
+ }
829
+ });
830
+ }
831
+
832
+ // RFC 8620 §6.2 — blob download. GET `/jmap/download/{accountId}/
833
+ // {blobId}/{name}?accept={type}`. Backend hook returns a stream-
834
+ // shaped buffer (Buffer or async-iterable) + the canonical MIME
835
+ // type; the handler pipes it to the response.
836
+ function downloadHandler(req, res) {
837
+ var actor = req.user || (req.actor || null);
838
+ if (!actor) {
839
+ res.statusCode = 401;
840
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
841
+ res.end(JSON.stringify({
842
+ type: "urn:ietf:params:jmap:error:forbidden",
843
+ description: "Authentication required",
844
+ }));
845
+ return;
846
+ }
847
+ if (typeof opts.mailStore.downloadBlob !== "function") {
848
+ res.statusCode = 503;
849
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
850
+ res.end(JSON.stringify({
851
+ type: "urn:ietf:params:jmap:error:serverUnavailable",
852
+ description: "Download backend not configured (mailStore.downloadBlob)",
853
+ }));
854
+ return;
855
+ }
856
+ var rawUrl = String(req.url || "");
857
+ if (rawUrl.length > MAX_URL_LEN) {
858
+ res.statusCode = 400;
859
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
860
+ res.end(JSON.stringify({
861
+ type: "urn:ietf:params:jmap:error:invalidArguments",
862
+ description: "Download URL exceeds the " + MAX_URL_LEN + "-byte cap",
863
+ }));
864
+ return;
865
+ }
866
+ var qIdx2 = rawUrl.indexOf("?");
867
+ var query = qIdx2 === -1 ? "" : rawUrl.slice(qIdx2 + 1);
868
+ var acceptType = "";
869
+ query.split("&").forEach(function (pair) {
870
+ if (!pair) return;
871
+ var eq = pair.indexOf("=");
872
+ var k = eq === -1 ? pair : pair.slice(0, eq);
873
+ var v = eq === -1 ? "" : pair.slice(eq + 1);
874
+ if (k === "accept") {
875
+ try { acceptType = decodeURIComponent(v); } catch (_e) { /* silent-catch: malformed % encoding */ }
876
+ }
877
+ });
878
+ // Path parsing — `/jmap/download/{accountId}/{blobId}/{name}`. The
879
+ // operator's router may strip the `/jmap/download/` prefix, so
880
+ // valid segment counts are EXACTLY 3 (router-stripped) OR 5+ AND
881
+ // starting with `jmap` + `download`. Anything else refuses BEFORE
882
+ // a tail-segment remap could land path tokens in the wrong
883
+ // accountId / blobId / name slots.
884
+ var pathSegs = _splitPathSegments(rawUrl);
885
+ var routerSupplied = req.params && req.params.accountId && req.params.blobId && req.params.name;
886
+ var accountId, blobId, fileName;
887
+ if (routerSupplied) {
888
+ accountId = req.params.accountId;
889
+ blobId = req.params.blobId;
890
+ fileName = req.params.name;
891
+ } else if (pathSegs.length === 3) {
892
+ accountId = pathSegs[0];
893
+ blobId = pathSegs[1];
894
+ fileName = pathSegs[2];
895
+ } else if (pathSegs.length >= 5 &&
896
+ pathSegs[pathSegs.length - 5].toLowerCase() === "jmap" &&
897
+ pathSegs[pathSegs.length - 4].toLowerCase() === "download") {
898
+ accountId = pathSegs[pathSegs.length - 3];
899
+ blobId = pathSegs[pathSegs.length - 2];
900
+ fileName = pathSegs[pathSegs.length - 1];
901
+ } else {
902
+ res.statusCode = 400;
903
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
904
+ res.end(JSON.stringify({
905
+ type: "urn:ietf:params:jmap:error:invalidArguments",
906
+ description: "Download URL must be /jmap/download/{accountId}/{blobId}/{name} (or router-stripped {accountId}/{blobId}/{name})",
907
+ }));
908
+ return;
909
+ }
910
+ if (!accountId || accountId.length > MAX_JMAP_ID_LEN || !JMAP_ID_RE.test(accountId)) {
911
+ res.statusCode = 400;
912
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
913
+ res.end(JSON.stringify({
914
+ type: "urn:ietf:params:jmap:error:invalidArguments",
915
+ description: "Download URL has malformed accountId segment (JMAP Id: [A-Za-z0-9_-]{1," + MAX_JMAP_ID_LEN + "})",
916
+ }));
917
+ return;
918
+ }
919
+ if (!blobId || blobId.length > MAX_JMAP_ID_LEN || !JMAP_ID_RE.test(blobId)) {
920
+ res.statusCode = 400;
921
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
922
+ res.end(JSON.stringify({
923
+ type: "urn:ietf:params:jmap:error:invalidArguments",
924
+ description: "Download URL has malformed blobId segment (JMAP Id: [A-Za-z0-9_-]{1," + MAX_JMAP_ID_LEN + "})",
925
+ }));
926
+ return;
927
+ }
928
+ Promise.resolve()
929
+ .then(function () { return opts.mailStore.downloadBlob(actor, accountId, blobId); })
930
+ .then(function (result) {
931
+ if (!result || (typeof result !== "object" && !Buffer.isBuffer(result))) {
932
+ res.statusCode = 404;
933
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
934
+ res.end(JSON.stringify({
935
+ type: "urn:ietf:params:jmap:error:invalidArguments",
936
+ description: "Blob not found",
937
+ }));
938
+ return;
939
+ }
940
+ var bytes = Buffer.isBuffer(result) ? result : result.bytes;
941
+ var bType = result.type || acceptType || "application/octet-stream";
942
+ if (!Buffer.isBuffer(bytes)) {
943
+ res.statusCode = 500;
944
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
945
+ res.end(JSON.stringify({
946
+ type: "urn:ietf:params:jmap:error:serverFail",
947
+ description: "downloadBlob backend returned a non-Buffer body",
948
+ }));
949
+ return;
950
+ }
951
+ res.statusCode = 200;
952
+ res.setHeader("Content-Type", bType);
953
+ res.setHeader("Content-Length", bytes.length);
954
+ // RFC 5987 — operator may want to surface fileName via
955
+ // Content-Disposition. Default to attachment when the
956
+ // download is a non-text type.
957
+ if (fileName && /^[A-Za-z0-9._-]{1,200}$/.test(fileName)) {
958
+ res.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
959
+ }
960
+ res.end(bytes);
961
+ })
962
+ .catch(function (err) {
963
+ _emit("mail.server.jmap.download_threw",
964
+ { accountId: accountId, blobId: blobId, error: (err && err.message) || String(err) }, "failure");
965
+ res.statusCode = 500;
966
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
967
+ res.end(JSON.stringify({
968
+ type: "urn:ietf:params:jmap:error:serverFail",
969
+ description: "Download failed",
970
+ }));
971
+ });
972
+ }
973
+
673
974
  function discoveryHandler(req, res) {
674
975
  // RFC 8620 §2.2 — well-known endpoint redirects (or directly returns)
675
976
  // the session URL. We redirect to /jmap/session per the most common
@@ -687,6 +988,8 @@ function create(opts) {
687
988
  sessionHandler: sessionHandler,
688
989
  discoveryHandler: discoveryHandler,
689
990
  eventSourceHandler: eventSourceHandler,
991
+ uploadHandler: uploadHandler,
992
+ downloadHandler: downloadHandler,
690
993
  MailServerJmapError: MailServerJmapError,
691
994
  };
692
995
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.29",
3
+ "version": "0.11.30",
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:bb88c938-c54a-4094-afe0-0fa2adc94cbd",
5
+ "serialNumber": "urn:uuid:f3f28554-3b87-49b1-9378-0caf849de6ae",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-21T15:36:40.156Z",
8
+ "timestamp": "2026-05-21T16:27:12.212Z",
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.29",
22
+ "bom-ref": "@blamejs/core@0.11.30",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.29",
25
+ "version": "0.11.30",
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",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.30",
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.29",
57
+ "ref": "@blamejs/core@0.11.30",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]