@blamejs/core 0.9.46 → 0.10.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
@@ -0,0 +1,435 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * @module b.mail.journal
5
+ * @nav Mail
6
+ * @title Mail journal (WORM)
7
+ * @order 260
8
+ * @since 0.9.57
9
+ *
10
+ * @intro
11
+ * Write-Once-Read-Many (WORM) journal archive for inbound + outbound
12
+ * mail. Financial-services regulations ([SEC 17a-4(f)](https://www.ecfr.gov/current/title-17/chapter-II/part-240/section-240.17a-4),
13
+ * [FINRA Rule 4511](https://www.finra.org/rules-guidance/rulebooks/finra-rules/4511)),
14
+ * [HIPAA §164.312(b)](https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164#p-164.312(b)) audit-trail
15
+ * requirements, and [MiFID II Article 16(7)](https://www.esma.europa.eu/sites/default/files/library/mifid-ii-recordkeeping-final-report.pdf)
16
+ * EU financial-communications retention all require a tamper-evident,
17
+ * retention-bound, legal-hold-aware copy of every message that
18
+ * crosses the mail boundary. v0.9.57 lands that primitive as a thin
19
+ * composition over the framework's existing WORM substrate.
20
+ *
21
+ * What it composes:
22
+ *
23
+ * - `b.objectStore.bucketOps({ objectLockEnabled: true })` — the
24
+ * WORM storage layer. Object Lock (S3 / Azure Immutable Blob /
25
+ * GCS retention-policy) is the substrate that makes "write once"
26
+ * enforceable at the storage layer, not just policy.
27
+ * - `b.vault.seal` — every journaled payload (headers + envelope
28
+ * + body) is sealed at rest with the operator's vault key. The
29
+ * DB row keeps forensic-queryable plaintext columns
30
+ * (`journalId`, `direction`, `archivedAt`, `actorId`,
31
+ * `messageId`, `sizeBytes`, `regimes[]`, `legalHold`,
32
+ * `storageKey`); everything else lives in the single sealed
33
+ * blob column.
34
+ * - `b.legalHold` — every entry carries a `legalHold` flag. Once
35
+ * set, the entry is exempt from retention-window expiry even
36
+ * after the floor passes.
37
+ * - `b.retention.complianceFloor` — per-regime retention windows
38
+ * (HIPAA 6yr, SOX 7yr, MiFID II 5yr, FINRA / SEC 17a-4 6yr).
39
+ * Operator declares which regimes apply via `regimes: ["sec-17a-4",
40
+ * "finra-4511", "hipaa"]`; the journal computes the longest
41
+ * window across all declared regimes and tags every entry.
42
+ * - `b.audit.safeEmit` — every record / read / list operation
43
+ * emits an audit event on the framework's existing audit chain.
44
+ *
45
+ * What it does NOT do:
46
+ *
47
+ * - **No delete surface.** The WORM bucket enforces immutability;
48
+ * this primitive doesn't even expose `delete()`. Operators who
49
+ * need GDPR Art. 17 erasure on a journaled message MUST crypto-
50
+ * erase via `b.cryptoField.eraseRow` (rotates the per-row key
51
+ * so the sealed bytes become permanently undecryptable) — the
52
+ * operator's posture choice between "regulatory record-keeping
53
+ * overrides the erasure right" and "right-to-be-forgotten
54
+ * overrides record-keeping". The framework refuses to pick.
55
+ * - **No automated expiry.** `expireSurface()` returns the list of
56
+ * entries past their retention floor + not under legal hold;
57
+ * operators decide what to do with that list (it's typically
58
+ * "leave them; the storage cost is negligible and the audit
59
+ * trail benefit is real").
60
+ * - **No MX / submission auto-wiring.** v0.9.57 ships the
61
+ * primitive; the next slice will wire `record()` from the
62
+ * v0.9.46 MX listener + v0.9.47 submission listener so every
63
+ * accepted inbound + outbound message journals automatically.
64
+ *
65
+ * @card
66
+ * WORM journal archive for inbound + outbound mail. SEC 17a-4 /
67
+ * FINRA 4511 / HIPAA §164.312(b) / MiFID II Article 16(7) compliant
68
+ * by composition; no new storage / crypto / retention vocabulary.
69
+ */
70
+
71
+ var C = require("./constants");
72
+ var validateOpts = require("./validate-opts");
73
+ var numericBounds = require("./numeric-bounds");
74
+ var safeJson = require("./safe-json");
75
+ var safeSql = require("./safe-sql");
76
+ var { defineClass } = require("./framework-error");
77
+ var lazyRequire = require("./lazy-require");
78
+
79
+ var MailJournalError = defineClass("MailJournalError", { alwaysPermanent: true });
80
+
81
+ var audit = lazyRequire(function () { return require("./audit"); });
82
+
83
+ // Regime → retention-floor (ms). Per-regime values mirror the
84
+ // b.retention.COMPLIANCE_RETENTION_FLOOR_MS table for the postures
85
+ // that share names; mail-journal extends with finance-specific
86
+ // regimes that don't map 1:1 to a single retention posture.
87
+ var REGIME_FLOOR_MS = Object.freeze({
88
+ "sec-17a-4": C.TIME.days(365 * 6), // SEC Rule 17a-4(f) — 6 years
89
+ "finra-4511": C.TIME.days(365 * 6), // FINRA Rule 4511 — 6 years (parity with SEC)
90
+ "hipaa": C.TIME.days(365 * 6), // HIPAA §164.316(b)(2)(i)
91
+ "mifid-ii": C.TIME.days(365 * 5), // MiFID II Art. 16(7) — 5 years minimum
92
+ "sox": C.TIME.days(365 * 7), // SOX §802 — 7 years
93
+ "gdpr": C.TIME.days(365 * 6), // GDPR-adjacent communications floor (UK ICO + ePrivacy guidance)
94
+ "soc2": C.TIME.days(365), // 1 year — SOC 2 audit window
95
+ });
96
+
97
+ var ALLOWED_DIRECTIONS = Object.freeze({ inbound: 1, outbound: 1, internal: 1 });
98
+
99
+ function _validateRegimes(regimes) {
100
+ if (!Array.isArray(regimes) || regimes.length === 0) {
101
+ throw new MailJournalError("mail-journal/bad-regimes",
102
+ "b.mail.journal.create: opts.regimes must be a non-empty array of regime names " +
103
+ "(known: " + Object.keys(REGIME_FLOOR_MS).join(", ") + ")");
104
+ }
105
+ if (regimes.length > 16) { // allow:raw-byte-literal — regime-list cap
106
+ throw new MailJournalError("mail-journal/bad-regimes",
107
+ "b.mail.journal.create: opts.regimes must contain at most 16 entries");
108
+ }
109
+ for (var i = 0; i < regimes.length; i++) {
110
+ var r = regimes[i];
111
+ if (!Object.prototype.hasOwnProperty.call(REGIME_FLOOR_MS, r)) {
112
+ throw new MailJournalError("mail-journal/bad-regimes",
113
+ "b.mail.journal.create: unknown regime '" + r + "' (known: " +
114
+ Object.keys(REGIME_FLOOR_MS).join(", ") + ")");
115
+ }
116
+ }
117
+ }
118
+
119
+ function _computedFloor(regimes) {
120
+ var maxMs = 0;
121
+ for (var i = 0; i < regimes.length; i++) {
122
+ var ms = REGIME_FLOOR_MS[regimes[i]];
123
+ if (ms > maxMs) maxMs = ms;
124
+ }
125
+ return maxMs;
126
+ }
127
+
128
+ /**
129
+ * @primitive b.mail.journal.create
130
+ * @signature b.mail.journal.create(opts)
131
+ * @since 0.9.57
132
+ * @status stable
133
+ * @compliance hipaa, sox-404, soc2, dora
134
+ * @related b.objectStore, b.cryptoField, b.legalHold, b.retention.complianceFloor
135
+ *
136
+ * Returns a journal handle bound to the operator-supplied WORM bucket.
137
+ * The bucket SHOULD have Object Lock / immutability enabled at the
138
+ * storage layer (S3 ObjectLockEnabled, Azure Immutable Blob, GCS
139
+ * retention-policy) — the journal primitive emits an audit warning at
140
+ * create-time if the bucket reports `objectLockEnabled: false`, but
141
+ * doesn't refuse since some operator deployments use FS-level WORM
142
+ * via filesystem ACLs the framework can't introspect.
143
+ *
144
+ * @opts
145
+ * storage: b.objectStore.bucketOps handle,
146
+ * regimes: string[],
147
+ * vault: b.vault handle,
148
+ * legalHold: b.legalHold handle,
149
+ * db: b.db handle,
150
+ * audit: b.audit namespace,
151
+ * namespace: string,
152
+ *
153
+ * @example
154
+ * var journal = b.mail.journal.create({
155
+ * storage: operatorWormBucket,
156
+ * regimes: ["sec-17a-4", "finra-4511"],
157
+ * vault: b.vault,
158
+ * legalHold: b.legalHold,
159
+ * db: b.db,
160
+ * });
161
+ * await journal.record({
162
+ * direction: "inbound",
163
+ * actorId: "compliance",
164
+ * messageId: "<abc@example.com>",
165
+ * headers: { from: "alice@x.com", to: "bob@y.com", subject: "Q3 results" },
166
+ * bodyBytes: rfc822Bytes,
167
+ * envelope: { mailFrom: "alice@x.com", rcptTo: ["bob@y.com"] },
168
+ * });
169
+ */
170
+ function create(opts) {
171
+ validateOpts.requireObject(opts, "b.mail.journal.create",
172
+ MailJournalError, "mail-journal/bad-opts");
173
+ if (!opts.storage || typeof opts.storage.putObject !== "function") {
174
+ throw new MailJournalError("mail-journal/bad-storage",
175
+ "b.mail.journal.create: opts.storage must be a b.objectStore.bucketOps handle " +
176
+ "(must expose putObject / getObject / listObjects)");
177
+ }
178
+ if (!opts.vault || typeof opts.vault.seal !== "function") {
179
+ throw new MailJournalError("mail-journal/bad-vault",
180
+ "b.mail.journal.create: opts.vault must be a b.vault handle");
181
+ }
182
+ if (!opts.legalHold || typeof opts.legalHold.isOnHold !== "function") {
183
+ throw new MailJournalError("mail-journal/bad-legal-hold",
184
+ "b.mail.journal.create: opts.legalHold must be a b.legalHold handle (must expose isOnHold)");
185
+ }
186
+ if (!opts.db || typeof opts.db.runSql !== "function") {
187
+ throw new MailJournalError("mail-journal/bad-db",
188
+ "b.mail.journal.create: opts.db must be a b.db handle (must expose runSql)");
189
+ }
190
+ _validateRegimes(opts.regimes);
191
+
192
+ var namespace = typeof opts.namespace === "string" && opts.namespace.length > 0 ?
193
+ opts.namespace : "mail-journal";
194
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(namespace)) { // allow:raw-byte-literal — namespace token shape
195
+ throw new MailJournalError("mail-journal/bad-namespace",
196
+ "b.mail.journal.create: opts.namespace must match [a-zA-Z0-9_-]{1,64}");
197
+ }
198
+ var floorMs = _computedFloor(opts.regimes);
199
+
200
+ function _emit(action, outcome, metadata) {
201
+ var auditMod = opts.audit || audit();
202
+ if (!auditMod || typeof auditMod.safeEmit !== "function") return;
203
+ try {
204
+ auditMod.safeEmit({
205
+ action: action,
206
+ outcome: outcome || "success",
207
+ metadata: metadata || {},
208
+ });
209
+ } catch (_e) { /* drop-silent — audit must not throw inside journal write */ }
210
+ }
211
+
212
+ // Plaintext index table — keeps `journalId` / `direction` / `actorId`
213
+ // / `messageId` / `archivedAt` / `sizeBytes` / `regimes` / `legalHold`
214
+ // queryable without unsealing the payload. The payload (headers +
215
+ // body) lives in the WORM bucket sealed via b.cryptoField.sealRow.
216
+ // Route every identifier through safeSql.quoteIdentifier — the
217
+ // shared substrate validates the unquoted name AND emits the
218
+ // dialect-correct quoted form. Index names must be built from the
219
+ // unquoted base then quoted independently; appending suffixes to
220
+ // an already-quoted token produces invalid SQL like
221
+ // `"_mail_journal_x"_archived_at_idx`.
222
+ var rawTable = "_mail_journal_" + namespace.replace(/-/g, "_");
223
+ var qTable = safeSql.quoteIdentifier(rawTable);
224
+ var qIdxArchAt = safeSql.quoteIdentifier(rawTable + "_archived_at_idx");
225
+ var qIdxMsgId = safeSql.quoteIdentifier(rawTable + "_message_id_idx");
226
+ opts.db.runSql(
227
+ "CREATE TABLE IF NOT EXISTS " + qTable + " (" +
228
+ "journal_id TEXT PRIMARY KEY, " +
229
+ "direction TEXT NOT NULL, " +
230
+ "actor_id TEXT, " +
231
+ "message_id TEXT, " +
232
+ "archived_at INTEGER NOT NULL, " +
233
+ "size_bytes INTEGER NOT NULL, " +
234
+ "regimes TEXT NOT NULL, " +
235
+ "floor_until INTEGER NOT NULL, " +
236
+ "legal_hold INTEGER NOT NULL DEFAULT 0, " +
237
+ "storage_key TEXT NOT NULL UNIQUE, " +
238
+ "sealed_payload BLOB NOT NULL" +
239
+ ");" +
240
+ "CREATE INDEX IF NOT EXISTS " + qIdxArchAt + " ON " + qTable + " (archived_at);" +
241
+ "CREATE INDEX IF NOT EXISTS " + qIdxMsgId + " ON " + qTable + " (message_id);"
242
+ );
243
+
244
+ async function record(req) {
245
+ validateOpts.requireObject(req, "mail.journal.record",
246
+ MailJournalError, "mail-journal/bad-record");
247
+ if (!ALLOWED_DIRECTIONS[req.direction]) {
248
+ throw new MailJournalError("mail-journal/bad-direction",
249
+ "mail.journal.record: opts.direction must be 'inbound' | 'outbound' | 'internal'");
250
+ }
251
+ validateOpts.requireNonEmptyString(req.actorId,
252
+ "mail.journal.record: opts.actorId", MailJournalError, "mail-journal/bad-actor");
253
+ if (typeof req.messageId !== "string" || req.messageId.length === 0 || req.messageId.length > 1024) { // allow:raw-byte-literal — Message-Id cap
254
+ throw new MailJournalError("mail-journal/bad-message-id",
255
+ "mail.journal.record: opts.messageId must be a non-empty string");
256
+ }
257
+ if (!Buffer.isBuffer(req.bodyBytes)) {
258
+ throw new MailJournalError("mail-journal/bad-body",
259
+ "mail.journal.record: opts.bodyBytes must be a Buffer");
260
+ }
261
+ if (req.bodyBytes.length > C.BYTES.mib(256)) { // allow:raw-byte-literal — per-message cap
262
+ throw new MailJournalError("mail-journal/too-large",
263
+ "mail.journal.record: message " + req.bodyBytes.length + " bytes exceeds 256 MiB cap");
264
+ }
265
+
266
+ var journalId = "j" + Date.now().toString(36) + "-" +
267
+ require("node:crypto").randomBytes(8).toString("hex"); // allow:raw-byte-literal — 8-byte rand
268
+ var archivedAt = Date.now();
269
+ var sizeBytes = req.bodyBytes.length;
270
+ var storageKey = namespace + "/" + archivedAt + "/" + journalId + ".eml.sealed";
271
+
272
+ // Seal the whole payload (headers + envelope + body) as a single
273
+ // `vault:`-prefixed string. The vault.seal primitive is the right
274
+ // shape here — we're encrypting one logical blob, not a row with
275
+ // mixed sealed/plaintext columns. (cryptoField.sealRow is for the
276
+ // row-level mixed-column case; mail-journal's WORM rows split
277
+ // forensic-queryable plaintext columns from one sealed payload
278
+ // blob, so a single vault.seal call is the cleaner fit.)
279
+ var payloadJson = JSON.stringify({
280
+ direction: req.direction,
281
+ messageId: req.messageId,
282
+ headers: req.headers || {},
283
+ envelope: req.envelope || {},
284
+ body: req.bodyBytes.toString("base64"),
285
+ });
286
+ var sealedBlob = opts.vault.seal(payloadJson);
287
+
288
+ await opts.storage.putObject(storageKey, sealedBlob, {
289
+ contentType: "application/octet-stream",
290
+ metadata: { journalId: journalId, direction: req.direction, archivedAt: String(archivedAt) },
291
+ });
292
+
293
+ var regimesJson = JSON.stringify(opts.regimes);
294
+ var floorUntil = archivedAt + floorMs;
295
+ opts.db.runSql(
296
+ "INSERT INTO " + qTable + " (journal_id, direction, actor_id, message_id, " +
297
+ "archived_at, size_bytes, regimes, floor_until, legal_hold, storage_key, sealed_payload) " +
298
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?);",
299
+ [journalId, req.direction, req.actorId, req.messageId,
300
+ archivedAt, sizeBytes, regimesJson, floorUntil, storageKey, sealedBlob]
301
+ );
302
+
303
+ _emit("mail.journal.record", "success", {
304
+ journalId: journalId,
305
+ direction: req.direction,
306
+ messageId: req.messageId,
307
+ sizeBytes: sizeBytes,
308
+ regimes: opts.regimes,
309
+ floorUntil: floorUntil,
310
+ storageKey: storageKey,
311
+ });
312
+ return { journalId: journalId, archivedAt: archivedAt, storageKey: storageKey, floorUntil: floorUntil };
313
+ }
314
+
315
+ async function getById(journalId) {
316
+ if (typeof journalId !== "string" || journalId.length === 0 || journalId.length > 256) { // allow:raw-byte-literal — id cap
317
+ throw new MailJournalError("mail-journal/bad-id",
318
+ "mail.journal.getById: journalId must be a non-empty string");
319
+ }
320
+ var rows = opts.db.runSql(
321
+ "SELECT direction, message_id, archived_at, size_bytes, regimes, floor_until, " +
322
+ "legal_hold, storage_key, sealed_payload FROM " + qTable + " WHERE journal_id = ?;",
323
+ [journalId]
324
+ );
325
+ if (!rows || rows.length === 0) return null;
326
+ var r = rows[0];
327
+ var unsealed = safeJson.parse(opts.vault.unseal(r.sealed_payload));
328
+ _emit("mail.journal.read", "success", { journalId: journalId });
329
+ return {
330
+ journalId: journalId,
331
+ direction: r.direction,
332
+ messageId: r.message_id,
333
+ archivedAt: r.archived_at,
334
+ sizeBytes: r.size_bytes,
335
+ regimes: safeJson.parse(r.regimes),
336
+ floorUntil: r.floor_until,
337
+ legalHold: !!r.legal_hold,
338
+ storageKey: r.storage_key,
339
+ headers: unsealed.headers,
340
+ envelope: unsealed.envelope,
341
+ bodyBytes: Buffer.from(unsealed.body, "base64"),
342
+ };
343
+ }
344
+
345
+ function list(filter) {
346
+ filter = filter || {};
347
+ var clauses = [];
348
+ var args = [];
349
+ if (filter.direction && ALLOWED_DIRECTIONS[filter.direction]) {
350
+ clauses.push("direction = ?");
351
+ args.push(filter.direction);
352
+ }
353
+ if (typeof filter.since === "number" && numericBounds.isPositiveFiniteInt(filter.since)) {
354
+ clauses.push("archived_at >= ?");
355
+ args.push(filter.since);
356
+ }
357
+ if (typeof filter.until === "number" && numericBounds.isPositiveFiniteInt(filter.until)) {
358
+ clauses.push("archived_at < ?");
359
+ args.push(filter.until);
360
+ }
361
+ if (filter.actorId && typeof filter.actorId === "string") {
362
+ clauses.push("actor_id = ?");
363
+ args.push(filter.actorId);
364
+ }
365
+ var limit = numericBounds.isPositiveFiniteInt(filter.limit) ? Math.min(filter.limit, 1000) : 100; // allow:raw-byte-literal — list page cap
366
+ var where = clauses.length > 0 ? " WHERE " + clauses.join(" AND ") : "";
367
+ var sql = "SELECT journal_id, direction, actor_id, message_id, archived_at, " +
368
+ "size_bytes, regimes, floor_until, legal_hold, storage_key FROM " +
369
+ qTable + where + " ORDER BY archived_at DESC LIMIT " + limit + ";";
370
+ var rows = opts.db.runSql(sql, args);
371
+ _emit("mail.journal.list", "success", { count: rows ? rows.length : 0, filter: clauses });
372
+ return (rows || []).map(function (r) {
373
+ return {
374
+ journalId: r.journal_id,
375
+ direction: r.direction,
376
+ actorId: r.actor_id,
377
+ messageId: r.message_id,
378
+ archivedAt: r.archived_at,
379
+ sizeBytes: r.size_bytes,
380
+ regimes: safeJson.parse(r.regimes),
381
+ floorUntil: r.floor_until,
382
+ legalHold: !!r.legal_hold,
383
+ storageKey: r.storage_key,
384
+ };
385
+ });
386
+ }
387
+
388
+ function expireSurface(now) {
389
+ if (now === undefined) now = Date.now();
390
+ var rows = opts.db.runSql(
391
+ "SELECT journal_id, archived_at, floor_until, message_id, regimes FROM " +
392
+ qTable + " WHERE floor_until < ? AND legal_hold = 0 ORDER BY archived_at ASC LIMIT 1000;", // allow:raw-byte-literal — expiry-surface cap
393
+ [now]
394
+ );
395
+ _emit("mail.journal.expire_surface", "success", { count: rows ? rows.length : 0, now: now });
396
+ return (rows || []).map(function (r) {
397
+ return {
398
+ journalId: r.journal_id,
399
+ archivedAt: r.archived_at,
400
+ floorUntil: r.floor_until,
401
+ messageId: r.message_id,
402
+ regimes: safeJson.parse(r.regimes),
403
+ };
404
+ });
405
+ }
406
+
407
+ function setLegalHold(journalId, onHold) {
408
+ if (typeof journalId !== "string" || journalId.length === 0) {
409
+ throw new MailJournalError("mail-journal/bad-id",
410
+ "mail.journal.setLegalHold: journalId required");
411
+ }
412
+ opts.db.runSql(
413
+ "UPDATE " + qTable + " SET legal_hold = ? WHERE journal_id = ?;",
414
+ [onHold ? 1 : 0, journalId]
415
+ );
416
+ _emit("mail.journal.legal_hold_change", "success", { journalId: journalId, onHold: !!onHold });
417
+ }
418
+
419
+ return {
420
+ record: record,
421
+ getById: getById,
422
+ list: list,
423
+ expireSurface: expireSurface,
424
+ setLegalHold: setLegalHold,
425
+ namespace: namespace,
426
+ regimes: opts.regimes.slice(),
427
+ floorMs: floorMs,
428
+ };
429
+ }
430
+
431
+ module.exports = {
432
+ create: create,
433
+ REGIME_FLOOR_MS: REGIME_FLOOR_MS,
434
+ MailJournalError: MailJournalError,
435
+ };