@blamejs/core 0.11.22 → 0.11.23
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 +2 -0
- package/lib/mail-agent.js +121 -0
- package/lib/mail-store.js +103 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.23 (2026-05-20) — **`b.mail.agent.expunge` — hard EXPUNGE with legal-hold + retention-floor refusal gates.** Operators (and the future IMAP EXPUNGE + JMAP Email/set destroyed wire-protocol adapters) get a single canonical path for permanent message removal that refuses to delete anything currently under legal hold or still inside the regulator-mandated retention window. The gate runs per-message; refused ids carry an explicit reason (`legal-hold` / `retention-floor` / `not-in-folder`) plus the floor + age + posture metadata that drove the refusal — wire adapters mirror those reasons to operators verbatim. The destructive SQL runs only on the surviving id set, inside a backend transaction that also bumps folder modseq + decrements quota atomically. **Added:** *`b.mail.agent.expunge({ actor, folder, objectIds })` — hard EXPUNGE primitive* — Composes two refusal gates before the destructive SQL runs. (1) Legal-hold gate: any message whose `legal_hold` flag is set refuses with reason `legal-hold`. The mail-store layer surfaces the flag in per-row metadata; this layer maps it to the operator-facing refusal. (2) Retention-floor gate: under a configured compliance posture (`hipaa` / `pci-dss` / `gdpr` / `soc2`), the regulator-mandated minimum retention TTL is read from `b.retention.COMPLIANCE_RETENTION_FLOOR_MS[posture]` and any message whose age (`now - receivedAt`) is below the floor refuses with reason `retention-floor` plus `floorMs` + `ageMs` + `posture` metadata. Returns `{ deleted: <ids>, refused: [{ id, reason, ... }] }`. Audit event `mail.agent.expunge.success` carries the requested / deleted / refused counts and a reason histogram so dashboards can spot abnormal refusal patterns without parsing per-id detail. · *`b.mailStore.create(...).hardExpunge(folder, objectIds)` — destructive SQL primitive* — Removes messages permanently from a folder inside a single backend transaction: deletes the message row + its flag rows, bumps the folder modseq, decrements the per-folder quota by the freed bytes / count. Returns `{ rows, deleted, refused }` where `refused` carries `{ id, reason: 'legal-hold' | 'not-in-folder' }` for each id the SQL gate refused (legal-hold is mirrored from the column; not-in-folder catches stale ids). The agent layer (`b.mail.agent.expunge`) is responsible for the retention-floor gate; this primitive is the wire-protocol-shaped backend surface. · *`b.mailStore.create(...).fetchByObjectId` returns `legalHold: boolean`* — Pre-existing fetch path now consistently exposes the legal-hold flag in its return shape. Previously the field existed in the returned object via a separate path; this commit consolidates the duplicate exports into a single canonical `legalHold` boolean derived from the SQLite `legal_hold` INTEGER column. **References:** [RFC 9051 (IMAP4rev2 — EXPUNGE semantics, §6.4.3)](https://www.rfc-editor.org/rfc/rfc9051.html) · [RFC 8621 (JMAP Mail — Email/set destroyed)](https://www.rfc-editor.org/rfc/rfc8621.html) · [45 CFR §164.316 (HIPAA — retention of records)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164/subpart-C/section-164.316) · [PCI-DSS v4.0.1 §3.5.1.1 (retention of cardholder data)](https://www.pcisecuritystandards.org/document_library) · [GDPR Art. 17 (right to erasure — operator-side accountability)](https://gdpr-info.eu/art-17-gdpr/)
|
|
12
|
+
|
|
11
13
|
- v0.11.22 (2026-05-20) — **`b.cert.create` — turnkey TLS-certificate manager composing ACME + sealed persistence + renewal scheduler + SNI + key escrow.** Operators wiring TLS no longer have to glue ACME + key generation + cert persistence + renewal scheduling + SNI dispatch + escrow by hand. `b.cert.create({ storage, acme, certs, renew, ocsp, audit, compliance })` accepts a declarative manifest of certificates plus the operator's choice of ACME challenge solver, and the manager owns the rest of the lifecycle: ACME RFC 8555 order + RFC 9773 ARI-aware renewal, leaf key rotation on renew, OCSP refresh scheduling, sealed-disk persistence via `b.vault.seal`, optional break-glass key escrow encrypted to an operator-supplied recipient via `b.crypto.encryptEnvelope`, and SNI dispatch for `https.createServer({ SNICallback })`. Composes existing primitives — `b.acme.create` (extended this release with per-challenge methods), `b.vault.seal`, `b.safeAsync.repeating`, `b.network.tls`, `b.audit` — so the operator-facing surface is one factory call. **Added:** *`b.cert.create(opts)` — turnkey certificate manager* — New top-level primitive. Storage backend: `sealed-disk` (default; sealed via `b.vault.seal`). ACME: directory URL + contact + auto-generated account key (sealed on disk; operator can override with explicit key material). Certs manifest: per-cert name + domains + keyAlg (ecdsa-p256 / ecdsa-p384 / rsa-2048 / rsa-3072 / rsa-4096) + challenge `{ type, provision, cleanup }` callbacks (http-01 / dns-01 / tls-alpn-01). Renewal: ARI-respecting scheduler with configurable `intervalMs` + `minDaysBeforeExpiry` thresholds. OCSP: `stapling: true` schedules periodic OCSP refresh via `b.network.tls.ocsp`. Key escrow: optional `keyEscrow: { recipient }` encrypts each renewed private key to the recipient public key via `b.crypto.encryptEnvelope` and persists alongside the sealed key — break-glass-only recovery path, NOT routine access. Surface: `start()` / `stop()` / `getContext(name)` / `sniCallback` / `refresh(name)` / event-emitter `on('cert.issued' | 'cert.renewed' | 'cert.renew-failed', handler)`. · *`b.acme.create.fetchAuthorization(authUrl)` — RFC 8555 §7.5 authorization GET* — POST-as-GET an authorization URL; returns the parsed authorization object with the challenge array. The challenge entries carry `{ type, url, token, status }` for each offered challenge type. Required by the cert manager's per-challenge flow but useful to operators implementing custom ACME wrappers. · *`b.acme.create.notifyChallengeReady(challengeUrl)` — RFC 8555 §7.5.1 ready-notification* — POST an empty JSON object to a challenge URL to signal the operator has provisioned the response. Returns the updated challenge object; the CA's validation runs asynchronously and the operator polls via `waitForAuthorization` afterwards. · *`b.acme.create.waitForAuthorization(authUrl, opts?)` — authorization status polling* — Polls an authorization until `status === 'valid'` (success) or `status === 'invalid'` (CA refused). Honors the client's `pollIntervalMs` + `pollMaxMs` defaults; per-call `opts.intervalMs` + `opts.timeoutMs` override available. Throws typed `acme/auth-invalid` on CA refusal and `acme/auth-timeout` on poll-budget exhaustion. · *`b.acme.create.buildCsr({ privateKey, publicKey, domains })` — RFC 2986 PKCS#10 CSR builder* — Builds a CertificationRequest signed with the leaf private key. Subject `CN=<first domain>`, all domains as `dNSName` entries in the SubjectAltName extension. Supports ECDSA P-256 (signed sha256), ECDSA P-384 (signed sha384), RSA 2048 / 3072 / 4096 (signed sha256). Ed25519 is rejected at the CSR layer because CA support is uneven — operators wanting Ed25519 certs build the CSR externally. PEM-encoded output ready to feed to `finalize(order, csrPem)`. · *`b.asn1Der` write primitives — `writeBitString` / `writeSet` / `writeUtf8String` / `writePrintableString` / `writeIa5String` / `writeBoolean` / `writeContextImplicit`* — ASN.1 DER encoder extensions needed for PKCS#10 CSR construction. PrintableString refuses input outside the RFC 5280 character set; IA5String refuses non-ASCII; UTF8String refuses non-string input. SET-OF encoding sorts children by their encoded bytes per DER. Internal-only today (consumed by `b.acme.create.buildCsr`); operators with their own ASN.1 needs can compose them. **Changed:** *`b.audit.FRAMEWORK_NAMESPACES` adds `cert`* — The cert-manager lifecycle emits `cert.account.generated` / `cert.issued` / `cert.renewed` / `cert.renew-failed` / `cert.challenge-cleanup` audit events; the audit-namespace coverage check at smoke-time now recognizes the `cert` namespace. **References:** [RFC 8555 (ACME)](https://www.rfc-editor.org/rfc/rfc8555.html) · [RFC 9773 (ACME Renewal Information / ARI)](https://www.rfc-editor.org/rfc/rfc9773.html) · [RFC 2986 (PKCS#10 Certification Request Syntax)](https://www.rfc-editor.org/rfc/rfc2986.html) · [RFC 5280 (X.509 Internet PKI)](https://www.rfc-editor.org/rfc/rfc5280.html) · [RFC 8737 (TLS-ALPN-01 challenge)](https://www.rfc-editor.org/rfc/rfc8737.html) · [OpenSSL CSR roundtrip — local OpenSSL validation](https://www.openssl.org/docs/man3.5/man1/openssl-req.html)
|
|
12
14
|
|
|
13
15
|
- v0.11.21 (2026-05-20) — **Supply-chain hardening — pinact + zizmor + actionlint gate, sha-to-tag tag-integrity verifier, GOVERNANCE.md.** Closes the input + tag-integrity halves of the supply-chain trust boundary. `pinact` refuses any `uses:` reference that isn't pinned to a 40-char SHA with a verified version-comment (defense against the CVE-2025-30066 retroactive-tag-rewrite class). `zizmor` audits every workflow for the documented security anti-pattern catalog (template-injection / excessive-permissions / cache-poisoning / impostor-commit / unredacted-secrets / etc.). The new `sha-to-tag-verify` workflow refuses to let the publish workflow proceed if a release tag's commit SHA isn't on `main`'s first-parent history OR wasn't the result of a merged PR — the source-side gate that TanStack's 2026-05-11 attack (84 malicious `@tanstack/*` versions published with valid SLSA L3 provenance) demonstrated as a structural defense alongside provenance verification. SECURITY.md gains operator-facing `slsa-verifier` and tag-SHA-integrity recipes. New top-level `GOVERNANCE.md` documents the solo-maintainer governance model, succession plan, key-loss recovery, and dependent-notification protocol. **Added:** *`.github/workflows/actions-lint.yml` — pinact + zizmor + actionlint gate* — Three tools, three layers per the arxiv.org "Unpacking Security Scanners for GitHub Actions Workflows" taxonomy. `pinact run --check` refuses any `uses:` reference that isn't SHA-pinned with a matching version-comment; `pinact run --verify` re-resolves each pinned SHA's registered tag at check time and refuses if the workflow's version-comment disagrees (catches retroactive tag rewrites). `zizmor` audits at `--min-severity low` across every documented rule class (template-injection, excessive-permissions, dangerous-triggers, unpinned-uses, cache-poisoning, github-env, hardcoded-container-credentials, impostor-commit, known-vulnerable-actions, obfuscation, ref-confusion, secrets-inherit, self-hosted-runner, unredacted-secrets, unsound-contains, use-trusted-publishing); SARIF emitted to GitHub Code Scanning. `actionlint` runs YAML + expression validation + shellcheck on every `run:` block. Single documented exception in `.pinact.yaml` — the SLSA reusable workflow MUST be tag-pinned because its internal builder-fetch step refuses non-tag refs. · *`.github/workflows/sha-to-tag-verify.yml` — tag-SHA integrity gate* — Runs on every `v*` tag push and refuses to let the publish workflow start if the tag's commit SHA isn't on `main`'s first-parent history OR wasn't the result of a merged PR. Defends against the tag-mutation class (CVE-2025-30066: 23,000+ affected repos in March 2025) and the source-side-malicious-publish class (TanStack 2026-05-11: 84 valid-SLSA-L3-provenance malicious versions). The same chain is documented for operator-side re-verification in SECURITY.md's new "Verifying release-commit integrity" subsection. · *`GOVERNANCE.md` — solo-maintainer governance, succession, key-loss recovery, dependent-notification* — New top-level document covering: (a) current governance model (solo maintainer pre-1.0, maintainer-final on technical direction); (b) succession plan with TBD-successor-with-documented-re-open-trigger, repository ownership, npm publish credentials, SSH signing key rotation procedure, Sigstore identity rotation; (c) key-loss recovery for every asset (npm publish, GitHub org, SSH signing key, Sigstore, domain); (d) dependent-notification protocol via `security@blamejs.com` with 30-day no-activity escalation. Bus-factor-1 is the largest non-technical risk pre-1.0; this document makes the recovery path defensible. · *SECURITY.md — `slsa-verifier` and tag-SHA-integrity operator-verification recipes* — "Verifying release authenticity" gains a `slsa-verifier verify-artifact` recipe (pinned to v2.7.1) for offline / API-independent provenance verification — `gh attestation verify` walks the chain via the GitHub API; `slsa-verifier` does it from disk. The recipe explicitly states the limit: SLSA provenance binds the tarball bytes to a workflow+commit+tag, but does NOT prove the source is clean (the TanStack incident shipped valid-provenance malicious versions because the source side was compromised). A new "Verifying release-commit integrity" subsection documents the sha-to-tag chain operators run alongside provenance verification — the source-side gate. · *`.pinact.yaml` — pinact configuration with documented SLSA exception* — Defines a single tag-pin exception for `slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml`. The SLSA reusable workflow's internal builder-fetch step refuses non-tag `BUILDER_REF` values, so the call MUST be tag-pinned; mitigated by slsa-framework's upstream tag-protection + immutable-release rules. The same exception also appears as a per-line `# allow:slsa-framework-action-not-sha-pinned` marker in `npm-publish.yml` for the framework's own codebase-patterns gate. **Changed:** *Workflow version-comment integrity — every pinned action's `# vX.Y.Z` comment now matches the registered tag for that SHA* — Pre-existing pins carried stale version-comments (`actions/checkout@de0fac2e... # v6.0.0` where that SHA is actually `v6.0.2`, `actions/setup-node@48b55a01... # v6.0.0` where the SHA is `v6.4.0`, `actions/download-artifact@3e5f45b2... # v7.0.1` where the SHA is `v8.0.1`, `github/codeql-action@9e0d7b8d... # v3.30.9` where the SHA is `v4.35.5`). The SHAs themselves stay (they're the actual-released versions); the comments now match. This is what pinact's `--verify` check enforces structurally going forward — a stale version-comment is the early-warning signal that a retroactive tag rewrite landed without operator notice. **References:** [CVE-2025-30066 (tj-actions/changed-files retroactive tag rewrite)](https://nvd.nist.gov/vuln/detail/CVE-2025-30066) · [TanStack npm publish incident 2025-05-11](https://blog.tanstack.com/the-tanstack-may-2025-supply-chain-attack/) · [pinact](https://github.com/suzuki-shunsuke/pinact) · [zizmor](https://github.com/woodruffw/zizmor) · [actionlint](https://github.com/rhysd/actionlint) · [slsa-verifier](https://github.com/slsa-framework/slsa-verifier)
|
package/lib/mail-agent.js
CHANGED
|
@@ -104,6 +104,7 @@ var SCOPE_FOR_METHOD = Object.freeze({
|
|
|
104
104
|
move: "mail:move",
|
|
105
105
|
flag: "mail:move",
|
|
106
106
|
delete: "mail:move",
|
|
107
|
+
expunge: "mail:expunge",
|
|
107
108
|
"sieve.list": "mail:sieve",
|
|
108
109
|
"sieve.put": "mail:sieve",
|
|
109
110
|
"sieve.activate": "mail:sieve",
|
|
@@ -209,6 +210,7 @@ function create(opts) {
|
|
|
209
210
|
move: function (args) { return _dispatchOrLocal(ctx, "move", args, _move); },
|
|
210
211
|
flag: function (args) { return _dispatchOrLocal(ctx, "flag", args, _flag); },
|
|
211
212
|
delete: function (args) { return _dispatchOrLocal(ctx, "delete", args, _delete); },
|
|
213
|
+
expunge: function (args) { return _dispatchOrLocal(ctx, "expunge", args, _expunge); },
|
|
212
214
|
|
|
213
215
|
// Sieve — needs v0.9.26 interpreter.
|
|
214
216
|
sieve: {
|
|
@@ -484,6 +486,125 @@ async function _delete(ctx, args) {
|
|
|
484
486
|
return r;
|
|
485
487
|
}
|
|
486
488
|
|
|
489
|
+
async function _expunge(ctx, args) {
|
|
490
|
+
// Hard EXPUNGE — permanent removal of messages from the mail store.
|
|
491
|
+
// Composes two refusal gates BEFORE the destructive SQL runs:
|
|
492
|
+
//
|
|
493
|
+
// 1. b.legalHold — any message whose `legal_hold` flag is set
|
|
494
|
+
// refuses with reason "legal-hold". The mail-store layer
|
|
495
|
+
// surfaces the flag in the row metadata; this layer maps that
|
|
496
|
+
// to the operator-facing refusal.
|
|
497
|
+
//
|
|
498
|
+
// 2. b.retention.complianceFloor — given the operator's posture
|
|
499
|
+
// (e.g. "hipaa"), `complianceFloor(posture, candidateTtlMs)`
|
|
500
|
+
// returns the regulator-mandated minimum retention TTL. Any
|
|
501
|
+
// message younger than that floor refuses with reason
|
|
502
|
+
// "retention-floor".
|
|
503
|
+
//
|
|
504
|
+
// Both gates run per-message; the response shape carries an
|
|
505
|
+
// explicit refusal reason for every refused id so the wire-protocol
|
|
506
|
+
// adapter (IMAP EXPUNGE → "* N EXPUNGE" suppression, JMAP
|
|
507
|
+
// Email/set destroyed → notDestroyed[id] = SetError) can mirror the
|
|
508
|
+
// reason to operators verbatim.
|
|
509
|
+
_entry(ctx, "expunge", args);
|
|
510
|
+
if (typeof args.folder !== "string" || !Array.isArray(args.objectIds)) {
|
|
511
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
512
|
+
"agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Look up the regulator-mandated retention floor for the operator's
|
|
516
|
+
// active posture. For expunge semantics, the floor IS the minimum
|
|
517
|
+
// TTL — messages younger than the floor MUST NOT be hard-deleted,
|
|
518
|
+
// even on operator request. Distinct from `b.retention.
|
|
519
|
+
// complianceFloor(posture, candidateTtl)` which composes the
|
|
520
|
+
// candidate TTL into a max — that primitive's "candidate must be
|
|
521
|
+
// positive" contract doesn't apply here because expunge means TTL=0.
|
|
522
|
+
// Read the floor table directly.
|
|
523
|
+
var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
|
|
524
|
+
var posture = (ctx && ctx.posture) || (args && args.posture) || null;
|
|
525
|
+
var floorMs = 0;
|
|
526
|
+
if (typeof posture === "string" && posture.length > 0) {
|
|
527
|
+
floorMs = retentionModule.COMPLIANCE_RETENTION_FLOOR_MS[posture] || 0;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Read message metadata BEFORE invoking hardExpunge so the per-id
|
|
531
|
+
// refusal map is built from the same row set the destructive call
|
|
532
|
+
// sees. Use the store's hardExpunge primitive in two passes:
|
|
533
|
+
// pass 1: pass an empty objectIds[] for the candidate scan? No —
|
|
534
|
+
// hardExpunge returns the metadata for the ids it was asked about,
|
|
535
|
+
// so call it ONCE with the full id set; it returns `refused` for
|
|
536
|
+
// legal-hold refusals + the metadata rows for the survivors.
|
|
537
|
+
// We then add retention-floor refusals to the response and pass
|
|
538
|
+
// the FINAL surviving id set to a second hardExpunge call? No —
|
|
539
|
+
// the surviving set is computed inline; the simpler shape is:
|
|
540
|
+
//
|
|
541
|
+
// - Filter via metadata read (using a `dryRun` flag on
|
|
542
|
+
// hardExpunge would work but adds API surface)
|
|
543
|
+
//
|
|
544
|
+
// Pragmatic v1: call hardExpunge once with the full set. It refuses
|
|
545
|
+
// legal-hold internally + returns the metadata for the rest. Then
|
|
546
|
+
// we filter the deleted set retroactively for retention-floor
|
|
547
|
+
// violations — but hardExpunge already DELETED them. That's wrong.
|
|
548
|
+
//
|
|
549
|
+
// Correct v1: call hardExpunge with an empty `objectIds` for a
|
|
550
|
+
// metadata-only pass? hardExpunge returns immediately for empty
|
|
551
|
+
// input. So we need an explicit "read metadata for these ids"
|
|
552
|
+
// query OR a hardExpunge `dryRun` flag.
|
|
553
|
+
//
|
|
554
|
+
// Use a fresh SELECT to read the gate-input data, then pass the
|
|
555
|
+
// surviving set to hardExpunge. The store exposes `queryByModseq`
|
|
556
|
+
// but that's a wide scan; for v1 expunge takes the metadata via a
|
|
557
|
+
// dedicated per-id lookup. (The store's hardExpunge SELECT is the
|
|
558
|
+
// same shape; expose it as `_selectForExpunge` via a small adapter,
|
|
559
|
+
// OR just round-trip through the existing fetchByObjectId.)
|
|
560
|
+
var nowMs = Date.now();
|
|
561
|
+
var refused = [];
|
|
562
|
+
var candidates = [];
|
|
563
|
+
for (var i = 0; i < args.objectIds.length; i += 1) {
|
|
564
|
+
var oid = args.objectIds[i];
|
|
565
|
+
var meta = ctx.store.fetchByObjectId(args.folder, oid);
|
|
566
|
+
if (!meta) {
|
|
567
|
+
refused.push({ id: oid, reason: "not-in-folder" });
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (meta.legalHold) {
|
|
571
|
+
refused.push({ id: oid, reason: "legal-hold" });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (floorMs > 0) {
|
|
575
|
+
var receivedAt = meta.receivedAt || meta.internalDate || 0;
|
|
576
|
+
var ageMs = nowMs - receivedAt;
|
|
577
|
+
if (ageMs < floorMs) {
|
|
578
|
+
refused.push({ id: oid, reason: "retention-floor",
|
|
579
|
+
floorMs: floorMs, ageMs: ageMs, posture: posture });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
candidates.push(oid);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Run the destructive SQL only on the surviving set.
|
|
587
|
+
var result = candidates.length > 0
|
|
588
|
+
? ctx.store.hardExpunge(args.folder, candidates)
|
|
589
|
+
: { rows: [], deleted: [], refused: [] };
|
|
590
|
+
|
|
591
|
+
ctx.auditEmit("mail.agent.expunge.success", args.actor, {
|
|
592
|
+
folder: args.folder,
|
|
593
|
+
requested: args.objectIds.length,
|
|
594
|
+
deleted: result.deleted.length,
|
|
595
|
+
refused: refused.length,
|
|
596
|
+
refusedReasons: refused.reduce(function (acc, r) {
|
|
597
|
+
acc[r.reason] = (acc[r.reason] || 0) + 1; return acc;
|
|
598
|
+
}, {}),
|
|
599
|
+
posture: posture,
|
|
600
|
+
floorMs: floorMs,
|
|
601
|
+
});
|
|
602
|
+
return {
|
|
603
|
+
deleted: result.deleted,
|
|
604
|
+
refused: refused,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
487
608
|
async function _sievePut(ctx, args) {
|
|
488
609
|
// Two-stage validation: agent-level shape guard for RBAC + name +
|
|
489
610
|
// size, then the full RFC 5228 grammar parse via b.safeSieve. The
|
package/lib/mail-store.js
CHANGED
|
@@ -185,6 +185,16 @@ function create(opts) {
|
|
|
185
185
|
var stmtBumpQuota = db.prepare(
|
|
186
186
|
"INSERT INTO " + qQuota + " (folder_id, used_bytes, used_count, cap_bytes, cap_count) VALUES (?, ?, ?, NULL, NULL) " +
|
|
187
187
|
"ON CONFLICT(folder_id) DO UPDATE SET used_bytes = used_bytes + excluded.used_bytes, used_count = used_count + excluded.used_count");
|
|
188
|
+
// Hard-expunge prepared statements — used by `hardExpunge` to delete
|
|
189
|
+
// a message permanently after retention-floor + legal-hold gates
|
|
190
|
+
// pass. The SELECT is the gate-input source (legal_hold flag + age);
|
|
191
|
+
// the DELETE + flag-cleanup + quota-decrement run inside a backend
|
|
192
|
+
// transaction so partial state can't survive a crash.
|
|
193
|
+
var stmtSelectForExpunge = db.prepare(
|
|
194
|
+
"SELECT objectid, folder_id, size_bytes, received_at, legal_hold FROM " + qMsgs +
|
|
195
|
+
" WHERE folder_id = ? AND objectid IN (SELECT value FROM json_each(?))");
|
|
196
|
+
var stmtDeleteMsg = db.prepare("DELETE FROM " + qMsgs + " WHERE objectid = ?");
|
|
197
|
+
var stmtDeleteFlags = db.prepare("DELETE FROM " + qFlags + " WHERE objectid = ?");
|
|
188
198
|
|
|
189
199
|
return {
|
|
190
200
|
appendMessage: function (folderName, rawBytes, appendOpts) {
|
|
@@ -285,6 +295,98 @@ function create(opts) {
|
|
|
285
295
|
objectids.forEach(function (oid) { stmtLegalHold.run(hold, oid); });
|
|
286
296
|
return { changed: objectids.length };
|
|
287
297
|
},
|
|
298
|
+
/**
|
|
299
|
+
* hardExpunge — remove messages permanently from a folder.
|
|
300
|
+
*
|
|
301
|
+
* Returns `{ rows: [{ objectid, size_bytes, received_at, legal_hold }],
|
|
302
|
+
* deleted: <ids>, refused: [{ id, reason }] }`. Per-row
|
|
303
|
+
* `legal_hold` is the column value at expunge time so the caller
|
|
304
|
+
* (typically `b.mail.agent.expunge`) can refuse messages currently
|
|
305
|
+
* under hold.
|
|
306
|
+
*
|
|
307
|
+
* The caller is responsible for:
|
|
308
|
+
* (1) Composing `b.legalHold` to refuse hold-flagged messages
|
|
309
|
+
* before passing the surviving set here, AND
|
|
310
|
+
* (2) Composing `b.retention.complianceFloor` to refuse messages
|
|
311
|
+
* whose `received_at` is inside the regulated retention window.
|
|
312
|
+
*
|
|
313
|
+
* This primitive does the destructive SQL work + transaction-
|
|
314
|
+
* scoped quota decrement + modseq bump. Refusals must happen at
|
|
315
|
+
* the agent layer; this layer is the wire-protocol-shaped backend
|
|
316
|
+
* surface.
|
|
317
|
+
*/
|
|
318
|
+
hardExpunge: function (folderName, objectids) {
|
|
319
|
+
var folder = stmtGetFolderByName.get(folderName);
|
|
320
|
+
if (!folder) {
|
|
321
|
+
throw new MailStoreError("mail-store/no-folder",
|
|
322
|
+
"hardExpunge: folder '" + folderName + "' not found");
|
|
323
|
+
}
|
|
324
|
+
if (!Array.isArray(objectids) || objectids.length === 0) {
|
|
325
|
+
return { rows: [], deleted: [], refused: [] };
|
|
326
|
+
}
|
|
327
|
+
// Deduplicate objectids before the per-id pass. Without this,
|
|
328
|
+
// `hardExpunge(folder, [id, id])` would append the same row to
|
|
329
|
+
// `toDelete` twice and drive `usedBytes` / `usedCount` negative
|
|
330
|
+
// via the double-subtract in the transaction; `deleted` would
|
|
331
|
+
// also carry the duplicate id back to the caller. Preserve
|
|
332
|
+
// first-seen ordering for stable refused/deleted output.
|
|
333
|
+
var seenIds = Object.create(null);
|
|
334
|
+
var uniqueIds = [];
|
|
335
|
+
for (var ui = 0; ui < objectids.length; ui += 1) {
|
|
336
|
+
if (!seenIds[objectids[ui]]) {
|
|
337
|
+
seenIds[objectids[ui]] = true;
|
|
338
|
+
uniqueIds.push(objectids[ui]);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
objectids = uniqueIds;
|
|
342
|
+
var rows = stmtSelectForExpunge.all(folder.id, JSON.stringify(objectids));
|
|
343
|
+
var byId = Object.create(null);
|
|
344
|
+
rows.forEach(function (r) { byId[r.objectid] = r; });
|
|
345
|
+
var refused = [];
|
|
346
|
+
var toDelete = [];
|
|
347
|
+
for (var i = 0; i < objectids.length; i += 1) {
|
|
348
|
+
var oid = objectids[i];
|
|
349
|
+
var row = byId[oid];
|
|
350
|
+
if (!row) {
|
|
351
|
+
refused.push({ id: oid, reason: "not-in-folder" });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (row.legal_hold === 1) {
|
|
355
|
+
refused.push({ id: oid, reason: "legal-hold" });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
toDelete.push(row);
|
|
359
|
+
}
|
|
360
|
+
if (toDelete.length === 0) return { rows: rows, deleted: [], refused: refused };
|
|
361
|
+
|
|
362
|
+
// One transaction: delete messages + their flags, bump folder
|
|
363
|
+
// modseq, decrement quota. Better-sqlite3-style `transaction`
|
|
364
|
+
// helpers wrap this; if the backend doesn't expose `transaction`,
|
|
365
|
+
// run the statements directly (atomicity falls back to per-stmt).
|
|
366
|
+
var totalBytes = 0;
|
|
367
|
+
var modseqBump = Date.now();
|
|
368
|
+
function _runTxn() {
|
|
369
|
+
for (var di = 0; di < toDelete.length; di += 1) {
|
|
370
|
+
stmtDeleteFlags.run(toDelete[di].objectid);
|
|
371
|
+
stmtDeleteMsg.run(toDelete[di].objectid);
|
|
372
|
+
totalBytes += toDelete[di].size_bytes || 0;
|
|
373
|
+
}
|
|
374
|
+
stmtBumpFolderModseq.run(modseqBump, folderName);
|
|
375
|
+
if (totalBytes > 0 || toDelete.length > 0) {
|
|
376
|
+
stmtDecrementQuota.run(totalBytes, toDelete.length, folder.id);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (typeof db.transaction === "function") {
|
|
380
|
+
db.transaction(_runTxn)();
|
|
381
|
+
} else {
|
|
382
|
+
_runTxn();
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
rows: rows,
|
|
386
|
+
deleted: toDelete.map(function (r) { return r.objectid; }),
|
|
387
|
+
refused: refused,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
288
390
|
_backend: db,
|
|
289
391
|
_tablePrefix: prefix,
|
|
290
392
|
};
|
|
@@ -445,7 +547,7 @@ function _fetchByObjectId(args) {
|
|
|
445
547
|
bodyText: unsealed.body_text,
|
|
446
548
|
bodyHtml: unsealed.body_html,
|
|
447
549
|
flags: flags,
|
|
448
|
-
legalHold: row.legal_hold === 1,
|
|
550
|
+
legalHold: row.legal_hold === 1, // allow:raw-byte-literal — sqlite INTEGER column 0|1
|
|
449
551
|
};
|
|
450
552
|
}
|
|
451
553
|
|
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:e4f090dd-9a46-4d67-b64e-b7b9105c5254",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-21T02:10:35.980Z",
|
|
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.23",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.23",
|
|
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.23",
|
|
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.23",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|