@bobfrankston/iflow-direct 0.1.32 → 0.1.34
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-native.d.ts +18 -0
- package/imap-native.js +76 -11
- package/package.json +1 -1
- package/types.d.ts +1 -0
package/imap-native.d.ts
CHANGED
|
@@ -48,7 +48,15 @@ export declare class NativeImapClient {
|
|
|
48
48
|
private transport;
|
|
49
49
|
private transportFactory;
|
|
50
50
|
private config;
|
|
51
|
+
/** Receive buffer + read offset. CRITICAL: never reassign with
|
|
52
|
+
* `this.buffer = this.buffer.substring(N)` — that copies the whole
|
|
53
|
+
* remainder per line and is O(n²) over a multi-line response (a 457KB
|
|
54
|
+
* LIST response actually blocked the daemon's event loop for 10 minutes
|
|
55
|
+
* in production). Use bufferOffset to advance the read cursor; only
|
|
56
|
+
* compact the buffer string when the consumed portion is >= half its
|
|
57
|
+
* length, keeping per-line work O(1). */
|
|
51
58
|
private buffer;
|
|
59
|
+
private bufferOffset;
|
|
52
60
|
private pendingCommand;
|
|
53
61
|
private capabilities;
|
|
54
62
|
private _connected;
|
|
@@ -173,6 +181,11 @@ export declare class NativeImapClient {
|
|
|
173
181
|
* 60s accommodates Gmail which is slow on SEARCH for large folders.
|
|
174
182
|
* Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
|
|
175
183
|
private inactivityTimeout;
|
|
184
|
+
/** Hard wall-clock cap per command (independent of inactivityTimeout).
|
|
185
|
+
* A pathological server that returns one byte every (inactivityTimeout-1)
|
|
186
|
+
* seconds would otherwise defer the inactivity timer forever; this is
|
|
187
|
+
* the absolute deadline that always fires. Default 5 minutes. */
|
|
188
|
+
private commandWallClockTimeout;
|
|
176
189
|
/** Server-greeting timeout in ms — how long to wait for the server's
|
|
177
190
|
* initial banner after TCP/TLS connects. Default 10s; bumped to 30s
|
|
178
191
|
* for slow shared-hosting Dovecot in mailx-imap. */
|
|
@@ -198,6 +211,11 @@ export declare class NativeImapClient {
|
|
|
198
211
|
private waitForContinuation;
|
|
199
212
|
private waitForTagged;
|
|
200
213
|
private handleData;
|
|
214
|
+
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
215
|
+
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
216
|
+
* that synchronously blocked the daemon for 10 minutes on a large LIST
|
|
217
|
+
* response. */
|
|
218
|
+
private compactBufferIfNeeded;
|
|
201
219
|
private processBuffer;
|
|
202
220
|
private handleUntaggedResponse;
|
|
203
221
|
private parseFetchResponses;
|
package/imap-native.js
CHANGED
|
@@ -11,7 +11,15 @@ export class NativeImapClient {
|
|
|
11
11
|
transport;
|
|
12
12
|
transportFactory;
|
|
13
13
|
config;
|
|
14
|
+
/** Receive buffer + read offset. CRITICAL: never reassign with
|
|
15
|
+
* `this.buffer = this.buffer.substring(N)` — that copies the whole
|
|
16
|
+
* remainder per line and is O(n²) over a multi-line response (a 457KB
|
|
17
|
+
* LIST response actually blocked the daemon's event loop for 10 minutes
|
|
18
|
+
* in production). Use bufferOffset to advance the read cursor; only
|
|
19
|
+
* compact the buffer string when the consumed portion is >= half its
|
|
20
|
+
* length, keeping per-line work O(1). */
|
|
14
21
|
buffer = "";
|
|
22
|
+
bufferOffset = 0;
|
|
15
23
|
pendingCommand = null;
|
|
16
24
|
capabilities = new Set();
|
|
17
25
|
_connected = false;
|
|
@@ -32,6 +40,10 @@ export class NativeImapClient {
|
|
|
32
40
|
this.transport = transportFactory();
|
|
33
41
|
this.verbose = config.verbose || false;
|
|
34
42
|
this.inactivityTimeout = config.inactivityTimeout ?? 60000;
|
|
43
|
+
// Hard wall-clock deadline per command (default 5 min). Distinct
|
|
44
|
+
// from inactivityTimeout — that one resets on each data chunk and
|
|
45
|
+
// can be deferred indefinitely by a slow-trickling server.
|
|
46
|
+
this.commandWallClockTimeout = config.commandWallClockTimeout ?? 300_000;
|
|
35
47
|
this.fetchChunkSize = config.fetchChunkSize ?? 25;
|
|
36
48
|
this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
|
|
37
49
|
this.greetingTimeout = config.greetingTimeout ?? 10000;
|
|
@@ -729,6 +741,11 @@ export class NativeImapClient {
|
|
|
729
741
|
* 60s accommodates Gmail which is slow on SEARCH for large folders.
|
|
730
742
|
* Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
|
|
731
743
|
inactivityTimeout;
|
|
744
|
+
/** Hard wall-clock cap per command (independent of inactivityTimeout).
|
|
745
|
+
* A pathological server that returns one byte every (inactivityTimeout-1)
|
|
746
|
+
* seconds would otherwise defer the inactivity timer forever; this is
|
|
747
|
+
* the absolute deadline that always fires. Default 5 minutes. */
|
|
748
|
+
commandWallClockTimeout;
|
|
732
749
|
/** Server-greeting timeout in ms — how long to wait for the server's
|
|
733
750
|
* initial banner after TCP/TLS connects. Default 10s; bumped to 30s
|
|
734
751
|
* for slow shared-hosting Dovecot in mailx-imap. */
|
|
@@ -796,21 +813,39 @@ export class NativeImapClient {
|
|
|
796
813
|
const waited = Date.now() - cmdStart;
|
|
797
814
|
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
798
815
|
}, HEARTBEAT_INTERVAL_MS);
|
|
799
|
-
const onTimeout = () => {
|
|
816
|
+
const onTimeout = (kind) => {
|
|
800
817
|
this.commandTimer = null;
|
|
801
818
|
this.pendingCommand = null;
|
|
802
819
|
if (heartbeatTimer) {
|
|
803
820
|
clearInterval(heartbeatTimer);
|
|
804
821
|
heartbeatTimer = null;
|
|
805
822
|
}
|
|
823
|
+
if (wallClockTimer) {
|
|
824
|
+
clearTimeout(wallClockTimer);
|
|
825
|
+
wallClockTimer = null;
|
|
826
|
+
}
|
|
806
827
|
// Kill the connection — a timed-out connection has stale data in the pipe.
|
|
807
828
|
// Mark disconnected so ensureConnected() reconnects on the next call —
|
|
808
829
|
// transport.close() may or may not fire onClose synchronously.
|
|
809
830
|
this._connected = false;
|
|
810
831
|
this.transport.close?.();
|
|
811
|
-
reject(new Error(
|
|
832
|
+
reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
|
|
812
833
|
};
|
|
813
|
-
|
|
834
|
+
// Two timers, two responsibilities:
|
|
835
|
+
// commandTimer = "no bytes for N seconds" — handleData resets it
|
|
836
|
+
// on every chunk so a steadily-streaming FETCH
|
|
837
|
+
// doesn't get killed mid-flight.
|
|
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).
|
|
846
|
+
this.commandTimer = setTimeout(() => onTimeout(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`), this.inactivityTimeout);
|
|
847
|
+
const COMMAND_WALL_CLOCK_TIMEOUT_MS = this.commandWallClockTimeout;
|
|
848
|
+
let wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${COMMAND_WALL_CLOCK_TIMEOUT_MS / 1000}s)`), COMMAND_WALL_CLOCK_TIMEOUT_MS);
|
|
814
849
|
const clearTimers = () => {
|
|
815
850
|
if (this.commandTimer) {
|
|
816
851
|
clearTimeout(this.commandTimer);
|
|
@@ -820,6 +855,10 @@ export class NativeImapClient {
|
|
|
820
855
|
clearInterval(heartbeatTimer);
|
|
821
856
|
heartbeatTimer = null;
|
|
822
857
|
}
|
|
858
|
+
if (wallClockTimer) {
|
|
859
|
+
clearTimeout(wallClockTimer);
|
|
860
|
+
wallClockTimer = null;
|
|
861
|
+
}
|
|
823
862
|
};
|
|
824
863
|
this.pendingCommand = {
|
|
825
864
|
tag, responses: [],
|
|
@@ -890,6 +929,16 @@ export class NativeImapClient {
|
|
|
890
929
|
this.buffer += data;
|
|
891
930
|
this.processBuffer();
|
|
892
931
|
}
|
|
932
|
+
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
933
|
+
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
934
|
+
* that synchronously blocked the daemon for 10 minutes on a large LIST
|
|
935
|
+
* response. */
|
|
936
|
+
compactBufferIfNeeded() {
|
|
937
|
+
if (this.bufferOffset > 0 && this.bufferOffset >= this.buffer.length / 2) {
|
|
938
|
+
this.buffer = this.buffer.substring(this.bufferOffset);
|
|
939
|
+
this.bufferOffset = 0;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
893
942
|
processBuffer() {
|
|
894
943
|
while (true) {
|
|
895
944
|
// Check for literal {size}\r\n — reading exact BYTE count of literal data
|
|
@@ -898,16 +947,20 @@ export class NativeImapClient {
|
|
|
898
947
|
// We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
|
|
899
948
|
if (this.pendingCommand?.literalBytes != null) {
|
|
900
949
|
const neededBytes = this.pendingCommand.literalBytes;
|
|
901
|
-
|
|
950
|
+
// Encode only the unread portion — was encoding the whole
|
|
951
|
+
// buffer (including already-consumed bytes) on every literal,
|
|
952
|
+
// which on a multi-MB buffer is a non-trivial chunk of work.
|
|
953
|
+
const unread = this.bufferOffset === 0 ? this.buffer : this.buffer.substring(this.bufferOffset);
|
|
954
|
+
const encoded = new TextEncoder().encode(unread);
|
|
902
955
|
const bufferBytes = encoded.byteLength;
|
|
903
956
|
if (bufferBytes >= neededBytes) {
|
|
904
957
|
const literal_bytes = encoded.subarray(0, neededBytes);
|
|
905
958
|
const rest_bytes = encoded.subarray(neededBytes);
|
|
906
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.
|
|
907
962
|
this.buffer = new TextDecoder().decode(rest_bytes);
|
|
908
|
-
|
|
909
|
-
// console.log(`iflow-direct: [imap] literal: ${neededBytes} bytes → ${literal.length} chars (multi-byte corrected)`);
|
|
910
|
-
// }
|
|
963
|
+
this.bufferOffset = 0;
|
|
911
964
|
// For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
|
|
912
965
|
// so tokenizeParenList treats them as a single token
|
|
913
966
|
if (!this.pendingCommand.currentLiteralKey) {
|
|
@@ -935,11 +988,23 @@ export class NativeImapClient {
|
|
|
935
988
|
}
|
|
936
989
|
break; // Wait for more data
|
|
937
990
|
}
|
|
938
|
-
|
|
939
|
-
|
|
991
|
+
// Find next CRLF starting from the read offset (NOT from 0 —
|
|
992
|
+
// the bytes before bufferOffset are already consumed and
|
|
993
|
+
// searching them is dead work that scales with response size).
|
|
994
|
+
const lineEnd = this.buffer.indexOf("\r\n", this.bufferOffset);
|
|
995
|
+
if (lineEnd < 0) {
|
|
996
|
+
// No complete line yet. Compact the buffer if we've advanced
|
|
997
|
+
// far enough that the unconsumed-prefix is dragging.
|
|
998
|
+
this.compactBufferIfNeeded();
|
|
940
999
|
break;
|
|
941
|
-
|
|
942
|
-
|
|
1000
|
+
}
|
|
1001
|
+
// Extract the line and advance the read cursor — no substring
|
|
1002
|
+
// copy of the remainder, no buffer reallocation.
|
|
1003
|
+
const line = this.buffer.substring(this.bufferOffset, lineEnd + 2);
|
|
1004
|
+
this.bufferOffset = lineEnd + 2;
|
|
1005
|
+
// Periodic compaction so the consumed prefix doesn't keep
|
|
1006
|
+
// growing forever in long-running connections (IDLE, big sync).
|
|
1007
|
+
this.compactBufferIfNeeded();
|
|
943
1008
|
// Check for literal announcement {size}\r\n at end of line
|
|
944
1009
|
const literalMatch = line.match(/\{(\d+)\}\r\n$/);
|
|
945
1010
|
if (literalMatch && this.pendingCommand) {
|
package/package.json
CHANGED
package/types.d.ts
CHANGED