# @bobfrankston/iflow-direct

Transport-agnostic IMAP client. Zero Node.js deps — works in Node, Electron, and
the browser (via a bridge transport). Caller supplies a `TransportFactory`, OAuth
token provider (if needed), and drives the client.

See [MIGRATION.md](./MIGRATION.md) for the split from legacy `@bobfrankston/iflow`
and the desktop/browser architecture diagram.

## Install

```bash
npm install @bobfrankston/iflow-direct
# desktop (Node) TCP transport implementation:
npm install @bobfrankston/node-tcp-transport
```

`@bobfrankston/tcp-transport` is the *interface* package (`TcpTransport` is a
type, plus the browser/Android `BridgeTcpTransport`); the class you instantiate
in Node is `NodeTcpTransport` from `@bobfrankston/node-tcp-transport`
(node:net/tls). `@bobfrankston/iflow-node` is a back-compat shim re-exporting
it as `NodeTransport` — new code should not use it.

## Quick start (Node)

```typescript
import { NativeImapClient } from "@bobfrankston/iflow-direct";
import { NodeTcpTransport } from "@bobfrankston/node-tcp-transport";

const client = new NativeImapClient(
    { server: "imap.example.com", port: 993, username: "me", password: "..." },
    () => new NodeTcpTransport(),
);
await client.connect();
await client.select("INBOX");
const msgs = await client.fetchMessages("1:50", { source: false });
await client.logout();
```

For OAuth (Gmail, Office 365), omit `password` and supply a `tokenProvider` —
an async function returning a current access token; the client calls it at
authentication time, so refresh logic lives in the provider:

```typescript
const client = new NativeImapClient(
    {
        server: "imap.gmail.com", port: 993, username: "me@gmail.com",
        tokenProvider: async () => myOauth.getFreshAccessToken(),
    },
    () => new NodeTcpTransport(),
);
```

(mailx pairs this with `@bobfrankston/oauthsupport`, but any source of a valid
token works.)

For the legacy API shape (`getFolderList`, etc.) use `CompatImapClient` from the
same package.

## Batch body retrieval

Two APIs for pulling bodies across many UIDs in a single `UID FETCH` round trip,
with per-message streaming as responses arrive (not buffered until the tagged OK).

### `fetchMessagesStream(range, options, onMessage?)`

Streaming variant of `fetchMessages`. `range` accepts any IMAP sequence set —
single UID (`"42"`), range (`"100:200"`), comma list (`"1,5,10,42"`), or mixed
(`"1:10,42,50:55"`). The server handles all forms.

```typescript
await client.select("INBOX");
await client.fetchMessagesStream(
    "1,5,10,42,100:150",
    { source: true, headers: false },
    (msg) => {
        // Called once per message as soon as its FETCH response
        // (literals included) is fully received. Tagged OK has not arrived yet.
        console.log(msg.uid, msg.source.length);
    },
);
```

Returns the parsed messages at the tagged OK; if `onMessage` is omitted, behaves
like `fetchMessages` (collect-all).

### `fetchBodiesBatch(folderPath, uids, onBody)`

Folder-scoped convenience. Selects the folder (skips if already selected), issues
one `UID FETCH <comma-list> (UID FLAGS ENVELOPE RFC822.SIZE INTERNALDATE BODY.PEEK[])`,
streams each `(uid, source)` through `onBody`, returns on tagged OK.

```typescript
await client.fetchBodiesBatch("INBOX", [1, 5, 10, 42], (uid, source) => {
    store.saveBody(uid, source);
});
```

Intended for prefetch pipelines — e.g. mailx-imap's body-prefetch path — that
would otherwise issue one SELECT+FETCH per message.

### Streaming guarantees

- `onMessage` / `onBody` fires on the same event-loop turn that the server's
  FETCH response (with any literal bodies) is fully parsed.
- Ordering matches server response order. For a comma list, servers typically
  return in the order requested, but RFC 3501 does not require it — treat order
  as server-defined.
- Callback exceptions are caught and logged (verbose mode) so one bad message
  doesn't abort the batch. Throw only if you want that behavior and wrap your
  own try/catch.
