/** * Finding what arrived: the delta fetch, and the adaptive poll that drives it. * * The delta fetch is the load-bearing half of this file and is shared with the * IDLE path, which is why it lives beside the poll loop rather than inside it. * IDLE and polling differ only in WHEN they ask; what they ask is identical, * and identical because it has to be: * * > **The delta always comes from `UID SEARCH UID :*`.** * * Not from arithmetic on `EXISTS`, which is a mailbox TOTAL and says neither * which message is new nor how many arrived. Not from a sequence number, which * renumbers on every expunge. Not from `SEARCH UNSEEN`, which cannot work at * all here because `EXAMINE` + `BODY.PEEK` means nothing is ever marked * `\Seen`, so the same messages would come back forever. * * The `*` in that range needs care * ──────────────────────────────── * `UID SEARCH UID 11:*` does NOT mean "UIDs from 11 upwards". RFC 3501 defines * `*` as the highest UID in the mailbox, and a range whose start exceeds its * end is not empty, it is the range with its endpoints swapped. So on a * mailbox whose highest UID is 10, `11:*` is the range 10:11 and matches * message 10, which the cursor says is already processed. Every server does * this and it is correct behaviour; a watcher that trusted the result would * redeliver its newest message on every single poll. Results are therefore * filtered to strictly above the cursor, here, once. * * The cursor moves behind the work, never ahead of it * ─────────────────────────────────────────────────── * `advance()` is called once per message and only after that message's * `deliver()` has resolved. A crash, a failed notice or a killed process * between fetch and completion therefore leaves the cursor below the message, * and the next pass fetches it again, a duplicate for dedup to suppress * rather than a message nobody ever hears about. This is the same rule * `TelegramIngressSupervisor` already follows for its offset, and it is the * reason a reconnect loses nothing: recovery is not "resume the stream", it is * "ask what is above the cursor". */ import type { InboundMailObserver, InboundMailSink, InboundWatcherSettings, MailboxCursorPort, MailboxReader, MailboxWire, WatcherClock } from './ports.js'; import type { MailboxCursor } from './types.js'; /** Everything one drain of the mailbox needs. */ export interface MailboxDeltaDeps { readonly settings: InboundWatcherSettings; readonly reader: MailboxReader; readonly wire: MailboxWire; readonly cursors: MailboxCursorPort; readonly sink: InboundMailSink; readonly clock: WatcherClock; readonly observer?: InboundMailObserver | undefined; /** Where the cursor stands going in. */ readonly cursor: MailboxCursor; /** Recorded on each message so a consumer can tell push from poll. */ readonly via: 'idle' | 'poll'; readonly signal: AbortSignal; } /** Why a drain stopped, and where it left the cursor. */ export type MailboxDeltaOutcome = /** Every message above the cursor was delivered. */ 'complete' /** Shutdown was requested part-way through. The rest is above the cursor. */ | 'aborted' /** The sink refused a message. The cursor is below it; it will come again. */ | 'delivery-failed' /** The server or socket failed. Classified by the caller. */ | 'read-failed'; export interface MailboxDeltaReport { readonly outcome: MailboxDeltaOutcome; /** How many UIDs the search returned above the cursor. */ readonly found: number; /** How many were handed to the sink and accepted. */ readonly delivered: number; /** UIDs the search returned and the FETCH did not: expunged in between. */ readonly vanished: number; /** Where the cursor stands coming out. */ readonly cursor: MailboxCursor; /** The failure, for `delivery-failed` and `read-failed`. */ readonly error: unknown; /** * Which command failed, for `read-failed`. * * A refused FETCH and a refused SEARCH are different claims about the * mailbox, the first says its contents are withheld, the second is * routinely transient, so the caller is told which one it was rather than * left to guess from the message text. */ readonly phase: 'search' | 'fetch' | null; /** * True when `read-failed` means "the server answered and this client could * not read the answer", rather than a refusal or a dead socket. * * The caller needs this as its own fact rather than as a string match on * `error.message`. An unreadable answer is retried, the message is still in * the mailbox and the cursor has not moved past it, but it is retried * against a condition that may never clear, so it needs a ceiling of its own * the way an unexpected throw does. Counting it requires being able to * recognise it, and recognising it by re-reading the sentence * `unreadableFetch` wrote would be a second classifier that silently stops * agreeing the day the sentence is reworded. */ readonly unreadableFetch: boolean; } /** * Ask the server for the UIDs above the cursor. * * Issued on the raw wire because `UID SEARCH UID n:*` is not on the client's * method surface, and bounded by the operation timeout because a search that * never answers is a dead connection wearing a healthy one's clothes. * * REFUSES A CURSOR OUTSIDE THE 32-BIT UID SPACE rather than searching from it. * `source-cursor.ts` and `MailboxCursorStore` between them make such a cursor * unstorable and unwritable, and `parseSearchNumbers` makes it unreadable off * the wire, so this should never fire, it is here because of what the failure * LOOKED like when it could. `UID SEARCH UID 9007199254740992:*` is a range a * server answers perfectly happily, and every UID it returns then fails the * `uid > lastSeenUid` filter below: the drain reports `complete, found: 0`, * the watcher reports healthy, and no mail is ever delivered again. Silence * that reports itself as success is the one failure mode this whole capability * exists to eliminate, so an impossible position is raised as a read failure, * `drainMailboxDelta` turns it into `read-failed` on the `search` phase, which * the caller backs off and discloses, rather than being searched from as if * it were a place. */ export declare function searchAboveCursor(wire: MailboxWire, lastSeenUid: number, options: { readonly timeoutMs: number; readonly signal: AbortSignal; }): Promise; /** * Fetch and process everything above the cursor, advancing behind each one. * * Batched at `deltaBatchSize`, and the batching is load-bearing rather than * defensive. `fetchEnvelopes` REFUSES a batch above `IMAP_MAX_FETCH_UIDS` * instead of trimming it, so a mailbox that took two thousand messages while * the daemon was down would fail its whole delta on one over-long `UID FETCH` * line and make no progress at all on any pass. Splitting it is what makes * that recovery possible; the ceiling is enforced when the setting is * resolved. * * Ascending UID order throughout, because the cursor is a high-water mark: * processing 12 before 11 and then failing on 11 would leave the cursor either * at 12 (losing 11) or at 10 (redelivering 12). In order, the cursor is always * exactly "everything below this is done". * * A UID the search returned and the FETCH did not is a message expunged in the * gap between the two, but ONLY when no unreadable response could have been * that UID's own. The cursor advances past a genuine expunge: the server has * said it is not there, and holding the cursor below a UID that no longer * exists would make every subsequent pass re-search from a point that can * never clear. * * Whether an unreadable response could have been this UID's is decided per * UID, not per batch, see `attributeUnreadable`. A batch-wide test was the * first version of this rule and it froze the cursor permanently: with UID 101 * genuinely expunged and UID 102 unreadable in the same batch, 101 was refused * as well, batch composition is stable across retries, and so the cursor could * never clear 100. The block that exists to avoid stepping over live mail must * not also refuse to step over mail the server has said is gone. */ export declare function drainMailboxDelta(deps: MailboxDeltaDeps): Promise; /** * Why the poll loop returned. * * `read-failed` is deliberately not split into "the socket died" and "the * server refused" here. Telling those apart decides whether the watcher * reconnects or reports `insufficient`, which is a capability judgement, and * it is made in one place, `classifyReadFailure`, rather than duplicated in * each loop. */ export type PollLoopOutcome = /** Shutdown requested. */ 'stopped' /** The search or fetch failed; the caller classifies the error. */ | 'read-failed' /** The sink refused a message; the caller retries after a pause. */ | 'delivery-failed'; export interface PollLoopResult { readonly outcome: PollLoopOutcome; readonly cursor: MailboxCursor; readonly error: unknown; /** Which command failed, for `read-failed`. */ readonly phase: 'search' | 'fetch' | null; /** True when `read-failed` was an unreadable answer. See `MailboxDeltaReport`. */ readonly unreadableFetch: boolean; /** How many drains ran, complete or not. */ readonly passes: number; /** * How many of those drains completed. * * The caller resets its consecutive-failure counters on a completed drain, * and a poll loop that ran for six hours and then hit one bad fetch has * completed thousands. Without this the caller only ever sees the drain that * ended the loop, so hours of demonstrated progress would count for nothing * and a ceiling meant for CONSECUTIVE failures would accumulate across * unrelated days. */ readonly completedDrains: number; } /** * Poll the mailbox until shutdown or until something the caller must act on. * * Runs when the server does not advertise IDLE, when IDLE is refused, and when * the owner asked for polling. It is not a lesser path: it finds exactly the * same messages by exactly the same search, just on a timer instead of on a * push, and a verification mail found within two minutes is found in time. * * The first drain happens IMMEDIATELY, before the first sleep. Whatever * arrived while the connection was down is already above the cursor, and * waiting out a poll interval before looking would add the interval to every * reconnect for no reason. */ export declare function runPollLoop(deps: MailboxDeltaDeps): Promise; //# sourceMappingURL=poll-loop.d.ts.map