@bobfrankston/iflow-direct 0.1.16 → 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 +122 -0
- package/imap-compat.d.ts +5 -0
- package/imap-compat.js +10 -0
- package/imap-native.d.ts +25 -0
- package/imap-native.js +112 -13
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @bobfrankston/iflow-direct
|
|
2
|
+
|
|
3
|
+
Transport-agnostic IMAP client. Zero Node.js deps — works in Node, Electron, and
|
|
4
|
+
the browser (via a bridge transport). Caller supplies a `TransportFactory`, OAuth
|
|
5
|
+
token provider (if needed), and drives the client.
|
|
6
|
+
|
|
7
|
+
See [MIGRATION.md](./MIGRATION.md) for the split from legacy `@bobfrankston/iflow`
|
|
8
|
+
and the desktop/browser architecture diagram.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @bobfrankston/iflow-direct
|
|
14
|
+
# desktop TCP transport:
|
|
15
|
+
npm install @bobfrankston/tcp-transport
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start (desktop)
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { NativeImapClient } from "@bobfrankston/iflow-direct";
|
|
22
|
+
import { TcpTransport } from "@bobfrankston/tcp-transport";
|
|
23
|
+
|
|
24
|
+
const client = new NativeImapClient(
|
|
25
|
+
{ server: "imap.example.com", port: 993, username: "me", password: "..." },
|
|
26
|
+
() => new TcpTransport(),
|
|
27
|
+
);
|
|
28
|
+
await client.connect();
|
|
29
|
+
await client.select("INBOX");
|
|
30
|
+
const msgs = await client.fetchMessages("1:50", { source: false });
|
|
31
|
+
await client.logout();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For the legacy API shape (`getFolderList`, etc.) use `CompatImapClient` from the
|
|
35
|
+
same package.
|
|
36
|
+
|
|
37
|
+
## Batch body retrieval
|
|
38
|
+
|
|
39
|
+
Two APIs for pulling bodies across many UIDs in a single `UID FETCH` round trip,
|
|
40
|
+
with per-message streaming as responses arrive (not buffered until the tagged OK).
|
|
41
|
+
|
|
42
|
+
### `fetchMessagesStream(range, options, onMessage?)`
|
|
43
|
+
|
|
44
|
+
Streaming variant of `fetchMessages`. `range` accepts any IMAP sequence set —
|
|
45
|
+
single UID (`"42"`), range (`"100:200"`), comma list (`"1,5,10,42"`), or mixed
|
|
46
|
+
(`"1:10,42,50:55"`). The server handles all forms.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
await client.select("INBOX");
|
|
50
|
+
await client.fetchMessagesStream(
|
|
51
|
+
"1,5,10,42,100:150",
|
|
52
|
+
{ source: true, headers: false },
|
|
53
|
+
(msg) => {
|
|
54
|
+
// Called once per message as soon as its FETCH response
|
|
55
|
+
// (literals included) is fully received. Tagged OK has not arrived yet.
|
|
56
|
+
console.log(msg.uid, msg.source.length);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Returns the parsed messages at the tagged OK; if `onMessage` is omitted, behaves
|
|
62
|
+
like `fetchMessages` (collect-all).
|
|
63
|
+
|
|
64
|
+
### `fetchBodiesBatch(folderPath, uids, onBody)`
|
|
65
|
+
|
|
66
|
+
Folder-scoped convenience. Selects the folder (skips if already selected), issues
|
|
67
|
+
one `UID FETCH <comma-list> (UID FLAGS ENVELOPE RFC822.SIZE INTERNALDATE BODY.PEEK[])`,
|
|
68
|
+
streams each `(uid, source)` through `onBody`, returns on tagged OK.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
await client.fetchBodiesBatch("INBOX", [1, 5, 10, 42], (uid, source) => {
|
|
72
|
+
store.saveBody(uid, source);
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Intended for prefetch pipelines — e.g. mailx-imap's body-prefetch path — that
|
|
77
|
+
would otherwise issue one SELECT+FETCH per message.
|
|
78
|
+
|
|
79
|
+
### Streaming guarantees
|
|
80
|
+
|
|
81
|
+
- `onMessage` / `onBody` fires on the same event-loop turn that the server's
|
|
82
|
+
FETCH response (with any literal bodies) is fully parsed.
|
|
83
|
+
- Ordering matches server response order. For a comma list, servers typically
|
|
84
|
+
return in the order requested, but RFC 3501 does not require it — treat order
|
|
85
|
+
as server-defined.
|
|
86
|
+
- Callback exceptions are caught and logged (verbose mode) so one bad message
|
|
87
|
+
doesn't abort the batch. Throw only if you want that behavior and wrap your
|
|
88
|
+
own try/catch.
|
|
89
|
+
- The tagged-OK promise still resolves with the full response list; callers
|
|
90
|
+
needing both streaming and a terminal "all done" signal should `await` the
|
|
91
|
+
returned promise.
|
|
92
|
+
|
|
93
|
+
## Other APIs
|
|
94
|
+
|
|
95
|
+
`NativeImapClient` exposes the usual IMAP surface: `select`, `examine`,
|
|
96
|
+
`listFolders`, `getStatus`, `search`, `fetchMessage`, `fetchSinceUid`,
|
|
97
|
+
`fetchByDate` (both with optional `onChunk` chunked callbacks), `addFlags`,
|
|
98
|
+
`removeFlags`, `copyMessage`, `moveMessage`, `deleteMessage`, `expunge`,
|
|
99
|
+
`appendMessage`, `startIdle`, `logout`. See `imap-native.ts` for signatures.
|
|
100
|
+
|
|
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,
|
|
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
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
`ImapClientConfig` fields relevant to throughput/resilience:
|
|
112
|
+
|
|
113
|
+
| Field | Default | Notes |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `inactivityTimeout` | 60000 | ms with no data before connection is declared dead. Timer resets on every byte. Slow Dovecot often needs 180000+. |
|
|
116
|
+
| `fetchChunkSize` | 25 | Initial chunk size for `fetchSinceUid`/`fetchByDate`. |
|
|
117
|
+
| `fetchChunkSizeMax` | 500 | Ramps up 4× per chunk. Batch APIs above bypass chunking — caller decides. |
|
|
118
|
+
| `verbose` | false | Log every command/response line + literal bookkeeping. |
|
|
119
|
+
|
|
120
|
+
## Files
|
|
121
|
+
|
|
122
|
+
See [MIGRATION.md § Files in iflow-direct](./MIGRATION.md#files-in-iflow-direct).
|
package/imap-compat.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export declare class CompatImapClient {
|
|
|
38
38
|
fetchMessagesSinceUid(mailbox: string, sinceUid: number, options?: {
|
|
39
39
|
source?: boolean;
|
|
40
40
|
}): Promise<FetchedMessage[]>;
|
|
41
|
+
/** Batch-fetch bodies for many UIDs in one folder on one connection. Streams
|
|
42
|
+
* each body through `onBody` as it arrives. No per-message round trips —
|
|
43
|
+
* one SELECT, one UID FETCH, streaming response. Required by mailx-imap's
|
|
44
|
+
* batch prefetch path. */
|
|
45
|
+
fetchBodiesBatch(mailbox: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
|
|
41
46
|
/** Fetch messages by date range. Optional onChunk callback for incremental processing. */
|
|
42
47
|
fetchMessageByDate(mailbox: string, start: Date, end?: Date, options?: {
|
|
43
48
|
source?: boolean;
|
package/imap-compat.js
CHANGED
|
@@ -93,6 +93,16 @@ export class CompatImapClient {
|
|
|
93
93
|
await this.native.closeMailbox();
|
|
94
94
|
return msgs.map(m => new FetchedMessage(m));
|
|
95
95
|
}
|
|
96
|
+
/** Batch-fetch bodies for many UIDs in one folder on one connection. Streams
|
|
97
|
+
* each body through `onBody` as it arrives. No per-message round trips —
|
|
98
|
+
* one SELECT, one UID FETCH, streaming response. Required by mailx-imap's
|
|
99
|
+
* batch prefetch path. */
|
|
100
|
+
async fetchBodiesBatch(mailbox, uids, onBody) {
|
|
101
|
+
if (uids.length === 0)
|
|
102
|
+
return;
|
|
103
|
+
await this.ensureConnected();
|
|
104
|
+
await this.native.fetchBodiesBatch(mailbox, uids, onBody);
|
|
105
|
+
}
|
|
96
106
|
/** Fetch messages by date range. Optional onChunk callback for incremental processing. */
|
|
97
107
|
async fetchMessageByDate(mailbox, start, end, options, onChunk) {
|
|
98
108
|
await this.ensureConnected();
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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()}`);
|