@bobfrankston/rmfmail 1.0.546 → 1.0.548

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 (40) 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 +51 -1
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +50 -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/lib/api-client.js +5 -0
  14. package/client/lib/api-client.js.map +1 -1
  15. package/client/lib/api-client.ts +5 -1
  16. package/client/styles/components.css +0 -12
  17. package/package.json +1 -1
  18. package/packages/mailx-server/index.d.ts.map +1 -1
  19. package/packages/mailx-server/index.js +13 -0
  20. package/packages/mailx-server/index.js.map +1 -1
  21. package/packages/mailx-server/index.ts +14 -0
  22. package/packages/mailx-service/index.d.ts +26 -0
  23. package/packages/mailx-service/index.d.ts.map +1 -1
  24. package/packages/mailx-service/index.js +82 -235
  25. package/packages/mailx-service/index.js.map +1 -1
  26. package/packages/mailx-service/index.ts +45 -54
  27. package/packages/mailx-service/local-store.d.ts +5 -0
  28. package/packages/mailx-service/local-store.d.ts.map +1 -1
  29. package/packages/mailx-service/local-store.js +112 -12
  30. package/packages/mailx-service/local-store.js.map +1 -1
  31. package/packages/mailx-service/reconciler.d.ts +62 -0
  32. package/packages/mailx-service/reconciler.d.ts.map +1 -0
  33. package/packages/mailx-service/reconciler.js +141 -0
  34. package/packages/mailx-service/reconciler.js.map +1 -0
  35. package/packages/mailx-service/reconciler.ts +151 -0
  36. package/packages/mailx-service/sync-queue.d.ts +79 -0
  37. package/packages/mailx-service/sync-queue.d.ts.map +1 -0
  38. package/packages/mailx-service/sync-queue.js +118 -0
  39. package/packages/mailx-service/sync-queue.js.map +1 -0
  40. package/packages/mailx-service/sync-queue.ts +134 -0
