@bobfrankston/mailx-imap 0.1.167 → 0.1.170
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 +1 -0
- package/index.js +75 -11
- package/package.json +9 -9
package/index.d.ts
CHANGED
|
@@ -237,6 +237,7 @@ export declare class ImapManager extends EventEmitter {
|
|
|
237
237
|
slow?: boolean;
|
|
238
238
|
lane?: string;
|
|
239
239
|
timeoutMs?: number;
|
|
240
|
+
priority?: boolean;
|
|
240
241
|
}): Promise<T>;
|
|
241
242
|
/** Drain the next queued task on EVERY lane for the account. Lanes run
|
|
242
243
|
* concurrently — each on its own connection — and the per-lane running
|
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-dire
|
|
|
7
7
|
import { authenticateOAuth, clearReauthBackoff } from "@bobfrankston/oauthsupport";
|
|
8
8
|
import { parseSerial, fixCharsetDeclString, storeBus } from "@bobfrankston/mailx-store";
|
|
9
9
|
import { loadSettings, getConfigDir, getHistoryDays, getPrefetch, tokenDirName } from "@bobfrankston/mailx-settings";
|
|
10
|
-
import { isJunkPreviewText } from "@bobfrankston/mailx-types";
|
|
10
|
+
import { isJunkPreviewText, logIfChanged } from "@bobfrankston/mailx-types";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
@@ -874,7 +874,19 @@ export class ImapManager extends EventEmitter {
|
|
|
874
874
|
reject(e);
|
|
875
875
|
}
|
|
876
876
|
};
|
|
877
|
-
|
|
877
|
+
// A task the user is waiting on goes to the FRONT of its lane.
|
|
878
|
+
// Lanes are strictly sequential, so without this a click-time body
|
|
879
|
+
// fetch sits behind whatever background work was queued first and
|
|
880
|
+
// can only start after all of it — Bob 2026-08-29: "if I look at a
|
|
881
|
+
// letter while sync things get confused … viewing the body should
|
|
882
|
+
// get priority instead of waiting for sync". It cannot preempt the
|
|
883
|
+
// command already in flight (an IMAP command cannot be cancelled
|
|
884
|
+
// without dropping the connection), but it no longer waits for the
|
|
885
|
+
// whole backlog.
|
|
886
|
+
if (opts.priority)
|
|
887
|
+
queue.tasks.unshift(task);
|
|
888
|
+
else
|
|
889
|
+
queue.tasks.push(task);
|
|
878
890
|
this.drainOpsQueue(accountId);
|
|
879
891
|
});
|
|
880
892
|
}
|
|
@@ -2405,10 +2417,13 @@ export class ImapManager extends EventEmitter {
|
|
|
2405
2417
|
&& statusMessageCount !== null
|
|
2406
2418
|
&& serverUidsArr.length === statusMessageCount;
|
|
2407
2419
|
if (serverUidsArr.length === 0 && localUidsAll.length > 0 && !authoritative) {
|
|
2408
|
-
|
|
2420
|
+
// Same line every cycle for a folder stuck in this state —
|
|
2421
|
+
// logIfChanged prints the first one, each change, and an
|
|
2422
|
+
// hourly restatement. (Claude Code 2026-08-29)
|
|
2423
|
+
logIfChanged(`${accountId}/${folder.path}:reconcile`, ` [sync] ${accountId}/${folder.path}: reconcile skipped — server UID list empty but local has ${localUidsAll.length} (treating as transient)`);
|
|
2409
2424
|
}
|
|
2410
2425
|
else if (!authoritative && localUids.length > 0 && toDelete.length / localUids.length > 0.5) {
|
|
2411
|
-
|
|
2426
|
+
logIfChanged(`${accountId}/${folder.path}:reconcile`, ` [sync] ${accountId}/${folder.path}: reconcile REFUSED — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
2412
2427
|
}
|
|
2413
2428
|
else if (toDelete.length > 0) {
|
|
2414
2429
|
// DEFERRED DELETE — DO NOT delete immediately. Server-side
|
|
@@ -2888,10 +2903,10 @@ export class ImapManager extends EventEmitter {
|
|
|
2888
2903
|
const serverUids = new Set(serverUidsArr);
|
|
2889
2904
|
const localUids = this.db.getUidsForFolder(accountId, folder.id);
|
|
2890
2905
|
if (serverUidsArr._truncated) {
|
|
2891
|
-
|
|
2906
|
+
logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile skipped — server list truncated (${serverUidsArr.length} ids)`);
|
|
2892
2907
|
}
|
|
2893
2908
|
else if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
2894
|
-
|
|
2909
|
+
logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile skipped — server list empty but local has ${localUids.length}`);
|
|
2895
2910
|
}
|
|
2896
2911
|
else {
|
|
2897
2912
|
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
@@ -2925,7 +2940,7 @@ export class ImapManager extends EventEmitter {
|
|
|
2925
2940
|
}
|
|
2926
2941
|
}
|
|
2927
2942
|
if (!confirmed) {
|
|
2928
|
-
|
|
2943
|
+
logIfChanged(`${accountId}/${folder.path}:api-reconcile`, ` [api] ${accountId}/${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%) — probably a sync bug, skipping`);
|
|
2929
2944
|
}
|
|
2930
2945
|
else {
|
|
2931
2946
|
for (const uid of toDelete) {
|
|
@@ -3511,10 +3526,41 @@ export class ImapManager extends EventEmitter {
|
|
|
3511
3526
|
this.syncFolder(accountId, inbox.id).catch(e => console.error(` [idle] expunge reconcile failed: ${e.message}`));
|
|
3512
3527
|
}, 2000);
|
|
3513
3528
|
};
|
|
3529
|
+
// A flag changed on a message we already have — someone starred
|
|
3530
|
+
// or read it on another client. The server pushes it during
|
|
3531
|
+
// IDLE as an unsolicited FETCH; before v1.2.296 that was
|
|
3532
|
+
// dropped, so a star set on the phone reached the desktop only
|
|
3533
|
+
// on the next periodic sync of INBOX. Measured on Bob's own
|
|
3534
|
+
// log that was a median of 5 minutes and up to 20 (Bob
|
|
3535
|
+
// 2026-08-29: "how long should it take for a * on the phone to
|
|
3536
|
+
// get to my desktop?").
|
|
3537
|
+
//
|
|
3538
|
+
// The push says WHICH flags but by sequence number, which this
|
|
3539
|
+
// connection cannot map to a UID reliably — so it triggers the
|
|
3540
|
+
// folder's normal QRESYNC pass, which asks the server for
|
|
3541
|
+
// exactly what changed since our modseq and took 53 ms in the
|
|
3542
|
+
// same log. Debounced: marking ten messages read on the phone
|
|
3543
|
+
// arrives as ten pushes.
|
|
3544
|
+
let flagTimer = null;
|
|
3545
|
+
const onFlagChange = () => {
|
|
3546
|
+
if (flagTimer)
|
|
3547
|
+
clearTimeout(flagTimer);
|
|
3548
|
+
flagTimer = setTimeout(() => {
|
|
3549
|
+
flagTimer = null;
|
|
3550
|
+
// Our own STOREs bounce straight back as flag pushes;
|
|
3551
|
+
// the local store already has those.
|
|
3552
|
+
if (this.db.getPendingSyncActions(accountId).length > 0)
|
|
3553
|
+
return;
|
|
3554
|
+
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
3555
|
+
if (!inbox)
|
|
3556
|
+
return;
|
|
3557
|
+
this.syncFolder(accountId, inbox.id).catch(e => console.error(` [idle] flag-change sync failed: ${e.message}`));
|
|
3558
|
+
}, 1500);
|
|
3559
|
+
};
|
|
3514
3560
|
const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
|
|
3515
3561
|
console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
|
|
3516
3562
|
this.syncInboxNewOnly(accountId).catch(e => console.error(` [idle] sync error: ${e.message}`));
|
|
3517
|
-
}, { notifySpec, onMailboxStatus, onExpunge });
|
|
3563
|
+
}, { notifySpec, onMailboxStatus, onExpunge, onFlagChange });
|
|
3518
3564
|
this.watchers.set(accountId, async () => {
|
|
3519
3565
|
await stop();
|
|
3520
3566
|
await watchClient.logout();
|
|
@@ -3728,6 +3774,13 @@ export class ImapManager extends EventEmitter {
|
|
|
3728
3774
|
// IMAP: fast lane on the ops queue. One try; if the socket is stale,
|
|
3729
3775
|
// withConnection's discard-on-error logic drops the client so the
|
|
3730
3776
|
// next attempt (caller-driven retry) gets a fresh one.
|
|
3777
|
+
//
|
|
3778
|
+
// An interactive fetch (`force`) jumps the lane queue and gets a
|
|
3779
|
+
// tighter cap: the reader is staring at a blank pane, and 90 s of
|
|
3780
|
+
// silence followed by a banner is the worst of both. 30 s is long
|
|
3781
|
+
// enough for a big body on a slow server and short enough that a
|
|
3782
|
+
// wedged lane becomes a RETRYABLE timeout while the reader is still
|
|
3783
|
+
// looking at the message.
|
|
3731
3784
|
try {
|
|
3732
3785
|
const raw = await this.withConnection(accountId, async (client) => {
|
|
3733
3786
|
const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
|
|
@@ -3736,7 +3789,7 @@ export class ImapManager extends EventEmitter {
|
|
|
3736
3789
|
if (!msg.source)
|
|
3737
3790
|
return null;
|
|
3738
3791
|
return Buffer.from(msg.source, "utf-8");
|
|
3739
|
-
});
|
|
3792
|
+
}, force ? { priority: true, timeoutMs: 30_000 } : {});
|
|
3740
3793
|
if (!raw)
|
|
3741
3794
|
return null;
|
|
3742
3795
|
const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
@@ -3751,8 +3804,19 @@ export class ImapManager extends EventEmitter {
|
|
|
3751
3804
|
if (e?.isNotFound)
|
|
3752
3805
|
throw e;
|
|
3753
3806
|
console.error(` Body fetch error (${accountId}/${uid}): ${e?.message || e}`);
|
|
3754
|
-
//
|
|
3755
|
-
//
|
|
3807
|
+
// A lane timeout / dropped socket says nothing about whether this
|
|
3808
|
+
// message HAS a body. Swallowing it into `null` told the caller
|
|
3809
|
+
// "the server has no body for this" — the reconciler classifies
|
|
3810
|
+
// that as non-transient, skips its retry budget, and banners.
|
|
3811
|
+
// That is the "eventually reports an error rather than retrying"
|
|
3812
|
+
// Bob hit (2026-08-29). Rethrow instead, so the reconciler's
|
|
3813
|
+
// transient test sees the word "timeout" and retries with backoff.
|
|
3814
|
+
const transient = /timeout|discarding client|connection|not connected|ECONN|ETIMEDOUT|ENOTFOUND|socket/i
|
|
3815
|
+
.test(String(e?.message || ""));
|
|
3816
|
+
if (transient)
|
|
3817
|
+
throw e;
|
|
3818
|
+
// A genuine "no body here" — arm the backoff so prefetch and the
|
|
3819
|
+
// next view don't re-attempt a fetch the server won't satisfy.
|
|
3756
3820
|
try {
|
|
3757
3821
|
this.markPrefetchEmpty(accountId, folderId, uid);
|
|
3758
3822
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.170",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
15
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
12
|
+
"@bobfrankston/mailx-types": "^0.1.76",
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.73",
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.109",
|
|
15
|
+
"@bobfrankston/iflow-direct": "^0.1.68",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
18
18
|
"@bobfrankston/mailx-sync": "^0.1.29",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
},
|
|
38
38
|
".transformedSnapshot": {
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@bobfrankston/mailx-types": "^0.1.
|
|
41
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
43
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
40
|
+
"@bobfrankston/mailx-types": "^0.1.76",
|
|
41
|
+
"@bobfrankston/mailx-settings": "^0.1.73",
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.109",
|
|
43
|
+
"@bobfrankston/iflow-direct": "^0.1.68",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.8",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.9",
|
|
46
46
|
"@bobfrankston/mailx-sync": "^0.1.29",
|