/** * gmail-source.ts, Gmail as a first-class inbound source * (docs/inbound-email.md §3.4d). * * A user who has already adopted Google credentials should not have to find an * IMAP host, a username and an app password to get inbound mail for a mailbox * the daemon can already read. So `users.history.list` becomes a source behind * the same seam IMAP sits behind, delivering the same messages to the same * sink, and expectation matching, taint labelling, dedup, notice rendering and * owner disclosure are written once for both. * * Three things about this file are load-bearing. * * **It is polling, and it says so.** There is no push available here: * `users.watch` + Pub/Sub needs a public HTTPS endpoint and a GCP topic, which * a daemon on someone's own machine behind NAT does not have. `latency` * therefore always answers `poll`, and it answers with the interval CURRENTLY * IN FORCE rather than a constant, a five-second worst case while a signup is * mid-flight and a sixty-second one when nothing is pending are different * promises, and a status line that showed the wrong one would be describing a * mode the daemon is not in. * * **A body-less grant is a refusal, never a quiet mailbox.** `gmail.metadata` * authorizes `users.history.list` and excludes the message body, Google's own * scope description says "but not the email body". A delta fetched under it * would come back `ok` with every body empty, which is indistinguishable from * a mailbox on a slow day and is the worst shape a defect can take in a * delivery path. `collectHistoryDelta` already refuses before making the call; * this file maps that refusal onto an `insufficient` verdict carrying Google's * own remedy, and stops polling until the grant changes. It never delivers a * body-less message and never reports an empty success in its place. * * **The position never moves past a message that was not read.** Gmail's * history is a forward-only log: records at or below a `startHistoryId` are * never returned again, so a cursor that advances over an unfetched message * does not postpone it, it makes it permanently unreachable, silently, and * under a `healthy` verdict, which is the worst shape this delivery path can * take. `collectHistoryDelta` separates a message that is GONE (deleted * between `history.list` and `messages.get`) from one we FAILED TO FETCH (a * rate limit, a server fault, a refused token, a dead socket) and reports the * second in `GmailHistoryDelta.unreadable`. This file refuses to advance while * that is non-empty. It is the same rule, on the same contract, that * `ImapEnvelopeBatch.unreadable` gives the IMAP drain, one idea, expressed * twice only where the identifier differs. * * **Losing our place is the same event on both sources.** A 404 on a * `startHistoryId` that aged out of Gmail's retention window means exactly what * a changed `UIDVALIDITY` means: the stored position names nothing. It goes * through `MailboxCursorStore.resolveGmail`, which is the same * establish-at-the-current-high-water-mark path `uid-validity-changed` uses, * discard, re-establish, disclose, and do NOT replay the mailbox. There is one * implementation of that rule and this file does not add a second. * * What this file cannot do * ──────────────────────── * It cannot open, hydrate, widen or extend an expectation. Whether one is open * arrives as an INJECTED PREDICATE, a function returning a boolean, and * nothing that could create an expectation is imported here, which is §2.1's * structural removal of the spawn capability applied to the other capability an * arriving message must never reach. A source that could register what it is * waiting for could decide, by content, what to wait for. */ import { type InboundCapabilityPolicy } from './capability-policy.js'; import type { MailboxCursorStore } from './cursor-store.js'; import type { InboundCapabilityVerdict, InboundMailObserver, InboundMailSink, InboundMailTerminalFailure, WatcherClock } from './ports.js'; import type { InboundMailSource, SourceLatency } from './source.js'; import type { GoogleApiResult } from '../../google/api-client.js'; import { type HistoryDeltaDeps } from '../../google/history-delta.js'; /** * "Is somebody waiting on a message right now?" * * A plain predicate, injected. Deliberately NOT the expectation registry, not * a narrowed view of it, and not anything that transitively imports it: the * registry can open an expectation, and a type that names it would let this * file be handed the thing it must not have. The caller, the supervisor, which * is already authorized, answers the question. */ export type ExpectationPresence = () => boolean; /** * The cursor operations the Gmail source needs, projected off the real store. * * A `Pick` rather than a hand-written interface, and that is the rule this * round keeps re-learning rather than a preference. Two structurally-identical * `MailboxCursor` declarations in separate lanes had already drifted in * BEHAVIOUR, one clamped with `Math.max`, the other assigned unconditionally, * while everything still compiled. A `Pick` cannot drift from what it picks, * and if `resolveGmail` gains an argument this stops compiling in the ordinary * build. * * Note what is absent: `resolve` and `advance`, the IMAP pair. A `historyId` * cannot say which UIDs are done, so a Gmail source holding them could only * misuse them. */ export type GmailCursorPort = Pick; export interface GmailMailSourceDeps { /** Config account id, not an address. */ readonly account: string; /** The watched Gmail label, e.g. `INBOX`. Keyed the same way an IMAP mailbox is. */ readonly mailbox: string; /** Scopes, one history page, one message, the same narrow I/O `collectHistoryDelta` takes. */ readonly history: HistoryDeltaDeps; /** * The mailbox's CURRENT `historyId`, as Google sent it. * * Injected rather than called here, so this file stays free of any Google * client: it is handed I/O, exactly as `history` is. The composition fills it * with `GoogleApiClient.currentHistoryId()`, which reads * `users.getProfile().historyId`, "the ID of the mailbox's current history * record", Google's own words for the field, and the reason that call is the * right one for a path that establishes without backfilling. Needed on * exactly two paths, both of which establish rather than replay: a first run, * and a `resync-required` recovery. */ readonly currentHistoryId: () => Promise>; readonly cursors: GmailCursorPort; readonly sink: InboundMailSink; readonly clock: WatcherClock; /** Injected; see `ExpectationPresence`. */ readonly expectationOpen: ExpectationPresence; /** `surfaces.email.inbound.gmailPollSecondsExpecting`, in milliseconds. */ readonly pollExpectingMs: number; /** `surfaces.email.inbound.gmailPollSecondsIdle`, in milliseconds. */ readonly pollIdleMs: number; /** How long an `insufficient` verdict waits before re-probing. Defaults to the watcher's 60 minutes. */ readonly capabilityRecheckMs?: number | undefined; /** * `surfaces.email.inbound.onInsufficientCapability`, read at source-create * time by `source-factory.ts`. * * Optional, defaulting to `INBOUND_CAPABILITY_POLICY_DEFAULT`, the value the * schema ships, so an omitted dependency produces the shipped behaviour * rather than the permissive one. That direction matters: the weaker policy * announces mail it can never act on, and nothing should be able to select it * by forgetting to pass a field. */ readonly capabilityPolicy?: InboundCapabilityPolicy | undefined; readonly observer?: InboundMailObserver | undefined; } export declare class GmailMailSource implements InboundMailSource { readonly kind: "gmail-history"; private readonly deps; private readonly tracker; private readonly pollExpectingMs; private readonly pollIdleMs; private readonly capabilityRecheckMs; private readonly capabilityPolicy; private readonly halt; private terminal; constructor(deps: GmailMailSourceDeps); /** * The interval in force: fast while something is being waited for, slow when * nothing is. * * The predicate is asked every time rather than sampled once, because an * expectation opening is exactly the moment the answer has to change, a * signup starts mid-run and the next wait is the short one. */ get intervalMs(): number; /** Always polling, always the interval in force. Never the word "real-time". */ get latency(): SourceLatency; /** The current verdict, for a supervisor folding this into channel status. */ get verdict(): InboundCapabilityVerdict; /** Non-null once a verdict only a changed grant can clear has been reached. */ get terminalFailure(): InboundMailTerminalFailure | null; /** * Establish the position and answer with a capability verdict. * * On Gmail, unlike IMAP, capability is DECLARATIVE: the token states what it * may do, so a body-less grant is caught before the first delta is fetched * rather than on the first message. That asymmetry is the protocol, and it is * why an insufficient verdict here can be trusted to mean "this will not work * at all" rather than "the first fetch failed". */ start(signal: AbortSignal): Promise; /** * Poll until aborted. * * Sleeps FIRST: `start()` has already made one pass, and asking again * immediately would spend a call to learn what was just learned. An * `insufficient` verdict waits `capabilityRecheckMs` instead of the poll * interval, a grant does not change in five seconds, and re-asking on the * poll interval would turn a refusal into a request loop. */ run(signal: AbortSignal): Promise; /** Stop polling. Safe to call twice, and safe to call before `start`. */ stop(): Promise; private waitMs; private pollOnce; /** * Establish the position at the current high-water mark, for a first run or * after a `resync-required`. * * Never backfills on either path. A newly watched label starts listening now * rather than retroactively deciding about mail that arrived before it was * asked to, and an expired cursor is not an invitation to re-announce a week * of old mail because Google rotated its history window. */ private establish; /** * Hand a delta's messages to the sink, then move the cursor. * * The cursor moves ONCE, after the last message, and this differs from the * IMAP path on purpose. There, each UID is its own position, so the cursor * can advance per message. A delta's `historyId` is one position for the * whole batch: advancing to it after the first message would put the cursor * above messages two onwards, and a crash there would lose them silently. * So a refused delivery leaves the whole delta above the cursor and it is * fetched again, dedup turns the re-delivery into a suppressed duplicate, * which is the failure this design chooses. * * A delta carrying `unreadable` entries takes the same exit for the same * reason. Those are messages Google named and would not hand over, and on a * forward-only history log the cursor moving past them is not a delay, it is * a permanent loss. The one case that DOES let the position move is a * message deleted between `history.list` and `messages.get`, and * `collectHistoryDelta` has already removed those, nothing here has to * re-decide it. */ private deliver; /** * The verdict while `notice-only` is in force over a body-less Google grant. * * Built through `resolveInboundCapabilityPolicy` rather than written out * here, so the sentence the owner reads about which policy is in force comes * from the one function that decides it. A second sentence composed at this * call site is the mirror that drifts. */ private noticeOnlyVerdict; /** * What the owner is told when a delta could not be fully read. * * Says the three things that decide whether this needs acting on: how many * messages were unread, that the position did NOT move, and Google's own * words for why. The count of what was delivered is included because a * partly-read delta is a different situation from one that read nothing, and * a note that omitted it would read like a total outage during a single * rate-limited fetch. */ private unreadableDetail; /** * The first unreadable entry, as the `GoogleApiFailure` `transientVerdict` * already knows how to classify. * * The FIRST rather than a summary, because the verdict turns on the status * and a synthesised "several things went wrong" would carry no status to * turn on, a 401 and a 429 need opposite answers (a new grant versus * waiting), and collapsing them would pick neither. * * `fix` is carried through rather than blanked, because `transientVerdict` * reads it directly on the `credentials-rejected` branch. Blanked, a token * refused mid-delta would reach the owner as "your mail has stopped" with no * remedial step attached, which is this file's own failure mode (`refuse` * carries Google's `problem` and `fix` verbatim for exactly this reason) * reproduced on the neighbouring path. */ private firstUnreadableFailure; /** * A grant that cannot do the job: refuse, name it, and say what fixes it. * * `problem` and `fix` are carried VERBATIM from `history-delta.ts`, which * quotes Google's own scope descriptions. Rewriting them here would be a * second explanation of the same condition, and the two would drift. * * The two reasons are distinct on purpose, the state tracker announces on a * change of state OR reason, so collapsing them into one would mean a token * that lost its last body scope, leaving only `gmail.metadata`, produced no * new announcement at all. */ private refuse; /** * An ordinary API failure, classified by what Google answered. * * A refused credential is `insufficient`, nothing clears it but a new grant. * A rate limit or a server fault is `degraded`: it is "not yet", not * "cannot", and the delta is still above the cursor when it clears. */ private transientVerdict; /** * One delta message as the pipeline's own shape. * * Takes `GmailMessageMetadata`, which `GmailMessageBody` extends, so both * delta arms pass one in and neither can reach a `body` property through this * parameter. * * The body arrives as its own discriminated argument rather than being read * off `message` or inferred from a boolean flag. That is what makes "full" * and "we have text" impossible to state separately: `'full'` is the only * shape that carries a `text`, and `'metadata-only'` has nowhere to put one. * Sniffing the property instead would decide "does this have a body" from * whether a field happens to be present, and a body-capable grant returning a * genuinely empty message would then be indistinguishable from a metadata-only * one, which is the confusion the whole round exists to remove. */ private toInboundMessage; private pollingDetail; /** Record a verdict and return it, so call sites read as one expression. */ private record; private note; } //# sourceMappingURL=gmail-source.d.ts.map