- The tagged-OK promise still resolves with the full response list; callers
  needing both streaming and a terminal "all done" signal should `await` the
  returned promise.

## Other APIs

`NativeImapClient` exposes the usual IMAP surface: `select`, `examine`,
`listFolders`, `getStatus`, `search`, `fetchMessage`, `fetchSinceUid`,
`fetchByDate` (both with optional `onChunk` chunked callbacks), `addFlags`,
`removeFlags`, `copyMessage`, `moveMessage`, `deleteMessage`, `deleteMessages`,
`expunge`, `appendMessage`, `getFolderSize`, `startIdle`, `logout`. See
`imap-native.ts` for signatures.

### Folder size (2026-09-16)

`getFolderSize(mailbox)` returns `{ messages, bytes, method }`. When the server
advertises `STATUS=SIZE` (RFC 8438) it is one `STATUS (MESSAGES SIZE)` round
trip and `method` is `"status"`. Otherwise it EXAMINEs the folder and sums a
size-only `UID FETCH 1:* (UID RFC822.SIZE)` — no envelopes or headers — and
`method` is `"fetch"`. `getStatus(mailbox, items?)` takes an optional item list
for the same reason; the default is unchanged. `CompatImapClient.getFolderSize`
is the mailbox-scoped wrapper.

### Bulk delete by criterion (2026-09-16)

Search, then delete the UIDs in one go. `CompatImapClient` does the SELECT /
CLOSE around each call:

```ts
// Everything in Trash received before 2025-01-01
const uids = await client.searchMessages("Trash", { before: new Date("2025-01-01") });
await client.deleteMessagesByUid("Trash", uids);
```

`searchMessages(mailbox, criteria)` accepts `from`/`to`/`cc`/`subject`/`body`/
`text` (string or string[]), `since`/`before` (Date — IMAP compares the
server's INTERNALDATE at day granularity; `before` is exclusive), the boolean
flags `seen`/`unseen`/`flagged`/`answered`/`draft`, plus `not` and `or` groups.
`deleteMessagesByUid(mailbox, uids)` issues one `UID STORE <set> +FLAGS.SILENT
(\Deleted)` per 1000-UID chunk and a single EXPUNGE, instead of a STORE and an
EXPUNGE round trip per message. Native equivalent: `deleteMessages(uids)` on a
selected mailbox.

IDLE auto-suspends when any other command is issued on the same connection and
re-enters afterwards — safe to interleave commands with a long-running IDLE,
including commands issued from the IDLE `onNewMail` callback itself (e.g. a
mailpuller-style `pullMail` that reacts to EXISTS by running STATUS + FETCH on
the same connection). Suspend sends DONE, awaits the tagged OK, runs the
command, then re-enters IDLE. If the caller stops IDLE (via the `startIdle`
stop function) during the command, IDLE is not re-entered.

## Connection liveness

`NativeImapClient.connected` (and `CompatImapClient`'s lazy `ensureConnected`
path, which now delegates to it) is authoritative: it gets cleared on transport
error, transport close, inactivity-timeout socket kill, and write failure. Any
of those paths also drops IDLE state so auto-suspend doesn't try to DONE a dead
socket. Callers that detect connection-style errors (e.g. DNS "hostname not
known" from a dead Android socket) can safely retry — the next operation will
reconnect from scratch rather than reusing the dead socket.

## Configuration

`ImapClientConfig` fields relevant to throughput/resilience:

| Field | Default | Notes |
|---|---|---|
| `inactivityTimeout` | 60000 | ms with no data before connection is declared dead. Timer resets on every byte. Slow Dovecot often needs 180000+. |
| `fetchChunkSize` | 25 | Initial chunk size for `fetchSinceUid`/`fetchByDate`. |
| `fetchChunkSizeMax` | 500 | Ramps up 4× per chunk. Batch APIs above bypass chunking — caller decides. |
| `verbose` | false | Log every command/response line + literal bookkeeping. |

## Files

See [MIGRATION.md § Files in iflow-direct](./MIGRATION.md#files-in-iflow-direct).
