@bobfrankston/rmfmail 1.2.77 → 1.2.79

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.
@@ -96,6 +96,11 @@ class AndroidSyncManager implements WebSyncManager {
96
96
  // advanced to fetching new mail, so the list stayed stale despite "Synced"
97
97
  // (Bob 2026-06-27). Coalesce concurrent calls to one in-flight run.
98
98
  private syncAllInflight: Promise<void> | null = null;
99
+ // Separate guard for the Phase-2/3 background pass (other folders + body
100
+ // prefetch). It runs DETACHED from the inbox guard so a long folder sweep
101
+ // can't make the 60s poll skip the inbox refresh (new mail must keep
102
+ // flowing). One background pass at a time — overlapping triggers no-op.
103
+ private bgSyncInflight: Promise<void> | null = null;
99
104
 
100
105
  constructor(
101
106
  private db: WebMailxDB,
@@ -164,50 +169,65 @@ class AndroidSyncManager implements WebSyncManager {
164
169
  }
165
170
 
166
171
  async syncAll(): Promise<void> {
167
- // Single-flight: coalesce overlapping triggers (boot / 60s interval /
168
- // resume) into one run so they can't stack and thrash the main thread.
172
+ // The inbox guard covers ONLY Phase 1 (inbox sync). Overlapping triggers
173
+ // coalesce so they can't thrash the single main thread.
169
174
  if (this.syncAllInflight) {
170
- console.log("[sync] syncAll already in flight — coalescing this trigger");
175
+ console.log("[sync] inbox sync already in flight — coalescing this trigger");
171
176
  return this.syncAllInflight;
172
177
  }
173
178
  const t0 = Date.now();
174
- console.log("[sync] syncAll START");
175
- this.syncAllInflight = this._syncAllImpl()
176
- .catch((e: any) => { console.error(`[sync] syncAll error: ${e?.message || e}`); })
179
+ console.log("[sync] syncAll START (inbox phase)");
180
+ this.syncAllInflight = this._syncInboxes()
181
+ .catch((e: any) => { console.error(`[sync] inbox sync error: ${e?.message || e}`); })
177
182
  .finally(() => {
178
- console.log(`[sync] syncAll DONE in ${Date.now() - t0}ms`);
183
+ console.log(`[sync] inbox phase DONE in ${Date.now() - t0}ms`);
179
184
  this.syncAllInflight = null;
180
185
  });
186
+ // Phase 2/3 (other folders + body prefetch) run DETACHED, under their own
187
+ // guard — they must not hold the inbox guard, or a slow folder sweep would
188
+ // make the next 60s poll skip new inbox mail.
189
+ this._kickBackgroundSync();
181
190
  return this.syncAllInflight;
182
191
  }
183
192
 
184
- private async _syncAllImpl(): Promise<void> {
185
- const accounts = this.db.getAccounts();
186
- vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
193
+ /** Phase 1: sync every account's INBOX IN PARALLEL so a slow account (e.g.
194
+ * Gmail's metadata fetch, or an auth-failing IMAP account) can't delay new
195
+ * mail for the others. Each account is time-boxed; network fetches overlap
196
+ * even though JS is single-threaded (the awaits yield). */
197
+ private async _syncInboxes(): Promise<void> {
198
+ const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id));
199
+ vlog(`syncInboxes: ${accounts.length} accounts: ${accounts.map(a => a.id).join(",")}`);
200
+ await Promise.all(accounts.map(account =>
201
+ withTimeout(`${account.id} inbox`, 60000, async () => {
202
+ const folders = await this.syncFolders(account.id);
203
+ const inbox = folders.find(f => f.specialUse === "inbox");
204
+ if (inbox) {
205
+ console.log(`[sync] ${account.id}: fetching INBOX (folderId=${inbox.id})`);
206
+ await this.syncFolder(account.id, inbox.id);
207
+ console.log(`[sync] ${account.id}: INBOX fetch done`);
208
+ emitEvent({ type: "syncComplete", accountId: account.id });
209
+ }
210
+ }).catch((e: any) => console.error(`[sync] ${account.id} inbox: ${e?.message || e}`)),
211
+ ));
212
+ }
187
213
 
188
- // Phase 1: Sync INBOX for every account first user sees new mail fast.
189
- // Each account is time-boxed so one hung account can't starve the rest.
190
- for (const account of accounts) {
191
- if (!this.providers.has(account.id)) continue;
192
- try {
193
- await withTimeout(`${account.id} inbox`, 60000, async () => {
194
- const folders = await this.syncFolders(account.id);
195
- const inbox = folders.find(f => f.specialUse === "inbox");
196
- if (inbox) {
197
- console.log(`[sync] ${account.id}: fetching INBOX (folderId=${inbox.id})`);
198
- await this.syncFolder(account.id, inbox.id);
199
- console.log(`[sync] ${account.id}: INBOX fetch done`);
200
- emitEvent({ type: "syncComplete", accountId: account.id });
201
- }
202
- });
203
- } catch (e: any) {
204
- console.error(`[sync] ${account.id} inbox: ${e.message}`);
205
- }
206
- }
214
+ /** Phase 2 + 3: remaining folders, then body prefetch. Runs in the
215
+ * background under its own single-flight guard. */
216
+ private _kickBackgroundSync(): void {
217
+ if (this.bgSyncInflight) return;
218
+ const t0 = Date.now();
219
+ this.bgSyncInflight = this._syncRemainingAndPrefetch()
220
+ .catch((e: any) => console.error(`[sync] background sweep error: ${e?.message || e}`))
221
+ .finally(() => {
222
+ console.log(`[sync] background sweep DONE in ${Date.now() - t0}ms`);
223
+ this.bgSyncInflight = null;
224
+ });
225
+ }
207
226
 
208
- // Phase 2: Remaining folders (sent, drafts, trash, then everything else).
227
+ private async _syncRemainingAndPrefetch(): Promise<void> {
228
+ const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id));
229
+ // Phase 2: remaining folders (per account, per folder time-boxed).
209
230
  for (const account of accounts) {
210
- if (!this.providers.has(account.id)) continue;
211
231
  try {
212
232
  const folders = this.db.getFolders(account.id);
213
233
  const remaining = folders.filter(f => f.specialUse !== "inbox");
@@ -219,16 +239,11 @@ class AndroidSyncManager implements WebSyncManager {
219
239
  emitEvent({ type: "syncComplete", accountId: account.id });
220
240
  } catch (e: any) {
221
241
  console.error(`[sync] ${account.id}: ${e.message}`);
222
- vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
223
242
  emitEvent({ type: "syncError", accountId: account.id, error: e.message });
224
243
  }
225
244
  }
226
-
227
- // Phase 3: background body prefetch. Fire-and-forget — sync itself is
228
- // already done and the UI doesn't wait on this. Per-account guard means
229
- // a slow account can't block a fast one.
245
+ // Phase 3: body prefetch (fire-and-forget per account).
230
246
  for (const account of accounts) {
231
- if (!this.providers.has(account.id)) continue;
232
247
  this.prefetchBodies(account.id).catch(e =>
233
248
  console.error(`[prefetch] ${account.id}: ${e.message}`));
234
249
  }
@@ -394,11 +409,27 @@ class AndroidSyncManager implements WebSyncManager {
394
409
  emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
395
410
  const highestUid = this.db.getHighestUid(accountId, folderId);
396
411
  const startDate = new Date(Date.now() - 30 * 86400000);
412
+ const account = this.db.getAccounts().find(a => a.id === accountId);
413
+ const isGoogle = !!account && isGoogleAccount(account);
397
414
 
398
415
  let messages: ProviderMessage[];
399
416
  if (highestUid > 0) {
400
- messages = await provider.fetchSince(folder.path, highestUid, { source: false });
401
- messages = messages.filter(m => m.uid > highestUid);
417
+ const opts: { source: boolean; knownUids?: Set<number> } = { source: false };
418
+ if (isGoogle) {
419
+ // Gmail re-lists a recent page (IDs aren't monotonic). Give it
420
+ // the set of UIDs we already have so it fetches only new ones
421
+ // instead of re-pulling ~200 every sync (Bob 2026-06-27).
422
+ opts.knownUids = new Set(this.db.getUidsForFolder(accountId, folderId));
423
+ }
424
+ messages = await provider.fetchSince(folder.path, highestUid, opts);
425
+ // IMAP UIDs are monotonic — keep the high-water guard. For Gmail the
426
+ // knownUids skip already returned only NEW messages; applying
427
+ // `uid > highestUid` to Gmail's HASH uids is a lottery that wrongly
428
+ // drops new mail whose hash falls below the mark (the long-standing
429
+ // "Gmail new messages missing" bug).
430
+ if (!isGoogle) {
431
+ messages = messages.filter(m => m.uid > highestUid);
432
+ }
402
433
  } else {
403
434
  const tomorrow = new Date(Date.now() + 86400000);
404
435
  messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",