@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 +19 -2
- package/imap-compat.js +29 -2
- package/imap-native.d.ts +68 -11
- package/imap-native.js +204 -75
- package/imap-protocol.d.ts +15 -0
- package/imap-protocol.js +37 -0
- package/package.json +3 -3
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
|
-
/**
|
|
100
|
-
|
|
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
|
-
/**
|
|
264
|
-
|
|
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
|
-
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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.
|
|
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
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
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.
|
|
951
|
-
this.buffer
|
|
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 {
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
|
|
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
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
|
|
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);
|
package/imap-protocol.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
53
|
+
"@bobfrankston/tcp-transport": "^0.1.6"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
}
|