@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.
- package/CHANGELOG.md +2 -0
- package/index.js +16 -0
- package/lib/guard-mail-compose.js +282 -0
- package/lib/guard-mail-move.js +202 -0
- package/lib/guard-mail-query.js +296 -0
- package/lib/guard-mail-reply.js +172 -0
- package/lib/guard-mail-sieve.js +207 -0
- package/lib/guard-message-id.js +241 -0
- package/lib/mail-agent.js +638 -0
- package/lib/mail-store.js +652 -0
- package/lib/mail.js +6 -0
- package/lib/safe-mime.js +714 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
};
|