/** * Native IMAP client — transport-agnostic. * Uses ImapTransport for I/O, imap-protocol for parsing. * Works with NodeTransport (desktop) or BridgeTransport (Android). * * This is a NEW client alongside the existing ImapClient (which wraps imapflow). * Existing callers are not affected. */ import type { TransportFactory } from "./transport.js"; import * as proto from "./imap-protocol.js"; import type { ImapClientConfig } from "./types.js"; export interface NativeFetchedMessage { seq: number; uid: number; flags: Set; date: Date | null; sentDate?: Date; subject: string; messageId: string; from: proto.AddressData[]; to: proto.AddressData[]; cc: proto.AddressData[]; bcc: proto.AddressData[]; sender: proto.AddressData[]; replyTo: proto.AddressData[]; inReplyTo: string; size: number; source: string; headers: string; seen: boolean; flagged: boolean; answered: boolean; draft: boolean; /** Per-message modification sequence (RFC 7162). Present when the * server is CONDSTORE/QRESYNC-capable and the client either has * ENABLE QRESYNC active or asked for MODSEQ explicitly. Used by the * caller to track its own `last_modseq` watermark per folder. */ modSeq?: number; } export interface NativeFolder { path: string; delimiter: string; flags: string[]; } export interface MailboxInfo { exists: number; recent: number; uidNext: number; uidValidity: number; flags: string[]; permanentFlags: string[]; /** Per RFC 7162. Present when the server is CONDSTORE/QRESYNC-capable * and includes [HIGHESTMODSEQ N] in the SELECT-OK response (or after * ENABLE QRESYNC). Use as the `since-modseq` argument to subsequent * resyncs to ask the server "what changed since this point?" */ highestModSeq?: number; } /** Parameters for `SELECT mailbox (QRESYNC (uidvalidity modseq))` per RFC 7162. * Caller persists `(uidValidity, modSeq)` per folder; after ENABLE QRESYNC * has been issued once on the session, the next SELECT replies with * `* VANISHED (EARLIER) ` for every message the server expunged since * `modSeq`, plus `* FETCH` for every message whose state changed since then. * No client-side UID diffing, no tombstones — the server tells you the * authoritative deletions. */ export interface QresyncParams { uidValidity: number; modSeq: number; knownUids?: string; } /** Result of a QRESYNC-enabled SELECT. The caller drains `vanishedUids` * (delete from local store), processes `changedMessages` (upsert state * changes), and saves the new `highestModSeq` for the next resync. */ export interface SelectResult extends MailboxInfo { vanishedUids: number[]; /** Untagged `* FETCH` responses the server emitted during the QRESYNC * SELECT — typically flag/MODSEQ changes for messages whose state * shifted between the caller's previous modSeq and now. Empty for * non-QRESYNC SELECTs. */ changedMessages: NativeFetchedMessage[]; /** True if the server's UIDVALIDITY changed since the caller's last * visit — in that case knownUids/modSeq are invalid and the caller * must full-resync from scratch. */ uidValidityChanged: boolean; } export declare class NativeImapClient { private transport; private transportFactory; private config; /** Byte-level receive buffer. IMAP literals (`{N}`) are octets — message * bodies, MIME parts with binary content, non-UTF-8 charsets — and the * parser MUST work in bytes to recover them with byte fidelity and * without paying O(n²) re-encoding cost on every chunk arrival. The * prior implementation kept `this.buffer` as a JS string, accumulated * UTF-8-decoded text from the transport, then `TextEncoder.encode`'d * the whole unread region on every TCP chunk while waiting for a * literal — pegged the event loop on multi-message FETCH responses. * * Invariants: * - `buffer[0 .. bufferLength)` holds valid received bytes * - `buffer[bufferOffset .. bufferLength)` is unread * - capacity = `buffer.length` ≥ `bufferLength`; we grow on demand * - never substring — bufferOffset advances past consumed bytes; * compactBufferIfNeeded reclaims space when consumed >= half. */ private buffer; private bufferLength; private bufferOffset; /** Cached decoder so per-line / per-literal decode doesn't re-construct * one. `fatal: false` (default) replaces invalid UTF-8 sequences with * U+FFFD — same lenient behavior as JavaScript's implicit string * decoding the parser used to rely on. */ private utf8Decoder; private pendingCommand; private capabilities; private _connected; private idleTag; private idleCallback; /** Fires when the SELECTED mailbox loses messages while parked in IDLE — * an EXISTS decrease, or an unsolicited `* EXPUNGE` / `* VANISHED` * (RFC 5465 NOTIFY SELECTED MessageExpunge). Lets the caller reconcile * a server-side deletion in real time instead of waiting for the next * periodic poll. Distinct from `idleCallback` (new mail only). */ private idleExpungeCallback; /** Fired for an unsolicited FETCH carrying FLAGS while parked in IDLE — * another client (a phone, Thunderbird, webmail) changed a flag on a * message in the selected mailbox. */ private idleFlagCallback; private idleRefreshTimer; /** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected * mailboxes (the server pushes these when the client has issued NOTIFY * SET with a PERSONAL group). Distinct from `idleCallback` which only * fires for EXISTS on the currently-selected mailbox. */ onMailboxStatus: ((mailbox: string, data: proto.StatusData) => void) | null; /** Set by startIdle's stop closure so auto-suspend knows not to resume after a command finishes. */ private idleStopped; private verbose; private selectedMailbox; private mailboxInfo; private greetingResolve; /** Callback for waitForContinuation — set when waiting for "+" response */ private continuationResolve; /** Per-command heartbeat (30s). Hoisted to instance state so any path * that swaps pendingCommand can clear it cleanly — closure-scoped * timers leaked when handleData's inline replacement or IDLE * suspend/resume bypassed sendCommandCore's resolve/reject closures. */ private heartbeatTimer; /** Hard wall-clock cap per command. Same hoist for the same reason. */ private wallClockTimer; constructor(config: ImapClientConfig, transportFactory: TransportFactory); get connected(): boolean; /** Check the underlying transport's connected state — catches silently dead sockets. */ get transportConnected(): boolean; /** Replace the transport with a fresh one from the factory. Call before reconnect * when the old transport is in a bad state (dead socket, stale bridge stream). */ resetTransport(): void; connect(): Promise; private readGreeting; private authenticate; private starttls; capability(): Promise>; /** Return the cached CAPABILITY set parsed at connect/login time. Callers * use this to gate optional code paths (NOTIFY, QRESYNC, MOVE, etc.) * without re-issuing CAPABILITY. Returns a defensive copy so callers * can't mutate internal state. */ getCapabilities(): Set; /** Snapshot of the currently-SELECTed mailbox's info. Lets the compat * client read `highestModSeq` etc. after any select-implying operation * (fetchMessagesSinceUid, etc.) without re-issuing SELECT. */ getMailboxInfo(): { uidValidity: number; uidNext: number; exists: number; highestModSeq?: number; }; private parseCapabilities; logout(): Promise; /** SELECT a mailbox. Optional QRESYNC params trigger RFC 7162 fast resync — * on a re-visit the server replies with `* VANISHED (EARLIER) ` for * every UID expunged since the caller's last `modSeq`, plus `* FETCH` * for every state change since then. The caller must have called * `enable(["QRESYNC"])` once on the session for this to take effect. * Returns a `SelectResult` rather than a bare `MailboxInfo` so VANISHED * is delivered to the caller. The result is upcast-compatible with * `MailboxInfo` for code paths that don't care about VANISHED. */ select(mailbox: string, qresync?: QresyncParams): Promise; /** RFC 5161 ENABLE. Activates an extension for the rest of the session. * QRESYNC requires this once before any SELECT for VANISHED responses * to be emitted. Capability-gate at the caller — emitting ENABLE for * an unadvertised extension is a no-op on compliant servers but spams * the response stream. */ enable(extensions: string[]): Promise>; examine(mailbox: string): Promise; /** Close the currently selected mailbox */ closeMailbox(): Promise; listFolders(): Promise; getStatus(mailbox: string, items?: string[]): Promise; /** * Total size of a mailbox in octets plus its message count (for imail -sizes; added * 2026-09-16 by Claude Code, Fable 5.1, at Bob's direction). * * Fast path: RFC 8438 `STATUS (MESSAGES SIZE)` — one round trip, no message traffic — * when the server advertises STATUS=SIZE (Dovecot, Gmail do). * Fallback: EXAMINE (read-only) + `UID FETCH 1:* (UID RFC822.SIZE)` summed here. That * is one FETCH line per message but no envelopes or headers, so it stays cheap even * on a folder of tens of thousands of messages. * * `method` says which path produced the number so a log can show it. */ getFolderSize(mailbox: string): Promise<{ messages: number; bytes: number; method: "status" | "fetch"; }>; createMailbox(mailbox: string): Promise; deleteMailbox(mailbox: string): Promise; renameMailbox(from: string, to: string): Promise; /** Fetch messages by UID range. Range may be sequence set (e.g. "1:100"), comma list ("1,5,10"), or mix ("1:10,42,50:55"). */ fetchMessages(range: string, options?: { source?: boolean; headers?: boolean; }): Promise; /** * Fetch messages by UID range/list, streaming each parsed message through `onMessage` * as soon as its server response (including any literal bodies) is received, instead * of buffering the entire batch until the tagged OK. Returns all parsed messages at end. * * Pass a comma list (`"1,5,10,42"`) or sequence set (`"100:200"`) — the server handles either. */ fetchMessagesStream(range: string, options?: { source?: boolean; headers?: boolean; }, onMessage?: (msg: NativeFetchedMessage) => void): Promise; /** * Folder-scoped batch body fetch. Selects `folderPath`, issues a single * `UID FETCH (BODY.PEEK[] ...)`, streams each body through `onBody` * as it arrives, and returns when the tagged OK is received. * * Eliminates per-message SELECT+FETCH round trips for callers like mailx-imap's * prefetch path. */ fetchBodiesBatch(folderPath: string, uids: number[], onBody: (uid: number, source: string) => void): Promise; /** Fetch messages since a UID */ fetchSinceUid(sinceUid: number, options?: { source?: boolean; }, onChunk?: (msgs: NativeFetchedMessage[]) => void): Promise; /** Fetch messages by date range. Optional onChunk callback receives each batch as it arrives. */ fetchByDate(since: Date, before?: Date, options?: { source?: boolean; }, onChunk?: (msgs: NativeFetchedMessage[]) => void): Promise; /** Fetch the most recent N messages by sequence number — avoids * SEARCH SINCE which can take minutes on cold Dovecot mailboxes. The * selected mailbox's `EXISTS` count is used to compute the range * `(EXISTS-N+1):EXISTS`; sequence FETCH is O(1) on the server because * it just reads message slots, no INTERNALDATE walk. Caller must have * SELECTed the mailbox first (sequence numbers are session-relative). */ fetchLatestN(n: number, options?: { source?: boolean; }, onChunk?: (msgs: NativeFetchedMessage[]) => void): Promise; /** Fetch a single message by UID */ fetchMessage(uid: number, options?: { source?: boolean; }): Promise; /** Get all UIDs in the current mailbox */ getUids(): Promise; /** UID SEARCH */ search(criteria: string): Promise; /** Set flags on a message */ addFlags(uid: number, flags: string[]): Promise; /** Remove flags from a message */ removeFlags(uid: number, flags: string[]): Promise; /** Copy a message to another mailbox */ copyMessage(uid: number, destination: string): Promise; /** Move a message to another mailbox (MOVE or COPY+DELETE) */ moveMessage(uid: number, destination: string): Promise; /** Delete a message by UID (flag + expunge) */ deleteMessage(uid: number): Promise; /** Delete many messages in the selected mailbox: one `UID STORE * +FLAGS.SILENT (\Deleted)` per chunk, then a single EXPUNGE. * 2026-09-16 — Claude Code (Fable 5.1), at Bob's direction. Emptying * Trash looped deleteMessage() per UID — a STORE and an EXPUNGE round * trip for every message — so a large Trash took minutes and the * caller's IPC timed out ("mailxapi timeout: emptyFolder"). Chunked so a * ten-thousand-UID set never produces a command line the server rejects. */ deleteMessages(uids: number[], chunkSize?: number): Promise; /** Expunge deleted messages */ expunge(): Promise; /** Append a message to a mailbox */ appendMessage(mailbox: string, message: string | Uint8Array, flags?: string[]): Promise; /** Issue NOTIFY SET so the server starts pushing unsolicited STATUS * responses for the mailboxes named in `spec` (beyond the currently * selected one). Capability gated — callers must check * `getCapabilities().has("NOTIFY")` before calling. Set * `onMailboxStatus` before issuing if you want to react. Issue AFTER * SELECT and BEFORE startIdle — the server holds the spec for the * lifetime of the connection. */ notify(spec: string): Promise; startIdle(onNewMail: (count: number) => void, onExpunge?: () => void, onFlagChange?: (seq: number, flags: string[]) => void): Promise<() => Promise>; /** * If IDLE is currently active, send DONE and wait for its tagged OK so the * connection is free to accept a new command. Saves the active callback so * the companion `resumeIdleAfterCommand` can re-enter IDLE on the same * mailbox afterwards. Returns the saved state, or null if IDLE was not active. * * Called automatically at the top of `sendCommand` whenever the caller issues * an interleaved command on an IDLE-holding connection (e.g. pullMail reacting * to an EXISTS notification). */ private suspendIdleForCommand; /** Re-enter IDLE with the saved callback after an auto-suspended command completes. */ private resumeIdleAfterCommand; /** Send DONE + re-IDLE. Called by the 28-minute refresh timer. */ private refreshIdle; getMessageCount(mailbox: string): Promise; /** Inactivity timeout — how long to wait with NO data before declaring the connection dead. * This is NOT a wall-clock timeout. Timer resets every time data arrives from the server. * A large FETCH returning data continuously will never timeout. * 60s accommodates Gmail which is slow on SEARCH for large folders. * Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */ private inactivityTimeout; /** Hard wall-clock cap per command (independent of inactivityTimeout). * A pathological server that returns one byte every (inactivityTimeout-1) * seconds would otherwise defer the inactivity timer forever; this is * the absolute deadline that always fires. Default 5 minutes. */ private commandWallClockTimeout; /** Server-greeting timeout in ms — how long to wait for the server's * initial banner after TCP/TLS connects. Default 10s; bumped to 30s * for slow shared-hosting Dovecot in mailx-imap. */ private greetingTimeout; /** Fetch chunk sizes — start small for quick first paint, ramp up for throughput. * Default 25 initial → 500 max. Overridable via ImapClientConfig.fetchChunkSize / * fetchChunkSizeMax. Slow servers benefit from smaller chunks (fewer timeouts). */ private fetchChunkSize; private fetchChunkSizeMax; /** Active command timer — reset by handleData on every data arrival */ private commandTimer; /** * Issue an IMAP command and await its tagged response. * * If IDLE is currently active on this connection, DONE it first (and wait * for its tagged OK) before writing the new command; re-enter IDLE after * the command finishes. Without this, commands issued from within an IDLE * EXISTS callback (e.g. mailpuller's pullMail) would be ignored by the * server until the inactivity timer killed the socket. */ /** Per-client command serialization. IMAP allows only one command in * flight at a time on a single connection (RFC 3501 §2.2.1). The * client's `pendingCommand` field tracks that command — if a second * caller concurrently invokes sendCommand, it overwrites * pendingCommand and the first caller's await is orphaned. We saw * this in production: the outbox poller's `withConnection`-queued * task ran on the same shared ops client as syncFolder's INBOX work, * and INBOX silently hung forever (no heartbeat fire, no wall-clock, * no reject — the promise just had no command tracking it anymore). * * This chain serializes ALL sendCommand callers against this client * regardless of whether the caller bothered to coordinate. * Defensive — the right thing for the upper layer to do is also use * the queue (withConnection in mailx-imap), but the IMAP client * shouldn't allow itself to be corrupted by a careless caller. */ private commandChain; private sendCommand; private sendCommandCore; private waitForContinuation; private waitForTagged; private handleData; /** Append received bytes to the receive buffer, growing capacity if * needed. O(n) over the chunk size, never over the buffer total — * doubling growth amortizes to O(1) per byte across the response. */ private appendToBuffer; /** Single canonical "command finished or abandoned" cleanup. Every * path that ends a command's lifecycle — resolve, reject, timeout, * socket close, command-replace — calls this. Was a leak source: * closure-scoped timers in sendCommandCore couldn't be cleared from * handleData's inline-replacement timer or from IDLE suspend/resume. */ private clearAllCommandTimers; /** Compact the buffer when bufferOffset has advanced past half the used * region. copyWithin moves the unread bytes to the front in-place * (no new allocation). Amortized O(1) per byte; never the O(n²) * substring-each-line pathology that blocked the event loop on a * large LIST response. */ private compactBufferIfNeeded; /** Find the next CRLF (0x0D 0x0A) in the unread region, returning the * byte index of the 0x0D, or -1 if not found. Manual byte loop — * Uint8Array doesn't have a native indexOf for byte sequences. */ private indexOfCRLF; private processBuffer; private handleUntaggedResponse; private parseFetchResponses; } //# sourceMappingURL=imap-native.d.ts.map