@bobfrankston/iflow-direct 0.1.35 → 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;
@@ -213,22 +227,47 @@ export declare class NativeImapClient {
213
227
  * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
214
228
  * server until the inactivity timer killed the socket.
215
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;
216
246
  private sendCommand;
217
247
  private sendCommandCore;
218
248
  private waitForContinuation;
219
249
  private waitForTagged;
220
250
  private handleData;
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;
221
255
  /** Single canonical "command finished or abandoned" cleanup. Every
222
256
  * path that ends a command's lifecycle — resolve, reject, timeout,
223
257
  * socket close, command-replace — calls this. Was a leak source:
224
258
  * closure-scoped timers in sendCommandCore couldn't be cleared from
225
259
  * handleData's inline-replacement timer or from IDLE suspend/resume. */
226
260
  private clearAllCommandTimers;
227
- /** Compact the buffer when bufferOffset has advanced past half. Cheap
228
- * amortized cost; eliminates the O(n²) substring-each-line antipattern
229
- * that synchronously blocked the daemon for 10 minutes on a large LIST
230
- * response. */
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. */
231
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;
232
271
  private processBuffer;
233
272
  private handleUntaggedResponse;
234
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;
@@ -73,7 +87,7 @@ export class NativeImapClient {
73
87
  catch { /* ignore */ }
74
88
  this.transport = this.transportFactory();
75
89
  this._connected = false;
76
- this.buffer = "";
90
+ this.bufferLength = 0;
77
91
  this.bufferOffset = 0;
78
92
  this.pendingCommand = null;
79
93
  this.selectedMailbox = null;
@@ -788,22 +802,52 @@ export class NativeImapClient {
788
802
  * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
789
803
  * server until the inactivity timer killed the socket.
790
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();
791
821
  async sendCommand(tag, command, onUntagged) {
792
- 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;
793
828
  try {
794
- return await this.sendCommandCore(tag, command, onUntagged);
795
- }
796
- finally {
797
- if (idleState) {
798
- try {
799
- await this.resumeIdleAfterCommand(idleState);
800
- }
801
- catch (err) {
802
- if (this.verbose)
803
- 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
+ }
804
844
  }
805
845
  }
806
846
  }
847
+ catch (e) {
848
+ myReject(e);
849
+ throw e;
850
+ }
807
851
  }
808
852
  sendCommandCore(tag, command, onUntagged) {
809
853
  return new Promise((resolve, reject) => {
@@ -920,9 +964,25 @@ export class NativeImapClient {
920
964
  }
921
965
  }, this.inactivityTimeout);
922
966
  }
923
- this.buffer += data;
967
+ this.appendToBuffer(data);
924
968
  this.processBuffer();
925
969
  }
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
+ }
926
986
  /** Single canonical "command finished or abandoned" cleanup. Every
927
987
  * path that ends a command's lifecycle — resolve, reject, timeout,
928
988
  * socket close, command-replace — calls this. Was a leak source:
@@ -942,78 +1002,99 @@ export class NativeImapClient {
942
1002
  this.wallClockTimer = null;
943
1003
  }
944
1004
  }
945
- /** Compact the buffer when bufferOffset has advanced past half. Cheap
946
- * amortized cost; eliminates the O(n²) substring-each-line antipattern
947
- * that synchronously blocked the daemon for 10 minutes on a large LIST
948
- * response. */
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. */
949
1010
  compactBufferIfNeeded() {
950
- if (this.bufferOffset > 0 && this.bufferOffset >= this.buffer.length / 2) {
951
- 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;
952
1014
  this.bufferOffset = 0;
953
1015
  }
954
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
+ }
955
1029
  processBuffer() {
956
1030
  while (true) {
957
- // Check for literal {size}\r\n reading exact BYTE count of literal data
958
- // CRITICAL: literalBytes is in octets (from IMAP {N}), but this.buffer is a
959
- // JavaScript string where multi-byte UTF-8 characters count as 1 character.
960
- // 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²).
961
1035
  if (this.pendingCommand?.literalBytes != null) {
962
1036
  const neededBytes = this.pendingCommand.literalBytes;
963
- // Encode only the unread portion — was encoding the whole
964
- // buffer (including already-consumed bytes) on every literal,
965
- // which on a multi-MB buffer is a non-trivial chunk of work.
966
- const unread = this.bufferOffset === 0 ? this.buffer : this.buffer.substring(this.bufferOffset);
967
- const encoded = new TextEncoder().encode(unread);
968
- const bufferBytes = encoded.byteLength;
969
- if (bufferBytes >= neededBytes) {
970
- const literal_bytes = encoded.subarray(0, neededBytes);
971
- const rest_bytes = encoded.subarray(neededBytes);
972
- let literal = new TextDecoder().decode(literal_bytes);
973
- // Reset buffer to just the rest — bufferOffset goes back
974
- // to 0 since we replaced the buffer content.
975
- this.buffer = new TextDecoder().decode(rest_bytes);
976
- this.bufferOffset = 0;
977
- // For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
978
- // so tokenizeParenList treats them as a single token
979
- if (!this.pendingCommand.currentLiteralKey) {
980
- literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
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}`);
981
1041
  }
982
- this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
983
- this.pendingCommand.literalBytes = undefined;
984
- // Store the literal data by its BODY key for parseFetchResponses
985
- if (this.pendingCommand.currentLiteralKey) {
986
- if (!this.pendingCommand.literals)
987
- this.pendingCommand.literals = new Map();
988
- this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
989
- this.pendingCommand.currentLiteralKey = undefined;
990
- this.pendingCommand.currentLiteralSize = undefined;
991
- }
992
- if (this.verbose)
993
- console.log(` [imap] literal consumed, ${literal.length} bytes, buffer remaining: ${this.buffer.length}`);
994
- // After consuming literal, check if the NEXT part has another literal
995
- // (e.g., BODY[HEADER] {500}\r\n<data>BODY[] {2000}\r\n<data>)\r\n)
996
- // Continue to process the next line/literal from the buffer
997
- 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, "")}"`;
998
1061
  }
999
- if (this.verbose && this.pendingCommand.literalBytes > 0) {
1000
- 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;
1001
1071
  }
1002
- 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;
1003
1079
  }
1004
- // Find next CRLF starting from the read offset (NOT from 0 —
1005
- // the bytes before bufferOffset are already consumed and
1006
- // searching them is dead work that scales with response size).
1007
- 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);
1008
1085
  if (lineEnd < 0) {
1009
1086
  // No complete line yet. Compact the buffer if we've advanced
1010
1087
  // far enough that the unconsumed-prefix is dragging.
1011
1088
  this.compactBufferIfNeeded();
1012
1089
  break;
1013
1090
  }
1014
- // Extract the line and advance the read cursor — no substring
1015
- // copy of the remainder, no buffer reallocation.
1016
- 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);
1017
1098
  this.bufferOffset = lineEnd + 2;
1018
1099
  // Periodic compaction so the consumed prefix doesn't keep
1019
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.35",
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
  }