#!/usr/bin/env node
/**
 * rmfmail -- email client
 *
 * Flag style: single-dash is canonical (-kill, -setup, ...). The parser also
 * accepts double-dash (--kill, --setup) as a synonym for every flag — old
 * scripts that used --server / --email / etc. keep working.
 *
 * Launch:
 *   rmfmail                 Start daemon + open WebView (IPC, no TCP)
 *   rmfmail -another         Don't replace any existing rmfmail daemon
 *   rmfmail -verbose         Echo logs to terminal (default: log file only)
 *   rmfmail -debug-server    Launch debug introspection server
 *
 * Commands (exit immediately):
 *   rmfmail -kill            Kill running rmfmail daemon + WebView
 *   rmfmail -v / -version    Show version and exit
 *   rmfmail -setup           Interactive first-time account setup (CLI)
 *   rmfmail -add             Add another account (CLI)
 *   rmfmail -test            Test IMAP/SMTP connectivity for all accounts
 *   rmfmail -rebuild         WIPE local DB + .eml files, then re-sync from IMAP
 *                            (slow; first-sync caps at 200 newest per folder)
 *   rmfmail -repair          DELETE DB rows, KEEP .eml files, re-sync from IMAP
 *                            (subject/encoding fixes; same 200-newest cap)
 *   rmfmail -recover         DELETE bobma DB rows, REBUILD index from .eml files
 *                            on disk (no network; recovers everything you had)
 *   rmfmail -reauth          Clear cached OAuth tokens; next start re-consents
 *                            (use when new Google scopes have been added)
 *   rmfmail -log             Print log file path and exit
 *   rmfmail -email <addr>    First-time setup with this email (skips prompt)
 *   rmfmail -import <file>   Import accounts.jsonc into GDrive and merge
 *   rmfmail -send <file>     Enqueue a .ltr/.eml for sending — daemon auto-routes
 *                            by parsing the file's `From:` header against
 *                            configured accounts. Falls back to the first
 *                            "override" account (non-known-provider domain).
 *   rmfmail -send <file> -account <id>
 *                            Same, but force the account (skips auto-routing).
 *
 * Mailto handler (Windows):
 *   rmfmail -register-mailto    Register as the OS-level mailto: handler so
 *                               rmfmail appears in the "Open this 'mailto'
 *                               link" picker (Settings → Default apps to set
 *                               it as the active default).
 *   rmfmail -unregister-mailto  Remove the mailto: handler registration.
 *   rmfmail -mailto <url>       (internal) Decode a mailto URL and open compose.
 *
 * Internal:
 *   rmfmail -daemon          (internal) marker for the detached daemon respawn.
 *   rmfmail -allow-elevated  Bypass the admin/root refusal (debug only).
 */

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import net from "node:net";
import { execSync } from "node:child_process";
import { ports } from "@bobfrankston/miscinfo";
import { showMessageBox, showMessageBoxEx, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";

// Guard against an unsupported Node at launch (e.g. Node downgraded after
// install). Floor 24.2.0: node:sqlite must work flag-free, and the code
// targets import.meta.main. Without this the user gets a cryptic crash.
{
    const [maj, min] = process.versions.node.split(".").map(Number);
    if (maj < 24 || (maj === 24 && min < 2)) {
        console.error(`rmfmail requires Node.js >= 24.2.0 (found ${process.version}). Please upgrade Node.`);
        process.exit(1);
    }
}

setAppName("rmfmail");
// Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
// icon resource of the pinned exe, or a Windows icon resource referenced
// via PKEY_AppUserModel_RelaunchIconResource — PNG can't play either role).
// Fall back to PNG for the in-window / tao-level icon on non-Windows.
{
    const icoPath = path.resolve(import.meta.dirname, "..", "client", "icon.ico");
    const pngPath = path.resolve(import.meta.dirname, "..", "client", "icon.png");
    setAppIcon(fs.existsSync(icoPath) ? icoPath : pngPath);
}
const PORT = ports.mailx;
const args = process.argv.slice(2);

// Normalize: accept both -flag and --flag, with or without an `=value` suffix.
function hasFlag(name: string): boolean {
    return args.some(a =>
        a === `-${name}` || a === `--${name}` ||
        a.startsWith(`-${name}=`) || a.startsWith(`--${name}=`));
}
/** Value of a flag given as `--flag=value` or `--flag value`. Returns
 *  undefined when the flag is absent or has no value. */
function getFlagValue(name: string): string | undefined {
    for (const form of [`--${name}`, `-${name}`]) {
        const eq = args.find(a => a.startsWith(`${form}=`));
        if (eq) return eq.slice(form.length + 1);
        const idx = args.indexOf(form);
        if (idx >= 0) {
            const next = args[idx + 1];
            if (next && !next.startsWith("-")) return next;
        }
    }
    return undefined;
}

const verbose = hasFlag("verbose");
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process

// ── mailto: handler hooks (P115) ──
//
// Three CLI entry points feed the OS-level mailto integration:
//   --mailto <url>            Decode the URL, drop pending-mailto.json, then
//                             continue to the existing instance check —
//                             which exits if a daemon is already running
//                             (the running daemon picks the file up via
//                             fs.watch) or falls through to spawn one.
//   --register-mailto         Write Windows registry keys (HKCU\SOFTWARE\Classes)
//                             so click-to-mail flows in browsers / Word /
//                             Outlook resolve to mailx. No-op on non-Windows.
//   --unregister-mailto       Remove those registry keys.
//
// The decode step is done FIRST (before the version-mismatch instance check
// near line 104) so the file is on disk regardless of which startup branch
// is about to run.
{
    const mailtoIdx = args.findIndex(a => a === "--mailto" || a === "-mailto");
    if (mailtoIdx !== -1) {
        const url = args[mailtoIdx + 1];
        if (!url) {
            console.error("Usage: rmfmail --mailto <mailto:url>");
            process.exit(1);
        }
        try {
            const { parseMailto, writePendingMailto } = await import("./mailto.js");
            writePendingMailto(parseMailto(url));
            // Strip --mailto and its argument from argv so the rest of the
            // file (instance check, auto-detach, daemon startup) sees a
            // clean command line equivalent to a bare `mailx`.
            args.splice(mailtoIdx, 2);
            process.argv.splice(mailtoIdx + 2, 2);
        } catch (e: any) {
            console.error(`rmfmail --mailto: ${e.message}`);
            process.exit(1);
        }
    }
}

// -send <file> [-account <id>]: enqueue a .ltr/.eml from disk for sending.
// Just drops the file into `~/.rmfmail/outbox/` (or `~/.rmfmail/outbox/<id>/`
// when -account is given) and exits. The running daemon's outbox sweep picks
// it up within 10 s; if the daemon isn't running, the file queues until next
// launch. Routing (auto-account from `From:` header) is handled by
// ImapManager.routeGeneralOutbox() inside the daemon — keeps the CLI thin.
{
    const sendIdx = args.findIndex(a => a === "-send" || a === "--send");
    if (sendIdx !== -1) {
        const file = args[sendIdx + 1];
        if (!file) {
            console.error("Usage: rmfmail -send <file> [-account <accountId>]");
            process.exit(1);
        }
        if (!fs.existsSync(file)) {
            console.error(`rmfmail -send: file not found: ${file}`);
            process.exit(1);
        }
        const acctIdx = args.findIndex(a => a === "-account" || a === "--account");
        const accountId = acctIdx !== -1 ? args[acctIdx + 1] : "";
        const { getConfigDir } = await import("@bobfrankston/mailx-settings");
        const outboxRoot = path.join(getConfigDir(), "outbox");
        const targetDir = accountId ? path.join(outboxRoot, accountId) : outboxRoot;
        fs.mkdirSync(targetDir, { recursive: true });
        const now = new Date();
        const pad2 = (n: number) => String(n).padStart(2, "0");
        const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
        const target = path.join(targetDir, ts);
        fs.copyFileSync(file, target);
        if (accountId) {
            console.log(`Queued ${path.basename(file)} → outbox/${accountId}/${ts}`);
        } else {
            console.log(`Queued ${path.basename(file)} → outbox/${ts} (daemon will auto-route by From: header)`);
        }
        console.log("Outbox sweep picks it up within 10 s (start rmfmail if it isn't running).");
        process.exit(0);
    }
}

if (hasFlag("register-mailto") || hasFlag("unregister-mailto")) {
    const unregister = hasFlag("unregister-mailto");
    // C124: Linux registration — a .desktop entry claiming the mailto:
    // scheme + xdg-mime default. No exe-launcher needed (unlike Windows):
    // Linux pickers read Name= from the .desktop file directly.
    if (process.platform === "linux") {
        const appsDir = path.join(os.homedir(), ".local", "share", "applications");
        const desktopPath = path.join(appsDir, "rmfmail.desktop");
        if (unregister) {
            try { fs.unlinkSync(desktopPath); } catch { /* not registered */ }
            try { execSync(`update-desktop-database "${appsDir}"`); } catch { /* optional tool */ }
            console.log("Unregistered rmfmail mailto: handler.");
            process.exit(0);
        }
        const __mailtoJs = path.join(import.meta.dirname, "mailx.js");
        const __iconPng = path.join(import.meta.dirname, "..", "client", "icon.png");
        // Exec quoting follows the Desktop Entry spec: quoted absolute paths,
        // bare %u field code (quoting %u breaks argument expansion).
        const desktopEntry = [
            "[Desktop Entry]",
            "Type=Application",
            "Name=rmfmail",
            "Comment=rmfmail email client",
            `Exec="${process.execPath}" "${__mailtoJs}" --mailto %u`,
            `Icon=${__iconPng}`,
            "Terminal=false",
            "MimeType=x-scheme-handler/mailto;",
            "Categories=Network;Email;",
            "",
        ].join("\n");
        fs.mkdirSync(appsDir, { recursive: true });
        fs.writeFileSync(desktopPath, desktopEntry);
        try {
            execSync("xdg-mime default rmfmail.desktop x-scheme-handler/mailto");
        } catch (e: any) {
            console.error(`xdg-mime failed (${e.message}) — handler installed but not set as default. Set it in your DE's default-apps settings.`);
        }
        try { execSync(`update-desktop-database "${appsDir}"`); } catch { /* optional tool */ }
        console.log(`Registered rmfmail as mailto: handler (${desktopPath}).`);
        process.exit(0);
    }
    if (process.platform !== "win32") {
        // macOS needs a real .app bundle with CFBundleURLTypes — LaunchServices
        // won't register a bare script. Tracked as the macOS half of C124.
        console.error("--register-mailto / --unregister-mailto: macOS registration not implemented yet (needs an .app bundle — TODO C124).");
        process.exit(1);
    }
    // Registered command points at the rmfmailto.exe launcher (per-app
    // binary at %LOCALAPPDATA%\rmfmail\bin\rmfmailto.exe). The exe has its
    // own VERSIONINFO with FileDescription="rmfmail" so the Win11 "Select
    // an app" picker shows "rmfmail" instead of falling through to the
    // host EXE's name (which made the picker show "Node.js JavaScript
    // Runtime" when we registered `node.exe ... mailx.js --mailto "%1"`
    // directly). rmfmailto.exe just shells out to node + mailx.js with the
    // same argv. Postinstall copies the binary from `bin/rmfmailto.exe`
    // (shipped in the npm package) to the per-app location below.
    const __perAppBin = path.join(process.env.LOCALAPPDATA || "", "rmfmail", "bin");
    const __rmfmailtoExe = path.join(__perAppBin, "rmfmailto.exe");
    const __pkgRmfmailtoExe = path.join(import.meta.dirname, "rmfmailto.exe");
    if (fs.existsSync(__pkgRmfmailtoExe) && (!fs.existsSync(__rmfmailtoExe) ||
        fs.statSync(__pkgRmfmailtoExe).mtimeMs > fs.statSync(__rmfmailtoExe).mtimeMs)) {
        try {
            fs.mkdirSync(__perAppBin, { recursive: true });
            fs.copyFileSync(__pkgRmfmailtoExe, __rmfmailtoExe);
            console.log(`Installed rmfmailto.exe to ${__rmfmailtoExe}`);
        } catch (e: any) {
            console.error(`Failed to copy rmfmailto.exe to per-app dir: ${e.message}`);
            // Fall through and try registering the in-package path so
            // mailto still works, just from inside node_modules.
        }
    }
    const __launcherExe = fs.existsSync(__rmfmailtoExe) ? __rmfmailtoExe : __pkgRmfmailtoExe;
    if (!fs.existsSync(__launcherExe)) {
        console.error(`rmfmailto.exe not found at ${__launcherExe} — package may have been built without it. Re-run 'npm run build' on the build machine.`);
        process.exit(1);
    }
    // reg.exe value for `\"path\" \"%1\"` — double-backslash escapes for
    // cmd.exe's shell layer, which strips one level before the value reaches
    // reg.exe. Quoting %1 keeps `?` and `&` from being eaten by cmd before
    // the EXE's argv layer.
    const cmd = `\\"${__launcherExe}\\" \\"%1\\"`;
    const keys: Array<[string, string, string]> = [
        // [hive\\path, value-name, value-data]
        [`HKCU\\Software\\Classes\\rmfmail\\shell\\open\\command`, "", cmd],
        [`HKCU\\Software\\Classes\\rmfmail\\DefaultIcon`,         "", `\\"${path.join(import.meta.dirname, "..", "client", "icon.ico")}\\",0`],
        [`HKCU\\Software\\Classes\\rmfmail`,                       "URL Protocol", ""],
        [`HKCU\\Software\\Classes\\rmfmail`,                       "",             "URL:rmfmail Protocol"],
        // Make rmfmail visible in Settings → Default apps → Email so the
        // user can flip it to default with one click.
        [`HKCU\\Software\\Clients\\Mail\\rmfmail`,                                       "", "rmfmail"],
        [`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities`,                         "ApplicationName", "rmfmail"],
        [`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities`,                         "ApplicationDescription", "Local-first email client"],
        [`HKCU\\Software\\Clients\\Mail\\rmfmail\\Capabilities\\URLAssociations`,        "mailto", "rmfmail"],
        [`HKCU\\Software\\RegisteredApplications`,                                        "rmfmail", "Software\\Clients\\Mail\\rmfmail\\Capabilities"],
        // Belt-and-suspenders for the Win11 picker's display-name lookup:
        // some code paths read `HKCU\Software\Classes\Applications\<exe>`
        // for the FriendlyAppName even when the registered ProgId already
        // has Capabilities\ApplicationName set. Setting both makes the
        // picker show "rmfmail" regardless of which path it follows.
        [`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe`, "FriendlyAppName", "rmfmail"],
        [`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe`, "ApplicationName", "rmfmail"],
        [`HKCU\\Software\\Classes\\Applications\\rmfmailto.exe\\shell\\open\\command`, "", cmd],
    ];
    if (unregister) {
        // Best-effort recursive deletes. /f suppresses the y/n prompt.
        for (const root of [
            "HKCU\\Software\\Classes\\rmfmail",
            "HKCU\\Software\\Clients\\Mail\\rmfmail",
            "HKCU\\Software\\Classes\\Applications\\rmfmailto.exe",
        ]) {
            try { execSync(`reg delete "${root}" /f`, { stdio: "pipe" }); }
            catch { /* not present — fine */ }
        }
        try { execSync(`reg delete "HKCU\\Software\\RegisteredApplications" /v rmfmail /f`, { stdio: "pipe" }); }
        catch { /* */ }
        // Defensive cleanup of leftover Applications\node.exe / mailx.exe
        // registrations from before rmfmailto.exe existed. The Win11 picker
        // shows "Node.js JavaScript Runtime" while these are present, even
        // though the new rmfmailto.exe keys are correct. We only delete
        // the shell\open\command subkey if its data looks rmfmail/mailx-
        // related; never wipe the entire `Applications\node.exe` key —
        // other Node tools register there too.
        for (const oldExe of ["node.exe", "mailx.exe", "rmfmail.exe"]) {
            const key = `HKCU\\Software\\Classes\\Applications\\${oldExe}\\shell\\open\\command`;
            try {
                const out = execSync(`reg query "${key}" /ve`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
                if (/mailx\.js|rmfmail/i.test(out)) {
                    try { execSync(`reg delete "${key}" /f`, { stdio: "pipe" }); console.log(`  cleaned leftover ${oldExe} mailto handler`); }
                    catch { /* */ }
                }
            } catch { /* not present — fine */ }
        }
        console.log("rmfmail unregistered as a mailto: handler.");
        console.log("Note: if rmfmail was the active default in Settings → Default apps → Email,");
        console.log("Windows may keep the prior selection in UserChoice until you pick another app.");
        process.exit(0);
    }
    // Prepass: same defensive cleanup as --unregister, restricted to
    // rmfmail/mailx-related leftover keys. Without this, registering on
    // top of an old install leaves "Node.js JavaScript Runtime" winning
    // the picker even though our new keys point at rmfmailto.exe.
    for (const oldExe of ["node.exe", "mailx.exe", "rmfmail.exe"]) {
        const key = `HKCU\\Software\\Classes\\Applications\\${oldExe}\\shell\\open\\command`;
        try {
            const out = execSync(`reg query "${key}" /ve`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
            if (/mailx\.js|rmfmail/i.test(out)) {
                try { execSync(`reg delete "${key}" /f`, { stdio: "pipe" }); console.log(`  cleaned leftover ${oldExe} mailto handler`); }
                catch { /* */ }
            }
        } catch { /* not present — fine */ }
    }
    for (const [keyPath, name, data] of keys) {
        const valueArg = name ? `/v "${name}"` : "/ve";
        const dataArg = data ? `/d "${data}"` : "";
        const type = data ? "/t REG_SZ" : "/t REG_SZ /d \"\"";
        const cmdLine = `reg add "${keyPath}" ${valueArg} ${type} ${dataArg} /f`;
        try { execSync(cmdLine, { stdio: "pipe" }); }
        catch (e: any) { console.error(`  failed: ${cmdLine}\n    ${e.message}`); }
    }
    console.log("rmfmail registered as a mailto: handler.");
    console.log("");
    console.log("To make it the default in Windows 11:");
    console.log("  Settings → Apps → Default apps → search \"rmfmail\" → click rmfmail");
    console.log("  → \"Set default\" for the mailto link type.");
    console.log("");
    console.log("Win11 enforces a hash on `HKCU\\...\\UrlAssociations\\mailto\\UserChoice`,");
    console.log("so the only programmatic ways to flip the active default are MSIX packaging");
    console.log("(parked under C46) or a hash-aware tool like SetUserFTA. The picker may");
    console.log("warn \"app must be from the Store\" — click \"More options\" / \"Choose another");
    console.log("app on this PC\" to reach rmfmail.");
    process.exit(0);
}

// Read our own version once — used for the instance file + upgrade check below.
const __selfRoot = path.join(import.meta.dirname, "..");
const __selfVersion: string = (() => {
    try { return JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version || "unknown"; }
    catch { return "unknown"; }
})();
const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail", "instance.json");

interface InstanceFile {
    pid: number;
    version: string;
    startedAt: number;
    /** PIDs of children spawned by this daemon: msger WebView host,
     *  reminder popups, anything else we launched. Tracked so `-kill`
     *  can SIGTERM them too without resorting to name-based sweeps. */
    childPids?: number[];
}
function readInstanceFile(): InstanceFile | null {
    try {
        const raw = fs.readFileSync(__instanceFile, "utf-8");
        const inst = JSON.parse(raw);
        if (typeof inst.pid === "number" && typeof inst.version === "string") return inst;
    } catch { /* missing or unreadable — treated as no instance */ }
    return null;
}
function writeInstanceFile(pid: number, childPids: number[] = []): void {
    try {
        fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
        const payload: InstanceFile = { pid, version: __selfVersion, startedAt: Date.now(), childPids };
        fs.writeFileSync(__instanceFile, JSON.stringify(payload, null, 2));
    } catch { /* non-fatal */ }
}
/** Add a child PID to the daemon's instance.json so a later `-kill`
 *  knows to reap it. Caller is expected to remove it (via
 *  removeChildPid) when the child exits naturally. */
function addChildPid(pid: number): void {
    const inst = readInstanceFile();
    if (!inst) return;
    const set = new Set(inst.childPids || []);
    set.add(pid);
    writeInstanceFile(inst.pid, [...set]);
}
function removeChildPid(pid: number): void {
    const inst = readInstanceFile();
    if (!inst) return;
    const next = (inst.childPids || []).filter(p => p !== pid);
    writeInstanceFile(inst.pid, next);
}
function clearInstanceFile(): void {
    try { fs.unlinkSync(__instanceFile); } catch { /* ignore */ }
}
function pidAlive(pid: number): boolean {
    try { process.kill(pid, 0); return true; } catch { return false; }
}
/** True only if `pid` is alive AND its command line looks like a rmfmail node
 *  process. Windows reuses PIDs aggressively — a rmfmail that exited without
 *  running its cleanup handler can leave behind an instance.json whose PID
 *  has since been recycled by some other app (e.g. Creative Cloud UI Helper),
 *  making bare pidAlive() return true and wedging every subsequent `mailx`
 *  with "already running". Verify it's actually us. Non-Windows: fall back
 *  to pidAlive — POSIX PID reuse is rare enough that we don't bother. */
function pidIsMailx(pid: number): boolean {
    // Cross-platform: trust instance.json as the source of identity.
    // We registered this PID at startup; if it's still alive, treat it
    // as ours. Earlier Windows-only check used PowerShell + CimInstance
    // to grep the command line, which violated the cross-platform rule
    // (see memory: feedback_cross_platform_strong_rule). The downside is
    // a brief PID-reuse window after a crash: if Windows recycles the
    // PID between crash and the next launch, our SIGTERM hits an
    // unrelated process. Mitigation: daemon clears instance.json on
    // graceful exit; stale-instance file only persists across hard
    // crashes, which is rare.
    return pidAlive(pid);
}

// Version-mismatch upgrade: if a daemon from an older version is running when
// the user types `mailx`, kill it so the new one can take over. Without this,
// a second invocation would silently no-op (daemon exists), leaving the user
// on an old UI with no indication that the install has been upgraded.
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
// the internal --daemon respawn.
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "relink-bodies", "import", "log", "reauth"];
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
// `--another` opts out of the replace-on-launch sweep so the user can run
// multiple rmfmail instances side by side (testing, parallel sessions, etc.).
// Without it, every launch kills any existing rmfmail daemon — including
// ones started earlier with --another, since we don't tag them.
const __keepOthers = hasFlag("another");
if (!isDaemon && !__isCommandInvocation && !__keepOthers) {
    // Replace-on-launch: any rmfmail daemon already running gets killed and
    // the new one takes over. instance.json only tracks ONE PID — older or
    // orphaned daemons can survive a partial upgrade and leave the user with
    // multiple windows on different versions. Sweep PowerShell for any
    // rmfmail-looking node process and kill it (unconditionally — version
    // match alone isn't enough; we always want exactly one daemon).
    const myPid = process.pid;
    const killedPids: number[] = [];
    const inst = readInstanceFile();
    if (inst && pidIsMailx(inst.pid) && inst.pid !== myPid) {
        try { process.kill(inst.pid, "SIGTERM"); killedPids.push(inst.pid); } catch { /* */ }
    }
    // Replace-on-launch trusts the registered PID in instance.json. The
    // graceful-shutdown handler in the prior daemon (this same file, below)
    // is responsible for closing its own child msger/popup processes via
    // handle.close() — name-based sweeps over `node.exe` / `msgernative.exe`
    // / per-app exes are fragile and ALSO not portable (taskkill is
    // Windows-only). If a daemon crashed without graceful shutdown, the
    // instance file gets cleared next launch via the stale-PID check and
    // the orphan stays until the user resolves it manually — preferable to
    // a sweep that risks killing an unrelated `mailx` / `rmfmail` process
    // owned by a different user (e.g., a developer running it from a
    // sibling worktree).
    // Brief wait + SIGKILL anything that didn't exit gracefully.
    if (killedPids.length) {
        const deadline = Date.now() + 2000;
        while (Date.now() < deadline && killedPids.some(p => pidAlive(p))) {
            const sab = new SharedArrayBuffer(4);
            Atomics.wait(new Int32Array(sab), 0, 0, 100);
        }
        for (const pid of killedPids) {
            if (pidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* */ } }
        }
        console.log(`rmfmail: replaced ${killedPids.length} prior daemon${killedPids.length === 1 ? "" : "s"} (PIDs ${killedPids.join(", ")})`);
        clearInstanceFile();
    } else if (inst) {
        // Stale instance file — PID is dead. Clean up.
        clearInstanceFile();
    }
}

// Auto-detach: re-spawn as background process so terminal returns immediately
// Skip for: --verbose (want console), --daemon (already detached),
// and any command flags (setup, kill, test, etc.)
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
    const { spawn } = await import("node:child_process");
    const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
        // windowsHide on the spawn options below — prevents the brief
        // console-window flash when the daemon launches.
        detached: true,
        stdio: "ignore",
        windowsHide: true,
    });
    child.unref();
    process.exit(0);
}

