/** * intake.ts, what happens to a message the sink accepted. * * This is the middle the capability was missing. Everything either side of it * already worked: the watcher found the message, the matcher could answer * about it, the producer could render a notice, each channel could escape one, * and `deliverSurfaceNotice` could send it. Nothing joined them, so an * arriving message reached the end of the sink and stopped, the same shape as * a notice that is rendered and never sent. * * The order is the whole of the design: * * 1. **Delivery evidence first.** `deliveredRecipientFromDeliveryHeaders` * brands ONLY the top-most `Delivered-To`, which is the one the receiving * agent stamped. The `To:` header travels as `unverifiedToHeaderClaim` * and is passed for display alone; it is never the correlation key, and * there is no path here that makes it one. * 2. **Ask the matcher, never tell it.** The intake holds an * `ExpectationMatcher`, whose signatures are projected off * `VerificationExpectationBook.matchCandidate`, so it can ask whether a * message satisfies something already registered, and can spend what it * was handed, but cannot open, hydrate, widen or extend anything. A * message may satisfy an expectation; it may never create one. * 3. **Ask whether this message was already announced.** `findByMessage`, * keyed on the identity the receiving server assigned. See below. * 4. **Record what is about to happen, including a refusal.** The record * goes in BEFORE the notice, in the `pending` state when a notice is * still to be attempted. See below, this ordering is load-bearing. * 5. **Render structure; the channel layer escapes.** The producer returns * spans and `renderNoticeForChannel` picks the escaper for the surface * the notice is about to be delivered to. This file CALLS that; it owns * no escaper and defines no character set. * 6. **Send the notice.** The last step that may throw. * 7. **Settle: the real notice status, then the grant.** Both run after the * notice is out, so neither may throw, see "nothing throws after the * send" below. * * Why the notice is sent AFTER the record, and not before it * ────────────────────────────────────────────────────────── * It used to be first, and the consequence is the same shape as the consume * defect below it: the notice went out, `records.record` then threw, a full * disk, a read-only state directory, the intake threw, the sink released its * claim, the cursor stayed below the message, and the next pass fetched it and * **announced it again**. Every pass. Dedup could not suppress it, because * releasing the claim is exactly how the retry is enabled, so the guard against * duplicate notices was the mechanism producing them. Reproduced: five * redeliveries, five notices, zero records. * * The rule the previous round stated for the expectation book applies without * modification to the notice, and had simply not been applied to it: **a pass * either completes, or it leaves the world exactly as it found it.** The notice * is the one step in this handler that cannot be undone, a message on the * owner's phone is not retractable, so it goes LAST among the steps that can * fail, and everything that might fail goes in front of it. A failing record * write now happens with nothing announced, so the redelivery announces once * rather than again. * * The record therefore has to be written before its own `noticeStatus` is * known, which is what `pending` is for. It is not a placeholder: a record * sitting at `pending` is the true statement that the message was recorded and * the notice has not resolved, and it is reachable in two real situations, a * transport that keeps refusing with `delivery-failed`, and a daemon killed * between the two steps. The second write, once the outcome IS known, replaces * that row rather than appending beside it, because `record()` upserts on the * message key. * * Nothing throws after the send * ───────────────────────────── * The corollary, and it is the part that is easy to undo by accident. Once the * notice is out, a throw from ANY later step, the second record write, the * consume, releases the claim and re-announces, which is the defect above by a * different verb. So both are attempted, and a failure in either is reported * through the observer and swallowed. What that gives up is stated rather than * glossed: a failed second write leaves the record at `pending` (disclosed, and * corrected by the next redelivery if there is one), and a failed consume * leaves the grant open (bounded by the expectation's own window and disclosed * by `onExpired`). Both are recoverable states that announce themselves. A * duplicate notice is neither. * * Why the message is asked about before it is announced * ──────────────────────────────────────────────────── * The sink's dedup cache is an in-process `Map`. It cannot survive the daemon's * hourly auto-update restart, and the cursor only advances after a pass * completes, so a restart in that window redelivers a message that was already * announced, into an empty cache, and the owner is told twice. No TTL fixes * that at any value, because the cache does not outlive the process (§6). * * The durable answer already existed: the record store is keyed by the identity * the receiving server assigned, and it says what happened to the notice. A * message whose record already reads `delivered` is not announced again. It * fails in the safe direction throughout, a discarded record file, a reaped * record, a store that cannot be read all lead to announcing, and §6's ruling * is that a duplicate beats silence, and it needs no new persisted state, no * new bounds and no new sweep. * * Why the grant is spent LAST, and not where the match is made * ──────────────────────────────────────────────────────────── * `matchCandidate` defaults to `consume: true`, so this file used to delete * the expectation before it had even tried to send the notice. On the one * failure the design explicitly retries, `delivery-failed`, the intake threw * with the grant already gone: the sink released its claim, the cursor stayed * put, the message was redelivered exactly as intended, and pass 2 found an * empty book and recorded `no-expectation`. The retry recovered the notice and * destroyed the thing the notice was about. The owner was told their own * verification mail was unsolicited. * * Nothing between the match and the end of the handler may mutate the * expectation, so every throw in between, a transport failure, a failed record * write, a killed process, hands the redelivery the same book pass 1 saw, and * it correlates identically. * * The alternative was to keep consuming up front and have the retry reuse a * RECORDED match. That needs a second durable store, keyed by message * identity, which then needs its own bounds, its own reaping and its own * recovery rules, a whole persisted state machine whose only job is to undo * an ordering choice this file is free to make differently. Deferring the * consume needs nothing new at all. * * What deferring costs, stated rather than glossed: the window between "notice * delivered" and "grant spent" is a few in-process statements, and a crash * inside it leaves the expectation open. That is bounded by the expectation's * own window, it is disclosed when the window elapses (`onExpired`), and the * redelivery re-matches and re-spends it if the dedup claim did not survive * either. The cost of the old ordering was unbounded by comparison: the * correlation was destroyed outright and no later pass could recover it. * Between a state that expires on its own and a fact that is gone for good, * this takes the one that expires. * * Which failures are retried, and why the distinction is load-bearing * ────────────────────────────────────────────────────────────────── * The sink claims a message BEFORE the work runs, and a claim that outlives a * failed attempt suppresses the retry, so a thrown error here is what * releases the claim and leaves the cursor below the message. Throwing is * therefore how a message gets another chance, and NOT throwing is how it is * declared done. * * - `delivery-failed`, the transport itself failed. Transient, so it throws: * the claim is released, the cursor stays put, and the next pass tries * again. Silence is the failure mode this capability exists to eliminate. * - Every other refusal (`no-route-binding`, `surface-delivery-disabled`, * `no-deliverable-target`, `unsupported-delivery-surface`, `empty-text`) * is STRUCTURAL: the owner has no route, or delivery to it is switched * off. Retrying cannot clear any of them, and throwing would pin the * cursor below a message that fails identically on every future pass while * the mailbox never drained. * * Recording them is not enough, and that gap is what `notice-health.ts` * closes. A structural refusal returns normally, so the cursor advances and * nothing ever re-announces the message, meaning the record IS the only * trace, and a record is something the owner has to go and look for. The * condition is therefore also reported to `noticeHealth`, which latches it, * counts the messages going unannounced under it, logs it once, and makes * `email.inbound.status` and the health entry say `degraded` for as long as * it lasts. A capability quietly demoted to a recorder is not a healthy one. * - A store write BEFORE the notice throws, because a retry can genuinely do * better and nothing has gone out yet. A store write AFTER the notice does * not, see "nothing throws after the send" above. * * What this does NOT do, stated so nobody reads the absence as an oversight: * * - **No link validation.** The IMAP path fetches ENVELOPES, not bodies, so * there are no links to validate at this point; `links` is empty and the * notice says nothing about links rather than claiming none were present. * Body-bearing link verdicts belong to the round that fetches bodies. * - **No Gmail record.** `InboundMailRecord` requires `uidValidity` and `uid` * as positive integers, and a Gmail message has neither, the store was * written before Gmail became a source and never revisited (the same defect * §13.2 records for the dedup identity). Rather than write a record that * `validateInboundMailRecord` would discard on the next load, a record that * exists until a restart and then does not, a Gmail message is announced * and its absence from the store is reported through the observer. That is * visible; a vanishing record is not. * - **It cannot start work.** There is no agent manager, session broker or * reply queue in any signature in this file. */ import { type StructuredNotice } from '../inbound-notice.js'; import type { AutomationRouteBinding } from '../../automation/routes.js'; import type { ExpectationMatcher } from './expectation-registry.js'; import type { InboundNoticeHealth } from './notice-health.js'; import type { InboundMailboxMessage, InboundMailObserver } from './ports.js'; import type { InboundMailStore } from './record-store.js'; import type { SurfaceNoticeDelivery } from '../../daemon/types.js'; /** `surfaces.email.inbound.notice.mode`, as the intake reads it. */ export type InboundNoticeMode = 'all' | 'expected-only' | 'none'; /** * What this module needs from a route binding: the surface it points at. * * A `Pick` off the real declaration rather than a restated `{ surfaceKind: * string }`. A restated shape would accept a hand-built object whose * `surfaceKind` is any string at all, and the whole point of reading the * binding is that the value came from the binding. */ export type NoticeRouteBinding = Pick; /** * Where the owner's notice goes, or, when there is nowhere, WHY. * * A discriminated answer rather than `NoticeRouteBinding | null`, and the null * is precisely what went wrong. Two entirely different states produced it: the * owner has connected no channel, and the whole route-binding feature is * switched off (`integrations.routeBinding`), in which case * `RouteBindingManager.listBindings()` answers `[]` no matter how many bindings * are stored. Both arrived here as `null`, both were recorded as * `no-route-binding`, and the second, an unrelated flag silently turning * inbound mail into a recorder, was indistinguishable from the first. * * The refusal's prose lives with the caller that resolves the route, because * that is where the config keys are known. This module records the refusal and * reports it; it does not name settings it cannot read. */ export type NoticeRouteResolution = { readonly kind: 'bound'; readonly binding: NoticeRouteBinding; } | { readonly kind: 'unavailable'; /** The condition's own name, finer-grained than the delivery layer's vocabulary. */ readonly reason: string; /** What is wrong, in one sentence, for a person. */ readonly detail: string; /** The remedial step. */ readonly fix: string; }; export interface InboundMailNoticeRoute { /** The owner's notice route binding, or why there is none. */ resolveBinding(): NoticeRouteResolution; /** * `DaemonSurfaceDeliveryHelper.deliverStructuredNotice`, bound to that * binding. * * Takes the STRUCTURE, never a rendered string. The intake therefore cannot * pick an escaper, cannot pick the wrong one, and cannot skip escaping, it * never holds a channel-formatted string to pass. That is the same guarantee * the producer has, extended across the port: the only code that turns spans * into text is the code that knows the destination. */ send(notice: StructuredNotice): Promise; } export interface InboundMailIntakeDeps { /** * Match-only. The methods that insert are absent from this type, so an * arriving message cannot register what it wants to be waiting for. */ readonly expectations: ExpectationMatcher; /** * `record` and `findByMessage`, the intake writes one message's record and * reads that same message's back. It does not sweep, list or delete: it has * no business seeing any message but the one it was handed. */ readonly records: Pick; readonly notices: InboundMailNoticeRoute; readonly noticeMode: () => InboundNoticeMode; readonly now: () => Date; /** * Where "mail is arriving and nobody is being told" is made visible. * * Optional because the intake is exercised on its own in several suites, and * absent it the behaviour is exactly today's, recorded, not surfaced. The * composition root always supplies one; a build that did not would be the * defect this exists to close, so `facade-inbound-mail.ts` passes the same * instance here and to the supervisor that reports it. */ readonly noticeHealth?: InboundNoticeHealth | undefined; readonly observer?: InboundMailObserver | undefined; } /** A transport failure that another pass could clear. Everything else is structural. */ export declare class InboundNoticeTransportError extends Error { constructor(reason: string); } /** Build the handler the `DedupingInboundMailSink` wraps. */ export declare function createInboundMailIntake(deps: InboundMailIntakeDeps): (message: InboundMailboxMessage) => Promise; //# sourceMappingURL=intake.d.ts.map