@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 +16 -1
- package/imap-compat.d.ts +6 -2
- package/imap-compat.js +7 -5
- package/imap-native.d.ts +25 -0
- package/imap-native.js +149 -18
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
592
|
-
|
|
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
|
}
|