@bobfrankston/mailx-settings 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cloud.d.ts CHANGED
@@ -50,6 +50,8 @@ export type CloudProvider = "gdrive" | "google" | "local";
50
50
  export interface CloudFile {
51
51
  /** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
52
52
  read(filePath: string): Promise<string | null>;
53
+ /** Read a file as raw bytes, base64-encoded (binary-safe). */
54
+ readBinary(filePath: string): Promise<string | null>;
53
55
  /** Write a file. For gdrive, path is just the filename. Throws on failure with a descriptive error. */
54
56
  write(filePath: string, content: string): Promise<void>;
55
57
  /** Check if a file exists. */
package/cloud.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["cloud.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AA+RH;;;;;;;;;oDASoD;AACpD,wBAAsB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA8BnF;AAcD;;;;;;;;;;;;;;;;;6CAiB6C;AAC7C,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwBvE;AA6FD,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,SAAS;IACtB,kFAAkF;IAClF,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,uGAAuG;IACvG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,8BAA8B;IAC9B,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAuBtF;AAED;;;;2DAI2D;AAC3D,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBvG"}
1
+ {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["cloud.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AA+RH;;;;;;;;;oDASoD;AACpD,wBAAsB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA8BnF;AAcD;;;;;;;;;;;;;;;;;6CAiB6C;AAC7C,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwBvE;AAyHD,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,SAAS;IACtB,kFAAkF;IAClF,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,8DAA8D;IAC9D,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrD,uGAAuG;IACvG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,8BAA8B;IAC9B,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAyBtF;AAED;;;;2DAI2D;AAC3D,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBvG"}
package/cloud.js CHANGED
@@ -483,6 +483,44 @@ async function gDriveReadFromFolder(folderId, fileName) {
483
483
  return null;
484
484
  }
485
485
  }
486
+ /** Read a file by name from a folder as RAW BYTES, returned base64-encoded.
487
+ * Same lookup as gDriveReadFromFolder but downloads via arrayBuffer so binary
488
+ * assets (e.g. a custom reminder sound) survive — `.text()` would corrupt
489
+ * them. Returns null if not found / on error. */
490
+ async function gDriveReadBinaryFromFolder(folderId, fileName) {
491
+ const token = await getGoogleDriveToken();
492
+ if (!token) {
493
+ console.error(` [cloud] gdrive readBinary ${fileName}: no token`);
494
+ return null;
495
+ }
496
+ try {
497
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
498
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name,size)`, {
499
+ headers: { Authorization: `Bearer ${token}` },
500
+ });
501
+ if (!res.ok) {
502
+ console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
503
+ return null;
504
+ }
505
+ const data = await res.json();
506
+ const fileId = pickDriveFile(data.files, fileName);
507
+ if (!fileId)
508
+ return null;
509
+ const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
510
+ headers: { Authorization: `Bearer ${token}` },
511
+ });
512
+ if (!contentRes.ok) {
513
+ console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
514
+ return null;
515
+ }
516
+ const buf = Buffer.from(await contentRes.arrayBuffer());
517
+ return buf.toString("base64");
518
+ }
519
+ catch (e) {
520
+ console.error(` [cloud] gdrive readBinary ${fileName}: ${e.message}`);
521
+ return null;
522
+ }
523
+ }
486
524
  /** Write a file by name to a folder (by ID) — creates or updates.
487
525
  * Throws on failure with a descriptive error so callers can surface it to the UI. */
488
526
  async function gDriveWriteToFolder(folderId, fileName, content) {
@@ -544,6 +582,7 @@ export function getCloudProvider(provider, folderId) {
544
582
  }
545
583
  return {
546
584
  read: (fileName) => gDriveReadFromFolder(folderId, fileName),
585
+ readBinary: (fileName) => gDriveReadBinaryFromFolder(folderId, fileName),
547
586
  write: (fileName, content) => gDriveWriteToFolder(folderId, fileName, content),
548
587
  exists: async (fileName) => (await gDriveReadFromFolder(folderId, fileName)) !== null,
549
588
  };
@@ -555,6 +594,12 @@ export function getCloudProvider(provider, folderId) {
555
594
  catch {
556
595
  return null;
557
596
  } },
597
+ readBinary: async (p) => { try {
598
+ return fs.readFileSync(p).toString("base64");
599
+ }
600
+ catch {
601
+ return null;
602
+ } },
558
603
  write: async (p, c) => { fs.writeFileSync(p, c); },
559
604
  exists: async (p) => fs.existsSync(p),
560
605
  };
@@ -0,0 +1,182 @@
1
+ # rmfmail / mailx — Architecture Review
2
+
3
+ **Date:** 2026-06-12
4
+ **Author:** Claude (Opus 4.8), at Bob's request, after a day where each fix surfaced the next failure.
5
+ **Status:** Diagnosis for independent audit. No code changed by this document.
6
+
7
+ ---
8
+
9
+ ## 0. How to use this document (instructions to the reviewing model)
10
+
11
+ You are being handed this to **audit, not echo**. For each numbered finding below:
12
+
13
+ 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.
14
+ 2. Mark each finding **CONFIRMED / PARTIALLY-CONFIRMED / WRONG**, and say *why* with your own file:line evidence.
15
+ 3. Call out anything this review **overstates, misattributes, or misses**. The author was firefighting all day and may have pattern-matched. Be adversarial.
16
+ 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/`.
17
+
18
+ The goal is a **correct** assessment of whether these are architectural faults or incidental bugs, and whether the proposed refactor sequence (§6) is right.
19
+
20
+ ---
21
+
22
+ ## 1. Executive summary
23
+
24
+ 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:
25
+
26
+ - **No isolation between local reads and network I/O** — a remote stall blocks local-only operations (the list times out).
27
+ - **A claimed-but-unfinished local-first model** — `CLAUDE.md` itself flags the reconciliation refactor as Priority 0 / incomplete.
28
+ - **Message identity is overloaded** — per-folder IMAP UID is used as identity in many places, despite a stable `uuid` column existing.
29
+ - **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.
30
+ - **Search is three different query languages behind one box.**
31
+
32
+ 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.
33
+
34
+ ---
35
+
36
+ ## 2. The triggering symptom (verify first — it's the clearest signal)
37
+
38
+ **Observed:** the message list shows `Error: mailxapi timeout: getUnifiedInbox` while the preview pane successfully renders a message.
39
+
40
+ **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).
41
+
42
+ **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.
43
+
44
+ ---
45
+
46
+ ## 3. Finding 1 — Single serialized IPC pump; no read/network isolation (root cause of the timeouts)
47
+
48
+ **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.
49
+
50
+ **Evidence:**
51
+ - `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.
52
+ - `packages/mailx-service/jsonrpc.ts:34–42`: `dispatch()` has **no timeout wrapper** and awaits the service method directly.
53
+ - `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.
54
+ - 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."
55
+
56
+ **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.
57
+
58
+ **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.
59
+
60
+ **This is the highest-leverage fix (see §6.1).**
61
+
62
+ ---
63
+
64
+ ## 4. Finding 2 — Local-first is half-wired
65
+
66
+ **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.
67
+
68
+ **Evidence:**
69
+ - `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.
70
+ - `docs/local-first-plan.md` — the plan; check how much is implemented vs aspirational.
71
+ - 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).
72
+ - 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.
73
+ - Reads are NOT guaranteed local: see Finding 1 — `getUnifiedInbox`/`getMessages` can time out, which a true local-first read path cannot.
74
+
75
+ **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.
76
+
77
+ **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.
78
+
79
+ ---
80
+
81
+ ## 5. Finding 3 — Message identity is overloaded; UID used as identity
82
+
83
+ **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.
84
+
85
+ **Evidence (each is a distinct call site to verify):**
86
+ - 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)`.
87
+ - `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).
88
+ - `db.ts` `updateMessageFolder(...)` (~2167+): `UPDATE messages SET folder_id=? WHERE account_id=? AND uid=?` — updates **every** folder's row with that UID.
89
+ - `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.
90
+ - `db.ts` `updateMessageFlags(...)` (~2148): comment documents the "stars on un-starred messages" bug (same numeric UID across folders); the null branch is still loose.
91
+ - 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.)
92
+ - 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).
93
+ - 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.
94
+
95
+ **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.
96
+
97
+ **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.
98
+
99
+ ---
100
+
101
+ ## 6. Finding 4 — No single mutation funnel (~21 implementations)
102
+
103
+ **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."
104
+
105
+ **Evidence (entry points → service → store → db, plus duplicates):**
106
+ - 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.
107
+ - 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).
108
+ - Store (`packages/mailx-store/store.ts`): `trashMessage` (~581, smart move-or-expunge), `undeleteMessage` (~618).
109
+ - DB (`packages/mailx-store/db.ts`): `deleteMessage` (~2512), `deleteAllMessages` (~1400).
110
+ - **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").
111
+ - **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.
112
+
113
+ **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."
114
+
115
+ **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`).
116
+
117
+ ---
118
+
119
+ ## 7. Finding 5 — Search is three languages behind one box
120
+
121
+ **Claim:** The one search box maps to three different executors with different semantics.
122
+
123
+ **Evidence:**
124
+ - 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.)
125
+ - DB FTS5: `packages/mailx-store/db.ts` `searchMessages` (~3176) — `MATCH` + `from:/to:/subject:/date:/is:/has:/folder:` qualifiers, wildcard, trash exclusion gated on `folderId`.
126
+ - Server IMAP sweep: `packages/mailx-service/index.ts` `search(scope="server")` (~1128) — per-folder IMAP `SEARCH`, different syntax, minutes-slow.
127
+
128
+ **Impact:** The same query string means different things by scope; qualifiers work in one path and not another. Inconsistent and surprising.
129
+
130
+ **How to verify:** Confirm the three executors and that there is no shared query parser/normalizer feeding them.
131
+
132
+ ---
133
+
134
+ ## 8. Cross-cutting root cause
135
+
136
+ Two decisions explain most of it:
137
+
138
+ 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.
139
+ 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.
140
+
141
+ Search (Finding 5) and Android divergence are smaller, independent cleanups.
142
+
143
+ ---
144
+
145
+ ## 9. Recommended refactor sequence (audit this ordering too)
146
+
147
+ **9.1 — Isolate reads from the network (highest leverage; ends the timeouts).**
148
+ - 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.
149
+ - 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`.
150
+
151
+ **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.
152
+
153
+ **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).
154
+
155
+ **9.4 — One search parser, three executors behind it.**
156
+
157
+ Do 9.1 and 9.2 first — they're what's burning the user right now.
158
+
159
+ ---
160
+
161
+ ## 10. What is NOT the architecture (be honest)
162
+
163
+ Not everything today was structural. Some was incidental and some was self-inflicted:
164
+ - **No bulk-delete confirmation** (the 114-message accident) was a missing UX guard, not deep architecture. Fixed 2026-06-12.
165
+ - **Repeatedly hot-patching the running daemon** during diagnosis caused stale-version confusion and extra churn — process, not architecture.
166
+ - 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).
167
+
168
+ A fair audit should separate "architecture guarantees recurring failures" (Findings 1–4: yes) from "incidental bug or operator error" (the above).
169
+
170
+ ---
171
+
172
+ ## 11. Open questions for the reviewer
173
+
174
+ 1. Is the IPC pump truly the bottleneck, or is there a read fast-path I missed in `bin/mailx.ts` / `api-client.ts`?
175
+ 2. Is `getUnifiedInbox` actually network-free, or does it transitively trigger a fetch/reconcile?
176
+ 3. How much of `docs/local-first-plan.md` is implemented vs aspirational?
177
+ 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?
178
+ 5. Is the ordering in §9 right, or should identity (9.3) precede read-isolation (9.1)?
179
+
180
+ ---
181
+
182
+ *End of review. Verify everything; trust nothing here on faith.*
@@ -0,0 +1,161 @@
1
+ # rmfmail / mailx — Target Architecture & Migration Plan
2
+
3
+ **Date:** 2026-06-12
4
+ **Goal:** Thunderbird/Outlook-grade reliability without a green-field rewrite. Re-architect the **core data + sync layer**; keep the existing UI and feature surface and migrate it onto the new core in shippable, reversible phases.
5
+ **Companion:** `architecture-review.md` (the diagnosis this design answers).
6
+
7
+ ---
8
+
9
+ ## 1. The two principles that make mail clients reliable
10
+
11
+ Every reliable mail client (Thunderbird's global DB / "Panorama", Outlook's OST, Apple Mail's Envelope Index) obeys these:
12
+
13
+ 1. **The display layer never blocks on the network.** A local store (a database) is the single source of truth for *everything the user sees and does*. Opening a folder, reading a message, searching, deleting — all are local operations that complete instantly. The network is a **separate, fully asynchronous background engine** that reconciles the store with the server. A dead/slow server makes mail *stale*, never makes the UI *freeze*.
14
+
15
+ 2. **One canonical store, one stable identity, one path per operation.** A message has one durable local identity that survives folder moves and server UID renumbering. Each operation (delete, move, flag) has exactly one implementation that mutates the store and queues a server action. There are no parallel code paths to drift.
16
+
17
+ rmfmail violates both today (see review §3, §6). This design restores both.
18
+
19
+ ---
20
+
21
+ ## 2. Three layers, strictly separated
22
+
23
+ ```
24
+ ┌─────────────────────────────────────────────────────────────┐
25
+ │ UI (client/) │
26
+ │ • reads: pure store queries (instant, never await network) │
27
+ │ • writes: call Store mutation → optimistic local change │
28
+ │ + enqueue server action; return immediately │
29
+ │ • re-renders from Store change events │
30
+ └───────────────▲───────────────────────────┬─────────────────┘
31
+ │ store events │ read / mutate (local only)
32
+ ┌───────────────┴───────────────────────────▼─────────────────┐
33
+ │ STORE (the source of truth for display) │
34
+ │ • SQLite. Stable message identity = local UUID. │
35
+ │ • Pure, synchronous, network-free read API. │
36
+ │ • Mutation funnel: trash/move/flag/copy/deletePermanent — │
37
+ │ one function each, keyed by UUID. Each = local change + │
38
+ │ durable queued action + change event. │
39
+ │ • Durable action queue (idempotent, UUID-keyed). │
40
+ └───────────────▲───────────────────────────┬─────────────────┘
41
+ │ "here is new/changed data" │ "drain these actions"
42
+ ┌───────────────┴───────────────────────────▼─────────────────┐
43
+ │ SYNC ENGINE (the ONLY thing that touches the network) │
44
+ │ • Owns all IMAP/Gmail connections, fully isolated. │
45
+ │ • PULL: incremental fetch (QRESYNC/CONDSTORE → set-diff). │
46
+ │ • PUSH: drain the action queue to the server, idempotently. │
47
+ │ • RECONCILE: re-bind identity across UID changes by │
48
+ │ Message-ID / UUID. Maintain (folder,uid,uidvalidity) map. │
49
+ │ • Bounded connections, per-op timeout + cancellation, │
50
+ │ backoff, poison-action cap. A hung op affects ONLY here. │
51
+ └──────────────────────────────────────────────────────────────┘
52
+ ```
53
+
54
+ The hard rule: **an arrow never points "up-and-blocking."** The UI calls into the Store and returns; it never awaits the Sync Engine. The Sync Engine pushes results down into the Store and the Store emits events up to the UI. There is no path where a network stall can sit inside a UI read.
55
+
56
+ ---
57
+
58
+ ## 3. Identity (fixes review §5 — the reappear/ghost/wipe class)
59
+
60
+ - Every message gets a **stable local `id` (UUID)** at first sight.
61
+ - The identity key is **Message-ID** when present; otherwise a synthesized stable hash of `(account, internaldate, from, subject, size)`. This guarantees an identity even for malformed / Message-ID-less mail (the gap that breaks re-binding today).
62
+ - `(folder, server_uid, uidvalidity)` is **transient routing metadata**, owned and maintained only by the Sync Engine, in a `message_locations` table. **Nothing in the UI or Store mutations is ever keyed on a bare `uid`.**
63
+ - Multi-folder membership (Gmail labels, or a message genuinely in two folders) is N rows in `message_locations` pointing at one message UUID.
64
+ - Server-side UID renumber (move, UIDVALIDITY bump) re-binds the location row to the same UUID by Message-ID match — never creates a new identity, never orphans the body.
65
+
66
+ **Consequence:** the 70k-row wipe (a `WHERE uid=?` that hit every folder) becomes structurally impossible — there is no UID-only delete. Cross-folder collisions, stars-on-wrong-rows, ghosts, wrong-row-on-auto-advance all dissolve because UID is no longer identity.
67
+
68
+ ---
69
+
70
+ ## 4. Mutation funnel (fixes review §6 — "every fix breaks another path")
71
+
72
+ The Store exposes exactly these mutators, each taking message UUIDs:
73
+
74
+ ```
75
+ store.trash(uuids) // move to Trash, or permanent-expunge if already in Trash
76
+ store.move(uuids, toFolder)
77
+ store.copy(uuids, toFolder)
78
+ store.flag(uuids, flag, on)
79
+ store.deletePermanent(uuids)
80
+ store.markRead(uuids, on)
81
+ ```
82
+
83
+ Each does, atomically: **(1)** update local rows + folder counts, **(2)** record an undo entry, **(3)** enqueue a durable, idempotent server action keyed by UUID, **(4)** emit a change event.
84
+
85
+ - **Every** UI trigger (Delete key, toolbar, drag, right-drag menu, context menu, pop-out, undo) calls these and nothing else. No optimistic-removal logic scattered in the UI; the UI just re-renders from the change event.
86
+ - `emptyFolder` becomes `store.deletePermanent(all-uuids-in-folder)` — same funnel, not a separate IMAP loop.
87
+ - **Delete `packages/mailx-core`** (dead duplicate).
88
+ - **Android shares this exact Store + queue.** The only platform difference is the `Transport` the Sync Engine injects (Node TCP vs bridge). No second `trashMessage`.
89
+
90
+ ---
91
+
92
+ ## 5. Read path & dispatch isolation (fixes review §1/§2 — the timeouts you're hitting now)
93
+
94
+ - All reads — `listFolder`, `unifiedInbox`, `getMessage`, `search` — are **pure synchronous SQLite** behind a single read facade. They **cannot** call the network. `getMessage` returns the cached body if present, else `{cached:false}` and the UI shows a placeholder while the Sync Engine fetches and emits `bodyReady`. A read **can never time out.**
95
+ - The IPC dispatch (`bin/mailx.ts`) must not serialize a read behind a network write. Two concrete options (pick after measuring):
96
+ - **(A)** Run the Sync Engine's network work off the dispatch thread (worker), so `dispatch()` only ever does fast local work.
97
+ - **(B)** Split dispatch into a read lane (own SQLite read handle, always-fast) and a write/command lane; reads are answered immediately regardless of what the write lane is doing.
98
+ - Network handlers get a hard wall-clock timeout + cancellation; a hung op is abandoned and its connection discarded, never occupying a shared slot. (The `withTimeout`/dual-lane machinery already exists in `mailx-imap` — it just needs to be *above* nothing that a read depends on.)
99
+
100
+ **Consequence:** `getUnifiedInbox` (and every list/read) returns instantly even while the server is hung. The "nothing in the summary" / `mailxapi timeout` failure class is gone by construction.
101
+
102
+ ---
103
+
104
+ ## 6. Sync Engine robustness (the part that earns "Thunderbird-grade")
105
+
106
+ - **Connection discipline:** per-account budget with a **dedicated interactive connection** that background sync/prefetch can never starve. Per-host semaphore stays as the server-cap guard.
107
+ - **Every op:** wall-clock timeout + cancellation; on timeout, discard the connection and back off. A single un-fetchable message (today's 4966060) is recorded with backoff and never re-attempted in a tight loop.
108
+ - **Push queue:** durable, idempotent, UUID-keyed actions; retry with backoff; a **poison cap** (give up after N, surface to the UI) so one bad action can't loop forever.
109
+ - **Pull:** capability-gated incremental sync — QRESYNC/CONDSTORE when the server advertises them, UID set-diff fallback otherwise; persist per-folder `UIDVALIDITY` + `HIGHESTMODSEQ`. (Memory note: capability-gate, don't hostname-branch.)
110
+ - **Reconcile:** the only place that re-binds UID↔UUID; tombstones with retained UUID suppress re-fetch of locally-deleted mail; UIDVALIDITY bump triggers a clean re-map, not a wipe.
111
+
112
+ ---
113
+
114
+ ## 7. Migration plan — phased, shippable, reversible (NOT big-bang)
115
+
116
+ Each phase ships on its own, is independently verifiable, and can be reverted. We do **not** rewrite the UI or the features.
117
+
118
+ **Phase 0 — Read isolation (do first; ends the timeouts).**
119
+ - Guarantee every read RPC is network-free and cannot be head-of-line-blocked. Implement §5 option A or B in `bin/mailx.ts` + the service read methods. Add hard timeouts/cancellation to network handlers.
120
+ - *Exit test:* with the IMAP server artificially hung, the list, folder switch, search, and cached-message open all still respond instantly. No `mailxapi timeout` on any read.
121
+
122
+ **Phase 1 — Identity + mutation funnel.**
123
+ - Introduce/standardize the UUID identity and `message_locations`; backfill UUIDs (rebuild-from-server is acceptable per the "no migrations, redownload" rule).
124
+ - Route all deletes/moves/flags through the §4 funnel; delete `mailx-core`; converge `emptyFolder`.
125
+ - *Exit test:* a scripted scenario with the same numeric UID in INBOX + Trash + Sent — delete/flag/move one and confirm the others are untouched. No ghost rows after a move+delete cycle.
126
+
127
+ **Phase 2 — Sync Engine extraction.**
128
+ - Make the Sync Engine the sole network owner with §6 discipline; move cross-account move and outbox under it. Dedicated interactive lane.
129
+ - *Exit test:* hung-folder fault injection — background sync stalls, interactive open/delete stay responsive; poison action gives up instead of looping.
130
+
131
+ **Phase 3 — Search unification + Android convergence.**
132
+ - One query parser feeding the (local-FTS / server-IMAP) executors. Android uses the shared Store + queue + a `Transport`.
133
+ - *Exit test:* identical query semantics across scopes; Android delete sets the same pending/tombstone state as desktop.
134
+
135
+ Order rationale: Phase 0 stops the bleeding you're seeing *right now*; Phase 1 removes the data-integrity class (the scary one — the wipe); Phases 2–3 are robustness + cleanup.
136
+
137
+ ---
138
+
139
+ ## 8. What we explicitly do NOT do
140
+
141
+ - No green-field rewrite. The UI, compose/editor, providers, OAuth, calendar/tasks, Android shell, and the feature set stay.
142
+ - No big-bang cutover. Each phase coexists with the rest.
143
+ - No hot-patching the live daemon during this work — changes land, get built, and are verified deliberately (the firefighting pattern is over).
144
+
145
+ ---
146
+
147
+ ## 9. How we'll verify reliability (the Thunderbird bar)
148
+
149
+ A short fault-injection harness, run after each phase:
150
+ 1. **Server hung** (stall `BODY[]` / SELECT): UI reads stay instant; no timeouts.
151
+ 2. **Server slow** (5s/op): actions queue and drain; UI never blocks.
152
+ 3. **Offline → online:** queued actions drain in order, idempotently; no dupes, no loss.
153
+ 4. **UID collision** across folders: per-message ops never hit the wrong message.
154
+ 5. **UIDVALIDITY bump:** re-map, no wipe.
155
+ 6. **Crash mid-action:** durable queue resumes; nothing lost, nothing double-applied.
156
+
157
+ When all six pass under injection, we're at the reliability bar you're asking for.
158
+
159
+ ---
160
+
161
+ *This is the target. Phase 0 is the next concrete step and is self-contained.*
@@ -0,0 +1,215 @@
1
+ # Outlook.com / Microsoft 365 Support
2
+
3
+ Status (2026-06-05): **scaffolding present, not usable.** An account auto-detects
4
+ the right hosts but cannot authenticate — there is no Microsoft OAuth wired, and
5
+ Microsoft has disabled basic-auth (password) for outlook.com / office365, so the
6
+ IMAP path can't fall back to a password either. Gmail is the only fully-wired
7
+ OAuth provider today.
8
+
9
+ This doc scopes what it takes to make Outlook a first-class account.
10
+
11
+ ---
12
+
13
+ ## What already exists
14
+
15
+ | Piece | Where | State |
16
+ |---|---|---|
17
+ | Graph API provider | `@bobfrankston/mailx-sync/outlook.ts` (re-exported `packages/mailx-imap/providers/outlook-api.ts`) | **Read-only**: `listFolders`, `fetchSince/ByDate/ByUids/One`, `getUids`. No send, no write-back, no drafts/attachments. |
18
+ | Host auto-config | `packages/mailx-settings/index.ts:503` (`outlook.com`, `hotmail.com` → `outlook.office365.com:993` / `smtp.office365.com:587`, `auth: "oauth2"`) | Done |
19
+ | MX-based detection | `bin/mailx.ts:1307` (`*.outlook.com` / `*.protection.outlook.com` MX → Microsoft 365) | Done |
20
+ | Generic OAuth flow | `@bobfrankston/oauthsupport` `OAuthTokenManager.ts:369` — validates `client_id/client_secret/auth_uri/token_uri` from the creds file; localhost loopback auth-code flow | **Provider-agnostic** — not Google-specific |
21
+ | OAuth SMTP send | `packages/mailx-imap/index.ts:1533` builds `{type:"OAuth2", user, accessToken}` from `config.tokenProvider()` | Works for any provider with a tokenProvider |
22
+
23
+ The single thing that makes Gmail work and Outlook not: the **tokenProvider** at
24
+ `packages/mailx-imap/index.ts:798-848` is built only for Google. It hardcodes the
25
+ Google credentials file (`~/.mailx/google-credentials.json`) and the all-Google
26
+ scope string. Nothing constructs a Microsoft tokenProvider, so
27
+ `OutlookApiProvider` is never instantiated and the office365 IMAP host has no
28
+ token to present.
29
+
30
+ ---
31
+
32
+ ## Decision: Graph API vs IMAP/XOAUTH2
33
+
34
+ Both need the same Azure app registration + Microsoft OAuth. The difference is
35
+ the sync transport once you have a token.
36
+
37
+ **Recommended: Graph API.** It mirrors the Gmail-API model the codebase already
38
+ follows (`isGmailAccount()` branch → REST provider, no IMAP client). Wins: no
39
+ connection-limit/timeout class of bugs (the entire `inactivityTimeout` /
40
+ `greetingTimeout` / chunk-size tuning at `index.ts:862` is IMAP-only pain);
41
+ provider is already half-written; same `MailProvider` interface so Android reuses
42
+ it verbatim. Cost: the provider is read-only — send + flag/move/delete + drafts
43
+ must be added (Graph endpoints, ~a day). Sharp edge: `idToUid()`
44
+ (`outlook.ts` ~135) hashes Graph's long string IDs to 48-bit ints, and
45
+ `fetchByUids`/`fetchOne` re-list the **whole** folder then filter — O(folder) per
46
+ fetch with collision risk. Real sync needs a UID↔providerId map (we already keep
47
+ `provider_id` in the DB) and direct `GET /messages/{id}` instead of list-and-scan.
48
+
49
+ **Alternative: IMAP + XOAUTH2.** Reuses the existing iflow-direct sync path
50
+ unchanged — just feed it a Microsoft token instead of a Google one. Wins: zero
51
+ new sync/send code; flag/move/delete/append already work over IMAP. Cost:
52
+ inherits every IMAP fragility we've spent months hardening for Dovecot/Gmail, now
53
+ against Exchange Online's quirks. Sharp edge: Microsoft is steering third-party
54
+ clients toward Graph and away from consumer IMAP; betting the integration on
55
+ office365 IMAP is the less future-proof path.
56
+
57
+ Pragmatic call: **IMAP/XOAUTH2 first** as the fast path to a working account (it's
58
+ mostly OAuth plumbing), then migrate sync to Graph for parity with Gmail. The
59
+ OAuth work below is shared, so it's not wasted either way.
60
+
61
+ ---
62
+
63
+ ## Work breakdown
64
+
65
+ ### 1. Azure app registration [S, one-time, external]
66
+ Register an app in Entra/Azure AD:
67
+ - Account type: "personal Microsoft accounts" (consumer outlook.com) and/or
68
+ "any org directory" (Microsoft 365). For both, use the `/common` authority.
69
+ - Redirect URI: `http://localhost` (loopback) — matches oauthsupport's flow
70
+ (`OAuthTokenManager.ts:463`, parses the loopback callback).
71
+ - API permissions (delegated):
72
+ - Graph path: `Mail.ReadWrite`, `Mail.Send`, `offline_access`, `openid`,
73
+ `profile`.
74
+ - IMAP path: `https://outlook.office.com/IMAP.AccessAsUser.All`,
75
+ `https://outlook.office.com/SMTP.Send`, `offline_access`.
76
+ - Produce a `microsoft-credentials.json` shaped like the Google one, with
77
+ `auth_uri: https://login.microsoftonline.com/common/oauth2/v2.0/authorize`,
78
+ `token_uri: https://login.microsoftonline.com/common/oauth2/v2.0/token`,
79
+ `redirect_uris: ["http://localhost"]`, plus `client_id` / `client_secret`.
80
+
81
+ Store at `~/.mailx/microsoft-credentials.json` (parallel to
82
+ `google-credentials.json`).
83
+
84
+ ### 2. Microsoft tokenProvider [M]
85
+ Generalize `index.ts:798-848` so the provider isn't Google-only:
86
+ - Pick the creds file + scope by account kind (gmail vs outlook), keyed off the
87
+ same signal `account.imap.auth === "oauth2"` plus host/email domain.
88
+ - For Outlook, point `authenticateOAuth` at `microsoft-credentials.json` with the
89
+ Microsoft scope set. The flow itself is unchanged — oauthsupport already reads
90
+ the endpoints from the creds file, so no oauthsupport changes are required
91
+ (confirm consumer-account PKCE: Microsoft may require `code_challenge` for the
92
+ loopback client; if so, add PKCE support in `OAuthTokenManager.getToken`).
93
+ - Token cache dir reuses the existing `tokens/<user>/` convention (`index.ts:814`).
94
+
95
+ This single change makes the **IMAP/XOAUTH2 path work end-to-end** — the office365
96
+ host config already exists, and SMTP OAuth (`index.ts:1533`) consumes the same
97
+ tokenProvider. Verify the token is minted with Outlook IMAP/SMTP scopes, not Graph
98
+ scopes (they're different audiences).
99
+
100
+ ### 3. Provider selection [S]
101
+ Add an `isOutlookAccount()` sibling to `isGmailAccount()` (`index.ts:898`) and an
102
+ `getOutlookProvider()` mirroring `getGmailProvider()` (`index.ts:906`) that does
103
+ `new OutlookApiProvider(config.tokenProvider)`. Then extend each
104
+ `isGmailAccount()` branch (sync, periodic STATUS, IDLE-skip, etc. — ~10 sites) to
105
+ a three-way provider switch. *(Only needed for the Graph path; the IMAP path
106
+ needs none of this — it flows through the normal IMAP code.)*
107
+
108
+ ### 4. Graph write-back + send [M] *(Graph path only)*
109
+ Add to `OutlookApiProvider`:
110
+ - `sendMail`: `POST /sendMail` with the MIME or the Graph message object.
111
+ - mark read/flag: `PATCH /messages/{id}` (`isRead`, `flag`).
112
+ - move: `POST /messages/{id}/move`.
113
+ - delete: `DELETE /messages/{id}` (or move to deleteditems).
114
+ - draft create/update: `POST /messages` / `PATCH`.
115
+ - attachments: `GET /messages/{id}/attachments`.
116
+ Replace list-and-scan in `fetchByUids`/`fetchOne` with direct
117
+ `GET /messages/{providerId}` using the DB's `provider_id` mapping.
118
+
119
+ ### 5. Setup UX [S]
120
+ `bin/mailx.ts` `knownOAuth` list (~line 1320) already includes `outlook.com` /
121
+ `hotmail.com`. Once the tokenProvider exists, first-run setup triggers the
122
+ Microsoft consent screen the same way Gmail does — no extra UI. Add an error
123
+ banner case for "Microsoft credentials missing" pointing at
124
+ `microsoft-credentials.json`.
125
+
126
+ ---
127
+
128
+ ## Step-by-step (with URLs)
129
+
130
+ ### A. Register the Azure app — the part only you can do
131
+ 1. Sign in to the Entra admin center → **App registrations**:
132
+ https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
133
+ (equivalently Azure portal → "Microsoft Entra ID" → "App registrations":
134
+ https://portal.azure.com)
135
+ 2. **New registration** (guide:
136
+ https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app):
137
+ - Name: `rmfmail`.
138
+ - Supported account types: **"Accounts in any organizational directory and
139
+ personal Microsoft accounts"** (covers both outlook.com consumer and M365).
140
+ - Redirect URI: platform **"Mobile and desktop applications"**, value
141
+ **`http://localhost`** (loopback — what oauthsupport's flow listens on).
142
+ Desktop/loopback registration:
143
+ https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-app-registration
144
+ 3. Copy the **Application (client) ID** from the app's Overview page.
145
+ 4. **Certificates & secrets** → **New client secret** → copy the secret *value*
146
+ (not the ID). (Public-client/PKCE is the alternative if you'd rather not ship a
147
+ secret — see Sharp edges.)
148
+ 5. **API permissions** → **Add a permission** → **Microsoft Graph** → **Delegated**
149
+ (permissions reference:
150
+ https://learn.microsoft.com/en-us/graph/permissions-reference):
151
+ - Graph path: `Mail.ReadWrite`, `Mail.Send`, `offline_access`, `openid`,
152
+ `profile`.
153
+ - IMAP path instead/also: `IMAP.AccessAsUser.All`, `SMTP.Send`,
154
+ `offline_access` (these live under the *Office 365 Exchange Online* API, not
155
+ Graph). Microsoft's IMAP/SMTP-OAuth guide:
156
+ https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
157
+ - Click **Grant admin consent** if it's a work tenant (personal accounts
158
+ consent at first sign-in).
159
+
160
+ Reference for the endpoints used below:
161
+ - Authorize: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
162
+ - Token: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
163
+ - Auth-code flow spec:
164
+ https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
165
+ - Graph mail API overview:
166
+ https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview
167
+
168
+ ### B. Drop in the credentials file
169
+ Create `~/.mailx/microsoft-credentials.json` mirroring the Google one's shape so
170
+ oauthsupport's `OAuthTokenManager` (reads `client_id`/`client_secret`/`auth_uri`/
171
+ `token_uri`, `OAuthTokenManager.ts:369`) consumes it unchanged:
172
+
173
+ {
174
+ "installed": {
175
+ "client_id": "<Application (client) ID>",
176
+ "client_secret": "<secret value>",
177
+ "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
178
+ "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
179
+ "redirect_uris": ["http://localhost"]
180
+ }
181
+ }
182
+
183
+ ### C. Wire the code (steps 2-3 from the work breakdown)
184
+ 1. In `packages/mailx-imap/index.ts` (~798), branch the tokenProvider on account
185
+ kind: Outlook accounts use `microsoft-credentials.json` + the Microsoft scope
186
+ string (IMAP scopes for the IMAP path, Graph scopes for the Graph path).
187
+ 2. For the **IMAP path** that's the whole change — the office365 host config
188
+ (`mailx-settings/index.ts:503`) and OAuth-SMTP (`index.ts:1533`) already
189
+ consume the tokenProvider. Test sign-in: first send triggers the loopback
190
+ consent at `http://localhost`.
191
+ 3. For the **Graph path**, add `isOutlookAccount()`/`getOutlookProvider()` next to
192
+ the Gmail equivalents (`index.ts:898/906`) and fill the provider's write-back
193
+ gaps (`sendMail`, flag/move/delete, drafts).
194
+
195
+ ## Sharp edges / open questions
196
+
197
+ - **Consumer vs M365 differ.** Personal outlook.com and Microsoft 365 work
198
+ accounts use different scope audiences and consent behavior. `/common` covers
199
+ both at the authority level, but test both — a token good for Graph isn't valid
200
+ for IMAP and vice-versa.
201
+ - **PKCE.** Microsoft increasingly requires PKCE for loopback clients. If consent
202
+ fails with `invalid_request`, add `code_challenge`/`code_verifier` to
203
+ oauthsupport's auth-code flow (it's the one oauthsupport change that might be
204
+ needed).
205
+ - **Basic auth is gone.** Don't add a password fallback for office365 — it will
206
+ always fail. The error path should say "Outlook requires sign-in", not "wrong
207
+ password".
208
+ - **Drive/OneDrive is unrelated.** `cloud.ts` already sketches MS Graph
209
+ `Files.ReadWrite` scopes for OneDrive storage — that's a separate app/scope from
210
+ mail and shares none of this wiring (and OneDrive cloud storage was removed;
211
+ GDrive only).
212
+ - **`idToUid` collisions.** A 32-bit hash of Graph IDs across a 10-year mailbox
213
+ will eventually collide. The Graph path must key on `provider_id`
214
+ (string) as identity, with the integer UID as a display/sort convenience only —
215
+ same lesson as `imap_uid_not_identity`.
package/index.d.ts CHANGED
@@ -27,6 +27,12 @@ export declare function getLastCloudError(): string | null;
27
27
  declare function getSharedDir(): string;
28
28
  /** Read a file via cloud API (when filesystem mount not available) */
29
29
  export declare function cloudRead(filename: string): Promise<string | null>;
30
+ /** Read a file from the shared cloud config folder as raw bytes, base64-encoded.
31
+ * Binary-safe sibling of cloudRead — used for the optional custom reminder
32
+ * sound that lives next to the JSONC config on Drive. Returns null if cloud
33
+ * isn't configured, the file is missing, or the read errors (caller falls back
34
+ * to the built-in chime). */
35
+ export declare function cloudReadBinary(filename: string): Promise<string | null>;
30
36
  /** Write a file via cloud API. Throws on failure with a descriptive error,
31
37
  * and updates lastCloudError so UI banners pick it up via getStorageInfo()
32
38
  * and the onCloudError listener. */
@@ -79,6 +85,9 @@ declare const DEFAULT_PREFERENCES: {
79
85
  historyDays: number;
80
86
  prefetch: boolean;
81
87
  };
88
+ reminders: {
89
+ sound: string;
90
+ };
82
91
  autocomplete: {
83
92
  enabled: boolean;
84
93
  provider: "ollama";
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"}
package/index.js CHANGED
@@ -288,6 +288,35 @@ export async function cloudRead(filename) {
288
288
  // Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
289
289
  return content;
290
290
  }
291
+ /** Read a file from the shared cloud config folder as raw bytes, base64-encoded.
292
+ * Binary-safe sibling of cloudRead — used for the optional custom reminder
293
+ * sound that lives next to the JSONC config on Drive. Returns null if cloud
294
+ * isn't configured, the file is missing, or the read errors (caller falls back
295
+ * to the built-in chime). */
296
+ export async function cloudReadBinary(filename) {
297
+ if (!pendingCloudConfig)
298
+ return null;
299
+ if (!pendingCloudConfig.folderId) {
300
+ pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
301
+ if (pendingCloudConfig.folderId)
302
+ saveFolderIdToConfig(pendingCloudConfig.folderId);
303
+ if (!pendingCloudConfig.folderId)
304
+ return null;
305
+ }
306
+ const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
307
+ if (!provider?.readBinary)
308
+ return null;
309
+ try {
310
+ return await Promise.race([
311
+ provider.readBinary(filename),
312
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`cloudReadBinary ${filename}: 15s timeout`)), 15_000)),
313
+ ]);
314
+ }
315
+ catch (e) {
316
+ console.error(` [cloud] readBinary ${filename}: ${e?.message || e}`);
317
+ return null;
318
+ }
319
+ }
291
320
  /** Write a file via cloud API. Throws on failure with a descriptive error,
292
321
  * and updates lastCloudError so UI banners pick it up via getStorageInfo()
293
322
  * and the onCloudError listener. */
@@ -622,6 +651,16 @@ const DEFAULT_PREFERENCES = {
622
651
  historyDays: 30,
623
652
  prefetch: true,
624
653
  },
654
+ reminders: {
655
+ // Sound played when a calendar/task reminder fires.
656
+ // "" → built-in chime (a soft two-note tone, no file needed)
657
+ // "none" → silent
658
+ // <name> → a custom sound FILE, resolved relative to the config:
659
+ // tried in the LOCAL config dir (~/.rmfmail/<name>) first,
660
+ // then in the shared cloud config folder on Drive. If it
661
+ // can't be read or played, falls back to the built-in chime.
662
+ sound: "",
663
+ },
625
664
  autocomplete: {
626
665
  enabled: false,
627
666
  provider: "ollama",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "license": "ISC",
19
19
  "dependencies": {
20
- "@bobfrankston/mailx-types": "^0.1.18",
20
+ "@bobfrankston/mailx-types": "^0.1.19",
21
21
  "jsonc-parser": "^3.3.1"
22
22
  },
23
23
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  ".transformedSnapshot": {
35
35
  "dependencies": {
36
- "@bobfrankston/mailx-types": "^0.1.18",
36
+ "@bobfrankston/mailx-types": "^0.1.19",
37
37
  "jsonc-parser": "^3.3.1"
38
38
  }
39
39
  }