/**
 * @bobfrankston/mailx-service
 * Pure business logic — no HTTP, no Express.
 * Both the Express API (mailx-api) and the Android bridge call these functions.
 */

import { randomUUID } from "node:crypto";
import * as dns from "node:dns/promises";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { MailxDB, parseSerial } from "@bobfrankston/mailx-store";

const __dirname = import.meta.dirname;
import { ImapManager } from "@bobfrankston/mailx-imap";
import * as gsync from "./google-sync.js";
import { Store } from "@bobfrankston/mailx-store";
import { SyncQueue } from "./sync-queue.js";
import { Reconciler } from "./reconciler.js";
import { spawnDbWorker, type SpawnedDbWorker } from "./db-worker-client.js";
export { spawnSyncWorker, type SpawnedSyncWorker } from "./sync-worker-client.js";
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, saveKeys, ensureKeysSectionExists, getStorePath, getStorageInfo, getConfigDir, loadUserDict, saveUserDict, cloudReadBinary, providerForDomain } from "@bobfrankston/mailx-settings";
import type { AccountConfig, Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse, ExtractedEvent, MailxApi } from "@bobfrankston/mailx-types";
import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";

// Charset normalization (sniffAndFixCharset) is owned solely by
// mailx-store/charset.ts — the single live parse path (LocalStore.getMessage)
// calls it there. mailx-service used to keep a duplicate copy that nothing
// invoked; removed 2026-05-21 (economy of mechanism — one charset fixer).

// parseListUnsubscribe moved to ./local-store.ts (only consumer is the
// body-read path, which is now part of LocalStore).

// ── Email provider detection (MX-based) ──

const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];

// Google calendar refresh window — see getCalendarEvents. Held fixed and
// generous so every caller's query window is a subset; the sidebar's
// 30-day horizon and the alarm poll's 3-hour window both fall inside it.
const CAL_REFRESH_BACK_DAYS = 7;
const CAL_REFRESH_FWD_DAYS = 120;

// Minimum wall-clock gap between Google calendar/tasks network refreshes,
// regardless of how often a caller asks. The alarm poll calls
// getCalendarEvents/getTasks every 30 s — without this gate that drove a
// Google API call every 30 s and burned the Tasks "Queries per day" project
// quota. Reads still return local rows instantly between refreshes.
const GOOGLE_REFRESH_MIN_INTERVAL_MS = 5 * 60_000;

async function detectEmailProvider(domain: string): Promise<{ cloud?: "gdrive"; imapHost: string; smtpHost: string; auth: "oauth2" | "password" } | null> {
    // Google is special: it also sets up the gdrive shared-config cloud.
    if (GOOGLE_DOMAINS.includes(domain)) return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
    // Everything else comes from the ONE shared PROVIDERS table in mailx-settings
    // (Outlook, Yahoo, AOL, iCloud, Verizon...). Previously this function had its
    // own hardcoded Google+MS-only list, so AOL/Yahoo/iCloud returned null and
    // never auto-configured on setup (Bob 2026-06-19). Single source of truth now.
    const p = providerForDomain(domain);
    if (p) return { imapHost: p.imap.host, smtpHost: p.smtp.host, auth: p.imap.auth };
    try {
        const records = await dns.resolveMx(domain);
        for (const mx of records) {
            const host = mx.exchange.toLowerCase();
            if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
                return { cloud: "gdrive", imapHost: "imap.gmail.com", smtpHost: "smtp.gmail.com", auth: "oauth2" };
            }
            if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
                return { imapHost: "outlook.office365.com", smtpHost: "smtp.office365.com", auth: "oauth2" };
            }
        }
    } catch { /* DNS lookup failed */ }
    return null;
}

// sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with Android)

/** Scratch dir where the *Open* path stages an attachment before handing it to
 *  the OS default app. It lives under the OS temp dir — not `~/.rmfmail/` —
 *  because these are throwaway copies, not app state: the OS sweeps temp as a
 *  backstop and they don't masquerade as durable data. `start`/`open` only need
 *  the file to exist long enough for the launched app to read it. */
function getAttachmentStagingDir(): string {
    return path.join(os.tmpdir(), "rmfmail", "attachments");
}

const ATTACHMENT_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;

/** Purge stale staged attachments (and retire the legacy
 *  `~/.rmfmail/attachments/` location, which never had cleanup and accumulated
 *  months of opened files). Best-effort: any error is swallowed — staging is
 *  not load-bearing. Called at startup and is cheap (one readdir). */
function cleanupAttachmentStaging(legacyDir: string): void {
    const sweep = (dir: string, maxAgeMs: number): void => {
        let entries: string[];
        try { entries = fs.readdirSync(dir); } catch { return; }
        const now = Date.now();
        for (const name of entries) {
            const p = path.join(dir, name);
            try {
                const st = fs.statSync(p);
                if (st.isFile() && (maxAgeMs === 0 || now - st.mtimeMs > maxAgeMs)) {
                    fs.unlinkSync(p);
                }
            } catch { /* ignore — file may have vanished or be locked */ }
        }
    };
    sweep(getAttachmentStagingDir(), ATTACHMENT_STAGING_MAX_AGE_MS);
    // Legacy location: these are all orphaned Open-staging copies, so clear
    // them regardless of age (maxAge 0), then drop the now-empty dir.
    sweep(legacyDir, 0);
    try { fs.rmdirSync(legacyDir); } catch { /* not empty / not present — fine */ }
}

/** Map a MIME type to a file extension so a saved attachment whose name lost
 *  its extension still dispatches to an OS handler. Covers the common types;
 *  unknown types return "" (the file opens without an extension as before). */
function extForMime(mime: string): string {
    const m = (mime || "").toLowerCase().split(";")[0].trim();
    const map: Record<string, string> = {
        "text/calendar": ".ics",
        "application/ics": ".ics",
        "application/pdf": ".pdf",
        "text/plain": ".txt",
        "text/html": ".html",
        "image/png": ".png",
        "image/jpeg": ".jpg",
        "image/gif": ".gif",
        "image/webp": ".webp",
        "image/svg+xml": ".svg",
        "application/zip": ".zip",
        "application/json": ".json",
        "application/msword": ".doc",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
        "application/vnd.ms-excel": ".xls",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
    };
    return map[m] || "";
}

/** Compare a local task row to an incoming Google task projection. Used by
 *  refreshTasks to skip no-op upserts that would otherwise emit `tasksUpdated`
 *  on every poll, feeding back into the UI's getTasks-on-event listener and
 *  burning the daily Google Tasks API quota. */
function taskRowEquals(prior: any, fresh: any): boolean {
    return prior.providerId === fresh.providerId
        && prior.title === fresh.title
        && (prior.notes || "") === (fresh.notes || "")
        && (prior.dueMs ?? null) === (fresh.dueMs ?? null)
        && (prior.completedMs ?? null) === (fresh.completedMs ?? null)
        && (prior.etag || "") === (fresh.etag || "");
}

/** Same shape as taskRowEquals — compares the calendar-event fields the
 *  Google projection actually carries, ignoring derived/local-only columns. */
function calendarRowEquals(prior: any, fresh: any): boolean {
    return prior.providerId === fresh.providerId
        && prior.title === fresh.title
        && prior.startMs === fresh.startMs
        && prior.endMs === fresh.endMs
        && !!prior.allDay === !!fresh.allDay
        && (prior.location || "") === (fresh.location || "")
        && (prior.notes || "") === (fresh.notes || "")
        && (prior.etag || "") === (fresh.etag || "")
        && (prior.recurringEventId || null) === (fresh.recurringEventId || null)
        && (prior.htmlLink || null) === (fresh.htmlLink || null)
        && (prior.calendarId || "") === (fresh.calendarId || "");
}

interface ReputationResult {
    flagged: boolean;
    listedCount: number;
    checkedCount: number;
    sources: Array<{ service: string; flagged: boolean; verdict: string }>;
    verdict: string;
    service: string;
}

// ── Service ──

/** Injected popup function — wraps mailx-host's showMessageBox so the
 *  service can pop OS-level always-on-top reminder windows without
 *  itself depending on mailx-host (keeps the host abstraction clean).
 *  Set via setPopupFn in bin/mailx.ts after construction. Returns the
 *  label of the button the user clicked, or "closed" / "dismissed" /
 *  "" when the window was closed without picking one. */
export type PopupFn = (opts: any) => Promise<{ button?: string; closed?: boolean; dismissed?: boolean }>;

/** Injected popout-window function — bin/mailx.ts wraps mailx-host's
 *  showMessageBoxEx pointed at the loopback popout server, so "open this
 *  message in its own OS window" spawns a native msger WebView window
 *  instead of falling back to the OS browser. Injection (not import)
 *  keeps mailx-service host-agnostic, same as PopupFn. */
export type PopoutWindowFn = (opts: { accountId: string; uid: number; folderId?: number; subject?: string }) => Promise<{ ok: boolean; reason?: string }>;

/** Injected compose-popout functions — bin/mailx.ts wraps mailx-host's
 *  showMessageBoxEx pointed at the popout server's static compose page, so
 *  "take this compose into its own OS window" spawns a native msger window.
 *  `open` receives the stash id the page will consume; `close` tears the
 *  window down after send/discard. Injection keeps mailx-service host-agnostic. */
export type ComposePopoutFns = {
    open: (opts: { id: string; subject?: string }) => Promise<{ ok: boolean; reason?: string }>;
    close: (id: string) => void;
};

/** How long an un-consumed compose-popout init stash survives. The popout
 *  window normally consumes it within a second of spawning; the timer only
 *  matters when the window fails to launch. */
const COMPOSE_POPOUT_INIT_TTL_MS = 300_000;

/** Best-effort MIME for a sound filename so the client's <audio> blob plays. */
function mimeForSoundExt(name: string): string {
    const ext = (name.split(".").pop() || "").toLowerCase();
    const map: Record<string, string> = {
        mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", oga: "audio/ogg",
        m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", weba: "audio/webm", opus: "audio/ogg",
    };
    return map[ext] || "audio/mpeg";
}

export class MailxService implements MailxApi {
    // Cached accounts — loadSettings() reads from the cloud-mounted
    // accounts.jsonc, which can stall on a flaky GDrive File Stream.
    // Refresh on configChanged (fs.watch) so edits still land.
    private _accountsCache: AccountConfig[] | null = null;
    // First-load-only guard for seedContactsFromMessages — see the
    // comment in loadContactsConfig() for why we don't re-walk on
    // every fs.watch firing of contacts.jsonc.
    private _contactsCorpusSeeded = false;
    // Retry bookkeeping for loadContactsConfig when the cloud isn't ready
    // yet at startup. Without retries, a failed first read left the denylist
    // / preferred list unloaded for the whole session.
    private _contactsRetries = 0;
    // Settings cache. `loadSettings()` does a synchronous read of
    // accounts.jsonc (via loadAccounts) AND preferences.jsonc — both of
    // which may live on a GDrive-mounted shared dir where readFileSync
    // can stall the entire event loop for 5-30 seconds when the file
    // isn't locally cached. Caching here drops getMessage / preview
    // latency from "wedged 28 seconds" to <50 ms; cache invalidates
    // on configChanged events the same way _accountsCache does.
    private _settingsCache: any | null = null;
    private popupFn: PopupFn | null = null;
    private popoutWindowFn: PopoutWindowFn | null = null;

    /** Inject the popup implementation. Called once at startup from
     *  bin/mailx.ts with mailx-host's showMessageBox. Without this,
     *  showReminderPopup returns a "no host" reason. */
    setPopupFn(fn: PopupFn): void { this.popupFn = fn; }

    /** Inject the popout-window implementation (native msger window per
     *  message). Without this, popoutWindow returns a "no host" reason and
     *  the client falls back to its in-window overlay. */
    setPopoutWindowFn(fn: PopoutWindowFn): void { this.popoutWindowFn = fn; }

    /** Open a message in its own native OS window (msger WebView). The
     *  client's 🗗 button lands here via jsonrpc. */
    async popoutWindow(accountId: string, uid: number, folderId?: number, subject?: string): Promise<{ ok: boolean; reason?: string }> {
        if (!this.popoutWindowFn) return { ok: false, reason: "no popout host" };
        try {
            return await this.popoutWindowFn({ accountId, uid, folderId, subject });
        } catch (e: any) {
            return { ok: false, reason: e?.message || String(e) };
        }
    }

    private composePopoutFns: ComposePopoutFns | null = null;
    /** Compose-popout init stash, keyed by the id embedded in the popout
     *  window's URL. One-shot: consumePopoutComposeInit deletes on read. */
    private composePopoutInits = new Map<string, any>();

    setComposePopoutFns(fns: ComposePopoutFns): void { this.composePopoutFns = fns; }

    /** Take a live compose into its own native OS window. The overlay hands
     *  us its full state (fields, body, attachments, draft identity); we
     *  stash it under a one-shot id and spawn the window pointed at the
     *  popout server's compose page with that id in the URL. */
    async popoutCompose(init: any): Promise<{ ok: boolean; reason?: string }> {
        if (!this.composePopoutFns) return { ok: false, reason: "no compose popout host" };
        const id = randomUUID();
        this.composePopoutInits.set(id, init);
        const gc = setTimeout(() => this.composePopoutInits.delete(id), COMPOSE_POPOUT_INIT_TTL_MS);
        (gc as any).unref?.();
        try {
            const r = await this.composePopoutFns.open({ id, subject: init?.subject });
            if (!r.ok) this.composePopoutInits.delete(id);
            return r;
        } catch (e: any) {
            this.composePopoutInits.delete(id);
            return { ok: false, reason: e?.message || String(e) };
        }
    }

    /** One-shot read of a stashed compose-popout init (popout page boot). */
    consumePopoutComposeInit(id: string): { init: any | null } {
        const init = this.composePopoutInits.get(id) ?? null;
        this.composePopoutInits.delete(id);
        return { init };
    }

    /** Close a compose-popout window (after send / discard in the popout). */
    closeComposePopout(id: string): { ok: boolean } {
        try { this.composePopoutFns?.close(id); } catch { /* window already gone */ }
        return { ok: true };
    }

    /** Local-first read/write facade. Every UI IPC handler that touches the
     *  local DB or body store goes through this — no awaiting IMAP, no
     *  awaiting Gmail API, no awaiting SMTP. See docs/local-first-plan.md. */
    private localStore: Store;

    /** Phase 0 read isolation (docs/clean-architecture.md §5). The DB read
     *  surface runs on its OWN thread with its own read-only SQLite handle, so
     *  UI list/search/open reads can NEVER be head-of-line-blocked behind a
     *  synchronous sync write or a hung IMAP op on the main event loop. Null
     *  until the worker has finished its init handshake (a few ms at startup)
     *  and null forever if spawning failed — in both cases reads fall back to
     *  the in-process `localStore`, same code, same answers. `readReady`
     *  resolves once the spawn attempt settles (either outcome). */
    private dbWorker: SpawnedDbWorker | null = null;
    private readReady: Promise<void>;

    /** Persistent (and in-memory body-fetch) queue. UI handlers commit
     *  locally, then enqueue a server-mirror task here. */
    private syncQueue: SyncQueue;

    /** Background loop: drains body-fetch tasks, retries failed message
     *  actions, emits sync-state events for the status pill. */
    private reconciler: Reconciler;

    constructor(
        /** The Store is the nexus — owns DB + .eml files + bus + operations.
         *  MailxService is the IPC adapter on top: it routes UI requests to
         *  Store reads/writes and to the SyncQueue/Reconciler for server-
         *  mirror work. The raw `db` getter below is a transitional shim so
         *  the ~50 `this.db.X(...)` callsites in this file don't all need
         *  touching at once; all writes should migrate to `this.localStore.X(...)`. */
        private store: Store,
        private imapManager: ImapManager,
    ) {
        this.localStore = store;
        this.syncQueue = new SyncQueue(store.db, imapManager);
        this.reconciler = new Reconciler(store.db, imapManager, this.syncQueue);
        this.reconciler.start();

        // Invalidate caches when their source files change on disk / GDrive.
        this.imapManager.on?.("configChanged", (filename: string) => {
            if (filename === "accounts.jsonc") this._accountsCache = null;
            this._settingsCache = null;
            this.localStore.invalidateConfigCaches();
            // The read-worker holds its OWN copy of these JSONC caches
            // (allowlist drives getMessage sanitization). Tell it to drop them.
            this.dbWorker?.bus.publish("config:changed", { filename });
            if (filename === "contacts.jsonc") {
                this.loadContactsConfig().catch(e =>
                    console.error(`  [contacts] reload failed: ${e?.message || e}`));
            }
        });
        // Wire DB → cloud flush. Debounced to absorb bursts.
        this.store.db.setOnContactsChanged(() => this.markContactsDirty());
        // Initial load of contacts.jsonc — fire-and-forget; missing file is fine.
        this.loadContactsConfig().catch(() => { /* file may not exist yet */ });
        // Sweep stale Open-staging copies and retire the legacy in-config dir.
        try { cleanupAttachmentStaging(path.join(getConfigDir(), "attachments")); } catch { /* best-effort */ }

        // Spawn the DB read-worker (Phase 0). Fire-and-forget: until it acks
        // init, reads fall back to the in-process store. The main MailxDB
        // above already ran schema + migrations, so the worker's read-only
        // handle opens against a fully-built file.
        this.readReady = this.spawnReadWorker();

        // Keep the WAL bounded. With three connections sharing the file the WAL
        // ballooned to 71 MB, which made every read/write crawl (Bob 2026-06-14)
        // — and even bounded, a 38 MB WAL during heavy sync made writes slow
        // enough to lock-contend (the contacts-seed 2s txns, 2026-06-16). PASSIVE
        // recycles frames older than the oldest live reader WITHOUT blocking the
        // UI on a reader. A TIGHTER 8s tick keeps the WAL small between sync
        // write bursts, so writes stay fast. Only main drives this — one
        // checkpointer avoids checkpoint contention.
        this._walCheckpointTimer = setInterval(() => {
            try {
                // TRUNCATE (non-blocking — checkpoint() drops busy_timeout to 0 for
                // it) both checkpoints frames AND shrinks the WAL file back to 0.
                // PASSIVE kept frames checkpointed but left the file at its grown
                // 26 MB high-water mark; TRUNCATE reclaims it the instant no writer
                // holds the lock, and harmlessly no-ops (busy=1) when one does.
                const r = this.store.db.checkpoint("TRUNCATE");
                // log = frames still in the WAL (~4KB each). A persistently large
                // log means a writer keeps winning the lock every tick — surface it.
                if (r && r.log > 12000) console.warn(`  [db] WAL still large after checkpoint: ${r.log} frames (~${Math.round(r.log * 4 / 1024)} MB), busy=${r.busy}`);
            } catch { /* non-fatal */ }
        }, 8_000);
        (this._walCheckpointTimer as any).unref?.();
    }

    private _walCheckpointTimer: ReturnType<typeof setInterval> | null = null;

    /** Bring up the read-worker. Resolves whether it succeeded or not — on
     *  failure `dbWorker` stays null and every read uses `localStore`. */
    private async spawnReadWorker(): Promise<void> {
        try {
            const w = await spawnDbWorker({ dbPath: getConfigDir(), storePath: getStorePath() });
            this.dbWorker = w;
            console.log("  [read-worker] online — UI reads isolated from the sync event loop");
            // If the worker dies unexpectedly, drop back to in-process reads
            // rather than rejecting every future request.
            w.worker.once("exit", (code: number) => {
                if (this.dbWorker === w) this.dbWorker = null;
                console.error(`  [read-worker] exited (code ${code}); reverting to in-process reads`);
            });
        } catch (e: any) {
            this.dbWorker = null;
            console.error(`  [read-worker] spawn failed; using in-process reads: ${e?.message || e}`);
        }
    }

    /** In-flight read coalescing. A slow daemon makes the UI re-fire the same
     *  read (scroll loadMore retries, poll loops, re-render storms): the log
     *  showed the IDENTICAL getUnifiedInbox queued dozens of times and all
     *  draining at once after a 78s stall. Keyed by method+args, a duplicate
     *  request that arrives while one is already running shares that single
     *  promise instead of enqueuing another worker job. Same args ⇒ same
     *  result, so this is always safe; different args (other pages) key
     *  separately. This caps the worker queue at one job per distinct read. */
    private inflightReads = new Map<string, Promise<any>>();

    /** Route a read through the worker bus if it's up, else run it in-process.
     *  A worker-side error (or a dead worker) transparently falls back so a
     *  read never fails just because the isolation layer hiccuped. Identical
     *  concurrent reads are coalesced (see inflightReads). */
    private async read<R>(method: string, args: any, local: () => R): Promise<R> {
        let key: string;
        try { key = method + ":" + JSON.stringify(args); }
        catch { key = ""; }   // unserializable args → don't coalesce
        if (key) {
            const existing = this.inflightReads.get(key);
            if (existing) return existing as Promise<R>;
        }
        const run = (async (): Promise<R> => {
            const w = this.dbWorker;
            if (w) {
                try {
                    return await w.bus.request<any, R>(method, args);
                } catch (e: any) {
                    console.error(`  [read-worker] ${method} failed (${e?.message || e}); in-process fallback`);
                }
            }
            return local();
        })();
        if (key) {
            this.inflightReads.set(key, run);
            try { return await run; }
            finally { this.inflightReads.delete(key); }
        }
        return run;
    }
    /** Transitional getter — direct DB access from MailxService for the
     *  ~50 callsites that haven't yet migrated to Store methods. Future:
     *  every mutation routes through `this.localStore.X(...)`. */
    private get db(): MailxDB { return this.store.db; }