const setupMode = hasFlag("setup");
const addMode = hasFlag("add");
const testMode = hasFlag("test");
const rebuildMode = hasFlag("rebuild");
const repairMode = hasFlag("repair");
const recoverMode = hasFlag("recover");
const importMode = hasFlag("import");

// Validate arguments
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "relink-bodies", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated", "another", "no-browser", "debug-server", "send", "account"];
for (const arg of args) {
    // Strip a leading -/-- and any `=value` suffix before checking.
    const flag = arg.replace(/^--?/, "").split("=")[0];
    if (arg.startsWith("-") && !knownFlags.includes(flag)) {
        console.error(`Unknown option: ${arg}`);
        console.error(
            "Usage: rmfmail [options]   (-- prefix also accepted for every flag)\n" +
            "  Launch:\n" +
            "    (no args)              Start daemon + open WebView (IPC, no TCP)\n" +
            "    -no-browser            Server mode without auto-opening a browser\n" +
            "    -another               Don't replace any existing rmfmail daemon\n" +
            "    -verbose               Echo logs to terminal (default: log file only)\n" +
            "    -debug-server          Launch debug introspection server\n" +
            "  Commands (exit immediately):\n" +
            "    -kill                  Kill running rmfmail daemon + WebView\n" +
            "    -v / -version          Print version and exit\n" +
            "    -setup                 Interactive first-time account setup (CLI)\n" +
            "    -add                   Add another account (CLI)\n" +
            "    -test                  Test IMAP/SMTP connectivity for all accounts\n" +
            "    -rebuild               WIPE DB + .eml files, re-sync from IMAP\n" +
            "                           (cap 200 newest per folder on first-sync)\n" +
            "    -repair                DELETE DB rows, KEEP .eml files, re-sync\n" +
            "                           (same 200-newest cap; fixes corrupt fields)\n" +
            "    -recover               REBUILD DB index from .eml files on disk\n" +
            "                           (no network; restores everything you had)\n" +
            "    -fix-flags             Reconcile local \\Flagged stars against the\n" +
            "                           IMAP server's actual SEARCH FLAGGED truth\n" +
            "                           (one-shot cleanup for the pre-1.1.172 UID-\n" +
            "                           collision bug)\n" +
            "    -reauth                Clear cached OAuth tokens; re-consent on next start\n" +
            "    -log                   Print log file path and exit\n" +
            "    -email <addr>          First-time setup with this email (skips prompt)\n" +
            "    -import <file>         Import accounts.jsonc into GDrive and merge\n" +
            "    -send <file>           Enqueue a .ltr/.eml for sending (auto-route by From:)\n" +
            "    -send <file> -account <id>  Enqueue under a specific account\n" +
            "  Mailto handler (Windows):\n" +
            "    -register-mailto       Register as the OS-level mailto: handler\n" +
            "    -unregister-mailto     Remove the mailto: handler registration\n" +
            "    -mailto <url>          (internal) Decode a mailto URL and open compose\n" +
            "  Internal:\n" +
            "    -daemon                (internal) marker for the detached daemon respawn\n" +
            "    -allow-elevated        Bypass the admin/root refusal (debug only)"
        );
        process.exit(1);
    }
}

function log(...msg: any[]): void { if (verbose) console.log("[rmfmail]", ...msg); }

/** Detect whether we're running with administrator / root privileges.
 *  Windows: `net session` requires admin — succeeds silently when elevated,
 *  errors "Access is denied" otherwise. Linux/Mac: check process uid.
 *  Returns true only when positively detected as elevated; on ambiguity
 *  (e.g. child_process spawn failed for non-privilege reasons), returns
 *  false so we don't block users on false positives. */
function isElevated(): boolean {
    try {
        if (process.platform === "win32") {
            execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
            return true;
        }
        if (typeof (process as any).getuid === "function") {
            return (process as any).getuid() === 0;
        }
    } catch { /* non-admin → net session fails */ }
    return false;
}

/** Put up a blocking warning dialog via showMessageBox. Returns the label
 *  the user clicked. The default (Quit) is first so Enter dismisses to
 *  safety. Caller decides what to do with "Continue anyway". */
async function warnElevated(): Promise<string> {
    const res = await showMessageBox({
        title: "rmfmail — elevated run not recommended",
        message:
            "rmfmail is running with Administrator privileges.\n\n" +
            "This can corrupt the per-user WebView2 profile at\n" +
            "%LOCALAPPDATA%\\msger\\webview2\\ and create admin-owned files\n" +
            "under ~/.rmfmail/ that later non-admin runs can't write to\n" +
            "(SQLite db, tokens, config).\n\n" +
            "Quit, relaunch from a normal shell, and only use admin if\n" +
            "you specifically know you need it. To bypass this warning\n" +
            "(for scripted admin use), pass --allow-elevated.",
        buttons: ["Quit", "Continue anyway"],
        size: { width: 540, height: 340 },
        escapeCloses: true,
    });
    return res.button;
}

// Kill any running rmfmail server
if (hasFlag("kill")) {
    log("Killing rmfmail processes...");
    const { execSync } = await import("node:child_process");
    let killed = 0;

    // Cross-platform kill via instance.json. Daemon writes its PID +
    // child PIDs (msger WebView, popups) at startup and on each spawn;
    // -kill reads them and SIGTERMs each. No name-based sweeps, no
    // PowerShell, no taskkill — those were Windows-only AND fragile
    // (could match unrelated processes). See memory:
    // feedback_cross_platform_strong_rule.
    const inst = readInstanceFile();
    const targets: number[] = [];
    if (inst && pidAlive(inst.pid)) targets.push(inst.pid);
    for (const cp of (inst?.childPids || [])) {
        if (pidAlive(cp)) targets.push(cp);
    }
    for (const pid of targets) {
        try {
            process.kill(pid, "SIGTERM");
            console.log(`SIGTERM → PID ${pid}`);
            killed++;
        } catch (e: any) {
            // ESRCH: stale PID, already dead. Anything else is unexpected.
            if (e?.code !== "ESRCH") console.error(`  kill ${pid}: ${e?.message || e}`);
        }
    }
    // Wait briefly for graceful shutdown, then SIGKILL anything still alive.
    if (targets.length) {
        const deadline = Date.now() + 2000;
        while (Date.now() < deadline && targets.some(p => pidAlive(p))) {
            const sab = new SharedArrayBuffer(4);
            Atomics.wait(new Int32Array(sab), 0, 0, 100);
        }
        for (const pid of targets) {
            if (pidAlive(pid)) {
                try { process.kill(pid, "SIGKILL"); console.log(`SIGKILL → PID ${pid}`); }
                catch { /* */ }
            }
        }
    }

    // Clean up stale SQLite WAL/SHM files
    const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
    for (const ext of ["mailx.db-shm", "mailx.db-wal"]) {
        const p = path.join(mailxDir, ext);
        try { fs.unlinkSync(p); log(`Cleaned ${ext}`); } catch { /* */ }
    }

    // Always clear instance.json — the daemon's exit handler does this on a
    // clean shutdown, but if it crashed (or was force-killed before the
    // handler ran) the file lingers and wedges every future `mailx` with
    // "already running" because Windows recycles PIDs aggressively. -kill
    // is the user's escape hatch; leave no lock behind.
    clearInstanceFile();

    if (killed === 0) console.log("No rmfmail processes found");
    process.exit(0);
}

