@blamejs/core 0.9.18 → 0.9.20

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.20 (2026-05-14) — **`b.mail.agent` — the standardization contract for every mail protocol blamejs ships.** Every above-the-wire mail surface (JMAP at v0.9.27, IMAP at v0.9.28, POP3 at v0.9.29, ManageSieve at v0.9.30, the MX listener at v0.9.24, submission at v0.9.25) translates protocol calls into `agent.X(args)`; RBAC, posture enforcement, audit emission, dispatch, and worker isolation are owned at the agent. (1) **`b.mail.agent.create({ store, audit, permissions, posture, identity, dispatch })`** — facade with 23 methods. Read surface (`search` / `fetch` / `thread` / `folders` / `quota`) is backed by v0.9.19 `b.mailStore` and runs immediately. Move surface (`move` / `flag` / `delete`) is backed by the new `mailStore.moveMessages` substrate; soft-delete moves to Trash + tags `\Deleted` (hard expunge wires at v0.9.28 with retention-floor enforcement). Write surface (`compose` / `send` / `reply` / `forward`), Sieve (`sieve.list/put/activate`), identity (`identity.set` / `vacation.set`), MDN (`mdn.send/parse/allowList`), regulated export, and migration `import` throw `mail-agent/not-implemented` with a `wiredAt` tag naming the slice that lights them up (defer-with-condition per the v1-defensible-scope rule). (2) **Dispatch contract** — `dispatch.mode` is `"local"` / `"queue"` / `"auto"`. `local` runs every method in-process. `queue` publishes envelopes to `mail.agent.tasks` via `b.queue.enqueue`; an `agent.consumer({ agent, queue })` running in a dedicated process or replicas across hosts pulls and executes. Posture metadata travels with each envelope; the consumer re-validates against its own posture before unseal so no posture downgrade survives the queue boundary. `auto` routes fast-path ops (`fetch` / `folders` / `flag` / `quota`) locally and heavy ops (`search` / `export`) to queue/workerPool when configured. (3) **Worker isolation** — `dispatch.workerPool` (composes `b.workerPool`) is validated at create-time; the agent reserves `vaultKeyDelivery: "in-worker"` (default) vs `"main-only"` (posture-conditional — HIPAA/PCI/GDPR default to main-only when the worker-script path wires at v0.9.26 Sieve). (4) **`b.mail.agent.consumer`** — the queue-side facade for multi-host load-spreading; carries its own `store` and re-validates posture at the boundary. (5) **Five new guards** through `b.gateContract` — **`b.guardMailQuery`** (search/fetch filter shape: bounded depth/keys/array-length, function/regex/Buffer/cycle refusal, `__proto__` key refusal, projection-column allowlist via `FILTERABLE_COLUMNS`, posture-required actor fields HIPAA→`purposeOfUse` / PCI→`pciScope` / GDPR→`lawfulBasis`); **`b.guardMailCompose`** (draft envelope: identity-vs-From alignment, recipient deduplication, attachment-byte cap default 25 MiB, body shape — exactly one of text/html unless `allowMultipartAlternative`, C0 control-char refusal in headers); **`b.guardMailReply`** (References-chain cap default 100 defends infinite-loop forwards, In-Reply-To continuity per RFC 5322 §3.6.4 — last References must match In-Reply-To, quoted-original byte cap, forwarded-attachment cardinality cap); **`b.guardMailMove`** (system-folder allowlist for INBOX/Sent/Drafts/Trash/Junk/Archive, admin-scope or `allowedFolders` gate for arbitrary destinations, path-traversal refusal, slash refusal — IMAP `.` hierarchy separator only); **`b.guardMailSieve`** (pre-parser shape-only: script-byte cap default 64 KiB, line-count cap defends one-byte-line bombs, name shape — path-traversal / slash / backslash refusal, actor-ownership check — non-admin actors restricted to their `ownedNames`; full Sieve parse at v0.9.26 via `b.safeSieve`). Each guard ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (6) **`b.mailStore.moveMessages(fromFolder, toFolder, objectIds)`** — per IMAP4rev2 §6.6.2, both folders bump modseq on move; agent.move composes this. (7) Fuzz harnesses ship for every new guard and the v0.9.19 substrates (`safe-mime` / `guard-message-id`) are now wired into the ClusterFuzzLite matrix.
12
+ - v0.9.19 (2026-05-14) — **First slice of the blamepost mail-stack sequence — `b.mailStore` + `b.safeMime` + `b.guardMessageId` substrates.** Byte-level mail-store foundation that every above-the-wire mail primitive composes (agent at v0.9.20, MX listener at v0.9.23, submission listener at v0.9.24, JMAP/IMAP/POP3 at v0.9.26-29, ManageSieve at v0.9.30, DAV at v0.9.32). (1) **`b.safeMime`** — RFC 5322 + 2045/2046/2047/EAI MIME parser. Bounded: total parts cap (default 64), nesting-depth cap (default 16), boundary length cap (default 70 per RFC 2046 §5.1.1), header-bytes cap (default 64 KiB), header-line cap (default 998 per RFC 5322 §2.1.1), body-bytes cap (default 25 MiB), message cap (default 50 MiB), charset allowlist (UTF-8 / US-ASCII / common legacy 8-bit), transfer-encoding allowlist (7bit/8bit/binary/qp/base64). Surface: `parse(bytes, opts) → tree`, `walk(tree, visitor)`, `findFirst(tree, predicate)`, `extractText(tree, opts)` (RFC 2046 §5.1.4 last-wins for `multipart/alternative`), `extractAttachments(tree, opts)`. Includes RFC 2047 Q + B encoded-word decoding for `Subject:` / `From:` etc. + RFC 2231 charset'lang'value filename decoding. Throws `safe-mime/<code>` on every cap exceeded / malformed boundary / unknown charset / unknown CTE / control chars in headers / NUL bytes. **Defends CVE-2024-39929** (Exim MIME multipart parser) and **CVE-2025-30258** (gnumail truncated-MIME-tree class). Fuzz harness ships in `fuzz/safe-mime.fuzz.js`. (2) **`b.guardMessageId`** — RFC 5322 §3.6.4 Message-Id validator. Gates Message-Id / In-Reply-To / References at the mail-store append boundary, the MX inbound boundary (v0.9.23), and the submission outbound path (v0.9.24). Refuses oversized (>998 bytes), bare CR/LF/NUL/C0-control/DEL (header-injection defense — defends `From:` / `Bcc:` smuggling via folded Message-Id continuation), unbracketed under strict profile, empty value, missing `@`, nested angle brackets, bidi codepoints (CVE-2021-42574 RTLO class in mail-header context). Profile family: strict (default) / balanced / permissive. Posture family: hipaa / pci-dss / gdpr / soc2 → all pin profile to strict. Surface: `validate(value, opts)`, `validateList(value, opts)` (References-chain cap = 100), `compliancePosture(posture)`. Fuzz harness ships in `fuzz/guard-message-id.fuzz.js`. (3) **`b.mailStore`** — byte-level mail-store substrate with pluggable backend (sqlite default; operator's `b.externalDb` Postgres or any `{ prepare(sql) → { run, get, all } }`-shaped object). Surface: `create(opts)` returning `{ appendMessage, fetchByObjectId, queryByModseq, setFlags, createFolder, listFolders, threadFor, quota, setLegalHold }`. **Sealed by default** via `b.cryptoField.sealRow` — `subject` / `from_addr` / `to_addrs` / `body_text` / `body_html` route through vault-managed AEAD envelope on insert + unseal on fetch. Plaintext (forensic-queryable without unsealing): `objectid` / `modseq` / `internal_date` / `received_at` / `size_bytes` / `flags` / `legal_hold` / `from_hash` / `message_id_hash`. Per-folder monotonic `modseq` counter (RFC 7162 CONDSTORE substrate). Per-message `objectid` (RFC 8474 JMAP cross-protocol identity). Threading at append time via In-Reply-To + References chain walk (cryptoField.lookupHash for hash-aware threading on sealed columns). Quota substrate (per-folder `used_bytes` + `used_count` maintained atomically). Legal-hold flag composes existing `b.legalHold`. Schema bootstraps at construction with six IMAP4rev2 default folders (INBOX / Sent / Drafts / Trash / Junk / Archive) and JMAP role mapping. Append composes `b.safeMime.parse` (bounded inbound) + `b.guardMessageId.validate` (header-injection gate). **Per the operator-confirmed blamepost roadmap** (`memory/specs/blamepost-roadmap.md`); next slice v0.9.20 wires `b.mail.agent` on top of this substrate.
11
13
  - v0.9.18 (2026-05-14) — **18 CodeQL alerts closed across 4 rule classes + SECURITY.md hardening checklist additions for v0.9.13+ primitives + MIGRATING.md out-of-band breaking-changes section.** Post-v0.9.17 audit identified 18 pre-existing CodeQL security findings on `main` — accumulated over many releases, surfaced explicitly when v0.9.15's rename sweep changed line content. v0.9.18 closes them all. (1) **`js/file-system-race` (6 sites)** — TOCTOU between `fs.existsSync()` / `fs.statSync()` and a subsequent file op. Fixed via the framework's canonical TOCTOU-safe-read scaffold (open fd first → `fstatSync` → `readSync` loop → `closeSync` in `finally`) at `lib/atomic-file.js` (`_readSyncCore`), `lib/restore-rollback.js` (marker write switched to exclusive-create `wx` + EEXIST-tolerant), `lib/network-tls.js` (`_readPathFile` extraction with per-file ENOENT tolerance), `lib/backup/bundle.js` (open-fd-first plus required-vs-skip branch routing), `lib/static.js` (request-serve hot path narrowed to single fd). `lib/vault/seal-pem-file.js` retained as-is with a CodeQL suppression — the site has an in-line `lstat.ino === fstat.ino` inode-equality defense (line 290) that refuses with `seal-pem-file/toctou-detected` if an attacker swaps the file between `lstat` and `open`. (2) **`js/insecure-temporary-file` (6 sites)** — predictable temp paths. `lib/vault/rotate.js` now uses `mkdtempSync` for a per-rotation random scratch dir + plain filenames inside (replaces the predictable `_blamejs_rotate.tmp.db` / `_blamejs_verify.tmp.db` paths in `stagingDir`). `lib/mtls-ca.js` switched to exclusive-create `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` so an attacker pre-creating the path is refused at `EEXIST`. `lib/atomic-file.js` (`fsyncDir`), `lib/vault/rotate.js` (`_fsyncFileByPath`), `lib/http-client.js` (atomic tmp path) retained as-is with suppressions — `dirPath` / `p` are operator-supplied framework data paths (not `os.tmpdir`-reachable), and `tmpPath` carries 16 hex chars of crypto-random suffix (line 1802 `dest + ".tmp-" + bCrypto.generateToken(8)`). (3) **`js/path-injection` (2 sites in `lib/static.js`)** — `nodeFs.createReadStream(absPath)` in `_readMeta` (line 161) and the request-serve hot path (line 1115). Suppression comments added referencing the upstream `_resolveSafe` lexical-resolve + `startsWith(rootResolved + nodePath.sep)` + realpath escape check at lines 181-207 — `absPath` is sandbox-validated against `root` before reaching these lines. (4) **`js/remote-property-injection` (4 sites)** — `lib/websocket.js` (`ext.params: {}` → `Object.create(null)`), `lib/middleware/csrf-protect.js` (`var out = {}` → `Object.create(null)` for cookie-parse output). `lib/middleware/body-parser.js` (multipart `fields[currentField] = ...`) retained as-is with suppression — `currentField` is gated upstream at line 867 by `POISONED_KEYS = new Set(["__proto__", "constructor", "prototype"])` refusing the field BEFORE assignment with a 400 BodyParserError. **Plus: SECURITY.md hardening checklist** gains 5 lines covering `b.middleware.idempotencyKey.dbStore` (hash + seal defaults), `b.metrics.snapshot` (out-of-process metrics export), `b.selfUpdate.standaloneVerifier` (zero-dep install-pipeline verifier), `b.pqcAgent.reload` (TLS-posture refresh without restart), `b.crypto.hashFilesParallel` (parallel SBOM/integrity-sweep hashing). **Plus: MIGRATING.md** now carries an "Out-of-band breaking changes" section (the v0.9.15 dbStore schema break is the first entry); `scripts/gen-migrating.js` extended with an `OUT_OF_BAND_BREAKS` table so future schema/on-disk format breaks land in MIGRATING.md without operators needing to grep CHANGELOG.
12
14
  - v0.9.17 (2026-05-14) — **Two new `codebase-patterns` detectors + 192-site cleanup sweep — `node:` prefix consistency + internal-binding leak prevention.** Post-v0.9.16 audit surfaced two enforceable invariants the existing detectors didn't cover. (1) **`node-builtin-prefix` detector** — every `require("<X>")` of a Node built-in (`fs`, `path`, `crypto`, `stream`, `tls`, `url`, `os`, `net`, `http`, `http2`, `https`, `zlib`, `dgram`, `events`, `child_process`, `readline`, …) must use the modern `require("node:<X>")` form. Three reasons: (a) userland packages on npm CAN be named after built-ins, so without the `node:` prefix a typo or `npm install` accident could shadow the built-in; (b) the prefix is a clearer at-a-glance signal that the dependency is on Node, not on a userland module; (c) bundler / SEA static-trace passes treat `node:` prefix as an unambiguous Node-builtin marker. Sweep: 153 `require()` rewrites across 79 framework files (2 parallel agents). The detector skips JSDoc `@example` block continuation lines (`*`-prefixed), so operator-facing examples that show `var fs = require("fs")` aren't rewritten — operators write their own bindings however they prefer. (2) **`internal-binding-in-prose` detector** — internal binding names (`nodeFs` / `nodePath` / `nodeCrypto` / `nodeStream` / `nodeTls` / `nodeUrl` / `bCrypto` / `retryHelper`) must NOT appear in operator-facing surface: JSDoc/comment continuation lines or string literals (error messages, audit metadata). Operators see the public API name (`path` / `fs` / `crypto` / `retry` / …), never the framework's internal alias. Sweep: 39 prose-leak fixes across 16 files — comments rewritten to use the operator-facing word (`nodePath` → `path`, `nodeFs.watch failed` → `fs.watch failed`, debug-log `"op": "nodeFs.unlinkSync"` → `"op": "fs.unlinkSync"`). (3) **2 follow-on require-binding canonicalizations** surfaced by the node-prefix sweep — `lib/ws-client.js` now destructures `var { EventEmitter } = require("node:events")` (was binding the entire `events` module to a class-shaped name) and `lib/process-spawn.js` renames inline `nodeChild` → `childProcess` (matches the module-level `childProcess` lazyRequire in `lib/dev.js`).
13
15
  - v0.9.16 (2026-05-14) — **Operator-facing prose cleanup + `require-binding-name` detector now covers `lazyRequire` wrappers + dbStore seal round-trip test added.** Post-v0.9.15 audit surfaced three classes of follow-up. (1) **Operator-facing prose leaks (7 sites)** — the v0.9.15 mechanical rename pattern `<OLD>.` → `<NEW>.` also caught occurrences inside JSDoc `@opts` comments and error-message string literals, so operators reading `b.keychain.create(opts)` saw `// absolute nodePath; required if file fallback may engage` instead of `// absolute path`. Fixed: `lib/db.js` (stream-limit error), `lib/keychain.js` (fallback-file error + 3 JSDoc lines), `lib/restore-bundle.js` (staging-dir error), `lib/watcher.js` (fs.watch failure error). Operators see plain English; internal binding names stay internal. (2) **`require-binding-name` detector extended to cover `lazyRequire`** — the v0.9.15 detector only matched plain `var X = require("M")` and missed the framework's `var X = lazyRequire(function () { return require("M"); })` pattern (used to break load cycles). 34 additional inconsistencies surfaced (`auditFwk` / `auditMod` / `auditModule` / `lazyAudit` → `audit`, `crypto` / `fwCrypto` → `bCrypto`, `dbMod` / `dbModule` → `db`, etc.) — every minority site renamed per the same canonical-name map. (3) **dbStore seal round-trip test** — the v0.9.15 test suite covered seal-falls-back-when-vault-not-ready and cross-process-sealed-row-preserved, but did NOT exercise the actual default-ON seal/unseal path because the test environment didn't `b.vault.init(...)`. New `testDbStoreSealRoundTripWithVault` bootstraps a plaintext vault, builds a dbStore with `seal: true`, writes a record + reads it back, and asserts (a) `headers` + `body` columns carry the `vault:` envelope on disk, (b) the round-trip restores the original values, and (c) `status_code` stays plaintext so forensic SELECTs still work without unsealing.
package/index.js CHANGED
@@ -86,6 +86,8 @@ var session = require("./lib/session");
86
86
  var storage = require("./lib/storage");
87
87
  var safeJson = require("./lib/safe-json");
88
88
  var safeJsonPath = require("./lib/safe-jsonpath");
89
+ var safeMime = require("./lib/safe-mime");
90
+ var mailStore = require("./lib/mail-store");
89
91
  var ntpCheck = require("./lib/ntp-check");
90
92
  var auditSign = require("./lib/audit-sign");
91
93
  var objectStore = require("./lib/object-store");
@@ -157,6 +159,12 @@ var guardCsv = require("./lib/guard-csv");
157
159
  var guardHtml = require("./lib/guard-html");
158
160
  var guardSvg = require("./lib/guard-svg");
159
161
  var guardFilename = require("./lib/guard-filename");
162
+ var guardMessageId = require("./lib/guard-message-id");
163
+ var guardMailQuery = require("./lib/guard-mail-query");
164
+ var guardMailCompose = require("./lib/guard-mail-compose");
165
+ var guardMailReply = require("./lib/guard-mail-reply");
166
+ var guardMailMove = require("./lib/guard-mail-move");
167
+ var guardMailSieve = require("./lib/guard-mail-sieve");
160
168
  var guardArchive = require("./lib/guard-archive");
161
169
  var guardJson = require("./lib/guard-json");
162
170
  var guardYaml = require("./lib/guard-yaml");
@@ -394,6 +402,12 @@ module.exports = {
394
402
  guardHtml: guardHtml,
395
403
  guardSvg: guardSvg,
396
404
  guardFilename: guardFilename,
405
+ guardMessageId: guardMessageId,
406
+ guardMailQuery: guardMailQuery,
407
+ guardMailCompose: guardMailCompose,
408
+ guardMailReply: guardMailReply,
409
+ guardMailMove: guardMailMove,
410
+ guardMailSieve: guardMailSieve,
397
411
  guardArchive: guardArchive,
398
412
  guardJson: guardJson,
399
413
  guardYaml: guardYaml,
@@ -473,6 +487,8 @@ module.exports = {
473
487
  flag: flag,
474
488
  safeJson: safeJson,
475
489
  safeJsonPath: safeJsonPath,
490
+ safeMime: safeMime,
491
+ mailStore: mailStore,
476
492
  safeSchema: safeSchema,
477
493
  pagination: pagination,
478
494
  metrics: metrics,
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailCompose
4
+ * @nav Guards
5
+ * @title Guard Mail Compose
6
+ * @order 431
7
+ *
8
+ * @intro
9
+ * Outbound draft validator for `b.mail.agent.compose` /
10
+ * `b.mail.agent.reply` / `b.mail.agent.forward`. Composes the
11
+ * existing `b.guardEmail.validateMessage` for address + header shape
12
+ * and adds compose-specific rules:
13
+ *
14
+ * - identity vs From alignment — operator-supplied `identity.email`
15
+ * must equal the From header local-part + domain (defends spoof-
16
+ * at-submission)
17
+ * - recipient deduplication — Sender / To / Cc / Bcc combined
18
+ * cardinality cap (default 100; envelope-from never duplicated)
19
+ * - attachment byte cap — sum of `body.attachments[*].size_bytes`
20
+ * must not exceed `maxAttachmentBytes` (default 25 MiB to match
21
+ * the RFC 5321 §4.5.3.1.10 receiver cap)
22
+ * - body shape — exactly one of `text` / `html` required (multipart
23
+ * at submission-time per RFC 2046 §5.1.3); both allowed when
24
+ * operator explicitly opts in via `allowMultipartAlternative`
25
+ * - Subject control-char refusal — same C0 / DEL rule the existing
26
+ * `b.guardEmail` applies to header values
27
+ *
28
+ * Profile vocabulary mirrors the rest of the guard family
29
+ * (`strict` / `balanced` / `permissive`); posture vocabulary
30
+ * (`hipaa` / `pci-dss` / `gdpr` / `soc2`) pins `strict`.
31
+ *
32
+ * @card
33
+ * Validates outbound mail drafts at `b.mail.agent.compose`.
34
+ * Identity-vs-From alignment, recipient dedup, attachment byte cap,
35
+ * body shape, header control-char refusal.
36
+ */
37
+
38
+ var { defineClass } = require("./framework-error");
39
+
40
+ var GuardMailComposeError = defineClass("GuardMailComposeError", { alwaysPermanent: true });
41
+
42
+ var DEFAULT_PROFILE = "strict";
43
+
44
+ var PROFILES = Object.freeze({
45
+ strict: { maxRecipients: 100, maxAttachmentBytes: 26214400, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 25 MiB, RFC 5322 §2.1.1 line cap
46
+ balanced: { maxRecipients: 500, maxAttachmentBytes: 52428800, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 50 MiB
47
+ permissive: { maxRecipients: 2000, maxAttachmentBytes: 104857600, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 100 MiB
48
+ });
49
+
50
+ var COMPLIANCE_POSTURES = Object.freeze({
51
+ hipaa: "strict",
52
+ "pci-dss": "strict",
53
+ gdpr: "strict",
54
+ soc2: "strict",
55
+ });
56
+
57
+ /**
58
+ * @primitive b.guardMailCompose.validate
59
+ * @signature b.guardMailCompose.validate(draft, opts?)
60
+ * @since 0.9.20
61
+ * @status stable
62
+ * @related b.guardMailReply, b.guardEmail
63
+ *
64
+ * Validate an outbound draft envelope. Returns the input on success;
65
+ * throws `GuardMailComposeError` on refusal.
66
+ *
67
+ * @opts
68
+ * profile: "strict" | "balanced" | "permissive",
69
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
70
+ * identity: { email: string, name?: string }, // required if checkIdentity
71
+ * checkIdentity: boolean, // default true
72
+ * allowMultipartAlternative: boolean, // default false
73
+ *
74
+ * @example
75
+ * b.guardMailCompose.validate({
76
+ * from: "alice@example.com",
77
+ * to: ["bob@example.com"],
78
+ * subject: "hello",
79
+ * body: { text: "hi" },
80
+ * }, { identity: { email: "alice@example.com" } });
81
+ */
82
+ function validate(draft, opts) {
83
+ opts = opts || {};
84
+ var profileName = _resolveProfile(opts);
85
+ var profile = PROFILES[profileName];
86
+ if (!draft || typeof draft !== "object") {
87
+ throw new GuardMailComposeError("mail-compose/bad-input",
88
+ "guardMailCompose.validate: draft required");
89
+ }
90
+ if (typeof draft.from !== "string" || draft.from.length === 0) {
91
+ throw new GuardMailComposeError("mail-compose/no-from",
92
+ "guardMailCompose.validate: draft.from required");
93
+ }
94
+ _checkHeaderValue(draft.from, "from");
95
+ _checkAddrList(draft.to, "to", profile);
96
+ _checkAddrList(draft.cc, "cc", profile);
97
+ _checkAddrList(draft.bcc, "bcc", profile);
98
+ if (!_anyRecipient(draft)) {
99
+ throw new GuardMailComposeError("mail-compose/no-recipient",
100
+ "guardMailCompose.validate: at least one to/cc/bcc required");
101
+ }
102
+ _checkRecipientCardinality(draft, profile);
103
+
104
+ if (typeof draft.subject !== "undefined") {
105
+ if (typeof draft.subject !== "string") {
106
+ throw new GuardMailComposeError("mail-compose/bad-subject",
107
+ "guardMailCompose.validate: subject must be a string");
108
+ }
109
+ if (Buffer.byteLength(draft.subject, "utf8") > profile.maxSubjectBytes) {
110
+ throw new GuardMailComposeError("mail-compose/subject-too-long",
111
+ "guardMailCompose.validate: subject exceeds maxSubjectBytes=" + profile.maxSubjectBytes);
112
+ }
113
+ _checkHeaderValue(draft.subject, "subject");
114
+ }
115
+
116
+ _checkBody(draft.body, profile, !!opts.allowMultipartAlternative);
117
+
118
+ // Identity vs From alignment — defends spoof-at-submission. When the
119
+ // operator wires an identity for the actor, the draft's From: header
120
+ // must match that identity's email. Disable explicitly via
121
+ // checkIdentity: false (e.g. shared-mailbox submission roles).
122
+ var checkIdentity = opts.checkIdentity !== false;
123
+ if (checkIdentity && opts.identity && opts.identity.email) {
124
+ var fromAddr = _extractAddr(draft.from);
125
+ if (fromAddr.toLowerCase() !== String(opts.identity.email).toLowerCase()) {
126
+ throw new GuardMailComposeError("mail-compose/identity-mismatch",
127
+ "guardMailCompose.validate: From '" + fromAddr +
128
+ "' does not match identity '" + opts.identity.email + "'");
129
+ }
130
+ }
131
+ return draft;
132
+ }
133
+
134
+ /**
135
+ * @primitive b.guardMailCompose.compliancePosture
136
+ * @signature b.guardMailCompose.compliancePosture(posture)
137
+ * @since 0.9.20
138
+ * @status stable
139
+ *
140
+ * Return the effective profile for a given compliance posture name.
141
+ * Returns `null` for unknown posture names so operator typos surface
142
+ * here instead of silently falling through to the default profile.
143
+ *
144
+ * @example
145
+ * b.guardMailCompose.compliancePosture("hipaa"); // → "strict"
146
+ */
147
+ function compliancePosture(posture) {
148
+ return COMPLIANCE_POSTURES[posture] || null;
149
+ }
150
+
151
+ function _checkAddrList(list, label, profile) {
152
+ if (typeof list === "undefined" || list === null) return;
153
+ if (!Array.isArray(list)) {
154
+ throw new GuardMailComposeError("mail-compose/bad-addr-list",
155
+ "guardMailCompose.validate: " + label + " must be an array of strings");
156
+ }
157
+ if (list.length > profile.maxRecipients) {
158
+ throw new GuardMailComposeError("mail-compose/too-many-recipients",
159
+ "guardMailCompose.validate: " + label + " count " + list.length +
160
+ " exceeds maxRecipients=" + profile.maxRecipients);
161
+ }
162
+ for (var i = 0; i < list.length; i += 1) {
163
+ if (typeof list[i] !== "string" || list[i].length === 0) {
164
+ throw new GuardMailComposeError("mail-compose/bad-addr",
165
+ "guardMailCompose.validate: " + label + "[" + i + "] must be a non-empty string");
166
+ }
167
+ _checkHeaderValue(list[i], label + "[" + i + "]");
168
+ var addr = _extractAddr(list[i]);
169
+ if (addr.indexOf("@") < 0) {
170
+ throw new GuardMailComposeError("mail-compose/bad-addr",
171
+ "guardMailCompose.validate: " + label + "[" + i + "] missing '@'");
172
+ }
173
+ }
174
+ }
175
+
176
+ function _checkRecipientCardinality(draft, profile) {
177
+ var all = [];
178
+ ["to", "cc", "bcc"].forEach(function (k) {
179
+ if (Array.isArray(draft[k])) {
180
+ for (var i = 0; i < draft[k].length; i += 1) {
181
+ all.push(_extractAddr(draft[k][i]).toLowerCase());
182
+ }
183
+ }
184
+ });
185
+ if (all.length > profile.maxRecipients) {
186
+ throw new GuardMailComposeError("mail-compose/too-many-recipients",
187
+ "guardMailCompose.validate: combined recipient count " + all.length +
188
+ " exceeds maxRecipients=" + profile.maxRecipients);
189
+ }
190
+ var seen = Object.create(null);
191
+ for (var j = 0; j < all.length; j += 1) {
192
+ if (seen[all[j]]) {
193
+ throw new GuardMailComposeError("mail-compose/duplicate-recipient",
194
+ "guardMailCompose.validate: '" + all[j] + "' appears in multiple recipient fields");
195
+ }
196
+ seen[all[j]] = true;
197
+ }
198
+ }
199
+
200
+ function _checkBody(body, profile, allowAlt) {
201
+ if (!body || typeof body !== "object") {
202
+ throw new GuardMailComposeError("mail-compose/no-body",
203
+ "guardMailCompose.validate: draft.body required");
204
+ }
205
+ var hasText = typeof body.text === "string" && body.text.length > 0;
206
+ var hasHtml = typeof body.html === "string" && body.html.length > 0;
207
+ if (!hasText && !hasHtml) {
208
+ throw new GuardMailComposeError("mail-compose/empty-body",
209
+ "guardMailCompose.validate: body.text or body.html required");
210
+ }
211
+ if (hasText && hasHtml && !allowAlt) {
212
+ throw new GuardMailComposeError("mail-compose/multipart-alternative-disallowed",
213
+ "guardMailCompose.validate: both text + html supplied — set allowMultipartAlternative: true");
214
+ }
215
+ if (Array.isArray(body.attachments)) {
216
+ var total = 0;
217
+ for (var i = 0; i < body.attachments.length; i += 1) {
218
+ var a = body.attachments[i];
219
+ if (!a || typeof a !== "object") {
220
+ throw new GuardMailComposeError("mail-compose/bad-attachment",
221
+ "guardMailCompose.validate: attachment[" + i + "] must be an object");
222
+ }
223
+ var size = typeof a.sizeBytes === "number" ? a.sizeBytes :
224
+ (typeof a.size_bytes === "number" ? a.size_bytes : 0);
225
+ if (size < 0 || !isFinite(size)) {
226
+ throw new GuardMailComposeError("mail-compose/bad-attachment-size",
227
+ "guardMailCompose.validate: attachment[" + i + "].sizeBytes invalid");
228
+ }
229
+ total += size;
230
+ if (total > profile.maxAttachmentBytes) {
231
+ throw new GuardMailComposeError("mail-compose/attachment-too-big",
232
+ "guardMailCompose.validate: attachment total " + total +
233
+ " exceeds maxAttachmentBytes=" + profile.maxAttachmentBytes);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ function _checkHeaderValue(v, label) {
240
+ for (var i = 0; i < v.length; i += 1) {
241
+ var c = v.charCodeAt(i);
242
+ if ((c < 0x20 && c !== 0x09) || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal in header
243
+ throw new GuardMailComposeError("mail-compose/control-char-in-header",
244
+ "guardMailCompose.validate: control char 0x" + c.toString(16) + " in " + label);
245
+ }
246
+ }
247
+ }
248
+
249
+ function _extractAddr(s) {
250
+ var lt = s.indexOf("<");
251
+ var gt = s.lastIndexOf(">");
252
+ if (lt >= 0 && gt > lt) return s.slice(lt + 1, gt).trim();
253
+ return s.trim();
254
+ }
255
+
256
+ function _anyRecipient(draft) {
257
+ return ["to", "cc", "bcc"].some(function (k) {
258
+ return Array.isArray(draft[k]) && draft[k].length > 0;
259
+ });
260
+ }
261
+
262
+ function _resolveProfile(opts) {
263
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
264
+ return COMPLIANCE_POSTURES[opts.posture];
265
+ }
266
+ var p = opts.profile || DEFAULT_PROFILE;
267
+ if (!PROFILES[p]) {
268
+ throw new GuardMailComposeError("mail-compose/bad-profile",
269
+ "guardMailCompose: unknown profile '" + p + "'");
270
+ }
271
+ return p;
272
+ }
273
+
274
+ module.exports = {
275
+ validate: validate,
276
+ compliancePosture: compliancePosture,
277
+ PROFILES: PROFILES,
278
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
279
+ GuardMailComposeError: GuardMailComposeError,
280
+ NAME: "mailCompose",
281
+ KIND: "mail-compose",
282
+ };
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardMailMove
4
+ * @nav Guards
5
+ * @title Guard Mail Move
6
+ * @order 433
7
+ *
8
+ * @intro
9
+ * Destination-folder allowlist validator for `b.mail.agent.move`.
10
+ * Refuses moves to system folders the actor doesn't have admin
11
+ * scope for, refuses cross-account moves (`fromFolder` and
12
+ * `toFolder` must belong to the same agent context), and refuses
13
+ * path-traversal-shaped folder names (`..` / leading `.` / NUL /
14
+ * bidi).
15
+ *
16
+ * System folders the framework treats specially:
17
+ *
18
+ * - **INBOX / Sent / Drafts**: always writable by the owner; no
19
+ * admin scope required.
20
+ * - **Junk / Trash**: always writable (Junk is the default Sieve
21
+ * junk destination; Trash is the soft-delete target).
22
+ * - **Archive**: always writable.
23
+ * - any operator-created folder: writable when in the actor's
24
+ * allowed-folders list (per the operator's RBAC) OR when the
25
+ * actor has `mailScope: "admin"`.
26
+ *
27
+ * The guard does NOT touch the underlying mail-store; that
28
+ * composition lives in `b.mail.agent.move`. The guard validates
29
+ * the SHAPE of the move call.
30
+ *
31
+ * @card
32
+ * Validates `b.mail.agent.move` destination. System-folder allowlist,
33
+ * path-traversal refusal, admin-scope gate for arbitrary destinations.
34
+ */
35
+
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var GuardMailMoveError = defineClass("GuardMailMoveError", { alwaysPermanent: true });
39
+
40
+ var DEFAULT_PROFILE = "strict";
41
+
42
+ var PROFILES = Object.freeze({
43
+ strict: { maxObjectIds: 1000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
44
+ balanced: { maxObjectIds: 5000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
45
+ permissive: { maxObjectIds: 50000, maxFolderNameBytes: 1024 }, // allow:raw-byte-literal
46
+ });
47
+
48
+ var COMPLIANCE_POSTURES = Object.freeze({
49
+ hipaa: "strict",
50
+ "pci-dss": "strict",
51
+ gdpr: "strict",
52
+ soc2: "strict",
53
+ });
54
+
55
+ // System folders every actor may write to without admin scope.
56
+ var SYSTEM_FOLDERS = Object.freeze({
57
+ INBOX: true, Sent: true, Drafts: true, Trash: true, Junk: true, Archive: true,
58
+ });
59
+
60
+ /**
61
+ * @primitive b.guardMailMove.validate
62
+ * @signature b.guardMailMove.validate(move, opts?)
63
+ * @since 0.9.20
64
+ * @status stable
65
+ * @related b.mail.agent.create
66
+ *
67
+ * Validate a `{ actor, fromFolder, toFolder, objectIds }` shape.
68
+ *
69
+ * @opts
70
+ * profile: "strict" | "balanced" | "permissive",
71
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
72
+ *
73
+ * @example
74
+ * b.guardMailMove.validate({
75
+ * actor: { id: "u1", mailScope: "user" },
76
+ * fromFolder: "INBOX",
77
+ * toFolder: "Archive",
78
+ * objectIds: ["abc123"],
79
+ * });
80
+ */
81
+ function validate(move, opts) {
82
+ opts = opts || {};
83
+ var profile = PROFILES[_resolveProfile(opts)];
84
+ if (!move || typeof move !== "object") {
85
+ throw new GuardMailMoveError("mail-move/bad-input",
86
+ "guardMailMove.validate: move required");
87
+ }
88
+ if (!move.actor || typeof move.actor !== "object" || typeof move.actor.id !== "string") {
89
+ throw new GuardMailMoveError("mail-move/no-actor",
90
+ "guardMailMove.validate: move.actor with .id required");
91
+ }
92
+ _checkFolderName(move.fromFolder, "fromFolder", profile);
93
+ _checkFolderName(move.toFolder, "toFolder", profile);
94
+ if (move.fromFolder === move.toFolder) {
95
+ throw new GuardMailMoveError("mail-move/same-folder",
96
+ "guardMailMove.validate: fromFolder and toFolder are the same");
97
+ }
98
+ if (!Array.isArray(move.objectIds)) {
99
+ throw new GuardMailMoveError("mail-move/bad-objectids",
100
+ "guardMailMove.validate: objectIds must be an array");
101
+ }
102
+ if (move.objectIds.length === 0) {
103
+ throw new GuardMailMoveError("mail-move/empty-objectids",
104
+ "guardMailMove.validate: objectIds must be non-empty");
105
+ }
106
+ if (move.objectIds.length > profile.maxObjectIds) {
107
+ throw new GuardMailMoveError("mail-move/too-many-objectids",
108
+ "guardMailMove.validate: objectIds count " + move.objectIds.length +
109
+ " exceeds maxObjectIds=" + profile.maxObjectIds);
110
+ }
111
+ for (var i = 0; i < move.objectIds.length; i += 1) {
112
+ var oid = move.objectIds[i];
113
+ if (typeof oid !== "string" || oid.length === 0) {
114
+ throw new GuardMailMoveError("mail-move/bad-objectid",
115
+ "guardMailMove.validate: objectIds[" + i + "] must be a non-empty string");
116
+ }
117
+ }
118
+
119
+ // System-folder allowlist OR admin scope OR allowed-folders.
120
+ var dest = move.toFolder;
121
+ if (SYSTEM_FOLDERS[dest]) return move;
122
+ var isAdmin = move.actor.mailScope === "admin";
123
+ if (isAdmin) return move;
124
+ var allowed = Array.isArray(move.actor.allowedFolders) ? move.actor.allowedFolders : null;
125
+ if (allowed && allowed.indexOf(dest) >= 0) return move;
126
+ throw new GuardMailMoveError("mail-move/destination-not-allowed",
127
+ "guardMailMove.validate: destination '" + dest +
128
+ "' requires mailScope:'admin' or membership in actor.allowedFolders");
129
+ }
130
+
131
+ /**
132
+ * @primitive b.guardMailMove.compliancePosture
133
+ * @signature b.guardMailMove.compliancePosture(posture)
134
+ * @since 0.9.20
135
+ * @status stable
136
+ *
137
+ * Return the effective profile for a given compliance posture name.
138
+ * Returns `null` for unknown posture names so operator typos surface
139
+ * here instead of silently falling through to the default profile.
140
+ *
141
+ * @example
142
+ * b.guardMailMove.compliancePosture("hipaa"); // → "strict"
143
+ */
144
+ function compliancePosture(posture) {
145
+ return COMPLIANCE_POSTURES[posture] || null;
146
+ }
147
+
148
+ function _checkFolderName(name, label, profile) {
149
+ if (typeof name !== "string" || name.length === 0) {
150
+ throw new GuardMailMoveError("mail-move/bad-folder-name",
151
+ "guardMailMove.validate: " + label + " must be a non-empty string");
152
+ }
153
+ if (Buffer.byteLength(name, "utf8") > profile.maxFolderNameBytes) {
154
+ throw new GuardMailMoveError("mail-move/folder-name-too-long",
155
+ "guardMailMove.validate: " + label + " exceeds maxFolderNameBytes=" + profile.maxFolderNameBytes);
156
+ }
157
+ // Path-traversal / control-char refusal. C0 controls, slash, NUL,
158
+ // leading `.`, and `..` segments are all refused regardless of
159
+ // profile.
160
+ if (name.indexOf("..") >= 0) {
161
+ throw new GuardMailMoveError("mail-move/path-traversal",
162
+ "guardMailMove.validate: " + label + " contains '..'");
163
+ }
164
+ if (name.charAt(0) === ".") {
165
+ throw new GuardMailMoveError("mail-move/hidden-name",
166
+ "guardMailMove.validate: " + label + " starts with '.' (hidden-folder shape refused)");
167
+ }
168
+ for (var i = 0; i < name.length; i += 1) {
169
+ var c = name.charCodeAt(i);
170
+ if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
171
+ throw new GuardMailMoveError("mail-move/control-char-in-name",
172
+ "guardMailMove.validate: " + label + " contains control char 0x" + c.toString(16));
173
+ }
174
+ if (c === 0x2F) { // allow:raw-byte-literal — '/' refusal
175
+ throw new GuardMailMoveError("mail-move/slash-in-name",
176
+ "guardMailMove.validate: " + label + " contains '/' (use IMAP '.' hierarchy separator)");
177
+ }
178
+ }
179
+ }
180
+
181
+ function _resolveProfile(opts) {
182
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
183
+ return COMPLIANCE_POSTURES[opts.posture];
184
+ }
185
+ var p = opts.profile || DEFAULT_PROFILE;
186
+ if (!PROFILES[p]) {
187
+ throw new GuardMailMoveError("mail-move/bad-profile",
188
+ "guardMailMove: unknown profile '" + p + "'");
189
+ }
190
+ return p;
191
+ }
192
+
193
+ module.exports = {
194
+ validate: validate,
195
+ compliancePosture: compliancePosture,
196
+ PROFILES: PROFILES,
197
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
198
+ SYSTEM_FOLDERS: SYSTEM_FOLDERS,
199
+ GuardMailMoveError: GuardMailMoveError,
200
+ NAME: "mailMove",
201
+ KIND: "mail-move",
202
+ };