@bobfrankston/iflow-direct 0.1.17 → 0.1.19

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/README.md CHANGED
@@ -99,7 +99,22 @@ would otherwise issue one SELECT+FETCH per message.
99
99
  `appendMessage`, `startIdle`, `logout`. See `imap-native.ts` for signatures.
100
100
 
101
101
  IDLE auto-suspends when any other command is issued on the same connection and
102
- re-enters afterwards — safe to interleave commands with a long-running IDLE.
102
+ re-enters afterwards — safe to interleave commands with a long-running IDLE,
103
+ including commands issued from the IDLE `onNewMail` callback itself (e.g. a
104
+ mailpuller-style `pullMail` that reacts to EXISTS by running STATUS + FETCH on
105
+ the same connection). Suspend sends DONE, awaits the tagged OK, runs the
106
+ command, then re-enters IDLE. If the caller stops IDLE (via the `startIdle`
107
+ stop function) during the command, IDLE is not re-entered.
108
+
109
+ ## Connection liveness
110
+
111
+ `NativeImapClient.connected` (and `CompatImapClient`'s lazy `ensureConnected`
112
+ path, which now delegates to it) is authoritative: it gets cleared on transport
113
+ error, transport close, inactivity-timeout socket kill, and write failure. Any
114
+ of those paths also drops IDLE state so auto-suspend doesn't try to DONE a dead
115
+ socket. Callers that detect connection-style errors (e.g. DNS "hostname not
116
+ known" from a dead Android socket) can safely retry — the next operation will
117
+ reconnect from scratch rather than reusing the dead socket.
103
118
 
104
119
  ## Configuration
105
120
 
package/imap-compat.d.ts CHANGED
@@ -23,11 +23,15 @@ export interface SpecialFolders {
23
23
  */
24
24
  export declare class CompatImapClient {
25
25
  private native;
26
- private _connected;
27
26
  constructor(config: ImapClientConfig, transportFactory: TransportFactory);
28
27
  /** Connect and authenticate */
29
28
  connect(): Promise<void>;
30
- /** Ensure connected (lazy connect) */
29
+ /**
30
+ * Ensure connected (lazy connect). Delegates to the native client's
31
+ * `connected` flag, which is cleared by transport close/error, inactivity
32
+ * timeout, and write failures — so a silently dead socket reconnects on the
33
+ * next call instead of cascading ENOTFOUND across every folder.
34
+ */
31
35
  private ensureConnected;
32
36
  logout(): Promise<void>;
33
37
  /** Get folder list */
package/imap-compat.js CHANGED
@@ -11,22 +11,24 @@ import { FetchedMessage } from "./fetched-message.js";
11
11
  */
12
12
  export class CompatImapClient {
13
13
  native;
14
- _connected = false;
15
14
  constructor(config, transportFactory) {
16
15
  this.native = new NativeImapClient(config, transportFactory);
17
16
  }
18
17
  /** Connect and authenticate */
19
18
  async connect() {
20
19
  await this.native.connect();
21
- this._connected = true;
22
20
  }
23
- /** Ensure connected (lazy connect) */
21
+ /**
22
+ * Ensure connected (lazy connect). Delegates to the native client's
23
+ * `connected` flag, which is cleared by transport close/error, inactivity
24
+ * timeout, and write failures — so a silently dead socket reconnects on the
25
+ * next call instead of cascading ENOTFOUND across every folder.
26
+ */
24
27
  async ensureConnected() {
25
- if (!this._connected)
28
+ if (!this.native.connected)
26
29
  await this.connect();
27
30
  }
28
31
  async logout() {
29
- this._connected = false;
30
32
  await this.native.logout();
31
33
  }
32
34
  /** Get folder list */
package/imap-native.d.ts CHANGED
@@ -55,6 +55,8 @@ export declare class NativeImapClient {
55
55
  private idleTag;
56
56
  private idleCallback;
57
57
  private idleRefreshTimer;
58
+ /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
59
+ private idleStopped;
58
60
  private verbose;
59
61
  private selectedMailbox;
60
62
  private mailboxInfo;
@@ -135,6 +137,19 @@ export declare class NativeImapClient {
135
137
  /** Append a message to a mailbox */
136
138
  appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise<number | null>;
137
139
  startIdle(onNewMail: (count: number) => void): Promise<() => Promise<void>>;
140
+ /**
141
+ * If IDLE is currently active, send DONE and wait for its tagged OK so the
142
+ * connection is free to accept a new command. Saves the active callback so
143
+ * the companion `resumeIdleAfterCommand` can re-enter IDLE on the same
144
+ * mailbox afterwards. Returns the saved state, or null if IDLE was not active.
145
+ *
146
+ * Called automatically at the top of `sendCommand` whenever the caller issues
147
+ * an interleaved command on an IDLE-holding connection (e.g. pullMail reacting
148
+ * to an EXISTS notification).
149
+ */
150
+ private suspendIdleForCommand;
151
+ /** Re-enter IDLE with the saved callback after an auto-suspended command completes. */
152
+ private resumeIdleAfterCommand;
138
153
  /** Send DONE + re-IDLE. Called by the 28-minute refresh timer. */
139
154
  private refreshIdle;
140
155
  getMessageCount(mailbox: string): Promise<number>;
@@ -151,7 +166,17 @@ export declare class NativeImapClient {
151
166
  private fetchChunkSizeMax;
152
167
  /** Active command timer — reset by handleData on every data arrival */
153
168
  private commandTimer;
169
+ /**
170
+ * Issue an IMAP command and await its tagged response.
171
+ *
172
+ * If IDLE is currently active on this connection, DONE it first (and wait
173
+ * for its tagged OK) before writing the new command; re-enter IDLE after
174
+ * the command finishes. Without this, commands issued from within an IDLE
175
+ * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
176
+ * server until the inactivity timer killed the socket.
177
+ */
154
178
  private sendCommand;
179
+ private sendCommandCore;
155
180
  private waitForContinuation;
156
181
  private waitForTagged;
157
182
  private handleData;
package/imap-native.js CHANGED
@@ -18,6 +18,8 @@ export class NativeImapClient {
18
18
  idleTag = null;
19
19
  idleCallback = null;
20
20
  idleRefreshTimer = null;
21
+ /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */
22
+ idleStopped = true;
21
23
  verbose;
22
24
  selectedMailbox = null;
23
25
  mailboxInfo = { exists: 0, recent: 0, uidNext: 0, uidValidity: 0, flags: [], permanentFlags: [] };
@@ -40,7 +42,14 @@ export class NativeImapClient {
40
42
  this.transport.onData((data) => this.handleData(data));
41
43
  this.transport.onClose(() => {
42
44
  this._connected = false;
43
- // Reject any pending command so it doesn't hang forever
45
+ // Drop stale IDLE state the socket is gone.
46
+ this.idleTag = null;
47
+ this.idleCallback = null;
48
+ this.idleStopped = true;
49
+ if (this.idleRefreshTimer) {
50
+ clearTimeout(this.idleRefreshTimer);
51
+ this.idleRefreshTimer = null;
52
+ }
44
53
  if (this.pendingCommand) {
45
54
  const { reject } = this.pendingCommand;
46
55
  this.pendingCommand = null;
@@ -50,7 +59,18 @@ export class NativeImapClient {
50
59
  this.transport.onError((err) => {
51
60
  if (this.verbose)
52
61
  console.error(` [imap] Transport error: ${err.message}`);
53
- // Reject any pending command on transport error
62
+ // Transport errors (DNS failure, ECONNRESET, TLS failure, etc.) mean
63
+ // the connection is dead — clear the flag so ensureConnected() will
64
+ // reconnect on the next call, and drop the stale IDLE state so we
65
+ // don't try to DONE a dead socket during auto-suspend.
66
+ this._connected = false;
67
+ this.idleTag = null;
68
+ this.idleCallback = null;
69
+ this.idleStopped = true;
70
+ if (this.idleRefreshTimer) {
71
+ clearTimeout(this.idleRefreshTimer);
72
+ this.idleRefreshTimer = null;
73
+ }
54
74
  if (this.pendingCommand) {
55
75
  const { reject } = this.pendingCommand;
56
76
  this.pendingCommand = null;
@@ -480,17 +500,20 @@ export class NativeImapClient {
480
500
  // ── IDLE ──
481
501
  async startIdle(onNewMail) {
482
502
  this.idleCallback = onNewMail;
483
- let stopped = false;
503
+ this.idleStopped = false;
484
504
  const beginIdleCycle = async () => {
485
505
  const tag = proto.nextTag();
486
506
  this.idleTag = tag;
507
+ // Arm continuation listener BEFORE writing — some servers reply fast enough
508
+ // that data can arrive in the same tick.
509
+ const waitCont = this.waitForContinuation(tag);
487
510
  await this.transport.write(proto.idleCommand(tag));
488
- await this.waitForContinuation(tag);
511
+ await waitCont;
489
512
  // RFC 2177: re-IDLE every ~28 minutes to avoid servers that terminate
490
513
  // idle sessions at the 29-minute mark, and to give us a steady
491
514
  // application-level keepalive that detects silently-dropped sockets.
492
515
  this.idleRefreshTimer = setTimeout(() => {
493
- if (stopped)
516
+ if (this.idleStopped)
494
517
  return;
495
518
  this.refreshIdle().catch(err => {
496
519
  if (this.verbose)
@@ -500,7 +523,7 @@ export class NativeImapClient {
500
523
  };
501
524
  await beginIdleCycle();
502
525
  return async () => {
503
- stopped = true;
526
+ this.idleStopped = true;
504
527
  if (this.idleRefreshTimer) {
505
528
  clearTimeout(this.idleRefreshTimer);
506
529
  this.idleRefreshTimer = null;
@@ -508,40 +531,108 @@ export class NativeImapClient {
508
531
  const tag = this.idleTag;
509
532
  this.idleTag = null;
510
533
  this.idleCallback = null;
511
- try {
512
- await this.transport.write(proto.doneCommand());
513
- }
514
- catch { /* ignore */ }
515
534
  if (tag) {
535
+ // Arm tagged-wait BEFORE DONE — avoid missing the OK.
536
+ const waitTag = this.waitForTagged(tag);
516
537
  try {
517
- await this.waitForTagged(tag);
538
+ await this.transport.write(proto.doneCommand());
539
+ }
540
+ catch { /* ignore */ }
541
+ try {
542
+ await waitTag;
518
543
  }
519
544
  catch { /* ignore */ }
520
545
  }
521
546
  };
522
547
  }
548
+ /**
549
+ * If IDLE is currently active, send DONE and wait for its tagged OK so the
550
+ * connection is free to accept a new command. Saves the active callback so
551
+ * the companion `resumeIdleAfterCommand` can re-enter IDLE on the same
552
+ * mailbox afterwards. Returns the saved state, or null if IDLE was not active.
553
+ *
554
+ * Called automatically at the top of `sendCommand` whenever the caller issues
555
+ * an interleaved command on an IDLE-holding connection (e.g. pullMail reacting
556
+ * to an EXISTS notification).
557
+ */
558
+ async suspendIdleForCommand() {
559
+ const oldTag = this.idleTag;
560
+ const cb = this.idleCallback;
561
+ if (!oldTag || !cb)
562
+ return null;
563
+ if (this.idleRefreshTimer) {
564
+ clearTimeout(this.idleRefreshTimer);
565
+ this.idleRefreshTimer = null;
566
+ }
567
+ this.idleTag = null;
568
+ this.idleCallback = null;
569
+ if (this.verbose)
570
+ console.log(` [imap] IDLE auto-suspending for interleaved command`);
571
+ // Arm tagged listener BEFORE DONE so we don't miss the OK if the server is fast.
572
+ const waitTag = this.waitForTagged(oldTag);
573
+ try {
574
+ await this.transport.write(proto.doneCommand());
575
+ }
576
+ catch (err) {
577
+ // Write failure — pendingCommand is still set by waitForTagged; clear it to prevent hang.
578
+ this.pendingCommand = null;
579
+ throw err;
580
+ }
581
+ try {
582
+ await waitTag;
583
+ }
584
+ catch { /* best-effort — proceed even if OK was lost */ }
585
+ return { callback: cb };
586
+ }
587
+ /** Re-enter IDLE with the saved callback after an auto-suspended command completes. */
588
+ async resumeIdleAfterCommand(info) {
589
+ // The caller may have stopped IDLE during the command window — respect that.
590
+ if (this.idleStopped)
591
+ return;
592
+ const newTag = proto.nextTag();
593
+ this.idleTag = newTag;
594
+ this.idleCallback = info.callback;
595
+ // Arm continuation listener BEFORE writing IDLE.
596
+ const waitCont = this.waitForContinuation(newTag);
597
+ await this.transport.write(proto.idleCommand(newTag));
598
+ await waitCont;
599
+ if (this.verbose)
600
+ console.log(` [imap] IDLE auto-resumed (${newTag})`);
601
+ this.idleRefreshTimer = setTimeout(() => {
602
+ if (this.idleStopped)
603
+ return;
604
+ this.refreshIdle().catch(err => {
605
+ if (this.verbose)
606
+ console.error(` [imap] IDLE refresh failed: ${err.message}`);
607
+ });
608
+ }, 28 * 60 * 1000);
609
+ }
523
610
  /** Send DONE + re-IDLE. Called by the 28-minute refresh timer. */
524
611
  async refreshIdle() {
525
612
  const oldTag = this.idleTag;
526
613
  if (!oldTag || !this.idleCallback)
527
614
  return;
528
615
  const cb = this.idleCallback;
529
- // DONE the current IDLE
616
+ // Arm tagged listener BEFORE writing DONE to avoid missing a fast OK.
617
+ const waitTag = this.waitForTagged(oldTag);
530
618
  await this.transport.write(proto.doneCommand());
531
619
  try {
532
- await this.waitForTagged(oldTag);
620
+ await waitTag;
533
621
  }
534
622
  catch { /* best-effort */ }
535
623
  // Re-IDLE with a new tag
536
624
  this.idleCallback = cb; // waitForTagged resolved pendingCommand, reinstall callback
537
625
  const newTag = proto.nextTag();
538
626
  this.idleTag = newTag;
627
+ const waitCont = this.waitForContinuation(newTag);
539
628
  await this.transport.write(proto.idleCommand(newTag));
540
- await this.waitForContinuation(newTag);
629
+ await waitCont;
541
630
  if (this.verbose)
542
631
  console.log(` [imap] IDLE refreshed (${newTag})`);
543
632
  // Schedule the next refresh
544
633
  this.idleRefreshTimer = setTimeout(() => {
634
+ if (this.idleStopped)
635
+ return;
545
636
  this.refreshIdle().catch(err => {
546
637
  if (this.verbose)
547
638
  console.error(` [imap] IDLE refresh failed: ${err.message}`);
@@ -567,7 +658,33 @@ export class NativeImapClient {
567
658
  fetchChunkSizeMax;
568
659
  /** Active command timer — reset by handleData on every data arrival */
569
660
  commandTimer = null;
570
- sendCommand(tag, command, onUntagged) {
661
+ /**
662
+ * Issue an IMAP command and await its tagged response.
663
+ *
664
+ * If IDLE is currently active on this connection, DONE it first (and wait
665
+ * for its tagged OK) before writing the new command; re-enter IDLE after
666
+ * the command finishes. Without this, commands issued from within an IDLE
667
+ * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
668
+ * server until the inactivity timer killed the socket.
669
+ */
670
+ async sendCommand(tag, command, onUntagged) {
671
+ const idleState = await this.suspendIdleForCommand();
672
+ try {
673
+ return await this.sendCommandCore(tag, command, onUntagged);
674
+ }
675
+ finally {
676
+ if (idleState) {
677
+ try {
678
+ await this.resumeIdleAfterCommand(idleState);
679
+ }
680
+ catch (err) {
681
+ if (this.verbose)
682
+ console.error(` [imap] IDLE auto-resume failed: ${err.message}`);
683
+ }
684
+ }
685
+ }
686
+ }
687
+ sendCommandCore(tag, command, onUntagged) {
571
688
  return new Promise((resolve, reject) => {
572
689
  if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
573
690
  console.log(` [imap] > ${command.trimEnd()}`);
@@ -575,7 +692,10 @@ export class NativeImapClient {
575
692
  const onTimeout = () => {
576
693
  this.commandTimer = null;
577
694
  this.pendingCommand = null;
578
- // Kill the connection — a timed-out connection has stale data in the pipe
695
+ // Kill the connection — a timed-out connection has stale data in the pipe.
696
+ // Mark disconnected so ensureConnected() reconnects on the next call —
697
+ // transport.close() may or may not fire onClose synchronously.
698
+ this._connected = false;
579
699
  this.transport.close?.();
580
700
  reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}`));
581
701
  };
@@ -588,8 +708,18 @@ export class NativeImapClient {
588
708
  clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
589
709
  onUntagged,
590
710
  };
591
- this.transport.write(command).catch((err) => { if (this.commandTimer)
592
- clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); });
711
+ this.transport.write(command).catch((err) => {
712
+ if (this.commandTimer)
713
+ clearTimeout(this.commandTimer);
714
+ this.commandTimer = null;
715
+ // Write failure = dead socket. Clear pendingCommand + disconnect flag
716
+ // so the next ensureConnected() does a fresh connect instead of
717
+ // reusing a socket that the OS layer has already declared dead
718
+ // (the source of the "hostname nor servname provided" cascade).
719
+ this.pendingCommand = null;
720
+ this._connected = false;
721
+ reject(err);
722
+ });
593
723
  });
594
724
  }
595
725
  waitForContinuation(tag) {
@@ -619,6 +749,7 @@ export class NativeImapClient {
619
749
  if (this.pendingCommand) {
620
750
  const cmd = this.pendingCommand;
621
751
  this.pendingCommand = null;
752
+ this._connected = false;
622
753
  this.transport.close?.();
623
754
  cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
624
755
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",