@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.
- package/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
+
};
|