/** * Can this watcher do its job at all, and if not, does the owner know? * * A watcher has three honest answers, not two. It is `healthy` when it is * doing what it was configured to do; `degraded` when it is running with less * than it wanted, polling because the server offers no push, or waiting out a * reconnect, and `insufficient` when it CANNOT do the job, because the * mailbox will not open or the credential is refused. * * The third one is the reason this file exists. A capability that cannot work * and does not say so is worse than one that is switched off, because the * owner believes they have a channel. So `insufficient` does not degrade * silently: the watcher does not run, and the condition is surfaced with the * one step that would fix it. * * Told once, not told repeatedly * ────────────────────────────── * The notification fires ONCE PER TRANSITION. Re-probing on an hourly timer * and announcing the same refused credential every hour trains the owner to * ignore the exact channel this capability depends on being read; by the third * night it is noise, and the message that mattered arrives in a stream they have * learned to swipe away. So a verdict identical to the one before it changes * nothing and says nothing, but the re-probe still runs, so fixing a scope or * a password recovers without a restart. * * The tri-state that this file exists to respect * ────────────────────────────────────────────── * `ImapConnectionReport.supportsIdle` is `boolean | null`, and `null` means * THE SERVER SAID NOTHING, never "no". `if (report.supportsIdle)` is * therefore wrong in a way that produces no error and no log line: it silently * polls forever against a server that would have pushed. Resolving a `null` * means asking, with a `CAPABILITY` command, and only a server that will not * answer even then leaves the question genuinely unknown. */ import type { EmailCapabilityFailureNotice, ImapIdleDecision } from '../imap-client.js'; import type { ImapBodyProbe } from '../imap-body-probe.js'; import type { InboundCapabilityReason, InboundCapabilityState, InboundCapabilityVerdict, InboundMailObserver, WatcherClock } from './ports.js'; export { resolveIdleSupport } from '../imap-open.js'; /** Build a verdict, taking its state and its fix from its reason. */ export declare function capabilityVerdict(reason: InboundCapabilityReason, detail?: string): InboundCapabilityVerdict; /** The state a reason implies. Exported so callers need no second table. */ export declare function stateForReason(reason: InboundCapabilityReason): InboundCapabilityState; /** * The verdict for a connection that opened, given what the mode asked for and * what the server turned out to offer. * * The IDLE decision itself is NOT made here. `resolveIdleSupport` in * `imap-open.ts` owns it, resolves "the server said nothing" by issuing * `CAPABILITY`, and names how it found out. This file only turns that answer * into a watcher state, because two resolvers would be two answers and the * quiet one would win. * * Explicitly configured polling is `healthy`, not `degraded`: the watcher is * doing exactly what it was told, nothing is wrong, and a permanent amber * light for a working configuration is alarm fatigue. Polling because push was * unavailable is `degraded`, because something the owner would want to know * did not go the way it was meant to. */ export declare function verdictForOpenConnection(input: { readonly mode: 'idle' | 'poll' | 'auto'; readonly idle: ImapIdleDecision; /** * What the connect-time body probe demonstrated, when one ran. * * Checked BEFORE the transport, and the ordering is the ruling rather than * an accident: whether message content can be read at all outranks whether * it arrives by push or by poll. A watcher that reported `polling-no-idle` * while unable to prove it can read anything would be describing the less * consequential of two facts. */ readonly body?: ImapBodyProbe | undefined; }): InboundCapabilityVerdict; /** * The verdict a body-capability reading implies, or null when it implies none. * * `readable` implies nothing on its own, the transport still decides between * `idle-push`, `polling-configured` and a polling fallback, so it returns * null and lets that decision stand. * * `unproven` is `degraded`, and this is the ruling worth being able to * re-argue later with the facts in front of you. `insufficient` would refuse * to run a watcher on a genuinely empty mailbox, which is what a freshly * created signup alias IS, and would therefore break the exact journey this * capability exists to serve. `healthy` would claim a capability nobody has * demonstrated, the same shape as the Gmail metadata-scope defect, which also * looked like success. `degraded` runs, tells the owner it has not yet been * able to prove it can read message content, and lets the first real message * settle it. * * `unreadable` never reaches here in the connect path: the connection port * raises before it hands a connection back. It is mapped anyway, because a * reading produced anywhere else must not silently fall through to "fine". */ export declare function verdictForBodyReadability(body: ImapBodyProbe): InboundCapabilityVerdict | null; /** Everything the caller can read off a failure, already classified. */ export interface OpenFailureVerdict { readonly verdict: InboundCapabilityVerdict; /** * True when retrying on a backoff cannot help and the watcher must stop * until something changes. Taken from the failure's own record, never * re-derived here. */ readonly terminal: boolean; /** The routable record, when the failure carried one. */ readonly notice: EmailCapabilityFailureNotice | null; } /** * Turn a failed `open()` into a verdict. * * This used to re-read the server's wording ahead of the reason, because * `composeOpenFailure` classified by PHASE and called every refusal at LOGIN a * rejected credential, which is terminal, and which Gmail's * `NO [LIMIT] Too many simultaneous connections` arrives as. That workaround * is gone: the email layer now classifies from the response code and the * wording first, produces `server-unavailable` for a refusal about the server * rather than the account, and prefers the non-terminal reading when the * refusal is ambiguous. * * So there is one classifier and this reads its answer. Two classifiers is how * the wrong one wins silently, and the wrong one here stops mail delivery * until a person notices. * * The owner-facing sentence comes from the notice as well, so a rejected * credential produces ONE text wherever it surfaces rather than one from the * email layer and a competing one from here. */ export declare function classifyOpenFailure(error: unknown): OpenFailureVerdict; /** * Whether a failure while reading messages means the mailbox cannot be read at * all, or merely that this connection ended. * * The server's own words are read by `classifyServerRefusal`, the same * function the open path uses, so a `[LIMIT]` refused mid-session is the same * `server-unavailable` it would have been at login. The phase reason handed in * is deliberately the non-terminal one, so a refusal that says nothing about * itself falls through to the rule below rather than to a verdict that stops * the watcher. * * When the server named nothing, the phase decides, and the two phases are * different claims: * * - A `NO`/`BAD` to a **FETCH** is a mailbox whose contents the server will * not hand over. Arrival can be observed and never read, so the capability * is `insufficient` and reconnecting achieves nothing. * - A `NO` to a **SEARCH** is not that claim. Servers refuse searches * transiently, under load, and on a folder being reindexed, and stopping * for an hour over one would turn a hiccup into silence. */ export declare function classifyReadFailure(error: unknown, phase?: 'search' | 'fetch'): OpenFailureVerdict; /** * Classify a failure that escaped the reading path entirely, a cursor write * that threw, an injected dependency that broke, a bug. * * This is the classifier behind the rule that an unexpected throw is CAUGHT, * named and reported rather than allowed to unwind the run loop into a * `.catch(() => undefined)`. `consecutive` is how many of these have happened * in a row without a completed drain in between: a first `ENOSPC` is a wait, * and the tenth is a condition the owner has to be told about, because at that * point "it will clear on its own" has been disproved by the machine. */ export declare function classifyLocalFailure(error: unknown, consecutive: number, giveUpAfter: number): OpenFailureVerdict; export declare function errorText(error: unknown): string; /** * Holds the current verdict and announces only the changes. * * `record()` returns whether it announced, so the caller can tell "this is * new" from "this is the same thing again" without comparing verdicts itself *, which is the comparison that gets forgotten, and forgetting it is what * turns an hourly re-probe into an hourly alarm. * * The comparison is on state AND reason, and deliberately not on `detail`: the * detail carries the server's own wording, and a server that phrases the same * refusal differently between attempts, a session id in the text, a * timestamp, must not read as a new condition. The stored verdict is still * updated to the latest wording, so status shows what the server last said. */ export declare class CapabilityStateTracker { private readonly account; private readonly mailbox; private readonly clock; private readonly observer; private verdict; private transitions; constructor(input: { readonly account: string; readonly mailbox: string; readonly clock: WatcherClock; readonly observer?: InboundMailObserver | undefined; }); get current(): InboundCapabilityVerdict | null; get state(): InboundCapabilityState; /** How many times the state or reason has actually changed. */ get transitionCount(): number; /** * Record a verdict. Returns true when this was a transition and the observer * was told, false when it was the same condition observed again. */ record(next: InboundCapabilityVerdict): boolean; } //# sourceMappingURL=capability.d.ts.map