@bobfrankston/iflow-direct 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/imap-native.js ADDED
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Native IMAP client — transport-agnostic.
3
+ * Uses ImapTransport for I/O, imap-protocol for parsing.
4
+ * Works with NodeTransport (desktop) or BridgeTransport (Android).
5
+ *
6
+ * This is a NEW client alongside the existing ImapClient (which wraps imapflow).
7
+ * Existing callers are not affected.
8
+ */
9
+ import * as proto from "./imap-protocol.js";
10
+ export class NativeImapClient {
11
+ transport;
12
+ transportFactory;
13
+ config;
14
+ buffer = "";
15
+ pendingCommand = null;
16
+ capabilities = new Set();
17
+ _connected = false;
18
+ idleTag = null;
19
+ idleCallback = null;
20
+ verbose;
21
+ selectedMailbox = null;
22
+ mailboxInfo = { exists: 0, recent: 0, uidNext: 0, uidValidity: 0, flags: [], permanentFlags: [] };
23
+ greetingResolve = null;
24
+ /** Callback for waitForContinuation — set when waiting for "+" response */
25
+ continuationResolve = null;
26
+ constructor(config, transportFactory) {
27
+ this.config = config;
28
+ this.transportFactory = transportFactory;
29
+ this.transport = transportFactory();
30
+ this.verbose = config.verbose || false;
31
+ }
32
+ get connected() { return this._connected; }
33
+ // ── Connection ──
34
+ async connect() {
35
+ const useTls = this.config.port === 993;
36
+ this.transport.onData((data) => this.handleData(data));
37
+ this.transport.onClose(() => {
38
+ this._connected = false;
39
+ // Reject any pending command so it doesn't hang forever
40
+ if (this.pendingCommand) {
41
+ const { reject } = this.pendingCommand;
42
+ this.pendingCommand = null;
43
+ reject(new Error("Connection closed"));
44
+ }
45
+ });
46
+ this.transport.onError((err) => {
47
+ if (this.verbose)
48
+ console.error(` [imap] Transport error: ${err.message}`);
49
+ // Reject any pending command on transport error
50
+ if (this.pendingCommand) {
51
+ const { reject } = this.pendingCommand;
52
+ this.pendingCommand = null;
53
+ reject(err);
54
+ }
55
+ });
56
+ await this.transport.connect(this.config.server, this.config.port, useTls, this.config.server);
57
+ // Read server greeting
58
+ const greeting = await this.readGreeting();
59
+ if (this.verbose)
60
+ console.log(` [imap] Greeting: ${greeting.raw}`);
61
+ // Parse capabilities from greeting
62
+ if (greeting.text.includes("CAPABILITY")) {
63
+ this.parseCapabilities(greeting.text);
64
+ }
65
+ else {
66
+ await this.capability();
67
+ }
68
+ // STARTTLS if needed
69
+ if (!useTls && this.capabilities.has("STARTTLS")) {
70
+ await this.starttls();
71
+ }
72
+ this._connected = true;
73
+ // Authenticate
74
+ await this.authenticate();
75
+ }
76
+ async readGreeting() {
77
+ return new Promise((resolve, reject) => {
78
+ const timeout = setTimeout(() => {
79
+ this.greetingResolve = null;
80
+ reject(new Error("Greeting timeout (10s)"));
81
+ }, 10000);
82
+ this.greetingResolve = (resp) => {
83
+ clearTimeout(timeout);
84
+ resolve(resp);
85
+ };
86
+ });
87
+ }
88
+ async authenticate() {
89
+ if (this.config.tokenProvider) {
90
+ const token = await this.config.tokenProvider();
91
+ const tag = proto.nextTag();
92
+ const cmd = proto.xoauth2Command(tag, this.config.username, token);
93
+ if (this.verbose)
94
+ console.log(` [imap] > AUTHENTICATE XOAUTH2 ...`);
95
+ const responses = await this.sendCommand(tag, cmd);
96
+ const tagged = responses.find(r => r.tag === tag);
97
+ if (!tagged || tagged.type !== "OK") {
98
+ const errText = tagged?.text || responses.map(r => r.raw).join("; ");
99
+ throw new Error(`Authentication failed: ${errText}`);
100
+ }
101
+ }
102
+ else if (this.config.password) {
103
+ const tag = proto.nextTag();
104
+ const cmd = proto.loginCommand(tag, this.config.username, this.config.password);
105
+ if (this.verbose)
106
+ console.log(` [imap] > LOGIN ${this.config.username} ***`);
107
+ const responses = await this.sendCommand(tag, cmd);
108
+ const tagged = responses.find(r => r.tag === tag);
109
+ if (!tagged || tagged.type !== "OK") {
110
+ const errText = tagged?.text || responses.map(r => r.raw).join("; ");
111
+ throw new Error(`Login failed: ${errText}`);
112
+ }
113
+ }
114
+ else {
115
+ throw new Error("No password or token provider configured");
116
+ }
117
+ // Re-read capabilities after auth (they may change)
118
+ await this.capability();
119
+ }
120
+ async starttls() {
121
+ const tag = proto.nextTag();
122
+ const responses = await this.sendCommand(tag, proto.starttlsCommand(tag));
123
+ const tagged = responses.find(r => r.tag === tag);
124
+ if (!tagged || tagged.type !== "OK")
125
+ throw new Error("STARTTLS failed");
126
+ await this.transport.upgradeTLS(this.config.server);
127
+ await this.capability();
128
+ }
129
+ async capability() {
130
+ const tag = proto.nextTag();
131
+ const responses = await this.sendCommand(tag, proto.capabilityCommand(tag));
132
+ for (const r of responses) {
133
+ if (r.tag === "*" && r.type === "CAPABILITY") {
134
+ this.parseCapabilities(r.text);
135
+ }
136
+ }
137
+ return this.capabilities;
138
+ }
139
+ parseCapabilities(text) {
140
+ const caps = text.replace(/^CAPABILITY\s*/i, "").split(/\s+/);
141
+ this.capabilities.clear();
142
+ for (const c of caps)
143
+ this.capabilities.add(c.toUpperCase());
144
+ }
145
+ async logout() {
146
+ // Force-close the transport immediately. The LOGOUT command is a courtesy —
147
+ // the server cleans up the session when the socket closes.
148
+ // Sending LOGOUT and waiting caused hangs and connection leaks.
149
+ this.pendingCommand = null;
150
+ this.transport.close();
151
+ this._connected = false;
152
+ }
153
+ // ── Mailbox Operations ──
154
+ async select(mailbox) {
155
+ const tag = proto.nextTag();
156
+ const responses = await this.sendCommand(tag, proto.selectCommand(tag, mailbox));
157
+ const tagged = responses.find(r => r.tag === tag);
158
+ if (!tagged || tagged.type !== "OK") {
159
+ throw new Error(`SELECT ${mailbox} failed: ${tagged?.text || "unknown"}`);
160
+ }
161
+ // Parse mailbox info from untagged responses
162
+ for (const r of responses) {
163
+ if (r.tag !== "*")
164
+ continue;
165
+ if (r.type === "EXISTS") {
166
+ this.mailboxInfo.exists = parseInt(r.text);
167
+ }
168
+ else if (r.type === "RECENT") {
169
+ this.mailboxInfo.recent = parseInt(r.text);
170
+ }
171
+ else if (r.type === "FLAGS") {
172
+ this.mailboxInfo.flags = [...proto.parseFlags(r.text)];
173
+ }
174
+ else if (r.type === "OK") {
175
+ const uidNextMatch = r.text.match(/UIDNEXT\s+(\d+)/i);
176
+ if (uidNextMatch)
177
+ this.mailboxInfo.uidNext = parseInt(uidNextMatch[1]);
178
+ const uidValMatch = r.text.match(/UIDVALIDITY\s+(\d+)/i);
179
+ if (uidValMatch)
180
+ this.mailboxInfo.uidValidity = parseInt(uidValMatch[1]);
181
+ const permMatch = r.text.match(/PERMANENTFLAGS\s+\(([^)]*)\)/i);
182
+ if (permMatch)
183
+ this.mailboxInfo.permanentFlags = permMatch[1].split(/\s+/).filter(Boolean);
184
+ }
185
+ }
186
+ this.selectedMailbox = mailbox;
187
+ return { ...this.mailboxInfo };
188
+ }
189
+ async examine(mailbox) {
190
+ const tag = proto.nextTag();
191
+ const responses = await this.sendCommand(tag, proto.examineCommand(tag, mailbox));
192
+ const tagged = responses.find(r => r.tag === tag);
193
+ if (!tagged || tagged.type !== "OK") {
194
+ throw new Error(`EXAMINE ${mailbox} failed: ${tagged?.text || "unknown"}`);
195
+ }
196
+ for (const r of responses) {
197
+ if (r.tag !== "*")
198
+ continue;
199
+ if (r.type === "EXISTS")
200
+ this.mailboxInfo.exists = parseInt(r.text);
201
+ else if (r.type === "RECENT")
202
+ this.mailboxInfo.recent = parseInt(r.text);
203
+ }
204
+ this.selectedMailbox = mailbox;
205
+ return { ...this.mailboxInfo };
206
+ }
207
+ /** Close the currently selected mailbox */
208
+ async closeMailbox() {
209
+ if (!this.selectedMailbox)
210
+ return;
211
+ const tag = proto.nextTag();
212
+ await this.sendCommand(tag, proto.buildCommand(tag, "CLOSE"));
213
+ this.selectedMailbox = null;
214
+ }
215
+ // ── Folder Operations ──
216
+ async listFolders() {
217
+ const tag = proto.nextTag();
218
+ const responses = await this.sendCommand(tag, proto.listCommand(tag));
219
+ const folders = [];
220
+ let unparsed = 0;
221
+ const listResponses = responses.filter(r => r.tag === "*" && r.type === "LIST");
222
+ if (listResponses.length === 0 && responses.length > 0) {
223
+ console.error(` [imap] LIST returned ${responses.length} responses but none were LIST type. Types: ${responses.map(r => `${r.tag}:${r.type}`).join(", ")}`);
224
+ if (responses.length <= 5) {
225
+ for (const r of responses)
226
+ console.error(` [imap] raw: ${JSON.stringify(r.text.substring(0, 200))}`);
227
+ }
228
+ }
229
+ for (const r of responses) {
230
+ if (r.tag === "*" && r.type === "LIST") {
231
+ const parsed = proto.parseListResponse(r.text);
232
+ if (parsed) {
233
+ folders.push(parsed);
234
+ }
235
+ else {
236
+ unparsed++;
237
+ if (unparsed <= 3)
238
+ console.error(` [imap] Unparsed LIST response: ${JSON.stringify(r.text.substring(0, 200))}`);
239
+ }
240
+ }
241
+ }
242
+ if (unparsed > 0)
243
+ console.error(` [imap] ${unparsed} LIST responses could not be parsed (${responses.length} total responses)`);
244
+ return folders;
245
+ }
246
+ async getStatus(mailbox) {
247
+ const tag = proto.nextTag();
248
+ const responses = await this.sendCommand(tag, proto.statusCommand(tag, mailbox, ["MESSAGES", "UIDNEXT", "UNSEEN"]));
249
+ for (const r of responses) {
250
+ if (r.tag === "*" && r.type === "STATUS") {
251
+ const data = proto.parseStatusResponse(r.text);
252
+ if (data)
253
+ return data;
254
+ }
255
+ }
256
+ return {};
257
+ }
258
+ async createMailbox(mailbox) {
259
+ const tag = proto.nextTag();
260
+ const responses = await this.sendCommand(tag, proto.createCommand(tag, mailbox));
261
+ const tagged = responses.find(r => r.tag === tag);
262
+ if (!tagged || tagged.type !== "OK")
263
+ throw new Error(`CREATE failed: ${tagged?.text || "unknown"}`);
264
+ }
265
+ async deleteMailbox(mailbox) {
266
+ const tag = proto.nextTag();
267
+ const responses = await this.sendCommand(tag, proto.deleteMailboxCommand(tag, mailbox));
268
+ const tagged = responses.find(r => r.tag === tag);
269
+ if (!tagged || tagged.type !== "OK")
270
+ throw new Error(`DELETE failed: ${tagged?.text || "unknown"}`);
271
+ }
272
+ async renameMailbox(from, to) {
273
+ const tag = proto.nextTag();
274
+ const responses = await this.sendCommand(tag, proto.renameCommand(tag, from, to));
275
+ const tagged = responses.find(r => r.tag === tag);
276
+ if (!tagged || tagged.type !== "OK")
277
+ throw new Error(`RENAME failed: ${tagged?.text || "unknown"}`);
278
+ }
279
+ // ── Message Operations ──
280
+ /** Fetch messages by UID range */
281
+ async fetchMessages(range, options = {}) {
282
+ const items = ["UID", "FLAGS", "ENVELOPE", "RFC822.SIZE", "INTERNALDATE"];
283
+ if (options.headers !== false)
284
+ items.push("BODY.PEEK[HEADER]");
285
+ if (options.source)
286
+ items.push("BODY.PEEK[]");
287
+ const tag = proto.nextTag();
288
+ const responses = await this.sendCommand(tag, proto.fetchCommand(tag, range, items));
289
+ return this.parseFetchResponses(responses);
290
+ }
291
+ /** Fetch messages since a UID */
292
+ async fetchSinceUid(sinceUid, options = {}, onChunk) {
293
+ const uids = await this.search(`UID ${sinceUid + 1}:*`);
294
+ if (uids.length === 0)
295
+ return [];
296
+ uids.reverse(); // Newest first
297
+ console.log(` [fetch] ${uids.length} UIDs since ${sinceUid} (newest first)`);
298
+ if (uids.length <= NativeImapClient.INITIAL_CHUNK_SIZE) {
299
+ const msgs = await this.fetchMessages(uids.join(","), options);
300
+ if (onChunk)
301
+ onChunk(msgs);
302
+ return msgs;
303
+ }
304
+ const allMessages = [];
305
+ let chunkSize = NativeImapClient.INITIAL_CHUNK_SIZE;
306
+ for (let i = 0; i < uids.length; i += chunkSize) {
307
+ const chunk = uids.slice(i, i + chunkSize);
308
+ const msgs = await this.fetchMessages(chunk.join(","), options);
309
+ allMessages.push(...msgs);
310
+ console.log(` [fetch] ${allMessages.length}/${uids.length} (chunk of ${chunk.length})`);
311
+ if (onChunk)
312
+ onChunk(msgs);
313
+ if (chunkSize < NativeImapClient.MAX_CHUNK_SIZE)
314
+ chunkSize = Math.min(chunkSize * 4, NativeImapClient.MAX_CHUNK_SIZE);
315
+ }
316
+ return allMessages;
317
+ }
318
+ /** Fetch messages by date range. Optional onChunk callback receives each batch as it arrives. */
319
+ async fetchByDate(since, before, options = {}, onChunk) {
320
+ const criteria = proto.buildSearchCriteria({
321
+ since,
322
+ before: before || undefined,
323
+ });
324
+ const uids = await this.search(criteria);
325
+ if (uids.length === 0)
326
+ return [];
327
+ // Reverse so newest messages (highest UIDs) come first
328
+ uids.reverse();
329
+ console.log(` [fetch] ${uids.length} UIDs to fetch (newest first)`);
330
+ const allMessages = [];
331
+ let chunkSize = NativeImapClient.INITIAL_CHUNK_SIZE;
332
+ for (let i = 0; i < uids.length; i += chunkSize) {
333
+ const chunk = uids.slice(i, i + chunkSize);
334
+ const msgs = await this.fetchMessages(chunk.join(","), options);
335
+ allMessages.push(...msgs);
336
+ console.log(` [fetch] ${allMessages.length}/${uids.length} (chunk of ${chunk.length})`);
337
+ if (onChunk)
338
+ onChunk(msgs);
339
+ if (chunkSize < NativeImapClient.MAX_CHUNK_SIZE)
340
+ chunkSize = Math.min(chunkSize * 4, NativeImapClient.MAX_CHUNK_SIZE);
341
+ }
342
+ return allMessages;
343
+ }
344
+ /** Fetch a single message by UID */
345
+ async fetchMessage(uid, options = {}) {
346
+ const msgs = await this.fetchMessages(String(uid), options);
347
+ return msgs.find(m => m.uid === uid) || null;
348
+ }
349
+ /** Get all UIDs in the current mailbox */
350
+ async getUids() {
351
+ return this.search("ALL");
352
+ }
353
+ /** UID SEARCH */
354
+ async search(criteria) {
355
+ const tag = proto.nextTag();
356
+ const responses = await this.sendCommand(tag, proto.searchCommand(tag, criteria));
357
+ for (const r of responses) {
358
+ if (r.tag === "*" && r.type === "SEARCH") {
359
+ return proto.parseSearchResponse(r.text);
360
+ }
361
+ }
362
+ return [];
363
+ }
364
+ /** Set flags on a message */
365
+ async addFlags(uid, flags) {
366
+ const tag = proto.nextTag();
367
+ const responses = await this.sendCommand(tag, proto.storeCommand(tag, uid, "+FLAGS.SILENT", flags));
368
+ const tagged = responses.find(r => r.tag === tag);
369
+ if (!tagged || tagged.type !== "OK")
370
+ throw new Error(`STORE +FLAGS failed: ${tagged?.text || "unknown"}`);
371
+ }
372
+ /** Remove flags from a message */
373
+ async removeFlags(uid, flags) {
374
+ const tag = proto.nextTag();
375
+ const responses = await this.sendCommand(tag, proto.storeCommand(tag, uid, "-FLAGS.SILENT", flags));
376
+ const tagged = responses.find(r => r.tag === tag);
377
+ if (!tagged || tagged.type !== "OK")
378
+ throw new Error(`STORE -FLAGS failed: ${tagged?.text || "unknown"}`);
379
+ }
380
+ /** Copy a message to another mailbox */
381
+ async copyMessage(uid, destination) {
382
+ const tag = proto.nextTag();
383
+ const responses = await this.sendCommand(tag, proto.copyCommand(tag, uid, destination));
384
+ const tagged = responses.find(r => r.tag === tag);
385
+ if (!tagged || tagged.type !== "OK")
386
+ throw new Error(`COPY failed: ${tagged?.text || "unknown"}`);
387
+ }
388
+ /** Move a message to another mailbox (MOVE or COPY+DELETE) */
389
+ async moveMessage(uid, destination) {
390
+ if (this.capabilities.has("MOVE")) {
391
+ const tag = proto.nextTag();
392
+ const responses = await this.sendCommand(tag, proto.moveCommand(tag, uid, destination));
393
+ const tagged = responses.find(r => r.tag === tag);
394
+ if (!tagged || tagged.type !== "OK")
395
+ throw new Error(`MOVE failed: ${tagged?.text || "unknown"}`);
396
+ }
397
+ else {
398
+ await this.copyMessage(uid, destination);
399
+ await this.addFlags(uid, ["\\Deleted"]);
400
+ await this.expunge();
401
+ }
402
+ }
403
+ /** Delete a message by UID (flag + expunge) */
404
+ async deleteMessage(uid) {
405
+ await this.addFlags(uid, ["\\Deleted"]);
406
+ await this.expunge();
407
+ }
408
+ /** Expunge deleted messages */
409
+ async expunge() {
410
+ const tag = proto.nextTag();
411
+ await this.sendCommand(tag, proto.buildCommand(tag, "EXPUNGE"));
412
+ }
413
+ /** Append a message to a mailbox */
414
+ async appendMessage(mailbox, message, flags = []) {
415
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
416
+ const size = new TextEncoder().encode(data).length;
417
+ const tag = proto.nextTag();
418
+ const cmd = proto.appendCommand(tag, mailbox, flags, size);
419
+ // Send command, wait for continuation
420
+ await this.transport.write(cmd);
421
+ // Wait for "+" continuation
422
+ const contResp = await this.waitForContinuation(tag);
423
+ if (!contResp)
424
+ throw new Error("APPEND: server did not send continuation");
425
+ // Send the message data + CRLF
426
+ await this.transport.write(data + "\r\n");
427
+ // Wait for tagged response
428
+ const responses = await this.waitForTagged(tag);
429
+ const tagged = responses.find(r => r.tag === tag);
430
+ if (!tagged || tagged.type !== "OK")
431
+ throw new Error(`APPEND failed: ${tagged?.text || "unknown"}`);
432
+ // Try to extract APPENDUID
433
+ const uidMatch = tagged.text.match(/APPENDUID\s+\d+\s+(\d+)/i);
434
+ return uidMatch ? parseInt(uidMatch[1]) : null;
435
+ }
436
+ // ── IDLE ──
437
+ async startIdle(onNewMail) {
438
+ this.idleCallback = onNewMail;
439
+ const tag = proto.nextTag();
440
+ this.idleTag = tag;
441
+ await this.transport.write(proto.idleCommand(tag));
442
+ // Wait for "+" continuation
443
+ await this.waitForContinuation(tag);
444
+ return async () => {
445
+ this.idleTag = null;
446
+ this.idleCallback = null;
447
+ await this.transport.write(proto.doneCommand());
448
+ await this.waitForTagged(tag);
449
+ };
450
+ }
451
+ // ── Message count (lightweight STATUS) ──
452
+ async getMessageCount(mailbox) {
453
+ const status = await this.getStatus(mailbox);
454
+ return status.messages || 0;
455
+ }
456
+ // ── Low-level command handling ──
457
+ /** Inactivity timeout — how long to wait with NO data before declaring the connection dead.
458
+ * This is NOT a wall-clock timeout. Timer resets every time data arrives from the server.
459
+ * A large FETCH returning data continuously will never timeout. */
460
+ inactivityTimeout = 30000;
461
+ /** Fetch chunk sizes — start small for quick first paint, ramp up for throughput */
462
+ static INITIAL_CHUNK_SIZE = 25;
463
+ static MAX_CHUNK_SIZE = 500;
464
+ /** Active command timer — reset by handleData on every data arrival */
465
+ commandTimer = null;
466
+ sendCommand(tag, command) {
467
+ return new Promise((resolve, reject) => {
468
+ if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
469
+ console.log(` [imap] > ${command.trimEnd()}`);
470
+ }
471
+ const onTimeout = () => {
472
+ this.commandTimer = null;
473
+ this.pendingCommand = null;
474
+ // Kill the connection — a timed-out connection has stale data in the pipe
475
+ this.transport.close?.();
476
+ reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}`));
477
+ };
478
+ this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
479
+ this.pendingCommand = {
480
+ tag, responses: [],
481
+ resolve: (responses) => { if (this.commandTimer)
482
+ clearTimeout(this.commandTimer); this.commandTimer = null; resolve(responses); },
483
+ reject: (err) => { if (this.commandTimer)
484
+ clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
485
+ };
486
+ this.transport.write(command).catch((err) => { if (this.commandTimer)
487
+ clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); });
488
+ });
489
+ }
490
+ waitForContinuation(tag) {
491
+ return new Promise((resolve) => {
492
+ const timeout = setTimeout(() => {
493
+ this.continuationResolve = null;
494
+ resolve(null);
495
+ }, 30000);
496
+ this.continuationResolve = (resp) => {
497
+ clearTimeout(timeout);
498
+ this.continuationResolve = null;
499
+ resolve(resp);
500
+ };
501
+ });
502
+ }
503
+ waitForTagged(tag) {
504
+ return new Promise((resolve, reject) => {
505
+ this.pendingCommand = { tag, resolve, reject, responses: [] };
506
+ });
507
+ }
508
+ handleData(data) {
509
+ // Reset inactivity timer — data is flowing, connection is alive
510
+ if (this.commandTimer) {
511
+ clearTimeout(this.commandTimer);
512
+ this.commandTimer = setTimeout(() => {
513
+ this.commandTimer = null;
514
+ if (this.pendingCommand) {
515
+ const cmd = this.pendingCommand;
516
+ this.pendingCommand = null;
517
+ this.transport.close?.();
518
+ cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
519
+ }
520
+ }, this.inactivityTimeout);
521
+ }
522
+ this.buffer += data;
523
+ this.processBuffer();
524
+ }
525
+ processBuffer() {
526
+ while (true) {
527
+ // Check for literal {size}\r\n — reading exact BYTE count of literal data
528
+ // CRITICAL: literalBytes is in octets (from IMAP {N}), but this.buffer is a
529
+ // JavaScript string where multi-byte UTF-8 characters count as 1 character.
530
+ // We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
531
+ if (this.pendingCommand?.literalBytes != null) {
532
+ const neededBytes = this.pendingCommand.literalBytes;
533
+ const encoded = new TextEncoder().encode(this.buffer);
534
+ const bufferBytes = encoded.byteLength;
535
+ if (bufferBytes >= neededBytes) {
536
+ const literal_bytes = encoded.subarray(0, neededBytes);
537
+ const rest_bytes = encoded.subarray(neededBytes);
538
+ let literal = new TextDecoder().decode(literal_bytes);
539
+ this.buffer = new TextDecoder().decode(rest_bytes);
540
+ // For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
541
+ // so tokenizeParenList treats them as a single token
542
+ if (!this.pendingCommand.currentLiteralKey) {
543
+ literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
544
+ }
545
+ this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
546
+ this.pendingCommand.literalBytes = undefined;
547
+ // Store the literal data by its BODY key for parseFetchResponses
548
+ if (this.pendingCommand.currentLiteralKey) {
549
+ if (!this.pendingCommand.literals)
550
+ this.pendingCommand.literals = new Map();
551
+ this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
552
+ this.pendingCommand.currentLiteralKey = undefined;
553
+ this.pendingCommand.currentLiteralSize = undefined;
554
+ }
555
+ if (this.verbose)
556
+ console.log(` [imap] literal consumed, ${literal.length} bytes, buffer remaining: ${this.buffer.length}`);
557
+ // After consuming literal, check if the NEXT part has another literal
558
+ // (e.g., BODY[HEADER] {500}\r\n<data>BODY[] {2000}\r\n<data>)\r\n)
559
+ // Continue to process the next line/literal from the buffer
560
+ continue;
561
+ }
562
+ if (this.verbose && this.pendingCommand.literalBytes > 0) {
563
+ console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${bufferBytes} bytes`);
564
+ }
565
+ break; // Wait for more data
566
+ }
567
+ const lineEnd = this.buffer.indexOf("\r\n");
568
+ if (lineEnd < 0)
569
+ break;
570
+ const line = this.buffer.substring(0, lineEnd + 2);
571
+ this.buffer = this.buffer.substring(lineEnd + 2);
572
+ // Check for literal announcement {size}\r\n at end of line
573
+ const literalMatch = line.match(/\{(\d+)\}\r\n$/);
574
+ if (literalMatch && this.pendingCommand) {
575
+ const size = parseInt(literalMatch[1]);
576
+ // Store the line prefix before {size} — will be prepended after literal is consumed
577
+ const linePrefix = line.substring(0, literalMatch.index);
578
+ if (this.pendingCommand.literalBuffer) {
579
+ // Already have buffered data from a previous literal — append this prefix
580
+ this.pendingCommand.literalBuffer += linePrefix;
581
+ }
582
+ else {
583
+ this.pendingCommand.literalBuffer = linePrefix;
584
+ }
585
+ this.pendingCommand.literalBytes = size;
586
+ // Extract the BODY section key from the prefix (e.g. BODY[], BODY[HEADER], BODY.PEEK[])
587
+ const keyMatch = linePrefix.match(/BODY(?:\.PEEK)?\[([^\]]*)\]\s*$/i);
588
+ if (keyMatch) {
589
+ this.pendingCommand.currentLiteralKey = `BODY[${keyMatch[1]}]`;
590
+ this.pendingCommand.currentLiteralSize = size;
591
+ }
592
+ if (this.verbose)
593
+ console.log(` [imap] literal announced: ${size} bytes, prefix: ${linePrefix.length} chars, key: ${this.pendingCommand.currentLiteralKey || "none"}`);
594
+ continue;
595
+ }
596
+ // If we have buffered literal data, prepend it to this line (the closing part after the literal)
597
+ let fullLine = line;
598
+ if (this.pendingCommand?.literalBuffer) {
599
+ fullLine = this.pendingCommand.literalBuffer + line;
600
+ this.pendingCommand.literalBuffer = undefined;
601
+ }
602
+ const resp = proto.parseResponseLine(fullLine);
603
+ // Attach accumulated literals to the response and reset for next response
604
+ if (this.pendingCommand?.literals?.size) {
605
+ resp.literals = this.pendingCommand.literals;
606
+ this.pendingCommand.literals = undefined;
607
+ }
608
+ if (this.verbose) {
609
+ const display = resp.raw.length > 200 ? resp.raw.substring(0, 200) + `... (${resp.raw.length} bytes)` : resp.raw;
610
+ console.log(` [imap] < [tag=${resp.tag} type=${resp.type}] ${display}`);
611
+ }
612
+ // Server greeting — resolve readGreeting() promise
613
+ if (this.greetingResolve && resp.tag === "*" && (resp.type === "OK" || resp.type === "PREAUTH")) {
614
+ const resolve = this.greetingResolve;
615
+ this.greetingResolve = null;
616
+ resolve(resp);
617
+ continue;
618
+ }
619
+ // During IDLE, handle EXISTS notifications
620
+ if (this.idleTag && resp.tag === "*" && resp.type === "EXISTS") {
621
+ const count = parseInt(resp.text);
622
+ if (this.idleCallback && count > this.mailboxInfo.exists) {
623
+ const newCount = count - this.mailboxInfo.exists;
624
+ this.mailboxInfo.exists = count;
625
+ this.idleCallback(newCount);
626
+ }
627
+ continue;
628
+ }
629
+ // Collect untagged responses for the pending command
630
+ if (resp.tag === "*" && this.pendingCommand) {
631
+ this.pendingCommand.responses.push(resp);
632
+ continue;
633
+ }
634
+ // Continuation response
635
+ if (resp.tag === "+") {
636
+ if (this.continuationResolve) {
637
+ // APPEND or other command waiting for continuation
638
+ this.continuationResolve(resp);
639
+ }
640
+ else if (!this.idleTag && this.pendingCommand) {
641
+ // Unexpected continuation (e.g. AUTHENTICATE challenge) — cancel
642
+ this.transport.write("\r\n").catch(() => { });
643
+ }
644
+ continue;
645
+ }
646
+ // Tagged response — command complete
647
+ if (this.pendingCommand && resp.tag === this.pendingCommand.tag) {
648
+ this.pendingCommand.responses.push(resp);
649
+ const { resolve, responses } = this.pendingCommand;
650
+ this.pendingCommand = null;
651
+ resolve(responses);
652
+ continue;
653
+ }
654
+ // Unhandled
655
+ this.handleUntaggedResponse(resp);
656
+ }
657
+ }
658
+ handleUntaggedResponse(resp) {
659
+ if (resp.type === "EXISTS") {
660
+ this.mailboxInfo.exists = parseInt(resp.text);
661
+ }
662
+ else if (resp.type === "EXPUNGE") {
663
+ this.mailboxInfo.exists = Math.max(0, this.mailboxInfo.exists - 1);
664
+ }
665
+ }
666
+ // ── FETCH Response Parser ──
667
+ parseFetchResponses(responses) {
668
+ const messages = [];
669
+ for (const r of responses) {
670
+ if (r.tag !== "*" || r.type !== "FETCH")
671
+ continue;
672
+ const msg = {
673
+ seq: 0, uid: 0, flags: new Set(), date: null,
674
+ subject: "", messageId: "", from: [], to: [], cc: [], bcc: [],
675
+ sender: [], replyTo: [], inReplyTo: "", size: 0,
676
+ source: "", headers: "", seen: false, flagged: false,
677
+ answered: false, draft: false,
678
+ };
679
+ // Extract sequence number from "5 FETCH (...)"
680
+ const seqMatch = r.text.match(/^(\d+)\s+FETCH/);
681
+ if (seqMatch)
682
+ msg.seq = parseInt(seqMatch[1]);
683
+ // Extract UID
684
+ const uidMatch = r.text.match(/UID\s+(\d+)/);
685
+ if (uidMatch)
686
+ msg.uid = parseInt(uidMatch[1]);
687
+ // Extract FLAGS
688
+ const flagsMatch = r.text.match(/FLAGS\s+\(([^)]*)\)/);
689
+ if (flagsMatch) {
690
+ msg.flags = new Set(flagsMatch[1].split(/\s+/).filter(Boolean));
691
+ msg.seen = msg.flags.has("\\Seen");
692
+ msg.flagged = msg.flags.has("\\Flagged");
693
+ msg.answered = msg.flags.has("\\Answered");
694
+ msg.draft = msg.flags.has("\\Draft");
695
+ }
696
+ // Extract RFC822.SIZE
697
+ const sizeMatch = r.text.match(/RFC822\.SIZE\s+(\d+)/);
698
+ if (sizeMatch)
699
+ msg.size = parseInt(sizeMatch[1]);
700
+ // Extract INTERNALDATE
701
+ const dateMatch = r.text.match(/INTERNALDATE\s+"([^"]+)"/);
702
+ if (dateMatch)
703
+ msg.date = new Date(dateMatch[1]);
704
+ // Extract ENVELOPE
705
+ const envMatch = r.text.match(/ENVELOPE\s+(\(.*\))/);
706
+ if (envMatch) {
707
+ const env = proto.parseEnvelope(envMatch[1]);
708
+ msg.date = msg.date || env.date;
709
+ msg.subject = env.subject;
710
+ msg.messageId = env.messageId;
711
+ msg.from = env.from;
712
+ msg.to = env.to;
713
+ msg.cc = env.cc;
714
+ msg.bcc = env.bcc;
715
+ msg.sender = env.sender;
716
+ msg.replyTo = env.replyTo;
717
+ msg.inReplyTo = env.inReplyTo;
718
+ }
719
+ // Extract body source and headers from literals tracked by processBuffer
720
+ if (r.literals) {
721
+ const source = r.literals.get("BODY[]");
722
+ if (source)
723
+ msg.source = source;
724
+ const headers = r.literals.get("BODY[HEADER]");
725
+ if (headers)
726
+ msg.headers = headers;
727
+ }
728
+ messages.push(msg);
729
+ }
730
+ return messages;
731
+ }
732
+ }
733
+ //# sourceMappingURL=imap-native.js.map