@bobfrankston/rmfmail 1.2.50 → 1.2.66

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.
Files changed (90) hide show
  1. package/client/android-bootstrap.bundle.js +63 -2
  2. package/client/android-bootstrap.bundle.js.map +4 -4
  3. package/client/app.bundle.js +417 -63
  4. package/client/app.bundle.js.map +3 -3
  5. package/client/app.js +182 -24
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +166 -21
  8. package/client/components/calendar-sidebar.js +5 -5
  9. package/client/components/calendar-sidebar.js.map +1 -1
  10. package/client/components/calendar-sidebar.ts +5 -5
  11. package/client/components/folder-tree.js +81 -0
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +70 -0
  14. package/client/components/message-list.js +282 -38
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +264 -36
  17. package/client/compose/compose.bundle.js +70 -14
  18. package/client/compose/compose.bundle.js.map +2 -2
  19. package/client/compose/compose.css +28 -7
  20. package/client/compose/compose.js +64 -4
  21. package/client/compose/compose.js.map +1 -1
  22. package/client/compose/compose.ts +66 -4
  23. package/client/compose/editor.js +23 -7
  24. package/client/compose/editor.js.map +1 -1
  25. package/client/compose/editor.ts +23 -7
  26. package/client/help/search-help.js +4 -3
  27. package/client/help/search-help.js.map +1 -1
  28. package/client/help/search-help.ts +4 -3
  29. package/client/index.html +5 -1
  30. package/client/lib/api-client.js +4 -2
  31. package/client/lib/api-client.js.map +1 -1
  32. package/client/lib/api-client.ts +4 -2
  33. package/client/lib/mailxapi.js +2 -2
  34. package/client/lib/rmf-tiny.js +39 -6
  35. package/client/styles/components.css +11 -0
  36. package/docs/azure.md +58 -0
  37. package/docs/config-help.md +131 -0
  38. package/docs/edit-in-word-docx.md +98 -0
  39. package/docs/host-abstraction-plan.md +169 -0
  40. package/docs/local-first-plan.md +303 -0
  41. package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  42. package/docs/outlook.md +80 -18
  43. package/docs/outlook.txt +35 -0
  44. package/docs/rules-design.md +172 -0
  45. package/package.json +9 -9
  46. package/packages/mailx-imap/index.d.ts +48 -1
  47. package/packages/mailx-imap/index.d.ts.map +1 -1
  48. package/packages/mailx-imap/index.js +320 -68
  49. package/packages/mailx-imap/index.js.map +1 -1
  50. package/packages/mailx-imap/index.ts +311 -65
  51. package/packages/mailx-imap/microsoft-credentials.json +8 -0
  52. package/packages/mailx-imap/package-lock.json +2 -2
  53. package/packages/mailx-imap/package.json +1 -1
  54. package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
  55. package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
  56. package/packages/mailx-imap/providers/outlook-api.js +7 -0
  57. package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
  58. package/packages/mailx-service/index.d.ts +7 -1
  59. package/packages/mailx-service/index.d.ts.map +1 -1
  60. package/packages/mailx-service/index.js +8 -8
  61. package/packages/mailx-service/index.js.map +1 -1
  62. package/packages/mailx-service/index.ts +8 -7
  63. package/packages/mailx-service/jsonrpc.js +1 -1
  64. package/packages/mailx-service/jsonrpc.js.map +1 -1
  65. package/packages/mailx-service/jsonrpc.ts +1 -1
  66. package/packages/mailx-settings/docs/azure.md +58 -0
  67. package/packages/mailx-settings/docs/config-help.md +131 -0
  68. package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
  69. package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
  70. package/packages/mailx-settings/docs/local-first-plan.md +303 -0
  71. package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  72. package/packages/mailx-settings/docs/outlook.md +80 -18
  73. package/packages/mailx-settings/docs/rules-design.md +172 -0
  74. package/packages/mailx-settings/index.d.ts.map +1 -1
  75. package/packages/mailx-settings/index.js +9 -8
  76. package/packages/mailx-settings/index.js.map +1 -1
  77. package/packages/mailx-settings/index.ts +9 -8
  78. package/packages/mailx-settings/package.json +1 -1
  79. package/packages/mailx-store/db.d.ts +36 -1
  80. package/packages/mailx-store/db.d.ts.map +1 -1
  81. package/packages/mailx-store/db.js +100 -6
  82. package/packages/mailx-store/db.js.map +1 -1
  83. package/packages/mailx-store/db.ts +100 -6
  84. package/packages/mailx-store/package.json +1 -1
  85. package/packages/mailx-types/contact-rules.d.ts +1 -1
  86. package/packages/mailx-types/contact-rules.js +1 -1
  87. package/packages/mailx-types/contact-rules.js.map +1 -1
  88. package/packages/mailx-types/contact-rules.jsonc +1 -1
  89. package/packages/mailx-types/contact-rules.ts +1 -1
  90. package/packages/mailx-types/package.json +1 -1
