@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.
- package/client/android-bootstrap.bundle.js +81 -13
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/package.json +3 -3
- package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +94 -27
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +86 -19
- package/packages/mailx-store-web/package.json +1 -1
|
@@ -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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
210
|
+
}).catch((e: any) => console.error(`[sync] ${account.id} inbox: ${e?.message || e}`)),
|
|
211
|
+
));
|
|
212
|
+
}
|
|
155
213
|
|
|
156
|
-
|
|
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
|
}
|