/**
 * SQLite metadata index for mailx.
 * Stores message headers, folder structure, sync state.
 * Message bodies are NOT here -- they live in the MessageStore backend.
 */
import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
/** Sanitize a harvested display name. A real display name carries none of
 *  `@ < > |` — their presence means an address parser mis-split a header
 *  (e.g. a List-Id / "domain | Name@addr" fragment) and the "name" now
 *  embeds structural mail punctuation or a whole email address. The worst
 *  case is an embedded address that DIFFERS from this contact's own email:
 *  autocomplete then shows a TRUSTED name while sending ELSEWHERE. Live
 *  example (Bob 2026-06-22): a `discovered` row name `bob.ma | Bob@bob.ma`
 *  was wired to `miyakot@kt.rim.or.jp`, so test mail Bob addressed to
 *  himself silently went to a stranger in Japan ("MIA"). Drop the name;
 *  keep the contact (the email is still useful) — autocomplete falls back
 *  to showing the bare address, which the user won't mistake for someone. */
export declare function cleanContactName(name: string): string;
/** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
 *  once on settings load; invalid regexes are skipped with a warning. */
export declare function setContactsDenyPatterns(patterns: string[]): void;
export declare class MailxDB {
    private db;
    /** True for the DB read-worker's connection. Read-only handles never run
     *  DDL / migrations / backfills (the main writer already did, before this
     *  handle opened) and reject any write at the SQLite level. */
    readonly readOnly: boolean;
    constructor(dbDir: string, opts?: {
        readOnly?: boolean;
        skipMigrations?: boolean;
    });
    /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
     *  runs at startup). The user-facing message names the recovery command. */
    private verifySchema;
    /** One-shot: rewrite absolute body_path entries to relative-to-store
     *  paths. Idempotent; flag stored in kv. Caller passes a FileMessageStore
     *  whose `rewriteAbsoluteToRelative` does the actual mapping (mailx-store
     *  doesn't depend on file-store directly to avoid a cycle). */
    runOneShotBodyPathMigration(rewriteFn: (rows: Array<{
        id: number;
        body_path: string;
    }>, update: (id: number, p: string) => void) => number): void;
    /** One-shot purge: when the in-DB filter rules tighten (new prefix or
     *  domain matchers), historical contacts harvested under the old rules
     *  remain in the table and keep being written back to contacts.jsonc on
     *  every cloud save. Bumping the version below re-runs the purge once
     *  per device. The kv flag (`contacts:purge_v`) records the last version
     *  that ran so we don't keep doing the work on every startup. */
    private runOneShotJunkContactPurge;
    /** Fetch a string from the kv table. Returns null when not set. */
    getKv(scope: string, key: string): string | null;
    /** Upsert a kv row. Pass `null` to delete. */
    setKv(scope: string, key: string, value: string | null): void;
    /** One-time: assign UUIDs to every `messages` row that's missing one.
     *  Runs on every startup but the WHERE clause makes it a no-op after the
     *  first pass. */
    private backfillUuids;
    /** Has this Message-ID already been sent? Used to prevent the outbox from
     *  re-sending the same raw file across crash/restart cycles. */
    hasSentMessage(messageId: string): boolean;
    /** Record a successfully sent message so future attempts are skipped. */
    recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
    /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
     *  that had a non-empty Cc field? Used by compose to auto-expand the Cc
     *  input when replying to someone who customarily gets Cc'd with others.
     *  Query scans only Sent folders (special_use='sent') and matches the
     *  recipient's address inside `to_json` via LIKE. No special index — the
     *  Sent folder's row count is typically a few thousand at most; acceptable
     *  on the compose-open path. */
    hasCcHistoryTo(recipientEmail: string): boolean;
    /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
     *  user's own Sent copy, so it's still a reliable signal that this user
     *  habitually Bccs when writing to this recipient. */
    hasBccHistoryTo(recipientEmail: string): boolean;
    /** Mark a local row as "delete pending server confirmation". The row
     *  stays visible (UI greys / strikes it through) until the server
     *  confirms via QRESYNC VANISHED or its UID drops out of the set-diff. */
    setMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
    /** Clear the pending-delete flag — used when the IMAP MOVE/EXPUNGE is
     *  refused by the server and the row should revert to a normal visible
     *  state, and by Ctrl-Z undelete. */
    clearMessagePendingDelete(accountId: string, uid: number, folderId: number): void;
    /** Query: is this (folder, uid) pending server-side delete? */
    isMessagePendingDelete(accountId: string, uid: number, folderId: number): boolean;
    /** @deprecated — superseded by setMessagePendingDelete. Retained as a
     *  no-op so legacy delete paths still compile; the table is preserved
     *  but never read by the sync path anymore. */
    addTombstone(accountId: string, messageId: string, subject?: string): void;
    /** Is this Message-ID tombstoned for this account? Tombstones suppress
     *  re-import of a row the user just deleted/moved while the server-side
     *  IMAP action is still in flight; without this, the moved-out source
     *  folder would re-insert the message on the next sync (server still
     *  sees the UID; mailx has no other way to know "I just moved this").
     *
     *  Lifetime: created at the local-delete/move moment, removed on
     *  successful IMAP action complete OR on PERMANENT IMAP action failure.
     *  The failure-clear is the 2026-05-13 fix for the "tombstones outlive
     *  failed deletes" bug — `failSyncAction` calls `clearTombstoneForUid`
     *  after the final retry attempt so the user sees the row reappear on
     *  the next sync (server still has it, mailx now agrees). QRESYNC
     *  servers don't need this fallback because VANISHED is authoritative;
     *  tombstones are no-ops on the QRESYNC fast path. */
    hasTombstone(accountId: string, messageId: string): boolean;
    /** Clear the tombstone for the message currently at (accountId, uid,
     *  folderId). Used by the sync-action failure path: when an IMAP MOVE
     *  or EXPUNGE fails terminally, the row is still on the server and the
     *  user should see it again — so the suppression must lift. The lookup
     *  is by (accountId, uid, folderId) → message_id, since the action
     *  table stores UID not Message-ID. No-op if the row no longer exists
     *  (concurrent server-side EXPUNGE etc.). */
    clearTombstoneForUid(accountId: string, uid: number, folderId: number): void;
    /** @deprecated — use clearMessagePendingDelete instead. Retained as a
     *  no-op for legacy callers (Ctrl-Z undelete paths still work because
     *  their sibling clearMessagePendingDelete is called). */
    removeTombstone(accountId: string, messageId: string): void;
    /** Age-out tombstones older than the given cutoff. Keeps the table from
     *  growing unboundedly. Default retention is 30 days; caller passes the
     *  actual cutoff in ms since epoch. */
    pruneTombstones(olderThanMs: number): number;
    upsertCalendarEvent(ev: {
        uuid?: string;
        accountId: string;
        providerId?: string;
        calendarId?: string;
        title: string;
        startMs: number;
        endMs: number;
        allDay?: boolean;
        location?: string;
        notes?: string;
        etag?: string;
        dirty?: boolean;
        recurringEventId?: string;
        htmlLink?: string;
        isHoliday?: boolean;
        reminderMinutes?: number[];
    }): string;
    getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
    /** Lookup by uuid only — used by patch/delete paths that don't have an
     *  accountId context. Returns the row even when it's soft-deleted. */
    getCalendarEventByUuid(uuid: string): any | null;
    getTaskByUuid(uuid: string): any | null;
    getDirtyCalendarEvents(accountId: string): any[];
    private calendarRowToObject;
    /** Find a calendar event by its Google Calendar event id (provider_id).
     *  Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
    getCalendarEventByProviderId(accountId: string, providerId: string): any | null;
    markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
    deleteCalendarEventLocal(uuid: string): void;
    purgeCalendarEvent(uuid: string): void;
    upsertTask(t: {
        uuid?: string;
        accountId: string;
        providerId?: string;
        listId?: string;
        title: string;
        notes?: string;
        dueMs?: number;
        completedMs?: number;
        etag?: string;
        dirty?: boolean;
    }): string;
    getTasks(accountId: string, includeCompleted?: boolean): any[];
    getDirtyTasks(accountId: string): any[];
    private taskRowToObject;
    markTaskClean(uuid: string, providerId: string, etag: string): void;
    deleteTaskLocal(uuid: string): void;
    purgeTask(uuid: string): void;
    /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
    deleteContactLocal(email: string): void;
    enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void;
    getStoreSyncQueue(kind?: string, accountId?: string): any[];
    completeStoreSync(id: number): void;
    failStoreSync(id: number, error: string): void;
    /** Idempotently add a column to a table if it's missing. */
    private addColumnIfMissing;
    /** Compute a thread id for an incoming message. Strategy:
     *  1. If any ancestor (in_reply_to or references) is already present in
     *     messages with a thread_id, reuse it — this handles the case where
     *     replies arrive before / after the root.
     *  2. Otherwise use the oldest ref (first entry in References), or
     *     in_reply_to, or the message's own messageId as the thread root. */
    private computeThreadId;
    /** Get all messages in a thread (across folders) for a given account. */
    getThreadMessages(accountId: string, threadId: string): MessageEnvelope[];
    close(): void;
    upsertAccount(id: string, name: string, email: string, configJson: string): void;
    getAccounts(): {
        id: string;
        name: string;
        email: string;
        lastSync: number;
    }[];
    getAccountConfigs(): {
        id: string;
        name: string;
        email: string;
        configJson: string;
    }[];
    updateLastSync(accountId: string, timestamp: number): void;
    upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
    /** Rename a folder row's path (+leaf name) and rewrite every descendant
     *  folder whose path lives under the old prefix. A single IMAP RENAME on the
     *  server renames child mailboxes too (RFC 3501), and Gmail/Outlook rename
     *  cascades the same way, so the local mirror must move the whole subtree in
     *  lockstep. All in one transaction so a crash can't leave half the subtree
     *  pointing at the old prefix.
     *
     *  `newName` is the new LEAF name written to the renamed row's `name`
     *  column; descendant rows keep their own leaf names (only the prefix
     *  changes). The (account_id, path) UNIQUE constraint means the caller must
     *  have already verified newPath is free. */
    renameFolderPath(accountId: string, oldPath: string, newPath: string, newName: string, delimiter: string): void;
    /** Update only a folder's display `name`, leaving `path` untouched. For
     *  providers whose `path` is an opaque, rename-stable id (Outlook/Graph:
     *  path = folder id), a rename/reparent changes the display name but NOT
     *  the id — so synthesizing a new hierarchical path (as renameFolderPath
     *  does) would corrupt the id-as-path invariant every later Graph op relies
     *  on. This updates the name in place. No descendant rewrite: opaque-id
     *  children don't carry the parent in their path. */
    renameFolderName(accountId: string, folderId: number, newName: string): void;
    getFolders(accountId: string): Folder[];
    /** Append a row to the audit_log table. Every destructive operation on
     *  the messages table writes here so "where did the rows go?" has an
     *  authoritative answer. Cheap insert, indexed by ts and (account, folder). */
    audit(entry: {
        kind: "delete-msg" | "delete-bulk" | "migration" | "manual" | string;
        accountId?: string;
        folderId?: number;
        uid?: number;
        count?: number;
        messageId?: string;
        subject?: string;
        reason?: string;
        source?: string;
    }): void;
    deleteFolder(folderId: number): void;
    markFolderRead(folderId: number): void;
    deleteAllMessages(accountId: string, folderId: number): void;
    /** Drop every `message_folders` row in `folderId` (optionally scoped to an
     *  account) and GC any `messages` rows that no longer have ANY memberships.
     *  Returns count of memberships removed (so callers can audit "I deleted N
     *  things"). The messages-row delete cascades to FTS / sync_actions /
     *  message_folders (CASCADE) so this is the safe single op for "clear
     *  this folder" without nuking multi-folder messages. */
    private gcMembershipsAndCollectOrphans;
    updateFolderCounts(folderId: number, total: number, unread: number): void;
    updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void;
    getFolderSync(folderId: number): {
        uidvalidity: number;
        highestModseq: string;
    };
    /** Record that a message row currently lives at (folder, uid). Idempotent
     *  via UNIQUE(folder_id, uid) — re-inserting the same membership just
     *  refreshes last_seen_at. A message in N Gmail labels has N rows.
     *  A server-side move is "delete one membership row, insert another";
     *  the messages row is untouched. Called from upsertMessage on every
     *  insert / move-detect / existing-row upsert during the additive
     *  migration. Reconcile is the only path that DELETES from this table
     *  (when the server stops listing a UID in a folder). */
    upsertMessageFolder(messageRowId: number, folderId: number, uid: number, exclusive?: boolean): void;
    /** Drop a membership row when reconcile finds the UID is no longer
     *  on the server in this folder. The messages row itself is NOT
     *  touched — it may still exist in other folders, or the deferred-
     *  cleanup pass (later) handles orphan messages with no memberships. */
    removeMessageFolder(folderId: number, uid: number): boolean;
    /** Reconcile-driven membership drop. Called when the server's UID list
     *  for a folder no longer contains a UID we have locally. Drops the
     *  membership row; if the messages row has no other memberships AND
     *  no other folder claims it via the legacy folder_id column, deletes
     *  the messages row + unlinks the body file. Returns true if any
     *  cleanup happened (so the caller can skip the legacy delete path).
     *
     *  During the additive migration we ALSO have to handle the legacy
     *  case where the row's folder_id matches but no message_folders row
     *  was migrated for it (race during initial population, etc.) — we
     *  fall back to the messages.folder_id check to clean up consistently. */
    dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string): boolean;
    upsertMessage(msg: {
        accountId: string;
        folderId: number;
        uid: number;
        messageId: string;
        inReplyTo: string;
        references: string[];
        date: number;
        /** Date: header (send time). Defaults to `date` when the caller has no
         *  header date, so the column is never null. */
        sentDate?: number;
        subject: string;
        from: EmailAddress;
        to: EmailAddress[];
        cc: EmailAddress[];
        flags: string[];
        size: number;
        hasAttachments: boolean;
        preview: string;
        bodyPath: string;
        providerId?: string;
        /** Exclusive-folder account (IMAP): enforce one-folder-per-message in
         *  message_folders. Omitted/false for Gmail (label = folder, many per
         *  message). Set true by the IMAP storeMessages path. */
        exclusive?: boolean;
        /** UIDs the server currently lists for THIS folder, when the caller
         *  just enumerated them (the set-diff backfill path). Lets move-detect
         *  distinguish a real move (old uid gone) from a DUPLICATE DELIVERY
         *  (same Message-ID at two live uids in one folder — e.g. a message
         *  addressed to two aliases of the same mailbox). Without this the
         *  single row ping-pongs between the two uids every sync cycle. */
        liveServerUids?: Set<number>;
    }): number;
    /** Backfill the FTS5 `body_text` column for a message after its body
     *  has been parsed. Capped at ~64 KB of text per row — FTS5 stores the
     *  raw text and indexes tokens; we don't need every byte of a 1 MB
     *  marketing email to find a word in the first paragraph.
     *
     *  Keyed on the FTS rowid (= messages.id) directly. The earlier
     *  (account, folder_id, uid) lookup keyed on the LEGACY messages.folder_id
     *  column — a message can live in several folders via message_folders,
     *  so that lookup could miss the row entirely and silently leave the
     *  body un-indexed (Bob 2026-05-18: "mcdade" in an opened message's
     *  body, search found nothing). */
    updateFtsBody(rowId: number, bodyText: string): void;
    /** List view: messages currently in (account, folder).
     *  Joins through `message_folders` so the UID + folder location come
     *  from membership rows, not from the legacy messages.folder_id /
     *  messages.uid columns. After a server-side move (Sieve filter,
     *  webmail action, etc.), the membership row is the truth — the
     *  legacy columns are frozen at first-sight values. */
    getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
    /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
    /** Short-TTL cache for the unified-inbox survivor COUNT (the GROUP BY that
     *  scans the whole inbox). Keyed by flaggedOnly+folder-set. */
    private _unifiedTotalCache;
    getUnifiedInbox(page?: number, pageSize?: number, flaggedOnly?: boolean, dateBasis?: "sent" | "received"): PagedResult<MessageEnvelope>;
    /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
     *  identity) and `bodyPath` (authoritative on-disk location) in addition
     *  to the server-binding metadata. */
    private rowToEnvelope;
    /** Look up a message by (account, uid) and optionally folder.
     *  Joins through `message_folders` so the message is found at its
     *  CURRENT location, not whatever messages.folder_id was at first
     *  sight. After server-side moves, that's the difference between
     *  hitting the message in `_Spam` (correct) and hitting nothing
     *  because the legacy messages.folder_id still points at INBOX. */
    getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope;
    /** Look up a message by its stable local UUID. Returned envelope includes
     *  the current (folder_id, uid) — these may have changed since the UUID
     *  was minted (folder move or server UID renumber) but the UUID itself
     *  is stable. Use this as the identity in any long-lived reference
     *  (compose in-reply-to, dally, undo stacks). */
    getMessageByUuid(uuid: string): MessageEnvelope;
    /** Look up a message's body_path. folderId is REQUIRED when available
     *  because (account, uid) is not a unique key — the same numeric UID
     *  refers to different messages in INBOX vs Outbox vs Sent etc. Passing
     *  no folderId returns the first matching row, which may be the wrong
     *  message's body — same class of bug as the parsedCache UID collision.
     *  Existing callers without folderId are tolerated (legacy) but should
     *  be migrated. */
    getMessageBodyPath(accountId: string, uid: number, folderId?: number): string;
    /** Update flags for a message. folderId is REQUIRED — same UID-collision
     *  bug class as the deleteMessage one we fixed 2026-05-27. The
     *  WHERE-account-AND-uid query previously updated EVERY folder's row
     *  that shared the numeric UID, causing the "stars on letters I never
     *  starred" symptom (Bob 2026-05-27: flagging one INBOX message lit
     *  up stars on a same-numeric-UID row in Drafts/Sent/sub-folders).
     *  folderId === null is allowed as an explicit "every folder" override
     *  for code paths that mean it; everyone else must pass folderId. */
    updateMessageFlags(accountId: string, folderId: number | null, uid: number, flags: string[]): void;
    updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void;
    /** Local-first move of a message between folders. Updates BOTH the
     *  legacy `messages.folder_id` column AND the `message_folders`
     *  membership row, so user-visible queries (which join through mf)
     *  immediately reflect the move. The same uid is preserved on the
     *  destination side — once the IMAP MOVE drains and reconcile runs,
     *  the membership uid is rebound to whatever the server assigned in
     *  the new folder (move-detect by Message-ID does this).
     *
     *  Returns true if the message was found and moved, false otherwise.
     *  Pre-fix `updateMessageFolder` only touched the legacy column, so
     *  user-visible folder lists (which join through `message_folders`)
     *  stayed showing the old location until the next reconcile.
     *  Symptom: drag-to-folder doesn't visually arrive, Ctrl+Z undelete
     *  doesn't restore until sync. */
    moveMessageLocal(accountId: string, uid: number, fromFolderId: number, toFolderId: number): boolean;
    /** Find a pending sync action by its identifying fields. Used by the
     *  undelete path to detect "is the to-trash MOVE still queued?" — if
     *  yes, cancelling it (completeSyncAction) is enough to undelete; the
     *  server never sees the move. */
    findPendingSyncAction(accountId: string, action: string, uid: number, folderId: number, targetFolderId?: number): {
        id: number;
    } | null;
    /** folderId REQUIRED — same UID-collision bug class as deleteMessage /
     *  updateMessageFlags. The same numeric UID exists in many folders for
     *  DIFFERENT messages; a (account, uid) UPDATE writes the body_path onto
     *  EVERY folder's uid=N row, so 22 unrelated messages ended up sharing one
     *  .eml and showed each other's bodies (Bob 2026-05-29 — subject/letter
     *  mismatch). Never write body_path without folder_id. */
    updateBodyPath(accountId: string, folderId: number, uid: number, bodyPath: string): void;
    /** Write the post-download metadata in one go: body file path, parsed
     *  attachment flag, preview text, and the parse timestamp. Called from
     *  prefetch after the body has been written to disk and parsed — without
     *  this, IMAP metadata-only sync leaves has_attachments=0 and preview=""
     *  forever (the paperclip 📎 never renders for non-Gmail accounts).
     *  Pass bodyPath = "" to update only the parsed fields (used by the
     *  backfill worker re-parsing existing .eml files on disk).
     *  folderId REQUIRED — see updateBodyPath: the (account, uid) form
     *  cross-wired body_path across every folder sharing the numeric UID. */
    updateBodyMeta(accountId: string, folderId: number, uid: number, bodyPath: string, hasAttachments: boolean, preview: string): void;
    /** Find rows whose body is on disk but was never run through extractPreview
     *  (has_attachments/preview reflect "no body source at sync time", typical
     *  of the IMAP metadata-only path). Used by the backfill worker to walk
     *  every existing message once after the prefetch-reparse fix landed.
     *  Returns id + body_path so the worker doesn't need a second lookup. */
    getMessagesNeedingBodyReparse(accountId: string, limit?: number): {
        id: number;
        uid: number;
        bodyPath: string;
    }[];
    /** Backfill variant that writes by row id (avoids re-resolving uid). */
    updateBodyMetaById(rowId: number, hasAttachments: boolean, preview: string): void;
    /** Mark a message as "replied to" by its RFC-5322 Message-ID. Source of
     *  truth for the ↩ indicator: the local DB knows who replied to whom via
     *  In-Reply-To headers, independent of whether the IMAP server propagates
     *  \Answered (Gmail labels, cross-account replies, etc.). Called from the
     *  send path when msg.inReplyTo is set, and from upsertMessage when a
     *  reply arrives. Idempotent — UPDATE on an already-1 row is a no-op. */
    markRepliedByMessageId(accountId: string, messageId: string): boolean;
    /** Get messages without cached bodies (for background prefetch) */
    getMessagesWithoutBody(accountId: string, limit?: number, excludeFolderIds?: number[]): {
        uid: number;
        folderId: number;
    }[];
    /** Rebuild messages_fts if it still has the OLD external-content schema
     *  (`content=messages`). That schema referenced columns (to_text/cc_text/
     *  body_text) that don't exist in `messages`, so content-dereferencing
     *  failed ("no such column: T.to_text") and corrupt/empty-subject rows
     *  silently never indexed → search missed them (Bob 2026-06-05). One-time
     *  per DB: drop, recreate standalone, reindex subject/from + derived to/cc.
     *  body_text backfills as bodies parse. */
    private migrateFtsSchema;
    /** True while the trigram migration's shadow table exists — the FTS write
     *  paths (upsert / updateFtsBody) mirror into it so the copy loop never
     *  chases a moving target. TTL-cached: upserts run hundreds/sec during
     *  sync and the answer only changes twice in the DB's lifetime. Checked
     *  via sqlite_master (not an in-memory flag) because the migration runs on
     *  the main writer while upserts run on the sync worker's connection. */
    private triShadowCache;
    private ftsTriShadowExists;
    /** Does messages_fts still use the old unicode61 word tokenizer? Checked
     *  per-call (not cached): the answer flips mid-session when the background
     *  migration swaps tables, and searchMessages must switch query dialects
     *  the moment it does — including on the read-worker's own connection. */
    private ftsIsTrigram;
    ftsNeedsTrigramMigration(): boolean;
    /** One-time background conversion of messages_fts from the unicode61 word
     *  tokenizer to trigram (substring search — "sprinkler" now finds
     *  "OpenSprinkler", Bob 2026-07-19). Word tokens only match by prefix, so
     *  any mid-word search silently found nothing.
     *
     *  Runs chunked on the main writer (~3-4 min for 190k rows, measured) so
     *  boot isn't blocked and sync writes stay responsive between chunks. The
     *  existing index keeps serving searches the whole time; concurrent FTS
     *  writes land in BOTH tables via ftsTriShadowExists() mirroring. The
     *  final swap (DROP + RENAME) is a single transaction, so a crash at any
     *  point leaves either the old table (migration restarts next boot) or
     *  the finished trigram table — never neither. Stored body_text survives
     *  because the copy reads it straight out of the old standalone table.
     *  Call ONLY from the long-lived daemon: a one-shot CLI command would sit
     *  alive for minutes finishing the copy. */
    startFtsTrigramMigration(): void;
    /** Record a prefetch failure (0-body fetch / store-write fail) for a UID,
     *  incrementing its backoff count. Persisted so it survives restarts. */
    recordPrefetchFailure(accountId: string, folderId: number, uid: number): void;
    /** Read a UID's prefetch-failure record (count + lastTried), or null. */
    getPrefetchFailure(accountId: string, folderId: number, uid: number): {
        count: number;
        lastTried: number;
    } | null;
    /** Clear a UID's prefetch-failure record after a successful fetch. */
    clearPrefetchFailure(accountId: string, folderId: number, uid: number): void;
    /** Wipe ALL prefetch-failure backoffs. Called once per boot: the backoff is
     *  a within-SESSION optimisation (it stops a poison body being re-fetched
     *  every 60s cycle), so a fresh session should give every body another try —
     *  the network may have recovered, and crucially a body backed off by a
     *  transient cause (a two-writer DB lock, a Dovecot 300s timeout) shouldn't
     *  stay suppressed for up to 12h (Bob 2026-06-14: "lots of predownloading
     *  not happening"). Genuine poison bodies simply re-fail once and re-back-off
     *  for the session — no per-cycle loop. */
    clearAllPrefetchFailures(): number;
    /** Highest server UID we've seen in this folder. After the
     *  message_folders refactor, this reads from the membership table
     *  rather than messages.uid — a server-side move from this folder
     *  drops the membership row, so the high-water mark correctly
     *  decreases when the message left. account_id is implicit
     *  (folder_id is account-scoped). */
    getHighestUid(_accountId: string, folderId: number): number;
    /** Oldest message date in this folder. Joins through message_folders
     *  so a message that LEFT this folder via server-side move stops
     *  influencing the date window for incremental backfill. */
    getOldestDate(_accountId: string, folderId: number): number;
    /** Number of messages currently IN this folder (server-side membership,
     *  not whatever messages.folder_id happens to say after a move). */
    getMessageCount(_accountId: string, folderId: number): number;
    /** All UIDs the server has confirmed in this folder. Used by
     *  reconcile to diff against the server's UID list — anything in
     *  here but NOT on the server gets its membership dropped (which
     *  may then GC the messages row if it has no other folders). */
    getUidsForFolder(_accountId: string, folderId: number): number[];
    /** UIDs we actually have a stored MESSAGE ROW for in this folder — from the
     *  `messages` table, NOT `message_folders`. The two can diverge: an "orphan
     *  membership" (a message_folders row whose messages row was never written /
     *  got deleted) makes getUidsForFolder report a UID we don't truly have.
     *  The set-diff backfill must diff against THIS so it re-fetches the real
     *  message data for orphans instead of treating them as present (Bob
     *  2026-06-20: ~1k old INBOX messages stuck missing — membership said
     *  "have it", the row didn't exist). */
    getStoredUids(accountId: string, folderId: number): number[];
    /** Count of stored MESSAGE ROWS in a folder (messages table). The deficit
     *  gate compares this to the server's count to decide whether to reconcile;
     *  using the messages table (not message_folders) means orphan memberships
     *  don't mask a real "rows are missing" deficit. */
    getStoredCount(accountId: string, folderId: number): number;
    /** List recent rows in a folder for a Sent-sweep / reconciliation pass.
     *  Returns only the columns the sweeper needs (uid, message_id, date,
     *  subject, size, has_attachments, preview, body_path) for every row in
     *  the given folder cached after `sinceMs`. Empty-message-id rows are
     *  excluded because the sweep keys on Message-ID. */
    getRecentMessagesByCachedAt(accountId: string, folderId: number, sinceMs: number): {
        uid: number;
        message_id: string;
        date: number;
        subject: string;
        size: number;
        has_attachments: number;
        preview: string;
        body_path: string;
    }[];
    /** Count local rows in a folder sharing one Message-ID. The Sent-sweep
     *  uses this as a sanity cross-check before re-APPENDing a "missing"
     *  message: >1 local copies mean earlier duplicates synced IN from the
     *  server, so the server-side header search that just said "missing"
     *  was lying (the 3,704-duplicate loop, 2026-07-02). */
    countMessagesByMessageId(accountId: string, folderId: number, messageId: string): number;
    /** Rebind a local row to a different server UID without re-upserting.
     *  Used by the Sent-sweep to repair optimistic-insert mispredictions
     *  (local row was inserted at the predicted UIDNEXT but the server
     *  appended at a different real UID). Idempotent: if no row matches
     *  (already rebound by an earlier sync), it's a no-op. */
    updateMessageUid(accountId: string, folderId: number, oldUid: number, newUid: number): void;
    /** Delete a message by account + folder + UID. folderId is REQUIRED:
     *  IMAP UIDs are per-folder, so the same numeric UID refers to different
     *  messages in INBOX vs Sent vs any other folder. The pre-2026-05-27
     *  implementation accepted (accountId, uid) only and ran
     *  `DELETE FROM messages WHERE account_id=? AND uid=?` — which on a
     *  QRESYNC VANISHED for INBOX uid=N would also delete the row for the
     *  SAME numeric uid=N living in Sent, Drafts, every label folder, etc.
     *  That's what wiped 70k bobma rows on 2026-05-27 when QRESYNC reported
     *  a handful of vanished UIDs. Never again — folderId required.
     *
     *  folderId === null is allowed as an explicit "I want every folder's
     *  copy of this UID gone" (used by cross-account move where the source
     *  is being abandoned entirely). All other callers MUST pass a folderId.
     *
     *  Reason is propagated into the audit_log so the DB carries an
     *  authoritative trail of every removal. */
    deleteMessage(accountId: string, folderId: number | null, uid: number, reason?: string, source?: string): void;
    /** Recalculate folder total/unread counts from actual messages */
    recalcFolderCounts(folderId: number): void;
    /** Bulk insert within a transaction for sync performance */
    beginTransaction(): void;
    commitTransaction(): void;
    rollbackTransaction(): void;
    /** Run `fn` inside a transaction — nesting-safe. `node:sqlite` throws on
     *  a nested BEGIN, and the async chunked walkers (seedContactsFromMessages,
     *  applyContactsConfig) can interleave with the sync backfill's own
     *  transactions across their `setImmediate` yields. If a transaction is
     *  already open this just runs `fn` (its writes join the open txn);
     *  otherwise it owns a fresh BEGIN/COMMIT. `fn` MUST be synchronous so it
     *  can't span an await and leave a transaction open across a yield. */
    /** Run a WAL checkpoint to bound the WAL file size. PASSIVE never blocks
     *  on readers — it reclaims frames older than the oldest live snapshot and
     *  returns immediately otherwise. Drive it from ONE connection (main) on a
     *  timer so the WAL can't balloon while three connections share the file.
     *  Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
     *  frames moved into the db) or null on error. */
    checkpoint(mode?: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"): {
        busy: number;
        log: number;
        checkpointed: number;
    } | null;
    runInTxn<T>(fn: () => T): T;
    /** Record an address used in sent mail */
    recordSentAddress(name: string, email: string): void;
    /** True if `email` (lowercased) appears in the active denylist. Cached
     *  in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
    private _denylist;
    isAddressDenylisted(emailLower: string): boolean;
    setContactsDenylist(emails: string[]): void;
    /** Priority sender / domain index — populated from contacts.jsonc on reload.
     *  Used by the client to flag list rows. Source of truth is the JSONC; this
     *  is just a fast in-memory lookup. */
    private _prioritySenders;
    private _priorityDomains;
    setPriorityIndex(senders: string[], domains: string[]): void;
    getPriorityIndex(): {
        senders: string[];
        domains: string[];
    };
    isPrioritySender(addr: string): boolean;
    /** Callback fired when local-DB contacts mutations happen (sends adding
     *  to discovered, corpus seeder finding new addresses). The service
     *  registers a debounced cloud flush here so the GDrive copy stays in
     *  sync. NOT fired from applyContactsConfig — that's the inbound path
     *  and would create a write loop. */
    private _onContactsChanged?;
    setOnContactsChanged(cb: () => void): void;
    private notifyContactsChanged;
    /** Fired when upsertMessage detects a server-side move (Message-ID match
     *  in another folder rebinds the existing row to the destination). The
     *  reconciler can listen and cancel any pending deferred-delete for the
     *  original (folder, uid), so the move propagates without the source
     *  folder's reconcile-delete firing 30 minutes later for a row that's
     *  been rebound elsewhere. */
    private _onMoveDetected?;
    setOnMoveDetected(cb: (info: {
        accountId: string;
        messageId: string;
        fromFolderId: number;
        fromUid: number;
        toFolderId: number;
        toUid: number;
        rowId: number;
    }) => void): void;
    /** Seed `discovered`-tier contacts from every address that appears in
     *  any cached message — From / To / Cc / Bcc across all folders. One row
     *  per email; first non-empty name observed wins. Sent-folder rows skip
     *  the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
     *  and denylisted addresses are dropped at seed time so they never enter
     *  autocomplete.
     *
     *  Discovered is a single tier; sub-distinctions like sent-vs-received
     *  collapse here because the user-facing UI shows them as one "discovered"
     *  source. Recency-weighted use_count differentiates within the tier. */
    /** ASYNC + chunked. This walks every cached message's address fields —
     *  on a large account that is hundreds of thousands of rows. The old
     *  synchronous version did `.all()` on the whole messages table in one
     *  call, materialising every row and blocking the event loop for tens
     *  of seconds to minutes (profiled 2026-05-15: 99% of daemon ticks were
     *  in native SQLite while this ran — every getMessage IPC, every preview
     *  click, queued behind it; that IS the "loading body takes forever"
     *  bug). Now keyset-paginated by `m.id` with a `setImmediate` yield
     *  between pages, so user IPC lands in the gaps. */
    seedContactsFromMessages(force?: boolean): Promise<number>;
    /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
     *  with the entries in `preferred[]`, merges `discovered[]` into the local
     *  cache, sets the in-memory denylist, and purges any discovered rows
     *  whose email is now denylisted. Preferred rows are *not* auto-purged on
     *  denylist hit — if the user explicitly added them they win that
     *  conflict; we just log a warning.
     *
     *  Discovered rows from the file are MERGED with whatever the local
     *  message-corpus seeder has produced. Each device contributes its
     *  observed addresses; over time GDrive accumulates the union. */
    applyContactsConfig(cfg: {
        preferred?: {
            name?: string;
            email: string;
            source?: string;
            organization?: string;
            org?: string;
            priority?: boolean;
        }[];
        denylist?: string[];
        denylistPatterns?: string[];
        priorityDomains?: string[];
        discovered?: {
            name?: string;
            email: string;
            useCount?: number;
            lastUsed?: number;
        }[];
    }): Promise<{
        preferred: number;
        discovered: number;
        purged: number;
        conflicts: string[];
    }>;
    /** Build the contacts.jsonc shape from current DB state — for round-trip
     *  to GDrive. Preferred-tier rows come from anything not in the reserved
     *  system sources; discovered comes from `source='discovered'` rows;
     *  denylist comes from the in-memory set (set by applyContactsConfig).
     *  Caller is responsible for actually writing the cloud copy. */
    exportContactsConfig(): {
        preferred: {
            name: string;
            email: string;
            source: string;
            organization?: string;
        }[];
        denylist: string[];
        discovered: {
            name: string;
            email: string;
            useCount: number;
            lastUsed: number;
        }[];
    };
    /** Search contacts by name or email prefix.
     *
     *  Source-tier bonus is what makes the curated address book win against
     *  passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
     *  source value other than the two reserved system sources) gets the
     *  highest tier — that's the user's explicit address book and overrides
     *  Google. Google sits in the middle (the auto-synced address book).
     *  `discovered` is the corpus-harvested floor.
     *
     *  Multi-name-per-email is supported: the same email can carry distinct
     *  (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
     *  surface as two rows, each typing-completable by their own name. */
    searchContacts(query: string, limit?: number): {
        name: string;
        email: string;
        source: string;
        sources: string[];
        useCount: number;
    }[];
    /** List all contacts (address-book view) with pagination + optional filter. */
    listContacts(query: string, page?: number, pageSize?: number): {
        items: {
            name: string;
            email: string;
            source: string;
            googleId: string | null;
            useCount: number;
            lastUsed: number;
        }[];
        total: number;
        page: number;
        pageSize: number;
    };
    /** Update or insert a contact manually (from the address book UI). */
    upsertContact(name: string, email: string): void;
    /** Delete a contact by email (address book UI). */
    deleteContact(email: string): void;
    /** Delete contact rows by Google People resourceName. Used by the
     *  incremental People sync when a person comes back with `metadata.deleted = true`
     *  — the email may have already changed/disappeared, but the resourceName
     *  is stable. Removes all rows tied to that Google identity (a single
     *  contact can have multiple email addresses, each is its own row). */
    deleteContactByGoogleId(googleId: string): number;
    /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
    searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number, includeTrashSpam?: boolean): PagedResult<MessageEnvelope>;
    /** Rebuild FTS index from existing messages.
     *  body_text is seeded from `preview` here (all db.ts can reach); the full
     *  message body is backfilled separately by mailx-imap's backfillFtsBodies()
     *  pass, which can read the cached .eml. */
    rebuildSearchIndex(): number;
    /** Queue a local action for later sync to IMAP */
    queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
        targetFolderId?: number;
        flags?: string[];
        rawMessage?: string;
    }): void;
    /** Get all pending sync actions for an account */
    getPendingSyncActions(accountId: string): {
        id: number;
        action: string;
        uid: number;
        folderId: number;
        targetFolderId: number;
        flags: string[];
        rawMessage: string;
        attempts: number;
    }[];
    /** Remove a completed sync action */
    completeSyncAction(id: number): void;
    /** Mark a sync action as failed */
    failSyncAction(id: number, error: string): void;
    /** Count pending sync actions for an account */
    getPendingSyncCount(accountId: string): number;
    /** Count total pending sync actions across all accounts */
    getTotalPendingSyncCount(): number;
}
//# sourceMappingURL=db.d.ts.map