/** * source-factory.ts, building the source the selection chose. * * The supervisor decides WHICH source reads the mailbox; this file is the one * place that knows how to construct one. Split out for the same reason * `connection.ts` is: constructing an IMAP source means resolving a host, an * account and a stored secret, and a supervisor that reached for those itself * could not be exercised without a machine that has them. * * The credential is resolved per CONNECTION, not once at construction * ───────────────────────────────────────────────────────────────── * `MailboxConnectionPort.open()` is called again on every reconnect, and this * factory resolves the password inside it. A password rotated while the daemon * is running is therefore picked up by the next reconnect rather than at the * next restart, and a credential that is missing entirely throws from inside * `open()`, on the watcher's own connection path, where `classifyOpenFailure` * already turns it into a `credentials-missing` verdict with the step that * fixes it. That is why nothing here catches it: a second, quieter report of * the same fact would be the one nobody sees. * * The Gmail arm arrives as an injected builder * ──────────────────────────────────────────── * `GmailMailSource` needs an `HistoryDeltaDeps` I/O slice over an adopted * Google credential and a `currentHistoryId()` probe, and neither is derivable * here for the same reason an IMAP password is not: they are composition-root * facts, and a factory that reached for them itself could not be exercised * without a machine that has Google adopted on it. `composeInboundMail` builds * the builder, over `GoogleApiClient.historyDeltaPort()` and * `currentHistoryId()`, which exist now, and passes it here. * * `deps.gmail` remains OPTIONAL, and that is a seam rather than a shrug: a test * exercising the IMAP arm should not have to supply a Gmail one. What is no * longer optional is the daemon composition's Gmail READER, so the shape that * used to reach production, every arm complete, nothing injected, `create()` * answering `null` on every machine, cannot recur silently. * * When there is no builder, `create()` returns `null` and the supervisor * REPORTS that; it does not fall back to IMAP. The refusal is the point: * quietly serving IMAP for a mailbox the owner asked to be read over Gmail is * the silent substitution §3.4d forbids, and quietly serving it under an `auto` * selection would report a `google-adopted` basis while running the other * source. */ import { type InboundCapabilityPolicy } from './capability-policy.js'; import { type ConfigReader, type SecretReader } from '../surface-config.js'; import type { EmailTransportPort } from '../email-service.js'; import type { MailboxCursorStore } from './cursor-store.js'; import type { InboundMailSource } from './source.js'; import type { InboundMailObserver, InboundMailSink, InboundWatcherSettings, RandomSource, WatcherClock } from './ports.js'; import type { InboundMailSourceFactory } from './supervisor.js'; /** * The Gmail poll cadence, resolved from config into milliseconds. * * A named type rather than two loose numbers because they travel together * through three hops and are trivially swappable at every one of them, the * fast one is five seconds and the slow one is sixty, and nothing about * `(number, number)` would stop a caller getting them the wrong way round. */ export interface GmailPollIntervals { /** `surfaces.email.inbound.gmailPollSecondsExpecting`, in milliseconds. */ readonly pollExpectingMs: number; /** `surfaces.email.inbound.gmailPollSecondsIdle`, in milliseconds. */ readonly pollIdleMs: number; } /** * What the Gmail arm needs from whoever composed a Google credential. * * The two poll intervals are handed IN rather than left to the builder, and * that is the fix rather than a nicety. `GmailMailSourceDeps` documents * `pollExpectingMs` and `pollIdleMs` as coming from * `surfaces.email.inbound.gmailPollSecondsExpecting` and `…gmailPollSecondsIdle`, * and nothing anywhere mapped those keys onto those fields: both settings had a * schema row, a validated range, and a description the owner reads in the * settings UI, and not one reader. Leaving the builder to pick its own numbers * would have made that permanent, because the composition that can see the * config is not the one that knows how to talk to Google, so the numbers have * to cross that boundary explicitly or they never cross it at all. * * Resolved once, where config lives, and arriving here already in * milliseconds. A builder that invented its own would be answering a question * the owner has already answered. */ export type GmailSourceBuilder = (input: GmailPollIntervals & { readonly account: string; readonly mailbox: string; readonly sink: InboundMailSink; readonly observer: InboundMailObserver; /** * `surfaces.email.inbound.capabilityRecheckMinutes`, in milliseconds. * * Included because `GmailMailSourceDeps.capabilityRecheckMs` defaults to the * watcher's constant when omitted, and a Gmail source silently re-probing on * a different schedule from the one the owner configured is the same class of * defect as the two above, a setting that appears to apply and does not. */ readonly capabilityRecheckMs: number; /** * `surfaces.email.inbound.onInsufficientCapability`. * * Handed in for the same reason the two intervals are: the composition that * can see the config is not the one that knows how to talk to Google, so the * value crosses that boundary explicitly or it never crosses it at all. This * is the key the schema has described since it was added and nothing read, * `notice-only` and `refuse-and-notify` were the same behaviour until this * argument existed. */ readonly capabilityPolicy: InboundCapabilityPolicy; }) => Promise; export interface InboundMailSourceFactoryDeps { /** Reads `surfaces.email.*`. The daemon tier, and only the daemon tier. */ readonly getConfig: ConfigReader; /** Where the mail password is read from. One store, see `resolveEmailPassword`. */ readonly secrets: SecretReader; /** * The real sockets. A test passes one whose members throw. * * `connectImapPlain` rides along because `surfaces.email.imap.secure` governs * the inbound connection exactly as it governs the mailbox service's: one key, * both IMAP paths, or it is honoured on one and quietly ignored on the other. * It is optional on the port itself, so a transport that predates it still * satisfies this. */ readonly transport: Pick; readonly cursors: MailboxCursorStore; /** Already resolved from config into milliseconds by the caller. */ readonly settings: Omit; readonly clock?: WatcherClock | undefined; readonly random?: RandomSource | undefined; /** * Supplied by `composeInboundMail`, which resolves an adopted Google * credential through `createDaemonGmailInboundReader`. * * Optional so the IMAP arm can be exercised without one. It was optional * before too, and nothing filled it, the daemon composition now does, and * ITS option is required, which is where the reachability is enforced rather * than here. */ readonly gmail?: GmailSourceBuilder | undefined; } export declare function createInboundMailSourceFactory(deps: InboundMailSourceFactoryDeps): InboundMailSourceFactory; //# sourceMappingURL=source-factory.d.ts.map