@bobfrankston/rmfmail 1.0.546 → 1.0.549

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 (60) hide show
  1. package/bin/mailx.js +13 -0
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +13 -0
  4. package/client/app.js +54 -1
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +53 -1
  7. package/client/components/message-list.js +1 -3
  8. package/client/components/message-list.js.map +1 -1
  9. package/client/components/message-list.ts +1 -3
  10. package/client/components/message-viewer.js +65 -97
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +51 -90
  13. package/client/compose/compose.js +2 -2
  14. package/client/compose/compose.js.map +1 -1
  15. package/client/compose/compose.ts +2 -2
  16. package/client/lib/api-client.js +5 -0
  17. package/client/lib/api-client.js.map +1 -1
  18. package/client/lib/api-client.ts +5 -1
  19. package/client/styles/components.css +0 -12
  20. package/package.json +1 -1
  21. package/packages/mailx-imap/index.d.ts +9 -1
  22. package/packages/mailx-imap/index.d.ts.map +1 -1
  23. package/packages/mailx-imap/index.js +25 -38
  24. package/packages/mailx-imap/index.js.map +1 -1
  25. package/packages/mailx-imap/index.ts +30 -42
  26. package/packages/mailx-imap/package-lock.json +2 -2
  27. package/packages/mailx-imap/package.json +1 -1
  28. package/packages/mailx-server/index.d.ts.map +1 -1
  29. package/packages/mailx-server/index.js +13 -0
  30. package/packages/mailx-server/index.js.map +1 -1
  31. package/packages/mailx-server/index.ts +14 -0
  32. package/packages/mailx-service/index.d.ts +26 -0
  33. package/packages/mailx-service/index.d.ts.map +1 -1
  34. package/packages/mailx-service/index.js +82 -235
  35. package/packages/mailx-service/index.js.map +1 -1
  36. package/packages/mailx-service/index.ts +45 -54
  37. package/packages/mailx-service/local-store.d.ts +5 -0
  38. package/packages/mailx-service/local-store.d.ts.map +1 -1
  39. package/packages/mailx-service/local-store.js +116 -12
  40. package/packages/mailx-service/local-store.js.map +1 -1
  41. package/packages/mailx-service/local-store.ts +5 -1
  42. package/packages/mailx-service/reconciler.d.ts +90 -0
  43. package/packages/mailx-service/reconciler.d.ts.map +1 -0
  44. package/packages/mailx-service/reconciler.js +209 -0
  45. package/packages/mailx-service/reconciler.js.map +1 -0
  46. package/packages/mailx-service/reconciler.ts +230 -0
  47. package/packages/mailx-service/sync-queue.d.ts +79 -0
  48. package/packages/mailx-service/sync-queue.d.ts.map +1 -0
  49. package/packages/mailx-service/sync-queue.js +118 -0
  50. package/packages/mailx-service/sync-queue.js.map +1 -0
  51. package/packages/mailx-service/sync-queue.ts +134 -0
  52. package/packages/mailx-settings/docs/contacts.md +6 -1
  53. package/packages/mailx-settings/docs/search.md +99 -0
  54. package/packages/mailx-settings/package.json +1 -1
  55. package/packages/mailx-store/file-store.d.ts +6 -0
  56. package/packages/mailx-store/file-store.d.ts.map +1 -1
  57. package/packages/mailx-store/file-store.js +8 -0
  58. package/packages/mailx-store/file-store.js.map +1 -1
  59. package/packages/mailx-store/file-store.ts +9 -0
  60. package/packages/mailx-store/package.json +1 -1
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Reconciler — single background loop that drains the SyncQueue, polls
3
+ * for server changes, and emits state events the UI listens for.
4
+ *
5
+ * Local-first contract: the Reconciler is the ONLY code path that touches
6
+ * IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
7
+ * and enqueue; the Reconciler picks up and mirrors. Reads from the UI
8
+ * never await the Reconciler.
9
+ *
10
+ * What this owns today:
11
+ * - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
12
+ * `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
13
+ * This is the new piece; previously body fetches happened inline on
14
+ * every getMessage call.
15
+ * - sync-state pill events: emits `syncStateChanged` so the UI can show
16
+ * "Sync OK / Syncing N items".
17
+ *
18
+ * What it delegates (intentional — no duplication):
19
+ * - message-action drain: ImapManager already runs a 30s actionsInterval
20
+ * that processes both outbox (processSendActions) and sync_actions
21
+ * (processSyncActions) per account. Per plan decision #5, outbox stays
22
+ * under ImapManager; we don't fork that work.
23
+ * - poll loops (per-account quick check, full sync, prefetch, tombstone
24
+ * prune): all owned by ImapManager's startPeriodicSync. The Reconciler
25
+ * does NOT shadow them — back-pressure on those timers can be added
26
+ * later via a single `setBackPressure(level)` hook.
27
+ *
28
+ * Part of docs/local-first-plan.md (step 4).
29
+ */
30
+ const DEFAULTS = {
31
+ bodyFetchConcurrency: 2,
32
+ statusEmitIntervalMs: 5_000,
33
+ prefetchIntervalMs: 60_000,
34
+ prefetchInitialDelayMs: 2_000,
35
+ prefetchBackpressureThreshold: 1,
36
+ tombstonePruneIntervalMs: 3600_000, // hourly
37
+ tombstoneRetentionDays: 30,
38
+ };
39
+ export class Reconciler {
40
+ db;
41
+ imapManager;
42
+ queue;
43
+ opts;
44
+ running = false;
45
+ statusTimer = null;
46
+ prefetchTimer = null;
47
+ tombstoneTimer = null;
48
+ inFlightFetches = 0;
49
+ /** Last status payload — used to suppress no-op syncStateChanged events.
50
+ * During a heavy multi-folder sync the 5s tick fires a lot of identical
51
+ * events; the WebView's event handler runs DOM work for each, which
52
+ * stacks up under load. Diff-only emits keep the channel quiet. */
53
+ lastStatus = { messageActions: -1, bodyFetches: -1, inFlightFetches: -1 };
54
+ constructor(db, imapManager, queue, opts = {}) {
55
+ this.db = db;
56
+ this.imapManager = imapManager;
57
+ this.queue = queue;
58
+ this.opts = { ...DEFAULTS, ...opts };
59
+ }
60
+ start() {
61
+ if (this.running)
62
+ return;
63
+ this.running = true;
64
+ // Status pill: emit current totals every few seconds so the UI
65
+ // can show "Syncing N items" without polling. Diff-only — emit
66
+ // ONLY when something actually changed since the last tick so
67
+ // we don't pipe a constant idle stream through the WebView's
68
+ // event channel during a heavy sync (where the WebView main
69
+ // thread is already busy painting list updates).
70
+ this.statusTimer = setInterval(() => {
71
+ const counts = this.queue.pendingCount();
72
+ const next = { ...counts, inFlightFetches: this.inFlightFetches };
73
+ if (next.messageActions === this.lastStatus.messageActions
74
+ && next.bodyFetches === this.lastStatus.bodyFetches
75
+ && next.inFlightFetches === this.lastStatus.inFlightFetches) {
76
+ return;
77
+ }
78
+ this.lastStatus = next;
79
+ this.imapManager.emit("syncStateChanged", next);
80
+ }, this.opts.statusEmitIntervalMs);
81
+ // Periodic prefetch tick — owned here (not in ImapManager) so it
82
+ // can back off when the interactive body-fetch lane has work.
83
+ // First tick fires soon after startup so "not downloaded" dots
84
+ // start filling in promptly.
85
+ const prefetchTick = () => this.runPrefetchTick();
86
+ setTimeout(prefetchTick, this.opts.prefetchInitialDelayMs);
87
+ this.prefetchTimer = setInterval(prefetchTick, this.opts.prefetchIntervalMs);
88
+ // Tombstone prune — bookkeeping only. Trivial cost (one indexed
89
+ // DELETE per hour), kept off the IMAP-touching code path because
90
+ // it has nothing to do with sync.
91
+ const pruneTick = () => this.runTombstonePrune();
92
+ setTimeout(pruneTick, 30_000); // first run after startup settles
93
+ this.tombstoneTimer = setInterval(pruneTick, this.opts.tombstonePruneIntervalMs);
94
+ // Kick the body-fetch loop.
95
+ this.pumpBodyFetches();
96
+ }
97
+ stop() {
98
+ this.running = false;
99
+ if (this.statusTimer) {
100
+ clearInterval(this.statusTimer);
101
+ this.statusTimer = null;
102
+ }
103
+ if (this.prefetchTimer) {
104
+ clearInterval(this.prefetchTimer);
105
+ this.prefetchTimer = null;
106
+ }
107
+ if (this.tombstoneTimer) {
108
+ clearInterval(this.tombstoneTimer);
109
+ this.tombstoneTimer = null;
110
+ }
111
+ }
112
+ /** Hourly bookkeeping — drop tombstones older than the retention window
113
+ * so the deleted-message lookup stays fast. */
114
+ runTombstonePrune() {
115
+ const cutoff = Date.now() - this.opts.tombstoneRetentionDays * 86400_000;
116
+ const n = this.db.pruneTombstones(cutoff);
117
+ if (n > 0)
118
+ console.log(` [tombstones] pruned ${n} older than ${this.opts.tombstoneRetentionDays} days`);
119
+ }
120
+ /** Fire prefetchBodies on each account unless the interactive body-fetch
121
+ * lane has user-clicked work pending — in which case skip this tick.
122
+ * ImapManager's per-account `prefetchingAccounts` guard de-dupes if
123
+ * the previous tick is still in flight. */
124
+ runPrefetchTick() {
125
+ if (!this.running)
126
+ return;
127
+ const counts = this.queue.pendingCount();
128
+ if (counts.bodyFetches >= this.opts.prefetchBackpressureThreshold) {
129
+ // Interactive work pending — let it drain. Next tick reconsiders.
130
+ return;
131
+ }
132
+ for (const acct of this.db.getAccounts()) {
133
+ this.imapManager.prefetchBodies(acct.id).catch(e => console.error(` [prefetch] ${acct.id}: ${e?.message || e}`));
134
+ }
135
+ }
136
+ /** User-visible "sync now" — runs an immediate poll on all accounts
137
+ * (or just one) without waiting for the next periodic tick. */
138
+ syncNow(accountId) {
139
+ const accts = accountId ? [{ id: accountId }] : this.db.getAccounts();
140
+ for (const a of accts) {
141
+ this.imapManager.processSyncActions(a.id).catch(() => { });
142
+ }
143
+ }
144
+ /** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
145
+ * fetch is fire-and-forget; on completion it kicks the pump again
146
+ * so a queue that grows during work stays drained. */
147
+ pumpBodyFetches() {
148
+ if (!this.running)
149
+ return;
150
+ while (this.inFlightFetches < this.opts.bodyFetchConcurrency) {
151
+ const next = this.queue.nextBodyFetch();
152
+ if (!next)
153
+ return;
154
+ this.inFlightFetches++;
155
+ (async () => {
156
+ try {
157
+ const raw = await this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid);
158
+ if (raw) {
159
+ this.imapManager.emit("bodyAvailable", {
160
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
161
+ });
162
+ }
163
+ }
164
+ catch (err) {
165
+ if (err?.isNotFound) {
166
+ try {
167
+ this.db.deleteMessage(next.accountId, next.uid);
168
+ this.db.recalcFolderCounts(next.folderId);
169
+ this.imapManager.emit("messageRemoved", {
170
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
171
+ });
172
+ }
173
+ catch { /* */ }
174
+ }
175
+ else {
176
+ const msg = err?.message || "body fetch failed";
177
+ const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg);
178
+ // Transient: re-enqueue (budget=3) so the next pump
179
+ // tick retries. Surface the error only when the
180
+ // budget is exhausted — otherwise we'd flash banners
181
+ // for every blip on a slow server.
182
+ if (transient && this.queue.requeueBodyFetch(next)) {
183
+ // Backoff so we don't immediately re-hit the same
184
+ // wedge. Body-fetch lane is in-memory; setTimeout
185
+ // is fine.
186
+ const backoffMs = 500 * Math.pow(2, next.attempts);
187
+ setTimeout(() => this.pumpBodyFetches(), backoffMs);
188
+ }
189
+ else {
190
+ this.imapManager.emit("bodyFetchError", {
191
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
192
+ error: msg, transient,
193
+ });
194
+ }
195
+ }
196
+ }
197
+ finally {
198
+ this.inFlightFetches--;
199
+ this.pumpBodyFetches();
200
+ }
201
+ })();
202
+ }
203
+ }
204
+ /** External nudge — call after enqueueing a high-priority body fetch. */
205
+ kick() {
206
+ this.pumpBodyFetches();
207
+ }
208
+ }
209
+ //# sourceMappingURL=reconciler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconciler.js","sourceRoot":"","sources":["reconciler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Reconciler — single background loop that drains the SyncQueue, polls
3
+ * for server changes, and emits state events the UI listens for.
4
+ *
5
+ * Local-first contract: the Reconciler is the ONLY code path that touches
6
+ * IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
7
+ * and enqueue; the Reconciler picks up and mirrors. Reads from the UI
8
+ * never await the Reconciler.
9
+ *
10
+ * What this owns today:
11
+ * - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
12
+ * `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
13
+ * This is the new piece; previously body fetches happened inline on
14
+ * every getMessage call.
15
+ * - sync-state pill events: emits `syncStateChanged` so the UI can show
16
+ * "Sync OK / Syncing N items".
17
+ *
18
+ * What it delegates (intentional — no duplication):
19
+ * - message-action drain: ImapManager already runs a 30s actionsInterval
20
+ * that processes both outbox (processSendActions) and sync_actions
21
+ * (processSyncActions) per account. Per plan decision #5, outbox stays
22
+ * under ImapManager; we don't fork that work.
23
+ * - poll loops (per-account quick check, full sync, prefetch, tombstone
24
+ * prune): all owned by ImapManager's startPeriodicSync. The Reconciler
25
+ * does NOT shadow them — back-pressure on those timers can be added
26
+ * later via a single `setBackPressure(level)` hook.
27
+ *
28
+ * Part of docs/local-first-plan.md (step 4).
29
+ */
30
+
31
+ import type { ImapManager } from "@bobfrankston/mailx-imap";
32
+ import type { MailxDB } from "@bobfrankston/mailx-store";
33
+ import type { SyncQueue } from "./sync-queue.js";
34
+
35
+ export interface ReconcilerOptions {
36
+ /** Max concurrent body fetches across all accounts (in addition to
37
+ * per-account semaphore in ImapManager). Keeps the interactive lane
38
+ * responsive even when prefetch is hammering. */
39
+ bodyFetchConcurrency?: number;
40
+ /** Periodic emit of syncStateChanged — drives the UI status pill. */
41
+ statusEmitIntervalMs?: number;
42
+ /** How often to fire the prefetch tick. Default 60s. */
43
+ prefetchIntervalMs?: number;
44
+ /** First prefetch fires this soon after start so the "not downloaded"
45
+ * dots fill in without making the user wait a minute. */
46
+ prefetchInitialDelayMs?: number;
47
+ /** Skip prefetch when the interactive body-fetch lane has at least
48
+ * this many items pending. Back-pressure keeps clicks responsive. */
49
+ prefetchBackpressureThreshold?: number;
50
+ /** Tombstone prune interval — bookkeeping only, hourly default. */
51
+ tombstonePruneIntervalMs?: number;
52
+ /** Tombstone retention — local-delete records older than this are
53
+ * removed by the periodic prune. */
54
+ tombstoneRetentionDays?: number;
55
+ }
56
+
57
+ const DEFAULTS: Required<ReconcilerOptions> = {
58
+ bodyFetchConcurrency: 2,
59
+ statusEmitIntervalMs: 5_000,
60
+ prefetchIntervalMs: 60_000,
61
+ prefetchInitialDelayMs: 2_000,
62
+ prefetchBackpressureThreshold: 1,
63
+ tombstonePruneIntervalMs: 3600_000, // hourly
64
+ tombstoneRetentionDays: 30,
65
+ };
66
+
67
+ export class Reconciler {
68
+ private opts: Required<ReconcilerOptions>;
69
+ private running = false;
70
+ private statusTimer: ReturnType<typeof setInterval> | null = null;
71
+ private prefetchTimer: ReturnType<typeof setInterval> | null = null;
72
+ private tombstoneTimer: ReturnType<typeof setInterval> | null = null;
73
+ private inFlightFetches = 0;
74
+ /** Last status payload — used to suppress no-op syncStateChanged events.
75
+ * During a heavy multi-folder sync the 5s tick fires a lot of identical
76
+ * events; the WebView's event handler runs DOM work for each, which
77
+ * stacks up under load. Diff-only emits keep the channel quiet. */
78
+ private lastStatus = { messageActions: -1, bodyFetches: -1, inFlightFetches: -1 };
79
+
80
+ constructor(
81
+ private db: MailxDB,
82
+ private imapManager: ImapManager,
83
+ private queue: SyncQueue,
84
+ opts: ReconcilerOptions = {},
85
+ ) {
86
+ this.opts = { ...DEFAULTS, ...opts };
87
+ }
88
+
89
+ start(): void {
90
+ if (this.running) return;
91
+ this.running = true;
92
+
93
+ // Status pill: emit current totals every few seconds so the UI
94
+ // can show "Syncing N items" without polling. Diff-only — emit
95
+ // ONLY when something actually changed since the last tick so
96
+ // we don't pipe a constant idle stream through the WebView's
97
+ // event channel during a heavy sync (where the WebView main
98
+ // thread is already busy painting list updates).
99
+ this.statusTimer = setInterval(() => {
100
+ const counts = this.queue.pendingCount();
101
+ const next = { ...counts, inFlightFetches: this.inFlightFetches };
102
+ if (next.messageActions === this.lastStatus.messageActions
103
+ && next.bodyFetches === this.lastStatus.bodyFetches
104
+ && next.inFlightFetches === this.lastStatus.inFlightFetches) {
105
+ return;
106
+ }
107
+ this.lastStatus = next;
108
+ this.imapManager.emit("syncStateChanged", next);
109
+ }, this.opts.statusEmitIntervalMs);
110
+
111
+ // Periodic prefetch tick — owned here (not in ImapManager) so it
112
+ // can back off when the interactive body-fetch lane has work.
113
+ // First tick fires soon after startup so "not downloaded" dots
114
+ // start filling in promptly.
115
+ const prefetchTick = (): void => this.runPrefetchTick();
116
+ setTimeout(prefetchTick, this.opts.prefetchInitialDelayMs);
117
+ this.prefetchTimer = setInterval(prefetchTick, this.opts.prefetchIntervalMs);
118
+
119
+ // Tombstone prune — bookkeeping only. Trivial cost (one indexed
120
+ // DELETE per hour), kept off the IMAP-touching code path because
121
+ // it has nothing to do with sync.
122
+ const pruneTick = (): void => this.runTombstonePrune();
123
+ setTimeout(pruneTick, 30_000); // first run after startup settles
124
+ this.tombstoneTimer = setInterval(pruneTick, this.opts.tombstonePruneIntervalMs);
125
+
126
+ // Kick the body-fetch loop.
127
+ this.pumpBodyFetches();
128
+ }
129
+
130
+ stop(): void {
131
+ this.running = false;
132
+ if (this.statusTimer) { clearInterval(this.statusTimer); this.statusTimer = null; }
133
+ if (this.prefetchTimer) { clearInterval(this.prefetchTimer); this.prefetchTimer = null; }
134
+ if (this.tombstoneTimer) { clearInterval(this.tombstoneTimer); this.tombstoneTimer = null; }
135
+ }
136
+
137
+ /** Hourly bookkeeping — drop tombstones older than the retention window
138
+ * so the deleted-message lookup stays fast. */
139
+ private runTombstonePrune(): void {
140
+ const cutoff = Date.now() - this.opts.tombstoneRetentionDays * 86400_000;
141
+ const n = this.db.pruneTombstones(cutoff);
142
+ if (n > 0) console.log(` [tombstones] pruned ${n} older than ${this.opts.tombstoneRetentionDays} days`);
143
+ }
144
+
145
+ /** Fire prefetchBodies on each account unless the interactive body-fetch
146
+ * lane has user-clicked work pending — in which case skip this tick.
147
+ * ImapManager's per-account `prefetchingAccounts` guard de-dupes if
148
+ * the previous tick is still in flight. */
149
+ private runPrefetchTick(): void {
150
+ if (!this.running) return;
151
+ const counts = this.queue.pendingCount();
152
+ if (counts.bodyFetches >= this.opts.prefetchBackpressureThreshold) {
153
+ // Interactive work pending — let it drain. Next tick reconsiders.
154
+ return;
155
+ }
156
+ for (const acct of this.db.getAccounts()) {
157
+ this.imapManager.prefetchBodies(acct.id).catch(e =>
158
+ console.error(` [prefetch] ${acct.id}: ${e?.message || e}`)
159
+ );
160
+ }
161
+ }
162
+
163
+ /** User-visible "sync now" — runs an immediate poll on all accounts
164
+ * (or just one) without waiting for the next periodic tick. */
165
+ syncNow(accountId?: string): void {
166
+ const accts = accountId ? [{ id: accountId }] : this.db.getAccounts();
167
+ for (const a of accts) {
168
+ this.imapManager.processSyncActions(a.id).catch(() => { /* */ });
169
+ }
170
+ }
171
+
172
+ /** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
173
+ * fetch is fire-and-forget; on completion it kicks the pump again
174
+ * so a queue that grows during work stays drained. */
175
+ private pumpBodyFetches(): void {
176
+ if (!this.running) return;
177
+ while (this.inFlightFetches < this.opts.bodyFetchConcurrency) {
178
+ const next = this.queue.nextBodyFetch();
179
+ if (!next) return;
180
+ this.inFlightFetches++;
181
+ (async () => {
182
+ try {
183
+ const raw = await this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid);
184
+ if (raw) {
185
+ this.imapManager.emit("bodyAvailable", {
186
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
187
+ });
188
+ }
189
+ } catch (err: any) {
190
+ if (err?.isNotFound) {
191
+ try {
192
+ this.db.deleteMessage(next.accountId, next.uid);
193
+ this.db.recalcFolderCounts(next.folderId);
194
+ this.imapManager.emit("messageRemoved", {
195
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
196
+ });
197
+ } catch { /* */ }
198
+ } else {
199
+ const msg = err?.message || "body fetch failed";
200
+ const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg);
201
+ // Transient: re-enqueue (budget=3) so the next pump
202
+ // tick retries. Surface the error only when the
203
+ // budget is exhausted — otherwise we'd flash banners
204
+ // for every blip on a slow server.
205
+ if (transient && this.queue.requeueBodyFetch(next)) {
206
+ // Backoff so we don't immediately re-hit the same
207
+ // wedge. Body-fetch lane is in-memory; setTimeout
208
+ // is fine.
209
+ const backoffMs = 500 * Math.pow(2, next.attempts);
210
+ setTimeout(() => this.pumpBodyFetches(), backoffMs);
211
+ } else {
212
+ this.imapManager.emit("bodyFetchError", {
213
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
214
+ error: msg, transient,
215
+ });
216
+ }
217
+ }
218
+ } finally {
219
+ this.inFlightFetches--;
220
+ this.pumpBodyFetches();
221
+ }
222
+ })();
223
+ }
224
+ }
225
+
226
+ /** External nudge — call after enqueueing a high-priority body fetch. */
227
+ kick(): void {
228
+ this.pumpBodyFetches();
229
+ }
230
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * SyncQueue — the formal enqueue surface for server-side mirroring of
3
+ * local actions.
4
+ *
5
+ * Local-first contract: every UI write commits to the local store first,
6
+ * then hands off here. Nothing in this file does network I/O; the
7
+ * Reconciler drains the queue.
8
+ *
9
+ * Implementation note: the underlying persistence already exists. Message
10
+ * ops live in `sync_actions` (drained by ImapManager.processSyncActions),
11
+ * non-message ops in `store_sync` (drained by the per-domain workers).
12
+ * This class is the public API surface — call sites switch to it so the
13
+ * architecture's seam is visible, the rules are enforceable, and we can
14
+ * later bolt on body-fetch / draft-push / send-push lanes without
15
+ * touching every call site again.
16
+ *
17
+ * Priority lanes (interactive > sync > prefetch > backfill) are tracked
18
+ * here as ordering hints; the actual scheduler is in the Reconciler.
19
+ *
20
+ * Part of docs/local-first-plan.md (steps 3-4).
21
+ */
22
+ import type { MailxDB } from "@bobfrankston/mailx-store";
23
+ import type { ImapManager } from "@bobfrankston/mailx-imap";
24
+ export type Lane = "interactive" | "sync" | "prefetch" | "backfill";
25
+ /** In-memory body-fetch dedupe + ordering. We keep this as a Set rather
26
+ * than persisted rows because body fetches are idempotent (the file is
27
+ * named after the (folder, uid) pair) and a re-fetch on next click is
28
+ * cheap if a process crash drops the queued item. */
29
+ interface BodyFetchKey {
30
+ accountId: string;
31
+ folderId: number;
32
+ uid: number;
33
+ lane: Lane;
34
+ attempts: number;
35
+ }
36
+ export declare class SyncQueue {
37
+ private db;
38
+ private imapManager;
39
+ private bodyFetches;
40
+ constructor(db: MailxDB, imapManager: ImapManager);
41
+ /** Queue a server-side move. The local rows have already been moved in
42
+ * the DB; the mirror reaches the server on the next reconciler pass. */
43
+ enqueueMove(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void;
44
+ /** Queue a server-side flag update (\Seen, \Flagged, \Answered, etc.). */
45
+ enqueueFlag(accountId: string, uid: number, folderId: number, flags: string[]): void;
46
+ /** Queue a server-side delete (or trash, depending on action). */
47
+ enqueueDelete(accountId: string, uid: number, folderId: number, kind?: "delete" | "trash"): void;
48
+ /** Queue an IMAP APPEND of a draft body. Drafts already have crash
49
+ * recovery via the editing/.eml on disk, so this lane is in-memory
50
+ * fire-and-forget for now — the architectural value is centralizing
51
+ * the call site, not adding a second on-disk store for the same
52
+ * bytes. The reconciler retries on transient IMAP failure via the
53
+ * existing sync_actions backoff after first failure. */
54
+ enqueueDraftPush(accountId: string, rawMessage: string, previousDraftUid?: number, draftId?: string): void;
55
+ /** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS
56
+ * the queue and a separate worker drains it; this seam is reserved
57
+ * for when that worker moves under the reconciler. */
58
+ enqueueSend(_accountId: string, _outboxId: string): void;
59
+ /** Request a body fetch. Dedupes per (account, folder, uid). The
60
+ * `lane` argument hints urgency: `interactive` is a click; `prefetch`
61
+ * is the background backfiller; `backfill` is the one-time post-sync
62
+ * catch-up. Reconciler picks higher priority first. */
63
+ enqueueBodyFetch(accountId: string, folderId: number, uid: number, lane?: Lane): void;
64
+ /** Re-enqueue a transient failure — the reconciler calls this after a
65
+ * fetch errored with a transient code. Returns `true` if the item was
66
+ * put back in the queue (still under retry budget) or `false` if its
67
+ * budget is exhausted (caller surfaces the error to the UI). */
68
+ requeueBodyFetch(key: BodyFetchKey, maxAttempts?: number): boolean;
69
+ /** Pop the next body-fetch task at or above `maxLane`. Used by the
70
+ * reconciler to drain interactive clicks before prefetch backfill. */
71
+ nextBodyFetch(maxLane?: Lane): BodyFetchKey | null;
72
+ /** Pending count for diagnostics + the sync-status pill. */
73
+ pendingCount(): {
74
+ messageActions: number;
75
+ bodyFetches: number;
76
+ };
77
+ }
78
+ export {};
79
+ //# sourceMappingURL=sync-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-queue.d.ts","sourceRoot":"","sources":["sync-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAE5D,MAAM,MAAM,IAAI,GAAG,aAAa,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAEpE;;;sDAGsD;AACtD,UAAU,YAAY;IAAG,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AAEzG,qBAAa,SAAS;IAId,OAAO,CAAC,EAAE;IACV,OAAO,CAAC,WAAW;IAJvB,OAAO,CAAC,WAAW,CAAmC;gBAG1C,EAAE,EAAE,OAAO,EACX,WAAW,EAAE,WAAW;IAKpC;6EACyE;IACzE,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAK3F,0EAA0E;IAC1E,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI;IAKpF,kEAAkE;IAClE,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,GAAE,QAAQ,GAAG,OAAkB,GAAG,IAAI;IAK1G;;;;;6DAKyD;IACzD,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,gBAAgB,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI;IAO1G;;2DAEuD;IACvD,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAMxD;;;4DAGwD;IACxD,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,IAAoB,GAAG,IAAI;IAYpG;;;qEAGiE;IACjE,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,WAAW,SAAI,GAAG,OAAO;IAQ7D;2EACuE;IACvE,aAAa,CAAC,OAAO,GAAE,IAAiB,GAAG,YAAY,GAAG,IAAI;IAY9D,4DAA4D;IAC5D,YAAY,IAAI;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE;CAMlE"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * SyncQueue — the formal enqueue surface for server-side mirroring of
3
+ * local actions.
4
+ *
5
+ * Local-first contract: every UI write commits to the local store first,
6
+ * then hands off here. Nothing in this file does network I/O; the
7
+ * Reconciler drains the queue.
8
+ *
9
+ * Implementation note: the underlying persistence already exists. Message
10
+ * ops live in `sync_actions` (drained by ImapManager.processSyncActions),
11
+ * non-message ops in `store_sync` (drained by the per-domain workers).
12
+ * This class is the public API surface — call sites switch to it so the
13
+ * architecture's seam is visible, the rules are enforceable, and we can
14
+ * later bolt on body-fetch / draft-push / send-push lanes without
15
+ * touching every call site again.
16
+ *
17
+ * Priority lanes (interactive > sync > prefetch > backfill) are tracked
18
+ * here as ordering hints; the actual scheduler is in the Reconciler.
19
+ *
20
+ * Part of docs/local-first-plan.md (steps 3-4).
21
+ */
22
+ export class SyncQueue {
23
+ db;
24
+ imapManager;
25
+ bodyFetches = new Map();
26
+ constructor(db, imapManager) {
27
+ this.db = db;
28
+ this.imapManager = imapManager;
29
+ }
30
+ // ── Message-mirror enqueues (commit local DB row first; then call here) ──
31
+ /** Queue a server-side move. The local rows have already been moved in
32
+ * the DB; the mirror reaches the server on the next reconciler pass. */
33
+ enqueueMove(accountId, uid, fromFolderId, toFolderId) {
34
+ this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
35
+ this.imapManager.processSyncActions(accountId).catch(() => { });
36
+ }
37
+ /** Queue a server-side flag update (\Seen, \Flagged, \Answered, etc.). */
38
+ enqueueFlag(accountId, uid, folderId, flags) {
39
+ this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
40
+ this.imapManager.processSyncActions(accountId).catch(() => { });
41
+ }
42
+ /** Queue a server-side delete (or trash, depending on action). */
43
+ enqueueDelete(accountId, uid, folderId, kind = "delete") {
44
+ this.db.queueSyncAction(accountId, kind, uid, folderId, {});
45
+ this.imapManager.processSyncActions(accountId).catch(() => { });
46
+ }
47
+ /** Queue an IMAP APPEND of a draft body. Drafts already have crash
48
+ * recovery via the editing/.eml on disk, so this lane is in-memory
49
+ * fire-and-forget for now — the architectural value is centralizing
50
+ * the call site, not adding a second on-disk store for the same
51
+ * bytes. The reconciler retries on transient IMAP failure via the
52
+ * existing sync_actions backoff after first failure. */
53
+ enqueueDraftPush(accountId, rawMessage, previousDraftUid, draftId) {
54
+ this.imapManager.saveDraft(accountId, rawMessage, previousDraftUid, draftId).catch((e) => {
55
+ console.error(` [sync-queue] draft push deferred (${draftId}): ${e?.message || e}`);
56
+ this.imapManager.emit("draftSaveDeferred", { accountId, draftId, error: String(e?.message || e) });
57
+ });
58
+ }
59
+ /** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS
60
+ * the queue and a separate worker drains it; this seam is reserved
61
+ * for when that worker moves under the reconciler. */
62
+ enqueueSend(_accountId, _outboxId) {
63
+ // No-op — outbox dir-based queue stays as-is per plan decision #5.
64
+ }
65
+ // ── Body fetch lane (in-memory; idempotent) ──
66
+ /** Request a body fetch. Dedupes per (account, folder, uid). The
67
+ * `lane` argument hints urgency: `interactive` is a click; `prefetch`
68
+ * is the background backfiller; `backfill` is the one-time post-sync
69
+ * catch-up. Reconciler picks higher priority first. */
70
+ enqueueBodyFetch(accountId, folderId, uid, lane = "interactive") {
71
+ const key = `${accountId}:${folderId}:${uid}`;
72
+ const existing = this.bodyFetches.get(key);
73
+ if (existing) {
74
+ // Upgrade lane if the new caller is more urgent.
75
+ const rank = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
76
+ if (rank[lane] < rank[existing.lane])
77
+ existing.lane = lane;
78
+ return;
79
+ }
80
+ this.bodyFetches.set(key, { accountId, folderId, uid, lane, attempts: 0 });
81
+ }
82
+ /** Re-enqueue a transient failure — the reconciler calls this after a
83
+ * fetch errored with a transient code. Returns `true` if the item was
84
+ * put back in the queue (still under retry budget) or `false` if its
85
+ * budget is exhausted (caller surfaces the error to the UI). */
86
+ requeueBodyFetch(key, maxAttempts = 3) {
87
+ if (key.attempts + 1 >= maxAttempts)
88
+ return false;
89
+ const k = `${key.accountId}:${key.folderId}:${key.uid}`;
90
+ // Don't downgrade lane on retry; keep the one the original caller set.
91
+ this.bodyFetches.set(k, { ...key, attempts: key.attempts + 1 });
92
+ return true;
93
+ }
94
+ /** Pop the next body-fetch task at or above `maxLane`. Used by the
95
+ * reconciler to drain interactive clicks before prefetch backfill. */
96
+ nextBodyFetch(maxLane = "backfill") {
97
+ const rank = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
98
+ const cap = rank[maxLane];
99
+ let best = null;
100
+ for (const v of this.bodyFetches.values()) {
101
+ if (rank[v.lane] > cap)
102
+ continue;
103
+ if (!best || rank[v.lane] < rank[best.lane])
104
+ best = v;
105
+ }
106
+ if (best)
107
+ this.bodyFetches.delete(`${best.accountId}:${best.folderId}:${best.uid}`);
108
+ return best;
109
+ }
110
+ /** Pending count for diagnostics + the sync-status pill. */
111
+ pendingCount() {
112
+ return {
113
+ messageActions: this.db.getTotalPendingSyncCount(),
114
+ bodyFetches: this.bodyFetches.size,
115
+ };
116
+ }
117
+ }
118
+ //# sourceMappingURL=sync-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-queue.js","sourceRoot":"","sources":["sync-queue.ts"],"names":[],"mappings":"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"}