@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 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
- /** 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
+ /** 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
- /** 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. */
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
- /** 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). */
21
- buffer = "";
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 (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;
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.buffer = "";
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 idleState = await this.suspendIdleForCommand();
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
- return await this.sendCommandCore(tag, command, onUntagged);
773
- }
774
- finally {
775
- if (idleState) {
776
- try {
777
- await this.resumeIdleAfterCommand(idleState);
778
- }
779
- catch (err) {
780
- if (this.verbose)
781
- console.error(` [imap] IDLE auto-resume failed: ${err.message}`);
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
- // HEARTBEAT_INTERVAL_MS periodic progress log while a command
805
- // is pending. Catches wedges in flight rather than only at the
806
- // 300s timeout edge. Key signal: if `sinceLastRead` keeps
807
- // climbing, the socket is silently dead. If reads are happening
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.commandTimer = null;
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
- // 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).
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
- 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);
849
- const clearTimers = () => {
850
- if (this.commandTimer) {
851
- clearTimeout(this.commandTimer);
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) => { clearTimers(); resolve(responses); },
866
- reject: (err) => { clearTimers(); 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
- clearTimers();
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
- clearTimers();
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 data is flowing, connection is alive
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.buffer += data;
967
+ this.appendToBuffer(data);
930
968
  this.processBuffer();
931
969
  }
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. */
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.buffer.length / 2) {
938
- this.buffer = this.buffer.substring(this.bufferOffset);
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 {size}\r\n reading exact BYTE count of literal data
945
- // CRITICAL: literalBytes is in octets (from IMAP {N}), but this.buffer is a
946
- // JavaScript string where multi-byte UTF-8 characters count as 1 character.
947
- // We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
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
- // 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);
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
- if (this.verbose)
980
- console.log(` [imap] literal consumed, ${literal.length} bytes, buffer remaining: ${this.buffer.length}`);
981
- // After consuming literal, check if the NEXT part has another literal
982
- // (e.g., BODY[HEADER] {500}\r\n<data>BODY[] {2000}\r\n<data>)\r\n)
983
- // Continue to process the next line/literal from the buffer
984
- continue;
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
- if (this.verbose && this.pendingCommand.literalBytes > 0) {
987
- console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${bufferBytes} bytes`);
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
- break; // Wait for more data
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 (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);
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
- // 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);
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.34",
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.5"
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.5"
53
+ "@bobfrankston/tcp-transport": "^0.1.6"
54
54
  }
55
55
  }
56
56
  }