@bobfrankston/rmfmail 1.2.50 → 1.2.65
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/client/android-bootstrap.bundle.js +63 -2
- package/client/android-bootstrap.bundle.js.map +4 -4
- package/client/app.bundle.js +417 -63
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +182 -24
- package/client/app.js.map +1 -1
- package/client/app.ts +166 -21
- package/client/components/calendar-sidebar.js +5 -5
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +5 -5
- package/client/components/folder-tree.js +81 -0
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +70 -0
- package/client/components/message-list.js +282 -38
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +264 -36
- package/client/compose/compose.bundle.js +70 -14
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.css +28 -7
- package/client/compose/compose.js +64 -4
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +66 -4
- package/client/compose/editor.js +23 -7
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +23 -7
- package/client/help/search-help.js +4 -3
- package/client/help/search-help.js.map +1 -1
- package/client/help/search-help.ts +4 -3
- package/client/index.html +5 -1
- package/client/lib/api-client.js +4 -2
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +4 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/lib/rmf-tiny.js +39 -6
- package/client/styles/components.css +6 -0
- package/docs/azure.md +58 -0
- package/docs/config-help.md +131 -0
- package/docs/edit-in-word-docx.md +98 -0
- package/docs/host-abstraction-plan.md +169 -0
- package/docs/local-first-plan.md +303 -0
- package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/docs/outlook.md +80 -18
- package/docs/outlook.txt +35 -0
- package/docs/rules-design.md +172 -0
- package/package.json +7 -7
- package/packages/mailx-imap/index.d.ts +48 -1
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +320 -68
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +311 -65
- package/packages/mailx-imap/microsoft-credentials.json +8 -0
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
- package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
- package/packages/mailx-imap/providers/outlook-api.js +7 -0
- package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
- package/packages/mailx-service/index.d.ts +7 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +8 -8
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +8 -7
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +1 -1
- package/packages/mailx-settings/docs/azure.md +58 -0
- package/packages/mailx-settings/docs/config-help.md +131 -0
- package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
- package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
- package/packages/mailx-settings/docs/local-first-plan.md +303 -0
- package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/packages/mailx-settings/docs/outlook.md +80 -18
- package/packages/mailx-settings/docs/rules-design.md +172 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +9 -8
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +9 -8
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts +36 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +100 -6
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +100 -6
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-types/contact-rules.d.ts +1 -1
- package/packages/mailx-types/contact-rules.js +1 -1
- package/packages/mailx-types/contact-rules.js.map +1 -1
- package/packages/mailx-types/contact-rules.jsonc +1 -1
- package/packages/mailx-types/contact-rules.ts +1 -1
- 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.*
|
package/docs/outlook.md
CHANGED
|
@@ -1,31 +1,93 @@
|
|
|
1
1
|
# Outlook.com / Microsoft 365 Support
|
|
2
2
|
|
|
3
|
-
Status (2026-06-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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`) | **
|
|
18
|
-
|
|
|
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
|
-
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
package/docs/outlook.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
Display name
|
|
3
|
+
:
|
|
4
|
+
rmfmail
|
|
5
|
+
Application (client) ID
|
|
6
|
+
:
|
|
7
|
+
f0df4236-5d26-4aa3-a243-47f043b9db36
|
|
8
|
+
Object ID
|
|
9
|
+
:
|
|
10
|
+
ea091a11-7b53-4d86-846d-a3788dfa2392
|
|
11
|
+
Directory (tenant) ID
|
|
12
|
+
:
|
|
13
|
+
3323c207-ec41-4587-b3a6-edd208fb7f9f
|
|
14
|
+
Supported account types
|
|
15
|
+
:
|
|
16
|
+
All Microsoft account users
|
|
17
|
+
Client credentials
|
|
18
|
+
:
|
|
19
|
+
Add a certificate or secret
|
|
20
|
+
Redirect URIs
|
|
21
|
+
:
|
|
22
|
+
0 web, 0 spa, 1 public client
|
|
23
|
+
Application ID URI
|
|
24
|
+
:
|
|
25
|
+
Add an Application ID URI
|
|
26
|
+
Managed application in local directory
|
|
27
|
+
:
|
|
28
|
+
rmfmail
|
|
29
|
+
State
|
|
30
|
+
:
|
|
31
|
+
Activated
|
|
32
|
+
|
|
33
|
+
Client \
|
|
34
|
+
Value: WK68Q~tJLRUgFZ8LdPSrdWzlE~rUFa6317ZnaaOM
|
|
35
|
+
Sectret: c5c131af-4805-401d-b751-0c745015764a
|