@bobfrankston/mailx-imap 0.1.30 → 0.1.32
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/index.d.ts +18 -0
- package/index.js +64 -3
- package/package.json +1 -1
package/index.d.ts
CHANGED
|
@@ -224,7 +224,25 @@ export declare class ImapManager extends EventEmitter {
|
|
|
224
224
|
path: string;
|
|
225
225
|
}, uid: number, source: string, flags: string[]): Promise<void>;
|
|
226
226
|
/** Sync messages for a specific folder */
|
|
227
|
+
/** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
|
|
228
|
+
* for the same folder concurrently — `syncOne` (full sync), `syncInbox`
|
|
229
|
+
* (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
|
|
230
|
+
* `syncInboxNewOnly` (IDLE callback). Each path takes a different
|
|
231
|
+
* connection, so withConnection can't serialize them. The DB layer
|
|
232
|
+
* enforces "one transaction per connection" but doesn't notice the
|
|
233
|
+
* concurrent UID set being mutated underneath. Symptom: two
|
|
234
|
+
* `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
|
|
235
|
+
* 21:47:52), `cannot start a transaction within a transaction` SQLite
|
|
236
|
+
* errors, and prefetch SELECT/FETCH races when sync's SELECT runs
|
|
237
|
+
* between prefetch's SELECT and FETCH.
|
|
238
|
+
*
|
|
239
|
+
* This per-folder mutex means the second concurrent caller waits
|
|
240
|
+
* rather than silently racing. The waiter still gets the lock when
|
|
241
|
+
* the first caller finishes — important for outbox flushes that
|
|
242
|
+
* expect their syncFolder for `Sent` to actually run. */
|
|
243
|
+
private syncFolderLocks;
|
|
227
244
|
syncFolder(accountId: string, folderId: number, client?: any): Promise<number>;
|
|
245
|
+
private _syncFolderImpl;
|
|
228
246
|
/** Sync all folders for all accounts */
|
|
229
247
|
syncAll(): Promise<void>;
|
|
230
248
|
private _syncAll;
|
package/index.js
CHANGED
|
@@ -972,7 +972,41 @@ export class ImapManager extends EventEmitter {
|
|
|
972
972
|
console.log(` [local-insert] ${folder.path} UID ${uid}: ${parsed.subject || "(no subject)"} (no IMAP roundtrip)`);
|
|
973
973
|
}
|
|
974
974
|
/** Sync messages for a specific folder */
|
|
975
|
+
/** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
|
|
976
|
+
* for the same folder concurrently — `syncOne` (full sync), `syncInbox`
|
|
977
|
+
* (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
|
|
978
|
+
* `syncInboxNewOnly` (IDLE callback). Each path takes a different
|
|
979
|
+
* connection, so withConnection can't serialize them. The DB layer
|
|
980
|
+
* enforces "one transaction per connection" but doesn't notice the
|
|
981
|
+
* concurrent UID set being mutated underneath. Symptom: two
|
|
982
|
+
* `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
|
|
983
|
+
* 21:47:52), `cannot start a transaction within a transaction` SQLite
|
|
984
|
+
* errors, and prefetch SELECT/FETCH races when sync's SELECT runs
|
|
985
|
+
* between prefetch's SELECT and FETCH.
|
|
986
|
+
*
|
|
987
|
+
* This per-folder mutex means the second concurrent caller waits
|
|
988
|
+
* rather than silently racing. The waiter still gets the lock when
|
|
989
|
+
* the first caller finishes — important for outbox flushes that
|
|
990
|
+
* expect their syncFolder for `Sent` to actually run. */
|
|
991
|
+
syncFolderLocks = new Map();
|
|
975
992
|
async syncFolder(accountId, folderId, client) {
|
|
993
|
+
const lockKey = `${accountId}:${folderId}`;
|
|
994
|
+
const inflight = this.syncFolderLocks.get(lockKey);
|
|
995
|
+
if (inflight) {
|
|
996
|
+
// Coalesce: callers that fire while a sync is in flight get the
|
|
997
|
+
// result of the in-flight call rather than starting a duplicate.
|
|
998
|
+
// For "quick check that finds new mail and triggers sync" this
|
|
999
|
+
// means the quick check waits for the existing sync to complete
|
|
1000
|
+
// — which is what the user wants anyway, no double work.
|
|
1001
|
+
console.log(` [sync-enter] ${accountId}/${folderId}: coalescing (sync already in flight)`);
|
|
1002
|
+
return inflight;
|
|
1003
|
+
}
|
|
1004
|
+
const promise = this._syncFolderImpl(accountId, folderId, client)
|
|
1005
|
+
.finally(() => { this.syncFolderLocks.delete(lockKey); });
|
|
1006
|
+
this.syncFolderLocks.set(lockKey, promise);
|
|
1007
|
+
return promise;
|
|
1008
|
+
}
|
|
1009
|
+
async _syncFolderImpl(accountId, folderId, client) {
|
|
976
1010
|
if (!client)
|
|
977
1011
|
client = await this.getOpsClient(accountId);
|
|
978
1012
|
const prefetch = getPrefetch();
|
|
@@ -2381,6 +2415,16 @@ export class ImapManager extends EventEmitter {
|
|
|
2381
2415
|
async prefetchBodies(accountId) {
|
|
2382
2416
|
if (this.prefetchingAccounts.has(accountId))
|
|
2383
2417
|
return;
|
|
2418
|
+
// Skip if the account isn't registered yet — the reconciler tick
|
|
2419
|
+
// can fire 2 s after daemon start, and addAccount may still be
|
|
2420
|
+
// running its OAuth flow. Without this guard we hit
|
|
2421
|
+
// ERROR_BUDGET (20) failures with "No config for account X" and
|
|
2422
|
+
// exit prefetch silently for the rest of the session — bodies
|
|
2423
|
+
// never download. Symptom: log shows 20 lines of `[prefetch] X
|
|
2424
|
+
// folder Y chunk 0: batch fetch failed: No config for account X`
|
|
2425
|
+
// followed by `stopping after 20 errors`.
|
|
2426
|
+
if (!this.configs.has(accountId))
|
|
2427
|
+
return;
|
|
2384
2428
|
this.prefetchingAccounts.add(accountId);
|
|
2385
2429
|
try {
|
|
2386
2430
|
await this._prefetchBodies(accountId);
|
|
@@ -3324,7 +3368,12 @@ export class ImapManager extends EventEmitter {
|
|
|
3324
3368
|
// this exact case: `.sending-rmf39-63196` sat in the queue
|
|
3325
3369
|
// for 7+ hours because PID 63196 was now an unrelated Node.
|
|
3326
3370
|
// (c) it's our PID — never sweep our own claim.
|
|
3327
|
-
|
|
3371
|
+
// 5 minutes: SMTP transactions complete in seconds; 5m of
|
|
3372
|
+
// claim with no progress means the worker is wedged or PID
|
|
3373
|
+
// got recycled. Earlier 1h was a band-aid that left Bob's
|
|
3374
|
+
// outbox stuck for hours when an SMTP wedge crashed the
|
|
3375
|
+
// worker mid-flight.
|
|
3376
|
+
const STALE_CLAIM_MS = 5 * 60_000;
|
|
3328
3377
|
const myPid = process.pid;
|
|
3329
3378
|
for (const dir of [outboxDir, queuedDir]) {
|
|
3330
3379
|
if (!fs.existsSync(dir))
|
|
@@ -3426,11 +3475,23 @@ export class ImapManager extends EventEmitter {
|
|
|
3426
3475
|
// file is visible to the scan loop again.
|
|
3427
3476
|
const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
|
|
3428
3477
|
const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
|
|
3429
|
-
|
|
3478
|
+
try {
|
|
3479
|
+
fs.writeFileSync(claimedPath, withDelay, "utf-8");
|
|
3480
|
+
}
|
|
3481
|
+
catch (we) {
|
|
3482
|
+
console.error(` [outbox] FAIL writeBack ${claimedPath}: ${we?.message || we}`);
|
|
3483
|
+
}
|
|
3430
3484
|
try {
|
|
3431
3485
|
fs.renameSync(claimedPath, filePath);
|
|
3432
3486
|
}
|
|
3433
|
-
catch {
|
|
3487
|
+
catch (re) {
|
|
3488
|
+
// Loud — the file is now stuck in `.sending-` state
|
|
3489
|
+
// until the recovery sweeper's stale-claim timer
|
|
3490
|
+
// (5 min) reclaims it. User-visible as "stuck" in
|
|
3491
|
+
// the outbox view; cancelable via the new always-
|
|
3492
|
+
// allowed Cancel button.
|
|
3493
|
+
console.error(` [outbox] FAIL renameBack ${claimedPath} → ${filePath}: ${re?.message || re} — claim will sit until stale-recovery (5 min)`);
|
|
3494
|
+
}
|
|
3434
3495
|
console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
|
|
3435
3496
|
}
|
|
3436
3497
|
}
|