@bobfrankston/rmfmail 1.2.76 → 1.2.78

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.
@@ -60,6 +60,27 @@ function vlog(msg: string): void {
60
60
 
61
61
  // ── Sync Manager ──
62
62
 
63
+ /** Race a promise against a wall-clock timeout. On timeout it REJECTS so the
64
+ * caller's try/catch moves on — the underlying op may keep running in the
65
+ * background, but it can no longer STALL the whole sync. Critical on Android:
66
+ * an auth-failing IMAP account (outlook/aol) or a slow BridgeTransport
67
+ * listFolders/fetch would otherwise hang Phase 1 forever and new mail for the
68
+ * healthy accounts never arrived (Bob 2026-06-27). */
69
+ function withTimeout<T>(label: string, ms: number, fn: () => Promise<T>): Promise<T> {
70
+ return new Promise<T>((resolve, reject) => {
71
+ let done = false;
72
+ const timer = setTimeout(() => {
73
+ if (done) return;
74
+ done = true;
75
+ reject(new Error(`timeout after ${ms}ms: ${label}`));
76
+ }, ms);
77
+ fn().then(
78
+ (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
79
+ (e) => { if (!done) { done = true; clearTimeout(timer); reject(e); } },
80
+ );
81
+ });
82
+ }
83
+
63
84
  class AndroidSyncManager implements WebSyncManager {
64
85
  private providers = new Map<string, MailProvider>();
65
86
  private tokenProviders = new Map<string, () => Promise<string>>();
@@ -67,6 +88,19 @@ class AndroidSyncManager implements WebSyncManager {
67
88
  // spawning parallel fetch loops that race on IndexedDB and blow through
68
89
  // Gmail's per-user quota.
69
90
  private prefetchingAccounts = new Set<string>();
91
+ // Single-flight guard for syncAll. It's triggered from THREE places — boot,
92
+ // a 60s interval, and every visibilitychange→visible — with no coordination.
93
+ // On Android everything (incl. wa-sqlite) runs on the main thread, so
94
+ // overlapping runs stacked up: re-listing folders repeatedly, contending on
95
+ // the DB, and freezing the event loop ~1s at a time — the sync never
96
+ // advanced to fetching new mail, so the list stayed stale despite "Synced"
97
+ // (Bob 2026-06-27). Coalesce concurrent calls to one in-flight run.
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;
70
104
 
71
105
  constructor(
72
106
  private db: WebMailxDB,
@@ -135,48 +169,81 @@ class AndroidSyncManager implements WebSyncManager {
135
169
  }
136
170
 
137
171
  async syncAll(): Promise<void> {
138
- const accounts = this.db.getAccounts();
139
- vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
172
+ // The inbox guard covers ONLY Phase 1 (inbox sync). Overlapping triggers
173
+ // coalesce so they can't thrash the single main thread.
174
+ if (this.syncAllInflight) {
175
+ console.log("[sync] inbox sync already in flight — coalescing this trigger");
176
+ return this.syncAllInflight;
177
+ }
178
+ const t0 = Date.now();
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}`); })
182
+ .finally(() => {
183
+ console.log(`[sync] inbox phase DONE in ${Date.now() - t0}ms`);
184
+ this.syncAllInflight = null;
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();
190
+ return this.syncAllInflight;
191
+ }
140
192
 
141
- // Phase 1: Sync INBOX for every account first user sees new mail fast.
142
- for (const account of accounts) {
143
- if (!this.providers.has(account.id)) continue;
144
- try {
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 () => {
145
202
  const folders = await this.syncFolders(account.id);
146
203
  const inbox = folders.find(f => f.specialUse === "inbox");
147
204
  if (inbox) {
205
+ console.log(`[sync] ${account.id}: fetching INBOX (folderId=${inbox.id})`);
148
206
  await this.syncFolder(account.id, inbox.id);
207
+ console.log(`[sync] ${account.id}: INBOX fetch done`);
149
208
  emitEvent({ type: "syncComplete", accountId: account.id });
150
209
  }
151
- } catch (e: any) {
152
- console.error(`[sync] ${account.id} inbox: ${e.message}`);
153
- }
154
- }
210
+ }).catch((e: any) => console.error(`[sync] ${account.id} inbox: ${e?.message || e}`)),
211
+ ));
212
+ }
155
213
 
156
- // Phase 2: Remaining folders (sent, drafts, trash, then everything else).
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
+ }
226
+
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).
157
230
  for (const account of accounts) {
158
- if (!this.providers.has(account.id)) continue;
159
231
  try {
160
232
  const folders = this.db.getFolders(account.id);
161
233
  const remaining = folders.filter(f => f.specialUse !== "inbox");
162
234
  for (const folder of remaining) {
163
- try { await this.syncFolder(account.id, folder.id); }
235
+ try { await withTimeout(`${account.id} ${folder.path}`, 45000, () => this.syncFolder(account.id, folder.id)); }
164
236
  catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); }
165
237
  }
166
238
  this.db.updateLastSync(account.id, Date.now());
167
239
  emitEvent({ type: "syncComplete", accountId: account.id });
168
240
  } catch (e: any) {
169
241
  console.error(`[sync] ${account.id}: ${e.message}`);
170
- vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
171
242
  emitEvent({ type: "syncError", accountId: account.id, error: e.message });
172
243
  }
173
244
  }
174
-
175
- // Phase 3: background body prefetch. Fire-and-forget — sync itself is
176
- // already done and the UI doesn't wait on this. Per-account guard means
177
- // a slow account can't block a fast one.
245
+ // Phase 3: body prefetch (fire-and-forget per account).
178
246
  for (const account of accounts) {
179
- if (!this.providers.has(account.id)) continue;
180
247
  this.prefetchBodies(account.id).catch(e =>
181
248
  console.error(`[prefetch] ${account.id}: ${e.message}`));
182
249
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",