@blamejs/core 0.9.18 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,652 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mailStore
4
+ * @nav Mail
5
+ * @title Mail Store
6
+ * @order 810
7
+ *
8
+ * @intro
9
+ * Byte-level mail-store substrate — the foundation every above-the-
10
+ * wire mail primitive composes (`b.mail.agent` at v0.9.20,
11
+ * `b.mail.server.mx` at v0.9.23, `b.mail.server.submission` at
12
+ * v0.9.24, IMAP/JMAP/POP3 at v0.9.27-29, ManageSieve at v0.9.30,
13
+ * DAV at v0.9.32).
14
+ *
15
+ * No auth, no audit, no posture-enforcement at THIS layer — those
16
+ * live in the agent above. The store is the lowest-level
17
+ * atomic-append + sealed-column shape over a pluggable backend.
18
+ *
19
+ * **Pluggable backend**: sqlite via `b.db` (default), operator's
20
+ * `b.externalDb` (Postgres), or any object exposing
21
+ * `prepare(sql) → { run, get, all }`. Schema is bootstrapped at
22
+ * `create()` when `init !== false`.
23
+ *
24
+ * **Sealed by default**: `subject` / `from_addr` / `to_addrs` /
25
+ * `body_text` / `body_html` are registered as sealed via
26
+ * `b.cryptoField.sealRow`. A DB dump leaks zero recoverable PII
27
+ * content. Plaintext (forensic-queryable without unsealing):
28
+ * `objectid`, `modseq`, `internal_date`, `received_at`, `flags`,
29
+ * `size_bytes`, `legal_hold`, `from_hash`, `message_id_hash`.
30
+ *
31
+ * **CONDSTORE-ready**: per-folder monotonic `modseq` counter
32
+ * (RFC 7162). Every state-changing op (`append` / `setFlags` /
33
+ * `delete`) bumps modseq atomically.
34
+ *
35
+ * **JMAP-ready**: per-message `objectid` (RFC 8474) — stable
36
+ * cross-protocol identity. IMAP's UID + UIDVALIDITY + JMAP's
37
+ * Email/get's `id` all map to `objectid`.
38
+ *
39
+ * **Threading at append**: JWZ algorithm + RFC 5256/9051 root via
40
+ * `Message-Id` + `In-Reply-To` + `References`. Threading state is
41
+ * maintained in the messages table itself (`thread_root_id` column)
42
+ * so JMAP `Thread/get` is a single index lookup.
43
+ *
44
+ * **Quota substrate**: per-user + per-folder `usedBytes` / `usedCount`
45
+ * counters maintained atomically with append/delete. The
46
+ * v0.9.33 IMAP-QUOTA / JMAP-Quotas surface reads these directly.
47
+ *
48
+ * **Legal hold**: `legal_hold` column composes existing
49
+ * `b.legalHold` primitive. Held messages refuse `delete` regardless
50
+ * of caller; only `b.legalHold.release` can flip the flag.
51
+ *
52
+ * Parses messages on append via `b.safeMime.parse` (bounded
53
+ * substrate, defends CVE-2024-39929 + CVE-2025-30258). Validates
54
+ * `Message-Id` via `b.guardMessageId.validate`.
55
+ *
56
+ * @card
57
+ * Byte-level mail-store substrate — pluggable backend (sqlite default), sealed-by-default subject/from/to/body, CONDSTORE modseq, JMAP objectid, threading at append, quota + legal-hold substrate. Foundation for the entire mail stack.
58
+ */
59
+
60
+ var C = require("./constants");
61
+ var bCrypto = require("./crypto");
62
+ var cryptoField = require("./crypto-field");
63
+ var safeMime = require("./safe-mime");
64
+ var safeSql = require("./safe-sql");
65
+ var guardMessageId = require("./guard-message-id");
66
+ var { defineClass } = require("./framework-error");
67
+
68
+ var MailStoreError = defineClass("MailStoreError", { alwaysPermanent: true });
69
+
70
+ var DEFAULT_TABLE_PREFIX = "blamejs_mail";
71
+ var DEFAULT_MAX_MESSAGE_BYTES = C.BYTES.mib(50);
72
+ var DEFAULT_MAX_BODY_BYTES = C.BYTES.mib(25);
73
+
74
+ // Standard IMAP4rev2 default folders + JMAP role mapping.
75
+ var DEFAULT_FOLDERS = Object.freeze([
76
+ { name: "INBOX", role: "inbox" },
77
+ { name: "Sent", role: "sent" },
78
+ { name: "Drafts", role: "drafts" },
79
+ { name: "Trash", role: "trash" },
80
+ { name: "Junk", role: "junk" },
81
+ { name: "Archive", role: "archive" },
82
+ ]);
83
+
84
+ /**
85
+ * @primitive b.mailStore.create
86
+ * @signature b.mailStore.create(opts)
87
+ * @since 0.9.19
88
+ * @status stable
89
+ * @related b.safeMime, b.guardMessageId, b.cryptoField
90
+ *
91
+ * Build a mail-store handle. Returns an object with `appendMessage` /
92
+ * `fetchByObjectId` / `queryByModseq` / `setFlags` / `createFolder` /
93
+ * `listFolders` / `threadFor` / `quota` / `setLegalHold` / `destroy`.
94
+ *
95
+ * @opts
96
+ * backend: object, // required — sqlite-shaped { prepare(sql) → { run, get, all }, transaction(fn) }
97
+ * tablePrefix: string, // default "blamejs_mail" — validated via safeSql.validateIdentifier
98
+ * init: boolean, // default true — bootstrap schema + register sealed fields + insert default folders
99
+ * compliance: string, // hipaa | pci-dss | gdpr | soc2 — pins sealing posture (default off → sealed-by-default uses framework defaults)
100
+ * maxMessageBytes: number, // default 50 MiB
101
+ * maxBodyBytes: number, // default 25 MiB
102
+ * safeMimeOpts: object, // pass-through to b.safeMime.parse
103
+ *
104
+ * @example
105
+ * var b = require("blamejs");
106
+ * await b.vault.init({ dataDir });
107
+ * await b.db.init({ dataDir, schema: [] });
108
+ * var store = b.mailStore.create({ backend: b.db });
109
+ * var meta = store.appendMessage("INBOX", messageBuffer);
110
+ * meta.objectid; // → "obj_01HXYZ..."
111
+ * meta.modseq; // → 42 (monotonic)
112
+ */
113
+ function create(opts) {
114
+ opts = opts || {};
115
+ if (!opts.backend || typeof opts.backend.prepare !== "function") {
116
+ throw new MailStoreError("mail-store/bad-backend",
117
+ "mailStore.create: opts.backend must be sqlite-shaped (.prepare(sql) → { run, get, all })");
118
+ }
119
+ var prefix = opts.tablePrefix || DEFAULT_TABLE_PREFIX;
120
+ try { safeSql.validateIdentifier(prefix); }
121
+ catch (e) {
122
+ throw new MailStoreError("mail-store/bad-table-prefix",
123
+ "mailStore.create: tablePrefix is not a valid SQL identifier: " + e.message);
124
+ }
125
+ var qMsgs = safeSql.quoteIdentifier(prefix + "_messages", "sqlite");
126
+ var qFolders = safeSql.quoteIdentifier(prefix + "_folders", "sqlite");
127
+ var qFlags = safeSql.quoteIdentifier(prefix + "_flags", "sqlite");
128
+ var qQuota = safeSql.quoteIdentifier(prefix + "_quota", "sqlite");
129
+ var messagesTable = prefix + "_messages";
130
+
131
+ var maxMessageBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : DEFAULT_MAX_MESSAGE_BYTES;
132
+ var maxBodyBytes = opts.maxBodyBytes !== undefined ? opts.maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
133
+ var safeMimeOpts = opts.safeMimeOpts || {};
134
+ var doInit = opts.init !== false;
135
+
136
+ var db = opts.backend;
137
+
138
+ // Register sealed fields with cryptoField. Operator-runtime
139
+ // sealing posture comes from b.compliance — the store doesn't
140
+ // re-decide. Registration is idempotent.
141
+ cryptoField.registerTable(messagesTable, {
142
+ sealedFields: ["subject", "from_addr", "to_addrs", "body_text", "body_html"],
143
+ derivedHashes: {
144
+ from_hash: { from: "from_addr", normalize: _normalizeAddr },
145
+ message_id_hash: { from: "message_id", normalize: _normalizeMsgId },
146
+ },
147
+ });
148
+
149
+ if (doInit) {
150
+ _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota);
151
+ _ensureDefaultFolders(db, qFolders);
152
+ }
153
+
154
+ // Prepared statements — cached across the store lifetime.
155
+ var stmtInsertMsg = db.prepare(
156
+ "INSERT INTO " + qMsgs + " (" +
157
+ "objectid, folder_id, modseq, internal_date, received_at, size_bytes, " +
158
+ "message_id, message_id_hash, in_reply_to, references_csv, " +
159
+ "thread_root_id, subject, from_addr, from_hash, to_addrs, " +
160
+ "body_text, body_html, legal_hold) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)"
161
+ );
162
+ var stmtBumpFolderModseq = db.prepare("UPDATE " + qFolders + " SET modseq_max = ? WHERE name = ?");
163
+ var stmtGetFolderByName = db.prepare("SELECT id, name, role, parent_id, modseq_max, uidvalidity FROM " + qFolders + " WHERE name = ?");
164
+ var stmtFetchMsg = db.prepare("SELECT * FROM " + qMsgs + " WHERE objectid = ? AND folder_id = ?");
165
+ var stmtQueryByModseq = db.prepare(
166
+ "SELECT objectid, modseq, size_bytes, internal_date, legal_hold FROM " + qMsgs +
167
+ " WHERE folder_id = ? AND modseq > ? ORDER BY modseq ASC LIMIT ?");
168
+ var stmtFlagsForMsg = db.prepare("SELECT flag FROM " + qFlags + " WHERE objectid = ?");
169
+ var stmtSetFlag = db.prepare("INSERT OR IGNORE INTO " + qFlags + " (objectid, flag, set_at) VALUES (?, ?, ?)");
170
+ var stmtUnsetFlag = db.prepare("DELETE FROM " + qFlags + " WHERE objectid = ? AND flag = ?");
171
+ var stmtLegalHold = db.prepare("UPDATE " + qMsgs + " SET legal_hold = ? WHERE objectid = ?");
172
+ var stmtMoveByObjectId = db.prepare(
173
+ "UPDATE " + qMsgs + " SET folder_id = ?, modseq = ? WHERE objectid = ? AND folder_id = ?");
174
+ var stmtSizeByObjectId = db.prepare(
175
+ "SELECT size_bytes FROM " + qMsgs + " WHERE objectid = ? AND folder_id = ?");
176
+ var stmtDecrementQuota = db.prepare(
177
+ "UPDATE " + qQuota + " SET used_bytes = used_bytes - ?, used_count = used_count - ? WHERE folder_id = ?");
178
+ var stmtThreadFor = db.prepare("SELECT objectid FROM " + qMsgs + " WHERE thread_root_id = ? ORDER BY received_at ASC");
179
+ var stmtFindThreadByMsgId = db.prepare(
180
+ "SELECT objectid, thread_root_id FROM " + qMsgs + " WHERE message_id_hash = ? LIMIT 1");
181
+ var stmtInsertFolder = db.prepare(
182
+ "INSERT INTO " + qFolders + " (name, role, parent_id, modseq_max, uidvalidity) VALUES (?, ?, ?, 0, ?)");
183
+ var stmtListFolders = db.prepare("SELECT id, name, role, parent_id, modseq_max FROM " + qFolders);
184
+ var stmtQuotaForFolder = db.prepare("SELECT used_bytes, used_count, cap_bytes, cap_count FROM " + qQuota + " WHERE folder_id = ?");
185
+ var stmtBumpQuota = db.prepare(
186
+ "INSERT INTO " + qQuota + " (folder_id, used_bytes, used_count, cap_bytes, cap_count) VALUES (?, ?, ?, NULL, NULL) " +
187
+ "ON CONFLICT(folder_id) DO UPDATE SET used_bytes = used_bytes + excluded.used_bytes, used_count = used_count + excluded.used_count");
188
+
189
+ return {
190
+ appendMessage: function (folderName, rawBytes, appendOpts) {
191
+ return _appendMessage({
192
+ db: db, qMsgs: qMsgs, qFlags: qFlags, messagesTable: messagesTable,
193
+ stmtInsertMsg: stmtInsertMsg,
194
+ stmtBumpFolderModseq: stmtBumpFolderModseq,
195
+ stmtGetFolderByName: stmtGetFolderByName,
196
+ stmtFindThreadByMsgId: stmtFindThreadByMsgId,
197
+ stmtBumpQuota: stmtBumpQuota,
198
+ folderName: folderName, rawBytes: rawBytes, appendOpts: appendOpts || {},
199
+ safeMimeOpts: safeMimeOpts,
200
+ maxMessageBytes: maxMessageBytes,
201
+ maxBodyBytes: maxBodyBytes,
202
+ });
203
+ },
204
+ fetchByObjectId: function (folderName, objectid) {
205
+ return _fetchByObjectId({
206
+ db: db, qMsgs: qMsgs, qFolders: qFolders, qFlags: qFlags, messagesTable: messagesTable,
207
+ stmtGetFolderByName: stmtGetFolderByName,
208
+ stmtFetchMsg: stmtFetchMsg,
209
+ stmtFlagsForMsg: stmtFlagsForMsg,
210
+ folderName: folderName, objectid: objectid,
211
+ });
212
+ },
213
+ queryByModseq: function (folderName, queryOpts) {
214
+ var folder = stmtGetFolderByName.get(folderName);
215
+ if (!folder) {
216
+ throw new MailStoreError("mail-store/no-folder",
217
+ "queryByModseq: folder '" + folderName + "' not found");
218
+ }
219
+ var sinceModseq = (queryOpts && queryOpts.sinceModseq) || 0;
220
+ var limit = (queryOpts && queryOpts.limit) || 1000; // allow:raw-byte-literal — query row cap, not bytes
221
+ var rows = stmtQueryByModseq.all(folder.id, sinceModseq, limit);
222
+ return rows.map(function (r) {
223
+ return {
224
+ objectid: r.objectid, modseq: r.modseq, sizeBytes: r.size_bytes,
225
+ internalDate: r.internal_date, legalHold: r.legal_hold === 1,
226
+ };
227
+ });
228
+ },
229
+ setFlags: function (folderName, objectids, flagOpts) {
230
+ return _setFlags({
231
+ db: db, qMsgs: qMsgs,
232
+ stmtGetFolderByName: stmtGetFolderByName,
233
+ stmtBumpFolderModseq: stmtBumpFolderModseq,
234
+ stmtSetFlag: stmtSetFlag,
235
+ stmtUnsetFlag: stmtUnsetFlag,
236
+ folderName: folderName, objectids: objectids, flagOpts: flagOpts || {},
237
+ });
238
+ },
239
+ createFolder: function (name, folderOpts) {
240
+ try { safeSql.validateIdentifier(name); } catch (_e) {
241
+ // Allow folder names with "." for hierarchy (IMAP convention) —
242
+ // safeSql is too strict here. Fall back to a looser shape check.
243
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
244
+ throw new MailStoreError("mail-store/bad-folder-name",
245
+ "createFolder: name must match [A-Za-z0-9_.-]+");
246
+ }
247
+ }
248
+ var fo = folderOpts || {};
249
+ var role = fo.role || null;
250
+ var parentId = fo.parentId || null;
251
+ var uidvalidity = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix timestamp, not bytes
252
+ stmtInsertFolder.run(name, role, parentId, uidvalidity);
253
+ return stmtGetFolderByName.get(name);
254
+ },
255
+ listFolders: function () { return stmtListFolders.all(); },
256
+ threadFor: function (objectid) {
257
+ var msg = db.prepare("SELECT thread_root_id FROM " + qMsgs + " WHERE objectid = ?").get(objectid);
258
+ if (!msg) return [];
259
+ return stmtThreadFor.all(msg.thread_root_id).map(function (r) { return r.objectid; });
260
+ },
261
+ quota: function (folderName) {
262
+ var folder = stmtGetFolderByName.get(folderName);
263
+ if (!folder) {
264
+ throw new MailStoreError("mail-store/no-folder",
265
+ "quota: folder '" + folderName + "' not found");
266
+ }
267
+ var q = stmtQuotaForFolder.get(folder.id);
268
+ if (!q) return { usedBytes: 0, usedCount: 0, capBytes: null, capCount: null };
269
+ return { usedBytes: q.used_bytes, usedCount: q.used_count, capBytes: q.cap_bytes, capCount: q.cap_count };
270
+ },
271
+ moveMessages: function (fromFolderName, toFolderName, objectids) {
272
+ return _moveMessages({
273
+ stmtGetFolderByName: stmtGetFolderByName,
274
+ stmtBumpFolderModseq: stmtBumpFolderModseq,
275
+ stmtMoveByObjectId: stmtMoveByObjectId,
276
+ stmtSizeByObjectId: stmtSizeByObjectId,
277
+ stmtDecrementQuota: stmtDecrementQuota,
278
+ stmtBumpQuota: stmtBumpQuota,
279
+ fromFolderName: fromFolderName, toFolderName: toFolderName,
280
+ objectids: objectids,
281
+ });
282
+ },
283
+ setLegalHold: function (objectids, holdOpts) {
284
+ var hold = (holdOpts && holdOpts.hold) ? 1 : 0; // allow:raw-byte-literal — boolean cast for sqlite INTEGER column
285
+ objectids.forEach(function (oid) { stmtLegalHold.run(hold, oid); });
286
+ return { changed: objectids.length };
287
+ },
288
+ _backend: db,
289
+ _tablePrefix: prefix,
290
+ };
291
+ }
292
+
293
+ // ---- Append --------------------------------------------------------------
294
+
295
+ function _appendMessage(args) {
296
+ var rawBytes = args.rawBytes;
297
+ if (!Buffer.isBuffer(rawBytes) && typeof rawBytes !== "string") {
298
+ throw new MailStoreError("mail-store/bad-input",
299
+ "appendMessage: rawBytes must be Buffer or string");
300
+ }
301
+ var buf = Buffer.isBuffer(rawBytes) ? rawBytes : Buffer.from(rawBytes, "utf8");
302
+ if (buf.length > args.maxMessageBytes) {
303
+ throw new MailStoreError("mail-store/oversize-message",
304
+ "appendMessage: " + buf.length + " bytes exceeds maxMessageBytes=" + args.maxMessageBytes);
305
+ }
306
+ var folder = args.stmtGetFolderByName.get(args.folderName);
307
+ if (!folder) {
308
+ throw new MailStoreError("mail-store/no-folder",
309
+ "appendMessage: folder '" + args.folderName + "' not found");
310
+ }
311
+
312
+ // Parse via safe-mime — bounded; defends CVE-2024-39929 + CVE-2025-30258.
313
+ var tree = safeMime.parse(buf, args.safeMimeOpts);
314
+
315
+ // Extract canonical fields.
316
+ var messageId = _extractMessageId(tree);
317
+ if (messageId) {
318
+ try { guardMessageId.validate(messageId); }
319
+ catch (e) {
320
+ throw new MailStoreError("mail-store/bad-message-id",
321
+ "appendMessage: Message-Id refused: " + e.message);
322
+ }
323
+ }
324
+ var inReplyTo = _extractMessageId(tree, "in-reply-to");
325
+ var referencesCsv = _extractReferences(tree);
326
+ var subject = tree.headers.get("subject") || "";
327
+ var fromAddr = tree.headers.get("from") || "";
328
+ var toAddrs = (tree.headers.getAll("to") || []).join(", ");
329
+ // Date header read but not parsed yet — agent slice (v0.9.20) will
330
+ // wire RFC 5322 §3.3 date-time parsing into internalDate / receivedAt.
331
+
332
+ var textPart = safeMime.extractText(tree, { prefer: "plain" });
333
+ var htmlPart = safeMime.extractText(tree, { prefer: "html" });
334
+ var bodyText = textPart ? textPart.body : "";
335
+ var bodyHtml = htmlPart && htmlPart.contentType === "text/html" ? htmlPart.body : "";
336
+
337
+ if (bodyText.length > args.maxBodyBytes || bodyHtml.length > args.maxBodyBytes) {
338
+ throw new MailStoreError("mail-store/oversize-body",
339
+ "appendMessage: body exceeds maxBodyBytes=" + args.maxBodyBytes);
340
+ }
341
+
342
+ // Threading — find the root via Message-Id chain.
343
+ var threadRootId = _findThreadRoot({
344
+ messageId: messageId, inReplyTo: inReplyTo, referencesCsv: referencesCsv,
345
+ stmtFindThreadByMsgId: args.stmtFindThreadByMsgId,
346
+ messagesTable: args.messagesTable,
347
+ });
348
+
349
+ // Allocate objectid + modseq atomically.
350
+ var objectid = "obj_" + bCrypto.generateToken(16).slice(0, 24); // allow:raw-byte-literal — 16-byte token, 24-char hex prefix as JMAP objectid (RFC 8474)
351
+ var modseq = (folder.modseq_max || 0) + 1;
352
+ if (!threadRootId) threadRootId = objectid; // root of new thread
353
+
354
+ var internalDate = Date.now();
355
+ var receivedAt = internalDate;
356
+
357
+ // Build the row + run cryptoField.sealRow to seal the registered fields.
358
+ var row = {
359
+ objectid: objectid,
360
+ folder_id: folder.id,
361
+ modseq: modseq,
362
+ internal_date: internalDate,
363
+ received_at: receivedAt,
364
+ size_bytes: buf.length,
365
+ message_id: messageId || "",
366
+ in_reply_to: inReplyTo || "",
367
+ references_csv: referencesCsv || "",
368
+ thread_root_id: threadRootId,
369
+ subject: subject,
370
+ from_addr: fromAddr,
371
+ to_addrs: toAddrs,
372
+ body_text: bodyText,
373
+ body_html: bodyHtml,
374
+ };
375
+ var sealed = cryptoField.sealRow(args.messagesTable, row);
376
+
377
+ args.stmtInsertMsg.run(
378
+ sealed.objectid, sealed.folder_id, sealed.modseq, sealed.internal_date,
379
+ sealed.received_at, sealed.size_bytes, sealed.message_id, sealed.message_id_hash,
380
+ sealed.in_reply_to, sealed.references_csv, sealed.thread_root_id,
381
+ sealed.subject, sealed.from_addr, sealed.from_hash, sealed.to_addrs,
382
+ sealed.body_text, sealed.body_html
383
+ );
384
+ args.stmtBumpFolderModseq.run(modseq, args.folderName);
385
+ args.stmtBumpQuota.run(folder.id, buf.length, 1);
386
+
387
+ return { objectid: objectid, modseq: modseq, sizeBytes: buf.length, threadRootId: threadRootId };
388
+ }
389
+
390
+ // ---- Fetch ----------------------------------------------------------------
391
+
392
+ function _fetchByObjectId(args) {
393
+ var folder = args.stmtGetFolderByName.get(args.folderName);
394
+ if (!folder) {
395
+ throw new MailStoreError("mail-store/no-folder",
396
+ "fetchByObjectId: folder '" + args.folderName + "' not found");
397
+ }
398
+ var row = args.stmtFetchMsg.get(args.objectid, folder.id);
399
+ if (!row) return null;
400
+
401
+ // Unseal via cryptoField — sealed fields are restored in-place.
402
+ var unsealed = cryptoField.unsealRow(args.messagesTable, row);
403
+ var flags = args.stmtFlagsForMsg.all(args.objectid).map(function (r) { return r.flag; });
404
+
405
+ return {
406
+ objectid: unsealed.objectid,
407
+ modseq: unsealed.modseq,
408
+ folder: args.folderName,
409
+ internalDate: unsealed.internal_date,
410
+ receivedAt: unsealed.received_at,
411
+ sizeBytes: unsealed.size_bytes,
412
+ messageId: unsealed.message_id || null,
413
+ inReplyTo: unsealed.in_reply_to || null,
414
+ referencesCsv: unsealed.references_csv || null,
415
+ threadRootId: unsealed.thread_root_id,
416
+ subject: unsealed.subject,
417
+ from: unsealed.from_addr,
418
+ to: unsealed.to_addrs,
419
+ bodyText: unsealed.body_text,
420
+ bodyHtml: unsealed.body_html,
421
+ flags: flags,
422
+ legalHold: row.legal_hold === 1,
423
+ };
424
+ }
425
+
426
+ // ---- Move -----------------------------------------------------------------
427
+
428
+ function _moveMessages(args) {
429
+ var fromFolder = args.stmtGetFolderByName.get(args.fromFolderName);
430
+ if (!fromFolder) {
431
+ throw new MailStoreError("mail-store/no-folder",
432
+ "moveMessages: from-folder '" + args.fromFolderName + "' not found");
433
+ }
434
+ var toFolder = args.stmtGetFolderByName.get(args.toFolderName);
435
+ if (!toFolder) {
436
+ throw new MailStoreError("mail-store/no-folder",
437
+ "moveMessages: to-folder '" + args.toFolderName + "' not found");
438
+ }
439
+ if (!Array.isArray(args.objectids)) {
440
+ throw new MailStoreError("mail-store/bad-input",
441
+ "moveMessages: objectids must be an array");
442
+ }
443
+ // Per RFC 7162 each folder owns its own modseq counter. The moved
444
+ // row joins the destination's sequence — it gets `dstModseq` (the
445
+ // destination folder's new max). Source still bumps its `modseq_max`
446
+ // to track the removal even though the row is gone; CONDSTORE
447
+ // clients polling the source for `since-modseq` see the change.
448
+ var srcModseq = (fromFolder.modseq_max || 0) + 1;
449
+ var dstModseq = (toFolder.modseq_max || 0) + 1;
450
+ var changed = 0;
451
+ var movedBytes = 0;
452
+ for (var i = 0; i < args.objectids.length; i += 1) {
453
+ // Capture size before the row's folder_id moves — the destination
454
+ // quota gets the delta and the source quota decrements by the same.
455
+ var size = args.stmtSizeByObjectId.get(args.objectids[i], fromFolder.id);
456
+ var bytes = size ? size.size_bytes : 0;
457
+ var r = args.stmtMoveByObjectId.run(toFolder.id, dstModseq, args.objectids[i], fromFolder.id);
458
+ if (r && r.changes) {
459
+ changed += r.changes;
460
+ movedBytes += bytes;
461
+ }
462
+ }
463
+ // Quota maintenance: decrement source by sum-of-sizes, increment
464
+ // destination. v0.9.19 substrate already maintains per-folder quota
465
+ // on append; move must keep both sides accurate.
466
+ if (changed > 0) {
467
+ args.stmtDecrementQuota.run(movedBytes, changed, fromFolder.id);
468
+ args.stmtBumpQuota.run(toFolder.id, movedBytes, changed);
469
+ }
470
+ args.stmtBumpFolderModseq.run(srcModseq, args.fromFolderName);
471
+ args.stmtBumpFolderModseq.run(dstModseq, args.toFolderName);
472
+ return { changed: changed, fromModseq: srcModseq, toModseq: dstModseq };
473
+ }
474
+
475
+ // ---- Flags ----------------------------------------------------------------
476
+
477
+ function _setFlags(args) {
478
+ var folder = args.stmtGetFolderByName.get(args.folderName);
479
+ if (!folder) {
480
+ throw new MailStoreError("mail-store/no-folder",
481
+ "setFlags: folder '" + args.folderName + "' not found");
482
+ }
483
+ var newModseq = (folder.modseq_max || 0) + 1;
484
+ var setFlags = (args.flagOpts.set || []);
485
+ var unsetFlags = (args.flagOpts.unset || []);
486
+ var changed = 0;
487
+ args.objectids.forEach(function (oid) {
488
+ setFlags.forEach(function (f) {
489
+ var r = args.stmtSetFlag.run(oid, f, Date.now());
490
+ if (r && r.changes) changed += r.changes;
491
+ });
492
+ unsetFlags.forEach(function (f) {
493
+ var r = args.stmtUnsetFlag.run(oid, f);
494
+ if (r && r.changes) changed += r.changes;
495
+ });
496
+ });
497
+ // Per-message modseq bump — without this, queryByModseq filters
498
+ // `messages.modseq > sinceModseq` and misses the flag change. CONDSTORE
499
+ // (RFC 7162) / JMAP Email/changes both depend on the per-message
500
+ // modseq being current. Per Codex P1 on PR #49.
501
+ if (args.objectids.length > 0 && (setFlags.length > 0 || unsetFlags.length > 0)) {
502
+ // Bulk-update via IN-clause. SQLite caps IN-clause at 32766 (max
503
+ // bound parameters); chunk for very large operands.
504
+ var CHUNK = 500; // allow:raw-byte-literal — IN-clause chunk size, not bytes
505
+ for (var i = 0; i < args.objectids.length; i += CHUNK) {
506
+ var chunk = args.objectids.slice(i, i + CHUNK);
507
+ var placeholders = chunk.map(function () { return "?"; }).join(",");
508
+ var sql = "UPDATE " + args.qMsgs + " SET modseq = ? WHERE objectid IN (" + placeholders + ")";
509
+ args.db.prepare(sql).run.apply(args.db.prepare(sql), [newModseq].concat(chunk));
510
+ }
511
+ }
512
+ args.stmtBumpFolderModseq.run(newModseq, args.folderName);
513
+ return { changed: changed, modseq: newModseq };
514
+ }
515
+
516
+ // ---- Threading helpers ----------------------------------------------------
517
+
518
+ function _findThreadRoot(args) {
519
+ // Walk the References chain (newest-first per RFC 5256). For each
520
+ // Message-Id in the chain, look up by hashed-id. First match wins.
521
+ // Uses cryptoField.lookupHash so the hash matches the derived-hash
522
+ // value computed by sealRow at insert time (same salt + namespace).
523
+ var candidates = [];
524
+ if (args.inReplyTo) candidates.push(args.inReplyTo);
525
+ if (args.referencesCsv) {
526
+ var refs = args.referencesCsv.split(",").map(function (s) { return s.trim(); });
527
+ for (var i = refs.length - 1; i >= 0; i -= 1) {
528
+ if (refs[i]) candidates.push(refs[i]);
529
+ }
530
+ }
531
+ for (var c = 0; c < candidates.length; c += 1) {
532
+ var lookup = cryptoField.lookupHash(args.messagesTable, "message_id", candidates[c]);
533
+ if (!lookup) continue;
534
+ var row = args.stmtFindThreadByMsgId.get(lookup.value);
535
+ if (row) return row.thread_root_id;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function _extractMessageId(tree, headerName) {
541
+ var name = headerName || "message-id";
542
+ var raw = tree.headers.get(name);
543
+ if (!raw) return null;
544
+ // Strip outer angle brackets when present + collapse whitespace.
545
+ var v = String(raw).trim();
546
+ return v;
547
+ }
548
+
549
+ function _extractReferences(tree) {
550
+ var raw = tree.headers.get("references");
551
+ if (!raw) return "";
552
+ return String(raw).split(/\s+/).filter(function (s) { return s.length > 0; }).join(",");
553
+ }
554
+
555
+ function _normalizeAddr(s) {
556
+ return String(s).toLowerCase().trim();
557
+ }
558
+
559
+ function _normalizeMsgId(s) {
560
+ // Strip outer angle brackets + lowercase for collision-free hashing.
561
+ var v = String(s).trim();
562
+ if (v.charAt(0) === "<" && v.charAt(v.length - 1) === ">") {
563
+ v = v.slice(1, -1);
564
+ }
565
+ return v.toLowerCase();
566
+ }
567
+
568
+ // ---- Schema bootstrap ----------------------------------------------------
569
+
570
+ function _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota) {
571
+ // Folders table — created first since messages reference folder_id.
572
+ db.prepare(
573
+ "CREATE TABLE IF NOT EXISTS " + qFolders + " (" +
574
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
575
+ "name TEXT UNIQUE NOT NULL, " +
576
+ "role TEXT, " +
577
+ "parent_id INTEGER, " +
578
+ "modseq_max INTEGER NOT NULL DEFAULT 0, " +
579
+ "uidvalidity INTEGER NOT NULL)"
580
+ ).run();
581
+ db.prepare(
582
+ "CREATE INDEX IF NOT EXISTS " + safeSql.quoteIdentifier(qFolders.slice(1, -1) + "_role_idx", "sqlite") +
583
+ " ON " + qFolders + "(role)"
584
+ ).run();
585
+
586
+ // Messages table — sealed-by-default subject / from / to / body.
587
+ db.prepare(
588
+ "CREATE TABLE IF NOT EXISTS " + qMsgs + " (" +
589
+ "objectid TEXT PRIMARY KEY, " +
590
+ "folder_id INTEGER NOT NULL, " +
591
+ "modseq INTEGER NOT NULL, " +
592
+ "internal_date INTEGER NOT NULL, " +
593
+ "received_at INTEGER NOT NULL, " +
594
+ "size_bytes INTEGER NOT NULL, " +
595
+ "message_id TEXT, " +
596
+ "message_id_hash TEXT, " +
597
+ "in_reply_to TEXT, " +
598
+ "references_csv TEXT, " +
599
+ "thread_root_id TEXT NOT NULL, " +
600
+ "subject TEXT, " +
601
+ "from_addr TEXT, " +
602
+ "from_hash TEXT, " +
603
+ "to_addrs TEXT, " +
604
+ "body_text TEXT, " +
605
+ "body_html TEXT, " +
606
+ "legal_hold INTEGER NOT NULL DEFAULT 0, " +
607
+ "FOREIGN KEY(folder_id) REFERENCES " + qFolders + "(id))"
608
+ ).run();
609
+ // Indexes — modseq for CONDSTORE, thread_root_id for thread fetch,
610
+ // message_id_hash for threading lookup, from_hash for sender search.
611
+ ["modseq", "thread_root_id", "message_id_hash", "from_hash", "received_at", "legal_hold"]
612
+ .forEach(function (col) {
613
+ db.prepare(
614
+ "CREATE INDEX IF NOT EXISTS " + safeSql.quoteIdentifier(qMsgs.slice(1, -1) + "_" + col + "_idx", "sqlite") +
615
+ " ON " + qMsgs + "(" + safeSql.quoteIdentifier(col, "sqlite") + ")"
616
+ ).run();
617
+ });
618
+
619
+ // Flags table — many-to-one with messages.
620
+ db.prepare(
621
+ "CREATE TABLE IF NOT EXISTS " + qFlags + " (" +
622
+ "objectid TEXT NOT NULL, " +
623
+ "flag TEXT NOT NULL, " +
624
+ "set_at INTEGER NOT NULL, " +
625
+ "PRIMARY KEY (objectid, flag), " +
626
+ "FOREIGN KEY(objectid) REFERENCES " + qMsgs + "(objectid) ON DELETE CASCADE)"
627
+ ).run();
628
+
629
+ // Quota table — per-folder counters bumped atomically with append/delete.
630
+ db.prepare(
631
+ "CREATE TABLE IF NOT EXISTS " + qQuota + " (" +
632
+ "folder_id INTEGER PRIMARY KEY, " +
633
+ "used_bytes INTEGER NOT NULL DEFAULT 0, " +
634
+ "used_count INTEGER NOT NULL DEFAULT 0, " +
635
+ "cap_bytes INTEGER, " +
636
+ "cap_count INTEGER, " +
637
+ "FOREIGN KEY(folder_id) REFERENCES " + qFolders + "(id))"
638
+ ).run();
639
+ }
640
+
641
+ function _ensureDefaultFolders(db, qFolders) {
642
+ var stmt = db.prepare("INSERT OR IGNORE INTO " + qFolders +
643
+ " (name, role, parent_id, modseq_max, uidvalidity) VALUES (?, ?, NULL, 0, ?)");
644
+ var uv = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix timestamp, not bytes
645
+ DEFAULT_FOLDERS.forEach(function (f) { stmt.run(f.name, f.role, uv); });
646
+ }
647
+
648
+ module.exports = {
649
+ create: create,
650
+ DEFAULT_FOLDERS: DEFAULT_FOLDERS,
651
+ MailStoreError: MailStoreError,
652
+ };
package/lib/mail.js CHANGED
@@ -68,6 +68,7 @@ var dkim = require("./mail-dkim");
68
68
  var mailAuth = require("./mail-auth");
69
69
  var mailBimi = require("./mail-bimi");
70
70
  var mailUnsubscribe = require("./mail-unsubscribe");
71
+ var mailAgent = require("./mail-agent");
71
72
  var net = lazyRequire(function () { return require("node:net"); });
72
73
  var networkDns = lazyRequire(function () { return require("./network-dns"); });
73
74
  var nodeUrl = require("node:url");
@@ -1862,4 +1863,9 @@ module.exports = {
1862
1863
  http: httpTransport,
1863
1864
  resend: resendTransport,
1864
1865
  },
1866
+ // The mail-stack standardization contract (v0.9.20). JMAP / IMAP /
1867
+ // POP3 / ManageSieve / MX / submission all translate into
1868
+ // `agent.X(args)`; RBAC + posture + audit + dispatch owned here.
1869
+ // See lib/mail-agent.js for the full surface.
1870
+ agent: mailAgent,
1865
1871
  };