# rmfmail / mailx — Architecture Review

**Date:** 2026-06-12
**Author:** Claude (Opus 4.8), at Bob's request, after a day where each fix surfaced the next failure.
**Status:** Diagnosis for independent audit. No code changed by this document.

---

## 0. How to use this document (instructions to the reviewing model)

You are being handed this to **audit, not echo**. For each numbered finding below:

1. Open the cited file(s) and **verify the claim against the actual code**. Line numbers are as of 2026-06-12 and may have drifted — confirm by content, not line number.
2. Mark each finding **CONFIRMED / PARTIALLY-CONFIRMED / WRONG**, and say *why* with your own file:line evidence.
3. Call out anything this review **overstates, misattributes, or misses**. The author was firefighting all day and may have pattern-matched. Be adversarial.
4. The repo root is `Y:/dev/email/mailx/app/` (note: code lives under `app/`; the top-level dir is not the git repo). Desktop packages are under `app/packages/`; the browser/Android variant is `app/packages/mailx-store-web`; the UI is `app/client/`.

The goal is a **correct** assessment of whether these are architectural faults or incidental bugs, and whether the proposed refactor sequence (§6) is right.

---

## 1. Executive summary

The recurring failures (messages reappearing/not disappearing after delete, ghost copies, stars on the wrong message, a 70k-row wipe on 2026-05-27, the list "summary" timing out while the preview works, folder-create silently failing, "AggregateError", search returning nothing or everything) are **not independent bugs**. They are symptoms of a small number of structural decisions:

- **No isolation between local reads and network I/O** — a remote stall blocks local-only operations (the list times out).
- **A claimed-but-unfinished local-first model** — `CLAUDE.md` itself flags the reconciliation refactor as Priority 0 / incomplete.
- **Message identity is overloaded** — per-folder IMAP UID is used as identity in many places, despite a stable `uuid` column existing.
- **No single mutation funnel** — the same logical operation (delete/move/trash) is implemented ~21 times across 6 packages, including a dead duplicate and divergent desktop/Android copies.
- **Search is three different query languages behind one box.**

Comparison point: Thunderbird/Outlook don't exhibit these because they (a) keep the UI/read path strictly off the network thread and (b) have one canonical store + one message-identity. rmfmail's debt is in exactly those two places.

---

## 2. The triggering symptom (verify first — it's the clearest signal)

**Observed:** the message list shows `Error: mailxapi timeout: getUnifiedInbox` while the preview pane successfully renders a message.

