@bobfrankston/iflow-direct 0.1.35 → 0.1.40

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
@@ -7,6 +7,7 @@ import { type NativeFolder } from "./imap-native.js";
7
7
  import { FetchedMessage } from "./fetched-message.js";
8
8
  import type { ImapClientConfig } from "./types.js";
9
9
  import type { TransportFactory } from "./transport.js";
10
+ import type * as proto from "./imap-protocol.js";
10
11
  /** Special folder detection result */
11
12
  export interface SpecialFolders {
12
13
  inbox?: string;
@@ -66,6 +67,12 @@ export declare class CompatImapClient {
66
67
  getMessagesCount(mailbox: string): Promise<number>;
67
68
  /** Get all UIDs in a mailbox */
68
69
  getUids(mailbox: string): Promise<number[]>;
70
+ /** Get UIDs whose INTERNALDATE is on/after `since`. Bounded version of
71
+ * getUids — returns only the date window the caller cares about
72
+ * instead of the entire folder. Lets set-diff reconciliation scope
73
+ * itself to "messages from the last N days" rather than enumerating
74
+ * every UID in a 134k-message folder. */
75
+ getUidsSince(mailbox: string, since: Date): Promise<number[]>;
69
76
  /** Fetch messages — supports two calling conventions for compatibility:
70
77
  *
71
78
  * New: fetchMessages(mailbox, "100:200") — UID range string
@@ -96,8 +103,18 @@ export declare class CompatImapClient {
96
103
  createmailbox(name: string): Promise<void>;
97
104
  /** Append a message to a mailbox */
98
105
  appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise<number | null>;
99
- /** Watch a mailbox for new messages (IDLE) */
100
- watchMailbox(mailbox: string, onNew: (count: number) => void): Promise<() => Promise<void>>;
106
+ /** Cached CAPABILITY set parsed at connect/login. Callers gate
107
+ * optional features (NOTIFY, QRESYNC, MOVE, ...) on this. */
108
+ getCapabilities(): Set<string>;
109
+ /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
110
+ * NOTIFY so the server also pushes STATUS responses for non-selected
111
+ * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
112
+ * for each. Capability check is the caller's responsibility; pass
113
+ * notifySpec only when `getCapabilities().has("NOTIFY")`. */
114
+ watchMailbox(mailbox: string, onNew: (count: number) => void, opts?: {
115
+ notifySpec?: string;
116
+ onMailboxStatus?: (mailbox: string, data: proto.StatusData) => void;
117
+ }): Promise<() => Promise<void>>;
101
118
  /** Copy a message to another server (cross-account) */
102
119
  moveMessageToServer(msg: any, fromMailbox: string, targetClient: CompatImapClient, toMailbox: string): Promise<void>;
103
120
  /** Rename a mailbox (via native access) */
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;
@@ -260,10 +274,23 @@ export class CompatImapClient {
260
274
  const data = typeof message === "string" ? message : new TextDecoder().decode(message);
261
275
  return this.native.appendMessage(mailbox, data, flags);
262
276
  }
263
- /** Watch a mailbox for new messages (IDLE) */
264
- async watchMailbox(mailbox, onNew) {
277
+ /** Cached CAPABILITY set parsed at connect/login. Callers gate
278
+ * optional features (NOTIFY, QRESYNC, MOVE, ...) on this. */
279
+ getCapabilities() {
280
+ return this.native.getCapabilities();
281
+ }
282
+ /** Watch a mailbox for new messages (IDLE). Optionally engage RFC 5465
283
+ * NOTIFY so the server also pushes STATUS responses for non-selected
284
+ * mailboxes named in `opts.notifySpec` — `opts.onMailboxStatus` fires
285
+ * for each. Capability check is the caller's responsibility; pass
286
+ * notifySpec only when `getCapabilities().has("NOTIFY")`. */
287
+ async watchMailbox(mailbox, onNew, opts) {
265
288
  await this.ensureConnected();
266
289
  await this.native.select(mailbox);
290
+ if (opts?.onMailboxStatus)
291
+ this.native.onMailboxStatus = opts.onMailboxStatus;
292
+ if (opts?.notifySpec)
293
+ await this.native.notify(opts.notifySpec);
267
294
  return this.native.startIdle(onNew);
268
295
  }
269
296
  /** Copy a message to another server (cross-account) */
package/imap-native.d.ts CHANGED
@@ -48,21 +48,40 @@ 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;
63
77
  private idleTag;
64
78
  private idleCallback;
65
79
  private idleRefreshTimer;
80
+ /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
81
+ * mailboxes (the server pushes these when the client has issued NOTIFY
82
+ * SET with a PERSONAL group). Distinct from `idleCallback` which only
83
+ * fires for EXISTS on the currently-selected mailbox. */
84
+ onMailboxStatus: ((mailbox: string, data: proto.StatusData) => void) | null;
66
85
  /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
67
86
  private idleStopped;
68
87
  private verbose;
@@ -90,6 +109,11 @@ export declare class NativeImapClient {
90
109
  private authenticate;
91
110
  private starttls;
92
111
  capability(): Promise<Set<string>>;
112
+ /** Return the cached CAPABILITY set parsed at connect/login time. Callers
113
+ * use this to gate optional code paths (NOTIFY, QRESYNC, MOVE, etc.)
114
+ * without re-issuing CAPABILITY. Returns a defensive copy so callers
115
+ * can't mutate internal state. */
116
+ getCapabilities(): Set<string>;
93
117
  private parseCapabilities;
94
118
  logout(): Promise<void>;
95
119
  select(mailbox: string): Promise<MailboxInfo>;
@@ -165,6 +189,14 @@ export declare class NativeImapClient {
165
189
  expunge(): Promise<void>;
166
190
  /** Append a message to a mailbox */
167
191
  appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise<number | null>;
192
+ /** Issue NOTIFY SET so the server starts pushing unsolicited STATUS
193
+ * responses for the mailboxes named in `spec` (beyond the currently
194
+ * selected one). Capability gated — callers must check
195
+ * `getCapabilities().has("NOTIFY")` before calling. Set
196
+ * `onMailboxStatus` before issuing if you want to react. Issue AFTER
197
+ * SELECT and BEFORE startIdle — the server holds the spec for the
198
+ * lifetime of the connection. */
199
+ notify(spec: string): Promise<void>;
168
200
  startIdle(onNewMail: (count: number) => void): Promise<() => Promise<void>>;
169
201
  /**
170
202
  * If IDLE is currently active, send DONE and wait for its tagged OK so the
@@ -213,22 +245,47 @@ export declare class NativeImapClient {
213
245
  * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
214
246
  * server until the inactivity timer killed the socket.
215
247
  */
248
+ /** Per-client command serialization. IMAP allows only one command in
249
+ * flight at a time on a single connection (RFC 3501 §2.2.1). The
250
+ * client's `pendingCommand` field tracks that command — if a second
251
+ * caller concurrently invokes sendCommand, it overwrites
252
+ * pendingCommand and the first caller's await is orphaned. We saw
253
+ * this in production: the outbox poller's `withConnection`-queued
254
+ * task ran on the same shared ops client as syncFolder's INBOX work,
255
+ * and INBOX silently hung forever (no heartbeat fire, no wall-clock,
256
+ * no reject — the promise just had no command tracking it anymore).
257
+ *
258
+ * This chain serializes ALL sendCommand callers against this client
259
+ * regardless of whether the caller bothered to coordinate.
260
+ * Defensive — the right thing for the upper layer to do is also use
261
+ * the queue (withConnection in mailx-imap), but the IMAP client
262
+ * shouldn't allow itself to be corrupted by a careless caller. */
263
+ private commandChain;
216
264
  private sendCommand;
217
265
  private sendCommandCore;
218
266
  private waitForContinuation;
219
267
  private waitForTagged;
220
268
  private handleData;
269
+ /** Append received bytes to the receive buffer, growing capacity if
270
+ * needed. O(n) over the chunk size, never over the buffer total —
271
+ * doubling growth amortizes to O(1) per byte across the response. */
272
+ private appendToBuffer;
221
273
  /** Single canonical "command finished or abandoned" cleanup. Every
222
274
  * path that ends a command's lifecycle — resolve, reject, timeout,
223
275
  * socket close, command-replace — calls this. Was a leak source:
224
276
  * closure-scoped timers in sendCommandCore couldn't be cleared from
225
277
  * handleData's inline-replacement timer or from IDLE suspend/resume. */
226
278
  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. */
279
+ /** Compact the buffer when bufferOffset has advanced past half the used
280
+ * region. copyWithin moves the unread bytes to the front in-place
281
+ * (no new allocation). Amortized O(1) per byte; never the O(n²)
282
+ * substring-each-line pathology that blocked the event loop on a
283
+ * large LIST response. */
231
284
  private compactBufferIfNeeded;
285
+ /** Find the next CRLF (0x0D 0x0A) in the unread region, returning the
286
+ * byte index of the 0x0D, or -1 if not found. Manual byte loop —
287
+ * Uint8Array doesn't have a native indexOf for byte sequences. */
288
+ private indexOfCRLF;
232
289
  private processBuffer;
233
290
  private handleUntaggedResponse;
234
291
  private parseFetchResponses;
package/imap-native.js CHANGED
@@ -11,21 +11,40 @@ 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;
26
40
  idleTag = null;
27
41
  idleCallback = null;
28
42
  idleRefreshTimer = null;
43
+ /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
44
+ * mailboxes (the server pushes these when the client has issued NOTIFY
45
+ * SET with a PERSONAL group). Distinct from `idleCallback` which only
46
+ * fires for EXISTS on the currently-selected mailbox. */
47
+ onMailboxStatus = null;
29
48
  /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
30
49
  idleStopped = true;
31
50
  verbose;
@@ -73,7 +92,7 @@ export class NativeImapClient {
73
92
  catch { /* ignore */ }
74
93
  this.transport = this.transportFactory();
75
94
  this._connected = false;
76
- this.buffer = "";
95
+ this.bufferLength = 0;
77
96
  this.bufferOffset = 0;
78
97
  this.pendingCommand = null;
79
98
  this.selectedMailbox = null;
@@ -210,6 +229,13 @@ export class NativeImapClient {
210
229
  }
211
230
  return this.capabilities;
212
231
  }
232
+ /** Return the cached CAPABILITY set parsed at connect/login time. Callers
233
+ * use this to gate optional code paths (NOTIFY, QRESYNC, MOVE, etc.)
234
+ * without re-issuing CAPABILITY. Returns a defensive copy so callers
235
+ * can't mutate internal state. */
236
+ getCapabilities() {
237
+ return new Set(this.capabilities);
238
+ }
213
239
  parseCapabilities(text) {
214
240
  const caps = text.replace(/^CAPABILITY\s*/i, "").split(/\s+/);
215
241
  this.capabilities.clear();
@@ -609,6 +635,22 @@ export class NativeImapClient {
609
635
  const uidMatch = tagged.text.match(/APPENDUID\s+\d+\s+(\d+)/i);
610
636
  return uidMatch ? parseInt(uidMatch[1]) : null;
611
637
  }
638
+ // ── NOTIFY (RFC 5465) ──
639
+ /** Issue NOTIFY SET so the server starts pushing unsolicited STATUS
640
+ * responses for the mailboxes named in `spec` (beyond the currently
641
+ * selected one). Capability gated — callers must check
642
+ * `getCapabilities().has("NOTIFY")` before calling. Set
643
+ * `onMailboxStatus` before issuing if you want to react. Issue AFTER
644
+ * SELECT and BEFORE startIdle — the server holds the spec for the
645
+ * lifetime of the connection. */
646
+ async notify(spec) {
647
+ const tag = proto.nextTag();
648
+ const responses = await this.sendCommand(tag, proto.notifyCommand(tag, spec));
649
+ const tagged = responses.find(r => r.tag === tag);
650
+ if (!tagged || tagged.type !== "OK") {
651
+ throw new Error(`NOTIFY SET failed: ${tagged?.text || "unknown"}`);
652
+ }
653
+ }
612
654
  // ── IDLE ──
613
655
  async startIdle(onNewMail) {
614
656
  this.idleCallback = onNewMail;
@@ -788,22 +830,52 @@ export class NativeImapClient {
788
830
  * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
789
831
  * server until the inactivity timer killed the socket.
790
832
  */
833
+ /** Per-client command serialization. IMAP allows only one command in
834
+ * flight at a time on a single connection (RFC 3501 §2.2.1). The
835
+ * client's `pendingCommand` field tracks that command — if a second
836
+ * caller concurrently invokes sendCommand, it overwrites
837
+ * pendingCommand and the first caller's await is orphaned. We saw
838
+ * this in production: the outbox poller's `withConnection`-queued
839
+ * task ran on the same shared ops client as syncFolder's INBOX work,
840
+ * and INBOX silently hung forever (no heartbeat fire, no wall-clock,
841
+ * no reject — the promise just had no command tracking it anymore).
842
+ *
843
+ * This chain serializes ALL sendCommand callers against this client
844
+ * regardless of whether the caller bothered to coordinate.
845
+ * Defensive — the right thing for the upper layer to do is also use
846
+ * the queue (withConnection in mailx-imap), but the IMAP client
847
+ * shouldn't allow itself to be corrupted by a careless caller. */
848
+ commandChain = Promise.resolve();
791
849
  async sendCommand(tag, command, onUntagged) {
792
- const idleState = await this.suspendIdleForCommand();
850
+ const prev = this.commandChain.catch(() => { });
851
+ let myResolve;
852
+ let myReject;
853
+ const myPromise = new Promise((res, rej) => { myResolve = res; myReject = rej; });
854
+ this.commandChain = myPromise.catch(() => { });
855
+ await prev;
793
856
  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}`);
857
+ const idleState = await this.suspendIdleForCommand();
858
+ try {
859
+ const out = await this.sendCommandCore(tag, command, onUntagged);
860
+ myResolve(out);
861
+ return out;
862
+ }
863
+ finally {
864
+ if (idleState) {
865
+ try {
866
+ await this.resumeIdleAfterCommand(idleState);
867
+ }
868
+ catch (err) {
869
+ if (this.verbose)
870
+ console.error(` [imap] IDLE auto-resume failed: ${err.message}`);
871
+ }
804
872
  }
805
873
  }
806
874
  }
875
+ catch (e) {
876
+ myReject(e);
877
+ throw e;
878
+ }
807
879
  }
808
880
  sendCommandCore(tag, command, onUntagged) {
809
881
  return new Promise((resolve, reject) => {
@@ -920,9 +992,25 @@ export class NativeImapClient {
920
992
  }
921
993
  }, this.inactivityTimeout);
922
994
  }
923
- this.buffer += data;
995
+ this.appendToBuffer(data);
924
996
  this.processBuffer();
925
997
  }
998
+ /** Append received bytes to the receive buffer, growing capacity if
999
+ * needed. O(n) over the chunk size, never over the buffer total —
1000
+ * doubling growth amortizes to O(1) per byte across the response. */
1001
+ appendToBuffer(data) {
1002
+ const need = this.bufferLength + data.length;
1003
+ if (need > this.buffer.length) {
1004
+ let cap = Math.max(this.buffer.length * 2, 8192);
1005
+ while (cap < need)
1006
+ cap *= 2;
1007
+ const grown = new Uint8Array(cap);
1008
+ grown.set(this.buffer.subarray(0, this.bufferLength));
1009
+ this.buffer = grown;
1010
+ }
1011
+ this.buffer.set(data, this.bufferLength);
1012
+ this.bufferLength += data.length;
1013
+ }
926
1014
  /** Single canonical "command finished or abandoned" cleanup. Every
927
1015
  * path that ends a command's lifecycle — resolve, reject, timeout,
928
1016
  * socket close, command-replace — calls this. Was a leak source:
@@ -942,78 +1030,99 @@ export class NativeImapClient {
942
1030
  this.wallClockTimer = null;
943
1031
  }
944
1032
  }
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. */
1033
+ /** Compact the buffer when bufferOffset has advanced past half the used
1034
+ * region. copyWithin moves the unread bytes to the front in-place
1035
+ * (no new allocation). Amortized O(1) per byte; never the O(n²)
1036
+ * substring-each-line pathology that blocked the event loop on a
1037
+ * large LIST response. */
949
1038
  compactBufferIfNeeded() {
950
- if (this.bufferOffset > 0 && this.bufferOffset >= this.buffer.length / 2) {
951
- this.buffer = this.buffer.substring(this.bufferOffset);
1039
+ if (this.bufferOffset > 0 && this.bufferOffset >= this.bufferLength / 2) {
1040
+ this.buffer.copyWithin(0, this.bufferOffset, this.bufferLength);
1041
+ this.bufferLength -= this.bufferOffset;
952
1042
  this.bufferOffset = 0;
953
1043
  }
954
1044
  }
1045
+ /** Find the next CRLF (0x0D 0x0A) in the unread region, returning the
1046
+ * byte index of the 0x0D, or -1 if not found. Manual byte loop —
1047
+ * Uint8Array doesn't have a native indexOf for byte sequences. */
1048
+ indexOfCRLF(from) {
1049
+ const buf = this.buffer;
1050
+ const end = this.bufferLength - 1;
1051
+ for (let i = from; i < end; i++) {
1052
+ if (buf[i] === 0x0D && buf[i + 1] === 0x0A)
1053
+ return i;
1054
+ }
1055
+ return -1;
1056
+ }
955
1057
  processBuffer() {
956
1058
  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).
1059
+ // Check for literal — `{N}` announces N octets of arbitrary data
1060
+ // (message body, MIME part, attachment chunk). The buffer is
1061
+ // bytes; the check is a direct length compare and the slice is
1062
+ // O(N) over the literal's own size never O(buffer²).
961
1063
  if (this.pendingCommand?.literalBytes != null) {
962
1064
  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, "")}"`;
981
- }
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;
1065
+ const available = this.bufferLength - this.bufferOffset;
1066
+ if (available < neededBytes) {
1067
+ if (this.verbose && this.pendingCommand.literalBytes > 0) {
1068
+ console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${available}`);
991
1069
  }
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;
1070
+ break; // Wait for more data — cheap, no decode.
1071
+ }
1072
+ // Slice the literal bytes; advance the read cursor by N.
1073
+ // The bytes themselves stay in the underlying buffer for the
1074
+ // moment, but bufferOffset advances past them and the next
1075
+ // compactBufferIfNeeded will reclaim the space.
1076
+ const literalBytes = this.buffer.subarray(this.bufferOffset, this.bufferOffset + neededBytes);
1077
+ this.bufferOffset += neededBytes;
1078
+ // Decode literal once (UTF-8 with replacement). For BODY[]
1079
+ // literals carrying non-UTF-8 charsets this is still lossy —
1080
+ // the proper fix is to keep literals as bytes through to the
1081
+ // body store, tracked separately. The win HERE is that the
1082
+ // decode happens once per literal, not once per chunk that
1083
+ // arrives during the literal's transit.
1084
+ let literal = this.utf8Decoder.decode(literalBytes);
1085
+ // For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
1086
+ // so tokenizeParenList treats them as a single token
1087
+ if (!this.pendingCommand.currentLiteralKey) {
1088
+ literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
998
1089
  }
999
- if (this.verbose && this.pendingCommand.literalBytes > 0) {
1000
- console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${bufferBytes} bytes`);
1090
+ this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
1091
+ this.pendingCommand.literalBytes = undefined;
1092
+ // Store the literal data by its BODY key for parseFetchResponses
1093
+ if (this.pendingCommand.currentLiteralKey) {
1094
+ if (!this.pendingCommand.literals)
1095
+ this.pendingCommand.literals = new Map();
1096
+ this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
1097
+ this.pendingCommand.currentLiteralKey = undefined;
1098
+ this.pendingCommand.currentLiteralSize = undefined;
1001
1099
  }
1002
- break; // Wait for more data
1100
+ if (this.verbose)
1101
+ console.log(` [imap] literal consumed, ${neededBytes} bytes, buffer remaining: ${this.bufferLength - this.bufferOffset}`);
1102
+ // Allow the consumed-prefix to be reclaimed when half-full —
1103
+ // long literal-heavy responses would otherwise grow the
1104
+ // backing buffer indefinitely between commands.
1105
+ this.compactBufferIfNeeded();
1106
+ continue;
1003
1107
  }
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);
1108
+ // Find next CRLF starting from the read offset on bytes (not
1109
+ // chars). bufferOffset advances past consumed bytes; we only
1110
+ // search the unread region so per-line work is O(line length),
1111
+ // never O(response size).
1112
+ const lineEnd = this.indexOfCRLF(this.bufferOffset);
1008
1113
  if (lineEnd < 0) {
1009
1114
  // No complete line yet. Compact the buffer if we've advanced
1010
1115
  // far enough that the unconsumed-prefix is dragging.
1011
1116
  this.compactBufferIfNeeded();
1012
1117
  break;
1013
1118
  }
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);
1119
+ // Slice the line bytes (including trailing CRLF) and decode
1120
+ // once. IMAP control lines are 7-bit ASCII per RFC 3501; UTF-8
1121
+ // decode is a superset and produces the same characters. The
1122
+ // existing string-based parser (parseResponseLine, tokenizers,
1123
+ // ENVELOPE/FLAGS handling) consumes the decoded line below.
1124
+ const lineBytes = this.buffer.subarray(this.bufferOffset, lineEnd + 2);
1125
+ const line = this.utf8Decoder.decode(lineBytes);
1017
1126
  this.bufferOffset = lineEnd + 2;
1018
1127
  // Periodic compaction so the consumed prefix doesn't keep
1019
1128
  // growing forever in long-running connections (IDLE, big sync).
@@ -1075,6 +1184,26 @@ export class NativeImapClient {
1075
1184
  }
1076
1185
  continue;
1077
1186
  }
1187
+ // RFC 5465 NOTIFY: unsolicited STATUS responses for non-selected
1188
+ // mailboxes arrive when the server has accepted a NOTIFY SET that
1189
+ // included a PERSONAL (or other) event group. Only route to the
1190
+ // callback when IDLE is active — a STATUS response outside IDLE
1191
+ // is the result of a direct STATUS command and belongs to the
1192
+ // pending command's response set (the fall-through below).
1193
+ if (this.idleTag && resp.tag === "*" && resp.type === "STATUS" && this.onMailboxStatus) {
1194
+ const parsed = proto.parseStatusResponseFull(resp.text);
1195
+ if (parsed) {
1196
+ try {
1197
+ this.onMailboxStatus(parsed.mailbox, parsed.data);
1198
+ }
1199
+ catch (err) {
1200
+ if (this.verbose)
1201
+ console.error(` [imap] onMailboxStatus threw: ${err.message}`);
1202
+ }
1203
+ continue;
1204
+ }
1205
+ // Parse failed — fall through so the response isn't silently dropped.
1206
+ }
1078
1207
  // Collect untagged responses for the pending command
1079
1208
  if (resp.tag === "*" && this.pendingCommand) {
1080
1209
  this.pendingCommand.responses.push(resp);
@@ -95,6 +95,13 @@ export declare function moveCommand(tag: string, uid: number, destination: strin
95
95
  export declare function appendCommand(tag: string, mailbox: string, flags: string[], size: number): string;
96
96
  /** Build IDLE command */
97
97
  export declare function idleCommand(tag: string): string;
98
+ /** Build NOTIFY SET command (RFC 5465). `spec` is the full event group list,
99
+ * e.g. "(SELECTED (MessageNew MessageExpunge FlagChange)) (PERSONAL
100
+ * (MessageNew MessageExpunge FlagChange MailboxName))" — the server then
101
+ * pushes unsolicited STATUS responses for non-selected mailboxes in the
102
+ * spec while the connection is idle. Capability gated: only issue when
103
+ * CAPABILITY response contains NOTIFY. */
104
+ export declare function notifyCommand(tag: string, spec: string): string;
98
105
  /** Build DONE command (ends IDLE) */
99
106
  export declare function doneCommand(): string;
100
107
  /** Build STARTTLS command */
@@ -117,6 +124,14 @@ export declare function parseResponseLine(line: string): ImapResponse;
117
124
  export declare function parseListResponse(text: string): ListData | null;
118
125
  /** Parse STATUS response: * STATUS "mailbox" (MESSAGES n UIDNEXT n ...) */
119
126
  export declare function parseStatusResponse(text: string): StatusData | null;
127
+ /** Parse a STATUS response keeping the mailbox name. Used by NOTIFY's
128
+ * unsolicited STATUS pushes where the mailbox identifies which folder
129
+ * changed — `parseStatusResponse` discards that. Mailbox may be quoted
130
+ * ("Sent") or atom-shaped (Sent); the regex handles both. */
131
+ export declare function parseStatusResponseFull(text: string): {
132
+ mailbox: string;
133
+ data: StatusData;
134
+ } | null;
120
135
  /** Parse UID SEARCH response: * SEARCH 1 2 3 4 5 */
121
136
  export declare function parseSearchResponse(text: string): number[];
122
137
  /** Parse FLAGS from a FETCH or SELECT response */
package/imap-protocol.js CHANGED
@@ -79,6 +79,15 @@ export function appendCommand(tag, mailbox, flags, size) {
79
79
  export function idleCommand(tag) {
80
80
  return buildCommand(tag, "IDLE");
81
81
  }
82
+ /** Build NOTIFY SET command (RFC 5465). `spec` is the full event group list,
83
+ * e.g. "(SELECTED (MessageNew MessageExpunge FlagChange)) (PERSONAL
84
+ * (MessageNew MessageExpunge FlagChange MailboxName))" — the server then
85
+ * pushes unsolicited STATUS responses for non-selected mailboxes in the
86
+ * spec while the connection is idle. Capability gated: only issue when
87
+ * CAPABILITY response contains NOTIFY. */
88
+ export function notifyCommand(tag, spec) {
89
+ return buildCommand(tag, `NOTIFY SET ${spec}`);
90
+ }
82
91
  /** Build DONE command (ends IDLE) */
83
92
  export function doneCommand() {
84
93
  return "DONE\r\n";
@@ -182,6 +191,34 @@ export function parseStatusResponse(text) {
182
191
  }
183
192
  return data;
184
193
  }
194
+ /** Parse a STATUS response keeping the mailbox name. Used by NOTIFY's
195
+ * unsolicited STATUS pushes where the mailbox identifies which folder
196
+ * changed — `parseStatusResponse` discards that. Mailbox may be quoted
197
+ * ("Sent") or atom-shaped (Sent); the regex handles both. */
198
+ export function parseStatusResponseFull(text) {
199
+ // Match: "quoted name" (data) OR atomname (data)
200
+ const m = text.match(/^"((?:[^"\\]|\\.)*)"\s*\((.*)\)\s*$/) || text.match(/^(\S+)\s*\((.*)\)\s*$/);
201
+ if (!m)
202
+ return null;
203
+ const mailbox = m[1].replace(/\\(.)/g, "$1");
204
+ const data = {};
205
+ const pairs = m[2].split(/\s+/);
206
+ for (let i = 0; i < pairs.length - 1; i += 2) {
207
+ const key = pairs[i].toUpperCase();
208
+ const val = parseInt(pairs[i + 1]);
209
+ if (key === "MESSAGES")
210
+ data.messages = val;
211
+ else if (key === "RECENT")
212
+ data.recent = val;
213
+ else if (key === "UIDNEXT")
214
+ data.uidNext = val;
215
+ else if (key === "UIDVALIDITY")
216
+ data.uidValidity = val;
217
+ else if (key === "UNSEEN")
218
+ data.unseen = val;
219
+ }
220
+ return { mailbox, data };
221
+ }
185
222
  /** Parse UID SEARCH response: * SEARCH 1 2 3 4 5 */
186
223
  export function parseSearchResponse(text) {
187
224
  if (!text.trim())
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.40",
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
  }