@blamejs/core 0.11.21 → 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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.21",
3
+ "version": "0.11.23",
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:a3658057-6230-4935-a5ba-f8e7aaa9a3c7",
5
+ "serialNumber": "urn:uuid:e4f090dd-9a46-4d67-b64e-b7b9105c5254",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-20T23:16:52.590Z",
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.21",
22
+ "bom-ref": "@blamejs/core@0.11.23",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.21",
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.21",
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.21",
57
+ "ref": "@blamejs/core@0.11.23",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]