@bobfrankston/rmfmail 1.1.97 → 1.1.99

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.
@@ -33,6 +33,17 @@ interface BodyFetchKey { accountId: string; folderId: number; uid: number; lane:
33
33
 
34
34
  export class SyncQueue {
35
35
  private bodyFetches = new Map<string, BodyFetchKey>();
36
+ /** Per-draft serialization slot. Autosave fires every ~2 s but an IMAP
37
+ * search-delete-append cycle takes longer; unserialized pushes
38
+ * overlapped, each one's "find the stale copy" running before a
39
+ * sibling's "append" — so neither saw the other's new copy and the
40
+ * Drafts folder filled with dozens of duplicates (Bob 2026-05-19).
41
+ * One push in flight per draft; while it runs, only the LATEST queued
42
+ * raw message is kept (intermediate autosaves are superseded). */
43
+ private draftPushes = new Map<string, {
44
+ inFlight: boolean;
45
+ pending: { rawMessage: string; previousDraftUid?: number } | null;
46
+ }>();
36
47
  /** Per-account debounce timers. Multiple rapid enqueues (3 flag toggles,
37
48
  * bulk move/delete) coalesce into one processSyncActions drain after
38
49
  * DRAIN_DEBOUNCE_MS. Was previously in ImapManager.debounceSyncActions;
@@ -97,10 +108,34 @@ export class SyncQueue {
97
108
  * bytes. The reconciler retries on transient IMAP failure via the
98
109
  * existing sync_actions backoff after first failure. */
99
110
  enqueueDraftPush(accountId: string, rawMessage: string, previousDraftUid?: number, draftId?: string): void {
100
- this.imapManager.saveDraft(accountId, rawMessage, previousDraftUid, draftId).catch((e: any) => {
101
- console.error(` [sync-queue] draft push deferred (${draftId}): ${e?.message || e}`);
102
- this.imapManager.emit("draftSaveDeferred", { accountId, draftId, error: String(e?.message || e) });
103
- });
111
+ const key = `${accountId}:${draftId || ""}`;
112
+ let slot = this.draftPushes.get(key);
113
+ if (slot && slot.inFlight) {
114
+ // A push is already running for this draft — supersede: keep only
115
+ // the newest content. The in-flight push finishes, then this runs.
116
+ slot.pending = { rawMessage, previousDraftUid };
117
+ return;
118
+ }
119
+ if (!slot) { slot = { inFlight: false, pending: null }; this.draftPushes.set(key, slot); }
120
+ slot.inFlight = true;
121
+ const run = (raw: string, prevUid?: number): void => {
122
+ this.imapManager.saveDraft(accountId, raw, prevUid, draftId)
123
+ .catch((e: any) => {
124
+ console.error(` [sync-queue] draft push deferred (${draftId}): ${e?.message || e}`);
125
+ this.imapManager.emit("draftSaveDeferred", { accountId, draftId, error: String(e?.message || e) });
126
+ })
127
+ .finally(() => {
128
+ const next = slot!.pending;
129
+ slot!.pending = null;
130
+ if (next) {
131
+ run(next.rawMessage, next.previousDraftUid); // drain the coalesced save
132
+ } else {
133
+ slot!.inFlight = false;
134
+ this.draftPushes.delete(key);
135
+ }
136
+ });
137
+ };
138
+ run(rawMessage, previousDraftUid);
104
139
  }
105
140
 
106
141
  /** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS