@bobfrankston/iflow-direct 0.1.31 → 0.1.33
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 +5 -0
- package/imap-native.js +91 -24
- package/package.json +1 -1
- package/types.d.ts +1 -0
package/imap-native.d.ts
CHANGED
|
@@ -173,6 +173,11 @@ export declare class NativeImapClient {
|
|
|
173
173
|
* 60s accommodates Gmail which is slow on SEARCH for large folders.
|
|
174
174
|
* Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
|
|
175
175
|
private inactivityTimeout;
|
|
176
|
+
/** Hard wall-clock cap per command (independent of inactivityTimeout).
|
|
177
|
+
* A pathological server that returns one byte every (inactivityTimeout-1)
|
|
178
|
+
* seconds would otherwise defer the inactivity timer forever; this is
|
|
179
|
+
* the absolute deadline that always fires. Default 5 minutes. */
|
|
180
|
+
private commandWallClockTimeout;
|
|
176
181
|
/** Server-greeting timeout in ms — how long to wait for the server's
|
|
177
182
|
* initial banner after TCP/TLS connects. Default 10s; bumped to 30s
|
|
178
183
|
* for slow shared-hosting Dovecot in mailx-imap. */
|
package/imap-native.js
CHANGED
|
@@ -32,6 +32,10 @@ export class NativeImapClient {
|
|
|
32
32
|
this.transport = transportFactory();
|
|
33
33
|
this.verbose = config.verbose || false;
|
|
34
34
|
this.inactivityTimeout = config.inactivityTimeout ?? 60000;
|
|
35
|
+
// Hard wall-clock deadline per command (default 5 min). Distinct
|
|
36
|
+
// from inactivityTimeout — that one resets on each data chunk and
|
|
37
|
+
// can be deferred indefinitely by a slow-trickling server.
|
|
38
|
+
this.commandWallClockTimeout = config.commandWallClockTimeout ?? 300_000;
|
|
35
39
|
this.fetchChunkSize = config.fetchChunkSize ?? 25;
|
|
36
40
|
this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
|
|
37
41
|
this.greetingTimeout = config.greetingTimeout ?? 10000;
|
|
@@ -729,6 +733,11 @@ export class NativeImapClient {
|
|
|
729
733
|
* 60s accommodates Gmail which is slow on SEARCH for large folders.
|
|
730
734
|
* Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
|
|
731
735
|
inactivityTimeout;
|
|
736
|
+
/** Hard wall-clock cap per command (independent of inactivityTimeout).
|
|
737
|
+
* A pathological server that returns one byte every (inactivityTimeout-1)
|
|
738
|
+
* seconds would otherwise defer the inactivity timer forever; this is
|
|
739
|
+
* the absolute deadline that always fires. Default 5 minutes. */
|
|
740
|
+
commandWallClockTimeout;
|
|
732
741
|
/** Server-greeting timeout in ms — how long to wait for the server's
|
|
733
742
|
* initial banner after TCP/TLS connects. Default 10s; bumped to 30s
|
|
734
743
|
* for slow shared-hosting Dovecot in mailx-imap. */
|
|
@@ -771,43 +780,101 @@ export class NativeImapClient {
|
|
|
771
780
|
if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
|
|
772
781
|
console.log(` [imap] > ${command.trimEnd()}`);
|
|
773
782
|
}
|
|
774
|
-
|
|
783
|
+
// Snapshot transport diagnostics — used by both heartbeat and timeout.
|
|
784
|
+
const transportDiag = () => {
|
|
785
|
+
try {
|
|
786
|
+
const d = this.transport?.diagnostics;
|
|
787
|
+
if (!d)
|
|
788
|
+
return "";
|
|
789
|
+
const sinceRead = d.lastReadAt ? Date.now() - d.lastReadAt : -1;
|
|
790
|
+
return ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${sinceRead}ms]`;
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return "";
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
// HEARTBEAT_INTERVAL_MS — periodic progress log while a command
|
|
797
|
+
// is pending. Catches wedges in flight rather than only at the
|
|
798
|
+
// 300s timeout edge. Key signal: if `sinceLastRead` keeps
|
|
799
|
+
// climbing, the socket is silently dead. If reads are happening
|
|
800
|
+
// but the tagged OK never arrives, the parser is stuck.
|
|
801
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
802
|
+
const cmdStart = Date.now();
|
|
803
|
+
const cmdSummary = command.split("\r")[0].substring(0, 80);
|
|
804
|
+
let heartbeatTimer = setInterval(() => {
|
|
805
|
+
const waited = Date.now() - cmdStart;
|
|
806
|
+
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
807
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
808
|
+
const onTimeout = (kind) => {
|
|
775
809
|
this.commandTimer = null;
|
|
776
810
|
this.pendingCommand = null;
|
|
811
|
+
if (heartbeatTimer) {
|
|
812
|
+
clearInterval(heartbeatTimer);
|
|
813
|
+
heartbeatTimer = null;
|
|
814
|
+
}
|
|
815
|
+
if (wallClockTimer) {
|
|
816
|
+
clearTimeout(wallClockTimer);
|
|
817
|
+
wallClockTimer = null;
|
|
818
|
+
}
|
|
777
819
|
// Kill the connection — a timed-out connection has stale data in the pipe.
|
|
778
820
|
// Mark disconnected so ensureConnected() reconnects on the next call —
|
|
779
821
|
// transport.close() may or may not fire onClose synchronously.
|
|
780
822
|
this._connected = false;
|
|
781
|
-
// Surface transport-level diagnostics if available — bytes
|
|
782
|
-
// written/read, time since last read, write count, conn id.
|
|
783
|
-
// Tells us whether the connection was silently dead
|
|
784
|
-
// (no reads, no writes acked) or genuinely waiting on a
|
|
785
|
-
// server reply that never came.
|
|
786
|
-
let diag = "";
|
|
787
|
-
try {
|
|
788
|
-
const d = this.transport?.diagnostics;
|
|
789
|
-
if (d) {
|
|
790
|
-
const sinceRead = d.lastReadAt ? Date.now() - d.lastReadAt : -1;
|
|
791
|
-
diag = ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${sinceRead}ms]`;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
catch { /* ignore */ }
|
|
795
823
|
this.transport.close?.();
|
|
796
|
-
reject(new Error(
|
|
824
|
+
reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
|
|
825
|
+
};
|
|
826
|
+
// Two timers, two responsibilities:
|
|
827
|
+
// commandTimer = "no bytes for N seconds" — handleData resets it
|
|
828
|
+
// on every chunk so a steadily-streaming FETCH
|
|
829
|
+
// doesn't get killed mid-flight.
|
|
830
|
+
// wallClockTimer = "this command has been pending too long
|
|
831
|
+
// total" — fires regardless of trickling bytes.
|
|
832
|
+
// Catches the pathological case where a server
|
|
833
|
+
// drips one byte every <inactivityTimeout> seconds
|
|
834
|
+
// for hours (real bobma bug we hit overnight:
|
|
835
|
+
// 3 connections wedged 2-3 hours each because
|
|
836
|
+
// the inactivity reset kept deferring the only
|
|
837
|
+
// timer in play).
|
|
838
|
+
this.commandTimer = setTimeout(() => onTimeout(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`), this.inactivityTimeout);
|
|
839
|
+
const COMMAND_WALL_CLOCK_TIMEOUT_MS = this.commandWallClockTimeout;
|
|
840
|
+
let wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${COMMAND_WALL_CLOCK_TIMEOUT_MS / 1000}s)`), COMMAND_WALL_CLOCK_TIMEOUT_MS);
|
|
841
|
+
const clearTimers = () => {
|
|
842
|
+
if (this.commandTimer) {
|
|
843
|
+
clearTimeout(this.commandTimer);
|
|
844
|
+
this.commandTimer = null;
|
|
845
|
+
}
|
|
846
|
+
if (heartbeatTimer) {
|
|
847
|
+
clearInterval(heartbeatTimer);
|
|
848
|
+
heartbeatTimer = null;
|
|
849
|
+
}
|
|
850
|
+
if (wallClockTimer) {
|
|
851
|
+
clearTimeout(wallClockTimer);
|
|
852
|
+
wallClockTimer = null;
|
|
853
|
+
}
|
|
797
854
|
};
|
|
798
|
-
this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
|
|
799
855
|
this.pendingCommand = {
|
|
800
856
|
tag, responses: [],
|
|
801
|
-
resolve: (responses) => {
|
|
802
|
-
|
|
803
|
-
reject: (err) => { if (this.commandTimer)
|
|
804
|
-
clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
|
|
857
|
+
resolve: (responses) => { clearTimers(); resolve(responses); },
|
|
858
|
+
reject: (err) => { clearTimers(); reject(err); },
|
|
805
859
|
onUntagged,
|
|
806
860
|
};
|
|
861
|
+
// PRE-WRITE HEALTH CHECK — verify the socket is in a writable
|
|
862
|
+
// state BEFORE we burn the inactivityTimeout window waiting for
|
|
863
|
+
// a reply that can't come. socket-half-closed (peer FIN'd) and
|
|
864
|
+
// destroyed states both fail fast here instead of after 300s.
|
|
865
|
+
try {
|
|
866
|
+
const h = this.transport?.socketHealth;
|
|
867
|
+
if (h && (h.destroyed || h.writableEnded || !h.writable)) {
|
|
868
|
+
clearTimers();
|
|
869
|
+
this.pendingCommand = null;
|
|
870
|
+
this._connected = false;
|
|
871
|
+
reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
catch { /* transport may not expose socketHealth — fall through */ }
|
|
807
876
|
this.transport.write(command).catch((err) => {
|
|
808
|
-
|
|
809
|
-
clearTimeout(this.commandTimer);
|
|
810
|
-
this.commandTimer = null;
|
|
877
|
+
clearTimers();
|
|
811
878
|
// Write failure = dead socket. Clear pendingCommand + disconnect flag
|
|
812
879
|
// so the next ensureConnected() does a fresh connect instead of
|
|
813
880
|
// reusing a socket that the OS layer has already declared dead
|
package/package.json
CHANGED
package/types.d.ts
CHANGED