@@ -0,0 +1,151 @@
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
+ }
43
+
44
+ const DEFAULTS: Required<ReconcilerOptions> = {
45
+ bodyFetchConcurrency: 2,
46
+ statusEmitIntervalMs: 5_000,
47
+ };
48
+
49
+ export class Reconciler {
50
+ private opts: Required<ReconcilerOptions>;
51
+ private running = false;
52
+ private statusTimer: ReturnType<typeof setInterval> | null = null;
53
+ private inFlightFetches = 0;
54
+
55
+ constructor(
56
+ private db: MailxDB,
57
+ private imapManager: ImapManager,
58
+ private queue: SyncQueue,
59
+ opts: ReconcilerOptions = {},
60
+ ) {
61
+ this.opts = { ...DEFAULTS, ...opts };
62
+ }
63
+
64
+ start(): void {
65
+ if (this.running) return;
66
+ this.running = true;
67
+
68
+ // Status pill: emit current totals every few seconds so the UI
69
+ // can show "Syncing N items" without polling.
70
+ this.statusTimer = setInterval(() => {
71
+ const counts = this.queue.pendingCount();
72
+ this.imapManager.emit("syncStateChanged", { ...counts, inFlightFetches: this.inFlightFetches });
73
+ }, this.opts.statusEmitIntervalMs);
74
+
75
+ // Kick the body-fetch loop.
76
+ this.pumpBodyFetches();
77
+ }
78
+
79
+ stop(): void {
80
+ this.running = false;
81
+ if (this.statusTimer) { clearInterval(this.statusTimer); this.statusTimer = null; }
82
+ }
83
+
84
+ /** User-visible "sync now" — runs an immediate poll on all accounts
85
+ * (or just one) without waiting for the next periodic tick. */
86
+ syncNow(accountId?: string): void {
87
+ const accts = accountId ? [{ id: accountId }] : this.db.getAccounts();
88
+ for (const a of accts) {
89
+ this.imapManager.processSyncActions(a.id).catch(() => { /* */ });
90
+ }
91
+ }
92
+
93
+ /** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
94
+ * fetch is fire-and-forget; on completion it kicks the pump again
95
+ * so a queue that grows during work stays drained. */
96
+ private pumpBodyFetches(): void {
97
+ if (!this.running) return;
98
+ while (this.inFlightFetches < this.opts.bodyFetchConcurrency) {
99
+ const next = this.queue.nextBodyFetch();
100
+ if (!next) return;
101
+ this.inFlightFetches++;
102
+ (async () => {
103
+ try {
104
+ const raw = await this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid);
105
+ if (raw) {
106
+ this.imapManager.emit("bodyAvailable", {
107
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
108
+ });
109
+ }
110
+ } catch (err: any) {
111
+ if (err?.isNotFound) {
112
+ try {
113
+ this.db.deleteMessage(next.accountId, next.uid);
114
+ this.db.recalcFolderCounts(next.folderId);
115
+ this.imapManager.emit("messageRemoved", {
116
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
117
+ });
118
+ } catch { /* */ }
119
+ } else {
120
+ const msg = err?.message || "body fetch failed";
121
+ const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(msg);
122
+ // Transient: re-enqueue (budget=3) so the next pump
123
+ // tick retries. Surface the error only when the
124
+ // budget is exhausted — otherwise we'd flash banners
125
+ // for every blip on a slow server.
126
+ if (transient && this.queue.requeueBodyFetch(next)) {
127
+ // Backoff so we don't immediately re-hit the same
128
+ // wedge. Body-fetch lane is in-memory; setTimeout
129
+ // is fine.
130
+ const backoffMs = 500 * Math.pow(2, next.attempts);
131
+ setTimeout(() => this.pumpBodyFetches(), backoffMs);
132
+ } else {
133
+ this.imapManager.emit("bodyFetchError", {
134
+ accountId: next.accountId, folderId: next.folderId, uid: next.uid,
135
+ error: msg, transient,
136
+ });
137
+ }
138
+ }
139
+ } finally {
140
+ this.inFlightFetches--;
141
+ this.pumpBodyFetches();
142
+ }
143
+ })();
144
+ }
145
+ }
146
+
147
+ /** External nudge — call after enqueueing a high-priority body fetch. */
148
+ kick(): void {
149
+ this.pumpBodyFetches();
150
+ }
151
+ }
@@ -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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"}
@@ -0,0 +1,134 @@
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
+
23
+ import type { MailxDB } from "@bobfrankston/mailx-store";
24
+ import type { ImapManager } from "@bobfrankston/mailx-imap";
25
+
26
+ export type Lane = "interactive" | "sync" | "prefetch" | "backfill";
27
+
28
+ /** In-memory body-fetch dedupe + ordering. We keep this as a Set rather
29
+ * than persisted rows because body fetches are idempotent (the file is
30
+ * named after the (folder, uid) pair) and a re-fetch on next click is
31
+ * cheap if a process crash drops the queued item. */
32
+ interface BodyFetchKey { accountId: string; folderId: number; uid: number; lane: Lane; attempts: number }
33
+
34
+ export class SyncQueue {
35
+ private bodyFetches = new Map<string, BodyFetchKey>();
36
+
37
+ constructor(
38
+ private db: MailxDB,
39
+ private imapManager: ImapManager,
40
+ ) {}
41
+
42
+ // ── Message-mirror enqueues (commit local DB row first; then call here) ──
43
+
44
+ /** Queue a server-side move. The local rows have already been moved in
45
+ * the DB; the mirror reaches the server on the next reconciler pass. */
46
+ enqueueMove(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void {
47
+ this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
48
+ this.imapManager.processSyncActions(accountId).catch(() => { /* retried by reconciler */ });
49
+ }
50
+
51
+ /** Queue a server-side flag update (\Seen, \Flagged, \Answered, etc.). */
52
+ enqueueFlag(accountId: string, uid: number, folderId: number, flags: string[]): void {
53
+ this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
54
+ this.imapManager.processSyncActions(accountId).catch(() => { /* retried */ });
55
+ }
56
+
57
+ /** Queue a server-side delete (or trash, depending on action). */
58
+ enqueueDelete(accountId: string, uid: number, folderId: number, kind: "delete" | "trash" = "delete"): void {
59
+ this.db.queueSyncAction(accountId, kind, uid, folderId, {});
60
+ this.imapManager.processSyncActions(accountId).catch(() => { /* retried */ });
61
+ }
62
+
63
+ /** Queue an IMAP APPEND of a draft body. Drafts already have crash
64
+ * recovery via the editing/.eml on disk, so this lane is in-memory
65
+ * fire-and-forget for now — the architectural value is centralizing
66
+ * the call site, not adding a second on-disk store for the same
67
+ * bytes. The reconciler retries on transient IMAP failure via the
68
+ * existing sync_actions backoff after first failure. */
69
+ enqueueDraftPush(accountId: string, rawMessage: string, previousDraftUid?: number, draftId?: string): void {
70
+ this.imapManager.saveDraft(accountId, rawMessage, previousDraftUid, draftId).catch((e: any) => {
71
+ console.error(` [sync-queue] draft push deferred (${draftId}): ${e?.message || e}`);
72
+ this.imapManager.emit("draftSaveDeferred", { accountId, draftId, error: String(e?.message || e) });
73
+ });
74
+ }
75
+
76
+ /** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS
77
+ * the queue and a separate worker drains it; this seam is reserved
78
+ * for when that worker moves under the reconciler. */
79
+ enqueueSend(_accountId: string, _outboxId: string): void {
80
+ // No-op — outbox dir-based queue stays as-is per plan decision #5.
81
+ }
82
+
83
+ // ── Body fetch lane (in-memory; idempotent) ──
84
+
85
+ /** Request a body fetch. Dedupes per (account, folder, uid). The
86
+ * `lane` argument hints urgency: `interactive` is a click; `prefetch`
87
+ * is the background backfiller; `backfill` is the one-time post-sync
88
+ * catch-up. Reconciler picks higher priority first. */
89
+ enqueueBodyFetch(accountId: string, folderId: number, uid: number, lane: Lane = "interactive"): void {
90
+ const key = `${accountId}:${folderId}:${uid}`;
91
+ const existing = this.bodyFetches.get(key);
92
+ if (existing) {
93
+ // Upgrade lane if the new caller is more urgent.
94
+ const rank: Record<Lane, number> = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
95
+ if (rank[lane] < rank[existing.lane]) existing.lane = lane;
96
+ return;
97
+ }
98
+ this.bodyFetches.set(key, { accountId, folderId, uid, lane, attempts: 0 });
99
+ }
100
+
101
+ /** Re-enqueue a transient failure — the reconciler calls this after a
102
+ * fetch errored with a transient code. Returns `true` if the item was
103
+ * put back in the queue (still under retry budget) or `false` if its
104
+ * budget is exhausted (caller surfaces the error to the UI). */
105
+ requeueBodyFetch(key: BodyFetchKey, maxAttempts = 3): boolean {
106
+ if (key.attempts + 1 >= maxAttempts) return false;
107
+ const k = `${key.accountId}:${key.folderId}:${key.uid}`;
108
+ // Don't downgrade lane on retry; keep the one the original caller set.
109
+ this.bodyFetches.set(k, { ...key, attempts: key.attempts + 1 });
110
+ return true;
111
+ }
112
+
113
+ /** Pop the next body-fetch task at or above `maxLane`. Used by the
114
+ * reconciler to drain interactive clicks before prefetch backfill. */
115
+ nextBodyFetch(maxLane: Lane = "backfill"): BodyFetchKey | null {
116
+ const rank: Record<Lane, number> = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
117
+ const cap = rank[maxLane];
118
+ let best: BodyFetchKey | null = null;
119
+ for (const v of this.bodyFetches.values()) {
120
+ if (rank[v.lane] > cap) continue;
121
+ if (!best || rank[v.lane] < rank[best.lane]) best = v;
122
+ }
123
+ if (best) this.bodyFetches.delete(`${best.accountId}:${best.folderId}:${best.uid}`);
124
+ return best;
125
+ }
126
+
127
+ /** Pending count for diagnostics + the sync-status pill. */
128
+ pendingCount(): { messageActions: number; bodyFetches: number } {
129
+ return {
130
+ messageActions: this.db.getTotalPendingSyncCount(),
131
+ bodyFetches: this.bodyFetches.size,
132
+ };
133
+ }
134
+ }