// Re-auth: clear cached OAuth tokens so the next start forces a fresh
// consent flow. Needed when scopes change (e.g. Google Tasks was added
// 2026-04-23 but existing tokens were issued against the older scope
// set, so tasks API calls 403ed with "insufficient authentication
// scopes"). Safe — tokens are only a cache; fresh consent re-issues.
if (hasFlag("reauth")) {
    const { getConfigDir } = await import("@bobfrankston/mailx-settings");
    const tokensDir = path.join(getConfigDir(), "tokens");
    if (!fs.existsSync(tokensDir)) {
        console.log("No tokens directory — nothing to clear.");
        process.exit(0);
    }
    let cleared = 0;
    for (const entry of fs.readdirSync(tokensDir)) {
        const userDir = path.join(tokensDir, entry);
        try {
            const stat = fs.statSync(userDir);
            if (!stat.isDirectory()) continue;
            const tokenFile = path.join(userDir, "oauth-token.json");
            if (fs.existsSync(tokenFile)) {
                fs.unlinkSync(tokenFile);
                console.log(`  Cleared token for ${entry}`);
                cleared++;
            }
        } catch { /* skip */ }
    }
    console.log(cleared === 0
        ? "No cached tokens found."
        : `Cleared ${cleared} cached token(s). Next 'rmfmail' start will open a browser OAuth consent so the new scopes (tasks, full contacts) get granted.`);
    process.exit(0);
}

// Rebuild: wipe DB + message store, keep accounts/settings
// Stop a running daemon so a command needing EXCLUSIVE file access (-rebuild
// deletes mailx.db; -repair opens it) doesn't fail with EBUSY. Windows won't
// unlink a file the daemon still has open, and the daemon holds mailx.db for
// its whole lifetime — so -rebuild/-repair USED to fail unless the user ran
// `-kill` first (Bob 2026-06-05: "why is there a long-term lock?"). Mirrors
// replace-on-launch: SIGTERM the instance.json PID, wait, SIGKILL, clear the
// file, then a short grace for the OS to release the handle.
function stopDaemonForExclusiveAccess(): void {
    const inst = readInstanceFile();
    if (!inst || !pidIsMailx(inst.pid)) return;
    const pid = inst.pid;
    try { process.kill(pid, "SIGTERM"); } catch { /* */ }
    const deadline = Date.now() + 4000;
    while (Date.now() < deadline && pidAlive(pid)) {
        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
    }
    if (pidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* */ } }
    clearInstanceFile();
    console.log(`  Stopped running daemon (PID ${pid}) to take exclusive DB access.`);
    // Windows releases the file handle a beat after the process exits.
    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 800);
}

// Unlink with a few EBUSY retries — even after the daemon exits, an antivirus /
// indexer / OneDrive scan can briefly hold the handle. Robust beats a hard fail.
function unlinkWithRetry(p: string): void {
    for (let attempt = 0; attempt < 10; attempt++) {
        try { fs.unlinkSync(p); return; }
        catch (e: any) {
            if (e?.code !== "EBUSY" && e?.code !== "EPERM") throw e;
            if (attempt === 9) throw e;
            Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
        }
    }
}

if (rebuildMode) {
    const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const storePath = getStorePath();

    console.log("Rebuilding rmfmail local cache...");
    console.log("  Accounts and settings will be preserved.");

    // Stop the daemon first so the DB file isn't locked open (the EBUSY fix).
    stopDaemonForExclusiveAccess();

    // Remove DB files
    for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
        const p = path.join(dbDir, f);
        if (fs.existsSync(p)) { unlinkWithRetry(p); console.log(`  Deleted ${f}`); }
    }

    // Remove message store
    if (fs.existsSync(storePath)) {
        fs.rmSync(storePath, { recursive: true });
        console.log(`  Deleted message store`);
    }

    console.log("  Rebuild complete. Run 'rmfmail' to start fresh.");
    process.exit(0);
}

// Repair: re-sync metadata (subjects, flags, envelopes) without deleting stored .eml files
if (repairMode) {
    const { getConfigDir } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const dbPath = path.join(dbDir, "mailx.db");

    if (!fs.existsSync(dbPath)) {
        console.error("No database found. Run 'rmfmail' first to create one.");
        process.exit(1);
    }

    console.log("Repairing rmfmail metadata...");
    console.log("  Message bodies (.eml files) will be preserved.");
    console.log("  Clearing message metadata for re-sync...");

    // Stop the daemon first so we don't write the DB concurrently with it
    // (corruption risk) or fail to open it exclusively.
    stopDaemonForExclusiveAccess();

    // node:sqlite (built-in to Node 24+) — matches what the daemon uses via
    // mailx-store. Earlier this dynamically loaded better-sqlite3, which was
    // never a project dependency; -repair would crash with ERR_MODULE_NOT_FOUND.
    const { DatabaseSync } = await import("node:sqlite");
    const db = new DatabaseSync(dbPath);
    db.exec("PRAGMA journal_mode = WAL");

    const count = (db.prepare("SELECT COUNT(*) as cnt FROM messages").get() as any).cnt;
    db.exec("DELETE FROM messages");
    // messages_fts is a CONTENTLESS FTS5 table linked to messages by id.
    // DELETE FROM messages_fts attempts to dereference each row's content
    // columns via that linkage — and the FTS schema is out of step with
    // current messages columns (FTS declares to_text/cc_text, messages
    // has to_json/cc_json), so the dereference fails with
    // "no such column: T.to_text". Use the FTS5 "delete-all" idiom
    // which clears the index without touching content lookups.
    try {
        db.exec("INSERT INTO messages_fts(messages_fts) VALUES('delete-all')");
    } catch (e: any) {
        console.error(`  FTS clear via delete-all failed: ${e.message} — dropping + recreating FTS index`);
        db.exec("DROP TABLE IF EXISTS messages_fts");
        // Re-create with current messages columns so subsequent indexer
        // inserts succeed. Mirrors the daemon's own create on first run.
        db.exec(`CREATE VIRTUAL TABLE messages_fts USING fts5(
            subject, from_name, from_address, to_text, cc_text, body_text,
            content=messages, content_rowid=id
        )`);
    }
    // Reset per-folder sync state so IMAP re-syncs all envelopes and
    // rebuilds folder_id assignments from the server. The repair-driving
    // case (stale ghost rows for folder X carrying UIDs that belong to
    // folder Y) only clears once the server's actual UID lists are
    // re-fetched and the local rows re-inserted with correct folder_id.
    db.exec("UPDATE folders SET total_count = 0, unread_count = 0, highest_modseq = NULL");
    db.close();

    console.log(`  Cleared ${count} message entries. Folder sync state reset.`);
    console.log("  Run 'rmfmail' to re-sync from IMAP with correct encoding.");
    process.exit(0);
}

