Mail Store
Byte-level mail-store substrate — the foundation every above-the- wire mail primitive composes (b.mail.agent at v0.9.20, b.mail.server.mx at v0.9.23, b.mail.server.submission at v0.9.24, IMAP/JMAP/POP3 at v0.9.27-29, ManageSieve at v0.9.30, DAV at v0.9.32).
No auth, no audit, no posture-enforcement at THIS layer — those live in the agent above. The store is the lowest-level atomic-append + sealed-column shape over a pluggable backend.
**Pluggable backend**: sqlite via b.db (default), or any object exposing prepare(sql) → { run, get, all } over a SQLite-compatible database. Schema is bootstrapped at create() when init !== false.
**Sealed by default**: subject / from_addr / to_addrs / cc_addrs / reply_to / body_text / body_html are registered as sealed via b.cryptoField.sealRow. A DB dump leaks zero recoverable PII content. Plaintext (forensic-queryable without unsealing): objectid, modseq, internal_date, received_at, flags, size_bytes, legal_hold, from_hash, message_id_hash.
**CONDSTORE-ready**: per-folder monotonic modseq counter (RFC 7162). Every state-changing op (append / setFlags / delete) bumps modseq atomically.
**JMAP-ready**: per-message objectid (RFC 8474) — stable cross-protocol identity. IMAP's UID + UIDVALIDITY + JMAP's Email/get's id all map to objectid.
**Threading at append**: JWZ algorithm + RFC 5256/9051 root via Message-Id + In-Reply-To + References. Threading state is maintained in the messages table itself (thread_root_id column) so JMAP Thread/get is a single index lookup.
**One thread belongs to one account**: every message carries a thread_scope, taken from appendOpts.threadScope or from the owner of the folder it lands in, and both the Message-Id lookup that picks a root and threadFor match only rows in that scope. A store that holds more than one account's folders gives each folder an owner at createFolder; without one, every folder shares the scope "" and the store threads as a single account's store. moveMessages refuses a move between folders with different owners. Rows written before a scope existed carry "", and RFC 8621 §3 makes threadId immutable, so moving them into per-account scopes means reinserting them with new objectids.
**Attachment facts recorded at append**: has_attachment and attachment_count are filled from the parse the append already does, and carried in both the fetch and the listing projections as hasAttachment and attachmentCount. A mailbox page and the RFC 8621 hasAttachment property read them from the row rather than re-reading and re-parsing every message: measured on 2 MB messages at a page size of 50, deriving them per render cost 7.5 s against 192 ms for the listing alone.
**Quota substrate**: per-user + per-folder usedBytes / usedCount counters maintained atomically with append/delete. The v0.9.33 IMAP-QUOTA / JMAP-Quotas surface reads these directly.
**Legal hold**: legal_hold column composes existing b.legalHold primitive. Held messages refuse delete regardless of caller; only b.legalHold.release can flip the flag.
Parses messages on append via b.safeMime.parse (bounded substrate, defends CVE-2024-39929 + CVE-2026-26312). Validates Message-Id via b.guardMessageId.validate.
**Recipients for a reply**: fetchByObjectId returns to, cc, and replyTo. Each holds every instance of its header joined with ", ", and an empty string when the message has none. cc and replyTo are null for a message appended before the store recorded them; create() adds their columns to an existing store, and the store keeps no raw message bytes to fill them in from. Bcc is not recorded.
b.mailStore.fts.tokenize(text) #
Split text into a deduplicated, lowercased, NFC-normalized token array. Drops stopwords + tokens outside the 2..64-codepoint band. Splits on every non-letter / non-digit codepoint, including the @ + . boundaries of email addresses so local-part + domain labels become independent tokens.
b.mailStore.fts.tokenize("Hello world from alice@example.com");
// → ["hello", "world", "alice", "example", "com"]
b.mailStore.fts.hashToken(table, field, token) #
Keyed hash of one token under the (table, field) namespace. Routes through b.cryptoField.computeNamespacedHash in hmac-shake256 mode — the same keyed-MAC machinery that protects sealed-column derived hashes — so rotating the vault key invalidates every FTS hash in step with every sealed-column hash. Returns a 16-char hex prefix.
var h = b.mailStore.fts.hashToken("mail_messages", "body", "kubernetes");
/^[0-9a-f]{16}$/.test(h); // → true
b.mailStore.fts.hashTokens(table, field, tokens) #
Hash an array of tokens → space-separated hash string suitable for direct insertion into an FTS5 column. Empty + duplicate token- hashes drop on the way out.
b.mailStore.fts.hashTokens("t", "subject", ["hello", "world"]);
// → "<16hex> <16hex>"
b.mailStore.fts.hashText(table, field, text) #
Tokenize + hash + join in one step. Convenience wrapper — equivalent to hashTokens(table, field, tokenize(text)).
b.mailStore.fts.hashText("mail_messages", "body", "kubernetes deploy");
// → "<16hex> <16hex>"
b.mailStore.fts.rowFromMessage(table, msg) #
Build the FTS5 row payload { objectid, subject_toks, addr_toks, body_toks } from a { objectid, subject, from, to, body } plaintext message. from + to share addr_toks.
b.mailStore.fts.rowFromMessage("t", { objectid:"o1", subject:"Hi", from:"a@x", to:"b@x", body:"hello" });
// → { objectid:"o1", subject_toks:"", addr_toks:" ", body_toks:"" }
b.mailStore.fts.columnAndFieldFor(filterKey) #
Map a search filter key (subject / body / from / to) to the FTS5 column it indexes into PLUS the namespace pseudo-field the indexer uses when hashing tokens. Used by the search path so the query-side hash transform matches the index-side one byte- for-byte.
b.mailStore.fts.columnAndFieldFor("from");
// → { column: "addr_toks", field: "addr" }
b.mailStore.fts.buildMatchExpression(table, field, term) #
Tokenize + hash an operator's query term and produce the FTS5 MATCH expression that selects rows containing every surviving token. Returns null when no tokens survive the tokenize + stopword filter (caller skips the FTS join in that case).
var expr = b.mailStore.fts.buildMatchExpression("t", "body", "kubernetes deploy");
// → "<16hex> AND <16hex>"
b.mailStore.fts.createSql(qFtsTable) #
Returns the CREATE VIRTUAL TABLE IF NOT EXISTS SQL for the sealed-token FTS5 table. The caller passes the quoted table identifier (e.g. "blamejs_mail_messages_fts").
db.prepare(b.mailStore.fts.createSql('"mail_fts"')).run();
b.mailStore.create(opts) #
{
backend: object, // required — sqlite-shaped { prepare(sql) → { run, get, all }, transaction(fn) }
tablePrefix: string, // default "blamejs_mail" — validated via safeSql.validateIdentifier
init: boolean, // default true — bootstrap schema + register sealed fields + insert default folders
compliance: string, // hipaa | pci-dss | gdpr | soc2 — pins sealing posture (default off → sealed-by-default uses framework defaults)
maxMessageBytes: number, // default 50 MiB
maxBodyBytes: number, // default 25 MiB
safeMimeOpts: object, // pass-through to b.safeMime.parse
`createFolder(name, { role, parentId, owner })` records `owner` as the
account that holds the folder, and `appendMessage(folder, bytes, {
threadScope })` names the scope directly. Both default to `""`.
}
Build a mail-store handle. Returns an object with appendMessage / fetchByObjectId / search / queryByModseq / setFlags / createFolder / listFolders / threadFor / quota / moveMessages / setLegalHold / hardExpunge.
var b = require("blamejs");
await b.vault.init({ dataDir });
await b.db.init({ dataDir, schema: [] });
var store = b.mailStore.create({ backend: b.db });
var meta = store.appendMessage("INBOX", messageBuffer);
meta.objectid; // → "obj_01HXYZ..."
meta.modseq; // → 42 (monotonic)