@@ -0,0 +1,303 @@
1
+ # Local-first reconciliation plan
2
+
3
+ > Status: **plan, not implementation**. Reviewed before any code is touched.
4
+ > Owner: Claude. Approver: Bob.
5
+
6
+ ## The rule
7
+
8
+ The local store is the source of truth for everything the user sees and does.
9
+ The server is a separate, asynchronously-reconciled mirror.
10
+ **No user-action code path waits on a network call.**
11
+
12
+ Every user-visible read returns from local SQLite + local files in microseconds.
13
+ Every user-visible write commits to the local store synchronously and returns
14
+ the local id immediately. Server-side mirroring is the sync layer's job, not
15
+ the UI's.
16
+
17
+ This document defines the invariants, the new API split, the event protocol,
18
+ the migration order, and the visible-state model.
19
+
20
+ ## Invariants the implementation must enforce
21
+
22
+ 1. **Reads never await server.** `getMessage`, `getMessages`, `getUnifiedInbox`,
23
+ `searchMessages` (local scope), `getCalendarEvents`, `getTasks`, autocomplete —
24
+ all read from local DB and return synchronously. If the IPC layer can't be
25
+ sync, it must return a Promise that resolves on the same tick.
26
+
27
+ 2. **Writes ACK from a local commit.**
28
+ - `saveDraft` writes the local Drafts row + `.eml` and returns the local UUID
29
+ in the same tick. The IMAP push is queued, never awaited.
30
+ - `send` writes the local Outbox row + `.ltr` and returns immediately. SMTP
31
+ and Sent-folder append are background work.
32
+ - `move`, `flag`, `delete` commit the local row mutation, return, and queue
33
+ the server-side mirror.
34
+
35
+ 3. **The sync queue is the only IMAP-touching code path.** A single background
36
+ reconciler drains queued local actions to the server, polls for server
37
+ changes, and emits events to the UI. Nothing else opens an IMAP client.
38
+
39
+ 4. **Local UUIDs are stable; server UIDs are metadata.** Every message gets a
40
+ local UUID at first sight (sync, draft save, server-search hit). Sync
41
+ maintains a UUID ↔ (account, folder, server-UID) map. Server-side moves
42
+ change the UID; the UUID stays. Local moves keep the UUID and queue a
43
+ server-side move. Tombstones (UUID + deleted flag) prevent re-fetch.
44
+
45
+ 5. **Slow / unreachable server is not a UI failure mode.** Connection caps,
46
+ IDLE drops, OAuth refresh hangs, 60-second IMAP timeouts — none reach the
47
+ click → render path. They surface in a non-blocking sync-status indicator.
48
+
49
+ ## Service-side API split
50
+
51
+ Today's `MailxService` mixes local reads with server-touching code. Split into
52
+ three layers:
53
+
54
+ ### Layer 1: `LocalStore` (sync, never touches network)
55
+
56
+ Pure local reads + local commits. All synchronous (or trivially async over
57
+ sql.js / fs). Throws if asked to do anything network-y.
58
+
59
+ ```typescript
60
+ class LocalStore {
61
+ // Reads
62
+ getMessage(uuid: string): LocalMessage | null;
63
+ getMessageByUid(accountId: string, uid: number): LocalMessage | null;
64
+ getMessages(query: MessageQuery): LocalMessage[];
65
+ getUnifiedInbox(page, pageSize): { items: LocalMessage[]; total: number };
66
+ searchMessages(query: string, ...): LocalMessage[]; // FTS5 only
67
+ getCalendarEvents(fromMs, toMs): CalEvent[];
68
+ getTasks(includeCompleted): Task[];
69
+ searchContacts(query): Contact[];
70
+ getMessageBody(uuid: string): { html?: string; text?: string; attachments: ... } | null;
71
+
72
+ // Writes — return local UUID, queue server mirror
73
+ saveDraft(draft: ComposeDraft): { uuid: string }; // queues
74
+ sendMessage(msg: ComposeMessage): { uuid: string }; // queues
75
+ moveMessages(uuids: string[], toFolderId: number): { ok: true };
76
+ flagMessages(uuids: string[], flags: string[]): { ok: true };
77
+ deleteMessages(uuids: string[]): { ok: true };
78
+ createCalendarEvent(ev): { uuid: string };
79
+ updateCalendarEvent(uuid, patch): { ok: true };
80
+ deleteCalendarEvent(uuid): { ok: true };
81
+ createTask(t): { uuid: string };
82
+ updateTask(uuid, patch): { ok: true };
83
+ deleteTask(uuid): { ok: true };
84
+ }
85
+ ```
86
+
87
+ Every UI IPC call routes through this layer first. The webview never sees
88
+ beyond this.
89
+
90
+ ### Layer 2: `SyncQueue` (async, mediates server)
91
+
92
+ The only code path that opens IMAP clients, calls Gmail API, posts SMTP, etc.
93
+ Single-threaded per account. Queue persisted across restarts.
94
+
95
+ ```typescript
96
+ class SyncQueue {
97
+ enqueueMove(uuid, fromFolderId, toFolderId): void;
98
+ enqueueFlag(uuid, addFlags, removeFlags): void;
99
+ enqueueDelete(uuid): void;
100
+ enqueueDraftPush(uuid): void;
101
+ enqueueSend(uuid): void;
102
+ enqueueBodyRefresh(uuid): void; // fire-and-forget; reconciler may dedupe
103
+
104
+ // Periodic
105
+ runPoll(): Promise<void>; // checks server for new mail, etc.
106
+
107
+ // Event emit
108
+ on(event: "mirrored" | "conflict" | "error", handler): void;
109
+ }
110
+ ```
111
+
112
+ Items in the queue are persistent (SQLite `sync_queue` table with `kind`,
113
+ `payload`, `attempts`, `created_at`, `last_attempt`, `next_attempt`, `status`).
114
+
115
+ ### Layer 3: `Reconciler` (background loop)
116
+
117
+ Drains the queue, polls for server changes, emits events.
118
+
119
+ ```typescript
120
+ class Reconciler {
121
+ start(): void; // long-running tick
122
+ stop(): void;
123
+ syncNow(accountId?): void; // user-initiated "sync now"
124
+ }
125
+ ```
126
+
127
+ Reconciler events:
128
+ - `messageUpserted(uuid, source: "local"|"server")` — UI updates that row
129
+ - `messageRemoved(uuid)` — UI removes the row
130
+ - `bodyAvailable(uuid)` — UI re-fetches body (will hit local cache instantly)
131
+ - `syncStateChanged(accountId, state)` — status indicator updates
132
+ - `conflict(uuid, kind, detail)` — user gets a banner ("local move failed
133
+ on server because folder is gone")
134
+
135
+ ## Event protocol (local store → UI)
136
+
137
+ Events flow one-way: store changes → UI redraws affected rows.
138
+
139
+ | Event | When | UI behavior |
140
+ |---|---|---|
141
+ | `messageUpserted` | new mail synced; flag changed; body cached; envelope refreshed | re-render that row in place; if it's the focused row, re-render preview |
142
+ | `messageRemoved` | server-side delete reconciled; local delete with no undo window | remove row; advance focus to next |
143
+ | `bodyAvailable` | body fetch completed (from prefetch or on-demand reconcile) | re-render preview if this is the focused row |
144
+ | `folderCountsChanged` | unread counts changed | update folder-tree pill |
145
+ | `syncStateChanged` | reconciler started/stopped, account auth issue, sync error | update status bar indicator |
146
+ | `conflict` | local action failed on server | show banner with retry/discard |
147
+
148
+ Events carry a "sequence number" so the UI can drop stale events when the
149
+ user has navigated away from the affected row.
150
+
151
+ ## Visible state model
152
+
153
+ Each row carries three independent indicators:
154
+
155
+ | Indicator | Source | Meaning |
156
+ |---|---|---|
157
+ | Body cached dot (existing teal/blue) | `body_path != null && file exists` | Tap is instant |
158
+ | Local-only outline (new) | `dirty=true && providerId=null` | Not yet pushed to server |
159
+ | Reconciliation pending (new) | `dirty=true && providerId!=null` | Push queued; will sync soon |
160
+
161
+ Only "body cached" affects how a click renders. The other two are advisory —
162
+ the user knows their action is committed locally and the server side will
163
+ catch up.
164
+
165
+ A status-bar pill shows `Sync OK` / `Syncing N items` / `Sync errors (click to view)`.
166
+ That's the only UI surface that reflects server state.
167
+
168
+ ## Migration order
169
+
170
+ Land in this order so each step is independently shippable:
171
+
172
+ 1. **Add `LocalStore` interface and route ALL UI IPC through it.** Service
173
+ methods become thin wrappers that pull from local DB only. No behavior
174
+ change yet — server fetches just stop happening on every UI call. Bodies
175
+ that aren't cached return null with a "not cached" hint.
176
+
177
+ 2. **UI handles the "not cached" case explicitly.** Show a placeholder-with-
178
+ spinner that says "downloading body…" with no time pressure (the click is
179
+ already responsive — body just takes time to arrive). Listen for
180
+ `bodyAvailable` event and re-render. Remove the 15s retry timer band-aid.
181
+ Remove `body-broken` red-dot band-aid.
182
+
183
+ 3. **Move all current `await imap*` calls in the service into `SyncQueue`
184
+ enqueue calls.** Drafts: write local + enqueue push. Send: write local +
185
+ enqueue SMTP. Move/flag/delete: commit local + enqueue mirror. Body fetch
186
+ on click: read local file or enqueue refresh (with `bodyAvailable` emit
187
+ when done).
188
+
189
+ 4. **Build the `Reconciler` loop** that drains the queue, runs periodic polls,
190
+ and emits events. Replace the current `syncAll` with a reconciler that
191
+ processes both queue items and "sync from server" pulls in priority order.
192
+
193
+ 5. **Add the visible-state indicators** (local-only outline, pending dot,
194
+ sync-status pill). Wire to the new event stream.
195
+
196
+ 6. **Migrate the conflict path.** When a queued item fails on the server
197
+ (folder deleted, message gone, auth lapsed), surface a single banner
198
+ listing the affected items with retry/discard.
199
+
200
+ 7. **Delete the band-aids**: 60s body-fetch timeout, 15s client retry timer,
201
+ `body-broken` class, "Fetching message body…" placeholder, all stale-gen
202
+ `if (gen !== showMessageGeneration) return` early-returns.
203
+
204
+ Each step ships standalone. After step 3 the UI is already responsive
205
+ even if the reconciler isn't built — it just stops doing sync until step 4.
206
+
207
+ ## Concrete call-site changes
208
+
209
+ ### Client side (TypeScript)
210
+
211
+ Files to touch and what changes:
212
+
213
+ - `client/components/message-viewer.ts` — `showMessage` becomes synchronous.
214
+ Reads from local cache, never awaits a server fetch. Body display routed
215
+ through new `bodyAvailable` event listener.
216
+ - `client/components/message-list.ts` — same for `loadMessages`,
217
+ `loadUnifiedInbox`, `loadSearchResults` (local scope only).
218
+ - `client/compose/compose.ts` — `saveDraft` and `send` get the local UUID
219
+ back immediately, no spinner.
220
+ - `client/components/calendar-sidebar.ts` — `getCalendarEvents` /
221
+ `createCalendarEvent` etc. pure local; events update on `messageUpserted`.
222
+ - `client/lib/api-client.ts` — every method documented as "local read" or
223
+ "queues server mirror". No method returns a server result directly anymore.
224
+ - `client/app.ts` — wire the new event types into the existing service-
225
+ channel listener.
226
+
227
+ ### Service side (TypeScript)
228
+
229
+ - `mailx-service/index.ts` — split. The `MailxService` class stays as the
230
+ IPC entry point but delegates: reads → `LocalStore`, writes → `LocalStore`
231
+ + `SyncQueue.enqueue*`, no more direct `imapManager.*` calls from UI
232
+ handlers.
233
+ - New file `mailx-service/local-store.ts` — pure local reads/writes.
234
+ - New file `mailx-service/sync-queue.ts` — persistent queue + enqueue API.
235
+ - New file `mailx-service/reconciler.ts` — background loop, draining +
236
+ polling, emits events.
237
+ - `mailx-imap/index.ts` — becomes a worker library called only by the
238
+ reconciler. UI never sees it.
239
+
240
+ ### Database
241
+
242
+ - New table `sync_queue (id, kind, payload, attempts, created_at,
243
+ last_attempt, next_attempt, status, account_id)`.
244
+ - New columns on `messages`: `dirty BOOLEAN` (local-only or pending),
245
+ `last_local_change_ms` (so reconciler picks oldest first).
246
+ - Existing `body_path` semantics stay.
247
+
248
+ ## What the user sees afterward
249
+
250
+ - Every click renders in <50ms regardless of server state.
251
+ - Drafts save and Reply opens are instant.
252
+ - New mail appears in the list as soon as the reconciler pulls it (no
253
+ user wait).
254
+ - A persistent status pill in the bottom-right shows whether sync is
255
+ current, lagging, or failing.
256
+ - Body fetches that haven't completed show a spinner inside the preview
257
+ pane that doesn't block interaction with the list.
258
+ - Multi-account: bobma's slowness affects only bobma's status pill, never
259
+ the UI as a whole.
260
+
261
+ ## Effort
262
+
263
+ Realistic for solo work, your pace:
264
+
265
+ - Step 1 (route UI through LocalStore): 1-2 days
266
+ - Step 2 (UI body-not-cached handling): 1 day
267
+ - Step 3 (move IMAP calls into SyncQueue): 2-3 days
268
+ - Step 4 (build Reconciler): 2-3 days
269
+ - Step 5 (visible-state indicators): 1 day
270
+ - Step 6 (conflict path): 1 day
271
+ - Step 7 (delete band-aids): 1 day
272
+
273
+ Total: ~9-12 working days, shipped incrementally. Each step is its own
274
+ publish; no big-bang.
275
+
276
+ ## Decisions (2026-05-06)
277
+
278
+ 1. **sync_queue concurrency**: priority lanes (interactive > sync > prefetch
279
+ > backfill) **plus** multiple handlers per lane. The interactive lane is
280
+ reserved for on-demand body fetches the user just clicked; sync runs in
281
+ parallel on a different lane. Per-account serialization still applies
282
+ inside each lane to keep us under Dovecot/Gmail connection caps.
283
+
284
+ 2. **Multi-device conflict**: last-writer-wins. The case (drag-at-the-same-time
285
+ on two devices) is rare enough that no banner / merge UI is needed. The
286
+ later push wins; the earlier one is silently superseded.
287
+
288
+ 3. **Tombstones**: live forever, no retention policy. Cost is ~200 bytes per
289
+ row in the `messages` table — negligible even at 10k deletes/year × 10
290
+ years. They're shared across devices implicitly: every device syncs the
291
+ same server state, so a delete on phone propagates to desktop via the
292
+ server reconcile, no peer-to-peer sync needed. Local-only tombstones
293
+ (queued delete not yet pushed) stay per-device until the queue drains.
294
+
295
+ 4. **Calendar / tasks / contacts**: same model. No special cases. Today's
296
+ two-way cache code is already on this pattern; the refactor just makes
297
+ message paths match.
298
+
299
+ 5. **Outbox**: keep the existing flow as-is. The Drafts → Outbox → Sent
300
+ directory-based queue works and isn't blocking the UI today; subsuming
301
+ it would be churn for no user benefit.
302
+
303
+ Step 1 starts immediately.
@@ -0,0 +1,107 @@
1
+ # npmglobalize: transitive workspace dep gap on standalone publish
2
+
3
+ ## Symptom
4
+
5
+ A workspace member with `npmVisibility: "public"` (`@bobfrankston/mailx-imap`) gets
6
+ published as a standalone registry package. Its tarball declares
7
+ `@bobfrankston/mailx-settings: ^0.1.6` as a registry dep — but
8
+ `mailx-settings` is *also* a workspace member, with no `.globalize.json5`,
9
+ so it was never published. End user runs `npm install -g @bobfrankston/mailx`
10
+ on a fresh machine and gets:
11
+
12
+ ```
13
+ 404 Not Found - GET https://registry.npmjs.org/@bobfrankston%2fmailx-settings
14
+ ```
15
+
16
+ Reproducer (mailx workspace, 2026-05-02):
17
+ - `app/packages/mailx-imap/.globalize.json5` → `npmVisibility: "public"`
18
+ - `app/packages/mailx-imap/package.json` → `dependencies: { "@bobfrankston/mailx-settings": "file:../mailx-settings" }`
19
+ - `app/packages/mailx-settings/` exists, no `.globalize.json5`
20
+ - `npmglobalize` from workspace root: builds everything, publishes mailx-imap and the parent mailx, exits with success
21
+ - `mailx-imap`'s published `package.json` rewrote `file:../mailx-settings` → `^0.1.6`
22
+ - `mailx-settings` was never published
23
+
24
+ ## What npmglobalize does today
25
+
26
+ When publishing a standalone-public workspace member, file: deps that point
27
+ at *other workspace members* get rewritten to registry version constraints
28
+ (`^X.Y.Z`) — same as it'd do for any external file: dep. No check that the
29
+ referenced workspace member is itself public/published.
30
+
31
+ Result: a tarball whose declared deps 404 on a clean install. The publish
32
+ itself succeeds; the breakage shows up at install time on a remote machine.
33
+
34
+ ## What npmglobalize should do
35
+
36
+ Pick one (preference order):
37
+
38
+ ### 1. Fail loud at publish time (cheapest, most defensible)
39
+
40
+ Before publishing a workspace member as standalone-public, walk its file:
41
+ deps. For each that resolves to another workspace member:
42
+ - If that member is also public → fine, the registry constraint will resolve.
43
+ - If that member is **not** public → **refuse to publish** with a clear error:
44
+
45
+ ```
46
+ Refusing to publish @bobfrankston/mailx-imap as a standalone registry
47
+ package: it depends on @bobfrankston/mailx-settings (workspace member,
48
+ not public). Published tarball would 404 on install.
49
+
50
+ Fixes:
51
+ - Add .globalize.json5 with npmVisibility: "public" to mailx-settings
52
+ (and any of its transitive workspace deps).
53
+ - Or remove .globalize.json5 from mailx-imap so it stays bundled
54
+ under the parent's tarball.
55
+ ```
56
+
57
+ This is the behavior I'd ask for first. It's predictable, errors at the
58
+ site of the mistake, and doesn't silently change packaging.
59
+
60
+ ### 2. Bundle transitive workspace deps in the standalone tarball
61
+
62
+ When publishing a standalone-public workspace member, copy any non-public
63
+ workspace deps **into the tarball** as bundled directories, and keep file:
64
+ paths instead of rewriting to registry constraints. The published tarball
65
+ becomes self-contained.
66
+
67
+ Trade-off: tarball gets bigger and pulls in copies on every standalone
68
+ publish. Multiple public members each carry their own copy of shared
69
+ internal deps — duplication on disk, but install-time correctness.
70
+
71
+ ### 3. Auto-promote transitive workspace deps to public
72
+
73
+ If a public member depends on a non-public workspace member, automatically
74
+ promote the dep to public and publish it too. Cascading.
75
+
76
+ Trade-off: silently expands the publish surface area — one new public
77
+ package can flip the entire transitive closure to public. Surprising and
78
+ hard to undo. Less defensible than (1) or (2).
79
+
80
+ ## Recommendation
81
+
82
+ Implement (1). It's a one-pass dependency walk before the publish step,
83
+ returning a clear error message. The other options can layer on later if
84
+ the workflow demands them — but the loud failure prevents the silent-bad-
85
+ publish from ever shipping in the first place.
86
+
87
+ ## Edge cases (1) needs to handle
88
+
89
+ - **External file: deps** (not workspace members): `file:../../MailApps/iflow-direct`
90
+ resolves outside the workspace. Today these get rewritten to registry
91
+ versions and presumably *those* deps are published separately. Don't
92
+ block on those — only on workspace-internal file: deps.
93
+ - **Public member depends on public member**: fine, registry resolves both.
94
+ - **Public member depends on a registry version that exists**: fine.
95
+ - **Public member depends on a workspace member with `noPublish: true`**:
96
+ same gap, same fail. Treat noPublish=true workspace members as "not public".
97
+ - **Cyclic deps**: shouldn't apply here (workspace dep graphs are DAGs by
98
+ npm's own constraint), but if the walk hits one, abort with a different
99
+ message.
100
+
101
+ ## Why this matters
102
+
103
+ Symptom is "Mac install fails" — but the root cause is npmglobalize
104
+ publishing a tarball it can't possibly install correctly. The user has
105
+ no signal at publish time; the breakage is invisible until someone tries
106
+ to install the package on a fresh machine. Fail-at-publish makes the
107
+ contract explicit: *if it publishes, it installs.*
@@ -1,31 +1,93 @@
1
1
  # Outlook.com / Microsoft 365 Support
2
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.
3
+ Status (2026-06-21): **native Graph support implemented and wired as a
4
+ PUBLIC client (PKCE, no secret).** The provider, the Microsoft tokenProvider,
5
+ and the full three-way dispatcher routing (sync, body fetch, flags/move/trash,
6
+ send via Graph `/sendMail`) are done. The **public client_id ships bundled in
7
+ the build** (`packages/mailx-imap/microsoft-credentials.json`), so a fresh
8
+ install needs no credential placement. The only human step is the one-time Azure
9
+ app registration.
8
10
 
9
- This doc scopes what it takes to make Outlook a first-class account.
11
+ > ## WHAT ONLY YOU DO (one-time, ~10 min already done for the `rmfmail` app)
12
+ >
13
+ > The app `rmfmail` is registered; its public client_id
14
+ > `f0df4236-5d26-4aa3-a243-47f043b9db36` is committed in the build. These steps
15
+ > are the recipe to reproduce it (e.g. a fresh app, or a fork):
16
+ >
17
+ > 1. **Register an Azure app** (full walkthrough in [§A](#a-register-the-azure-app--the-part-only-you-can-do) below):
18
+ > - Entra → App registrations → New registration → name `rmfmail`,
19
+ > account type **"any org directory + personal Microsoft accounts"**.
20
+ > - Redirect URI: platform **"Mobile and desktop applications"** = **`http://localhost`**.
21
+ > - **Authentication → Advanced settings → "Allow public client flows" → Yes.**
22
+ > (Required for the no-secret PKCE flow.)
23
+ > - **No client secret.** Skip "Certificates & secrets" entirely — a public
24
+ > client doesn't use one (this is what removes the 2-year renewal chore).
25
+ > - API permissions → Microsoft Graph → **Delegated**: `Mail.ReadWrite`,
26
+ > `Mail.Send`, `offline_access`, `openid`, `profile`.
27
+ > 2. **Put the public client_id in the build** — edit
28
+ > `packages/mailx-imap/microsoft-credentials.json` (already present for
29
+ > `rmfmail`; client_id only, **never a secret**):
30
+ > ```json
31
+ > {
32
+ > "installed": {
33
+ > "client_id": "<Application (client) ID>",
34
+ > "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
35
+ > "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
36
+ > "redirect_uris": ["http://localhost"]
37
+ > }
38
+ > }
39
+ > ```
40
+ > A per-machine override at `~/.rmfmail/microsoft-credentials.json` (note:
41
+ > **`.rmfmail`, not `.mailx`** — that's `getConfigDir()`) takes precedence if
42
+ > present, but isn't needed.
43
+ > 3. **Add an Outlook account** to `accounts.jsonc` (cloud-canonical copy, e.g.
44
+ > `<Drive>/Home/.rmfmail/accounts.jsonc`) — just `{ "id": "...", "email":
45
+ > "you@outlook.com" }`; the `@outlook.com`/M365 domain auto-routes to Graph.
46
+ > Restart; first sync pops the Microsoft consent at `http://localhost`; approve.
47
+ >
48
+ > ## 🔁 Renewal / expiry — what you have to do over time
49
+ >
50
+ > **Nothing on a schedule.** Because this is a public client with **no secret**:
51
+ > - **Client secret** — none exists, so there is **no 2-year (or any) secret
52
+ > renewal**. (A confidential setup would force rotating the secret before its
53
+ > expiry; we deliberately avoid that.)
54
+ > - **Client ID** — permanent; never expires.
55
+ > - **Refresh token** — cached per account under `~/.rmfmail/tokens/<email>/`;
56
+ > rmfmail refreshes it automatically. If it ever goes stale (you revoke access,
57
+ > change the password, or Microsoft invalidates it), rmfmail just re-shows the
58
+ > consent once. Not a scheduled task.
59
+ > - **API permissions** — only re-touched if you add a feature needing a new
60
+ > scope (e.g. Calendar). Existing mail scopes don't expire.
61
+ >
62
+ > If consent fails with `invalid_request` / "client secret required", the app
63
+ > isn't set as a public client — re-check "Allow public client flows = Yes" and
64
+ > that no leftover per-machine `~/.rmfmail/microsoft-credentials.json` is passing
65
+ > a stale secret. Tokens that mint but sync-401 usually mean a scope/audience
66
+ > mismatch (Graph vs IMAP) — we mint **Graph**.
67
+
68
+ The rest of this doc is the implementation reference and the detailed Azure
69
+ walkthrough.
10
70
 
11
71
  ---
12
72
 
13
- ## What already exists
73
+ ## What already exists (now wired — 2026-06-20)
14
74
 
15
75
  | Piece | Where | State |
16
76
  |---|---|---|
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 |
77
+ | Graph API provider | `@bobfrankston/mailx-sync/outlook.ts` (re-exported `packages/mailx-imap/providers/outlook-api.ts`) | **Full**: read (`listFolders`, `fetchSince/ByDate/ByUids/One`, `getUids`) + write-back (`setFlags`, `trashMessage`, `moveMessage`), `sendRaw` (POST /sendMail), `fetchBodiesBatch`, provider_id-first identity, rate-limit + Retry-After backoff. Mirrors `gmail.ts`. |
78
+ | Microsoft tokenProvider | `packages/mailx-imap/index.ts` `addAccount` | **Done**: `isOutlookCfg(account)` reads `~/.rmfmail/microsoft-credentials.json` if present, else the **bundled** `packages/mailx-imap/microsoft-credentials.json`; mints Graph scopes (`Mail.ReadWrite Mail.Send offline_access openid profile`). |
79
+ | Bundled public creds | `packages/mailx-imap/microsoft-credentials.json` | **Done**: public client_id only (no secret), committed + shipped with the package — fresh installs need no creds placement. |
80
+ | Provider routing | `packages/mailx-imap/index.ts` | **Done**: `isOutlookAccount`/`isApiAccount`/`getApiProvider`/`getOutlookProvider`; ~15 dispatch sites route Outlook through the REST path like Gmail. Send via `sendRawForAccount` → Graph `/sendMail`. |
81
+ | Host auto-config | `packages/mailx-settings/index.ts:503` (`outlook.com`, `hotmail.com` → `outlook.office365.com:993` / `smtp.office365.com:587`, `auth: "oauth2"`) | Done (the `auth:"oauth2"` flag is what lands Outlook in the tokenProvider branch) |
19
82
  | 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.
83
+ | OAuth flow (PKCE) | `@bobfrankston/oauthsupport` `OAuthTokenManager.ts` | **PKCE added 2026-06-21**: `client_secret` now optional; auth-code flow sends `code_challenge`/`code_verifier` (S256). Public clients (Microsoft, no secret) and confidential clients (Google, secret) both work through one path. |
84
+
85
+ **The one remaining gate is the Azure app registration** (your part, top of this
86
+ doc) and it's a public client, so once the app is registered with "Allow
87
+ public client flows" enabled, Outlook authenticates and syncs/sends entirely via
88
+ Graph no IMAP, no SMTP, no secret to manage. (Historical note: send does NOT
89
+ use the old OAuth-SMTP path at `index.ts:1533`, because a Graph-scoped token has
90
+ the wrong audience for SMTP; Outlook sends via Graph `/sendMail`.)
29
91
 
30
92
  ---
31
93