    private _contactsFlushTimer: ReturnType<typeof setTimeout> | null = null;
    private _contactsFlushInFlight = false;
    private readonly CONTACTS_FLUSH_DEBOUNCE_MS = 30_000;
    /** Schedule a debounced flush of the local contacts state to GDrive.
     *  Multiple changes within the debounce window collapse to one write. */
    markContactsDirty(): void {
        if (this._contactsFlushTimer) clearTimeout(this._contactsFlushTimer);
        this._contactsFlushTimer = setTimeout(() => {
            this._contactsFlushTimer = null;
            this.flushContactsConfig().catch(e =>
                console.error(`  [contacts] flush failed: ${e?.message || e}`));
        }, this.CONTACTS_FLUSH_DEBOUNCE_MS);
    }

    /** Serializes read-modify-write cycles on the cloud contacts.jsonc within
     *  this process. Without it, the debounced flush can read the file, an
     *  addToDenylist/setPriority write can land, and the flush write-back then
     *  clobbers it — the "never suggest didn't stick" bug. Cross-device
     *  overlap is handled separately by flushContactsConfig merging instead
     *  of overwriting. */
    private _contactsCloudChain: Promise<void> = Promise.resolve();
    private contactsCloudSerial<T>(fn: () => Promise<T>): Promise<T> {
        const run = this._contactsCloudChain.then(fn);
        this._contactsCloudChain = run.then((): undefined => undefined, (): undefined => undefined);
        return run;
    }

    /** Write current DB contacts state to GDrive contacts.jsonc. Called via
     *  the debounced timer; also exposed for force-flush on shutdown or
     *  after a manual seed. Idempotent — safe to call multiple times. */
    async flushContactsConfig(): Promise<void> {
        if (this._contactsFlushInFlight) return;
        this._contactsFlushInFlight = true;
        try {
            await this.contactsCloudSerial(() => this.flushContactsConfigNow());
        } finally {
            this._contactsFlushInFlight = false;
        }
    }

    private async flushContactsConfigNow(): Promise<void> {
        const local = this.db.exportContactsConfig();
        const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
        const { parse: parseJsonc } = await import("jsonc-parser");
        // Q150: `discovered` is a per-device cache derived from the local
        // mail corpus — it is NOT written to the cloud file. That keeps
        // contacts.jsonc a few KB of curated data instead of a 1.5 MB
        // blob, and avoids the cross-device last-writer-wins clobber on
        // every use-count tick.
        // MERGE into the existing file rather than overwrite: the local
        // table only owns `preferred` + `denylist`; `groups`,
        // `priorityDomains`, and any hand-added keys must survive a flush
        // (the old overwrite silently wiped them).
        let existing: Record<string, unknown> = {};
        try {
            const raw = await cloudRead("contacts.jsonc");
            if (raw) existing = (parseJsonc(raw, [], { allowTrailingComma: true }) as Record<string, unknown>) || {};
        } catch { existing = {}; }
        // `preferred` and `denylist` also merge per-entry, not overwrite.
        // Every mutation path (desktop ⊘/★, Android ⊘/★, hand-edits on
        // Drive) writes the FILE first and reloads the DB from it, so the
        // file is authoritative and the DB is a mirror of the last load.
        // Writing the mirror back wholesale silently dropped anything
        // added since that load — a phone's "never suggest", a Drive
        // hand-edit inside the 3-minute poll window, even this machine's
        // own ⊘ when the debounced flush raced the reload — and stripped
        // per-entry keys the DB doesn't round-trip (`priority: true`).
        // Keep every file entry as-is; append local-only ones. The cost:
        // a hand-DELETED entry can be resurrected if a flush fires before
        // the next cloud-poll reload (≤3 min) — accepted over losing adds.
        const fileDeny: string[] = (Array.isArray((existing as any).denylist) ? (existing as any).denylist : [])
            .filter((e: unknown) => typeof e === "string");
        const denyKeys = new Set(fileDeny.map(e => e.trim().toLowerCase()));
        const denylist = [...fileDeny];
        for (const e of local.denylist) {
            const k = (e || "").trim().toLowerCase();
            if (k && !denyKeys.has(k)) { denyKeys.add(k); denylist.push(e); }
        }
        const filePref: any[] = (Array.isArray((existing as any).preferred) ? (existing as any).preferred : [])
            .filter((e: any) => e && typeof e.email === "string");
        const prefKey = (e: any): string => `${(e?.email || "").trim().toLowerCase()}|${(e?.name || "").trim().toLowerCase()}`;
        const prefKeys = new Set(filePref.map(prefKey));
        const preferred = [...filePref];
        for (const e of local.preferred) {
            if (!prefKeys.has(prefKey(e))) { prefKeys.add(prefKey(e)); preferred.push(e); }
        }
        const merged: Record<string, unknown> = { ...existing, preferred, denylist };
        delete merged.discovered;   // never sync the local-derived cache
        await cloudWrite("contacts.jsonc", JSON.stringify(merged, null, 2));
        // Adopt cloud-side denylist adds now instead of waiting for the
        // 3-minute poll → reload cycle to bring them back around.
        this.db.setContactsDenylist(denylist);
        console.log(`  [contacts] flushed to cloud: ${preferred.length} preferred + ${denylist.length} denylisted (merged with file; discovered kept local — Q150)`);
    }

    /** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
     *  into the DB. On first run with no file, seed from message corpus and
     *  write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
     *  makes a new device useful immediately on a shared GDrive setup. */
    async loadContactsConfig(): Promise<{ preferred: number; discovered: number; purged: number; conflicts: string[] } | null> {
        let raw: string | null = null;
        let cloudAvailable = false;
        try {
            const { cloudRead } = await import("@bobfrankston/mailx-settings");
            raw = await cloudRead("contacts.jsonc");
            cloudAvailable = true;
        } catch { /* cloud unavailable */ }

        // Cloud not ready — the read THREW (GDrive auth/init still in
        // progress; loadContactsConfig() is fired from the constructor). Do
        // NOT fall through to applyContactsConfig({denylist:[]}): that wipes
        // the in-memory denylist for the whole session, so every "never
        // suggest this address" choice silently comes back next run (Bob
        // 2026-05-21: "it's on drive but not read for the new session").
        // Retry with backoff until the cloud is actually up.
        if (!cloudAvailable) {
            const RETRY_DELAYS = [3_000, 8_000, 20_000, 45_000, 90_000, 90_000];
            if (this._contactsRetries < RETRY_DELAYS.length) {
                const delay = RETRY_DELAYS[this._contactsRetries];
                this._contactsRetries++;
                console.log(`  [contacts] cloud not ready — retry ${this._contactsRetries} in ${delay / 1000}s`);
                setTimeout(() => { this.loadContactsConfig().catch(() => { /* */ }); }, delay);
            } else {
                console.error(`  [contacts] cloud unreachable after ${RETRY_DELAYS.length} retries — denylist/preferred NOT loaded this session`);
            }
            return null;
        }
        // Cloud reachable — clear the retry counter so a later fs.watch
        // reload that briefly fails can retry afresh.
        this._contactsRetries = 0;

        if (!raw) {
            // No file (yet) — genuinely missing, cloud confirmed reachable.
            // Reset in-memory denylist and seed discovered from the local
            // message corpus so autocomplete works immediately.
            await this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
            try { await this.db.seedContactsFromMessages(); } catch { /* corpus may be empty */ }
            // Auto-bootstrap GDrive copy if cloud is reachable. The file gets
            // a header comment so a user opening it on Drive sees what it is.
            if (cloudAvailable) {
                try {
                    await this.flushContactsConfig();
                    console.log("  [contacts] auto-seeded contacts.jsonc on GDrive from local corpus");
                } catch (e: any) {
                    console.error(`  [contacts] auto-seed flush failed: ${e?.message || e}`);
                }
            }
            return null;
        }

        const { parse: parseJsonc } = await import("jsonc-parser");
        const errors: any[] = [];
        const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
        if (errors.length) {
            console.error(`  [contacts] contacts.jsonc has parse errors — applying empty config: ${errors.map((e: any) => e.error).join(", ")}`);
            await this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
            return null;
        }
        const result = await this.db.applyContactsConfig(cfg || {});
        // Seed-from-messages is a CORPUS WALK (every message's From/To/Cc
        // fields). It is now async + keyset-chunked (db.seedContactsFromMessages)
        // so it yields the event loop between pages — no longer the
        // half-minute-to-minutes daemon wedge it once was. Still only run on
        // the FIRST load after startup; subsequent reloads just sync the
        // file → DB. Individual sends keep the discovered set current via
        // recordSentAddress() in the hot path.
        if (!this._contactsCorpusSeeded) {
            this._contactsCorpusSeeded = true;
            this.db.seedContactsFromMessages()
                .catch(() => { /* corpus may be empty */ });
        }
        return result;
    }

    /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
     *  then re-apply. Mutates the file in place — preserves existing entries
     *  and the user's hand-formatting where the parser permits. */
    async addPreferredContact(entry: { name: string; email: string; source?: string; organization?: string }): Promise<void> {
        // contacts.jsonc mutation logic is shared with the Android service
        // (mailx-store-web) — see mailx-types/contacts-config.ts. Only the
        // cloud-I/O functions and the local re-apply differ per platform.
        await this.contactsCloudSerial(async () => {
            const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
            const { addContactsPreferredEntry } = await import("@bobfrankston/mailx-types");
            await addContactsPreferredEntry(entry, cloudRead, cloudWrite);
        });
        await this.loadContactsConfig();
    }

    /** Return the priority sender / domain index — derived from the most
     *  recently loaded contacts.jsonc. Used by the client to flag list
     *  rows visually. */
    getPriorityLists(): { senders: string[]; domains: string[] } {
        return this.db.getPriorityIndex();
    }

    /** Mark / unmark a sender as priority. Finds the matching preferred[]
     *  entry (by lowercased email), sets `priority: true|false`, and writes
     *  contacts.jsonc back to cloud. If no preferred entry exists, one is
     *  inserted with the given name. Reloads the in-memory index. */
    async setPrioritySender(email: string, value: boolean, name?: string): Promise<void> {
        const lower = (email || "").trim().toLowerCase();
        if (!lower) return;
        await this.contactsCloudSerial(async () => {
            const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
            const { parse: parseJsonc } = await import("jsonc-parser");
            let cfg: any = {};
            const raw = await cloudRead("contacts.jsonc");
            if (raw) { try { cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {}; } catch { cfg = {}; } }
            if (!Array.isArray(cfg.preferred)) cfg.preferred = [];
            const idx = cfg.preferred.findIndex((e: any) => (e?.email || "").toLowerCase() === lower);
            if (idx >= 0) {
                if (value) cfg.preferred[idx].priority = true;
                else delete cfg.preferred[idx].priority;
            } else if (value) {
                cfg.preferred.push({ name: name || "", email, priority: true });
            }
            await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
        });
        await this.loadContactsConfig();
    }

    /** Mark / unmark a domain as priority. Maintained in
     *  contacts.jsonc → priorityDomains[] (separate from preferred[]
     *  because per-address contacts don't have a domain shape). */
    async setPriorityDomain(domain: string, value: boolean): Promise<void> {
        const lower = (domain || "").trim().toLowerCase();
        if (!lower) return;
        await this.contactsCloudSerial(async () => {
            const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
            const { parse: parseJsonc } = await import("jsonc-parser");
            let cfg: any = {};
            const raw = await cloudRead("contacts.jsonc");
            if (raw) { try { cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {}; } catch { cfg = {}; } }
            if (!Array.isArray(cfg.priorityDomains)) cfg.priorityDomains = [];
            const i = cfg.priorityDomains.findIndex((d: string) => (d || "").toLowerCase() === lower);
            if (value && i < 0) cfg.priorityDomains.push(lower);
            else if (!value && i >= 0) cfg.priorityDomains.splice(i, 1);
            await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
        });
        await this.loadContactsConfig();
    }

    /** Append an email to contacts.jsonc#denylist[] and write back to cloud,
     *  then re-apply (which purges any matching discovered rows). */
    async addToDenylist(email: string): Promise<void> {
        // Shared with the Android service — see mailx-types/contacts-config.ts.
        await this.contactsCloudSerial(async () => {
            const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
            const { addContactsDenylistEntry } = await import("@bobfrankston/mailx-types");
            await addContactsDenylistEntry(email, cloudRead, cloudWrite);
        });
        await this.loadContactsConfig();
    }

    /** Return accounts from cache — load once, reuse until configChanged. */
    private getCachedAccounts(): AccountConfig[] {
        if (!this._accountsCache) this._accountsCache = loadAccounts();
        return this._accountsCache;
    }

    /** Return full settings from cache — load once, reuse until
     *  configChanged invalidates. Hot-path callers (getMessage on every
     *  preview click, refreshCalendarEvents on every poll) MUST go
     *  through here; calling raw `loadSettings()` blocks the event loop
     *  with a sync GDrive-mounted readFileSync. See `_settingsCache`
     *  comment for the wedge-28-seconds story. */
    private getCachedSettings(): any {
        if (!this._settingsCache) this._settingsCache = loadSettings();
        return this._settingsCache;
    }

    /** Resolve the reminder sound for the client when an alarm fires.
     *  Returns `{mute:true}` for "none", the raw sound bytes (base64 + mime)
     *  for a custom file, or `{}` to mean "play the built-in chime" (the
     *  default, AND the fallback when a configured file can't be read). The
     *  custom path is resolved relative to the config: the LOCAL config dir
     *  (~/.rmfmail/<name>, binary-safe, subpaths OK) first, then the shared
     *  cloud config folder on Drive (flat, basename only) — so the sound can
     *  live next to the JSONC config and follow the user across machines. */
    async getReminderSound(): Promise<{ mute?: boolean; dataBase64?: string; mime?: string }> {
        const settings = this.getCachedSettings() || {};
        const ref = String(settings.reminders?.sound || "").trim();
        if (!ref) return {};
        if (/^(none|off|silent|mute)$/i.test(ref)) return { mute: true };
        const mime = mimeForSoundExt(ref);
        try {
            const local = path.join(getConfigDir(), ref);
            if (fs.existsSync(local) && fs.statSync(local).isFile()) {
                return { dataBase64: fs.readFileSync(local).toString("base64"), mime };
            }
        } catch { /* fall through to cloud */ }
        try {
            const b64 = await cloudReadBinary(path.basename(ref));
            if (b64) return { dataBase64: b64, mime };
        } catch { /* fall through to default */ }
        console.error(`  [reminder-sound] could not read "${ref}" locally or on Drive — using built-in chime`);
        return {};
    }

    // ── Accounts ──

    getAccounts(): any[] {
        const dbAccounts = this.db.getAccounts();
        const cfgs = this.getCachedAccounts();
        // Order by settings (accounts.jsonc is the source of truth for order)
        const ordered: any[] = [];
        for (const cfg of cfgs) {
            const a = dbAccounts.find(d => d.id === cfg.id);
            if (a) ordered.push({
                ...a, label: cfg.label, defaultSend: cfg.defaultSend || false,
                primary: !!(cfg as any).primary,
                primaryCalendar: !!(cfg as any).primaryCalendar,
                primaryTasks: !!(cfg as any).primaryTasks,
                primaryContacts: !!(cfg as any).primaryContacts,
                identityDomains: (cfg as any).identityDomains || [],
            });
        }
        // Append any DB accounts not in settings
        for (const a of dbAccounts) {
            if (!ordered.find((o: any) => o.id === a.id)) ordered.push(a);
        }
        return ordered;
    }

    // ── Folders ──

    getFolders(accountId: string): Folder[] | Promise<Folder[]> {
        return this.read("db:getFolders", { accountId }, () => this.db.getFolders(accountId));
    }

    // ── Messages ──

    getUnifiedInbox(page = 1, pageSize = 50, flaggedOnly = false, dateBasis: "sent" | "received" = "sent"): any {
        return this.read("db:getUnifiedInbox", { page, pageSize, flaggedOnly, dateBasis },
            () => this.localStore.getUnifiedInbox(page, pageSize, flaggedOnly, dateBasis));
    }

    getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string, flaggedOnly = false, dateBasis: "sent" | "received" = "sent"): any {
        const query = { accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search, flaggedOnly, dateBasis };
        return this.read("db:getMessages", query, () => this.localStore.getMessages(query));
    }

    /** UI body read — local-first. Returns immediately from the local cache:
     *  `cached: true` with the full parsed body when on disk, or `cached: false`
     *  with envelope-only when not. In the cache-miss case we kick off a
     *  fire-and-forget IMAP fetch in the background and emit `bodyAvailable`
     *  when the body lands; the UI listens for that event and re-requests.
     *
     *  No `await imap*` in the click → render path. The 60s body-fetch race
     *  and structured `bodyError` shape that lived here previously are gone —
     *  step 1 of the local-first refactor (docs/local-first-plan.md). */
    async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise<any> {
        // Local read (envelope + cached body parse) runs on the read-worker
        // thread when available — the .eml read + simpleParser can't stall the
        // main event loop. The fetch-enqueue + reputation work below stays on
        // main (it touches the SyncQueue and the network, which the read-only
        // worker has no business doing).
        const local = await this.read<any>(
            "db:getMessage", { accountId, uid, allowRemote, folderId },
            () => this.localStore.getMessage(accountId, uid, allowRemote, folderId),
        );
        if (!local) throw new Error("Message not found");

        if (!local.cached) {
            // cached:false has two meanings — `bodyOnDisk` disambiguates.
            //   bodyOnDisk false → .eml not on disk; queue a server fetch.
            //   bodyOnDisk true  → .eml on disk, parse in flight; do NOT
            //     fetch — just return envelope-only and let the UI wait for
            //     the `bodyAvailable` bus event the Store publishes when the
            //     parse completes. Queuing a fetch in the parse-pending case
            //     was the 10-Hz "Loading body…" loop (Bob 2026-05-15): the
            //     redundant fetch re-published bodyAvailable, the viewer
            //     re-called getMessage, parse still pending → cached:false →
            //     fetch again → forever.
            if (!(local as any).bodyOnDisk) {
                this.syncQueue.enqueueBodyFetch(accountId, local.folderId as any, local.uid as any, "interactive");
                this.reconciler.kick();
            }
            return local;
        }

        // Optional sender-reputation check. This is the one residual server
        // touch in the read path (DNSBL lookup, ~500 ms). Opt-in via Settings;
        // moves to the reconciler in step 4. See feedback_no_bandaids.md.
        let reputation: ReputationResult | null = null;
        // CACHED settings — `getMessage` is the message-preview hot path
        // and runs on every click. Raw `loadSettings()` does a sync
        // accounts.jsonc read which (on GDrive-mounted shared dirs)
        // can stall the daemon's event loop for 5-30 s. See the
        // 28-second-wedge bug at boot-log 11:00:21 / project notes.
        const settings = (this.getCachedSettings() as any) || {};
        const senderDomain = (local.from?.address || "").toLowerCase().split("@")[1] || "";
        if (settings.checkDomainReputation && senderDomain && local.hasRemoteContent) {
            reputation = await this.checkDomainReputation(senderDomain);
        }

        return { ...local, reputation };
    }

    /** Diagnostic accessor — exposed for the sync-status pill / debug UI.
     *  Returns the current queue counts so the UI can render
     *  "Sync OK / Syncing N items" without polling per-account state. */
    getSyncStatus(): { messageActions: number; bodyFetches: number } {
        return this.syncQueue.pendingCount();
    }

    /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
     *  HTTPS URL the message's List-Unsubscribe header advertised. Done server-
     *  side because the unsubscribe endpoint usually doesn't set CORS headers,
     *  so a browser-side fetch would be blocked. */
    async unsubscribeOneClick(url: string): Promise<{ ok: boolean; status: number; statusText: string }> {
        if (!/^https:\/\//i.test(url)) throw new Error("one-click unsubscribe requires an https URL");
        // RFC 8058: POST with body `List-Unsubscribe=One-Click`. Strict —
        // no GET fallback. The header `List-Unsubscribe-Post: One-Click`
        // is the sender's promise that POST works; if their endpoint then
        // 405s a POST, that's a sender bug, surface it. Pre-fix did a
        // silent GET fallback on 4xx, which (a) doesn't actually
        // unsubscribe per RFC 8058 (servers may treat GET as "view
        // unsubscribe page", not "unsubscribe"), and (b) hides the real
        // problem from the user. Bob 2026-05-09: "we're back to using
        // get rather than post."
        const headers: Record<string, string> = {
            "Content-Type": "application/x-www-form-urlencoded",
            "User-Agent": "mailx/1.0 (https://github.com/BobFrankston/mailx)",
        };
        // `redirect: "manual"` so we don't auto-issue a GET to whatever
        // confirmation page the sender redirects to. Per RFC 8058 §4 the
        // POST status is what indicates success; many senders redirect to
        // a "you're unsubscribed" page that we never need to render. The
        // pre-fix `redirect: "follow"` caused the user to see
        // `Cannot GET /u/…` — that was the redirected GET hitting an
        // unhandled route on the sender's confirmation host, NOT rmfmail
        // using GET. Treat 2xx and 3xx as success.
        const resp = await fetch(url, {
            method: "POST",
            headers,
            body: "List-Unsubscribe=One-Click",
            redirect: "manual",
        });
        // status===0 with type==="opaqueredirect" is what `manual` returns
        // for 30x; reflect that as success since the POST was accepted.
        const isRedirect = resp.type === "opaqueredirect" || (resp.status >= 300 && resp.status < 400);
        const ok = resp.ok || isRedirect;
        const body = ok ? "" : await resp.text().catch(() => "");
        const displayStatus = isRedirect ? 200 : resp.status;
        const displayText = isRedirect ? "OK (redirect)" : resp.statusText;
        console.log(`  [unsub] POST ${url} → ${resp.status} ${resp.statusText}${body ? `; body: ${body.slice(0, 200)}` : ""}`);
        const statusText = ok
            ? displayText
            : ((body.trim().split("\n")[0] || resp.statusText).slice(0, 200));
        return { ok, status: displayStatus, statusText };
    }

    // ── External edit in Microsoft Word ──

    /** Per-session map: editId → temp file path + watcher cleanup.
     *  Lives in memory only — cleared when the user closes compose or sends. */
    private wordEdits = new Map<string, { path: string; stop: () => void }>();

    /** Hand the current compose body off to Microsoft Word for editing. Writes
     *  the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
     *  default OS handler (Word on Windows when .html is associated; otherwise
     *  the user's chosen editor for HTML), and starts an fs.watch that emits
     *  `wordEditUpdated` when Word saves. The compose UI listens for that
     *  event and reloads the editor.
     *
     *  Windows-only by current default — on Mac/Linux there's no equivalent
     *  reliable round-trip. The compose toolbar should hide the button on
     *  non-win32 platforms. */
    async openInWord(editId: string, html: string): Promise<{ ok: boolean; path: string; opener: string }> {
        const dir = path.join(getConfigDir(), "external-edit");
        fs.mkdirSync(dir, { recursive: true });
        // Pre-convert to .docx (Word's native format) so Save in Word writes
        // back to the same file naturally — no Save-As-Web-Page gymnastics.
        // The .html-as-input flow had a 50% rate of "user saved but rmfmail
        // didn't reload" because Word silently switched format to .docx.
        //
        // UNIQUE filename per open: re-clicking "Edit in Word" while the
        // previous Word window still holds <editId>.docx made writeFileSync
        // throw EBUSY → fell back to the broken .html path (Bob 2026-06-06).
        // A fresh timestamped path can't be locked, so the primary .docx
        // round-trip always works. Date.now() is fine here (daemon, not a
        // workflow script).
        const fileBase = `${editId}-${Date.now()}`;
        const filePath = path.join(dir, `${fileBase}.docx`);
        const wrapped = `<!doctype html>
<html><head><meta charset="utf-8"><title>mailx draft</title></head>
<body>${html || "<p></p>"}</body></html>
`;
        try {
            const htmlToDocx = (await import("html-to-docx")).default;
            const buf = await htmlToDocx(wrapped) as Buffer | ArrayBuffer;
            const out = Buffer.isBuffer(buf) ? buf : Buffer.from(buf as ArrayBuffer);
            fs.writeFileSync(filePath, out);
        } catch (e: any) {
            // html-to-docx couldn't produce a docx (rare; usually CJK font
            // fallback issues). Fall back to plain .html so the user still
            // gets *something* — they'll need Save-As-Web-Page in Word but
            // that's better than no edit path at all.
            console.warn(`  [word-edit] html-to-docx failed: ${e?.message || e} — falling back to .html`);
            const htmlPath = path.join(dir, `${fileBase}.html`);
            fs.writeFileSync(htmlPath, wrapped, "utf-8");
            // Use the .html path instead of .docx for both launch and watch.
            return this.openExternalHtmlFallback(editId, htmlPath);
        }

        // Stop any existing watcher for this edit (re-open re-arms cleanly).
        this.wordEdits.get(editId)?.stop();

        // Try Word explicitly first; on failure (Word not installed, exec not
        // in PATH) fall back to the OS default handler so the user still gets
        // *some* editor. Report which one ran so the UI can say "Opening in
        // Word…" vs "Opening in default editor…".
        //
        // CRITICAL: must use async spawn (not spawnSync). spawnSync blocks
        // the Node event loop until the spawned process exits — and on
        // Windows, `cmd /c start ... <gui-app>` sometimes does not return
        // immediately when the GUI app hangs around. That froze the entire
        // mailx IPC bridge on Edit-in-Word click; subsequent clicks
        // (Discard, X, anything) hung waiting for a response that never
        // came back. Async spawn launches and returns immediately;
        // success/failure of the GUI launch is invisible from here, but
        // the file is written and the watcher is armed regardless.
        const { spawn } = await import("node:child_process");
        const tryLaunch = (cmd: string, args: string[]): boolean => {
            try {
                const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
                child.on("error", () => { /* ENOENT etc. — caller already moved on */ });
                child.unref();
                return true;
            } catch { return false; }
        };
        // Editor preference: settings.externalEditor in `~/.mailx/config.jsonc`
        // can be "word" | "libreoffice" | "auto" (default). Auto means try
        // Word first, then LibreOffice, then OS default — gives Word users the
        // expected experience while still working when Word isn't installed.
        // LibreOffice tends to round-trip email-shaped HTML cleaner than
        // Word, so users on either platform may want to flip it via the
        // config editor.
        const settings = (this.getCachedSettings() as any) || {};
        const pref = (settings.externalEditor || "auto") as "word" | "libreoffice" | "auto";
        const tryWord = (): boolean => {
            if (process.platform === "win32") return tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath]);
            if (process.platform === "darwin") return tryLaunch("open", ["-a", "Microsoft Word", filePath]);
            return false; // no MS Word on Linux
        };
        const tryLibreOffice = (): boolean => {
            if (process.platform === "win32") return tryLaunch("cmd", ["/c", "start", "", "/B", "soffice.exe", "--writer", filePath]);
            if (process.platform === "darwin") return tryLaunch("open", ["-a", "LibreOffice", filePath]);
            return tryLaunch("soffice", ["--writer", filePath]) || tryLaunch("libreoffice", ["--writer", filePath]);
        };
        const tryDefault = (): boolean => {
            if (process.platform === "win32") return tryLaunch("cmd", ["/c", "start", "", filePath]);
            if (process.platform === "darwin") return tryLaunch("open", [filePath]);
            return tryLaunch("xdg-open", [filePath]);
        };
        let opener = "none";
        const order: Array<[string, () => boolean]> =
            pref === "libreoffice"
                ? [["libreoffice", tryLibreOffice], ["word", tryWord], ["default", tryDefault]]
            : pref === "word"
                ? [["word", tryWord], ["default", tryDefault]]
            : [["word", tryWord], ["libreoffice", tryLibreOffice], ["default", tryDefault]]; // auto
        for (const [name, fn] of order) {
            if (fn()) { opener = name; break; }
        }
        if (opener === "none") {
            console.error(`  [word-edit] no editor found on this platform — file written to ${filePath}`);
        } else {
            console.log(`  [word-edit] opened ${filePath} via ${opener}`);
        }

        // Watch for save events. fs.watch on Windows fires multiple events
        // per save (rename + change for atomic replacement); debounce so the
        // UI only reloads once per save. Watch the directory rather than the
        // file directly because Word writes via temp-file rename, which can
        // invalidate a file-level watch.
        //
        // On save: read the .docx, run mammoth to convert to HTML, emit the
        // wordEditUpdated event. Mammoth is dynamically imported on first
        // save so the cold-start cost is paid only when the user actually
        // edits in Word — most sessions never hit it.
        let debounce: ReturnType<typeof setTimeout> | null = null;
        let lastSize = -1;
        const watcher = fs.watch(dir, (eventType, name) => {
            if (name !== `${fileBase}.docx`) return;
            if (debounce) clearTimeout(debounce);
            debounce = setTimeout(async () => {
                let stat: fs.Stats;
                try { stat = fs.statSync(filePath); } catch { return; }
                if (stat.size === lastSize) return;
                lastSize = stat.size;
                try {
                    const buf = fs.readFileSync(filePath);
                    const mammoth = (await import("mammoth")).default;
                    const result = await mammoth.convertToHtml({ buffer: buf });
                    let inner = result.value || "";
                    // mammoth returns body-content HTML (no <html><body> wrapper).
                    // Defensive: if a wrapper sneaks in, strip it.
                    const bodyMatch = inner.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
                    if (bodyMatch) inner = bodyMatch[1];
                    this.imapManager.emit("wordEditUpdated", { editId, html: inner });
                    // Bring the rmfmail compose window to front so the user
                    // doesn't need to Alt+Tab — saves a step in the round-trip.
                    try {
                        const host = await import("@bobfrankston/mailx-host");
                        if ((host as any).focusMainWindow) (host as any).focusMainWindow();
                    } catch { /* host doesn't expose focus yet — non-fatal */ }
                } catch (e: any) {
                    console.error(`  [word-edit] read after save failed: ${e.message}`);
                }
            }, 300);
        });
        const stop = () => {
            try { watcher.close(); } catch { /* */ }
            if (debounce) clearTimeout(debounce);
        };
        this.wordEdits.set(editId, { path: filePath, stop });

        return { ok: opener !== "none", path: filePath, opener };
    }

    /** Fallback path used when html-to-docx fails. Reproduces the old
     *  .html-based flow: writes <editId>.html, watches it, emits the body
     *  content on save. User has to Save-As-Web-Page in Word for the file
     *  to update — but at least the edit path works. */
    private async openExternalHtmlFallback(editId: string, filePath: string): Promise<{ ok: boolean; path: string; opener: string }> {
        const dir = path.dirname(filePath);
        this.wordEdits.get(editId)?.stop();
        const { spawn } = await import("node:child_process");
        const tryLaunch = (cmd: string, args: string[]): boolean => {
            try {
                const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
                child.on("error", () => { /* */ });
                child.unref();
                return true;
            } catch { return false; }
        };
        let opener = "none";
        if (process.platform === "win32") {
            if (tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath])) opener = "word";
            else if (tryLaunch("cmd", ["/c", "start", "", filePath])) opener = "default";
        } else if (process.platform === "darwin") {
            if (tryLaunch("open", ["-a", "Microsoft Word", filePath])) opener = "word";
            else if (tryLaunch("open", [filePath])) opener = "default";
        } else {
            if (tryLaunch("xdg-open", [filePath])) opener = "default";
        }
        let debounce: ReturnType<typeof setTimeout> | null = null;
        let lastSize = -1;
        const htmlName = path.basename(filePath);                  // <editId>.html
        const docxName = htmlName.replace(/\.html$/i, ".docx");    // what Word writes on Ctrl+S
        const docxPath = path.join(dir, docxName);
        // CRITICAL: Word, given an .html, converts it to a SIBLING .docx on save
        // (Bob 2026-06-06: "Word turns it into a docx you don't see when I do
        // ^s"). The old watcher only watched the .html, so those edits never came
        // back. Watch BOTH; prefer the .docx (run it through mammoth) since
        // that's the authoritative save, falling back to the .html.
        const watcher = fs.watch(dir, (_evt, name) => {
            if (name !== htmlName && name !== docxName) return;
            if (debounce) clearTimeout(debounce);
            debounce = setTimeout(async () => {
                try {
                    let inner: string;
                    if (fs.existsSync(docxPath)) {
                        const st = fs.statSync(docxPath);
                        if (st.size === lastSize) return;
                        lastSize = st.size;
                        const mammoth = (await import("mammoth")).default;
                        const result = await mammoth.convertToHtml({ buffer: fs.readFileSync(docxPath) });
                        inner = result.value || "";
                        const bm = inner.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
                        if (bm) inner = bm[1];
                    } else {
                        const st = fs.statSync(filePath);
                        if (st.size === lastSize) return;
                        lastSize = st.size;
                        const updatedHtml = fs.readFileSync(filePath, "utf-8");
                        const bm = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
                        inner = bm ? bm[1] : updatedHtml;
                    }
                    this.imapManager.emit("wordEditUpdated", { editId, html: inner });
                } catch (e: any) {
                    console.error(`  [word-edit fallback] read failed: ${e.message}`);
                }
            }, 300);
        });
        const stop = () => { try { watcher.close(); } catch { /* */ } if (debounce) clearTimeout(debounce); };
        this.wordEdits.set(editId, { path: filePath, stop });
        return { ok: opener !== "none", path: filePath, opener };
    }

    /** Show an OS-level always-on-top reminder popup. Spawns a separate
     *  msger window (via the injected popupFn) with the supplied HTML
     *  and button list; the chosen button label is returned to the
     *  client which then snoozes/dismisses/opens the underlying event.
     *  No-op (returns reason) when no popupFn was injected — keeps the
     *  service usable in test harnesses without a UI host. */
    async showReminderPopup(opts: {
        title: string;
        html: string;
        buttons: string[];
        size?: { width: number; height: number };
        pos?: { x: number; y: number };
    }): Promise<{ button: string; form?: any; reason?: string }> {
        if (!this.popupFn) return { button: "", reason: "no popup host" };
        try {
            const r = await this.popupFn({
                title: opts.title,
                html: opts.html,
                // rawHtml RESTORED — caller now ships a full HTML document
                // that includes its own button row + an inline script that
                // sends results back via `window.ipc.postMessage` (the wry
                // bridge msger's own template uses for sendResult). msger
                // returns the button label from the postMessage JSON, so
                // the discriminated result flow is preserved.
                rawHtml: true,
                buttons: opts.buttons,
                size: opts.size || { width: 480, height: 240 },
                pos: opts.pos,                  // omit → msger centers
                alwaysOnTop: true,
                escapeCloses: false,            // Outlook reminders ignore Esc
                // detach: false so the popup is parented to the daemon.
                // `rmfmail -kill` reaps the daemon's child processes —
                // previously detach:true made popups outlive the daemon,
                // and the user had to close each one manually after kill.
                // Follow-up: switch to showMessageBoxEx + an openPopups
                // registry so we can keep detach:true and explicitly close
                // on graceful shutdown.
                detach: false,
            });
            return {
                button: r.button || (r.dismissed ? "dismissed" : r.closed ? "closed" : ""),
                // Pass msger's `form` field through. Used by the calendar
                // reminder popup to send back arbitrary snooze durations
                // (e.g. { minutes: 75 }) — see alarms.ts.
                form: (r as any).form,
            };
        } catch (e: any) {
            console.error(`  [reminder] popup failed: ${e.message}`);
            return { button: "", reason: e.message };
        }
    }

    /** Read + delete the pending mailto: file (P115). Client calls this
     *  on startup so a `mailx --mailto <url>` invocation that spawned us
     *  doesn't lose its compose payload to the daemon-fires-before-app-
     *  registers race window. Returns null when no pending file is present.
     *  The file lives at `~/.rmfmail/pending-mailto.json` and is one-shot —
     *  reading it deletes it. */
    consumePendingMailto(): {
        to: string[]; cc: string[]; bcc: string[];
        subject: string; body: string; inReplyTo: string;
    } | null {
        try {
            const home = process.env.USERPROFILE || process.env.HOME || ".";
            const target = path.join(home, ".rmfmail", "pending-mailto.json");
            if (!fs.existsSync(target)) return null;
            const raw = fs.readFileSync(target, "utf-8");
            try { fs.unlinkSync(target); } catch { /* */ }
            const parsed = JSON.parse(raw) as any;
            // Same 5-min staleness rule used in bin/mailto.ts.
            if (parsed.writtenAt && Date.now() - parsed.writtenAt > 5 * 60_000) return null;
            return {
                to: Array.isArray(parsed.to) ? parsed.to : [],
                cc: Array.isArray(parsed.cc) ? parsed.cc : [],
                bcc: Array.isArray(parsed.bcc) ? parsed.bcc : [],
                subject: parsed.subject || "",
                body: parsed.body || "",
                inReplyTo: parsed.inReplyTo || "",
            };
        } catch (e: any) {
            console.error(`  [mailto] consume failed: ${e.message}`);
            return null;
        }
    }

    /** End external editing. Stops the watcher, removes the temp file.
     *  Caller is the compose UI when the user closes the window or sends. */
    async closeWordEdit(editId: string): Promise<void> {
        const entry = this.wordEdits.get(editId);
        if (!entry) return;
        entry.stop();
        this.wordEdits.delete(editId);
        try { fs.unlinkSync(entry.path); } catch { /* file already gone — fine */ }
    }

    /** Toggle flags. Local DB write + Store event + server-mirror enqueue.
     *  No IMAP code path runs in the click→ack window — the queued drain
     *  fires 1s later, coalescing rapid toggles. */
    async updateFlags(accountId: string, uid: number, flags: string[], folderId?: number): Promise<void> {
        // folderId MUST come from the caller. UID is per-folder, not identity,
        // so re-deriving it via getMessageByUid(accountId, uid) returns an
        // ARBITRARY folder's row when the same UID exists in several folders —
        // the flag change then queued against the wrong folder, the intended
        // message stayed flagged on the server, and the next reconcile restored
        // the star ("vanish, reappears", Bob 2026-06-26). The client always
        // knows the row's folderId; only fall back to the ambiguous lookup for
        // legacy callers that still omit it.
        let fid = folderId;
        if (fid == null) {
            const envelope = this.db.getMessageByUid(accountId, uid);
            fid = envelope?.folderId || 0;
        }
        console.log(`  [flag] updateFlags acct=${accountId} uid=${uid} folderId=${fid}${folderId == null ? " (DERIVED — client sent none!)" : ""} flags=${JSON.stringify(flags)}`);
        this.localStore.updateFlags(accountId, uid, fid, flags);
        this.syncQueue.enqueueFlag(accountId, uid, fid, flags);
    }

    // ── Remote content allow-list ──

    async allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void> {
        const list = loadAllowlist();
        if (type === "sender" && !list.senders.includes(value)) list.senders.push(value);
        else if (type === "domain" && !list.domains.includes(value)) list.domains.push(value);
        else if (type === "recipient") {
            if (!list.recipients) list.recipients = [];
            if (!list.recipients.includes(value)) list.recipients.push(value);
        }
        await saveAllowlist(list);
        console.log(`  [allow] Added ${type}: ${value}`);
    }

    // ── User dictionary (cloud-mirrored spellcheck additions) ──

    async getUserDict(): Promise<string[]> {
        return await loadUserDict();
    }

    async addUserDictWord(word: string): Promise<string[]> {
        const trimmed = (word || "").trim();
        if (!trimmed) return await loadUserDict();
        const words = await loadUserDict();
        if (!words.includes(trimmed)) words.push(trimmed);
        await saveUserDict(words);
        return await loadUserDict();
    }

    /** Bulk add — used to reconcile a client's localStorage cache up to the
     *  cloud file (e.g. words added before the cloud round-trip existed). */
    async addUserDictWords(words: string[]): Promise<string[]> {
        const incoming = (Array.isArray(words) ? words : [])
            .map(w => (w || "").trim()).filter(Boolean);
        const existing = await loadUserDict();
        await saveUserDict([...existing, ...incoming]);
        return await loadUserDict();
    }

    async removeUserDictWord(word: string): Promise<string[]> {
        const trimmed = (word || "").trim();
        const words = (await loadUserDict()).filter(w => w !== trimmed);
        await saveUserDict(words);
        return await loadUserDict();
    }

    /** Domain-reputation cache. Lookups are fast (~50ms each, three in
     *  parallel) but we still don't want to redo them on every render of
     *  the same sender's mail. Five-minute TTL — long enough that scrolling
     *  a folder fans out one query set, short enough that a newly-listed
     *  domain surfaces within minutes. */
    private reputationCache = new Map<string, { result: ReputationResult; expiresAt: number }>();
    private static readonly REPUTATION_TTL_MS = 5 * 60_000;
    private static readonly REPUTATION_TIMEOUT_MS = 500;

    /** Check a domain against three free no-key DNS blocklists in parallel:
     *
     *    Spamhaus DBL    — `<d>.dbl.spamhaus.org`     spam/phish/malware
     *    SURBL multi     — `<d>.multi.surbl.org`      mixed (ph/mw/abuse)
     *    URIBL multi     — `<d>.multi.uribl.com`      black/grey/red lists
     *
     *  Each lookup is bounded at 500 ms; missing/slow services are treated
     *  as "unknown" (don't poison the cache). Returns the aggregate plus
     *  the per-service detail so the UI can show "N of 3 services flag
     *  this domain" with the contributing source list.
     *
     *  Privacy: each query leaks the bare domain to that DNSBL's
     *  infrastructure plus the user's local resolver. Opt-in via Settings.
     *
     *  No API keys, free for personal use across all three services. */
    async checkDomainReputation(domain: string): Promise<ReputationResult | null> {
        domain = (domain || "").toLowerCase().trim();
        if (!domain) return null;
        const cached = this.reputationCache.get(domain);
        if (cached && cached.expiresAt > Date.now()) return cached.result;

        const probe = async (service: string, host: string, mapVerdict: (lastOctet: string) => string)
            : Promise<{ service: string; flagged: boolean; verdict: string } | null> => {
            try {
                const lookup = dns.resolve4(`${domain}.${host}`);
                const timeout = new Promise<never>((_, reject) =>
                    setTimeout(() => reject(new Error("dnsbl-timeout")), MailxService.REPUTATION_TIMEOUT_MS));
                const records = await Promise.race([lookup, timeout]) as string[];
                const last = records[0]?.split(".").pop() || "";
                return { service, flagged: true, verdict: mapVerdict(last) };
            } catch (e: any) {
                const code = e?.code || "";
                if (code === "ENOTFOUND" || code === "ENODATA") {
                    return { service, flagged: false, verdict: "clean" };
                }
                return null; // timeout / network — unknown
            }
        };

        const dblVerdict = (last: string) =>
            last === "2" ? "spam" :
            last === "4" ? "phishing" :
            last === "5" ? "malware" :
            last === "6" ? "botnet" :
            "listed";
        // SURBL/URIBL encode multiple list memberships in a bitfield; the
        // distinction matters less to the end user than "how many sources
        // agree", so we keep a generic "listed" verdict for both.
        const generic = (_last: string) => "listed";

        const sources = await Promise.all([
            probe("Spamhaus DBL", "dbl.spamhaus.org", dblVerdict),
            probe("SURBL", "multi.surbl.org", generic),
            probe("URIBL", "multi.uribl.com", generic),
        ]);

        const known = sources.filter((s): s is { service: string; flagged: boolean; verdict: string } => s !== null);
        const flagged = known.filter(s => s.flagged);
        const result: ReputationResult = {
            flagged: flagged.length > 0,
            listedCount: flagged.length,
            checkedCount: known.length,
            sources: flagged,
            // Pick the most specific verdict if Spamhaus contributed (since
            // DBL distinguishes phishing/malware/etc); otherwise generic.
            verdict: flagged.find(s => s.service === "Spamhaus DBL")?.verdict || flagged[0]?.verdict || "clean",
            service: flagged.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
        };
        this.reputationCache.set(domain, { result, expiresAt: Date.now() + MailxService.REPUTATION_TTL_MS });
        return result;
    }

    /** Mark a sender or domain as suspect. Surfaced in the remote-content
     *  banner as a red warning on subsequent messages. Toggle: calling with
     *  the same value removes it. Returns the new state for UI feedback. */
    async flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{ flagged: boolean }> {
        const list = loadAllowlist() as any;
        const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
        if (!Array.isArray(list[key])) list[key] = [];
        const lower = (value || "").toLowerCase();
        const idx = list[key].findIndex((v: string) => (v || "").toLowerCase() === lower);
        if (idx >= 0) {
            list[key].splice(idx, 1);
            await saveAllowlist(list);
            console.log(`  [flag] Removed ${type}: ${value}`);
            return { flagged: false };
        }
        list[key].push(value);
        await saveAllowlist(list);
        console.log(`  [flag] Added ${type}: ${value}`);
        return { flagged: true };
    }

    // ── Search ──

    /** Monotonic generation for in-flight server searches. A new server
     *  search or a cancelServerSearch() call bumps it; the server-search
     *  loop checks it between folder batches and bails when superseded. */
    private serverSearchGen = 0;

    /** Abort any in-flight server search. The client calls this when the
     *  search box is edited or cleared so a 90-folder IMAP sweep doesn't
     *  keep churning for a query the user has already moved on from. */
    cancelServerSearch(): void {
        this.serverSearchGen++;
    }

    async search(q: string, page = 1, pageSize = 50, scope = "all", accountId?: string, folderId?: number, includeTrashSpam = false): Promise<any> {
        q = (q || "").trim();
        if (!q) return { items: [], total: 0, page, pageSize };

        if (scope === "server") {
            // Abort token. Each server search claims a generation; the loop
            // checks it between folder batches and bails the moment a newer
            // server search (or an explicit cancelServerSearch) bumps the
            // counter. Without this, typing fast left N stale 90-folder IMAP
            // sweeps all churning to completion in the background.
            const myGen = ++this.serverSearchGen;
            // Parse qualifiers once; SEARCH runs per folder.
            const criteria: any = {};
            const fromMatch = q.match(/from:(\S+)/i);
            const toMatch = q.match(/to:(\S+)/i);
            const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
            const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
            if (fromMatch) criteria.from = fromMatch[1];
            if (toMatch) criteria.to = toMatch[1];
            if (subjectMatch) criteria.subject = subjectMatch[1].trim();
            if (bodyText) criteria.body = bodyText;

            // Server search spans every selectable folder on every enabled
            // account — otherwise a message that got moved / was in Sent /
            // only exists in an archive folder silently fails to turn up.
            // Each folder runs as its own SEARCH; we dedupe by messageId.
            const dbAccounts = accountId
                ? [{ id: accountId }]
                : this.db.getAccounts();
            const seen = new Set<string>();
            const items: any[] = [];
            let total = 0;
            // Partial-failure accounting. The old code did `if (r.status !==
            // "fulfilled") continue` — a folder whose SEARCH rejected
            // (connection discard, a genuinely slow huge folder hitting the
            // 90s cap) contributed zero results AND zero signal. The user saw
            // "No results" for a search that simply never ran in the folder
            // holding the match. Now we count failures and hand them back so
            // the UI can say "searched 88/93 — 5 folders failed, retry".
            let foldersSearched = 0;
            let foldersFailed = 0;
            const failedFolders: string[] = [];
            let droppedHits = 0;

            // Bounded concurrency. The per-account ops queue already
            // serializes connection use, so this doesn't change IMAP
            // parallelism — but firing all ~93 folder promises at once
            // builds a 90+-deep pending-promise queue with 90+ live
            // setTimeout timers. Batching keeps that bounded and gives a
            // natural place to add early-abort later.
            const SERVER_SEARCH_BATCH = 8;

            let aborted = false;
            outer:
            for (const acct of dbAccounts) {
                const folders = this.db.getFolders(acct.id)
                    .filter((f: any) => !(f.flags || []).some((x: string) => /noselect/i.test(x)));
                for (let i = 0; i < folders.length; i += SERVER_SEARCH_BATCH) {
                    // Cancellation check between batches — a newer server
                    // search or cancelServerSearch() bumped the generation.
                    if (myGen !== this.serverSearchGen) {
                        aborted = true;
                        console.log(`  [server-search] q="${q}" aborted (superseded) after ${foldersSearched} folders`);
                        break outer;
                    }
                    const batch = folders.slice(i, i + SERVER_SEARCH_BATCH);
                    const results = await Promise.allSettled(
                        batch.map(f =>
                            this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
                                .then(uids => ({ folder: f, uids }))
                        )
                    );
                    for (let j = 0; j < results.length; j++) {
                        const r = results[j];
                        if (r.status !== "fulfilled") {
                            foldersFailed++;
                            failedFolders.push(`${acct.id}/${batch[j].path}`);
                            console.error(`  [server-search] ${acct.id}/${batch[j].path}: ${r.reason?.message || r.reason}`);
                            continue;
                        }
                        foldersSearched++;
                        for (const uid of r.value.uids) {
                            const msg = this.db.getMessageByUid(acct.id, uid, r.value.folder.id);
                            if (!msg) {
                                // SEARCH matched on the server but the
                                // fetch-and-store in searchAndFetchOnServer
                                // didn't land the row. Count it so the total
                                // doesn't silently under-report.
                                droppedHits++;
                                console.error(`  [server-search] ${acct.id}/${r.value.folder.path} uid ${uid}: SEARCH hit not in local DB after fetch — dropped`);
                                continue;
                            }
                            const key = msg.messageId || `${acct.id}:${r.value.folder.id}:${uid}`;
                            if (seen.has(key)) continue;
                            seen.add(key);
                            items.push(msg);
                            total++;
                        }
                    }
                }
            }

            // Newest first, then paginate.
            items.sort((a: any, b: any) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
            const sliced = items.slice((page - 1) * pageSize, page * pageSize);
            if (foldersFailed > 0 || droppedHits > 0) {
                console.log(`  [server-search] q="${q}" — ${total} hits across ${foldersSearched} folders; ${foldersFailed} folders failed, ${droppedHits} hits dropped`);
            }
            return {
                items: sliced, total, page, pageSize,
                aborted,
                partial: foldersFailed > 0 || droppedHits > 0,
                foldersSearched, foldersFailed, failedFolders, droppedHits,
            };
        } else if (scope === "current" && accountId && folderId) {
            // Per-folder search — folder scope is the user's explicit
            // intent, including trash/spam if that's where they are.
            return this.read("db:searchMessages",
                { query: q, page, pageSize, accountId, folderId, includeTrashSpam: true },
                () => this.localStore.searchMessages(q, page, pageSize, accountId, folderId, true));
        } else {
            return this.read("db:searchMessages",
                { query: q, page, pageSize, includeTrashSpam },
                () => this.localStore.searchMessages(q, page, pageSize, undefined, undefined, includeTrashSpam));
        }
    }

    rebuildSearchIndex(): number {
        const count = this.db.rebuildSearchIndex();
        console.log(`  Rebuilt search index: ${count} messages`);
        return count;
    }

    // ── Sync ──

    getSyncPending(): { pending: number } {
        return { pending: this.db.getTotalPendingSyncCount() };
    }

    /** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
    getOutboxStatus(): any {
        return this.imapManager.getOutboxStatus();
    }

    /** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
     *  last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
    getDiagnostics(): any {
        return this.imapManager.getDiagnosticsSnapshot();
    }

    /** Return the account that supplies `feature` data (calendar / tasks /
     *  contacts). Resolution order:
     *    1. Any account with `primary<Feature>: true`   (per-feature override)
     *    2. Any account with `primary: true`            (catch-all default)
     *    3. First account                               (fallback)
     *  Called without `feature` it returns the catch-all primary — same
     *  semantics as the original single-flag version for back-compat. */
    getPrimaryAccount(feature?: string): any {
        const all = this.getAccounts();
        if (feature) {
            const perFeatureKey = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
            const perFeature = all.find((a: any) => a[perFeatureKey]);
            if (perFeature) return perFeature;
        }
        // Calendar / tasks / contacts go through the Google APIs and need an
        // OAuth-capable account. Without this filter, an IMAP+password
        // account (e.g. bobma) appearing first in the list would be picked
        // and every refresh would log "No OAuth token for bobma" forever.
        // Mail / send have no such constraint — any account works.
        if (feature === "calendar" || feature === "tasks" || feature === "contacts") {
            // OAuth-capable = explicit imap.auth=oauth2 (configured IMAP-with-OAuth
            // accounts), OR a Gmail/Workspace account that uses the Gmail API
            // path and has no `imap` block at all. The previous version checked
            // only imap.auth, which excluded API-path Gmail accounts entirely
            // and left bobma (password) as the only candidate, producing
            // "No OAuth token for bobma" every refresh tick.
            const isOAuthCapable = (a: any): boolean => {
                if (a.imap?.auth === "oauth2") return true;
                const domain = (a.email || "").split("@")[1]?.toLowerCase();
                if (!domain) return false;
                return GOOGLE_DOMAINS.includes(domain) || MS_DOMAINS.includes(domain);
            };
            const oauthOnly = all.filter(isOAuthCapable);
            const primaryOauth = oauthOnly.find((a: any) => a.primary);
            return primaryOauth || oauthOnly[0] || null;
        }
        return all.find((a: any) => a.primary) || all[0] || null;
    }

    // ── Calendar / Tasks / Contacts: two-way cache (2026-04-23) ──

    /** Feature names that have already emitted authScopeError this session.
     *  Stops the "banner flashing on and off continually" loop where every
     *  5-min poll / sidebar nav re-fired the event and the client re-rendered
     *  the red banner. Cleared when the user hits Re-authenticate. */
    private scopeErrorEmitted = new Set<string>();

    /** Quota cooldown — feature → epoch-ms when the next API call is allowed.
     *  Set when Google returns 429 (rate limit / daily-quota exceeded). While
     *  cooldown is in effect, getCalendarEvents/getTasks return local DB rows
     *  without firing a refresh. Heuristic cooldown is one hour; the daily
     *  Google Tasks quota actually resets at Pacific midnight, but a one-hour
     *  short-circuit keeps the log clean and avoids hammering after a burst. */
    private quotaCooldown = new Map<string, number>();

    /** Sticky "quota exceeded" emit guard — same shape as scopeErrorEmitted. */
    private quotaErrorEmitted = new Set<string>();

    /** In-flight refresh promises keyed by feature, so concurrent UI calls
     *  share one Google round-trip instead of stacking N parallel fetches.
     *  The fire-and-forget loop where `tasksUpdated` re-triggers `getTasks`
     *  used to spawn a new refresh on every event RTT — this dedupes them. */
    private refreshingCalendar = new Map<string, Promise<boolean>>();
    private refreshingTasks = new Map<string, Promise<boolean>>();
    /** Wall-clock of the last Google refresh per account, for calendar and
     *  tasks. The alarm poll calls getCalendarEvents/getTasks every 30 s;
     *  without a throttle that hammered Google's APIs and exhausted the
     *  Tasks "Queries per day" project quota (Bob 2026-05-16). These gate
     *  the actual network refresh to GOOGLE_REFRESH_MIN_INTERVAL_MS — the
     *  call still returns local rows instantly, it just doesn't re-pull. */
    private lastCalendarRefresh = new Map<string, number>();
    private lastTasksRefresh = new Map<string, number>();

    /** Delete the cached Google OAuth token (the one used for Calendar / Tasks
     *  / Contacts scopes — NOT the IMAP token which `reauthenticate()` handles)
     *  and clear the sticky auth-error state so a subsequent refresh can
     *  re-trigger browser consent with the current scope set. Equivalent of
     *  `mailx -reauth` but callable from the UI. Returns `{ cleared: N }` so
     *  the caller can tell the user what happened. */
    reauthGoogleScopes(): { cleared: number } {
        const tokensDir = path.join(getConfigDir(), "tokens");
        let cleared = 0;
        if (fs.existsSync(tokensDir)) {
            for (const entry of fs.readdirSync(tokensDir)) {
                const userDir = path.join(tokensDir, entry);
                try {
                    if (!fs.statSync(userDir).isDirectory()) continue;
                    const tokenFile = path.join(userDir, "oauth-token.json");
                    if (fs.existsSync(tokenFile)) {
                        fs.unlinkSync(tokenFile);
                        console.log(`  [reauth-google] cleared ${tokenFile}`);
                        cleared++;
                    }
                } catch { /* skip */ }
            }
        }
        // Reset the sticky set so the next failure (if re-consent didn't take)
        // can fire a fresh banner. Also trigger a kickoff refresh so the
        // browser consent pops open now instead of on next sidebar nav.
        this.scopeErrorEmitted.clear();
        this.quotaCooldown.clear();
        this.quotaErrorEmitted.clear();
        const now = Date.now();
        const horizonMs = 90 * 86400_000;
        this.getCalendarEvents(now, now + horizonMs);  // fire-and-forget — triggers consent via primaryTokenProvider
        this.getTasks(false);                           // same path, `tasks` scope
        return { cleared };
    }

    private async primaryTokenProvider(feature: string): Promise<(() => Promise<string>)> {
        const acct = this.getPrimaryAccount(feature);
        if (!acct) throw new Error(`No primary account for ${feature}`);
        return async () => {
            const tok = await this.imapManager.getOAuthToken(acct.id);
            if (!tok) throw new Error(`No OAuth token for ${acct.id}`);
            return tok;
        };
    }

    /** Return cal events visible in [fromMs..toMs), refreshing from Google
     *  in the background. Caller displays local results immediately; after
     *  the refresh completes the service emits `calendarUpdated` so the UI
     *  re-renders with pulled-in rows. Fire-and-forget-with-event, not
     *  fire-and-forget-and-pray. */
    async getCalendarEvents(fromMs: number, toMs: number): Promise<any[]> {
        const acct = this.getPrimaryAccount("calendar");
        if (!acct) return [];
        const acctId = acct.id;
        // Skip the network entirely while in quota cooldown, or when the
        // last refresh was under GOOGLE_REFRESH_MIN_INTERVAL_MS ago — return
        // DB rows. The alarm poll calls this every 30 s; the throttle keeps
        // that from becoming a Google API call every 30 s.
        const sinceCal = Date.now() - (this.lastCalendarRefresh.get(acctId) || 0);
        if (!this.inQuotaCooldown("calendar") && sinceCal >= GOOGLE_REFRESH_MIN_INTERVAL_MS) {
            let promise = this.refreshingCalendar.get(acctId);
            if (!promise) {
                this.lastCalendarRefresh.set(acctId, Date.now());
                // The refresh window is deliberately decoupled from the
                // caller's query window. Two callers hit getCalendarEvents:
                // the sidebar (30-day forward window) and the alarm poll
                // (~3-hour window, every 30s). The per-account dedup below
                // means whichever caller fires *first* sets the window for
                // the shared refresh — and the alarm poll, always running,
                // kept winning, so the sidebar piggybacked on a 3-hour pull
                // and showed nothing. Pulling a fixed generous window makes
                // the dedup correct: every refresh is a superset of every
                // query, so piggybacking is always safe.
                const rFrom = Date.now() - CAL_REFRESH_BACK_DAYS * 86400_000;
                const rTo = Date.now() + CAL_REFRESH_FWD_DAYS * 86400_000;
                promise = this.refreshCalendarEvents(acctId, rFrom, rTo)
                    .finally(() => this.refreshingCalendar.delete(acctId));
                this.refreshingCalendar.set(acctId, promise);
                promise
                    .then(changed => {
                        if (changed) this.imapManager.emit("calendarUpdated", { accountId: acctId });
                    })
                    .catch(e => this.handleGoogleRefreshError("calendar", e));
            }
        }
        const rows = this.localStore.getCalendarEvents(acctId, fromMs, toMs);
        // Diagnostic: separate counts for plain / recurring / holiday rows
        // so log inspection reveals whether the issue is fetch-side
        // (refresh didn't write them) vs read/render-side (they're in DB
        // but not appearing in the UI).
        const recurring = rows.filter(r => r.recurringEventId).length;
        const holidays = rows.filter(r => r.isHoliday).length;
        console.log(`  [calendar] getCalendarEvents → ${rows.length} rows (${recurring} recurring, ${holidays} holiday) for window ${new Date(fromMs).toISOString().slice(0, 10)}..${new Date(toMs).toISOString().slice(0, 10)}`);
        return rows;
    }

    /** List the user's *selected* Google calendars (id, display name, color,
     *  primary flag) so the sidebar can render one checkbox + icon per
     *  calendar. The user curates the set by selecting calendars in Google;
     *  mailx only reflects it. Returns [] on quota cooldown / auth failure
     *  — the sidebar then just shows no per-calendar controls. */
    async getCalendars(): Promise<Array<{ id: string; name: string; color: string; primary: boolean }>> {
        const acct = this.getPrimaryAccount("calendar");
        if (!acct) return [];
        if (this.inQuotaCooldown("calendar")) return [];
        try {
            const tp = await this.primaryTokenProvider("calendar");
            const all = await gsync.listCalendars(tp);
            return all
                .filter(c => c.selected)
                .map(c => ({ id: c.id, name: c.summary, color: c.backgroundColor || "", primary: c.primary }));
        } catch (e: any) {
            this.handleGoogleRefreshError("calendar", e);
            return [];
        }
    }

    /** Returns true if the feature is currently in a quota-exceeded cooldown. */
    private inQuotaCooldown(feature: string): boolean {
        const until = this.quotaCooldown.get(feature);
        if (!until) return false;
        if (Date.now() < until) return true;
        this.quotaCooldown.delete(feature);
        this.quotaErrorEmitted.delete(feature);
        return false;
    }

    /** Single error-handling path for Google refresh failures.
     *  Distinguishes 429 (quota) from 401/403 (scope) so each gets the right
     *  cooldown + sticky-emit treatment without duplicating the regex blocks. */
    private handleGoogleRefreshError(feature: string, e: any): void {
        const msg = String(e?.message || e);
        const status = e instanceof gsync.GoogleHttpError ? e.status : 0;
        const is429 = status === 429 || /\b429\b|rateLimitExceeded|quotaExceeded|userRateLimitExceeded/i.test(msg);
        const isScope = !is429 && (status === 401 || status === 403
            || /insufficient (authentication )?scope|PERMISSION_DENIED|\b403\b/i.test(msg));
        console.error(`[${feature}] refresh failed: ${msg}`);
        if (is429) {
            const cooldownMs = 60 * 60_000; // one hour heuristic
            this.quotaCooldown.set(feature, Date.now() + cooldownMs);
            if (!this.quotaErrorEmitted.has(feature)) {
                this.quotaErrorEmitted.add(feature);
                this.imapManager.emit("quotaError", {
                    feature,
                    message: `Google ${feature} daily quota exceeded — try again later.`,
                    untilMs: Date.now() + cooldownMs,
                });
            }
            return;
        }
        if (isScope) {
            if (!this.scopeErrorEmitted.has(feature)) {
                this.scopeErrorEmitted.add(feature);
                const labels: Record<string, string> = {
                    calendar: "Google Calendar", tasks: "Google Tasks", contacts: "Google Contacts",
                };
                this.imapManager.emit("authScopeError", {
                    feature,
                    message: `${labels[feature] || feature} access needs re-consent.`,
                });
            }
        }
    }

    /** Pull events in [fromMs..toMs) from Google, upsert locally, reconcile
     *  server-side deletions. Returns true if anything changed so callers
     *  can decide whether to emit a refresh event. `changed` is only true
     *  when at least one row's data actually differs — without this guard
     *  the UI's `calendarUpdated` listener re-triggers `getCalendarEvents`,
     *  which fires another `refreshCalendarEvents`, which emits again, etc.
     *  Tight loop = 429 quota burn. */
    private async refreshCalendarEvents(accountId: string, fromMs: number, toMs: number): Promise<boolean> {
        const tp = await this.primaryTokenProvider("calendar");
        // Enumerate ALL of the user's `selected` calendars (the ones shown
        // in Google's web UI) and pull events from each. Earlier code
        // hard-coded `primary` only — so any event living on a secondary
        // calendar (a personal "Statin" reminder, a shared work cal, a
        // sports schedule) was invisible to mailx. Bob 2026-05-12:
        // "you're still not showing me and alerting me about recurring
        // events." Thunderbird and Google's own UI walk the entire
        // calendarList; mailx now does too. Failing to list calendars
        // falls back to primary so single-calendar users still work.
        let calendarsToFetch: { id: string; label: string; defaultReminderMinutes: number[] }[] =
            [{ id: "primary", label: "primary", defaultReminderMinutes: [] }];
        // A failed enumeration / fetch returns fewer events than really
        // exist; running the delete-reconciliation then purges live rows
        // (the same data-loss shape as the Gmail partial-list bug). Track
        // failures and skip the purge when any occurred.
        let fetchFailed = false;
        try {
            const all = await gsync.listCalendars(tp);
            const selected = all.filter(c => c.selected);
            if (selected.length > 0) {
                calendarsToFetch = selected.map(c => ({
                    id: c.id, label: c.summary || c.id,
                    defaultReminderMinutes: c.defaultReminderMinutes,
                }));
                console.log(`  [calendar] enumerated ${all.length} calendars, ${selected.length} selected`);
            } else {
                console.log(`  [calendar] no selected calendars — falling back to primary`);
            }
        } catch (e: any) {
            // listCalendars failed — we'd fetch only `primary` and miss every
            // secondary calendar's events. Treat as a failed pass.
            fetchFailed = true;
            console.warn(`  [calendar] listCalendars failed (${e?.message || e}) — falling back to primary`);
        }
        const fetchResults: Array<{ events: any[]; calendarId: string; defaultReminderMinutes: number[] }> =
            await Promise.all(calendarsToFetch.map(async c => {
                try {
                    const ev = await gsync.listCalendarEvents(tp, fromMs, toMs, c.id);
                    console.log(`  [calendar] pulled ${ev.length} events from "${c.label}" (${new Date(fromMs).toISOString().slice(0, 10)} to ${new Date(toMs).toISOString().slice(0, 10)})`);
                    return { events: ev, calendarId: c.id, defaultReminderMinutes: c.defaultReminderMinutes };
                } catch (e: any) {
                    fetchFailed = true;
                    console.warn(`  [calendar] fetch from "${c.label}" failed: ${e?.message || e}`);
                    return { events: [] as any[], calendarId: c.id, defaultReminderMinutes: c.defaultReminderMinutes };
                }
            }));
        // Flatten with each event remembering its source calendar id + that
        // calendar's default reminder list. The `calendarId` rides onto every
        // row so the sidebar can show a per-calendar icon and the user can
        // hide a calendar without touching Google.
        const eventsWithDefaults: Array<{ ev: any; calendarId: string; defaultReminderMinutes: number[] }> = [];
        for (const fr of fetchResults) {
            for (const ev of fr.events) {
                eventsWithDefaults.push({ ev, calendarId: fr.calendarId, defaultReminderMinutes: fr.defaultReminderMinutes });
            }
        }
        // Holiday calendars are no longer hard-coded (the old HOLIDAY_SOURCES
        // pull + `showHolidays`/`showJewishHolidays` toggles are retired). The
        // user curates which calendars exist by selecting them in Google
        // Calendar; mailx enumerates them above and tags every row with its
        // source `calendarId`. The sidebar derives holiday/birthday/personal
        // kind from that id and lets the user hide calendars per-mailx.
        let changed = false;
        // Upsert by provider_id — dedup globally, not just within the window,
        // so an event whose start moves outside the prior query range doesn't
        // get a second row on the next pull. If the same event id arrives
        // from two calendars, last-write-wins on `calendarId` (one row, one
        // source icon — multi-source badges are a future refinement, Q143).
        const seenProviderIds = new Set<string>();
        for (const { ev, calendarId, defaultReminderMinutes } of eventsWithDefaults) {
            const local = { ...gsync.calendarEventToLocal(ev, accountId, defaultReminderMinutes), calendarId };
            seenProviderIds.add(ev.id);
            const existing = this.db.getCalendarEventByProviderId(accountId, ev.id);
            if (existing && calendarRowEquals(existing, local)) continue;
            this.db.upsertCalendarEvent({ uuid: existing?.uuid, ...local });
            changed = true;
        }
        // Server-side delete reconciliation: any local non-dirty row whose
        // start falls in the queried window and whose provider_id wasn't
        // returned was deleted on Google — or its calendar was deselected in
        // Google (no longer enumerated, so its events stop arriving). Both
        // mean the row should go. SKIPPED when any fetch failed this pass —
        // a partial result would otherwise purge live events.
        if (!fetchFailed) {
            const localWindow = this.db.getCalendarEvents(accountId, fromMs, toMs);
            for (const row of localWindow) {
                if (!row.providerId) continue; // local-only, never pushed
                if (row.dirty) continue;       // locally edited, pending push
                if (seenProviderIds.has(row.providerId)) continue;
                this.db.purgeCalendarEvent(row.uuid);
                changed = true;
            }
        } else {
            console.warn(`  [calendar] fetch incomplete — skipping delete reconciliation to protect the local cache`);
        }
        return changed;
    }

    async createCalendarEventLocal(ev: {
        title: string; startMs: number; endMs: number; allDay?: boolean;
        location?: string; notes?: string;
    }): Promise<string> {
        const acct = this.getPrimaryAccount("calendar");
        if (!acct) throw new Error("No primary calendar account");
        const uuid = this.db.upsertCalendarEvent({
            accountId: acct.id, ...ev, dirty: true,
        });
        this.db.enqueueStoreSync("calendar", "create", acct.id, uuid, ev);
        this.drainStoreSync().catch(() => { /* best-effort; retried on poll */ });
        return uuid;
    }

    async updateCalendarEventLocal(uuid: string, patch: {
        title?: string; startMs?: number; endMs?: number; allDay?: boolean;
        location?: string; notes?: string;
    }): Promise<void> {
        // Merge with existing row before writing so partial patches don't
        // null-out unspecified fields in upsert.
        const existing = this.db.getCalendarEventByUuid(uuid);
        if (!existing) throw new Error(`No calendar event ${uuid}`);
        this.db.upsertCalendarEvent({
            uuid, accountId: existing.accountId, providerId: existing.providerId,
            calendarId: existing.calendarId, dirty: true,
            title: patch.title ?? existing.title,
            startMs: patch.startMs ?? existing.startMs,
            endMs: patch.endMs ?? existing.endMs,
            allDay: patch.allDay ?? existing.allDay,
            location: patch.location ?? existing.location,
            notes: patch.notes ?? existing.notes,
        });
        this.db.enqueueStoreSync("calendar", "update", existing.accountId, uuid,
            { providerId: existing.providerId, patch });
        this.drainStoreSync().catch(() => { /* */ });
    }

    async deleteCalendarEventLocal(uuid: string): Promise<void> {
        const ev = this.db.getCalendarEventByUuid(uuid);
        if (!ev) return;
        this.db.deleteCalendarEventLocal(uuid);
        if (ev.providerId) {
            this.db.enqueueStoreSync("calendar", "delete", ev.accountId, uuid, { providerId: ev.providerId });
            this.drainStoreSync().catch(() => { /* */ });
        } else {
            // Never made it to the server; just purge locally.
            this.db.purgeCalendarEvent(uuid);
        }
    }

    async getTasks(includeCompleted = false): Promise<any[]> {
        const acct = this.getPrimaryAccount("tasks");
        if (!acct) return [];
        const acctId = acct.id;
        const sinceTasks = Date.now() - (this.lastTasksRefresh.get(acctId) || 0);
        if (!this.inQuotaCooldown("tasks") && sinceTasks >= GOOGLE_REFRESH_MIN_INTERVAL_MS) {
            const key = `${acctId}:${includeCompleted ? 1 : 0}`;
            let promise = this.refreshingTasks.get(key);
            if (!promise) {
                this.lastTasksRefresh.set(acctId, Date.now());
                promise = this.refreshTasks(acctId, includeCompleted)
                    .finally(() => this.refreshingTasks.delete(key));
                this.refreshingTasks.set(key, promise);
                promise
                    .then(changed => {
                        if (changed) this.imapManager.emit("tasksUpdated", { accountId: acctId });
                    })
                    .catch(e => this.handleGoogleRefreshError("tasks", e));
            }
        }
        return this.localStore.getTasks(acctId, includeCompleted);
    }

    private async refreshTasks(accountId: string, includeCompleted: boolean): Promise<boolean> {
        const tp = await this.primaryTokenProvider("tasks");
        // Enumerate every task list (Google Reminders since late 2023 live
        // here, and users can have many lists — "My Tasks", "Reminders",
        // custom ones). Earlier code only queried `@default`, so reminders
        // filed elsewhere were invisible. Bob 2026-05-12. Falling back to
        // a single `@default` query keeps single-list users working when
        // the listTaskLists call fails.
        let lists: { id: string; title: string }[] = [{ id: "@default", title: "@default" }];
        // Track whether ANY fetch failed. A failed fetch returns an empty
        // list indistinguishable from "all tasks deleted on the server" —
        // running the delete-reconciliation then wipes the whole local task
        // cache. `firstError` is surfaced so the quota cooldown gets armed.
        let fetchFailed = false;
        let firstError: any = null;
        try {
            const all = await gsync.listTaskLists(tp);
            if (all.length > 0) {
                lists = all;
                console.log(`  [tasks] enumerated ${all.length} task list(s)`);
            }
        } catch (e: any) {
            fetchFailed = true;
            firstError = e;
            console.warn(`  [tasks] listTaskLists failed (${e?.message || e}) — falling back to @default`);
        }
        const fetchResults = await Promise.all(lists.map(async l => {
            try {
                const t = await gsync.listTasks(tp, l.id, includeCompleted);
                console.log(`  [tasks] pulled ${t.length} tasks from "${l.title}"`);
                return t;
            } catch (e: any) {
                fetchFailed = true;
                firstError = firstError || e;
                console.warn(`  [tasks] fetch from "${l.title}" failed: ${e?.message || e}`);
                return [] as any[];
            }
        }));
        const tasks: any[] = ([] as any[]).concat(...fetchResults);
        const existing = this.db.getTasks(accountId, true);
        let changed = false;
        const seen = new Set<string>();
        for (const t of tasks) {
            const local = gsync.taskToLocal(t, accountId);
            const prior = existing.find(e => e.providerId === t.id);
            seen.add(t.id);
            // Skip the upsert when nothing actually differs. Otherwise every
            // refresh emits `tasksUpdated`, the UI listener calls `getTasks`,
            // which fires another `refreshTasks` — tight loop, 429 quota burn.
            if (prior && taskRowEquals(prior, local)) continue;
            this.db.upsertTask({ uuid: prior?.uuid, ...local });
            changed = true;
        }
        // Server-side delete reconciliation — ONLY when every list fetched
        // cleanly. On a failed fetch (429, network) the server result is
        // an empty list, which the purge below would read as "everything
        // was deleted" and wipe the entire local task cache. Guard it.
        if (!fetchFailed) {
            for (const row of existing) {
                if (!row.providerId || row.dirty) continue;
                if (seen.has(row.providerId)) continue;
                this.db.purgeTask(row.uuid);
                changed = true;
            }
        } else {
            console.warn(`  [tasks] fetch incomplete — skipping delete reconciliation to protect the local cache`);
            // Arm the quota cooldown. Without this the 429 is swallowed here
            // and the 30 s poll hammers Google's Tasks API forever.
            this.handleGoogleRefreshError("tasks", firstError);
        }
        return changed;
    }

    async createTaskLocal(t: { title: string; notes?: string; dueMs?: number }): Promise<string> {
        const acct = this.getPrimaryAccount("tasks");
        if (!acct) throw new Error("No primary tasks account");
        const uuid = this.db.upsertTask({ accountId: acct.id, ...t, dirty: true });
        this.db.enqueueStoreSync("tasks", "create", acct.id, uuid, t);
        this.drainStoreSync().catch(() => { /* */ });
        return uuid;
    }

    async updateTaskLocal(uuid: string, patch: {
        title?: string; notes?: string; dueMs?: number; completedMs?: number;
    }): Promise<void> {
        const existing = this.db.getTaskByUuid(uuid);
        if (!existing) throw new Error(`No task ${uuid}`);
        this.db.upsertTask({
            uuid, accountId: existing.accountId, providerId: existing.providerId,
            listId: existing.listId, dirty: true,
            title: patch.title ?? existing.title,
            notes: patch.notes ?? existing.notes,
            dueMs: patch.dueMs ?? existing.dueMs,
            completedMs: patch.completedMs ?? existing.completedMs,
        });
        this.db.enqueueStoreSync("tasks", "update", existing.accountId, uuid,
            { providerId: existing.providerId, patch });
        this.drainStoreSync().catch(() => { /* */ });
    }

    async deleteTaskLocal(uuid: string): Promise<void> {
        const task = this.db.getTaskByUuid(uuid);
        if (!task) return;
        this.db.deleteTaskLocal(uuid);
        if (task.providerId) {
            this.db.enqueueStoreSync("tasks", "delete", task.accountId, uuid, { providerId: task.providerId });
            this.drainStoreSync().catch(() => { /* */ });
        } else {
            this.db.purgeTask(uuid);
        }
    }

    /** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
     *  Called on every local edit, and on a periodic tick from the outbox worker. */
    async drainStoreSync(): Promise<void> {
        const queue = this.db.getStoreSyncQueue();
        for (const entry of queue) {
            try {
                if (entry.kind === "calendar") {
                    const tp = await this.primaryTokenProvider("calendar");
                    if (entry.op === "create") {
                        const created = await gsync.createCalendarEvent(tp, gsync.localToCalendarEvent(entry.payload));
                        this.db.markCalendarEventClean(entry.targetUuid, created.id, created.etag || "");
                    } else if (entry.op === "update") {
                        const updated = await gsync.updateCalendarEvent(tp, entry.payload.providerId,
                            gsync.localToCalendarEvent(entry.payload.patch));
                        this.db.markCalendarEventClean(entry.targetUuid, updated.id, updated.etag || "");
                    } else if (entry.op === "delete") {
                        await gsync.deleteCalendarEvent(tp, entry.payload.providerId);
                        this.db.purgeCalendarEvent(entry.targetUuid);
                    }
                } else if (entry.kind === "tasks") {
                    const tp = await this.primaryTokenProvider("tasks");
                    if (entry.op === "create") {
                        const created = await gsync.createTask(tp, gsync.localToTask(entry.payload));
                        this.db.markTaskClean(entry.targetUuid, created.id, created.etag || "");
                    } else if (entry.op === "update") {
                        const updated = await gsync.updateTask(tp, entry.payload.providerId,
                            gsync.localToTask(entry.payload.patch));
                        this.db.markTaskClean(entry.targetUuid, updated.id, updated.etag || "");
                    } else if (entry.op === "delete") {
                        await gsync.deleteTask(tp, entry.payload.providerId);
                        this.db.purgeTask(entry.targetUuid);
                    }
                } else if (entry.kind === "contacts") {
                    const tp = await this.primaryTokenProvider("contacts");
                    if (entry.op === "create") {
                        await gsync.createContact(tp, entry.payload);
                    } else if (entry.op === "update") {
                        await gsync.updateContact(tp, entry.payload.resourceName,
                            entry.payload.updatePersonFields || "names,emailAddresses",
                            entry.payload.person);
                    } else if (entry.op === "delete") {
                        await gsync.deleteContact(tp, entry.payload.resourceName);
                    }
                }
                this.db.completeStoreSync(entry.id);
            } catch (e: any) {
                console.error(`[store_sync] ${entry.kind}/${entry.op}/${entry.targetUuid} failed: ${e.message}`);
                this.db.failStoreSync(entry.id, e.message || String(e));
            }
        }
    }

    /** List queued outgoing messages with parsed envelope headers so the UI
     *  can render a pink-row "pending" view before IMAP APPEND succeeds. */
    listQueuedOutgoing(): any[] {
        const configDir = getConfigDir();
        const outboxRoot = path.join(configDir, "outbox");
        const sendingRoot = path.join(configDir, "sending");
        const out: any[] = [];
        const parseEnv = (raw: string, file: string, dir: string, accountId: string) => {
            const headerEnd = raw.search(/\r?\n\r?\n/);
            const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
            const get = (name: string): string => {
                const re = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n\\s+.+)*)`, "mi");
                const m = headers.match(re);
                return m ? m[1].replace(/\r?\n\s+/g, " ").trim() : "";
            };
            const retries = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
            const st = (() => { try { return fs.statSync(path.join(dir, file)); } catch { return null; } })();
            return {
                accountId,
                file,
                path: path.join(dir, file),
                dir,
                from: get("From"),
                to: get("To"),
                cc: get("Cc"),
                bcc: get("Bcc"),
                subject: get("Subject"),
                date: get("Date"),
                messageId: get("Message-ID"),
                attempts: retries,
                sizeBytes: st?.size || 0,
                createdAt: st?.mtimeMs || 0,
                claimed: /\.sending-[^-]+-\d+$/.test(file),
            };
        };
        const scanDir = (accountId: string, dir: string) => {
            if (!fs.existsSync(dir)) return;
            for (const f of fs.readdirSync(dir)) {
                if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f)) continue;
                const fp = path.join(dir, f);
                try {
                    const raw = fs.readFileSync(fp, "utf-8");
                    out.push(parseEnv(raw, f, dir, accountId));
                } catch (err: any) {
                    // Unreadable file — still show it so the user can cancel.
                    // Previously silently skipped, which produced the user-reported
                    // "outbox badge shows 1 but the modal is empty" symptom:
                    // getOutboxStatus counted the file, listQueuedOutgoing dropped it.
                    const st = (() => { try { return fs.statSync(fp); } catch { return null; } })();
                    out.push({
                        accountId,
                        file: f,
                        path: fp,
                        dir,
                        from: "",
                        to: "",
                        cc: "",
                        bcc: "",
                        subject: `[unreadable: ${err?.code || err?.message || "read failed"}]`,
                        date: "",
                        messageId: "",
                        attempts: 0,
                        sizeBytes: st?.size || 0,
                        createdAt: st?.mtimeMs || 0,
                        claimed: /\.sending-[^-]+-\d+$/.test(f),
                    });
                }
            }
        };
        try {
            if (fs.existsSync(outboxRoot)) {
                for (const acct of fs.readdirSync(outboxRoot)) scanDir(acct, path.join(outboxRoot, acct));
            }
            if (fs.existsSync(sendingRoot)) {
                for (const acct of fs.readdirSync(sendingRoot)) scanDir(acct, path.join(sendingRoot, acct, "queued"));
            }
        } catch { /* */ }
        out.sort((a, b) => b.createdAt - a.createdAt);
        return out;
    }

    /** Manually drop a queued message (not yet sent). Removes the .ltr file. */
    cancelQueuedOutgoing(filePath: string): { ok: true } {
        // Safety: refuse anything outside the ~/.mailx tree.
        const dir = getConfigDir();
        if (!filePath.startsWith(dir)) throw new Error("path outside mailx data dir");
        if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
        return { ok: true };
    }

    async syncAll(): Promise<void> {
        await this.imapManager.syncAll();
    }

    async syncAccount(accountId: string): Promise<void> {
        const folders = await this.imapManager.syncFolders(accountId);
        folders.sort((a, b) => {
            if (a.specialUse === "inbox") return -1;
            if (b.specialUse === "inbox") return 1;
            return 0;
        });
        for (const folder of folders) {
            try {
                await this.imapManager.syncFolder(accountId, folder.id);
            } catch (e: any) {
                console.error(`  Skipping folder ${folder.path}: ${e.message}`);
            }
        }
    }

    /** Sync ONE folder now. Backs the lazy-folder-sync model (Bob 2026-05-28):
     *  the client fires this when the user opens a folder, so an on-demand
     *  folder is fresh without the app full-sweeping all 79 folders every
     *  5 minutes. Gmail folders are API-synced; IMAP folders go through
     *  syncFolder. Fire-and-forget from the caller's view — the folderSynced
     *  event refreshes the list when it lands. */
    async syncFolderNow(accountId: string, folderId: number): Promise<void> {
        try {
            await this.imapManager.syncFolder(accountId, folderId);
        } catch (e: any) {
            console.error(`  [sync-on-open] ${accountId}/${folderId}: ${e?.message || e}`);
        }
    }

    /** Force re-authentication for an account (deletes token, opens browser consent) */
    async reauthenticate(accountId: string): Promise<boolean> {
        return this.imapManager.reauthenticate(accountId);
    }

    // ── Send ──

    async send(msg: any): Promise<void> {
        // Local-first: the critical path is validate → build raw → queue
        // locally. Everything else (contacts recording, IMAP APPEND,
        // SMTP) happens after the IPC ACK. Settings come from cache so
        // a stalled GDrive mount doesn't block the send.
        const t0 = Date.now();
        const lap = (label: string) => console.log(`  [send] +${Date.now() - t0}ms ${label}`);
        console.log(`  [send] ENTRY from=${msg?.from} to=${JSON.stringify(msg?.to)} subject="${msg?.subject}" attachments=${msg?.attachments?.length || 0}`);
        const accounts = this.getCachedAccounts();
        let account = accounts.find(a => a.id === msg.from);
        if (!account) {
            // Cache miss — invalidate and try one authoritative read.
            this._accountsCache = null;
            account = this.getCachedAccounts().find(a => a.id === msg.from);
        }
        if (!account) {
            const ids = accounts.map(a => a.id).join(", ");
            console.error(`  [send] FAIL: Unknown account "${msg.from}". Known accounts: [${ids}]`);
            throw new Error(`Unknown account: ${msg.from}`);
        }
        lap("account resolved");

        // Vet every recipient address — refuse to send if any field contains a
        // non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
        // autocomplete). This catches garbage BEFORE it hits SMTP, where the
        // server would either accept-and-bounce or reject the whole envelope.
        // Empty / whitespace-only fragments are silently dropped (covers a
        // trailing comma in the To field, blank pills, etc.) — they're a
        // user-input artifact, not a "bad address" worth refusing the send.
        // The list is mutated in place so the dropped entries don't reach
        // the SMTP envelope assembly below.
        const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
        const validateAndPrune = (label: string, list: { name?: string; address?: string }[] | undefined): { name?: string; address?: string }[] | undefined => {
            if (!list) return list;
            const kept: { name?: string; address?: string }[] = [];
            for (const a of list) {
                const addr = (a?.address || "").trim();
                if (!addr) continue;        // drop blanks (trailing comma, empty pill)
                if (!emailRe.test(addr)) {
                    throw new Error(`${label} has an invalid address: "${addr}"${a?.name ? ` (displayed as "${a.name}")` : ""}`);
                }
                kept.push({ name: a.name, address: addr });
            }
            return kept;
        };
        msg.to  = validateAndPrune("To",  msg.to)  as any;
        msg.cc  = validateAndPrune("Cc",  msg.cc)  as any;
        msg.bcc = validateAndPrune("Bcc", msg.bcc) as any;
        if (!msg.to?.length) throw new Error("No To recipients");

        // Extract bare email from fromAddress (may be "Name <addr>" or just "addr")
        let fromAddr = msg.fromAddress || account.email;
        const angleMatch = fromAddr.match(/<([^>]+)>/);
        if (angleMatch) fromAddr = angleMatch[1];
        if (!emailRe.test(fromAddr)) throw new Error(`From address is not a valid email: "${fromAddr}"`);
        const fromHeader = `${account.name} <${fromAddr}>`;
        const to = msg.to.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        const cc = msg.cc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        const bcc = msg.bcc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
        // HTML-bodied mail gets a text/plain alternative part too — spam
        // filters (SpamAssassin / Rspamd / Google) penalise HTML-only mail
        // by 1-2 points, and plain-text-only readers still exist. The text
        // part is derived from the HTML via htmlToPlainText when the caller
        // didn't supply an explicit bodyText.
        const hasHtml = !!msg.bodyHtml;
        const htmlBody = msg.bodyHtml || "";
        const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
        const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
        const textEncoded = encodeQuotedPrintable(textBody);

        // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
        const domain = account.email.split("@")[1] || "mailx.local";
        const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;

        const hasAttachments = Array.isArray(msg.attachments) && msg.attachments.length > 0;
        const commonHeaders = [
            `From: ${fromHeader}`, `To: ${to}`,
            cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
            `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
            `Message-ID: ${messageId}`,
            msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
            msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
            `MIME-Version: 1.0`,
        ].filter(h => h !== null);

        let rawMessage: string;
        const newBoundary = () =>
            `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
        // Inner body: either a multipart/alternative (text+html) or a single
        // text/plain. `innerBody` is the body-only portion (no envelope
        // headers) that will be wrapped by the attachments multipart if any.
        const makeInner = (): { headers: string[]; body: string } => {
            if (hasHtml) {
                const altBoundary = newBoundary();
                const body =
                    `--${altBoundary}\r\n` +
                    `Content-Type: text/plain; charset=UTF-8\r\n` +
                    `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                    `${textEncoded}\r\n` +
                    `--${altBoundary}\r\n` +
                    `Content-Type: text/html; charset=UTF-8\r\n` +
                    `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                    `${htmlEncoded}\r\n` +
                    `--${altBoundary}--\r\n`;
                return {
                    headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`],
                    body,
                };
            }
            // Plain-text-only send — no HTML supplied, no alternative needed.
            return {
                headers: [
                    `Content-Type: text/plain; charset=UTF-8`,
                    `Content-Transfer-Encoding: quoted-printable`,
                ],
                body: textEncoded,
            };
        };
        if (hasAttachments) {
            // multipart/mixed wrapping (multipart/alternative | text/plain)
            // + one base64 attachment part per file. Attachment chunks are
            // wrapped at 76-char lines per RFC 2045.
            const mixedBoundary = newBoundary();
            const wrap76 = (s: string): string => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
            const inner = makeInner();
            const parts: string[] = [];
            parts.push(
                `--${mixedBoundary}\r\n` +
                inner.headers.join("\r\n") +
                `\r\n\r\n` +
                inner.body
            );
            for (const att of msg.attachments) {
                const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
                const mime = att.mimeType || "application/octet-stream";
                const wrapped = wrap76(att.dataBase64 || "");
                parts.push(
                    `--${mixedBoundary}\r\n` +
                    `Content-Type: ${mime}; name="${filename}"\r\n` +
                    `Content-Disposition: attachment; filename="${filename}"\r\n` +
                    `Content-Transfer-Encoding: base64\r\n\r\n` +
                    `${wrapped}\r\n`
                );
            }
            const headers = [
                ...commonHeaders,
                `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`,
            ].join("\r\n");
            rawMessage = `${headers}\r\n\r\n${parts.join("")}--${mixedBoundary}--\r\n`;
        } else {
            const inner = makeInner();
            const headers = [
                ...commonHeaders,
                ...inner.headers,
            ].join("\r\n");
            rawMessage = `${headers}\r\n\r\n${inner.body}`;
        }

        lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
        this.imapManager.queueOutgoingLocal(account.id, rawMessage);
        lap("queued to disk");

        // ↩ indicator: mark the original replied-to in the local DB immediately
        // so the list shows the marker without waiting for the Sent message to
        // sync back. Idempotent — if the user replies twice, second call is a
        // no-op. Cross-account replies are intentionally supported: the marker
        // appears on the original even if the reply went out from a different
        // account, because is_replied is derived from In-Reply-To regardless
        // of which mailbox the reply landed in.
        if (msg.inReplyTo) {
            try {
                for (const a of this.getCachedAccounts()) {
                    this.db.markRepliedByMessageId(a.id, msg.inReplyTo);
                }
            } catch (e: any) { console.error(`  [send] markReplied raised: ${e?.message || e}`); }
        }

        // Reliable draft cleanup: the client used to fire deleteDraft() as a
        // separate fire-and-forget IPC and silently swallow failures, which
        // produced stale "draft droppings" whenever the IMAP path hiccuped
        // (busy connection, transient auth refresh, etc.). Now the client
        // hands draftUid/draftId in the send payload, and imapManager's
        // deleteDraft queues a sync_action on failure for retry — the
        // local-first "queue and reconcile" path that flag/move already use.
        if (msg.draftUid || msg.draftId) {
            this.syncQueue.forgetDraft(account.id, msg.draftId || "");
            this.imapManager.deleteDraft(account.id, msg.draftUid || 0, msg.draftId || "")
                .catch((e: any) => console.error(`  [send] draft cleanup raised: ${e?.message || e}`));
        }
        // No more "optimistic Sent row" — Sent reflects the server, period.
        // Pending-to-be-sent items live in the Outbox view (the dir-based
        // outbox queue + IMAP Outbox folder for multi-machine interlock).
        // When SMTP + IMAP-APPEND complete, IDLE-on-Sent (mailx-imap
        // startWatching) fires syncFolder which picks up the real UID.
        console.log(`  Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);

        // Contacts recording is off the critical path — deferred until after
        // the IPC ACK so a slow DB write can't stall the send.
        setImmediate(() => {
            try {
                for (const addr of msg.to) this.db.recordSentAddress(addr.name, addr.address);
                if (msg.cc) for (const addr of msg.cc) this.db.recordSentAddress(addr.name, addr.address);
                if (msg.bcc) for (const addr of msg.bcc) this.db.recordSentAddress(addr.name, addr.address);
            } catch (e: any) {
                console.error(`  recordSentAddress failed: ${e?.message || e}`);
            }
        });
    }

    // ── Delete / Move / Undelete ──

    /** Trash a single message. Local-first: local row moves to Trash (or
     *  expunges if already there); the server mirror is queued. */
    async deleteMessage(accountId: string, uid: number, folderId?: number): Promise<void> {
        // folderId disambiguates the row: uid is per-folder, so a bare
        // (account, uid) lookup can resolve to a DIFFERENT folder's message
        // (Bob 2026-06-06: pressing Del on an inbox digest trashed a message
        // that was already in Trash — the daemon found the wrong row). The
        // client always knows the folder of the selected row; honor it.
        const envelope = this.db.getMessageByUid(accountId, uid, folderId);
        if (!envelope) throw new Error("Message not found");
        this.db.setMessagePendingDelete(accountId, uid, envelope.folderId);
        const trash = this.localStore.findSpecialFolder(accountId, "trash");
        const result = this.localStore.trashMessage(accountId, uid, envelope.folderId, trash?.id ?? null);
        if (result === "moved-to-trash" && trash) {
            this.syncQueue.enqueueMove(accountId, uid, envelope.folderId, trash.id);
        } else {
            this.syncQueue.enqueueDelete(accountId, uid, envelope.folderId, "delete");
        }
    }

    /** Bulk trash. Same shape as deleteMessage but loops; each row gets its
     *  own pending-delete flag, local move/expunge, and queue entry. */
    async deleteMessages(accountId: string, uids: number[], folderIds?: number[]): Promise<void> {
        const trash = this.localStore.findSpecialFolder(accountId, "trash");
        this.localStore.bus.withBatch(() => {
            for (let i = 0; i < uids.length; i++) {
                const uid = uids[i];
                // Parallel folderIds[] disambiguates each uid to its folder —
                // see deleteMessage for why a bare (account, uid) lookup is wrong.
                const env = this.db.getMessageByUid(accountId, uid, folderIds?.[i]);
                if (!env) continue;
                this.db.setMessagePendingDelete(accountId, uid, env.folderId);
                const result = this.localStore.trashMessage(accountId, uid, env.folderId, trash?.id ?? null);
                if (result === "moved-to-trash" && trash) {
                    this.syncQueue.enqueueMove(accountId, uid, env.folderId, trash.id);
                } else {
                    this.syncQueue.enqueueDelete(accountId, uid, env.folderId, "delete");
                }
            }
        });
    }

    /** Move a message to another folder. Same-account moves go through the
     *  Store + queue. Cross-account moves still synchronously bridge two
     *  IMAP connections (rare; local-first violation noted as future work). */
    async moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void> {
        if (targetAccountId && targetAccountId !== accountId) {
            // Cross-account: source row removed, body re-fetched on target
            // via the destination provider. Done synchronously today — moving
            // this onto the bus needs a two-sided outbound APPEND queue which
            // is out of scope for this refactor. Local DB cleanup still
            // happens via mailx-imap (see moveMessageCrossAccount).
            await this.imapManager.moveMessageCrossAccount(accountId, uid, this.db.getMessageByUid(accountId, uid)?.folderId || 0, targetAccountId, targetFolderId);
            return;
        }
        const envelope = this.db.getMessageByUid(accountId, uid);
        if (!envelope) throw new Error("Message not found");
        const moved = this.localStore.moveMessage(accountId, uid, envelope.folderId, targetFolderId);
        if (!moved) throw new Error(`Move failed: no row at (${accountId}, folder=${envelope.folderId}, uid=${uid})`);
        this.syncQueue.enqueueMove(accountId, uid, envelope.folderId, targetFolderId);
    }

    /** Bulk COPY: the messages stay where they are; a copy is queued to the
     *  target folder (server APPEND via the sync queue). No local row is
     *  moved/removed — the target copy is imported when its folder syncs
     *  (the copy action triggers that). folderIds[] disambiguates each uid to
     *  its source folder, index-aligned with uids (same contract as
     *  deleteMessages). */
    async copyMessages(accountId: string, uids: number[], folderIds: number[] | undefined, targetFolderId: number): Promise<void> {
        for (let i = 0; i < uids.length; i++) {
            const uid = uids[i];
            const env = this.db.getMessageByUid(accountId, uid, folderIds?.[i]);
            if (!env) continue;
            this.syncQueue.enqueueCopy(accountId, uid, env.folderId, targetFolderId);
        }
    }

    /** Bulk move. Loops the single-move pattern under a withBatch so the
     *  one folder-counts re-render fires once per affected folder, not per
     *  message. */
    async moveMessages(accountId: string, uids: number[], targetFolderId: number, folderIds?: number[]): Promise<void> {
        this.localStore.bus.withBatch(() => {
            for (let i = 0; i < uids.length; i++) {
                const uid = uids[i];
                // Parallel folderIds[] disambiguates each uid to its source
                // folder. A bare (account, uid) lookup is WRONG: IMAP UIDs are
                // per-folder, so the same number exists in INBOX and _Spam for
                // different messages. getMessageByUid without a folderId does
                // LIMIT 1 across all folders and can resolve to the wrong row,
                // moving the wrong message (or no-op) and leaving the real one
                // in place — it then reappears on the next list refresh. Same
                // class as the flag-clear wrong-folder bug. (see deleteMessages)
                const env = this.db.getMessageByUid(accountId, uid, folderIds?.[i]);
                if (!env) continue;
                if (!this.localStore.moveMessage(accountId, uid, env.folderId, targetFolderId)) continue;
                this.syncQueue.enqueueMove(accountId, uid, env.folderId, targetFolderId);
            }
        });
    }

    /** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
     *  Throws if the account has no spam folder configured or the folder doesn't exist locally. */
    async markAsSpamMessages(accountId: string, uids: number[], folderIds?: number[]): Promise<{ targetFolderId: number; moved: number }> {
        // The spam folder is whatever the provider's getSpecialFolders() said
        // it is. iflow-direct's compat client fills this in from RFC 6154
        // \Junk / \Spam flags (with sensible defaults if the server doesn't
        // advertise them); Gmail has SPAM built in. mailx stores the result
        // as `specialUse: "junk"` on the matching folder row.
        //
        // Earlier versions required an explicit `spam:` field in accounts.jsonc
        // and the button erroring out when that was absent. That's obsolete —
        // the provider knows where spam goes. Just look up the flagged folder.
        const folders = this.db.getFolders(accountId);
        const target = folders.find(f => f.specialUse === "junk");
        if (!target) throw new Error(`No \\Junk/\\Spam folder found for ${accountId}`);
        await this.moveMessages(accountId, uids, target.id, folderIds);
        return { targetFolderId: target.id, moved: uids.length };
    }

    /** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
     *  per user 2026-04-23 ("let's make it smart later; no auto-delete until
     *  safety issues are addressed"). One row per click. Columns: timestamp
     *  (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
     *  address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
     *  and quotes in subjects survive. No move, no flag change, no server
     *  hit — just the log. Useful as training data for a future classifier.
     */
    async recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{ ok: true; row: string }> {
        const env = this.db.getMessageByUid(accountId, uid, folderId);
        if (!env) throw new Error(`Message not found: ${accountId}/${uid}`);
        const bodyPath = env.bodyPath || "";
        // Prefer `body_path` (authoritative). `Delivered-To` isn't in the
        // envelope struct, so parse from the cached `.eml` if available.
        let deliveredTo = "";
        if (bodyPath) {
            try {
                const raw = fs.readFileSync(bodyPath, "utf-8").slice(0, 4096);
                const m = raw.match(/^Delivered-To:\s*(.+)$/mi);
                if (m) deliveredTo = m[1].trim();
            } catch { /* not fatal — leave blank */ }
        }
        const now = new Date();
        const isoDate = now.toISOString().slice(0, 10);
        const isoTime = now.toISOString().slice(11, 19);
        const fromAddr = env.from?.address || "";
        const subject = env.subject || "";
        const csvEscape = (s: string) => `"${String(s).replace(/"/g, '""')}"`;
        const row = [
            String(now.getTime()),
            csvEscape(isoDate),
            csvEscape(isoTime),
            csvEscape(accountId),
            csvEscape(deliveredTo),
            csvEscape(fromAddr),
            csvEscape(subject),
            csvEscape(bodyPath),
        ].join(",") + "\n";
        const spamCsvPath = path.join(getConfigDir(), "spam.csv");
        // Write a header if the file doesn't exist yet so the CSV is self-describing.
        if (!fs.existsSync(spamCsvPath)) {
            fs.writeFileSync(spamCsvPath, "timestamp_ms,date,time,account,delivered_to,from,subject,eml_path\n", "utf-8");
        }
        fs.appendFileSync(spamCsvPath, row, "utf-8");
        console.log(`  [spam] reported ${accountId}/${uid} → spam.csv`);
        return { ok: true, row };
    }

    /** Restore from Trash. Local-first: move the row back, clear tombstone
     *  and pending-delete flag, then either retract the still-queued MOVE
     *  to trash or queue a counter-MOVE (trash → original). */
    async undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void> {
        const trash = this.localStore.findSpecialFolder(accountId, "trash");
        if (!trash) throw new Error("No Trash folder configured");

        // Clear bookkeeping flags before the local move so a re-sync mid-flight
        // can't re-mark the row as pending-delete and re-tombstone it.
        const envelope = this.db.getMessageByUid(accountId, uid, trash.id);
        if (envelope?.messageId) this.db.removeTombstone(accountId, envelope.messageId);
        this.db.clearMessagePendingDelete(accountId, uid, trash.id);

        this.localStore.undeleteMessage(accountId, uid, trash.id, folderId);

        // (a) MOVE to trash still queued? retract it — server never saw the
        // delete, no counter-action needed. (b) Already drained → queue
        // the counter-MOVE so server state catches up.
        if (!this.syncQueue.cancelPendingMove(accountId, uid, folderId, trash.id)) {
            this.syncQueue.enqueueMove(accountId, uid, trash.id, folderId);
        }
    }

    async deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void> {
        await this.imapManager.deleteOnServer(accountId, folderPath, uid);
    }

    // ── Folder management ──

    async createFolder(accountId: string, parentPath: string, name: string): Promise<void> {
        // Determine the IMAP hierarchy delimiter from the parent (or any
        // existing folder on the account). Earlier the delimiter was
        // hardcoded "." — fine for Dovecot, wrong for Gmail/IMAP with "/".
        const allFolders = this.db.getFolders(accountId);
        const parent = parentPath ? allFolders.find(f => f.path === parentPath) : null;
        const delimiter = parent?.delimiter || allFolders[0]?.delimiter || ".";
        const fullPath = parentPath ? `${parentPath}${delimiter}${name}` : name;

        // Server CREATE + local folder-row write + verify, all on the sync
        // worker (the verify catches the wedged-connection "returned OK but
        // never reached the server" case — Bob 2026-06-12 Archive/dbgtrash).
        const created = await this.imapManager.createFolderViaServer(accountId, fullPath, name, delimiter);
        this.imapManager.emit("folderCountsChanged", accountId, {});
        if (!created) {
            throw new Error(`Folder "${name}" was not created on the server — please try again (the connection may have been busy).`);
        }
    }

    /** Rename a folder in place, or — when `newParentId` is given — reparent it.
     *  Both are a single server-side mailbox rename (no copy-and-delete). Path
     *  computation, guard rails (special-use refusal, name validation, collision
     *  / self-descendant checks), the API-vs-IMAP branch, and the local
     *  folder-row + descendant-prefix rewrite all live in ImapManager (on the
     *  sync worker) so this stays a thin delegate. */
    async renameFolder(accountId: string, folderId: number, newName: string, newParentId?: number): Promise<void> {
        await this.imapManager.renameFolder(accountId, folderId, newName, newParentId);
    }

    async deleteFolder(accountId: string, folderId: number): Promise<void> {
        const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
        if (!folder) throw new Error("Folder not found");
        // Server delete (tolerant of already-gone) on the worker, then drop the
        // local folder row here.
        await this.imapManager.deleteFolderViaServer(accountId, folder.path);
        this.db.deleteFolder(folderId);
    }

    /** Move a folder into the account's Trash. Default delete action; user
     *  has to opt in to permanent removal via Shift+Delete (which routes
     *  to `deleteFolder` above).
     *
     *  Strategy:
     *  - IMAP RENAME `<path>` → `<trashPath><delim><name>`. Most servers
     *    bring the message contents and any subfolders along automatically.
     *  - Name collision in Trash → append " (YYYY-MM-DD)", then a counter
     *    " (YYYY-MM-DD 2)", ... up to a sane cap.
     *  - RENAME rejected (server forbids subfolders under Trash, e.g. some
     *    Dovecot configs flag Trash \Noinferiors) → fall back to moving the
     *    folder's messages into Trash root via the existing trash path,
     *    then `mailboxDelete` on the now-empty folder. Children lost in
     *    that fallback get the same treatment recursively.
     *  - Folder is itself Trash, or already inside Trash → throw (caller
     *    should detect and route to `deleteFolder` instead).
     */
    async moveFolderToTrash(accountId: string, folderId: number): Promise<void> {
        const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
        if (!folder) throw new Error("Folder not found");
        const trash = this.db.getFolders(accountId).find(f => f.specialUse === "trash");
        if (!trash) throw new Error("This account has no Trash folder — use Delete permanently instead.");
        if (folder.id === trash.id) throw new Error("Cannot move Trash into itself.");
        const delim = folder.delimiter || trash.delimiter || "/";
        const trashPrefix = trash.path + delim;
        if (folder.path === trash.path || folder.path.startsWith(trashPrefix)) {
            throw new Error("Folder is already in Trash — use Delete permanently.");
        }

        // Pick a non-colliding target path inside Trash.
        const allPaths = new Set(this.db.getFolders(accountId).map(f => f.path));
        const baseName = folder.name;
        const today = new Date().toISOString().slice(0, 10);
        const candidates = [
            baseName,
            `${baseName} (${today})`,
            ...Array.from({ length: 20 }, (_, i) => `${baseName} (${today} ${i + 2})`),
        ];
        let targetPath = "";
        for (const name of candidates) {
            const p = trashPrefix + name;
            if (!allPaths.has(p)) { targetPath = p; break; }
        }
        if (!targetPath) throw new Error(`Too many "${baseName}" copies in Trash — empty Trash or use Delete permanently.`);

        // RENAME-into-Trash (or message-spill fallback) + folder-row cleanup,
        // all on the sync worker.
        await this.imapManager.moveFolderToTrashViaServer(accountId, folder.id, folder.path, targetPath, trash.id, trash.path, delim);
        this.imapManager.emit("folderCountsChanged", accountId, {});
    }

    markFolderRead(folderId: number): void {
        this.db.markFolderRead(folderId);
    }

    async emptyFolder(accountId: string, folderId: number): Promise<void> {
        const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
        if (!folder) throw new Error("Folder not found");
        this.db.deleteAllMessages(accountId, folderId);
        // Recalc + broadcast so the folder-tree badge drops to 0 immediately.
        // Without this, the badge kept showing the old unread count even
        // though the list was empty (user-reported bug).
        this.db.recalcFolderCounts(folderId);
        try {
            (this.imapManager as any).emit?.("folderCountsChanged", accountId, {});
        } catch { /* non-fatal */ }
        await this.imapManager.emptyFolderViaServer(accountId, folder.path);
    }

    // ── Attachments ──

    async getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ content: Buffer; contentType: string; filename: string }> {
        const envelope = this.db.getMessageByUid(accountId, uid, folderId);
        if (!envelope) throw new Error("Message not found");

        // Prefer the on-disk body — when the user just opened the message,
        // LocalStore.getMessage either returned cached:true (body already on
        // disk) or kicked a background fetch that wrote it. Either way a
        // re-fetch here would be wasted IMAP work and risks racing.
        const bodyStore = this.imapManager.getBodyStore();
        let raw: Buffer | null = null;
        const storedPath = envelope.bodyPath || this.db.getMessageBodyPath(accountId, uid, folderId ?? envelope.folderId) || "";
        if (storedPath && await bodyStore.hasByPath(storedPath)) {
            try { raw = await bodyStore.readByPath(storedPath); } catch { raw = null; }
        }
        if (!raw) {
            raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
        }
        if (!raw) throw new Error("Message body not available");

        const parsed = await parseSerial(raw);
        const att = parsed.attachments?.[attachmentId];
        if (!att) throw new Error("Attachment not found");
        return {
            content: att.content,
            contentType: att.contentType || "application/octet-stream",
            filename: (att.filename || "attachment").replace(/"/g, ""),
        };
    }

    /** Raw RFC 822 source of a message — for "Save message as .eml". Reads the
     *  on-disk body file (same resolution as getAttachment), falling back to a
     *  server fetch. Returned base64 so it survives the IPC JSON channel. */
    async getMessageSource(accountId: string, uid: number, folderId?: number): Promise<{ dataBase64: string; filename: string }> {
        const envelope = this.db.getMessageByUid(accountId, uid, folderId);
        if (!envelope) throw new Error("Message not found");
        const bodyStore = this.imapManager.getBodyStore();
        let raw: Buffer | null = null;
        const storedPath = envelope.bodyPath || this.db.getMessageBodyPath(accountId, uid, folderId ?? envelope.folderId) || "";
        if (storedPath && await bodyStore.hasByPath(storedPath)) {
            try { raw = await bodyStore.readByPath(storedPath); } catch { raw = null; }
        }
        if (!raw) {
            raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
        }
        if (!raw) throw new Error("Message body not available");
        // Filename from the subject — sanitized to a safe basename.
        const base = (envelope.subject || "message").replace(/[<>:"/\\|?*\r\n]/g, "_").slice(0, 80).trim() || "message";
        return { dataBase64: raw.toString("base64"), filename: `${base}.eml` };
    }

    /** Save an attachment to a local temp dir and open it with the OS default
     *  application. The desktop UI uses this instead of a browser download —
     *  a programmatic `<a download>` click is silently dropped inside msger's
     *  WebView2, so the open must happen in the Node process (same pattern as
     *  openInWord / openLocalPath). Cross-platform: start / open / xdg-open. */
    async openAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number, filename?: string): Promise<{ ok: boolean; path: string }> {
        const att = await this.getAttachment(accountId, uid, attachmentId, folderId);
        const dir = getAttachmentStagingDir();
        fs.mkdirSync(dir, { recursive: true });
        // Prefer the filename the UI is showing — it comes from the sync-time
        // parse stored in the DB. The re-parse inside getAttachment() can lose
        // a part's name param (notably Google calendar invites: the part
        // re-parses with an empty filename), and a NAMELESS temp file gets no
        // extension, so `start` on Windows has no handler to dispatch to and
        // silently no-ops — the invite.ics that "did nothing" (2026-06-08).
        // basename() strips any path components so a crafted name can't escape
        // the temp dir; also drop newlines.
        let safeName = (path.basename(filename || att.filename || "attachment").replace(/[\r\n]/g, "_")) || "attachment";
        // Guarantee an extension so the OS has something to associate. Derive
        // it from the MIME type when the name lacks one.
        if (!path.extname(safeName)) {
            const ext = extForMime(att.contentType);
            if (ext) safeName += ext;
        }
        const target = path.join(dir, safeName);
        fs.writeFileSync(target, att.content);
        if (process.platform === "win32") {
            // Mark-of-the-Web: tag the staged file as coming from the Internet
            // (Zone.Identifier ADS, ZoneId 3) so Office opens it in Protected
            // View and SmartScreen vets executables — same treatment a browser
            // download gets. Email attachments are exactly the threat model
            // MotW exists for (Bob 2026-07-02: "the file should be flagged as
            // being saved from an unknown source"). Best-effort: ADS needs
            // NTFS; FAT/exFAT volumes throw and we still open the file.
            try { fs.writeFileSync(`${target}:Zone.Identifier`, "[ZoneTransfer]\r\nZoneId=3\r\n"); } catch { /* non-NTFS */ }
        }
        const { spawn } = await import("node:child_process");
        if (process.platform === "win32") {
            spawn("cmd", ["/c", "start", "", target], { detached: true, stdio: "ignore", windowsHide: true }).unref();
        } else if (process.platform === "darwin") {
            spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
        } else {
            spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
        }
        return { ok: true, path: target };
    }

    /** Open an http/https/mailto URL in the OS default browser/handler from the
     *  Node process. The UI used to rely on `window.open(url, "_blank")`, but
     *  inside msger's WebView2 that opens a local in-app window (or no-ops)
     *  rather than the system browser — so clicking a link in a message, or an
     *  unsubscribe "click here", "stayed local" (Bob 2026-06-13, "old bug
     *  back"). `mailxapi.openExternal` now routes here.
     *
     *  Security: the URL comes from untrusted email. We (1) parse it and allow
     *  ONLY http/https/mailto — no file:, javascript:, data:, etc. — and (2)
     *  pass it as a single spawn ARG with no shell, and on Windows use rundll32
     *  (not `cmd /c start`, which re-parses `&`/`^` in query strings and is
     *  injection-prone). Same cross-platform spawn pattern as openAttachment. */
    async openExternal(url: string): Promise<{ ok: boolean; reason?: string }> {
        let u: URL;
        try { u = new URL(String(url)); }
        catch { return { ok: false, reason: "unparseable URL" }; }
        if (!["http:", "https:", "mailto:"].includes(u.protocol)) {
            console.error(`  [openExternal] refused non-web scheme: ${u.protocol}`);
            return { ok: false, reason: `unsupported scheme ${u.protocol}` };
        }
        const target = u.href;
        try {
            const { spawn } = await import("node:child_process");
            if (process.platform === "win32") {
                // rundll32 receives the URL as a direct argv — no cmd, so query
                // strings with & can't break out into a second command.
                spawn("rundll32", ["url.dll,FileProtocolHandler", target], { detached: true, stdio: "ignore", windowsHide: true }).unref();
            } else if (process.platform === "darwin") {
                spawn("open", [target], { detached: true, stdio: "ignore" }).unref();
            } else {
                spawn("xdg-open", [target], { detached: true, stdio: "ignore" }).unref();
            }
            return { ok: true };
        } catch (e: any) {
            console.error(`  [openExternal] spawn failed: ${e?.message || e}`);
            return { ok: false, reason: e?.message || String(e) };
        }
    }

    // ── Drafts ──

    async saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string, bcc?: string): Promise<{ draftUid: number | null; draftId: string }> {
        // Local-first: commit the draft to the local filesystem synchronously
        // and return immediately. The IMAP APPEND (and the previous-draft
        // delete) run in the background. Previously this method awaited IMAP
        // inline, which produced the 30/120s `mailxapi timeout: saveDraft`
        // the user reported — every IMAP stall (slow server, hung OAuth,
        // maxed connection pool) froze autosave. The local `.eml` written
        // below is the user's crash-safety net; IMAP is a sync target, not
        // a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
        // so the reconciler can de-duplicate on the server by header search
        // even without the previousDraftUid round-trip.
        // Account lookup uses the cached list — `loadSettings()` reads
        // accounts.jsonc from the GDrive mount and could itself stall for
        // 120s, which was the actual `mailxapi timeout: saveDraft` source
        // (the IMAP work was fire-and-forget, but loadSettings wasn't).
        let account = this.getCachedAccounts().find(a => a.id === accountId);
        if (!account) {
            this._accountsCache = null;
            account = this.getCachedAccounts().find(a => a.id === accountId);
        }
        if (!account) throw new Error(`Unknown account: ${accountId}`);

        // Generate or reuse a stable draft ID for dedup
        const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

        const body = bodyHtml || bodyText || "";
        const bodyEncoded = encodeQuotedPrintable(body);

        const headers = [
            `From: ${account.name} <${account.email}>`,
            // Bcc belongs IN a draft — it's the send path that strips it.
            // Without it, editing a saved draft silently lost the Bcc list.
            to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
            `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
            `X-Mailx-Draft-ID: ${id}`,
            `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
        ].filter(h => h !== null).join("\r\n");
        const raw = `${headers}\r\n\r\n${bodyEncoded}`;

        // Local commit: write editing copy to disk. Crash recovery lives in
        // the last 3 files. Synchronous fs (~ms) so the caller returns fast.
        try {
            const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
            fs.mkdirSync(editingDir, { recursive: true });
            const pad2 = (n: number) => String(n).padStart(2, "0");
            const now = new Date();
            const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
            fs.writeFileSync(path.join(editingDir, `${ts}.eml`), raw);
            // Keep only last 3
            const files = fs.readdirSync(editingDir).filter(f => f.endsWith(".eml")).sort();
            while (files.length > 3) {
                fs.unlinkSync(path.join(editingDir, files.shift()!));
            }
        } catch { /* non-fatal — draft stays in memory at least */ }

        // Background reconcile to server Drafts folder via the SyncQueue
        // seam. Fire-and-forget; deferred failures surface as a `draftSaveDeferred`
        // event the UI can render in the status bar.
        this.syncQueue.enqueueDraftPush(accountId, raw, previousDraftUid, id);

        // Notify the UI that the on-disk content for the draft just changed.
        // The message-viewer (read-only preview pane) currently shows the
        // BEFORE-save body of `previousDraftUid`; without this event it never
        // refreshes until the user clicks the row again. Listener invalidates
        // its cache and re-renders. Scoped to the draft just saved so other
        // windows / other messages ignore the event.
        this.imapManager.emit("draftSaved", {
            accountId,
            previousDraftUid: previousDraftUid ?? null,
            draftId: id,
            subject: subject || "(no subject)",
            bodyHtml: body,
            savedAt: Date.now(),
        });

        return { draftUid: null, draftId: id };
    }

    async deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void> {
        this.syncQueue.forgetDraft(accountId, draftId || "");
        await this.imapManager.deleteDraft(accountId, draftUid, draftId);
    }

    // ── Contacts ──

    searchContacts(query: string): any[] {
        query = (query || "").trim();
        if (query.length < 1) return [];
        return this.localStore.searchContacts(query);
    }

    /** Q49: boolean hint for compose to auto-expand Cc when replying to this
     *  address. True when at least one past sent message to the same recipient
     *  had a non-empty Cc field. */
    hasCcHistoryTo(email: string): boolean {
        return this.db.hasCcHistoryTo(email);
    }

    /** Q49: same shape, for Bcc. Sent folder is the only place Bcc appears,
     *  so the signal is local-only but still reflects the user's habit. */
    hasBccHistoryTo(email: string): boolean {
        return this.db.hasBccHistoryTo(email);
    }

    async syncGoogleContacts(): Promise<void> {
        await this.imapManager.syncAllContacts();
    }

    async seedContacts(): Promise<number> {
        const added = await this.db.seedContactsFromMessages();
        console.log(`  Seeded ${added} contacts from message history`);
        return added;
    }

    /** Explicit add to address book — used by the right-click "Add to contacts"
     *  action on From/To/Cc addresses in the message viewer. Just calls the same
     *  validated upsert path as recordSentAddress. */
    addContact(name: string, email: string): boolean {
        if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) return false;
        this.db.recordSentAddress(name || "", email);
        return true;
    }

    /** Address-book listing — paginated, filterable. */
    listContacts(query: string, page = 1, pageSize = 100): any {
        return this.localStore.listContacts(query || "", page, pageSize);
    }

    /** Upsert a contact from the address book UI (edit name). Two-way cache:
     *  commits locally, queues a Google People push. */
    upsertContact(name: string, email: string): { ok: true } {
        this.db.upsertContact(name || "", email);
        const acct = this.getPrimaryAccount("contacts");
        if (acct) {
            // Google People `createContact` — resourceName is assigned by the
            // server and stored back as google_id once the drainer gets an ACK.
            this.db.enqueueStoreSync("contacts", "create", acct.id, email, {
                names: [{ givenName: name || "" }],
                emailAddresses: [{ value: email }],
            });
            this.drainStoreSync().catch(() => { /* retried on next tick */ });
        }
        return { ok: true };
    }

    /** Delete a contact from the address book. Also pushes the deletion to
     *  Google People if the contact had a resourceName (i.e. was synced). */
    deleteContact(email: string): { ok: true } {
        const acct = this.getPrimaryAccount("contacts");
        // Look up the resourceName before deleting so we can push to Google.
        const contacts = this.db.listContacts("", 1, 10_000) as any;
        const contact = (contacts.items || []).find((c: any) => c.email === email);
        this.db.deleteContactLocal(email);
        if (acct && contact?.googleId) {
            this.db.enqueueStoreSync("contacts", "delete", acct.id, email, {
                resourceName: contact.googleId,
            });
            this.drainStoreSync().catch(() => { /* */ });
        }
        return { ok: true };
    }

    /** Open a configured local path in the OS file explorer. Whitelisted to
     *  avoid the UI poking at arbitrary paths. */
    /** Open an absolute file path in the OS default *text* editor.
     *  Distinct from "open with the file's associated app" — for a .eml
     *  that would usually be Outlook / a mail client; the user wants a
     *  plain-text viewer like Notepad / TextEdit / xdg's text handler.
     *
     *  Cross-platform strategy:
     *  - Windows: `notepad.exe <path>`. Always present since Windows 95.
     *    Notepad reads any file regardless of extension.
     *  - macOS: `open -t <path>` — the `-t` flag explicitly routes to the
     *    user's default text editor (TextEdit or whatever they've set).
     *  - Linux: try `$VISUAL` / `$EDITOR` first (user's stated preference),
     *    then fall back to `xdg-open` with a `.txt` symlink so xdg picks
     *    the text MIME handler rather than the .eml handler.
     *
     *  Path whitelist: must live under the configured store path or
     *  ~/.rmfmail, so the IPC can't be coerced into opening arbitrary
     *  files (e.g., system passwords).
     */
    async openInTextEditor(filePath: string): Promise<{ ok: boolean; opener: string; reason?: string }> {
        // Whitelist: only files under the body-store or ~/.rmfmail.
        const home = process.env.USERPROFILE || process.env.HOME || "";
        const dotMailx = path.resolve(path.join(home, ".rmfmail"));
        const absTarget = path.resolve(filePath);
        if (!absTarget.startsWith(dotMailx)) {
            return { ok: false, opener: "none", reason: `path not under ${dotMailx}` };
        }
        if (!fs.existsSync(absTarget)) {
            return { ok: false, opener: "none", reason: "file not found" };
        }

        const { spawn } = await import("node:child_process");
        const launch = (cmd: string, args: string[]): boolean => {
            try {
                const c = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
                c.on("error", () => { /* */ });
                c.unref();
                return true;
            } catch { return false; }
        };

        if (process.platform === "win32") {
            if (launch("notepad.exe", [absTarget])) return { ok: true, opener: "notepad" };
            return { ok: false, opener: "none", reason: "notepad launch failed" };
        }
        if (process.platform === "darwin") {
            if (launch("open", ["-t", absTarget])) return { ok: true, opener: "open -t" };
            return { ok: false, opener: "none", reason: "open -t failed" };
        }
        // Linux / other: try $VISUAL or $EDITOR; otherwise route through
        // xdg-open on a .txt symlink so xdg picks the text-MIME handler
        // instead of the user's .eml handler (often Thunderbird).
        const editor = process.env.VISUAL || process.env.EDITOR;
        if (editor && launch(editor, [absTarget])) return { ok: true, opener: editor };
        try {
            const tmpLink = path.join(path.dirname(absTarget), `${path.basename(absTarget)}.txt`);
            try { fs.unlinkSync(tmpLink); } catch { /* */ }
            try { fs.symlinkSync(absTarget, tmpLink); }
            catch { fs.copyFileSync(absTarget, tmpLink); }
            if (launch("xdg-open", [tmpLink])) return { ok: true, opener: "xdg-open(.txt)" };
        } catch (e: any) {
            return { ok: false, opener: "none", reason: e?.message || String(e) };
        }
        return { ok: false, opener: "none", reason: "no editor found" };
    }

    async openLocalPath(which: "config" | "log"): Promise<{ ok: true; path: string }> {
        const dir = getConfigDir();
        let target = dir;
        if (which === "log") {
            const today = new Date();
            const pad = (n: number) => String(n).padStart(2, "0");
            const fname = `rmfmail-${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}.log`;
            target = path.join(dir, "logs", fname);
            // Fall back to the legacy `mailx-YYYY-MM-DD.log` name if the
            // user-facing "open log" action is invoked in the gap between
            // upgrade and the next daemon restart (the old daemon is still
            // writing to the legacy path).
            if (!fs.existsSync(target)) {
                const legacy = path.join(dir, "logs", fname.replace(/^rmfmail-/, "mailx-"));
                if (fs.existsSync(legacy)) target = legacy;
            }
        }
        const { spawn } = await import("child_process");
        const cmd = process.platform === "win32" ? "explorer"
            : process.platform === "darwin" ? "open"
            : "xdg-open";
        const args = process.platform === "win32" && which === "log"
            ? ["/select,", target]
            : [target];
        spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
        return { ok: true, path: target };
    }

    /** Get all messages in a thread (across folders) for an account. */
    getThreadMessages(accountId: string, threadId: string): any {
        return this.db.getThreadMessages(accountId, threadId);
    }

    /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
     *  Names are whitelisted so the UI can't read arbitrary files.
     *  `config.jsonc` is the local per-machine config (not cloud-synced). */
    async readJsoncFile(name: string): Promise<string | null> {
        const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
        if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`);
        if (name === "config.jsonc") {
            const configPath = path.join(getConfigDir(), "config.jsonc");
            try { return fs.readFileSync(configPath, "utf-8"); } catch { return null; }
        }
        const { cloudRead } = await import("@bobfrankston/mailx-settings");
        return cloudRead(name);
    }

    // Reformat JSONC preserving comments — applyEdits returns whitespace-only edits.
    async formatJsonc(content: string): Promise<string> {
        const { format, applyEdits } = await import("jsonc-parser");
        const edits = format(content, undefined, {
            tabSize: 2,
            insertSpaces: true,
            eol: "\n",
            insertFinalNewline: true,
        });
        return applyEdits(content, edits);
    }

    /** Return the help markdown for a named config file. Prefers a per-file
     *  doc (`<name>.md` minus the `.jsonc` suffix — e.g. `contacts.md` for
     *  `contacts.jsonc`) shipped in the package's `docs/` dir; falls back to
     *  the legacy `config-help.md`-with-sections format if the per-file
     *  isn't available. */
    async readConfigHelp(name: string): Promise<string> {
        // Also serves topical help docs (search, editor, etc.) — same
        // mechanism as per-file config docs, just with no .jsonc suffix.
        const WHITELIST = [
            "accounts.jsonc", "allowlist.jsonc", "clients.jsonc",
            "config.jsonc", "contacts.jsonc", "preferences.jsonc",
            "search", "editor",
        ];
        if (!WHITELIST.includes(name)) return "";
        const stem = name.replace(/\.jsonc$/, "");
        // Per-file: `accounts.md`, `contacts.md`, etc. — preferred.
        const perFileCandidates = [
            path.join(__dirname, "..", "..", "docs", `${stem}.md`),       // workspace dev
            path.join(__dirname, "docs", `${stem}.md`),                   // installed pkg (prepack copies here)
            path.join(__dirname, "..", "docs", `${stem}.md`),
        ];
        for (const p of perFileCandidates) {
            try { return fs.readFileSync(p, "utf-8").trim(); } catch { /* try next */ }
        }
        // Legacy fallback: single config-help.md with `## <filename>` sections.
        const legacyCandidates = [
            path.join(__dirname, "..", "..", "docs", "config-help.md"),
            path.join(__dirname, "config-help.md"),
        ];
        let md = "";
        for (const p of legacyCandidates) {
            try { md = fs.readFileSync(p, "utf-8"); break; } catch { /* try next */ }
        }
        if (!md) return "";
        const lines = md.split(/\r?\n/);
        let inSection = false;
        const out: string[] = [];
        for (const line of lines) {
            const h2 = /^##\s+(.+?)\s*$/.exec(line);
            if (h2) {
                if (inSection) break;
                if (h2[1].trim() === name) { inSection = true; continue; }
            }
            if (inSection) out.push(line);
        }
        return out.join("\n").trim();
    }

    /** Write a JSONC config file. Validates that the content parses as JSONC
     *  (loosely — strips comments/trailing commas) before writing.
     *  Saves the prior content to a dated backup file first — manual edits
     *  occasionally have typos that survive validation (semantically wrong
     *  but syntactically OK), and a one-key undo isn't enough; the user
     *  asked to be able to recover yesterday's accounts.jsonc. Automatic
     *  saveAccounts/saveAllowlist paths skip backups (they're driven by
     *  trusted code, not the JSONC editor). */
    async writeJsoncFile(name: string, content: string): Promise<void> {
        const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
        if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`);
        // Validate the content parses before writing
        const { parse: parseJsonc } = await import("jsonc-parser");
        const errors: any[] = [];
        parseJsonc(content, errors, { allowTrailingComma: true });
        if (errors.length) {
            throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
        }
        const previous = await this.readJsoncForBackup(name);
        await this.backupJsoncIfChanged(name, previous, content);
        if (name === "config.jsonc") {
            const configPath = path.join(getConfigDir(), "config.jsonc");
            fs.writeFileSync(configPath, content);
            return;
        }
        const { cloudWrite } = await import("@bobfrankston/mailx-settings");
        await cloudWrite(name, content); // throws on failure with descriptive error
    }

    /** Read the current content of a config file (cloud or local) so it can
     *  be saved as a backup before being overwritten. Returns null if the
     *  file doesn't exist yet (first save — nothing to back up). */
    private async readJsoncForBackup(name: string): Promise<string | null> {
        if (name === "config.jsonc") {
            const configPath = path.join(getConfigDir(), "config.jsonc");
            try { return fs.readFileSync(configPath, "utf-8"); } catch { return null; }
        }
        try {
            const { cloudRead } = await import("@bobfrankston/mailx-settings");
            return await cloudRead(name);
        } catch { return null; }
    }

    /** Write the prior content to `<configDir>/backup/<name>.<ts>.bak` and
     *  prune so at most 10 backups per file remain AND none are older than 7
     *  days. Skipped when previous content is null (first write) or
     *  identical to the new content (no-op save). */
    private async backupJsoncIfChanged(name: string, previous: string | null, next: string): Promise<void> {
        if (previous == null || previous === next) return;
        const backupDir = path.join(getConfigDir(), "backup");
        try { fs.mkdirSync(backupDir, { recursive: true }); } catch { /* */ }
        // Filename-safe ISO timestamp (colons become hyphens on Windows).
        const stamp = new Date().toISOString().replace(/[:.]/g, "-");
        const backupPath = path.join(backupDir, `${name}.${stamp}.bak`);
        try { fs.writeFileSync(backupPath, previous); }
        catch (e: any) {
            console.error(`[backup] failed to write ${backupPath}: ${e.message}`);
            return; // don't block the save just because backup failed
        }
        // Prune: keep at most 10 most-recent for this filename, drop anything
        // older than 7 days. Whichever cuts more wins.
        const MAX_KEEP = 10;
        const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
        const now = Date.now();
        let entries: { path: string; mtime: number }[];
        try {
            entries = fs.readdirSync(backupDir)
                .filter(f => f.startsWith(`${name}.`) && f.endsWith(".bak"))
                .map(f => {
                    const p = path.join(backupDir, f);
                    return { path: p, mtime: fs.statSync(p).mtimeMs };
                })
                .sort((a, b) => b.mtime - a.mtime); // newest first
        } catch { return; }
        for (let i = 0; i < entries.length; i++) {
            const tooOld = now - entries[i].mtime > MAX_AGE_MS;
            const tooMany = i >= MAX_KEEP;
            if (tooOld || tooMany) {
                try { fs.unlinkSync(entries[i].path); } catch { /* */ }
            }
        }
    }

    // ── Settings ──

    getSettings(): any {
        return loadSettings();
    }

    async saveSettings(settings: any): Promise<void> {
        await saveSettings(settings);
        // Invalidate caches in both this service AND the LocalStore so
        // the next hot-path read (getMessage / refreshCalendarEvents)
        // re-reads the freshly-written file. Without this, a client save
        // followed quickly by a getCalendarEvents call would still see
        // the prior cached value (toggles like `calendar.showHolidays`
        // appeared not to "take effect" until much later).
        this._settingsCache = null;
        this.localStore.invalidateConfigCaches();
    }

    getStorageInfo(): { provider: string; mode: string; cloudPath?: string; folderName?: string; folderId?: string; configDir?: string; cloudError?: string } {
        return getStorageInfo();
    }

    // ── Setup & Repair ──

    async setupAccount(name: string, email: string, password?: string): Promise<{ ok: boolean; error?: string; message?: string }> {
        if (!email) return { ok: false, error: "Email address required" };
        const domain = email.split("@")[1]?.toLowerCase() || "";
        const detected = await detectEmailProvider(domain);
        if (detected?.cloud) {
            await initCloudConfig(detected.cloud);
        }
        // Check cloud for existing accounts — if found, just load them all
        let accounts = loadAccounts();
        if (accounts.length === 0) {
            accounts = await loadAccountsAsync();
        }
        if (accounts.length > 0) {
            // Existing accounts found on cloud — use them directly
            console.log(`  Found ${accounts.length} existing account(s) from cloud settings`);
            const settings = loadSettings();
            for (const acct of settings.accounts) {
                if (!acct.enabled) continue;
                try {
                    await this.imapManager.addAccount(acct);
                    console.log(`  Account loaded: ${acct.label || acct.name} (${acct.id})`);
                } catch (e: any) {
                    console.error(`  Account ${acct.id} error: ${e.message}`);
                }
            }
            this.imapManager.syncAll().catch(() => {});
            return { ok: true, message: `Loaded ${accounts.length} existing account(s) from cloud.` };
        }
        // No existing accounts — create new one
        const isGoogle = ["gmail.com", "googlemail.com"].includes(domain) || detected?.cloud === "gdrive";
        const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);

        const account: any = { email, name: name || email.split("@")[0] };
        if (password) account.password = password;
        if (detected && !isOAuth) {
            account.imap = { host: detected.imapHost, port: 993, tls: true, auth: detected.auth, user: email };
            account.smtp = { host: detected.smtpHost, port: 587, tls: true, auth: detected.auth, user: email };
        }
        account.id = domain.split(".")[0] || "account";

        // Save provisional account so addAccount can register it. Cloud failures
        // surface via onCloudError listeners (UI banner) — don't fail setup itself.
        try {
            await saveAccounts([account]);
        } catch (e: any) {
            console.error(`  [setup] saveAccounts failed: ${e.message}`);
            return { ok: false, error: `Failed to save account: ${e.message}` };
        }
        // Re-read normalized settings and register
        let settings = loadSettings();
        for (const acct of settings.accounts) {
            if (!acct.enabled) continue;
            try {
                await this.imapManager.addAccount(acct);
                console.log(`  Account loaded: ${acct.label || acct.name} (${acct.id})`);
            } catch (e: any) {
                console.error(`  Account ${acct.id} error: ${e.message}`);
            }
        }
        // For Google accounts where the user didn't supply a name, fetch the
        // display name from the People API now that addAccount has authenticated.
        // contacts.readonly is in the Gmail OAuth scope, so the same token works.
        if (!name && isGoogle) {
            try {
                const tok = await this.imapManager.getOAuthToken(account.id);
                if (tok) {
                    const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
                    const profile = await getGoogleProfile(tok);
                    if (profile?.name && profile.name !== account.name) {
                        console.log(`  [setup] Display name from Google: ${profile.name}`);
                        account.name = profile.name;
                        // Re-save with the resolved name (best-effort; cloud errors
                        // surface via onCloudError).
                        try { await saveAccounts([account]); } catch (e: any) {
                            console.error(`  [setup] re-saveAccounts with profile name failed: ${e.message}`);
                        }
                        settings = loadSettings();
                    }
                }
            } catch (e: any) {
                console.error(`  [setup] getGoogleProfile failed: ${e.message}`);
            }
        }
        this.imapManager.syncAll().catch(() => {});
        return { ok: true, message: `${settings.accounts.length} account(s) configured and syncing.` };
    }

    async repairAccounts(): Promise<{ ok: boolean; error?: string; message?: string }> {
        const dbAccounts = this.db.getAccountConfigs();
        if (dbAccounts.length === 0) {
            return { ok: false, error: "No cached accounts in database" };
        }
        const restored: AccountConfig[] = [];
        for (const a of dbAccounts) {
            try { restored.push(JSON.parse(a.configJson)); }
            catch { /* skip corrupt */ }
        }
        if (restored.length === 0) {
            return { ok: false, error: "Could not parse cached account configs" };
        }
        await saveAccounts(restored);
        for (const acct of restored) {
            try {
                await this.imapManager.addAccount(acct);
                console.log(`  [repair] Re-registered account: ${acct.name} (${acct.id})`);
            } catch (e: any) {
                console.error(`  [repair] Failed to register ${acct.id}: ${e.message}`);
            }
        }
        this.imapManager.syncAll().catch(() => {});
        return { ok: true, message: `Restored ${restored.length} account(s) and started sync.` };
    }

    // ── Autocomplete ──

    getAutocompleteSettings(): AutocompleteSettings {
        return loadAutocomplete();
    }

    saveAutocompleteSettings(settings: AutocompleteSettings): void {
        saveAutocomplete(settings);
    }

    async autocomplete(req: AutocompleteRequest): Promise<AutocompleteResponse> {
        const acConfig = loadAutocomplete();
        if (!acConfig.enabled || acConfig.provider === "off") {
            return { suggestion: "" };
        }

        const bodyText = req.bodyText || "";
        const prompt = `You are an email writing assistant. Complete the following email naturally.\nOutput ONLY the completion text — no explanation, no greeting repeat.\nKeep it to 1-2 sentences max.\n\nTo: ${req.to || ""}\nSubject: ${req.subject || ""}\n\n${bodyText}`;

        try {
            if (acConfig.provider === "ollama") {
                const truncated = bodyText.slice(-500);
                const ollamaPrompt = prompt.replace(bodyText, truncated);
                const res = await fetch(`${acConfig.ollamaUrl}/api/generate`, {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        model: acConfig.ollamaModel,
                        prompt: ollamaPrompt,
                        stream: false,
                        options: { num_predict: acConfig.maxTokens },
                    }),
                });
                if (!res.ok) return { suggestion: "" };
                const data = await res.json() as any;
                return { suggestion: trimSuggestion(data.response || "") };
            }

            // Cloud providers: API key now lives in accounts.jsonc.keys, not
            // preferences.jsonc.autocomplete.cloudApiKey. Read it freshly per
            // call so a key edited in the config editor takes effect without
            // a service restart. Fall back to the legacy field for users who
            // haven't migrated yet.
            const aiKeys = loadKeys();
            if (acConfig.provider === "claude") {
                const apiKey = aiKeys.anthropic || acConfig.cloudApiKey;
                if (!apiKey) return { suggestion: "" };
                const res = await fetch("https://api.anthropic.com/v1/messages", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        "x-api-key": apiKey,
                        "anthropic-version": "2023-06-01",
                    },
                    body: JSON.stringify({
                        model: acConfig.cloudModel,
                        max_tokens: acConfig.maxTokens,
                        messages: [{ role: "user", content: prompt }],
                    }),
                });
                if (!res.ok) return { suggestion: "" };
                const data = await res.json() as any;
                const text = data.content?.[0]?.text || "";
                return { suggestion: trimSuggestion(text) };
            }

            if (acConfig.provider === "openai") {
                const apiKey = aiKeys.openai || acConfig.cloudApiKey;
                if (!apiKey) return { suggestion: "" };
                const res = await fetch("https://api.openai.com/v1/chat/completions", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                        "Authorization": `Bearer ${apiKey}`,
                    },
                    body: JSON.stringify({
                        model: acConfig.cloudModel,
                        max_tokens: acConfig.maxTokens,
                        messages: [
                            { role: "system", content: "You are an email writing assistant. Output ONLY the completion text." },
                            { role: "user", content: prompt },
                        ],
                    }),
                });
                if (!res.ok) return { suggestion: "" };
                const data = await res.json() as any;
                const text = data.choices?.[0]?.message?.content || "";
                return { suggestion: trimSuggestion(text) };
            }
        } catch (e: any) {
            console.error(`  [autocomplete] ${acConfig.provider} error: ${e.message}`);
        }

        return { suggestion: "" };
    }

    /** Generic AI text transform — translate / proofread / summarize.
     *  Shares the autocomplete provider config (provider, key, model). Each
     *  feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
     *  default false. Returns empty text + reason when disabled or on error. */
    async aiTransform(req: AiTransformRequest): Promise<AiTransformResponse> {
        const cfg = loadAutocomplete();
        if (cfg.provider === "off") return { text: "", reason: "AI provider not configured" };

        const featureGate: Record<string, boolean | undefined> = {
            translate: cfg.translateEnabled,
            proofread: cfg.proofreadEnabled,
            summarize: cfg.proofreadEnabled, // bundled with proofread for now
            // extractEvent is gated on the same flag so the user has one
            // toggle for "AI features that send your text to the cloud".
            // Local-Ollama users get it for free without changing the flag.
            extractEvent: cfg.proofreadEnabled,
        };
        if (!featureGate[req.action]) return { text: "", reason: `AI ${req.action} disabled in settings` };

        const text = (req.text || "").slice(0, 8000); // sanity cap
        if (!text.trim()) return { text: "", reason: "no input" };

        const target = req.targetLang || "en";
        const nowISO = req.nowISO || new Date().toISOString();
        let systemPrompt: string;
        let userPrompt: string;
        switch (req.action) {
            case "translate":
                systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
                userPrompt = text;
                break;
            case "proofread":
                systemPrompt = `You are an editor. Return the user's text with grammar, spelling, and clarity fixed. Preserve voice and meaning. Output ONLY the corrected text, no explanation.`;
                userPrompt = text;
                break;
            case "summarize":
                systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
                userPrompt = text;
                break;
            case "extractEvent":
                // Strict JSON-only response — anything else makes the client
                // throw on JSON.parse. Today's date is supplied so the model
                // can resolve "tomorrow"/"next Tuesday" consistently with the
                // user's wall clock (the service may not share it).
                systemPrompt = `You parse natural-language event descriptions into structured calendar events. Today is ${nowISO.slice(0, 10)} (current time ${nowISO}). Output ONLY a JSON object with these fields, no prose, no markdown fences:
  - title (string, required) — short event name
  - startISO (string) — ISO 8601 local time, e.g. "2026-05-06T12:00:00". Omit if no time mentioned.
  - endISO (string) — ISO 8601 local time. If duration is unstated, default to 1 hour after start.
  - allDay (boolean) — true when no time-of-day was given.
  - location (string) — venue / address. Omit if absent.
  - notes (string) — extra detail not captured in title. Omit if absent.
Resolve relative dates ("tomorrow", "next Friday") against today's date. If no year is given, use the next future occurrence. If only a date is given (no time), set allDay=true and omit endISO.`;
                userPrompt = text;
                break;
        }

        // Get the raw text from the active provider, then post-process for
        // structured-output actions (extractEvent → JSON.parse). Single
        // provider-call site so the three branches stay close together.
        let rawText = "";
        let reason = "";
        try {
            if (cfg.provider === "ollama") {
                const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({
                        model: cfg.ollamaModel,
                        prompt: `${systemPrompt}\n\n${userPrompt}`,
                        stream: false,
                        options: { num_predict: 1024 },
                    }),
                });
                if (!res.ok) return { text: "", reason: `ollama ${res.status}` };
                const data = await res.json() as any;
                rawText = (data.response || "").trim();
            } else {
                // Cloud providers: read API keys from accounts.jsonc,
                // fall back to the legacy preferences.cloudApiKey field.
                const aiKeys = loadKeys();
                if (cfg.provider === "claude") {
                    const apiKey = aiKeys.anthropic || cfg.cloudApiKey;
                    if (!apiKey) return { text: "", reason: "no Anthropic API key in accounts.jsonc keys" };
                    const res = await fetch("https://api.anthropic.com/v1/messages", {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "x-api-key": apiKey,
                            "anthropic-version": "2023-06-01",
                        },
                        body: JSON.stringify({
                            model: cfg.cloudModel,
                            max_tokens: 2048,
                            system: systemPrompt,
                            messages: [{ role: "user", content: userPrompt }],
                        }),
                    });
                    if (!res.ok) return { text: "", reason: `claude ${res.status}` };
                    const data = await res.json() as any;
                    rawText = (data.content?.[0]?.text || "").trim();
                } else if (cfg.provider === "openai") {
                    const apiKey = aiKeys.openai || cfg.cloudApiKey;
                    if (!apiKey) return { text: "", reason: "no OpenAI API key in accounts.jsonc keys" };
                    const res = await fetch("https://api.openai.com/v1/chat/completions", {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "Authorization": `Bearer ${apiKey}`,
                        },
                        body: JSON.stringify({
                            model: cfg.cloudModel,
                            max_tokens: 2048,
                            messages: [
                                { role: "system", content: systemPrompt },
                                { role: "user", content: userPrompt },
                            ],
                        }),
                    });
                    if (!res.ok) return { text: "", reason: `openai ${res.status}` };
                    const data = await res.json() as any;
                    rawText = (data.choices?.[0]?.message?.content || "").trim();
                } else {
                    return { text: "", reason: "no provider matched" };
                }
            }
        } catch (e: any) {
            console.error(`  [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
            return { text: "", reason: e.message };
        }

        if (req.action !== "extractEvent") return { text: rawText, reason };

        // extractEvent post-processing: pull a JSON object out of rawText.
        // Models sometimes wrap output in ```json fences or add a leading
        // sentence even when told not to. Locate the first { ... } block
        // and parse that. Empty event with reason "no JSON" lets the UI
        // fall back to "use the raw description as the event title".
        const match = rawText.match(/\{[\s\S]*\}/);
        if (!match) return { text: rawText, reason: "AI did not return JSON" };
        try {
            const parsed = JSON.parse(match[0]);
            if (typeof parsed?.title !== "string" || !parsed.title.trim()) {
                return { text: rawText, reason: "AI JSON missing title" };
            }
            const ev: ExtractedEvent = {
                title: parsed.title.trim(),
                startISO: typeof parsed.startISO === "string" ? parsed.startISO : undefined,
                endISO: typeof parsed.endISO === "string" ? parsed.endISO : undefined,
                allDay: typeof parsed.allDay === "boolean" ? parsed.allDay : undefined,
                location: typeof parsed.location === "string" ? parsed.location : undefined,
                notes: typeof parsed.notes === "string" ? parsed.notes : undefined,
            };
            return { text: JSON.stringify(ev), event: ev };
        } catch (e: any) {
            return { text: rawText, reason: `AI JSON parse failed: ${e.message}` };
        }
    }

    // ── MailxApi contract aliases ─────────────────────────────────────
    // The IPC method names (used in jsonrpc.ts and on the wire) sometimes
    // differ from the internal method names that grew up around the
    // implementation. To satisfy `implements MailxApi` without renaming
    // dozens of call sites, declare thin aliases here. NEW methods should
    // be named to match the IPC surface so this section doesn't grow.

    sendMessage(msg: any): Promise<void> { return this.send(msg); }
    searchMessages(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number, includeTrashSpam?: boolean): Promise<any> {
        return this.search(q, page, pageSize, scope, accountId, folderId, includeTrashSpam);
    }
    async createCalendarEvent(ev: any): Promise<{ uuid: string }> { return { uuid: await this.createCalendarEventLocal(ev) }; }
    async updateCalendarEvent(uuid: string, patch: any): Promise<{ ok: true }> { await this.updateCalendarEventLocal(uuid, patch); return { ok: true }; }
    async deleteCalendarEvent(uuid: string): Promise<{ ok: true }> { await this.deleteCalendarEventLocal(uuid); return { ok: true }; }
    async createTask(t: { title: string; notes?: string; dueMs?: number }): Promise<{ uuid: string }> { return { uuid: await this.createTaskLocal(t) }; }
    async updateTask(uuid: string, patch: any): Promise<{ ok: true }> { await this.updateTaskLocal(uuid, patch); return { ok: true }; }
    async deleteTask(uuid: string): Promise<{ ok: true }> { await this.deleteTaskLocal(uuid); return { ok: true }; }
    saveSettingsData(settings: any): Promise<void> { return this.saveSettings(settings); }
}

/** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
function trimSuggestion(text: string): string {
    let s = text.trim();
    if (!s) return "";
    // Cap at 2 sentences
    const sentences = s.match(/[^.!?]*[.!?]/g);
    if (sentences && sentences.length > 2) {
        s = sentences.slice(0, 2).join("").trim();
    }
    return s;
}
