@bobfrankston/iflow-direct 0.1.17 → 0.1.18

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,12 @@ 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.
103
108
 
104
109
  ## Configuration
105
110
 
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: [] };
@@ -480,17 +482,20 @@ export class NativeImapClient {
480
482
  // ── IDLE ──
481
483
  async startIdle(onNewMail) {
482
484
  this.idleCallback = onNewMail;
483
- let stopped = false;
485
+ this.idleStopped = false;
484
486
  const beginIdleCycle = async () => {
485
487
  const tag = proto.nextTag();
486
488
  this.idleTag = tag;
489
+ // Arm continuation listener BEFORE writing — some servers reply fast enough
490
+ // that data can arrive in the same tick.
491
+ const waitCont = this.waitForContinuation(tag);
487
492
  await this.transport.write(proto.idleCommand(tag));
488
- await this.waitForContinuation(tag);
493
+ await waitCont;
489
494
  // RFC 2177: re-IDLE every ~28 minutes to avoid servers that terminate
490
495
  // idle sessions at the 29-minute mark, and to give us a steady
491
496
  // application-level keepalive that detects silently-dropped sockets.
492
497
  this.idleRefreshTimer = setTimeout(() => {
493
- if (stopped)
498
+ if (this.idleStopped)
494
499
  return;
495
500
  this.refreshIdle().catch(err => {
496
501
  if (this.verbose)
@@ -500,7 +505,7 @@ export class NativeImapClient {
500
505
  };
501
506
  await beginIdleCycle();
502
507
  return async () => {
503
- stopped = true;
508
+ this.idleStopped = true;
504
509
  if (this.idleRefreshTimer) {
505
510
  clearTimeout(this.idleRefreshTimer);
506
511
  this.idleRefreshTimer = null;
@@ -508,40 +513,108 @@ export class NativeImapClient {
508
513
  const tag = this.idleTag;
509
514
  this.idleTag = null;
510
515
  this.idleCallback = null;
511
- try {
512
- await this.transport.write(proto.doneCommand());
513
- }
514
- catch { /* ignore */ }
515
516
  if (tag) {
517
+ // Arm tagged-wait BEFORE DONE — avoid missing the OK.
518
+ const waitTag = this.waitForTagged(tag);
519
+ try {
520
+ await this.transport.write(proto.doneCommand());
521
+ }
522
+ catch { /* ignore */ }
516
523
  try {
517
- await this.waitForTagged(tag);
524
+ await waitTag;
518
525
  }
519
526
  catch { /* ignore */ }
520
527
  }
521
528
  };
522
529
  }
530
+ /**
531
+ * If IDLE is currently active, send DONE and wait for its tagged OK so the
532
+ * connection is free to accept a new command. Saves the active callback so
533
+ * the companion `resumeIdleAfterCommand` can re-enter IDLE on the same
534
+ * mailbox afterwards. Returns the saved state, or null if IDLE was not active.
535
+ *
536
+ * Called automatically at the top of `sendCommand` whenever the caller issues
537
+ * an interleaved command on an IDLE-holding connection (e.g. pullMail reacting
538
+ * to an EXISTS notification).
539
+ */
540
+ async suspendIdleForCommand() {
541
+ const oldTag = this.idleTag;
542
+ const cb = this.idleCallback;
543
+ if (!oldTag || !cb)
544
+ return null;
545
+ if (this.idleRefreshTimer) {
546
+ clearTimeout(this.idleRefreshTimer);
547
+ this.idleRefreshTimer = null;
548
+ }
549
+ this.idleTag = null;
550
+ this.idleCallback = null;
551
+ if (this.verbose)
552
+ console.log(` [imap] IDLE auto-suspending for interleaved command`);
553
+ // Arm tagged listener BEFORE DONE so we don't miss the OK if the server is fast.
554
+ const waitTag = this.waitForTagged(oldTag);
555
+ try {
556
+ await this.transport.write(proto.doneCommand());
557
+ }
558
+ catch (err) {
559
+ // Write failure — pendingCommand is still set by waitForTagged; clear it to prevent hang.
560
+ this.pendingCommand = null;
561
+ throw err;
562
+ }
563
+ try {
564
+ await waitTag;
565
+ }
566
+ catch { /* best-effort — proceed even if OK was lost */ }
567
+ return { callback: cb };
568
+ }
569
+ /** Re-enter IDLE with the saved callback after an auto-suspended command completes. */
570
+ async resumeIdleAfterCommand(info) {
571
+ // The caller may have stopped IDLE during the command window — respect that.
572
+ if (this.idleStopped)
573
+ return;
574
+ const newTag = proto.nextTag();
575
+ this.idleTag = newTag;
576
+ this.idleCallback = info.callback;
577
+ // Arm continuation listener BEFORE writing IDLE.
578
+ const waitCont = this.waitForContinuation(newTag);
579
+ await this.transport.write(proto.idleCommand(newTag));
580
+ await waitCont;
581
+ if (this.verbose)
582
+ console.log(` [imap] IDLE auto-resumed (${newTag})`);
583
+ this.idleRefreshTimer = setTimeout(() => {
584
+ if (this.idleStopped)
585
+ return;
586
+ this.refreshIdle().catch(err => {
587
+ if (this.verbose)
588
+ console.error(` [imap] IDLE refresh failed: ${err.message}`);
589
+ });
590
+ }, 28 * 60 * 1000);
591
+ }
523
592
  /** Send DONE + re-IDLE. Called by the 28-minute refresh timer. */
524
593
  async refreshIdle() {
525
594
  const oldTag = this.idleTag;
526
595
  if (!oldTag || !this.idleCallback)
527
596
  return;
528
597
  const cb = this.idleCallback;
529
- // DONE the current IDLE
598
+ // Arm tagged listener BEFORE writing DONE to avoid missing a fast OK.
599
+ const waitTag = this.waitForTagged(oldTag);
530
600
  await this.transport.write(proto.doneCommand());
531
601
  try {
532
- await this.waitForTagged(oldTag);
602
+ await waitTag;
533
603
  }
534
604
  catch { /* best-effort */ }
535
605
  // Re-IDLE with a new tag
536
606
  this.idleCallback = cb; // waitForTagged resolved pendingCommand, reinstall callback
537
607
  const newTag = proto.nextTag();
538
608
  this.idleTag = newTag;
609
+ const waitCont = this.waitForContinuation(newTag);
539
610
  await this.transport.write(proto.idleCommand(newTag));
540
- await this.waitForContinuation(newTag);
611
+ await waitCont;
541
612
  if (this.verbose)
542
613
  console.log(` [imap] IDLE refreshed (${newTag})`);
543
614
  // Schedule the next refresh
544
615
  this.idleRefreshTimer = setTimeout(() => {
616
+ if (this.idleStopped)
617
+ return;
545
618
  this.refreshIdle().catch(err => {
546
619
  if (this.verbose)
547
620
  console.error(` [imap] IDLE refresh failed: ${err.message}`);
@@ -567,7 +640,33 @@ export class NativeImapClient {
567
640
  fetchChunkSizeMax;
568
641
  /** Active command timer — reset by handleData on every data arrival */
569
642
  commandTimer = null;
570
- sendCommand(tag, command, onUntagged) {
643
+ /**
644
+ * Issue an IMAP command and await its tagged response.
645
+ *
646
+ * If IDLE is currently active on this connection, DONE it first (and wait
647
+ * for its tagged OK) before writing the new command; re-enter IDLE after
648
+ * the command finishes. Without this, commands issued from within an IDLE
649
+ * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the
650
+ * server until the inactivity timer killed the socket.
651
+ */
652
+ async sendCommand(tag, command, onUntagged) {
653
+ const idleState = await this.suspendIdleForCommand();
654
+ try {
655
+ return await this.sendCommandCore(tag, command, onUntagged);
656
+ }
657
+ finally {
658
+ if (idleState) {
659
+ try {
660
+ await this.resumeIdleAfterCommand(idleState);
661
+ }
662
+ catch (err) {
663
+ if (this.verbose)
664
+ console.error(` [imap] IDLE auto-resume failed: ${err.message}`);
665
+ }
666
+ }
667
+ }
668
+ }
669
+ sendCommandCore(tag, command, onUntagged) {
571
670
  return new Promise((resolve, reject) => {
572
671
  if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
573
672
  console.log(` [imap] > ${command.trimEnd()}`);
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.18",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",