**Why this is diagnostic:** `getUnifiedInbox` is a **local SQLite read** (`packages/mailx-store/db.ts`, `getUnifiedInbox(...)`, and `packages/mailx-service` → `store.getUnifiedInbox`). It should never touch the network and should be sub-millisecond. For it to **time out** with an IPC-level `mailxapi timeout`, a pure-local read must be **queued behind a slow/hung network operation**. That is only possible if reads and network writes share one serialized pipe (see Finding #1).

**Verify:** trace `getUnifiedInbox` from the client (`client/lib/api-client.ts`) through the IPC pump (`bin/mailx.ts`) to `MailxService` and `MailxDB`. Confirm there is no network call on that path, and confirm the IPC pump serializes it behind other handlers.

---

## 3. Finding 1 — Single serialized IPC pump; no read/network isolation (root cause of the timeouts)

**Claim:** All IPC requests (interactive reads, deletes, sync, prefetch) are dispatched through one serialized `await dispatch(...)`. One slow handler blocks every subsequent call, including local-only reads.

**Evidence:**
- `bin/mailx.ts` (~lines 2049–2140): a single `handle.onRequest()` callback does `await dispatch(svc, req)` per request, serially. No concurrency, no per-action timeout, no priority lane at this layer.
- `packages/mailx-service/jsonrpc.ts:34–42`: `dispatch()` has **no timeout wrapper** and awaits the service method directly.
- `packages/mailx-imap/index.ts`: there IS a dual-lane connection model (`opsClients`/`fastClients`, `opsQueues` fast/slow, a per-host semaphore `HOST_PERMITS = 4`, ~lines 484–662) and per-op timeouts (`withTimeout`, fast 90s / slow 300s). **But** that isolation is *below* the IPC pump — it does not prevent a slow service method from holding the single dispatch await.
- Documented in code: `packages/mailx-imap/index.ts` ~2873 — "one un-fetchable message … each attempt hanging ~90s and tying up the ops queue, so user actions (delete, open) were delayed."

**Impact:** A single hung IMAP operation (today: UID 4966060 body fetch) cascades into delete timeouts, folder-create failures, `AggregateError` on new connections (host-permit exhaustion), and the `getUnifiedInbox` list timeout. These were *one* congestion event, not many bugs.

**How to verify:** Confirm `bin/mailx.ts` awaits dispatch serially. Confirm no read-only fast path that bypasses IMAP-touching handlers. Confirm whether any RPC method that should be local-only (`getMessages`, `getUnifiedInbox`, search-from-cache) can transitively `await` an IMAP call.

**This is the highest-leverage fix (see §6.1).**

---

## 4. Finding 2 — Local-first is half-wired

**Claim:** The design states "local store is source of truth; server is async-reconciled mirror," but several reads/writes still block on the daemon/IMAP, and the reconciliation refactor is explicitly unfinished.

**Evidence:**
- `CLAUDE.md` (project root) — "CRITICAL: Local-first" section and the note that the **Priority 0 "Local-first reconciliation refactor"** is the load-bearing unfinished work; several user-visible bugs are called out as symptoms of the violation.
- `docs/local-first-plan.md` — the plan; check how much is implemented vs aspirational.
- Truly local-first today (commit local + queue): `packages/mailx-service/index.ts` `deleteMessages` (~2219), `moveMessages` (~2277), `copyMessages` (~2262, new), `saveDraft` via `sync-queue.ts` `enqueueDraftPush` (~110).
- Still synchronous / blocking: cross-account `moveMessage` (~2242) explicitly calls `imapManager.moveMessageCrossAccount(...)` synchronously and is labeled a "local-first violation"; outbox send remains a dir-based queue outside the reconciler.
- Reads are NOT guaranteed local: see Finding 1 — `getUnifiedInbox`/`getMessages` can time out, which a true local-first read path cannot.

**Impact:** The app *advertises* instant local behavior but degrades to "frozen UI" under a slow server — the exact failure `CLAUDE.md` says must not happen.

**How to verify:** Enumerate every read RPC and confirm whether it can block on IMAP. Confirm which writes return after a local commit vs after a server round-trip.

---

## 5. Finding 3 — Message identity is overloaded; UID used as identity

**Claim:** Messages are keyed by per-folder IMAP UID (often without `folder_id`) in many places, despite UID not being a stable identity and despite a `uuid` column existing.

**Evidence (each is a distinct call site to verify):**
- Stable identity exists but is under-used: `messages.uuid` + `idx_messages_uuid` (`packages/mailx-store/db.ts` ~472). Most lookups still go by `(account, uid)` or `(account, folder, uid)`.
- `packages/mailx-store/db.ts` `getMessageBodyPath(...)` (~2129–2138): when `folderId` is omitted, `WHERE account_id=? AND uid=?` — **cross-folder collision** (can return the wrong folder's body).
- `db.ts` `updateMessageFolder(...)` (~2167+): `UPDATE messages SET folder_id=? WHERE account_id=? AND uid=?` — updates **every** folder's row with that UID.
- `db.ts` `deleteMessage(...)` (~2512): the comment documents the **70k-row wipe on 2026-05-27** — a QRESYNC `VANISHED` for one folder's UID deleted the same numeric UID in every other folder. Fix added `folder_id` but still allows `folderId === null` ("delete in every folder") which is a footgun.
- `db.ts` `updateMessageFlags(...)` (~2148): comment documents the "stars on un-starred messages" bug (same numeric UID across folders); the null branch is still loose.
- Client: `client/components/message-list.ts` `rowKey(accountId, uid)` (~307) omits folder → `rowByKey` collides across folders; `focusByIdentity` can land on the wrong row. (Today's "Trash rows didn't disappear" was the same class in `removeMessages` / the DOM-removal key — partially fixed 2026-06-12 by adding folder to the key.)
- Service/IMAP callers that read without folder context: `packages/mailx-service/index.ts` `moveMessages` loop uses `getMessageByUid(accountId, uid)` (no folder, ~2280 area); `packages/mailx-imap/index.ts` `processSyncActions` uses `getMessageByUid(accountId, action.uid)` without folder (~3452/3472 area).
- Re-binding after a server move depends on **Message-ID** (`db.ts` upsert ~1710–1757). Messages with empty Message-ID (malformed, partial fetch, some Gmail-API rows) never re-bind → ghosting.

**Impact:** This identity model is the common root of: messages reappearing, not disappearing, ghost copies, wrong message in viewer on auto-advance, stars on wrong rows, and the catastrophic 70k-row wipe.

**How to verify:** grep `db.ts` for `WHERE account_id = ? AND uid = ?` (no `folder_id`) and judge each. grep the client for `${accountId}:${uid}` keys. Check whether any single identity abstraction exists or it's ad-hoc per call site.

---

## 6. Finding 4 — No single mutation funnel (~21 implementations)

**Claim:** Delete/trash/move is implemented in ~21 loosely-coupled places; a fix in one rarely reaches the others. This is the mechanical reason "every fix generates more problems."

**Evidence (entry points → service → store → db, plus duplicates):**
- Client triggers (`client/app.ts`): `deleteSelectedMessages` (~1459), `spamSelectedMessages` (~1824), pop-out delete listener (~5169), right-drag move (~1710), undo handler (~1550). Each does its own optimistic `removeMessagesAndReconcile` + its own IPC.
- Service (`packages/mailx-service/index.ts`): `deleteMessage` (~2199), `deleteMessages` (~2219), `markAsSpamMessages` (~2290, delegates to `moveMessages`), `moveMessage` (~2242), `moveMessages` (~2277), `undeleteMessage` (~2358), `emptyFolder` (~2581, its own IMAP delete loop — bypasses trash).
- Store (`packages/mailx-store/store.ts`): `trashMessage` (~581, smart move-or-expunge), `undeleteMessage` (~618).
- DB (`packages/mailx-store/db.ts`): `deleteMessage` (~2512), `deleteAllMessages` (~1400).
- **DEAD duplicate:** `packages/mailx-core/index.ts` `deleteMessage` (~297) reimplements trash but **skips** `addTombstone`, `recalcFolderCounts`, the pending-delete flag, and the store bus event. It is unreachable today but is a trap (a memory note: "fixes kept landing in the dead mailx-core dup").
- **Divergent Android copies:** `packages/mailx-store-web/sync-manager.ts` `trashMessage` (~354) and `android-bootstrap.ts` `trashMessage` (~502) both **hard-delete** (no smart trash, no tombstone, no pending flag) — different side-effects from desktop.

**Impact:** When you fix one delete path (e.g., add `folderId` disambiguation, add a confirmation, fix optimistic removal), the sibling paths (`emptyFolder`'s inline loop, Android's stub, the dead core) stay broken or diverge. That is the literal mechanism behind "I fix one thing and another breaks."

**How to verify:** Confirm there is no single function all mutations funnel through. Confirm `emptyFolder` and the Android stubs do not call `LocalStore.trashMessage`. Confirm `mailx-core` is unreferenced (grep imports of `@bobfrankston/mailx-core`).

---

## 7. Finding 5 — Search is three languages behind one box

**Claim:** The one search box maps to three different executors with different semantics.

**Evidence:**
- Client-side substring filter: `client/components/message-list.ts` `setLiveFilter` (~40) — `.textContent` substring; no qualifier parsing. (This is what made `from:amazon` return nothing and `Hoddie` return everything; routed "This folder" through it until the 2026-06-12 fix.)
- DB FTS5: `packages/mailx-store/db.ts` `searchMessages` (~3176) — `MATCH` + `from:/to:/subject:/date:/is:/has:/folder:` qualifiers, wildcard, trash exclusion gated on `folderId`.
- Server IMAP sweep: `packages/mailx-service/index.ts` `search(scope="server")` (~1128) — per-folder IMAP `SEARCH`, different syntax, minutes-slow.

**Impact:** The same query string means different things by scope; qualifiers work in one path and not another. Inconsistent and surprising.

**How to verify:** Confirm the three executors and that there is no shared query parser/normalizer feeding them.

---

## 8. Cross-cutting root cause

Two decisions explain most of it:

1. **The UI/read path is not isolated from network I/O** (Findings 1–2). Fix this and the timeouts/"frozen summary"/cascade class disappears.
2. **There is no canonical identity and no canonical mutation path** (Findings 3–4). Fix this and the reappear/ghost/wrong-row/wipe class disappears.

Search (Finding 5) and Android divergence are smaller, independent cleanups.

---

## 9. Recommended refactor sequence (audit this ordering too)

**9.1 — Isolate reads from the network (highest leverage; ends the timeouts).**
- Every read RPC (`getMessages`, `getUnifiedInbox`, cached `getMessage`, FTS search) served **straight from SQLite** on a path that never `await`s IMAP and cannot be blocked by a network handler.
- Put IMAP/network work on its own queue/worker so a hung fetch can't occupy the dispatch pump. At minimum: don't serialize reads behind writes in `bin/mailx.ts`; add a hard timeout + cancellation on network-touching handlers; never let a read transitively call `withConnection`.

**9.2 — Finish local-first.** Every user action commits to SQLite and returns; the reconciler is the *only* code that touches the server. Move cross-account move and outbox under it.

**9.3 — One identity + one mutation funnel.** Use `uuid` as the in-memory and cross-folder identity; treat `(folder, uid)` as transient server metadata only. Route **all** deletes/moves/trash through a single function (validate → local commit → enqueue → undo). Delete `mailx-core`. Unify the Android `trashMessage` with the desktop store path (the `mailx-imap-core` over `Transport`/`Storage` interfaces noted as TODO C125).

**9.4 — One search parser, three executors behind it.**

Do 9.1 and 9.2 first — they're what's burning the user right now.

---

## 10. What is NOT the architecture (be honest)

Not everything today was structural. Some was incidental and some was self-inflicted:
- **No bulk-delete confirmation** (the 114-message accident) was a missing UX guard, not deep architecture. Fixed 2026-06-12.
- **Repeatedly hot-patching the running daemon** during diagnosis caused stale-version confusion and extra churn — process, not architecture.
- The **immediate trigger** (UID 4966060 stalling on the server's `BODY[]`) was external. The *architecture* is at fault only for letting that one stall cascade (Finding 1).

A fair audit should separate "architecture guarantees recurring failures" (Findings 1–4: yes) from "incidental bug or operator error" (the above).

---

## 11. Open questions for the reviewer

1. Is the IPC pump truly the bottleneck, or is there a read fast-path I missed in `bin/mailx.ts` / `api-client.ts`?
2. Is `getUnifiedInbox` actually network-free, or does it transitively trigger a fetch/reconcile?
3. How much of `docs/local-first-plan.md` is implemented vs aspirational?
4. Is `uuid` populated reliably on every row (IMAP + Gmail-API + APPEND paths), or are there rows with null `uuid` that would break a UUID-keyed identity?
5. Is the ordering in §9 right, or should identity (9.3) precede read-isolation (9.1)?

---

*End of review. Verify everything; trust nothing here on faith.*