// Recover: walk .eml files on disk and rebuild DB index from them. No network.
// Use case: data loss event wiped DB rows but mailxstore .eml files survived
// (2026-05-27 UID-collision DELETE bug). Each .eml is parsed for Message-ID +
// envelope fields and inserted as a placeholder row with a negative UID in
// INBOX so the user can read/search them immediately. Subsequent IMAP sync
// rebinds these placeholders to real (folder, uid) when the server confirms
// each message — at which point the synthetic-UID row is replaced or merged
// by the existing move-detect-via-Message-ID logic.
//
// Skips messages whose Message-ID is already indexed (idempotent re-run).
if (recoverMode) {
    const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const storePath = getStorePath();
    const dbPath = path.join(dbDir, "mailx.db");
    if (!fs.existsSync(dbPath)) {
        console.error("No database found. Run 'rmfmail -setup' first.");
        process.exit(1);
    }
    if (!fs.existsSync(storePath)) {
        console.error(`No mailxstore directory found at ${storePath}.`);
        process.exit(1);
    }

    console.log("Recovering rmfmail index from .eml files on disk...");
    console.log(`  Store: ${storePath}`);
    console.log("  Local-only — no IMAP/network calls. Existing DB rows preserved.");

    const { DatabaseSync } = await import("node:sqlite");
    const db = new DatabaseSync(dbPath);
    db.exec("PRAGMA journal_mode = WAL");

    // BACKFILL existing rows that are missing a message_folders membership.
    // The unified inbox view + per-folder list view both JOIN through
    // message_folders, so a messages row with no membership is INVISIBLE
    // in the UI even though it's in the DB. This catches the pre-1.1.174
    // -recover runs that only populated messages — Bob's symptom was 8304
    // INBOX rows in the folder badge but an empty list view (2026-05-27).
    const backfilled = db.prepare(`
        INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
        SELECT m.id, m.folder_id, m.uid, m.cached_at
        FROM messages m
        WHERE NOT EXISTS (SELECT 1 FROM message_folders mf WHERE mf.message_row_id = m.id)
    `).run();
    if ((backfilled as any).changes > 0) {
        console.log(`  [backfill] inserted ${(backfilled as any).changes} missing message_folders rows`);
    }

    // Build the Message-ID set already in the DB so we can skip duplicates.
    const seenStmt = db.prepare("SELECT message_id FROM messages WHERE account_id = ? AND message_id IS NOT NULL AND message_id != ''");
    // INBOX folder per account (target for recovered rows). If no INBOX row
    // exists for an account we skip that account entirely — we have nowhere
    // to put the recovered messages and creating a folder row from a CLI is
    // out of scope here.
    const inboxStmt = db.prepare("SELECT id FROM folders WHERE account_id = ? AND (LOWER(special_use) = 'inbox' OR LOWER(path) = 'inbox') LIMIT 1");
    // Synthetic UID allocator: start well below the smallest real IMAP UID
    // and decrement, so they're distinct from existing real or synthetic UIDs.
    let synthUid = -2_000_000;
    const insertMsg = db.prepare(`
        INSERT INTO messages
            (account_id, folder_id, uid, message_id, date, subject,
             from_address, from_name, to_json, cc_json, flags_json, size,
             has_attachments, preview, body_path, cached_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
    // Insert a membership row for every messages row we create so the
    // list views (which JOIN through message_folders) can see them.
    const insertMembership = db.prepare(`
        INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
        VALUES (?, ?, ?, ?)
    `);

    // Walk every account directory under storePath.
    const accountDirs = fs.readdirSync(storePath, { withFileTypes: true })
        .filter(d => d.isDirectory())
        .map(d => d.name);
    let totalScanned = 0;
    let totalIndexed = 0;
    let totalSkipped = 0;
    for (const acct of accountDirs) {
        const inboxRow = inboxStmt.get(acct) as any;
        if (!inboxRow) {
            console.log(`  [skip] ${acct}: no INBOX folder row in DB — skipping ${fs.readdirSync(path.join(storePath, acct)).length} entries`);
            continue;
        }
        const inboxFolderId: number = inboxRow.id;
        const seen = new Set<string>();
        for (const r of seenStmt.iterate(acct) as any) {
            if (r.message_id) seen.add(String(r.message_id));
        }
        console.log(`  [${acct}] INBOX folder_id=${inboxFolderId}; already indexed: ${seen.size}`);

        const acctRoot = path.join(storePath, acct);
        let scanned = 0;
        let indexed = 0;
        let skipped = 0;

        // Iterate two-level layout: acct/<2hex>/<uuid>.eml. Also tolerate
        // flat or three-level layouts that older builds might've used.
        const walkDir = (dir: string): void => {
            for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
                const p = path.join(dir, entry.name);
                if (entry.isDirectory()) { walkDir(p); continue; }
                if (!entry.isFile() || !entry.name.endsWith(".eml")) continue;
                scanned++;
                try {
                    // Read only the headers — first ~8 KB is plenty. Faster
                    // than slurping a 100 KB body when we only need fields.
                    const fd = fs.openSync(p, "r");
                    const buf = Buffer.alloc(8192);
                    const n = fs.readSync(fd, buf, 0, 8192, 0);
                    fs.closeSync(fd);
                    const head = buf.slice(0, n).toString("utf-8");
                    // Header section ends at the first blank line. Stop there
                    // so a `Subject:` inside the body can't shadow the real one.
                    const headerEnd = head.search(/\r?\n\r?\n/);
                    const hdrText = headerEnd >= 0 ? head.slice(0, headerEnd) : head;
                    const getHdr = (name: string): string => {
                        const re = new RegExp(`^${name}:[ \\t]*(.*(?:\\r?\\n[ \\t]+.*)*)`, "im");
                        const m = hdrText.match(re);
                        return m ? m[1].replace(/\r?\n[ \t]+/g, " ").trim() : "";
                    };
                    const messageId = (getHdr("Message-ID") || getHdr("Message-Id")).replace(/^<|>$/g, "");
                    if (!messageId) { skipped++; continue; }
                    if (seen.has(messageId) || seen.has(`<${messageId}>`)) { skipped++; continue; }
                    const subject = getHdr("Subject");
                    const dateStr = getHdr("Date");
                    const fromRaw = getHdr("From");
                    const toRaw = getHdr("To");
                    const ccRaw = getHdr("Cc");
                    const date = dateStr ? (Date.parse(dateStr) || 0) : 0;
                    // Crude address split — good enough for index reconstruction;
                    // the daemon's normal upsert will overwrite with parsed data
                    // once IMAP rebinds. We just need something to display.
                    const parseAddrs = (raw: string): { name: string; address: string }[] => {
                        if (!raw) return [];
                        return raw.split(",").map(s => {
                            const m = s.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/) || s.match(/^\s*([^\s<>"]+@[^\s<>"]+)\s*$/);
                            if (!m) return { name: "", address: s.trim() };
                            return m[2] ? { name: m[1].trim(), address: m[2].trim() } : { name: "", address: m[1].trim() };
                        });
                    };
                    const fromAddrs = parseAddrs(fromRaw);
                    const from = fromAddrs[0] || { name: "", address: "" };
                    const toList = parseAddrs(toRaw);
                    const ccList = parseAddrs(ccRaw);
                    const stat = fs.statSync(p);
                    const rel = path.relative(storePath, p).replace(/\\/g, "/");
                    const thisSynthUid = synthUid--;
                    const result = insertMsg.run(
                        acct,
                        inboxFolderId,
                        thisSynthUid,
                        messageId,
                        date,
                        subject,
                        from.address,
                        from.name,
                        JSON.stringify(toList),
                        JSON.stringify(ccList),
                        "[]",                       // flags_json
                        stat.size,                  // size
                        0,                          // has_attachments — leave as 0; daemon refreshes on first view
                        "",                         // preview — empty, daemon will fill on first view
                        rel,                        // body_path (relative to storePath)
                        Date.now(),                 // cached_at
                    );
                    // Membership row — list views JOIN through this, without
                    // it the recovered row is invisible in the UI.
                    const newRowId = Number((result as any).lastInsertRowid);
                    if (newRowId > 0) {
                        insertMembership.run(newRowId, inboxFolderId, thisSynthUid, Date.now());
                    }
                    seen.add(messageId);
                    indexed++;
                    if (indexed % 500 === 0) console.log(`  [${acct}] ${indexed} indexed (${scanned} scanned)`);
                } catch (e: any) {
                    skipped++;
                    if (skipped < 5) console.error(`  [${acct}] parse error on ${p}: ${e?.message || e}`);
                }
            }
        };
        walkDir(acctRoot);
        // Refresh folder counts so the INBOX row shows the new total.
        const cnt = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE folder_id = ?").get(inboxFolderId) as any).c;
        db.prepare("UPDATE folders SET total_count = ? WHERE id = ?").run(cnt, inboxFolderId);
        console.log(`  [${acct}] done — scanned ${scanned}, indexed ${indexed}, skipped ${skipped}. INBOX total now ${cnt}.`);
        totalScanned += scanned;
        totalIndexed += indexed;
        totalSkipped += skipped;
    }
    db.close();
    console.log(`Recovery complete: indexed ${totalIndexed} of ${totalScanned} .eml files (${totalSkipped} skipped).`);
    console.log("  Recovered rows have synthetic negative UIDs and live in INBOX.");
    console.log("  Run 'rmfmail' to start the daemon — normal IMAP sync will rebind");
    console.log("  these placeholders to real (folder, uid) tuples by Message-ID.");
    process.exit(0);
}

// Fix flags: strip every \Flagged that exists ONLY in the local DB but NOT
// on the IMAP server, per folder. The pre-1.1.172 updateMessageFlags had a
// UID-without-folder bug that propagated a flag set on one folder's UID to
// every same-numeric-UID row across the account — leaving stars on hundreds
// of letters the user never starred. This is the one-shot cleanup that
// reconciles every local flag against the server's truth.
//
// What it does: per IMAP-account folder, runs `UID SEARCH FLAGGED` on the
// server, takes that as authoritative, and clears \Flagged from every local
// row whose UID isn't in the server's set.
//
// What it does NOT touch: \Seen, \Draft, or any custom flags — only the
// star (\Flagged). That keeps the cleanup scope tight to the symptom the
// user reported.
if (hasFlag("fix-flags")) {
    const { getConfigDir } = await import("@bobfrankston/mailx-settings");
    const { loadSettings } = await import("@bobfrankston/mailx-settings");
    const { NativeImapClient } = await import("@bobfrankston/iflow-direct");
    const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
    const dbDir = getConfigDir();
    const dbPath = path.join(dbDir, "mailx.db");
    if (!fs.existsSync(dbPath)) { console.error("No database found."); process.exit(1); }
    const settings = loadSettings();
    const { DatabaseSync } = await import("node:sqlite");
    const db = new DatabaseSync(dbPath);
    db.exec("PRAGMA journal_mode = WAL");
    console.log("Fixing spurious \\Flagged stars...");
    console.log("  Authoritative source: each folder's server-side UID SEARCH FLAGGED.");
    console.log("  Only \\Flagged is touched; \\Seen and others are left alone.");
    let totalCleared = 0;
    let totalFolders = 0;
    for (const acct of settings.accounts) {
        const imap: any = (acct as any).imap;
        if (!imap) { console.log(`  [skip] ${acct.id}: no IMAP config (likely Gmail-API)`); continue; }
        if (!imap.password) { console.log(`  [skip] ${acct.id}: OAuth account — fix-flags only supports password-auth IMAP for now`); continue; }
        let client: any;
        try {
            client = new (NativeImapClient as any)({
                server: imap.host, port: imap.port || 993,
                user: imap.user || acct.email, password: imap.password,
                useTls: imap.tls !== false,
            }, () => new (NodeTcpTransport as any)());
            await client.connect();
            const folderRows = db.prepare("SELECT id, path FROM folders WHERE account_id = ?").all(acct.id) as { id: number; path: string }[];
            for (const folder of folderRows) {
                try {
                    await client.select(folder.path);
                    const serverFlaggedUids: number[] = await client.search("FLAGGED");
                    const serverSet = new Set<number>(serverFlaggedUids);
                    // Pull every local row in this folder that has \Flagged
                    const stmt = db.prepare(
                        "SELECT uid, flags_json FROM messages WHERE account_id = ? AND folder_id = ? AND flags_json LIKE ?"
                    );
                    const upd = db.prepare(
                        "UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?"
                    );
                    let cleared = 0;
                    for (const r of stmt.iterate(acct.id, folder.id, "%\\\\Flagged%") as any) {
                        const uid = r.uid;
                        if (serverSet.has(uid)) continue;
                        let flags: string[] = [];
                        try { flags = JSON.parse(r.flags_json); } catch { /* */ }
                        const without = flags.filter(f => f !== "\\Flagged");
                        if (without.length !== flags.length) {
                            upd.run(JSON.stringify(without), acct.id, folder.id, uid);
                            cleared++;
                        }
                    }
                    if (cleared > 0) {
                        console.log(`  [${acct.id}] ${folder.path}: cleared ${cleared} spurious \\Flagged (server has ${serverFlaggedUids.length} actual)`);
                        totalCleared += cleared;
                    }
                    totalFolders++;
                } catch (e: any) {
                    console.error(`  [${acct.id}] ${folder.path}: ${e?.message || e}`);
                }
            }
        } catch (e: any) {
            console.error(`  [${acct.id}] connect failed: ${e?.message || e}`);
        } finally {
            try { if (client) await client.logout(); } catch { /* */ }
        }
    }
    db.close();
    console.log(`Done. Cleared ${totalCleared} spurious \\Flagged rows across ${totalFolders} folders.`);
    console.log("  Run 'rmfmail' to start the daemon.");
    process.exit(0);
}

// Relink message bodies from on-disk .eml files by Message-ID (no network).
// Heals the body_path cross-wiring from the pre-1.1.196 updateBodyMeta bug:
// the .eml files on disk are correct; only the DB pointers were wrong. Match
// each file to its row by Message-ID and repair body_path — far cheaper than
// clearing 55k pointers and re-downloading from the server (Bob 2026-05-29).
if (hasFlag("relink-bodies")) {
    const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const storePath = getStorePath();
    const dbPath = path.join(dbDir, "mailx.db");
    if (!fs.existsSync(dbPath)) { console.error("No database found."); process.exit(1); }
    if (!fs.existsSync(storePath)) { console.error(`No mailxstore at ${storePath}.`); process.exit(1); }
    const { DatabaseSync } = await import("node:sqlite");
    const db = new DatabaseSync(dbPath);
    db.exec("PRAGMA journal_mode = WAL");
    console.log("Relinking bodies from on-disk .eml by Message-ID (local-only)...");

    const readMsgId = (p: string): string => {
        try {
            const fd = fs.openSync(p, "r");
            const buf = Buffer.alloc(8192);
            const n = fs.readSync(fd, buf, 0, 8192, 0);
            fs.closeSync(fd);
            const head = buf.slice(0, n).toString("utf-8");
            const end = head.search(/\r?\n\r?\n/);
            const hdr = end >= 0 ? head.slice(0, end) : head;
            const m = hdr.match(/^Message-I[dD]:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/im);
            return m ? m[1].replace(/\r?\n[ \t]+/g, " ").trim().replace(/^<|>$/g, "") : "";
        } catch { return ""; }
    };

    // 1. Index every .eml by account + Message-ID → relative path.
    const midToPath = new Map<string, string>(); // key: `${acct}\x00${mid}`
    let scanned = 0;
    const accountDirs = fs.readdirSync(storePath, { withFileTypes: true })
        .filter(d => d.isDirectory()).map(d => d.name);
    const walk = (acct: string, dir: string): void => {
        for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
            const p = path.join(dir, e.name);
            if (e.isDirectory()) { walk(acct, p); continue; }
            if (!e.isFile() || !e.name.endsWith(".eml")) continue;
            scanned++;
            const mid = readMsgId(p);
            if (!mid) continue;
            const key = `${acct}\x00${mid}`;
            if (!midToPath.has(key)) midToPath.set(key, path.relative(storePath, p).replace(/\\/g, "/"));
            if (scanned % 5000 === 0) console.log(`  scanned ${scanned} .eml...`);
        }
    };
    for (const acct of accountDirs) walk(acct, path.join(storePath, acct));
    console.log(`  indexed ${midToPath.size} unique Message-IDs from ${scanned} .eml files`);

    // 2. For each row with a Message-ID, point body_path at the matching .eml
    //    (or clear it when no file on disk matches → re-fetch on demand).
    const rows = db.prepare("SELECT id, account_id, message_id, body_path FROM messages WHERE message_id IS NOT NULL AND message_id != ''").all() as any[];
    const upd = db.prepare("UPDATE messages SET body_path = ?, body_parsed_at = NULL WHERE id = ?");
    let relinked = 0, already = 0, cleared = 0;
    for (const r of rows) {
        const mid = String(r.message_id).replace(/^<|>$/g, "");
        const correct = midToPath.get(`${r.account_id}\x00${mid}`);
        if (correct) {
            if (r.body_path !== correct) { upd.run(correct, r.id); relinked++; }
            else already++;
        } else if (r.body_path) {
            upd.run("", r.id); cleared++;
        }
    }
    db.close();
    console.log(`Relink done: ${relinked} repaired, ${already} already correct, ${cleared} cleared (no matching .eml → will re-fetch).`);
    console.log("  Run 'rmfmail' to start the daemon.");
    process.exit(0);
}

// Import accounts from a local file into GDrive
if (importMode) {
    const importPath = args.find(a => !a.startsWith("-"));
    if (!importPath) {
        console.error("Usage: rmfmail --import <path-to-accounts.jsonc>");
        console.error("  Reads accounts from a local file and saves to Google Drive.");
        console.error("  Example: rmfmail --import ~/OneDrive/home/.rmfmail/accounts.jsonc");
        process.exit(1);
    }
    const { parse: parseJsonc } = await import("jsonc-parser");
    const absPath = path.resolve(importPath);
    if (!fs.existsSync(absPath)) {
        console.error(`File not found: ${absPath}`);
        process.exit(1);
    }
    const content = fs.readFileSync(absPath, "utf-8").replace(/\r/g, "");
    const data = parseJsonc(content);
    const accounts = data?.accounts || (Array.isArray(data) ? data : null);
    if (!accounts || accounts.length === 0) {
        console.error("No accounts found in file. Expected { accounts: [...] } or [...]");
        process.exit(1);
    }
    console.log(`Found ${accounts.length} account(s) in ${absPath}`);

    // Initialize cloud config (GDrive) and save
    const { initCloudConfig, loadAccounts, saveAccounts } = await import("@bobfrankston/mailx-settings");
    await initCloudConfig("gdrive");

    // Merge: existing cloud accounts + imported, deduplicate by email
    const existing = loadAccounts();
    const merged = [...existing];
    for (const acct of accounts) {
        if (!merged.some(e => e.email === acct.email)) {
            merged.push(acct);
            console.log(`  + ${acct.label || acct.email}`);
        } else {
            console.log(`  = ${acct.label || acct.email} (already exists)`);
        }
    }
    // Wrap with name if the source had one
    const wrapper: any = { accounts: merged };
    if (data?.name) wrapper.name = data.name;
    await saveAccounts(merged);
    console.log(`Saved ${merged.length} account(s). Run 'rmfmail' to start.`);
    process.exit(0);
}

// Version
if (hasFlag("v") || hasFlag("version")) {
    const root = path.join(import.meta.dirname, "..");
    const ver = (pkg: string): string => {
        for (const dir of [`${root}/node_modules/${pkg}`, `${root}/node_modules/@bobfrankston/${pkg.replace("@bobfrankston/","")}`]) {
            try { return JSON.parse(fs.readFileSync(`${dir}/package.json`, "utf-8")).version; } catch { /* */ }
        }
        // Check workspace packages
        const short = pkg.replace("@bobfrankston/","");
        try { return JSON.parse(fs.readFileSync(`${root}/packages/${short}/package.json`, "utf-8")).version; } catch { /* */ }
        return "not found";
    };
    try {
        const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf-8"));
        console.log(`\x1b[1;97;44m rmfmail v${pkg.version} \x1b[0m`);
    } catch { console.log("rmfmail (version unknown)"); }
    console.log(`  node      ${process.version}`);
    console.log(`  iflow-direct  ${ver("@bobfrankston/iflow-direct")}`);
    console.log(`  miscinfo  ${ver("@bobfrankston/miscinfo")}`);
    console.log(`  oauth     ${ver("@bobfrankston/oauthsupport")}`);
    console.log(`  store     ${ver("@bobfrankston/mailx-store")}`);
    console.log(`  api       ${ver("@bobfrankston/mailx-api")}`);
    console.log(`  platform  ${process.platform} ${process.arch}`);
    process.exit(0);
}

function isPortInUse(port: number): Promise<boolean> {
    return new Promise((resolve) => {
        const socket = net.createConnection({ port, host: "127.0.0.1" });
        socket.once("connect", () => { socket.destroy(); resolve(true); });
        socket.once("error", () => resolve(false));
    });
}

/** Launch msger pointing at the server URL */
function launchMsger(url: string): void {
    showMessageBox({ url, detach: true, size: { width: 1400, height: 900 } });
}

async function prompt(question: string): Promise<string> {
    const readline = await import("readline");
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    return new Promise(resolve => rl.question(question, (ans: string) => { rl.close(); resolve(ans.trim()); }));
}

interface DiscoveredServer {
    imap: { host: string; port: number; auth: string };
    smtp: { host: string; port: number; auth: string };
    source: string;
}

interface AccountInput {
    email: string;
    password?: string;
    imap?: { host: string; port?: number };
    smtp?: { host: string; port?: number };
}

/** Check if rmfmail is configured (has local config or accounts) */
function hasConfig(): boolean {
    const home = process.env.USERPROFILE || process.env.HOME || "";
    const mailxDir = path.join(home, ".rmfmail");
    for (const f of ["config.jsonc", "accounts.jsonc", "settings.jsonc"]) {
        if (fs.existsSync(path.join(mailxDir, f))) return true;
    }
    return false;
}

/** Try to auto-discover mail server settings from email domain */
async function autoDiscover(domain: string): Promise<DiscoveredServer | null> {
    // 1. Try Thunderbird ISPDB
    try {
        const res = await fetch(`https://autoconfig.thunderbird.net/v1.1/${domain}`, { signal: AbortSignal.timeout(5000) });
        if (res.ok) {
            const xml = await res.text();
            const imap = xml.match(/<incomingServer type="imap">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
            const smtp = xml.match(/<outgoingServer type="smtp">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
            if (imap && smtp) {
                return {
                    imap: { host: imap[1], port: parseInt(imap[2]), auth: imap[3].includes("OAuth2") ? "oauth2" : "password" },
                    smtp: { host: smtp[1], port: parseInt(smtp[2]), auth: smtp[3].includes("OAuth2") ? "oauth2" : "password" },
                    source: "ISPDB",
                };
            }
        }
    } catch { /* timeout or not found */ }

    // 2. Try DNS SRV records
    try {
        const dns = await import("node:dns/promises");
        const imapSrv = await dns.resolveSrv(`_imaps._tcp.${domain}`).catch((): null => null);
        const smtpSrv = await dns.resolveSrv(`_submission._tcp.${domain}`).catch((): null => null);
        if (imapSrv?.[0] && smtpSrv?.[0]) {
            return {
                imap: { host: imapSrv[0].name, port: imapSrv[0].port, auth: "password" },
                smtp: { host: smtpSrv[0].name, port: smtpSrv[0].port, auth: "password" },
                source: "DNS SRV",
            };
        }
    } catch { /* DNS failed */ }

    // 3. Try MX-based detection (Google Workspace, Microsoft 365)
    try {
        const dns = await import("node:dns/promises");
        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 {
                    imap: { host: "imap.gmail.com", port: 993, auth: "oauth2" },
                    smtp: { host: "smtp.gmail.com", port: 587, auth: "oauth2" },
                    source: `MX → Google (${host})`,
                };
            }
            if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
                return {
                    imap: { host: "outlook.office365.com", port: 993, auth: "oauth2" },
                    smtp: { host: "smtp.office365.com", port: 587, auth: "oauth2" },
                    source: `MX → Microsoft (${host})`,
                };
            }
        }
    } catch { /* DNS failed */ }

    return null;
}

/** Prompt user for account details, auto-discover where possible */
async function promptForAccount(intro?: string): Promise<AccountInput | null> {
    if (intro) console.log(intro);
    const email = await prompt("Email address (or 'skip'): ");
    if (!email || email.toLowerCase() === "skip") return null;

    const domain = email.split("@")[1]?.toLowerCase() || "";
    const knownOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"];

    if (knownOAuth.includes(domain)) {
        console.log(`  ${domain}: OAuth2 — no password needed. Browser will prompt for authorization.`);
        return { email };
    }

    // Try auto-discovery
    console.log(`  Looking up mail servers for ${domain}...`);
    const discovered = await autoDiscover(domain);

    if (discovered) {
        console.log(`  Found via ${discovered.source}: IMAP ${discovered.imap.host}:${discovered.imap.port}, SMTP ${discovered.smtp.host}:${discovered.smtp.port}`);
        if (discovered.imap.auth === "oauth2") {
            console.log("  OAuth2 authentication — browser will prompt for authorization.");
            return { email };
        }
        const password = await prompt("Password: ");
        return {
            email, password,
            imap: { host: discovered.imap.host, port: discovered.imap.port },
            smtp: { host: discovered.smtp.host, port: discovered.smtp.port },
        };
    }

    // Manual fallback
    console.log("  Could not auto-detect servers. Enter manually:");
    const password = await prompt("Password: ");
    const imapHost = await prompt(`IMAP host [imap.${domain}]: `) || `imap.${domain}`;
    const smtpHost = await prompt(`SMTP host [smtp.${domain}]: `) || `smtp.${domain}`;
    return {
        email, password,
        imap: { host: imapHost },
        smtp: { host: smtpHost },
    };
}

/** Interactive first-time setup — GDrive API for cloud storage.
 *  Returns true if an account was added (caller should proceed to launch UI),
 *  false if the user skipped (UI's in-browser setup form will take over). */
async function runSetup(providedEmail?: string): Promise<boolean> {
    console.log("\nrmfmail— first-time setup\n");

    const home = process.env.USERPROFILE || process.env.HOME || "";
    const mailxDir = path.join(home, ".rmfmail");
    fs.mkdirSync(mailxDir, { recursive: true });

    // Use --email flag or prompt interactively
    const email = providedEmail || await prompt("Email address (Gmail required for now): ");
    if (!email || !email.includes("@")) {
        console.log(`\nNo account added. The UI will show a setup form.`);
        return false;
    }
    if (providedEmail) console.log(`Using email: ${email}`);

    const domain = email.split("@")[1]?.toLowerCase() || "";
    let isGoogle = ["gmail.com", "googlemail.com"].includes(domain);
    if (!isGoogle) {
        try {
            const dnsmod = await import("node:dns/promises");
            const records = await dnsmod.resolveMx(domain);
            isGoogle = records.some(mx => {
                const host = mx.exchange.toLowerCase();
                return host.endsWith(".google.com") || host.endsWith(".googlemail.com");
            });
            if (isGoogle) console.log(`  ${domain} is hosted on Google (detected via MX)`);
        } catch { /* DNS lookup failed */ }
    }

    // For Google-hosted accounts, check Drive for existing settings first
    let driveFolderId: string | null = null;
    if (isGoogle) {
        console.log("\nChecking Google Drive for existing rmfmail settings...");
        try {
            const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
            driveFolderId = await gDriveFindOrCreateFolder();
            if (driveFolderId) {
                console.log(`  Drive folder: My Drive/rmfmail/ (${driveFolderId})`);
                const gdrive = getCloudProvider("gdrive", driveFolderId);
                if (gdrive) {
                    // Read accounts.jsonc (canonical) — ignore legacy settings.jsonc
                    const existing = await gdrive.read("accounts.jsonc");
                    if (existing) {
                        const { parse: parseJsonc } = await import("jsonc-parser");
                        const data = parseJsonc(existing);
                        const accts = data?.accounts || (Array.isArray(data) ? data : []);
                        if (accts.length > 0) {
                            console.log(`\nFound ${accts.length} existing account(s) on Google Drive (My Drive/rmfmail/accounts.jsonc):`);
                            for (const a of accts) console.log(`  • ${a.label || a.name || a.email}`);
                            // Save config pointing to Drive — no prompts needed
                            const config = { sharedDir: { provider: "gdrive", path: "rmfmail", folderId: driveFolderId } };
                            fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
                            console.log("Local config created. Starting rmfmail...\n");
                            return true;
                        }
                    }
                }
                // No existing accounts — save Drive config for later
                const config = { sharedDir: { provider: "gdrive", path: "rmfmail", folderId: driveFolderId } };
                fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
            } else {
                console.log("  Could not access Google Drive (OAuth not granted or token expired).");
                console.log("  Account will be saved locally; the UI will retry the cloud sync when you fix Drive access.");
            }
        } catch (e: any) {
            console.log(`  Drive check failed: ${e.message} — will save locally and retry from UI.`);
        }
    }

    // No existing accounts found — build a new account
    const account = { email } as any;
    const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
    if (!isOAuth) {
        account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
    }

    // Display name: leave empty for Google accounts so MailxService.setupAccount
    // (or the next launch's IMAP auth) can resolve it from the People API once
    // the Gmail OAuth token exists. The Drive token alone doesn't have the
    // contacts.readonly scope, so we can't fetch it here at CLI-prompt time.
    const defaultName = email.split("@")[0];
    const name = await prompt(`Your name (for From: header) [auto-detect from Google, or '${defaultName}']: `) || (isGoogle ? "" : defaultName);

    // ALWAYS write a local copy first so the data is never lost. The cloud
    // write below is the sync, not the source of truth on this machine.
    const accountsData = { name, accounts: [account] };
    const localAccountsPath = path.join(mailxDir, "accounts.jsonc");
    fs.writeFileSync(localAccountsPath, JSON.stringify(accountsData, null, 2));

    if (isGoogle && driveFolderId) {
        // Save to Google Drive via API
        console.log("\nSaving account to Google Drive...");
        try {
            const { getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
            const gdrive = getCloudProvider("gdrive", driveFolderId);
            if (!gdrive) throw new Error("getCloudProvider returned null");
            await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
            console.log("  Account saved to Google Drive.");
        } catch (e: any) {
            console.log(`  Drive write failed: ${e.message}`);
            console.log(`  Local copy saved at ${localAccountsPath} — UI will retry cloud sync.`);
        }
    } else if (isGoogle && !driveFolderId) {
        console.log(`  Skipping Drive sync (no folder ID). Local copy at ${localAccountsPath}.`);
    }

    console.log("Setup complete. Starting rmfmail...\n");
    return true;
}

/** Test account connectivity — IMAP connect, SMTP send, sync round-trip */
async function runTest(): Promise<void> {
    console.log("\nrmfmail— connection test\n");

    // Start server in-process to access settings
    console.log("Loading settings...");
    const { loadSettings, getSharedDir, initLocalConfig } = await import("@bobfrankston/mailx-settings");
    initLocalConfig();
    const settings = loadSettings();

    if (settings.accounts.length === 0) {
        console.log("No accounts configured. Run: rmfmail -setup");
        process.exit(1);
    }

    console.log(`Shared dir: ${getSharedDir()}`);
    console.log(`Accounts: ${settings.accounts.map((a: any) => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);

    for (const account of settings.accounts) {
        if (!account.enabled) { console.log(`  ${account.label || account.id}: SKIPPED (disabled)\n`); continue; }

        console.log(`Testing ${account.label || account.id} (${account.email}):`);

        // Test IMAP
        try {
            const { createAutoImapConfig, CompatImapClient } = await import("@bobfrankston/iflow-direct");
            const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
            const config = createAutoImapConfig({
                server: account.imap.host,
                port: account.imap.port,
                username: account.imap.user,
                password: account.imap.password
            });
            const client = new CompatImapClient(config, () => new NodeTcpTransport());
            const folders = await client.getFolderList();
            await client.logout();
            console.log(`  IMAP: OK (${folders.length} folders)`);
        } catch (e: any) {
            console.log(`  IMAP: FAILED — ${e.message}`);
        }

        // Test SMTP
        try {
            const { createTransport } = await import("nodemailer");
            let smtpAuth: any;
            if (account.smtp.auth === "password") {
                smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
            } else if (account.smtp.auth === "oauth2") {
                // Try to get OAuth token
                const { createAutoImapConfig } = await import("@bobfrankston/iflow-direct");
                const config = createAutoImapConfig({
                    server: account.imap.host,
                    port: account.imap.port,
                    username: account.imap.user,
                });
                if (config.tokenProvider) {
                    const accessToken = await config.tokenProvider();
                    smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
                }
            }
            const transport = createTransport({
                host: account.smtp.host,
                port: account.smtp.port,
                secure: account.smtp.port === 465,
                auth: smtpAuth,
                tls: { rejectUnauthorized: false },
            });
            await transport.verify();
            console.log(`  SMTP: OK`);

            // Send test message to self
            const testSubject = `rmfmail test — ${new Date().toLocaleString()}`;
            await transport.sendMail({
                from: `${account.name} <${account.email}>`,
                to: account.email,
                subject: testSubject,
                text: `This is a test message from rmfmail -test.\nSent: ${new Date().toISOString()}\nAccount: ${account.id}`,
            });
            console.log(`  SEND: OK — test message sent to ${account.email}`);
            console.log(`  Subject: "${testSubject}"`);
        } catch (e: any) {
            console.log(`  SMTP: FAILED — ${e.message}`);
        }

        console.log();
    }

    console.log("Test complete. Check your inbox for the test message(s).");
    process.exit(0);
}

/** Register this client on GDrive — writes/updates clients.jsonc with device info.
 *  Best-effort: silently skips when cloud isn't ready (fresh install, expired
 *  token). The scary "No cloud configured" banner must NOT fire for this. */
async function registerClient(settings: any): Promise<void> {
    const { cloudRead, cloudWrite, getStorageInfo } = await import("@bobfrankston/mailx-settings");
    // Check if cloud is actually ready before attempting a write — cloudWrite
    // calls setCloudError which pushes an error banner to the UI. We'd rather
    // silently skip than show "No cloud configured" at startup.
    const info = getStorageInfo();
    if (info.mode === "local" || !info.provider || info.provider === "local") return;

    // Device ID: stable per machine, stored locally
    const deviceIdPath = path.join(os.homedir(), ".rmfmail", "device-id");
    let deviceId: string;
    if (fs.existsSync(deviceIdPath)) {
        deviceId = fs.readFileSync(deviceIdPath, "utf-8").trim();
    } else {
        deviceId = `${os.hostname()}-${Date.now().toString(36)}`;
        fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
        fs.writeFileSync(deviceIdPath, deviceId);
    }

    // Get local IP
    let localIp = "";
    try {
        const nets = os.networkInterfaces();
        for (const addrs of Object.values(nets) as (os.NetworkInterfaceInfo[] | undefined)[]) {
            for (const addr of addrs || []) {
                if (addr.family === "IPv4" && !addr.internal) { localIp = addr.address; break; }
            }
            if (localIp) break;
        }
    } catch { /* ignore */ }

    // Read existing clients.jsonc from cloud (may not exist yet — that's fine)
    let clients: Record<string, any> = {};
    try {
        const content = await cloudRead("clients.jsonc");
        if (content) clients = JSON.parse(content);
    } catch { /* start fresh */ }

    // Update this device's entry
    clients[deviceId] = {
        hostname: os.hostname(),
        platform: `${process.platform} ${process.arch}`,
        accounts: settings.accounts.map((a: any) => a.id),
        lastSeen: new Date().toISOString(),
        ip: localIp,
        version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
    };

    // Write back. cloudWrite now throws on failure (and sets lastCloudError),
    // so swallow here — registerClient is fire-and-forget from the caller.
    try {
        await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
        console.log(`  [client] Registered device: ${deviceId}`);
    } catch (e: any) {
        console.error(`  [client] Failed to register device: ${e.message}`);
    }
}

async function main(): Promise<void> {
    log(`Platform: ${process.platform} ${process.arch}`);
    log(`Node: ${process.version}`);
    log(`Mode: ${setupMode ? "setup" : "auto"}`);

    // Refuse to run elevated unless explicitly opted in. An elevated mailx
    // can poison %LOCALAPPDATA%\msger\webview2\ (see msger/notes.md WebView2
    // profile playbook) and create admin-owned files under ~/.rmfmail/ that
    // later non-admin runs can't write to. `net session` requires admin on
    // Windows; succeeds → admin, fails → non-admin. Linux/Mac use process
    // uid (0 = root). --allow-elevated bypasses for scripted admin use.
    if (!hasFlag("allow-elevated") && !isDaemon && isElevated()) {
        const button = await warnElevated();
        if (button !== "Continue anyway") {
            log("User chose Quit on elevated-run warning. Exiting.");
            process.exit(0);
        }
        log("User chose Continue anyway on elevated-run warning. Proceeding (will likely poison local state).");
    }

    // Test connectivity
    if (testMode) {
        await runTest();
        return;
    }

    // Add account to existing config
    if (addMode) {
        const account = await promptForAccount();
        if (account) {
            const home = process.env.USERPROFILE || process.env.HOME || "";
            const mailxDir = path.join(home, ".rmfmail");
            const settingsPath = path.join(mailxDir, "settings.jsonc");
            let settings: any;
            try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, "")); } catch { settings = { accounts: [] }; }
            if (!settings.accounts) settings.accounts = [];
            settings.accounts.push(account);
            fs.mkdirSync(mailxDir, { recursive: true });
            fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
            console.log(`Added ${account.email} to settings. Restart rmfmail to connect.`);
        }
        process.exit(0);
    }

    // Auto-detect first run — enter setup if no config exists.
    // Skip CLI prompts entirely when stdin isn't a TTY (auto-detached daemon
    // has stdio:"ignore", so prompt() returns "" instantly and the user never
    // gets to type their email — silent no-setup). The in-browser setup form
    // takes over in that case.
    const hasTty = setupMode ? !!process.stdin.isTTY : (process.stdin.isTTY === true);
    if (setupMode || !hasConfig()) {
        if (!setupMode) console.log("No rmfmail configuration found.");
        // -email or -mail flag skips the interactive prompt
        const emailFlag = args.findIndex(a => a === "-email" || a === "--email" || a === "-mail" || a === "--mail");
        const emailArg = args.find(a => a.startsWith("-email=") || a.startsWith("--email=") || a.startsWith("-mail=") || a.startsWith("--mail="))?.split("=")[1]
            || (emailFlag >= 0 ? args[emailFlag + 1] : undefined);
        if (hasTty || emailArg) {
            await runSetup(emailArg);
        } else {
            console.log("No TTY and no -email flag — skipping CLI setup; in-browser setup form will appear.");
            // Ensure the data dir exists so the UI can write its config.
            const home = process.env.USERPROFILE || process.env.HOME || "";
            fs.mkdirSync(path.join(home, ".rmfmail"), { recursive: true });
        }
    }

    // Redirect console to log file — keep terminal clean
    if (!verbose) {
        const home = process.env.USERPROFILE || process.env.HOME || ".";
        const logDir = path.join(home, ".rmfmail", "logs");
        fs.mkdirSync(logDir, { recursive: true });
        // Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
        // scan the dir, stat, delete. Cheap even with years of history.
        // Bumped 7 → 30 days so "where did my letter go?" reports can still
        // reach the `[reconcile-delete]` log entry weeks after the fact.
        const LOG_RETENTION_DAYS = 30;
        const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
        try {
            for (const name of fs.readdirSync(logDir)) {
                // Match both legacy `mailx-` and current `rmfmail-` filenames
                // so the 30-day prune cleans up history from before the
                // rebrand without leaving the old files orphaned.
                if (!/^(?:mailx|rmfmail)-\d{4}-\d{2}-\d{2}\.log$/.test(name)) continue;
                const full = path.join(logDir, name);
                try {
                    const st = fs.statSync(full);
                    if (st.mtimeMs < cutoff) fs.unlinkSync(full);
                } catch { /* ignore per-file error */ }
            }
        } catch { /* ignore — log pruning is best-effort */ }
        // Local time, not UTC — `toISOString` always emits UTC which made
        // log lines look offset from wall-clock by the user's timezone (EST
        // showed 16:00 in the log when the user's clock said 12:00). Helper
        // pads each field; gives "HH:MM:SS.mmm" matching the user's local
        // time, and "YYYY-MM-DD" for the filename in their local timezone.
        const pad2 = (n: number): string => n.toString().padStart(2, "0");
        const pad3 = (n: number): string => n.toString().padStart(3, "0");
        const localDate = (): string => {
            const d = new Date();
            return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
        };
        const ts = (): string => {
            const d = new Date();
            return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
        };
        const logPath = path.join(logDir, `rmfmail-${localDate()}.log`);
        const logStream = fs.createWriteStream(logPath, { flags: "a" });
        console.log = (...a: any[]) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
        console.error = (...a: any[]) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
        // Redirect daemon's process.stderr to the log too. msger forwards its
        // Rust child's stderr to our process.stderr; in daemon mode that's
        // stdio:"ignore" → /dev/null, which buries diagnostics like
        // "Failed to load icon ..." that we need to debug taskbar-icon
        // problems. Wire it through here.
        const origStderrWrite = process.stderr.write.bind(process.stderr);
        process.stderr.write = ((chunk: any, ...rest: any[]): boolean => {
            try {
                const s = typeof chunk === "string" ? chunk : chunk.toString();
                logStream.write(`${ts()} STDERR ${s}${s.endsWith("\n") ? "" : "\n"}`);
            } catch { /* ignore — best-effort logging */ }
            return origStderrWrite(chunk, ...rest);
        }) as typeof process.stderr.write;
    }

    // IPC service mode — no HTTP server
    const _tStart = Date.now();
    const _tick = (label: string): void => console.log(`  [boot ${String(Date.now() - _tStart).padStart(5)} ms] ${label}`);
    _tick("starting rmfmail service");
    // Parallelize all the package resolutions that don't depend on each
    // other. Sequencing five `await import` calls cost cold-start latency
    // for nothing — each was independent. Same for node-tcp-transport
    // and mailx-store/file-store.js further down; folded in here so the
    // resolution + module-init cost happens once, in parallel.
    const [
        { MailxDB, prewarmParseWorker, storeBus, Store },
        { ImapManager },
        { MailxService, spawnSyncWorker },
        { dispatch },
        { loadSettings, loadAccountsAsync, loadAllowlistAsync, getConfigDir, getStorageInfo, getStorePath },
        { NodeTcpTransport },
        { FileMessageStore },
    ] = await Promise.all([
        import("@bobfrankston/mailx-store"),
        import("@bobfrankston/mailx-imap"),
        import("@bobfrankston/mailx-service"),
        import("@bobfrankston/mailx-service/jsonrpc.js"),
        import("@bobfrankston/mailx-settings"),
        import("@bobfrankston/node-tcp-transport"),
        import("@bobfrankston/mailx-store/file-store.js"),
    ]);
    _tick("modules loaded");

    // mailparser cold-start absorber. The first `simpleParser` call in a
    // fresh process loads libmime, iconv-lite, charset tables, etc. and JIT-
    // compiles the parse pipeline. Empirically that single first parse takes
    // 14-25 seconds while every subsequent parse is 50-200 ms — meaning the
    // FIRST user click after boot used to land on the cold path and the
    // preview pane sat on "Loading body..." for 15-25 s (see log
    // 2026-05-14 01:11:44 simpleParser 14524ms for 5 KB).
    //
    // mailx-store now runs every parse in a worker thread; prewarmParseWorker
    // spawns that worker now (instead of waiting for the first parseSerial
    // call) so its self-warm completes in parallel with WebView init,
    // OAuth, IMAP sync, etc. By the time the user clicks a message the
    // worker is already hot. Main event loop is never blocked.
    prewarmParseWorker();

    let settings = loadSettings();
    _tick(`settings loaded (${settings.accounts?.length || 0} accounts)`);
    // CRITICAL: don't await cloud refresh of accounts.jsonc here — it adds
    // 1-3s of GDrive latency to startup BEFORE the IPC dispatcher is even
    // registered. WebView opens, hits "Connecting to server..." overlay,
    // and sits there waiting for the daemon to finish a cloud read it
    // doesn't actually need at boot.
    //
    // The local accounts.jsonc cache (loaded synchronously above) has the
    // last-known state; that's what every account+folder+message UI read
    // depends on. Cloud refresh's only job is "another device added an
    // account" — fire-and-forget, log + warn if it diverges, the user can
    // restart for it to take effect. Was a band-aid hidden in a sync
    // path; per feedback_no_bandaids, fix the real cause.
    void (async () => {
        try {
            const cloudAccounts = await loadAccountsAsync();
            if (cloudAccounts.length > 0 && cloudAccounts.length !== settings.accounts.length) {
                console.log(`  [cloud] accounts diverged: cache=${settings.accounts.length} cloud=${cloudAccounts.length} — restart to apply`);
            }
        } catch (e: any) {
            console.error(`  [cloud] background account refresh failed: ${e?.message || e}`);
        }
    })();

    const db = new MailxDB(getConfigDir());
    _tick("DB opened");
    // Store — the architectural nexus (DB + .eml files + bus + operations).
    // Constructed once, shared across MailxService and ImapManager. Sync
    // clients (ImapManager) consume it; they don't own the DB.
    const store = new Store(db, new FileMessageStore(getStorePath()));
    _tick("Store ready");
    // Prime the shared allow-list from cloud (fire-and-forget). loadAllowlist()
    // is sync filesystem-only; on a fresh install the Drive copy was never
    // pulled, so the screener fell back to DEFAULT_ALLOWLIST until first save.
    // cloudRead caches to LOCAL_DIR; invalidate the store cache so the freshly
    // pulled list takes effect without a restart.
    void (async () => {
        try {
            await loadAllowlistAsync();
            store.invalidateConfigCaches();
            console.log("  [cloud] allow-list primed from shared config");
        } catch (e: any) {
            console.error(`  [cloud] background allow-list prime failed: ${e?.message || e}`);
        }
    })();
    // One-shot: rewrite absolute body_path values (legacy `~/.mailx/...` and
    // friends) to paths relative to the store base. After this, renaming the
    // local config dir or moving the store no longer breaks body reads.
    {
        const tmpStore = new FileMessageStore(getStorePath());
        db.runOneShotBodyPathMigration((rows, update) => tmpStore.rewriteAbsoluteToRelative(rows, update));
    }
    _tick("body-path migration checked");

    // Auto-create the sending/ recovery README on every startup. Stays in
    // sync with the running version of mailx; user can ignore once the
    // disk-staging fallback is no longer needed.
    try {
        const sendingDir = path.join(getConfigDir(), "sending");
        fs.mkdirSync(sendingDir, { recursive: true });
        const readmePath = path.join(sendingDir, "README.md");
        const readmeBody = `# \`~/.rmfmail/sending/\` and \`~/.rmfmail/outbox/\` — outgoing-mail staging

Auto-generated by rmfmail on startup. Manual recovery reference for when rmfmail is broken or you need to feed an outgoing message into another mail program.

## Layout

\`\`\`
~/.rmfmail/
├── outbox/<account>/
│   └── *.ltr                  ← THE QUEUE. Worker scans every 10s, sends, deletes on success.
└── sending/<account>/
    ├── editing/               ← Last 3 draft autosaves while composing.
    ├── queued/                ← Manual drop-in / crash-recovery copies.
    └── sent/                  ← Audit trail of successfully sent messages.
\`\`\`

In-flight files are atomically renamed to \`<file>.sending-<host>-<pid>\` while the worker is processing them — same-machine claim so two rmfmail instances don't double-send. Stale claims (dead PIDs on this host) are recovered on the next tick.

## Manual fallback

- **rmfmail is dead, need to send a draft** — most recent file in \`sending/<account>/editing/\` is a complete RFC 822 message; copy the body into another mail client and resend.
- **Feed a raw .eml to mailx** — drop into \`sending/<account>/queued/\`. Picked up within 10s.
- **rmfmail says queued but server doesn't have it** — look in \`outbox/<account>/\`. \`.ltr\` still there → worker hasn't sent yet (check \`~/.rmfmail/logs/\`). \`.sending-<host>-<pid>\` → in flight. Gone → success.

## Format

RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable in a text editor). Every message carries \`Message-ID:\` for cross-device dedup; \`X-Mailx-Retry\` marks retry attempts.
`;
        // Only rewrite if content drifted (avoids gratuitous mtime updates).
        let existing = "";
        try { existing = fs.readFileSync(readmePath, "utf-8"); } catch { /* missing */ }
        if (existing !== readmeBody) fs.writeFileSync(readmePath, readmeBody);
    } catch (e: any) {
        console.error(`  [readme] Could not write sending README: ${e?.message || e}`);
    }

    // `--server` (Express HTTP browser mode) was REMOVED 2026-06-29. The client
    // went IPC-only (everything through the msger `mailxapi` bridge; the old
    // `fetch("/api/...")` path was deleted), and nothing injected a browser-side
    // bridge — so the served page could no longer talk to the server and the
    // mode was effectively dead. The Android app now covers the "use it from a
    // phone" case it originally existed for. The `mailx-api` Express router lives
    // on, used only by the optional `--debug-server` dev introspection endpoint.

    // Sync runs on its OWN thread now: spawn the sync worker (it hosts
    // ImapManager + its own write connection) and hand the main thread a proxy
    // that quacks like ImapManager (method calls forwarded over the bus,
    // events re-emitted locally). A hung fetch / outbox / reconnect storm in
    // the worker can no longer freeze the UI's event loop. Falls back to an
    // in-process ImapManager if the worker can't spawn (packaging edge / very
    // old Node) so the daemon still runs — just without the isolation.
    let imapManager: InstanceType<typeof ImapManager>;
    let syncWorkerHandle: { close(): Promise<void> } | null = null;
    try {
        const synced = await spawnSyncWorker({ configDir: getConfigDir(), storePath: getStorePath() });
        imapManager = synced.proxy;
        syncWorkerHandle = synced;
        _tick("sync worker online");
        console.log("  [sync-worker] online — IMAP/sync/outbox isolated from the UI event loop");
    } catch (e: any) {
        console.error(`  [sync-worker] spawn failed; running sync in-process: ${e?.message || e}`);
        imapManager = new ImapManager(store, () => new NodeTcpTransport());
    }
    void syncWorkerHandle;
    // Native client is the only option (iflow-direct)
    const svc = new MailxService(store, imapManager);
    // Loopback popout-window server (renders a single message for its own OS
    // window). Ephemeral port + per-launch token; best-effort. Toolbar actions
    // from popout windows are forwarded to the main window as `popoutAction`
    // events (app.ts opens the editable compose there — the popout page has no
    // IPC bridge so it can't host compose itself). `sendToClient` is late-bound:
    // the ServiceHandle doesn't exist yet at this point in startup.
    let popoutInfo: { port: number; token: string } | null = null;
    let sendToClient: ((msg: any) => void) | null = null;
    const popoutWindows = new Map<string, ReturnType<typeof showMessageBoxEx>>();
    // Timestamp of the most recent popout close — reported as a delta in the
    // main-window shutdown log line to prove/refute the "closing a popout
    // closes the main window" coupling (Bob 2026-07-06, unreproduced).
    let lastPopoutClose: { at: number; key: string } | null = null;
    const popoutKey = (a: string, f: number | undefined, u: number): string => `${a}|${f ?? ""}|${u}`;
    try {
        const { startPopoutServer } = await import("./popout-server.js");
        popoutInfo = await startPopoutServer(svc, (act) => {
            console.log(`  [popout] action ${act.action} a=${act.accountId} u=${act.uid} f=${act.folderId ?? ""}`);
            sendToClient?.({ _event: "popoutAction", type: "popoutAction", ...act });
            if (act.action === "delete") {
                // The message is gone — close its window (browser-opened
                // fallback windows close themselves client-side).
                const h = popoutWindows.get(popoutKey(act.accountId, act.folderId, act.uid));
                if (h && !h.closed) { try { h.close(); } catch { /* already gone */ } }
            }
        });
    } catch (e: any) {
        console.error(`  [popout] disabled: ${e?.message || e}`);
    }
    // Debug-server runs expose the popout token so an external driver (agent,
    // curl) can exercise /v and /action headlessly. Loopback-only surface;
    // never logged in normal runs.
    if (popoutInfo && hasFlag("debug-server")) {
        console.log(`  [popout] DEBUG token=${popoutInfo.token} (logged because --debug-server)`);
    }
    // Inject the popup function so MailxService.showReminderPopup can spawn
    // an OS-level always-on-top window via mailx-host. Kept as injection
    // (not import) so mailx-service stays host-agnostic.
    //
    // Uses showMessageBoxEx so we get a MessageBoxHandle back with
    // `pid` and `close()`. The pid goes into instance.json's childPids
    // for the lifetime of the popup, so a `rmfmail -kill` from another
    // shell can SIGTERM it without name-based sweeps (cross-platform —
    // see memory: feedback_cross_platform_strong_rule). Remove on
    // resolution so the file doesn't accumulate stale PIDs.
    svc.setPopupFn(async (opts: any) => {
        const h = showMessageBoxEx(opts);
        if (typeof h.pid === "number") addChildPid(h.pid);
        try {
            const r = await h.result;
            return { button: r.button, closed: r.closed, dismissed: r.dismissed, form: (r as any).form };
        } finally {
            if (typeof h.pid === "number") removeChildPid(h.pid);
        }
    });

    // Optional debug HTTP server ALONGSIDE the GUI. `--server` mode replaces
    // the WebView with Express; `--debug-server` instead runs a loopback
    // Express API *next to* the running GUI, mounted on the SAME live store
    // and imapManager (no parallel SQLite handle, no parallel sync). It lets
    // an external tool inspect and drive daemon state — getFolders,
    // getMessage, sync status — while the real app runs, so GUI-path bugs
    // can be reproduced without a human clicking. Loopback-only; off unless
    // explicitly requested.
    if (hasFlag("debug-server")) {
        try {
            const { default: express } = await import("express");
            const { createApiRouter } = await import("@bobfrankston/mailx-api");
            const { ports } = await import("@bobfrankston/miscinfo");
            const dbgApp = express();
            dbgApp.use(express.json({ limit: "25mb" }));
            dbgApp.use("/api", createApiRouter(store, imapManager));
            // Port: `--debug-server=<n>` / `--debug-server <n>` if given,
            // else the reserved `ports.rmfmaildbg` from miscinfo (the named
            // entry exists so the value is reserved and not hardcoded here).
            const dbgPortArg = Number(getFlagValue("debug-server"));
            const dbgPort = Number.isInteger(dbgPortArg) && dbgPortArg > 0 && dbgPortArg < 65536
                ? dbgPortArg
                : ports.rmfmaildbg;
            dbgApp.listen(dbgPort, "127.0.0.1", () => {
                console.log(`  [debug-server] listening on http://127.0.0.1:${dbgPort}/api (alongside GUI)`);
            });
        } catch (e: any) {
            console.error(`  [debug-server] failed to start: ${e?.message || e}`);
        }
    }

    // Open msger in service mode — custom protocol serves files from client dir
    const clientDir = path.join(import.meta.dirname, "..", "client");
    const mailxapiPath = path.join(clientDir, "lib", "mailxapi.js");
    let mailxapiScript = fs.readFileSync(mailxapiPath, "utf-8");
    // Tell the client where the popout-window server lives (base URL + token) so
    // the viewer's "open in separate window" can window.open it.
    if (popoutInfo) {
        mailxapiScript += `\nwindow.__rmfPopout=${JSON.stringify({ base: `http://127.0.0.1:${popoutInfo.port}`, token: popoutInfo.token })};`;
    }

    // Restore saved window geometry (position + size) from previous session
    const windowJsonPath = path.join(getConfigDir(), "window.json");
    interface WindowGeometry { x: number; y: number; width: number; height: number }
    let savedGeometry: WindowGeometry | null = null;
    try {
        const raw = JSON.parse(fs.readFileSync(windowJsonPath, "utf-8"));
        if (typeof raw.width === "number" && raw.width > 200 &&
            typeof raw.height === "number" && raw.height > 200) {
            savedGeometry = raw as WindowGeometry;
        }
    } catch { /* no saved geometry — use defaults */ }

    const rootPkgJson = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
    const rootPkgVersion = rootPkgJson.version;
    // Used by the auto-update poller — must match the actual published name
    // so `npm view <name> version` returns the running package's latest.
    // Hard-coding "@bobfrankston/mailx" left this stuck on the pre-rebrand
    // package and triggered downgrade banners after every release.
    const rootPkgName = rootPkgJson.name || "@bobfrankston/rmfmail";
    // Pass the .png as the window-decode icon: msger uses the `image` crate
    // to decode `options.icon` for the Tao window icon, and PNG-in-ICO files
    // round-trip unreliably through the image-0.24 ICO decoder, which leaves
    // the taskbar entry showing the empty msgernative.exe default. PNG always
    // decodes cleanly. The .ico path is forwarded separately as
    // `relaunchIcon`, which msger uses for `PKEY_AppUserModel_RelaunchIconResource`
    // (the path is consumed verbatim — no decode — so PNG-in-ICO is fine
    // there). Falls back if either file is missing.
    const __iconIco = path.join(clientDir, "icon.ico");
    const __iconPng = path.join(clientDir, "icon.png");
    const __iconPathRuntime = fs.existsSync(__iconPng) ? __iconPng : __iconIco;
    const __iconPathPin = fs.existsSync(__iconIco) ? __iconIco : undefined;
    console.log(`[icon] runtime=${__iconPathRuntime} (exists=${fs.existsSync(__iconPathRuntime)}) pin=${__iconPathPin ?? "none"} (exists=${__iconPathPin ? fs.existsSync(__iconPathPin) : false})`);
    // Pinned-shortcut launch command. Windows captures the running exe at
    // pin time by default, but the running exe is `mailx.exe` — actually
    // msgernative.exe expecting JSON options on stdin. Click that bare and
    // it dies on `serde_json::from_str("")`. Override with an explicit
    // node + script invocation so the pin re-enters through bin/mailx.js
    // the same way the `mailx` CLI does.
    const __mailxJs = path.join(import.meta.dirname, "mailx.js");
    const __relaunchCommand = `"${process.execPath}" "${__mailxJs}"`;
    // "Open in separate window" (🗗) spawns a NATIVE msger WebView window
    // pointed at the loopback popout server — not the OS browser (Bob
    // 2026-07-06). Injected here (not in mailx-service) because it needs
    // mailx-host + the icon paths. One window per message: a second request
    // for the same message is a no-op while its window is open. Child PIDs
    // are tracked in instance.json so `rmfmail -kill` reaps popouts too.
    if (popoutInfo) {
        const pi = popoutInfo;
        svc.setPopoutWindowFn(async ({ accountId, uid, folderId, subject }) => {
            const key = popoutKey(accountId, folderId, uid);
            const existing = popoutWindows.get(key);
            if (existing && !existing.closed) return { ok: true };
            const url = `http://127.0.0.1:${pi.port}/v?a=${encodeURIComponent(accountId)}&u=${uid}&f=${folderId ?? ""}&t=${encodeURIComponent(pi.token)}`;
            const h = showMessageBoxEx({
                title: subject || "Message",
                url,
                size: { width: 820, height: 900 },
                icon: __iconPathRuntime,
                aumid: "com.frankston.rmfmail",
                escapeCloses: true,
                // The daemon is a BACKGROUND process — without this the new
                // window shows unactivated BEHIND the focused main window
                // and reads as "nothing opened" (Bob 2026-07-09).
                focusOnCreate: true,
            });
            popoutWindows.set(key, h);
            if (typeof h.pid === "number") addChildPid(h.pid);
            console.log(`  [popout] window OPEN pid=${h.pid ?? "?"} key=${key} (main daemon pid=${process.pid})`);
            void h.result.catch((e: any) => { console.log(`  [popout] window result rejected key=${key}: ${e?.message || e}`); }).finally(() => {
                popoutWindows.delete(key);
                if (typeof h.pid === "number") removeChildPid(h.pid);
                lastPopoutClose = { at: Date.now(), key };
                console.log(`  [popout] window CLOSED pid=${h.pid ?? "?"} key=${key} — main daemon still pid=${process.pid}`);
            });
            return { ok: true };
        });
        // Compose popout — same native-window plumbing, but the page is the
        // REAL compose (served statically by the popout server with the
        // IPC-over-HTTP bridge injected), not a rendered snapshot. Keyed by
        // the one-shot init id so send/discard can close the right window.
        // escapeCloses stays false: Escape inside compose routes to the
        // Save/Discard/Cancel prompt — a hard window-kill would eat drafts.
        svc.setComposePopoutFns({
            open: async ({ id, subject }) => {
                const key = `compose|${id}`;
                const url = `http://127.0.0.1:${pi.port}/app/${pi.token}/client/compose/compose.html?pi=${encodeURIComponent(id)}`;
                const h = showMessageBoxEx({
                    title: subject ? `Compose: ${subject}` : "Compose",
                    url,
                    size: { width: 980, height: 900 },
                    icon: __iconPathRuntime,
                    aumid: "com.frankston.rmfmail",
                    escapeCloses: false,
                    // Same as the message popout: the daemon is background,
                    // so an unactivated window lands behind the main window.
                    focusOnCreate: true,
                });
                popoutWindows.set(key, h);
                if (typeof h.pid === "number") addChildPid(h.pid);
                console.log(`  [popout] compose window OPEN pid=${h.pid ?? "?"} key=${key}`);
                void h.result.catch((e: any) => { console.log(`  [popout] compose window result rejected key=${key}: ${e?.message || e}`); }).finally(() => {
                    popoutWindows.delete(key);
                    if (typeof h.pid === "number") removeChildPid(h.pid);
                    lastPopoutClose = { at: Date.now(), key };
                    console.log(`  [popout] compose window CLOSED pid=${h.pid ?? "?"} key=${key}`);
                });
                return { ok: true };
            },
            close: (id: string) => {
                const h = popoutWindows.get(`compose|${id}`);
                if (h && !h.closed) { try { h.close(); } catch { /* already gone */ } }
            },
        });
    }
    _tick("calling showService (WebView opens here)");
    const handle = showService({
        title: `rmfmail v${rootPkgVersion}`,
        url: "index.html",
        contentDir: clientDir,
        initScript: mailxapiScript,
        icon: __iconPathRuntime,
        relaunchIcon: __iconPathPin,
        relaunchCommand: __relaunchCommand,
        relaunchDisplayName: "rmfmail",
        aumid: "com.frankston.rmfmail",
        size: savedGeometry
            ? { width: savedGeometry.width, height: savedGeometry.height }
            : { width: 1400, height: 900 },
        pos: savedGeometry
            ? { x: savedGeometry.x, y: savedGeometry.y }
            : undefined,
        escapeCloses: false,
    });
    // Late-bind the popout-action forwarder now that the main window's
    // service channel exists.
    sendToClient = (m: any) => handle.send(m);

    // Register ourselves as the live instance so subsequent `rmfmail`
    // invocations can detect version-mismatch and upgrade us (see top of
    // file). Clear on any of: SIGINT, SIGTERM, normal exit.
    //
    // Critical: the SIGTERM handler must *close the WebView child process*
    // (handle.close() → SIGTERM to the msger child) before Node exits.
    // Without this, the auto-upgrade leaves the old WebView orphaned on
    // screen and the user sees an apparently frozen "old rmfmail" while
    // the new Node is trying to spawn a second one. Cascade-closing the
    // child makes the version-mismatch auto-upgrade transparent.
    //
    // Child PIDs (the msger WebView host + any reminder popups) go into
    // instance.json so that a future `rmfmail -kill` can SIGTERM them
    // cross-platform without resorting to name-based process sweeps.
    const initialChildPids: number[] = [];
    const handleChild = (handle as any)?.pid ?? (handle as any)?.child?.pid;
    if (typeof handleChild === "number") initialChildPids.push(handleChild);
    writeInstanceFile(process.pid, initialChildPids);
    const __cleanupInstance = () => {
        // Only clear if WE are still the registered instance. Prevents the
        // restart-daemon sequence (clear → spawn → new daemon writes its
        // own entry → we exit) from deleting the replacement's claim on
        // the way out.
        const inst = readInstanceFile();
        if (inst && inst.pid === process.pid) clearInstanceFile();
        try { handle.close(); } catch { /* already gone */ }
    };
    process.once("exit", __cleanupInstance);
    process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
    process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });

    // Handle requests from WebView → dispatch to MailxService
    // Pass server version to dispatch so getVersion returns it
    const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));

    handle.onRequest(async (req) => {
        if (!req._action) {
            // msger sends {"button":"OK","closed":true} on navigation — ignore it, don't exit
            if (req.closed || req.button) {
                console.log(`[ipc] ← ignored close signal during navigation: ${JSON.stringify(req).substring(0, 100)}`);
                return;
            }
            console.log(`[ipc] ← ignored (no _action): ${JSON.stringify(req).substring(0, 100)}`);
            return;
        }
        console.log(`[ipc] ← ${req._action} (${req._cbid})`);
        // Save window position+size for next launch (handled here, not in MailxService)
        if (req._action === "saveWindowGeometry") {
            try {
                const geom: WindowGeometry = {
                    x: Number(req.x) || 0,
                    y: Number(req.y) || 0,
                    width: Math.max(400, Number(req.width) || 1400),
                    height: Math.max(300, Number(req.height) || 900),
                };
                fs.writeFileSync(windowJsonPath, JSON.stringify(geom, null, 2));
            } catch (e: any) {
                console.error(`[window] Failed to save geometry: ${e.message}`);
            }
            handle.send({ _cbid: req._cbid, result: { ok: true } });
            return;
        }
        // Restart the daemon in-place without npm install. Subtle: the new
        // mailx's startup-time instance check sees the instance.json we
        // wrote and bails with "already running" if versions match —
        // skipping the new process entirely. Clear the instance file
        // FIRST so the replacement can claim the slot, THEN spawn, THEN
        // gracefully shut this process down. The exit handler guards
        // against clobbering the replacement's entry (see __cleanupInstance
        // below — only clears if instance.json's PID still matches ours).
        if (req._action === "restartDaemon") {
            handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
            try {
                clearInstanceFile();
                const { spawn: spawnChild } = await import("child_process");
                const child = spawnChild("rmfmail", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
                child.unref();
                console.log("  [restart] Spawned fresh daemon; shutting down current");
                // Give the spawn a moment to take hold before we start
                // tearing things down — otherwise IMAP disconnects could
                // race with the new process's startup handshake.
                await new Promise(r => setTimeout(r, 800));
            } catch (e: any) {
                console.error(`  [restart] Spawn failed: ${e.message}`);
            }
            gracefulShutdown("User requested restart");
            return;
        }
        // Auto-update action: run npm install then restart
        if (req._action === "performUpdate") {
            handle.send({ _cbid: req._cbid, ok: true, status: "updating" });
            try {
                const { execSync, spawn: spawnChild } = await import("child_process");
                console.log("  [update] Installing latest version...");
                execSync("npm install -g @bobfrankston/rmfmail", { encoding: "utf-8", timeout: 120_000, stdio: "inherit", windowsHide: true });
                console.log("  [update] Install complete — relaunching");
                // Spawn the new version detached so it outlives this process
                const child = spawnChild("rmfmail", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
                child.unref();
                gracefulShutdown("Update applied");
            } catch (e: any) {
                // Don't shut down on a failed install — the user is still in
                // the middle of a session, an offline-time "Update now" tap
                // shouldn't kill the running app. Surface the failure so the
                // banner can offer a retry instead of leaving "Updating..."
                // pinned forever.
                const msg = e?.message || String(e);
                console.error(`  [update] Install failed: ${msg}`);
                const offline = /ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|getaddrinfo|network|registry/i.test(msg);
                handle.send({ _event: "updateFailed", type: "updateFailed", offline, message: msg });
            }
            return;
        }
        // Per-action wall-clock timing so a "took N seconds" report tells us
        // where between Rust→stdin→dispatch→service the time actually went.
        const ipcT0 = Date.now();
        try {
            const response = await dispatch(svc as any, req);
            const elapsed = Date.now() - ipcT0;
            console.log(`[ipc] → ${req._action} (${req._cbid}) ok in ${elapsed}ms`);
            handle.send(response);
        } catch (e: any) {
            const elapsed = Date.now() - ipcT0;
            console.error(`[ipc] → ${req._action} (${req._cbid}) error in ${elapsed}ms: ${e.message}`);
            handle.send({ _cbid: req._cbid, error: e.message });
        }
    });

    // Wire IMAP events → push to WebView (throttled to avoid flooding stdin)
    let pendingSyncProgress: Record<string, { phase: string; progress: number }> = {};
    let syncProgressTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("syncProgress", (accountId: string, phase: string, progress: number) => {
        pendingSyncProgress[accountId] = { phase, progress };
        if (!syncProgressTimer) {
            syncProgressTimer = setTimeout(() => {
                syncProgressTimer = null;
                for (const [id, p] of Object.entries(pendingSyncProgress)) {
                    handle.send({ _event: "syncProgress", type: "syncProgress", accountId: id, phase: p.phase, progress: p.progress });
                }
                pendingSyncProgress = {};
            }, 500); // batch sync events every 500ms
        }
    });
    let pendingCounts: Record<string, any> = {};
    let countsTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("folderCountsChanged", (accountId: string, counts: any) => {
        pendingCounts[accountId] = counts;
        if (!countsTimer) {
            countsTimer = setTimeout(() => {
                countsTimer = null;
                for (const [id, c] of Object.entries(pendingCounts)) {
                    handle.send({ _event: "folderCountsChanged", type: "folderCountsChanged", accountId: id, ...c });
                }
                pendingCounts = {};
            }, 1000); // batch count updates every 1s
        }
    });
    // Batch folderSynced events (same cadence as counts) so a sync-all burst
    // doesn't flood the WebView with one stdin write per folder.
    let pendingSynced: Record<string, { folderId: number; syncedAt: number }[]> = {};
    let syncedTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("folderSynced", (accountId: string, folderId: number, syncedAt: number) => {
        (pendingSynced[accountId] ||= []).push({ folderId, syncedAt });
        if (!syncedTimer) {
            syncedTimer = setTimeout(() => {
                syncedTimer = null;
                for (const [id, entries] of Object.entries(pendingSynced)) {
                    handle.send({ _event: "folderSynced", type: "folderSynced", accountId: id, entries });
                }
                pendingSynced = {};
            }, 1000);
        }
    });
    // C121: connect-phase progress → status-bar "Connecting to <host>
    // (<phase>, N.Ns)" ticker. Low-rate (a handful of events per connection
    // attempt), so no batching — the phase TRANSITIONS are the signal and
    // coalescing them away would defeat the point.
    imapManager.on("connectProgress", (accountId: string, info: any) => {
        handle.send({ _event: "connectProgress", type: "connectProgress", accountId, ...info });
    });
    imapManager.on("syncError", (accountId: string, error: string) => {
        handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
    });
    imapManager.on("accountError", (accountId: string, error: string, hint: string, isOAuth: boolean) => {
        handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
    });
    imapManager.on("configChanged", (filename: string) => {
        handle.send({ _event: "configChanged", type: "configChanged", filename });
    });
    imapManager.on("outboxStatus", (status: any) => {
        handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
    });
    // syncComplete drives the folder-tree refresh that picks up newly-discovered
    // folders on first run (Gmail accounts have no folders in the DB until the
    // first sync fetches the labels). Without this forward, the UI shows the
    // account but no folders, and never auto-selects the inbox.
    imapManager.on("syncComplete", (accountId: string) => {
        handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
    });
    imapManager.on("syncActionFailed", (accountId: string, action: string, uid: number, error: string) => {
        handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
    });
    // External-edit (Word) save events. The service watches the temp file
    // and emits this when Word writes; compose.ts listens and reloads Quill.
    imapManager.on("wordEditUpdated", (payload: { editId: string; html: string }) => {
        handle.send({ _event: "wordEditUpdated", type: "wordEditUpdated", ...payload });
    });
    // Cloud-write/read failures from mailx-settings → push to UI as a banner so
    // silent fall-back-to-local can no longer swallow Drive errors.
    const { onCloudError } = await import("@bobfrankston/mailx-settings");
    onCloudError((error, ctx) => {
        if (error) {
            handle.send({ _event: "cloudError", type: "cloudError", error, op: ctx?.op, filename: ctx?.filename });
        } else {
            handle.send({ _event: "cloudError", type: "cloudError", error: null });
        }
    });
    // Coalesce bodyCached into batches so a prefetch storm doesn't flood stdin
    // with one IPC write per message — lets the UI flip many rows at once.
    let pendingCached: { accountId: string; uid: number }[] = [];
    let cachedTimer: ReturnType<typeof setTimeout> | null = null;
    // Calendar/tasks refresh completion — service emits when a pull merged
    // new rows or reconciled a server-side delete. Client re-renders its
    // sidebar on receipt. No payload beyond accountId.
    imapManager.on("calendarUpdated", (payload: any) => {
        handle.send({ _event: "calendarUpdated", type: "calendarUpdated", ...payload });
    });
    imapManager.on("tasksUpdated", (payload: any) => {
        handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
    });
    imapManager.on("authScopeError", (payload: any) => {
        handle.send({ _event: "authScopeError", type: "authScopeError", ...payload });
    });
    imapManager.on("bodyCached", (accountId: string, uid: number) => {
        pendingCached.push({ accountId, uid });
        if (!cachedTimer) {
            cachedTimer = setTimeout(() => {
                cachedTimer = null;
                const batch = pendingCached;
                pendingCached = [];
                handle.send({ _event: "bodyCached", type: "bodyCached", items: batch });
            }, 500);
        }
    });
    // bodyAvailable / bodyFetchError / messageRemoved migrated to storeBus
    // by the reconciler; the wildcard subscriber below forwards them via
    // `_event: "store"` for the client's subscribeStore() consumers.

    // Store-bus → WebView. Every Store mutation flows through this single
    // forwarder; consumers on the client subscribe via subscribeStore(topic,
    // handler) for the new bus shape. No per-event legacy bridge — the
    // viewer and app-level consumers have all migrated.
    storeBus.subscribe("*", (event) => {
        handle.send({ _event: "store", type: "store", ...event });
    });

    // Pending mailto: handler (P115).
    //
    // Two pickup paths, both safe to fire concurrently because the
    // consume helpers atomically read+delete:
    //   1. Startup — client calls service.consumePendingMailto via IPC once
    //      app.ts is ready. Handles the case where `mailx --mailto <url>`
    //      spawned us; the file existed before fs.watch armed.
    //   2. Live click — fs.watch fires when a `mailx --mailto <url>` write
    //      lands while we're already running. We push an `openMailto`
    //      event; the client's onEvent listener opens compose.
    //
    // We do NOT pre-fire from the daemon at startup — that would race the
    // client poll and one of them would lose the file (whichever read it
    // first wins; the other gets null and never opens compose).
    {
        const { consumePendingMailto, getPendingMailtoFile } = await import("./mailto.js");
        try {
            const pendingPath = getPendingMailtoFile();
            const dir = path.dirname(pendingPath);
            const baseName = path.basename(pendingPath);
            fs.mkdirSync(dir, { recursive: true });
            fs.watch(dir, (_event, filename) => {
                if (filename !== baseName) return;
                if (!fs.existsSync(pendingPath)) return;
                // Brief debounce — atomic rename fires twice on Windows
                // (rename + change). The second read returns null because
                // consume deleted the file on the first; the early-return
                // above just skips a wasted IPC frame.
                setTimeout(() => {
                    const data = consumePendingMailto();
                    if (!data) return;
                    handle.send({ _event: "openMailto", type: "openMailto",
                        to: data.to, cc: data.cc, bcc: data.bcc,
                        subject: data.subject, body: data.body, inReplyTo: data.inReplyTo });
                }, 50);
            });
        } catch (e: any) {
            console.error(`  [mailto] watch setup failed: ${e.message}`);
        }
    }

    // No artificial pause here. msger's stdin is queued — writes from
    // imapManager events arriving before the WebView is ready get buffered
    // and delivered when the WebView attaches. The earlier 500 ms wait
    // was added defensively but added unconditional cold-start latency
    // for no observed benefit.

    // Register accounts in parallel. Each addAccount is independent: it
    // loads the account's config, schedules its own periodic timers, and
    // (for Gmail) authenticates OAuth. Sequencing them serialized startup
    // — bobma's IMAP LOGIN was waiting behind gmail's OAuth refresh, even
    // though the two share nothing. Parallel cuts cold-start time by
    // (#accounts - 1) × per-account-init.
    _tick("addAccount loop starting");
    await Promise.all(
        settings.accounts.filter(a => a.enabled).map(async (account) => {
            try {
                await imapManager.addAccount(account);
                console.log(`  Account: ${account.label || account.name} (${account.id})`);
            } catch (e: any) {
                console.error(`  Failed: ${account.id}: ${e.message}`);
            }
        })
    );
    _tick("addAccount complete");

    // After OAuth has completed, resolve missing display names for Google
    // accounts via the People API (contacts.readonly is in the Gmail scope).
    // "Missing" = empty or matches the email local-part default.
    try {
        const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
        const { saveAccounts } = await import("@bobfrankston/mailx-settings");
        let updated = false;
        for (const acct of settings.accounts) {
            if (!acct.enabled) continue;
            const isGoogle = acct.email.endsWith("@gmail.com")
                          || acct.email.endsWith("@googlemail.com")
                          || acct.imap?.host?.includes("gmail");
            if (!isGoogle) continue;
            const local = acct.email.split("@")[0];
            const looksDefault = !acct.name || acct.name === local;
            if (!looksDefault) continue;
            try {
                const tok = await imapManager.getOAuthToken(acct.id);
                if (!tok) continue;
                const profile = await getGoogleProfile(tok);
                if (profile?.name && profile.name !== acct.name) {
                    console.log(`  [name-resolve] ${acct.id}: '${acct.name || "(empty)"}' → '${profile.name}'`);
                    acct.name = profile.name;
                    updated = true;
                }
            } catch (e: any) {
                console.error(`  [name-resolve] ${acct.id}: ${e.message}`);
            }
        }
        if (updated) {
            try { await saveAccounts(settings.accounts); } catch (e: any) {
                console.error(`  [name-resolve] saveAccounts failed: ${e.message}`);
            }
        }
    } catch (e: any) {
        console.error(`  [name-resolve] init failed: ${e.message}`);
    }
    // Register this client device on GDrive (fire-and-forget).
    // Skip when no accounts — cloud isn't configured yet on fresh installs,
    // so the write fails with "No cloud configured" and flashes a scary banner.
    if (settings.accounts.length > 0) {
        registerClient(settings).catch(() => {});
    }

    // Start sync in background — don't block. Kick off IDLE watchers once the
    // initial sync finishes so IMAP accounts get instant-push new-mail (the
    // 5-min STATUS poll is only a safety net).
    if (settings.accounts.some(a => a.enabled)) {
        // Fast-path: fire a quick INBOX check on every account IMMEDIATELY,
        // parallel to the full syncAll. quickInboxCheckAccount uses a fresh
        // client + a cached folder list from the DB, so it skips the
        // folder-list fetch that syncAll's step 1 does. On a cold Dovecot
        // session that folder LIST can take several seconds on big trees
        // (bobma = ~105 folders) — no reason the user should wait for it
        // before seeing mail that arrived overnight in INBOX.
        for (const acct of settings.accounts) {
            if (!acct.enabled) continue;
            imapManager.quickInboxCheckAccount(acct.id).catch(e =>
                console.error(`  [startup-check] ${acct.id}: ${e?.message || e}`)
            );
        }
        imapManager.syncAll()
            .then(() => imapManager.startWatching())
            .then(() => {
                // Seed the contacts table from received messages so address
                // autocomplete works on the first compose without waiting for
                // the user to manually trigger it. Cheap — one grouped SELECT
                // + one INSERT per new sender. No-op if contact already exists.
                db.seedContactsFromMessages()
                    .then(added => { if (added > 0) console.log(`  [contacts] seeded ${added} from message senders`); })
                    .catch(e => console.error(`  [contacts] seed error: ${e?.message || e}`));
            })
            .catch(e => console.error(`  Sync error: ${e.message}`));
    }
    imapManager.startPeriodicSync(settings.sync.intervalMinutes);
    imapManager.startOutboxWorker();
    imapManager.startSentSweep();
    imapManager.watchConfigFiles();

    // Deploy app-owned reference docs (.md per JSONC schema) to the cloud
    // folder so users see them next to the .jsonc they document. Versioned
    // by app version — only redeploys when the app updates. Fire-and-forget;
    // failures don't block startup.
    try {
        const { deployDocs, ensureKeysSectionExists, ensureAutocompletePrefsExist, ensureGroupsSampleExists } = await import("@bobfrankston/mailx-settings");
        const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));
        deployDocs(rootPkg.version || "dev").catch(e => console.warn(`  [docs] ${e.message}`));
        // First-run: write `keys: { anthropic: "", openai: "" }` placeholders
        // into accounts.jsonc so the user sees where to paste API keys when
        // they open Settings → Edit config files. Idempotent.
        ensureKeysSectionExists().catch(e => console.warn(`  [keys] ensure failed: ${e.message}`));
        // Same idea for preferences.jsonc.autocomplete: materialize the AI
        // provider knobs (provider, model, ollamaUrl, enabled flags) so they
        // are visible in the config editor. Idempotent.
        ensureAutocompletePrefsExist().catch(e => console.warn(`  [autocomplete-prefs] ensure failed: ${e.message}`));
        // Same idea for contacts.jsonc.groups: write a placeholder mailing
        // list so the user sees the schema for groups. Compose recognizes
        // them as type-the-name → member expansion. Idempotent.
        ensureGroupsSampleExists().catch(e => console.warn(`  [groups-sample] ensure failed: ${e.message}`));
    } catch (e: any) {
        console.warn(`  [docs] deploy skipped: ${e.message}`);
    }
    // Re-seed contacts every 30 min so newly-received senders surface in
    // autocomplete without restarting mailx. Cheap; idempotent.
    setInterval(async () => {
        try {
            const added = await db.seedContactsFromMessages();
            if (added > 0) console.log(`  [contacts] periodic seed added ${added} new senders`);
        } catch (e: any) { console.error(`  [contacts] periodic seed error: ${e.message}`); }
    }, 30 * 60_000);
    // Drain store_sync (calendar / tasks / contacts two-way pushes) every
    // 30s. Local edits also drain immediately; this picks up rows that
    // failed their first attempt (network blip, token refresh, 5xx).
    setInterval(() => {
        svc.drainStoreSync().catch((e: any) =>
            console.error(`  [store_sync] periodic drain error: ${e?.message || e}`));
    }, 30_000);
    // Calendar + Tasks poll — pulls server-side changes so the sidebar
    // reflects events added/edited/deleted on another device without
    // needing a sidebar nav click. 5-minute cadence is well under the
    // per-user rate limit and the 1M/day project quota. Webhooks would
    // be cheaper in theory but need a public HTTPS endpoint; poll is
    // the pragmatic choice (confirmed 2026-04-23). getCalendarEvents /
    // getTasks emit the refresh event via imapManager, so the sidebar
    // re-renders automatically.
    const CAL_POLL_MS = 5 * 60_000;
    const horizonDays = 90;  // larger than sidebar's default so background
    // poll catches upcoming-week events the sidebar hasn't asked for yet.
    setInterval(() => {
        const now = Date.now();
        svc.getCalendarEvents(now, now + horizonDays * 86400_000)
            .catch((e: any) => console.error(`  [calendar] poll error: ${e?.message || e}`));
        svc.getTasks(false)
            .catch((e: any) => console.error(`  [tasks] poll error: ${e?.message || e}`));
    }, CAL_POLL_MS);

    // Contacts poll — incremental sync via People API syncToken (persisted
    // per-account in the kv table). Cheap after the first run because only
    // changed/deleted contacts come back. 15-minute cadence picks up new
    // contacts added on phone / web without restart while staying well
    // under the People API rate limit.
    const CONTACTS_POLL_MS = 15 * 60_000;
    setInterval(() => {
        svc.syncGoogleContacts()
            .catch((e: any) => console.error(`  [contacts] poll error: ${e?.message || e}`));
    }, CONTACTS_POLL_MS);

    // Auto-update: periodically check npm for a newer version and push a
    // notification to the WebView so the user can update with one click.
    const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
    // Semver-ish "a newer than b" — numeric, left-to-right.
    const __isNewer = (a: string, b: string): boolean => {
        const pa = a.split(".").map(n => parseInt(n, 10) || 0);
        const pb = b.split(".").map(n => parseInt(n, 10) || 0);
        for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
            const diff = (pa[i] || 0) - (pb[i] || 0);
            if (diff !== 0) return diff > 0;
        }
        return false;
    };
    async function checkForUpdate(): Promise<void> {
        // FIRST, network-free: has a newer build been INSTALLED on disk since
        // this daemon started? `npmglobalize` overwrites the global install's
        // package.json the instant it finishes, so re-reading it detects the
        // upgrade immediately — no npm registry round-trip (which times out on
        // a flaky network, so the banner never showed and the user kept running
        // stale code — Bob 2026-06-15 "why doesn't the new version kill old
        // daemons"). rootPkgVersion is the version THIS process is RUNNING.
        try {
            const installed = JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version;
            if (installed && __isNewer(installed, rootPkgVersion)) {
                console.log(`  [update] Newer build installed on disk: running ${rootPkgVersion} → installed ${installed}. Restart to apply.`);
                handle.send({ _event: "updateAvailable", type: "updateAvailable", current: rootPkgVersion, latest: installed, local: true });
                return; // already have the bits; no need to ask the registry
            }
        } catch { /* fall through to the remote check */ }
        try {
            // spawn with windowsHide:true — execSync briefly flashes a cmd
            // window on Windows every time the periodic check fires.
            const { spawn } = await import("child_process");
            // Check the running package's own name from package.json instead
            // of hard-coding "@bobfrankston/mailx" — that was the pre-rebrand
            // name and stayed pinned to v1.0.500, so the update banner kept
            // firing on every release telling the user to "update" to an
            // older version. Reading rootPkg.name keeps this correct across
            // any future rename (rmfmail → ...).
            const pkgName = rootPkgName;
            const latest = await new Promise<string>((resolve, reject) => {
                // Single command string + shell:true — NOT (cmd, args[], shell:true).
                // The args-array-with-shell form is deprecated (DEP0190, args
                // unescaped). shell:true is still required on Windows where
                // `npm` is `npm.cmd` and modern Node refuses to spawn a .cmd
                // without a shell. pkgName is the package.json `name` — a
                // controlled constant, no shell metacharacters.
                const child = spawn(`npm view ${pkgName} version`, {
                    windowsHide: true,
                    shell: true,
                });
                let out = "";
                let err = "";
                child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
                child.stderr.on("data", (d: Buffer) => { err += d.toString(); });
                const killer = setTimeout(() => { try { child.kill(); } catch { /* */ } reject(new Error("npm view timed out")); }, 15_000);
                child.on("error", (e) => { clearTimeout(killer); reject(e); });
                child.on("exit", (code) => {
                    clearTimeout(killer);
                    if (code === 0) resolve(out.trim());
                    else reject(new Error(err.trim() || `npm view exit ${code}`));
                });
            });
            const current = rootPkgVersion;
            // Only notify when latest is strictly NEWER than current. Plain
            // string-inequality fired on downgrades too (the rebrand-stale
            // 1.0.500 vs running 1.0.528 case), so this does a real semver
            // comparison: split on dots, compare numerically left-to-right.
            const isNewer = (a: string, b: string): boolean => {
                const pa = a.split(".").map(n => parseInt(n, 10) || 0);
                const pb = b.split(".").map(n => parseInt(n, 10) || 0);
                for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
                    const diff = (pa[i] || 0) - (pb[i] || 0);
                    if (diff !== 0) return diff > 0;
                }
                return false;
            };
            if (latest && isNewer(latest, current)) {
                console.log(`  [update] New version available: ${current} → ${latest}`);
                handle.send({ _event: "updateAvailable", type: "updateAvailable", current, latest });
            }
        } catch (e: any) {
            // Silent — network down, npm not reachable, etc.
            console.log(`  [update] Check failed: ${e.message}`);
        }
    }
    // Remote (npm registry) check: first after 2 min, then every 30 min.
    setTimeout(() => {
        checkForUpdate();
        setInterval(checkForUpdate, UPDATE_CHECK_MS);
    }, 120_000);
    // Network-free LOCAL-install poll: re-read our own package.json every 20s.
    // npmglobalize overwrites it the moment an install finishes, so the update
    // banner appears within seconds of a new build landing — even fully offline
    // and even if this daemon has been running for hours (Bob 2026-06-15). First
    // run at 15s so a banner shows soon after boot if the daemon predates the
    // installed build.
    const __localUpdatePoll = (): void => {
        try {
            const installed = JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version;
            if (installed && __isNewer(installed, rootPkgVersion)) {
                handle.send({ _event: "updateAvailable", type: "updateAvailable", current: rootPkgVersion, latest: installed, local: true });
            }
        } catch { /* non-fatal */ }
    };
    setTimeout(() => { __localUpdatePoll(); const t = setInterval(__localUpdatePoll, 20_000); (t as any).unref?.(); }, 15_000);

    // Graceful shutdown — close IMAP connections, stop timers, close DB
    let shuttingDown = false;
    async function gracefulShutdown(reason: string): Promise<void> {
        if (shuttingDown) return;
        shuttingDown = true;
        console.log(`${reason} — shutting down`);
        imapManager.stopPeriodicSync();
        imapManager.stopOutboxWorker();
        // 3s hard timeout — don't hang on broken IMAP connections
        const forceExit = setTimeout(() => { console.log("Forced exit"); process.exit(0); }, 3000);
        try { await imapManager.shutdown(); } catch { /* proceed */ }
        clearTimeout(forceExit);
        db.close();
        process.exit(0);
    }

    process.on("SIGINT", () => gracefulShutdown("SIGINT"));
    process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
    process.on("uncaughtException", (err: any) => {
        // A broken stdout/stderr pipe (EPIPE) must NOT take the whole daemon
        // down. The daemon runs detached — losing the console is cosmetic. This
        // fires when the parent terminal/pipe closes, or (the case that bit us)
        // a worker thread's piped stdio loses its sink while the worker keeps
        // logging. Swallowing it keeps mail flowing; killing the daemon over a
        // closed pipe is the bug (Bob 2026-06-16: --debug-server launch crashed
        // the daemon 3s in via a sync-worker stdio EPIPE).
        if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED" || err?.code === "ERR_STREAM_WRITE_AFTER_END"
            || /\bEPIPE\b|write after end|premature close/i.test(String(err?.message || ""))) {
            try { process.stderr.write(`[ignored ${err.code || "stream"} on stdio]\n`); } catch { /* even this can EPIPE */ }
            return;
        }
        console.error(`UNCAUGHT EXCEPTION: ${err.stack || err.message}`);
        gracefulShutdown("uncaughtException");
    });
    process.on("unhandledRejection", (reason: any) => {
        console.error(`UNHANDLED REJECTION: ${reason?.stack || reason?.message || reason}`);
    });
    process.on("exit", (code) => {
        console.log(`Process exit (code ${code})`);
        if (!shuttingDown) {
            imapManager.stopPeriodicSync();
            imapManager.stopOutboxWorker();
            db.close();
        }
    });

    // Wait for MAIN window close, then shut down. `handle` is the service
    // window ONLY — popout windows are separate showMessageBoxEx children and
    // must never resolve this. Log open popouts at shutdown so if a popout
    // close ever coincides with a main-window close we can see the timing.
    // The 2026-07-06 incident (log 13:59:56) shows the main child's own Rust
    // process emitting the close result 123ms after a popout interaction —
    // the correlation delta below is what will prove/refute the coupling
    // next time it fires.
    await handle.closed;
    const popoutDelta = lastPopoutClose
        ? `; last popout closed ${Date.now() - lastPopoutClose.at}ms ago (${lastPopoutClose.key})`
        : "; no popout closed this session";
    await gracefulShutdown(`Window closed (main service child; ${popoutWindows.size} popout window(s) still tracked${popoutDelta})`);
}

main().catch(console.error);
