/** * supervisor.ts, inbound mail's lifecycle owner (docs/inbound-email.md §3.5). * * IMAP has no inbound HTTP request, so email is the second of the two adapter * lifecycle shapes: a stateful supervisor with `start()` / `stop()` / `status`, * owned by `BuiltinChannelRuntime` and armed at boot, exactly like * `TelegramIngressSupervisor`. Everything under it, IDLE, the poll fallback, * capability classification, backoff, the cursor rules, dedup, already exists * and is already tested; this file starts it, stops it, and tells the truth * about what it is doing. * * Four properties, each of which is why this is a file rather than four lines * in the composition root. * * **It rehydrates before it serves.** `runRecoverySweep()` runs first, so a * cursor for an account no longer in config, a torn record and an already- * expired expectation are gone before the first message is looked at. Then the * expectation registry hydrates with each record's ORIGINAL absolute expiry, * never a fresh window, because a restart that extended a grant would be a * grant nobody remembers issuing (§9.2). Only then is a source started, and it * resumes from the persisted cursor, so mail that arrived across the daemon's * hourly auto-restart is fetched rather than skipped. * * **It refuses rather than substitutes.** The source is chosen by * `selectInboundMailSource`, whose refusal arm carries the remedial step; * a refusal stops the supervisor with that reason in `status`, and it never * quietly serves the other source instead (§3.4d). The same rule applies one * level down: a selected source the factory cannot build is reported, not * swapped. * * **Its status is what it is doing, not what is configured.** `status` is * derived from the running source and the last capability verdict. A mailbox * whose credential is still in the config file and whose watcher is dead * reports `inactive`, with the reason. * * **It cannot start work.** It holds stores, a sink, a source factory and a * notice sender. There is no agent manager, no session broker and no reply * queue in any signature in this file, §2.1's structural removal, at the one * seam that would otherwise have been handed all of them. */ import { DedupingInboundMailSink } from './sink.js'; import { type InboundMailHealthEntry } from './health.js'; import { type InboundNoticeHealth } from './notice-health.js'; import { type InboundMailSource } from './source.js'; import { type InboundMailSourceKind, type InboundSourceSelectionInput } from './source-selection.js'; import type { InboundExpectationRegistry } from './expectation-registry.js'; import type { PersistedExpectationStore } from './expectation-store.js'; import type { InboundMailHousekeeper } from './housekeeping.js'; import type { MailboxCursorStore } from './cursor-store.js'; import type { InboundMailStore } from './record-store.js'; import type { InboundCapabilityVerdict, InboundMailObserver, InboundMailTerminalFailure, InboundMailboxMessage } from './ports.js'; import type { InboundMailStatusSnapshot } from './supervisor-status.js'; import type { InboundMailboxWatcherStatus } from './watcher.js'; import type { ConfigManager } from '../../config/manager.js'; /** * The disclosure shapes, re-exported so a file split does not move the public * surface. Their declarations live in `supervisor-status.ts`. */ export type { DisclosedCursor, InboundMailRetentionReport, InboundMailSourceReport, InboundMailStatusSnapshot, InboundMailStoreHealth, } from './supervisor-status.js'; /** * The status triple every poll/socket surface reports (§3.5). * * `mode` is projected off the watcher's own declaration rather than restated: * `'idle' | 'polling' | 'inactive'` is the watcher's vocabulary, and a second * copy of it here is a second declaration that can drift from the thing whose * behaviour it describes. */ export interface InboundMailSupervisorStatus { readonly mode: InboundMailboxWatcherStatus['mode']; /** Why this mode, and, when inactive, exactly what to fix. */ readonly reason: string; readonly running: boolean; } /** The config reads this supervisor makes, projected off the real manager. */ export type InboundMailConfigPort = Pick; /** * How a selected source is built. * * A port rather than a `switch` in this file, for one reason: building an IMAP * source needs a host, a username and a resolved secret, and building a Gmail * source needs an adopted Google credential and a history probe. Those are * composition-root facts, and a supervisor that reached for them itself could * not be exercised without a machine that has them. * * Returning `null` means "this build cannot serve that source" and is reported * as such, never silently answered with the other one. */ export interface InboundMailSourceFactory { create(input: { readonly kind: InboundMailSourceKind; readonly account: string; readonly mailbox: string; readonly sink: DedupingInboundMailSink; readonly observer: InboundMailObserver; }): Promise; } export interface InboundMailSupervisorDeps { readonly config: InboundMailConfigPort; /** Config account id and mailbox this supervisor watches. */ readonly account: string; readonly mailbox: string; readonly sources: InboundMailSourceFactory; /** * The facts `selectInboundMailSource` needs and this module cannot know: is a * Gmail source available, is the mail account a Gmail one, and, when the * first is false, why. Asked at `start()`, so a credential adopted after * boot is seen on the next start rather than the next restart. */ readonly selectionFacts: () => Promise>; readonly cursors: MailboxCursorStore; readonly records: InboundMailStore; readonly expectations: InboundExpectationRegistry; /** * The expectation store's live bounds, for disclosure. * * Projected off the store rather than restated as a constant here: the cap * is the store's to enforce, and a number copied into this file would be a * second declaration that reports a bound the store is not applying. */ readonly expectationPolicy: Pick; readonly housekeeper: InboundMailHousekeeper; /** What a found message goes through. Injected: the supervisor owns lifecycle, not intake. */ readonly handle: (message: InboundMailboxMessage) => Promise; /** * Whether arriving mail is actually reaching the owner. * * Written by the intake, read here. A structural notice refusal completes the * pass, the cursor advances and the message is never re-announced, so the * watcher goes on looking perfectly healthy while every message it finds goes * unannounced. That is the condition this reads, and it is why `health()` * reports `degraded` for it and `status.reason` says so in words. * * Optional so a supervisor can be exercised without one, in which case it * behaves exactly as before. The composition root passes the same instance it * gives the intake. */ readonly noticeHealth?: Pick | undefined; readonly observer?: InboundMailObserver | undefined; readonly now?: (() => number) | undefined; } export declare class InboundMailSupervisor { private readonly deps; private readonly now; private source; private abort; private loop; private currentStatus; private selection; private verdict; private terminal; private starting; /** Start-time steps that failed without stopping the watcher. Carried into `status`. */ private degradations; constructor(deps: InboundMailSupervisorDeps); /** * What this supervisor is doing, with any notice refusal folded into the * sentence. * * Appended here rather than at `settle()` because the two facts arrive at * different times: the mode is decided when the source starts, and whether * the owner is being told is decided per message, long afterwards. A status * that reported the first and not the second is exactly the reading that let * a mailbox whose every notice was refused go on saying `idle`. */ get status(): InboundMailSupervisorStatus; private noticeRefusal; /** The last capability verdict reached, or null before any probe. */ get capability(): InboundCapabilityVerdict | null; /** The last failure only a change can clear, or null. */ get terminalFailure(): InboundMailTerminalFailure | null; /** * Arm inbound mail. Safe to call repeatedly: a running supervisor is stopped * first, so a config change re-decides cleanly rather than layering a second * source on top of the first. * * Concurrent calls share one start rather than racing, the cluster * coordinator and a config-change restart can both arrive, and two * simultaneous starts against one mailbox is the duplicate-notice failure * the cluster gate exists to prevent, reproduced inside a single process. */ start(): Promise; private runStart; /** * Hold the source's run loop and observe how it ends. * * This was `source.run(signal).catch(() => undefined)`, and that single * expression is what made a permanent death invisible: the rejection was * discarded unread, nothing was reported, and `status.running` went on * saying `true` for a loop that had already returned. A run loop can end in * exactly three ways and each has to be answered: * * - the signal fired, a deliberate stop, and `stop()` settles the status; * - it returned on its own, the source decided it was done, which nothing * asked it to do, so it is reported as a stop with the reason unknown; * - it threw, the failure is named, routed to the observer as a terminal * failure (the observer is where the OWNER is reached from), and put in * `status`. * * A superseded controller is ignored: a restart has already replaced this * source, and letting the old loop's ending overwrite the new one's status * would report a stop for a watcher that is running. */ private watch; private settleLoopEnd; /** Record a terminal failure and forward it, without letting the route's failure become ours. */ private announceTerminal; /** * A setting the running source re-reads on every reconnect has changed; look * again now rather than at the next scheduled check. * * This is the seam that makes `recheckNow()` real. It existed on * `InboundMailboxWatcher`, was delegated verbatim by `ImapMailSource`, and was * called by NOTHING, its own comment said "called when configuration * changed" while no configuration change reached it, because nothing * subscribed to any `surfaces.email.*` key anywhere. An owner who fixed a * wrong IMAP host waited out `capabilityRecheckMinutes` to find out whether it * had worked, or restarted the daemon. Both are the restart this platform is * supposed not to need. * * Deliberately NOT a restart. `start()` re-runs the recovery sweep, re-decides * the source and rebuilds the dedup cache, none of which a corrected password * warrants, and a restart per settings save is how a mailbox ends up * reconnecting in a loop while somebody is still typing. The reconnect the * watcher was going to make anyway is simply made now. * * A no-op when nothing is running, and a no-op on a source that declares no * `recheckNow`. Both are honest answers to "look again", not swallowed * failures: whether there is anything to look again AT is the source's own * business, see `InboundMailSource.recheckNow`. */ recheckNow(): void; /** * Stop reading and release the connection. * * Does not resolve until the source has genuinely stopped, the cluster * handoff depends on that promise being honest, because the successor node * is told to start only after this resolves, and two nodes both holding a * connection to one mailbox both notify. */ stop(): Promise; private releaseSource; /** Email's health entry, read from live state (never from config presence). */ health(): InboundMailHealthEntry; /** * Everything `email.inbound.status` discloses: the cursors, the open * expectations, the capability state, the source in force with its latency, * and what each store retains. */ describeStatus(): Promise; /** * One entry per persisted store: read normally, discarded, or unreadable now. * * Always all three, in a fixed order, because an omitted store reads as a * store with nothing to say, and "nothing to say" is the wrong answer about * a file whose contents were thrown away. */ private describeStores; private describeSource; /** * The dedup window, in milliseconds. * * Read from config so the window is tunable, and NOT because it has a * correctness floor. It used to say it did, "it must outlast a restart * cycle", and that was structurally false: the cache is built fresh three * lines above, inside `runStart()`, which also runs on a config-change * restart and on a cluster-gate handoff. A restart does not expire the claim, * it destroys the cache, and no value here changes that. A floor guarding a * property the mechanism cannot provide at any setting is a floor guarding * nothing. * * What the window genuinely bounds is two passes inside ONE process arriving * at the same message, an IDLE wake overlapping a fallback poll, or a retry * after a failed pass. Those are seconds apart, so any sane value works and * the default is generous rather than critical. What actually stops a * restart-crossing duplicate ANNOUNCEMENT is the record store: `intake.ts` * reads the message's own record before announcing, and that survives. */ private dedupTtlMs; /** * The caller's observer with the two facts `status` is built from kept. * * Every call forwards. Nothing is filtered, the adapter reads the stream, * it does not consume from it. */ private observer; /** * Set the status, with anything that degraded at start-time appended. * * Appended rather than kept in a separate field nobody reads: `reason` is the * one string every surface renders, and a start that half-worked has to be * visible in the sentence the owner is actually shown. */ private settle; } //# sourceMappingURL=supervisor.d.ts.map