/** * SQLite-backed local mirror of server-side mail. * * Uses `node:sqlite`, built into Node 24 — no native module, no install step, * nothing to rebuild across Node or architecture changes. The project already * requires Node >= 24, so this adds zero runtime dependencies. * * This class is deliberately dumb: it stores and retrieves rows and knows * nothing about IMAP. Reconciliation lives in the sync engine, and hit/stale/ * offline policy lives in the MailReadService facade. */ export type SyncTier = 'qresync' | 'condstore' | 'baseline'; /** * Largest decoded body the mirror persists. Matches the ballpark mature * clients ship (K-9 ~32 KB autodownload, FairEmail 256 KiB metered cap) while * comfortably holding any real correspondence; anything larger is served live. */ export declare const MAX_CACHED_BODY_BYTES: number; export type BodySubtype = 'plain' | 'html'; /** A message as handed to the store for persistence. */ export interface CachedMessageInput { account: string; mailbox: string; uid: number; /** Cache epoch, as a decimal string — see schema.ts. */ uidValidity: string; modseq?: string; /** RFC 8474 EMAILID / Gmail X-GM-MSGID: stable across moves. */ emailId?: string; envelope: unknown; bodyStructure?: unknown; flags: string[]; messageId?: string; inReplyTo?: string; references?: string[]; /** Epoch milliseconds. */ internalDate: number; subject?: string; fromText?: string; preview?: string; hasAttachments: boolean; /** Attachment filenames, indexed for full-text search. */ attachmentNames?: string[]; } /** A message as read back out of the mirror. */ export interface CachedMessage { account: string; mailbox: string; uid: number; uidValidity: string; modseq?: string; emailId?: string; envelope: unknown; bodyStructure?: unknown; flags: string[]; seen: boolean; flagged: boolean; answered: boolean; messageId?: string; inReplyTo?: string; references?: string[]; internalDate: number; subject?: string; fromText?: string; preview?: string; bodyText?: string; bodySubtype?: BodySubtype; headers?: Record; bodyFetchedAt?: number; hasAttachments: boolean; attachmentNames: string[]; cachedAt: number; lastAccessedAt?: number; } /** A downloaded body attached to an existing message row. */ export interface CachedBodyInput { text: string; subtype?: BodySubtype; headers?: Record; preview?: string; } export interface MailboxState { account: string; mailbox: string; uidValidity: string; uidNext?: number; highestModseq?: string; syncTier: SyncTier; totalMessages?: number; unseenMessages?: number; lastSyncedAt?: number; bodyBackfillUid?: number; } export interface PageOptions { page: number; pageSize: number; } export interface Page { items: T[]; total: number; page: number; pageSize: number; hasMore: boolean; } /** * Filters for listMessages, mirroring the live IMAP listEmails filters so a * cached listing is substitutable for a live one. */ export interface MessageFilters { /** ISO date — messages at or after this instant. */ since?: string; /** ISO date — messages strictly before this instant. */ before?: string; /** Substring match against "Name
". */ from?: string; /** Substring match against the subject. */ subject?: string; seen?: boolean; flagged?: boolean; answered?: boolean; hasAttachment?: boolean; } export interface MailboxCacheStats { account: string; mailbox: string; uidValidity: string; syncTier: SyncTier; totalMessages?: number; cachedMessages: number; bodiesCached: number; lastSyncedAt?: number; } export interface CacheStats { mailboxes: MailboxCacheStats[]; messageCount: number; bodyCount: number; attachmentCount: number; /** Bytes of attachment content held in the blob store, per the index. */ attachmentBytes: number; /** Size of the SQLite mirror itself, excluding attachment blobs. */ sizeBytes: number; } /** An attachment's bytes as indexed against the blob store. */ export interface CachedAttachmentInput { account: string; mailbox: string; uid: number; uidValidity: string; /** IMAP MIME part path, e.g. "2" or "1.2". */ partId: string; filename: string; mimeType: string; size: number; sha256: string; } export interface CachedAttachment extends CachedAttachmentInput { cachedAt: number; lastAccessedAt?: number; } export default class CacheStore { private db; private readonly dbPath; /** * Prepared statements, keyed by SQL. `node:sqlite` has no built-in * statement cache, and re-preparing on every call costs more than the * query itself for point reads. */ private stmts; /** * @param dbPath filesystem path, or ':memory:' for tests. */ constructor(dbPath: string); /** Open the database and apply the per-connection pragmas. */ private open; /** * Apply the schema, rebuilding from scratch if the file was written by any * other build (older or newer — CREATE IF NOT EXISTS cannot reshape an * existing table). The mirror is regenerable, so discarding it is always * safe and is preferable to failing at startup. Rebuilding recreates the * file rather than dropping tables: auto_vacuum only applies to a database * whose tables were created after the pragma was set. */ private migrate; /** * Run `fn` inside an immediate transaction. * * BEGIN IMMEDIATE rather than the default deferred: busy_timeout does not * apply when a read transaction tries to upgrade to a write, which is the * usual source of spurious SQLITE_BUSY errors. */ private transaction; private prep; /** * Query helpers. * * `node:sqlite` types rows as `Record`, which never * structurally overlaps a concrete row interface. The cast is unavoidable; * confining it to these two helpers keeps it from spreading across every * query, and the row interfaces stay the single description of table shape. */ private queryAll; private queryOne; close(): void; /** * Insert or update messages. * * Already-downloaded content is preserved: a metadata resync (a flag * change, say) must not discard a body, headers or preview and force a * second download. COALESCE keeps the stored value when the incoming row * has none. */ putMessages(messages: CachedMessageInput[]): void; /** Attach a downloaded body (and its immutable metadata) to a message. */ putBody(account: string, mailbox: string, uid: number, uidValidity: string, body: CachedBodyInput): void; /** * Update a message's flags (and the derived filter columns) in place. * * Used by write-through invalidation: a flag change acknowledged by the * server is reflected immediately instead of waiting for the next sync. */ applyFlags(account: string, mailbox: string, uid: number, uidValidity: string, flags: string[]): void; /** * Apply a server flag sweep to the mirror. * * Only rows whose flag set actually differs are written — every row UPDATE * rewrites its FTS entry via the triggers, so no-op writes are not free. * Entries for UIDs the mirror does not hold are ignored. * * @returns how many rows changed. */ reconcileFlags(account: string, mailbox: string, uidValidity: string, entries: { uid: number; flags: string[]; }[]): number; /** Record a read access, so eviction can rank by recency. */ touchAccess(account: string, mailbox: string, uidValidity: string, uids: number[]): void; getMessage(account: string, mailbox: string, uid: number, uidValidity: string): CachedMessage | undefined; /** Look a message up by Message-ID across every mailbox of an account. */ findByMessageId(account: string, messageId: string): CachedMessage[]; /** Newest-first page. Costs the same at page 50 as at page 1. */ listMessages(account: string, mailbox: string, uidValidity: string, options: PageOptions, filters?: MessageFilters): Page; /** Full-text search over subject, sender, body and attachment names. */ searchMessages(account: string, mailbox: string, uidValidity: string, query: string, limit?: number, offset?: number): CachedMessage[]; /** Total full-text matches, so a paged search can report `total`. */ countSearchMessages(account: string, mailbox: string, uidValidity: string, query: string): number; /** * Walk a conversation from any one of its members, oldest first. * * Replaces the live path's 3-searches-per-Message-ID storm with a bounded * transitive closure over the mirror's threading columns: a message belongs * to the thread when its Message-ID, In-Reply-To, or References chain * touches an id already in the set. * * Scoped to one mailbox generation, like every other read. */ findThreadMessages(account: string, mailbox: string, uidValidity: string, messageId: string, limit: number): CachedMessage[]; /** * Every cached UID for one mailbox generation. * * Used by the sync engine to diff against the server's UID set, which on a * server without QRESYNC is the only way a deletion is ever detected. */ listAllUids(account: string, mailbox: string, uidValidity: string): number[]; /** * UIDs of cached messages whose body was never fetched NOR attempted, * newest first. Attempted-but-bodyless messages (no text part, oversized, * download failure) are excluded so backfill never spins on them. */ listMissingBodies(account: string, mailbox: string, uidValidity: string, limit: number): number[]; /** Record that a body fetch was attempted and yielded nothing cacheable. */ markBodyAttempted(account: string, mailbox: string, uid: number, uidValidity: string): void; /** How many bodies one mailbox generation holds, for the backfill budget. */ countBodies(account: string, mailbox: string, uidValidity: string): number; /** * Lowest mirrored UID for one generation, or undefined when nothing is * mirrored. The read path uses it together with the server's message count * to decide whether the mirror covers the whole mailbox. */ lowestUid(account: string, mailbox: string, uidValidity: string): number | undefined; /** Mirrored message count for one generation — the coverage numerator. */ countMessagesInEpoch(account: string, mailbox: string, uidValidity: string): number; countMessages(account: string, mailbox: string): number; deleteMessages(account: string, mailbox: string, uids: number[], uidValidity: string): void; /** Index an attachment whose bytes are already in the blob store. */ putAttachment(entry: CachedAttachmentInput): void; /** * Look an attachment up the way the tool addresses it: by the selector the * caller used (MIME part id when available, else filename) within one * message of one mailbox generation. */ getAttachment(account: string, mailbox: string, uid: number, uidValidity: string, filename: string): CachedAttachment | undefined; /** * How many index rows point at one blob. * * Content is deduplicated, so a blob may only be deleted once the last row * referencing it is gone. Counting beats a refcount column, which drifts. */ countBlobRefs(sha256: string): number; /** Record an attachment read, so eviction can rank by recency. */ touchAttachment(account: string, mailbox: string, uid: number, uidValidity: string, filename: string): void; /** * Attachment rows in eviction order: never-read first, then oldest-read. * * `cached_at` breaks ties so the ordering is deterministic (and so a test * is not at the mercy of insertion order). */ listEvictableAttachments(limit: number): CachedAttachment[]; /** Remove one attachment index row. The blob is the caller's business. */ deleteAttachment(account: string, mailbox: string, uid: number, uidValidity: string, partId: string): void; /** Messages holding a body, in eviction order (never-read first). */ listEvictableBodies(limit: number): { account: string; mailbox: string; uid: number; uidValidity: string; bytes: number; }[]; /** * Drop a cached body, keeping the envelope. * * `body_fetched_at` is cleared too: an evicted body is a body that may be * fetched again, unlike one that was attempted and found uncacheable. The * UPDATE goes through the message row so the FTS triggers drop its terms. */ clearBody(account: string, mailbox: string, uid: number, uidValidity: string): void; /** Every digest the index references, for orphan reconciliation. */ listIndexedDigests(): string[]; /** Drop every index row pointing at content that is no longer held. */ deleteAttachmentsByDigest(sha256: string): void; /** * Delete envelopes older than a cutoff — the last resort, used only when * dropping every body and blob still leaves the mirror over budget. * * @returns how many rows were removed. */ deleteMessagesOlderThan(cutoffMs: number, limit: number): number; putMailboxState(state: MailboxState): void; getMailboxState(account: string, mailbox: string): MailboxState | undefined; /** * Start a new cache epoch for a mailbox. * * Called when the server reports a UIDVALIDITY different from the stored * one: every cached UID for that mailbox now refers to a different message, * so the generation is dropped wholesale. * * @returns how many messages were discarded. */ resetEpoch(account: string, mailbox: string, newUidValidity: string): number; /** * Drop mirrored mail and the attachment index. * * Blob content is removed by the maintenance pass, which reconciles the * blob directory against this index — so clearing the index is what makes * the bytes collectable. */ clear(account?: string): void; /** * Attachment bytes held, per the index. * * The eviction loop measures with this rather than walking the blob * directory: it runs on every reconcile and after every eviction, and a * synchronous stat per file would stall the server. */ attachmentUsageBytes(): number; /** On-disk size in bytes, for `cache status`. */ sizeBytes(): number; /** Per-mailbox mirror statistics, for `cache status` and check_health. */ getStats(): CacheStats; /** * Check the mirror for internal corruption, for `cache verify`. * * Covers the two things that can silently drift: SQLite's own page * structure, and the external-content FTS index, which only stays in step * with the message table because of triggers. */ checkIntegrity(): { ok: boolean; problems: string[]; }; /** Truncate the WAL after a sync burst; cheap when there is nothing to do. */ checkpoint(): void; /** * Hand freed pages back to the filesystem. * * @param pages maximum pages to free; omit to free all currently on the * freelist. */ incrementalVacuum(pages?: number): void; } //# sourceMappingURL=store.d.ts.map