@bobfrankston/iflow-direct 0.1.34 → 0.1.39
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/imap-compat.d.ts +6 -0
- package/imap-compat.js +14 -0
- package/imap-native.d.ts +63 -11
- package/imap-native.js +228 -134
- package/package.json +3 -3
package/imap-compat.d.ts
CHANGED
|
@@ -66,6 +66,12 @@ export declare class CompatImapClient {
|
|
|
66
66
|
getMessagesCount(mailbox: string): Promise<number>;
|
|
67
67
|
/** Get all UIDs in a mailbox */
|
|
68
68
|
getUids(mailbox: string): Promise<number[]>;
|
|
69
|
+
/** Get UIDs whose INTERNALDATE is on/after `since`. Bounded version of
|
|
70
|
+
* getUids — returns only the date window the caller cares about
|
|
71
|
+
* instead of the entire folder. Lets set-diff reconciliation scope
|
|
72
|
+
* itself to "messages from the last N days" rather than enumerating
|
|
73
|
+
* every UID in a 134k-message folder. */
|
|
74
|
+
getUidsSince(mailbox: string, since: Date): Promise<number[]>;
|
|
69
75
|
/** Fetch messages — supports two calling conventions for compatibility:
|
|
70
76
|
*
|
|
71
77
|
* New: fetchMessages(mailbox, "100:200") — UID range string
|
package/imap-compat.js
CHANGED
|
@@ -168,6 +168,20 @@ export class CompatImapClient {
|
|
|
168
168
|
await this.native.closeMailbox();
|
|
169
169
|
return uids;
|
|
170
170
|
}
|
|
171
|
+
/** Get UIDs whose INTERNALDATE is on/after `since`. Bounded version of
|
|
172
|
+
* getUids — returns only the date window the caller cares about
|
|
173
|
+
* instead of the entire folder. Lets set-diff reconciliation scope
|
|
174
|
+
* itself to "messages from the last N days" rather than enumerating
|
|
175
|
+
* every UID in a 134k-message folder. */
|
|
176
|
+
async getUidsSince(mailbox, since) {
|
|
177
|
+
await this.ensureConnected();
|
|
178
|
+
await this.native.select(mailbox);
|
|
179
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
180
|
+
const d = `${since.getUTCDate()}-${months[since.getUTCMonth()]}-${since.getUTCFullYear()}`;
|
|
181
|
+
const uids = await this.native.search(`SINCE ${d}`);
|
|
182
|
+
await this.native.closeMailbox();
|
|
183
|
+
return uids;
|
|
184
|
+
}
|
|
171
185
|
async fetchMessages(mailbox, rangeOrEnd, countOrOptions, maybeOptions) {
|
|
172
186
|
let range;
|
|
173
187
|
let options;
|
package/imap-native.d.ts
CHANGED
|
@@ -48,15 +48,29 @@ export declare class NativeImapClient {
|
|
|
48
48
|
private transport;
|
|
49
49
|
private transportFactory;
|
|
50
50
|
private config;
|
|
51
|
-
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
51
|
+
/** Byte-level receive buffer. IMAP literals (`{N}`) are octets — message
|
|
52
|
+
* bodies, MIME parts with binary content, non-UTF-8 charsets — and the
|
|
53
|
+
* parser MUST work in bytes to recover them with byte fidelity and
|
|
54
|
+
* without paying O(n²) re-encoding cost on every chunk arrival. The
|
|
55
|
+
* prior implementation kept `this.buffer` as a JS string, accumulated
|
|
56
|
+
* UTF-8-decoded text from the transport, then `TextEncoder.encode`'d
|
|
57
|
+
* the whole unread region on every TCP chunk while waiting for a
|
|
58
|
+
* literal — pegged the event loop on multi-message FETCH responses.
|
|
59
|
+
*
|
|
60
|
+
* Invariants:
|
|
61
|
+
* - `buffer[0 .. bufferLength)` holds valid received bytes
|
|
62
|
+
* - `buffer[bufferOffset .. bufferLength)` is unread
|
|
63
|
+
* - capacity = `buffer.length` ≥ `bufferLength`; we grow on demand
|
|
64
|
+
* - never substring — bufferOffset advances past consumed bytes;
|
|
65
|
+
* compactBufferIfNeeded reclaims space when consumed >= half. */
|
|
58
66
|
private buffer;
|
|
67
|
+
private bufferLength;
|
|
59
68
|
private bufferOffset;
|
|
69
|
+
/** Cached decoder so per-line / per-literal decode doesn't re-construct
|
|
70
|
+
* one. `fatal: false` (default) replaces invalid UTF-8 sequences with
|
|
71
|
+
* U+FFFD — same lenient behavior as JavaScript's implicit string
|
|
72
|
+
* decoding the parser used to rely on. */
|
|
73
|
+
private utf8Decoder;
|
|
60
74
|
private pendingCommand;
|
|
61
75
|
private capabilities;
|
|
62
76
|
private _connected;
|
|
@@ -71,6 +85,13 @@ export declare class NativeImapClient {
|
|
|
71
85
|
private greetingResolve;
|
|
72
86
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
73
87
|
private continuationResolve;
|
|
88
|
+
/** Per-command heartbeat (30s). Hoisted to instance state so any path
|
|
89
|
+
* that swaps pendingCommand can clear it cleanly — closure-scoped
|
|
90
|
+
* timers leaked when handleData's inline replacement or IDLE
|
|
91
|
+
* suspend/resume bypassed sendCommandCore's resolve/reject closures. */
|
|
92
|
+
private heartbeatTimer;
|
|
93
|
+
/** Hard wall-clock cap per command. Same hoist for the same reason. */
|
|
94
|
+
private wallClockTimer;
|
|
74
95
|
constructor(config: ImapClientConfig, transportFactory: TransportFactory);
|
|
75
96
|
get connected(): boolean;
|
|
76
97
|
/** Check the underlying transport's connected state — catches silently dead sockets. */
|
|
@@ -206,16 +227,47 @@ export declare class NativeImapClient {
|
|
|
206
227
|
* EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
|
|
207
228
|
* server until the inactivity timer killed the socket.
|
|
208
229
|
*/
|
|
230
|
+
/** Per-client command serialization. IMAP allows only one command in
|
|
231
|
+
* flight at a time on a single connection (RFC 3501 §2.2.1). The
|
|
232
|
+
* client's `pendingCommand` field tracks that command — if a second
|
|
233
|
+
* caller concurrently invokes sendCommand, it overwrites
|
|
234
|
+
* pendingCommand and the first caller's await is orphaned. We saw
|
|
235
|
+
* this in production: the outbox poller's `withConnection`-queued
|
|
236
|
+
* task ran on the same shared ops client as syncFolder's INBOX work,
|
|
237
|
+
* and INBOX silently hung forever (no heartbeat fire, no wall-clock,
|
|
238
|
+
* no reject — the promise just had no command tracking it anymore).
|
|
239
|
+
*
|
|
240
|
+
* This chain serializes ALL sendCommand callers against this client
|
|
241
|
+
* regardless of whether the caller bothered to coordinate.
|
|
242
|
+
* Defensive — the right thing for the upper layer to do is also use
|
|
243
|
+
* the queue (withConnection in mailx-imap), but the IMAP client
|
|
244
|
+
* shouldn't allow itself to be corrupted by a careless caller. */
|
|
245
|
+
private commandChain;
|
|
209
246
|
private sendCommand;
|
|
210
247
|
private sendCommandCore;
|
|
211
248
|
private waitForContinuation;
|
|
212
249
|
private waitForTagged;
|
|
213
250
|
private handleData;
|
|
214
|
-
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
|
|
251
|
+
/** Append received bytes to the receive buffer, growing capacity if
|
|
252
|
+
* needed. O(n) over the chunk size, never over the buffer total —
|
|
253
|
+
* doubling growth amortizes to O(1) per byte across the response. */
|
|
254
|
+
private appendToBuffer;
|
|
255
|
+
/** Single canonical "command finished or abandoned" cleanup. Every
|
|
256
|
+
* path that ends a command's lifecycle — resolve, reject, timeout,
|
|
257
|
+
* socket close, command-replace — calls this. Was a leak source:
|
|
258
|
+
* closure-scoped timers in sendCommandCore couldn't be cleared from
|
|
259
|
+
* handleData's inline-replacement timer or from IDLE suspend/resume. */
|
|
260
|
+
private clearAllCommandTimers;
|
|
261
|
+
/** Compact the buffer when bufferOffset has advanced past half the used
|
|
262
|
+
* region. copyWithin moves the unread bytes to the front in-place
|
|
263
|
+
* (no new allocation). Amortized O(1) per byte; never the O(n²)
|
|
264
|
+
* substring-each-line pathology that blocked the event loop on a
|
|
265
|
+
* large LIST response. */
|
|
218
266
|
private compactBufferIfNeeded;
|
|
267
|
+
/** Find the next CRLF (0x0D 0x0A) in the unread region, returning the
|
|
268
|
+
* byte index of the 0x0D, or -1 if not found. Manual byte loop —
|
|
269
|
+
* Uint8Array doesn't have a native indexOf for byte sequences. */
|
|
270
|
+
private indexOfCRLF;
|
|
219
271
|
private processBuffer;
|
|
220
272
|
private handleUntaggedResponse;
|
|
221
273
|
private parseFetchResponses;
|
package/imap-native.js
CHANGED
|
@@ -11,15 +11,29 @@ export class NativeImapClient {
|
|
|
11
11
|
transport;
|
|
12
12
|
transportFactory;
|
|
13
13
|
config;
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
14
|
+
/** Byte-level receive buffer. IMAP literals (`{N}`) are octets — message
|
|
15
|
+
* bodies, MIME parts with binary content, non-UTF-8 charsets — and the
|
|
16
|
+
* parser MUST work in bytes to recover them with byte fidelity and
|
|
17
|
+
* without paying O(n²) re-encoding cost on every chunk arrival. The
|
|
18
|
+
* prior implementation kept `this.buffer` as a JS string, accumulated
|
|
19
|
+
* UTF-8-decoded text from the transport, then `TextEncoder.encode`'d
|
|
20
|
+
* the whole unread region on every TCP chunk while waiting for a
|
|
21
|
+
* literal — pegged the event loop on multi-message FETCH responses.
|
|
22
|
+
*
|
|
23
|
+
* Invariants:
|
|
24
|
+
* - `buffer[0 .. bufferLength)` holds valid received bytes
|
|
25
|
+
* - `buffer[bufferOffset .. bufferLength)` is unread
|
|
26
|
+
* - capacity = `buffer.length` ≥ `bufferLength`; we grow on demand
|
|
27
|
+
* - never substring — bufferOffset advances past consumed bytes;
|
|
28
|
+
* compactBufferIfNeeded reclaims space when consumed >= half. */
|
|
29
|
+
buffer = new Uint8Array(8192);
|
|
30
|
+
bufferLength = 0;
|
|
22
31
|
bufferOffset = 0;
|
|
32
|
+
/** Cached decoder so per-line / per-literal decode doesn't re-construct
|
|
33
|
+
* one. `fatal: false` (default) replaces invalid UTF-8 sequences with
|
|
34
|
+
* U+FFFD — same lenient behavior as JavaScript's implicit string
|
|
35
|
+
* decoding the parser used to rely on. */
|
|
36
|
+
utf8Decoder = new TextDecoder("utf-8");
|
|
23
37
|
pendingCommand = null;
|
|
24
38
|
capabilities = new Set();
|
|
25
39
|
_connected = false;
|
|
@@ -34,16 +48,29 @@ export class NativeImapClient {
|
|
|
34
48
|
greetingResolve = null;
|
|
35
49
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
36
50
|
continuationResolve = null;
|
|
51
|
+
/** Per-command heartbeat (30s). Hoisted to instance state so any path
|
|
52
|
+
* that swaps pendingCommand can clear it cleanly — closure-scoped
|
|
53
|
+
* timers leaked when handleData's inline replacement or IDLE
|
|
54
|
+
* suspend/resume bypassed sendCommandCore's resolve/reject closures. */
|
|
55
|
+
heartbeatTimer = null;
|
|
56
|
+
/** Hard wall-clock cap per command. Same hoist for the same reason. */
|
|
57
|
+
wallClockTimer = null;
|
|
37
58
|
constructor(config, transportFactory) {
|
|
38
59
|
this.config = config;
|
|
39
60
|
this.transportFactory = transportFactory;
|
|
40
61
|
this.transport = transportFactory();
|
|
41
62
|
this.verbose = config.verbose || false;
|
|
42
63
|
this.inactivityTimeout = config.inactivityTimeout ?? 60000;
|
|
43
|
-
// Hard wall-clock deadline per command
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
64
|
+
// Hard wall-clock deadline per command. Distinct from inactivityTimeout
|
|
65
|
+
// — that one resets on each data chunk and can be deferred indefinitely
|
|
66
|
+
// by a slow-trickling server. Default 90s: in production we see
|
|
67
|
+
// connections silently die with sinceLastRead climbing for 50s+ on
|
|
68
|
+
// FETCH; 5 min was leaving them wedged for too long, blocking sync /
|
|
69
|
+
// prefetch / new-mail behind a dead socket. 90s lets a slow but
|
|
70
|
+
// genuine FETCH complete (Sent in isolation: 26ms; even a 50-message
|
|
71
|
+
// FETCH was <1s) while killing dead sockets aggressively. Override
|
|
72
|
+
// via ImapClientConfig.commandWallClockTimeout for known-slow servers.
|
|
73
|
+
this.commandWallClockTimeout = config.commandWallClockTimeout ?? 90_000;
|
|
47
74
|
this.fetchChunkSize = config.fetchChunkSize ?? 25;
|
|
48
75
|
this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
|
|
49
76
|
this.greetingTimeout = config.greetingTimeout ?? 10000;
|
|
@@ -60,9 +87,14 @@ export class NativeImapClient {
|
|
|
60
87
|
catch { /* ignore */ }
|
|
61
88
|
this.transport = this.transportFactory();
|
|
62
89
|
this._connected = false;
|
|
63
|
-
this.
|
|
90
|
+
this.bufferLength = 0;
|
|
91
|
+
this.bufferOffset = 0;
|
|
64
92
|
this.pendingCommand = null;
|
|
65
93
|
this.selectedMailbox = null;
|
|
94
|
+
// Old socket is gone — kill any timers still running for the
|
|
95
|
+
// command that was on it. Without this, a stale heartbeat/wall-clock
|
|
96
|
+
// continues firing into a transport that no longer exists.
|
|
97
|
+
this.clearAllCommandTimers();
|
|
66
98
|
}
|
|
67
99
|
// ── Connection ──
|
|
68
100
|
async connect() {
|
|
@@ -78,6 +110,10 @@ export class NativeImapClient {
|
|
|
78
110
|
clearTimeout(this.idleRefreshTimer);
|
|
79
111
|
this.idleRefreshTimer = null;
|
|
80
112
|
}
|
|
113
|
+
// Kill any per-command timers — the connection is gone, no
|
|
114
|
+
// command on it can ever complete. Otherwise heartbeat keeps
|
|
115
|
+
// firing for a connection that no longer exists.
|
|
116
|
+
this.clearAllCommandTimers();
|
|
81
117
|
if (this.pendingCommand) {
|
|
82
118
|
const { reject } = this.pendingCommand;
|
|
83
119
|
this.pendingCommand = null;
|
|
@@ -766,22 +802,52 @@ export class NativeImapClient {
|
|
|
766
802
|
* EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
|
|
767
803
|
* server until the inactivity timer killed the socket.
|
|
768
804
|
*/
|
|
805
|
+
/** Per-client command serialization. IMAP allows only one command in
|
|
806
|
+
* flight at a time on a single connection (RFC 3501 §2.2.1). The
|
|
807
|
+
* client's `pendingCommand` field tracks that command — if a second
|
|
808
|
+
* caller concurrently invokes sendCommand, it overwrites
|
|
809
|
+
* pendingCommand and the first caller's await is orphaned. We saw
|
|
810
|
+
* this in production: the outbox poller's `withConnection`-queued
|
|
811
|
+
* task ran on the same shared ops client as syncFolder's INBOX work,
|
|
812
|
+
* and INBOX silently hung forever (no heartbeat fire, no wall-clock,
|
|
813
|
+
* no reject — the promise just had no command tracking it anymore).
|
|
814
|
+
*
|
|
815
|
+
* This chain serializes ALL sendCommand callers against this client
|
|
816
|
+
* regardless of whether the caller bothered to coordinate.
|
|
817
|
+
* Defensive — the right thing for the upper layer to do is also use
|
|
818
|
+
* the queue (withConnection in mailx-imap), but the IMAP client
|
|
819
|
+
* shouldn't allow itself to be corrupted by a careless caller. */
|
|
820
|
+
commandChain = Promise.resolve();
|
|
769
821
|
async sendCommand(tag, command, onUntagged) {
|
|
770
|
-
const
|
|
822
|
+
const prev = this.commandChain.catch(() => { });
|
|
823
|
+
let myResolve;
|
|
824
|
+
let myReject;
|
|
825
|
+
const myPromise = new Promise((res, rej) => { myResolve = res; myReject = rej; });
|
|
826
|
+
this.commandChain = myPromise.catch(() => { });
|
|
827
|
+
await prev;
|
|
771
828
|
try {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
829
|
+
const idleState = await this.suspendIdleForCommand();
|
|
830
|
+
try {
|
|
831
|
+
const out = await this.sendCommandCore(tag, command, onUntagged);
|
|
832
|
+
myResolve(out);
|
|
833
|
+
return out;
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
if (idleState) {
|
|
837
|
+
try {
|
|
838
|
+
await this.resumeIdleAfterCommand(idleState);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
if (this.verbose)
|
|
842
|
+
console.error(` [imap] IDLE auto-resume failed: ${err.message}`);
|
|
843
|
+
}
|
|
782
844
|
}
|
|
783
845
|
}
|
|
784
846
|
}
|
|
847
|
+
catch (e) {
|
|
848
|
+
myReject(e);
|
|
849
|
+
throw e;
|
|
850
|
+
}
|
|
785
851
|
}
|
|
786
852
|
sendCommandCore(tag, command, onUntagged) {
|
|
787
853
|
return new Promise((resolve, reject) => {
|
|
@@ -801,69 +867,33 @@ export class NativeImapClient {
|
|
|
801
867
|
return "";
|
|
802
868
|
}
|
|
803
869
|
};
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
// but the tagged OK never arrives, the parser is stuck.
|
|
809
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
870
|
+
// Always start clean — defends against a prior pendingCommand
|
|
871
|
+
// having leaked timers (e.g. swap by suspendIdle/resumeIdle that
|
|
872
|
+
// bypassed resolve/reject).
|
|
873
|
+
this.clearAllCommandTimers();
|
|
810
874
|
const cmdStart = Date.now();
|
|
811
875
|
const cmdSummary = command.split("\r")[0].substring(0, 80);
|
|
812
|
-
let heartbeatTimer = setInterval(() => {
|
|
813
|
-
const waited = Date.now() - cmdStart;
|
|
814
|
-
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
815
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
816
876
|
const onTimeout = (kind) => {
|
|
817
|
-
this.
|
|
877
|
+
this.clearAllCommandTimers();
|
|
818
878
|
this.pendingCommand = null;
|
|
819
|
-
if (heartbeatTimer) {
|
|
820
|
-
clearInterval(heartbeatTimer);
|
|
821
|
-
heartbeatTimer = null;
|
|
822
|
-
}
|
|
823
|
-
if (wallClockTimer) {
|
|
824
|
-
clearTimeout(wallClockTimer);
|
|
825
|
-
wallClockTimer = null;
|
|
826
|
-
}
|
|
827
|
-
// Kill the connection — a timed-out connection has stale data in the pipe.
|
|
828
|
-
// Mark disconnected so ensureConnected() reconnects on the next call —
|
|
829
|
-
// transport.close() may or may not fire onClose synchronously.
|
|
830
879
|
this._connected = false;
|
|
831
880
|
this.transport.close?.();
|
|
832
881
|
reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
|
|
833
882
|
};
|
|
834
|
-
//
|
|
835
|
-
// commandTimer
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// wallClockTimer = "this command has been pending too long
|
|
839
|
-
// total" — fires regardless of trickling bytes.
|
|
840
|
-
// Catches the pathological case where a server
|
|
841
|
-
// drips one byte every <inactivityTimeout> seconds
|
|
842
|
-
// for hours (real bobma bug we hit overnight:
|
|
843
|
-
// 3 connections wedged 2-3 hours each because
|
|
844
|
-
// the inactivity reset kept deferring the only
|
|
845
|
-
// timer in play).
|
|
883
|
+
// Three timers, three responsibilities — all instance-scoped now:
|
|
884
|
+
// commandTimer = inactivity (resets on each data chunk)
|
|
885
|
+
// wallClockTimer = hard cap from command start, never resets
|
|
886
|
+
// heartbeatTimer = periodic progress log every 30s
|
|
846
887
|
this.commandTimer = setTimeout(() => onTimeout(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`), this.inactivityTimeout);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
this.commandTimer = null;
|
|
853
|
-
}
|
|
854
|
-
if (heartbeatTimer) {
|
|
855
|
-
clearInterval(heartbeatTimer);
|
|
856
|
-
heartbeatTimer = null;
|
|
857
|
-
}
|
|
858
|
-
if (wallClockTimer) {
|
|
859
|
-
clearTimeout(wallClockTimer);
|
|
860
|
-
wallClockTimer = null;
|
|
861
|
-
}
|
|
862
|
-
};
|
|
888
|
+
this.wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${this.commandWallClockTimeout / 1000}s)`), this.commandWallClockTimeout);
|
|
889
|
+
this.heartbeatTimer = setInterval(() => {
|
|
890
|
+
const waited = Date.now() - cmdStart;
|
|
891
|
+
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
892
|
+
}, 30_000);
|
|
863
893
|
this.pendingCommand = {
|
|
864
894
|
tag, responses: [],
|
|
865
|
-
resolve: (responses) => {
|
|
866
|
-
reject: (err) => {
|
|
895
|
+
resolve: (responses) => { this.clearAllCommandTimers(); resolve(responses); },
|
|
896
|
+
reject: (err) => { this.clearAllCommandTimers(); reject(err); },
|
|
867
897
|
onUntagged,
|
|
868
898
|
};
|
|
869
899
|
// PRE-WRITE HEALTH CHECK — verify the socket is in a writable
|
|
@@ -873,7 +903,7 @@ export class NativeImapClient {
|
|
|
873
903
|
try {
|
|
874
904
|
const h = this.transport?.socketHealth;
|
|
875
905
|
if (h && (h.destroyed || h.writableEnded || !h.writable)) {
|
|
876
|
-
|
|
906
|
+
this.clearAllCommandTimers();
|
|
877
907
|
this.pendingCommand = null;
|
|
878
908
|
this._connected = false;
|
|
879
909
|
reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
|
|
@@ -882,7 +912,7 @@ export class NativeImapClient {
|
|
|
882
912
|
}
|
|
883
913
|
catch { /* transport may not expose socketHealth — fall through */ }
|
|
884
914
|
this.transport.write(command).catch((err) => {
|
|
885
|
-
|
|
915
|
+
this.clearAllCommandTimers();
|
|
886
916
|
// Write failure = dead socket. Clear pendingCommand + disconnect flag
|
|
887
917
|
// so the next ensureConnected() does a fresh connect instead of
|
|
888
918
|
// reusing a socket that the OS layer has already declared dead
|
|
@@ -912,7 +942,10 @@ export class NativeImapClient {
|
|
|
912
942
|
});
|
|
913
943
|
}
|
|
914
944
|
handleData(data) {
|
|
915
|
-
// Reset inactivity timer
|
|
945
|
+
// Reset inactivity timer ONLY (heartbeat + wall-clock NEVER reset
|
|
946
|
+
// on data — that was the original bug: wall-clock got reset every
|
|
947
|
+
// byte, so a slow-trickling server's command never died). Inactivity
|
|
948
|
+
// is "no data for N seconds"; data IS arriving, so push it out.
|
|
916
949
|
if (this.commandTimer) {
|
|
917
950
|
clearTimeout(this.commandTimer);
|
|
918
951
|
this.commandTimer = setTimeout(() => {
|
|
@@ -920,87 +953,148 @@ export class NativeImapClient {
|
|
|
920
953
|
if (this.pendingCommand) {
|
|
921
954
|
const cmd = this.pendingCommand;
|
|
922
955
|
this.pendingCommand = null;
|
|
956
|
+
// Use the canonical clear so heartbeat + wall-clock
|
|
957
|
+
// also stop. cmd.reject runs clearAllCommandTimers via
|
|
958
|
+
// the closure binding — call it directly here too in
|
|
959
|
+
// case cmd.reject was already replaced (defense in depth).
|
|
960
|
+
this.clearAllCommandTimers();
|
|
923
961
|
this._connected = false;
|
|
924
962
|
this.transport.close?.();
|
|
925
963
|
cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
|
|
926
964
|
}
|
|
927
965
|
}, this.inactivityTimeout);
|
|
928
966
|
}
|
|
929
|
-
this.
|
|
967
|
+
this.appendToBuffer(data);
|
|
930
968
|
this.processBuffer();
|
|
931
969
|
}
|
|
932
|
-
/**
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
|
|
970
|
+
/** Append received bytes to the receive buffer, growing capacity if
|
|
971
|
+
* needed. O(n) over the chunk size, never over the buffer total —
|
|
972
|
+
* doubling growth amortizes to O(1) per byte across the response. */
|
|
973
|
+
appendToBuffer(data) {
|
|
974
|
+
const need = this.bufferLength + data.length;
|
|
975
|
+
if (need > this.buffer.length) {
|
|
976
|
+
let cap = Math.max(this.buffer.length * 2, 8192);
|
|
977
|
+
while (cap < need)
|
|
978
|
+
cap *= 2;
|
|
979
|
+
const grown = new Uint8Array(cap);
|
|
980
|
+
grown.set(this.buffer.subarray(0, this.bufferLength));
|
|
981
|
+
this.buffer = grown;
|
|
982
|
+
}
|
|
983
|
+
this.buffer.set(data, this.bufferLength);
|
|
984
|
+
this.bufferLength += data.length;
|
|
985
|
+
}
|
|
986
|
+
/** Single canonical "command finished or abandoned" cleanup. Every
|
|
987
|
+
* path that ends a command's lifecycle — resolve, reject, timeout,
|
|
988
|
+
* socket close, command-replace — calls this. Was a leak source:
|
|
989
|
+
* closure-scoped timers in sendCommandCore couldn't be cleared from
|
|
990
|
+
* handleData's inline-replacement timer or from IDLE suspend/resume. */
|
|
991
|
+
clearAllCommandTimers() {
|
|
992
|
+
if (this.commandTimer) {
|
|
993
|
+
clearTimeout(this.commandTimer);
|
|
994
|
+
this.commandTimer = null;
|
|
995
|
+
}
|
|
996
|
+
if (this.heartbeatTimer) {
|
|
997
|
+
clearInterval(this.heartbeatTimer);
|
|
998
|
+
this.heartbeatTimer = null;
|
|
999
|
+
}
|
|
1000
|
+
if (this.wallClockTimer) {
|
|
1001
|
+
clearTimeout(this.wallClockTimer);
|
|
1002
|
+
this.wallClockTimer = null;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/** Compact the buffer when bufferOffset has advanced past half the used
|
|
1006
|
+
* region. copyWithin moves the unread bytes to the front in-place
|
|
1007
|
+
* (no new allocation). Amortized O(1) per byte; never the O(n²)
|
|
1008
|
+
* substring-each-line pathology that blocked the event loop on a
|
|
1009
|
+
* large LIST response. */
|
|
936
1010
|
compactBufferIfNeeded() {
|
|
937
|
-
if (this.bufferOffset > 0 && this.bufferOffset >= this.
|
|
938
|
-
this.buffer
|
|
1011
|
+
if (this.bufferOffset > 0 && this.bufferOffset >= this.bufferLength / 2) {
|
|
1012
|
+
this.buffer.copyWithin(0, this.bufferOffset, this.bufferLength);
|
|
1013
|
+
this.bufferLength -= this.bufferOffset;
|
|
939
1014
|
this.bufferOffset = 0;
|
|
940
1015
|
}
|
|
941
1016
|
}
|
|
1017
|
+
/** Find the next CRLF (0x0D 0x0A) in the unread region, returning the
|
|
1018
|
+
* byte index of the 0x0D, or -1 if not found. Manual byte loop —
|
|
1019
|
+
* Uint8Array doesn't have a native indexOf for byte sequences. */
|
|
1020
|
+
indexOfCRLF(from) {
|
|
1021
|
+
const buf = this.buffer;
|
|
1022
|
+
const end = this.bufferLength - 1;
|
|
1023
|
+
for (let i = from; i < end; i++) {
|
|
1024
|
+
if (buf[i] === 0x0D && buf[i + 1] === 0x0A)
|
|
1025
|
+
return i;
|
|
1026
|
+
}
|
|
1027
|
+
return -1;
|
|
1028
|
+
}
|
|
942
1029
|
processBuffer() {
|
|
943
1030
|
while (true) {
|
|
944
|
-
// Check for literal {
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
1031
|
+
// Check for literal — `{N}` announces N octets of arbitrary data
|
|
1032
|
+
// (message body, MIME part, attachment chunk). The buffer is
|
|
1033
|
+
// bytes; the check is a direct length compare and the slice is
|
|
1034
|
+
// O(N) over the literal's own size — never O(buffer²).
|
|
948
1035
|
if (this.pendingCommand?.literalBytes != null) {
|
|
949
1036
|
const neededBytes = this.pendingCommand.literalBytes;
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const encoded = new TextEncoder().encode(unread);
|
|
955
|
-
const bufferBytes = encoded.byteLength;
|
|
956
|
-
if (bufferBytes >= neededBytes) {
|
|
957
|
-
const literal_bytes = encoded.subarray(0, neededBytes);
|
|
958
|
-
const rest_bytes = encoded.subarray(neededBytes);
|
|
959
|
-
let literal = new TextDecoder().decode(literal_bytes);
|
|
960
|
-
// Reset buffer to just the rest — bufferOffset goes back
|
|
961
|
-
// to 0 since we replaced the buffer content.
|
|
962
|
-
this.buffer = new TextDecoder().decode(rest_bytes);
|
|
963
|
-
this.bufferOffset = 0;
|
|
964
|
-
// For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
|
|
965
|
-
// so tokenizeParenList treats them as a single token
|
|
966
|
-
if (!this.pendingCommand.currentLiteralKey) {
|
|
967
|
-
literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
|
|
968
|
-
}
|
|
969
|
-
this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
|
|
970
|
-
this.pendingCommand.literalBytes = undefined;
|
|
971
|
-
// Store the literal data by its BODY key for parseFetchResponses
|
|
972
|
-
if (this.pendingCommand.currentLiteralKey) {
|
|
973
|
-
if (!this.pendingCommand.literals)
|
|
974
|
-
this.pendingCommand.literals = new Map();
|
|
975
|
-
this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
|
|
976
|
-
this.pendingCommand.currentLiteralKey = undefined;
|
|
977
|
-
this.pendingCommand.currentLiteralSize = undefined;
|
|
1037
|
+
const available = this.bufferLength - this.bufferOffset;
|
|
1038
|
+
if (available < neededBytes) {
|
|
1039
|
+
if (this.verbose && this.pendingCommand.literalBytes > 0) {
|
|
1040
|
+
console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${available}`);
|
|
978
1041
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1042
|
+
break; // Wait for more data — cheap, no decode.
|
|
1043
|
+
}
|
|
1044
|
+
// Slice the literal bytes; advance the read cursor by N.
|
|
1045
|
+
// The bytes themselves stay in the underlying buffer for the
|
|
1046
|
+
// moment, but bufferOffset advances past them and the next
|
|
1047
|
+
// compactBufferIfNeeded will reclaim the space.
|
|
1048
|
+
const literalBytes = this.buffer.subarray(this.bufferOffset, this.bufferOffset + neededBytes);
|
|
1049
|
+
this.bufferOffset += neededBytes;
|
|
1050
|
+
// Decode literal once (UTF-8 with replacement). For BODY[]
|
|
1051
|
+
// literals carrying non-UTF-8 charsets this is still lossy —
|
|
1052
|
+
// the proper fix is to keep literals as bytes through to the
|
|
1053
|
+
// body store, tracked separately. The win HERE is that the
|
|
1054
|
+
// decode happens once per literal, not once per chunk that
|
|
1055
|
+
// arrives during the literal's transit.
|
|
1056
|
+
let literal = this.utf8Decoder.decode(literalBytes);
|
|
1057
|
+
// For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
|
|
1058
|
+
// so tokenizeParenList treats them as a single token
|
|
1059
|
+
if (!this.pendingCommand.currentLiteralKey) {
|
|
1060
|
+
literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
|
|
985
1061
|
}
|
|
986
|
-
|
|
987
|
-
|
|
1062
|
+
this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
|
|
1063
|
+
this.pendingCommand.literalBytes = undefined;
|
|
1064
|
+
// Store the literal data by its BODY key for parseFetchResponses
|
|
1065
|
+
if (this.pendingCommand.currentLiteralKey) {
|
|
1066
|
+
if (!this.pendingCommand.literals)
|
|
1067
|
+
this.pendingCommand.literals = new Map();
|
|
1068
|
+
this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
|
|
1069
|
+
this.pendingCommand.currentLiteralKey = undefined;
|
|
1070
|
+
this.pendingCommand.currentLiteralSize = undefined;
|
|
988
1071
|
}
|
|
989
|
-
|
|
1072
|
+
if (this.verbose)
|
|
1073
|
+
console.log(` [imap] literal consumed, ${neededBytes} bytes, buffer remaining: ${this.bufferLength - this.bufferOffset}`);
|
|
1074
|
+
// Allow the consumed-prefix to be reclaimed when half-full —
|
|
1075
|
+
// long literal-heavy responses would otherwise grow the
|
|
1076
|
+
// backing buffer indefinitely between commands.
|
|
1077
|
+
this.compactBufferIfNeeded();
|
|
1078
|
+
continue;
|
|
990
1079
|
}
|
|
991
|
-
// Find next CRLF starting from the read offset
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
|
|
1080
|
+
// Find next CRLF starting from the read offset on bytes (not
|
|
1081
|
+
// chars). bufferOffset advances past consumed bytes; we only
|
|
1082
|
+
// search the unread region so per-line work is O(line length),
|
|
1083
|
+
// never O(response size).
|
|
1084
|
+
const lineEnd = this.indexOfCRLF(this.bufferOffset);
|
|
995
1085
|
if (lineEnd < 0) {
|
|
996
1086
|
// No complete line yet. Compact the buffer if we've advanced
|
|
997
1087
|
// far enough that the unconsumed-prefix is dragging.
|
|
998
1088
|
this.compactBufferIfNeeded();
|
|
999
1089
|
break;
|
|
1000
1090
|
}
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1091
|
+
// Slice the line bytes (including trailing CRLF) and decode
|
|
1092
|
+
// once. IMAP control lines are 7-bit ASCII per RFC 3501; UTF-8
|
|
1093
|
+
// decode is a superset and produces the same characters. The
|
|
1094
|
+
// existing string-based parser (parseResponseLine, tokenizers,
|
|
1095
|
+
// ENVELOPE/FLAGS handling) consumes the decoded line below.
|
|
1096
|
+
const lineBytes = this.buffer.subarray(this.bufferOffset, lineEnd + 2);
|
|
1097
|
+
const line = this.utf8Decoder.decode(lineBytes);
|
|
1004
1098
|
this.bufferOffset = lineEnd + 2;
|
|
1005
1099
|
// Periodic compaction so the consumed prefix doesn't keep
|
|
1006
1100
|
// growing forever in long-running connections (IDLE, big sync).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/iflow-direct",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"author": "Bob Frankston",
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
22
|
+
"@bobfrankston/tcp-transport": "^0.1.6"
|
|
23
23
|
},
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
".transformedSnapshot": {
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
53
|
+
"@bobfrankston/tcp-transport": "^0.1.6"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
}
|