import { PersistentStore, type PersistentStoreCorruption } from '../../state/persistent-store.js'; import { type HousekeepingTrigger } from './types.js'; import type { SurfaceNoticeRefusal } from '../../daemon/types.js'; export { clampDeliveryAddress, clampLinkVerdicts, clampRecordScope, INBOUND_MAIL_OUTCOMES, INBOUND_NOTICE_STATUSES, MAX_ACCOUNT_CHARS, MAX_DELIVERED_TO_CHARS, MAX_MAILBOX_CHARS, MAX_LINK_REASON_CHARS, MAX_LINK_VERDICTS, MAX_NOTICE_FAILURE_REASON_CHARS, MAX_SENDER_DISPLAY_CHARS, MAX_SUBJECT_CHARS, validateInboundMailRecord, } from './record-validation.js'; /** Correlates to `VerificationMatch['kind']` in verification-expectations.ts, plus link-only outcomes that never reach expectation matching. */ export type InboundMailOutcome = 'matched-expectation' | 'no-expectation' | 'recipient-mismatch' | 'expired-expectation' | 'ambiguous' | 'no-delivery-evidence'; /** * `delivered` / `suppressed` / `pending`, the three outcomes that never come * back from the transport, plus every reason `deliverSurfaceNotice` refuses * with, PROJECTED off `SurfaceNoticeRefusal` rather than restated (§7.3). * * It was restated, and it had already drifted: the hand-written list omitted * `empty-text` and `unsupported-delivery-surface`, both of which * `deliverSurfaceNotice` really does return. A notice refused for either of * them could not be recorded, `validateInboundMailRecord` would reject the * record on load and drop it, so the one case the owner most needs to see * ("mail arrived and could not be announced") was the case that vanished. * A projection cannot drift, because there is nothing to keep in sync. * * `pending` is the state a record is written in BEFORE the notice is attempted, * and it is what makes the ordering in `intake.ts` possible: the record is the * thing that can fail, so it goes first, and the notice, the one step nothing * can undo, goes after it. A record sitting at `pending` means exactly what it * says: the message was recorded and the notice for it has not resolved. It is * reached in two real situations, the transport is refusing with * `delivery-failed` and the message is being retried, or the daemon died * between the record and the send, and in both of them `pending` is the true * answer where `suppressed` or `delivered` would be a guess. */ export type InboundNoticeStatus = 'delivered' | 'suppressed' | 'pending' | SurfaceNoticeRefusal; /** A link's registrable domain plus verdict only, never the raw URL the message assembled (§7). */ export interface InboundLinkVerdict { readonly registrableDomain: string; readonly verdict: 'allowed' | 'refused' | 'unresolved'; /** Bounded at `MAX_LINK_REASON_CHARS`, sixty-four unbounded strings is an unbounded record. */ readonly reason?: string | undefined; } /** Structured fields for one inbound message, plus a bounded body excerpt. */ /** Everything a record carries regardless of which source found the message. */ export interface InboundMailRecordCommon { readonly id: string; readonly account: string; readonly mailbox: string; /** Attacker-written `From:` text. Card shapes redacted and length-bounded at write time (§11.0). */ readonly senderDisplay: string; /** Sanitized/truncated (§7): newlines and control characters removed before this is ever stored. Card shapes redacted (§11.0). */ readonly subject: string; /** The alias the message landed at. Sender-chosen on a catch-all domain, so card shapes are redacted here too (§11.0). */ readonly deliveredToAddress: string | null; readonly deliveryEvidenceSource: 'alias-mailbox' | 'delivered-to-header' | 'x-original-to-header' | 'none'; readonly links: readonly InboundLinkVerdict[]; readonly outcome: InboundMailOutcome; readonly noticeStatus: InboundNoticeStatus; readonly noticeFailureReason?: string | undefined; /** Bounded to MAX_BODY_EXCERPT_CHARS. Never rendered to the owner (§7), retained for the owner's own later inspection / debugging only. */ readonly bodyExcerpt: string; readonly receivedAt: string; } /** A record of a message an IMAP source found. */ export interface ImapInboundMailRecord extends InboundMailRecordCommon { readonly source: 'imap'; readonly uidValidity: number; readonly uid: number; } /** A record of a message a Gmail source found. */ export interface GmailInboundMailRecord extends InboundMailRecordCommon { readonly source: 'gmail'; /** Gmail's opaque message resource id. Not a number, never coerced to one. */ readonly resourceId: string; /** * The delta's high-water mark, a decimal uint64 STRING. * * Never parsed to a number. `18446744073709551615` does not survive a * round trip through a JS double, and a position that silently shifts is a * position that re-reads or skips history. `source-cursor.ts` already made * that impossible for the cursor; this is the same value in the same * shape, validated by the same predicate rather than a second copy of it. */ readonly historyId: string; } /** * One stored record, discriminated on the source that found the message. * * A union rather than a widened record with optional `uid` / `historyId`, and * the reason is the defect this replaced: `validateInboundMailRecord` required * a positive `uidValidity` and `uid` unconditionally, so EVERY Gmail message * failed validation and was dropped, on the path automatic selection makes * the default once Google is adopted. Mail arrived, matched, was announced, * and nothing was ever written. §9.3's retention had nothing to retain, * §11.0's card redaction had nothing to redact, and `email.inbound.status` * truthfully reported zero records, which reads as "no mail" rather than * "cannot store mail". * * Same discriminant and same rule as `InboundSourceCursor`. */ export type InboundMailRecord = ImapInboundMailRecord | GmailInboundMailRecord; /** * A record's identity as one readable string, for disclosure only. * * `imap::` / `gmail:`. Never a key and never * parsed back apart, a sweep report exists to tell the owner WHICH message * went, and a report that carried `uid: 0` for every Gmail record (which is * what an IMAP-shaped field would have to do) tells the owner nothing while * looking like it told them something. */ export declare function describeRecordIdentity(record: InboundMailRecord): string; /** * `Pick` distributed across the union, for the same reason `DistributiveOmit` * exists below: a plain `Pick` over a union collapses to the SHARED keys, so * `uid`, `uidValidity` and `resourceId` would all vanish and the resulting * "key" would identify nothing. */ type DistributivePick = T extends unknown ? Pick> : never; /** * One message's natural key: the mailbox it landed in, plus the identity the * receiving server assigned it. * * Projected off `InboundMailRecord` rather than restated, so it cannot describe * a field the record does not have. This is the key `record()` upserts on and * `findByMessage()` looks up, deliberately NOT the record `id`, which is a * fresh UUID per write and therefore identifies a WRITE rather than a MESSAGE. * * Never the `Message-ID` header, for the reason `sink.ts` states at length: the * sender writes it, so two different messages can carry the same one, and a key * a sender can choose is a key a sender can collide with. */ export type InboundMailMessageKey = DistributivePick; /** `file-unreadable` is the whole-file counterpart of `malformed`, see `CursorDiscardReason`. */ export type InboundMailDiscardReason = 'malformed' | 'file-unreadable' | 'expired' | 'over-cap'; export interface InboundMailDiscard { readonly id: string; readonly account: string; readonly mailbox: string; /** See `describeRecordIdentity`. Disclosure, not a key. */ readonly messageRef: string; readonly reason: InboundMailDiscardReason; readonly removedAt: number; /** Present where the removal needs a sentence rather than a reason word. */ readonly note?: string | undefined; } export interface InboundMailRecordSweepReport { readonly sweptAt: number; readonly removed: readonly InboundMailDiscard[]; readonly retained: number; } export interface InboundMailRecordPolicy { /** Age bound. Records older than this are reaped regardless of count. */ readonly retentionMs: number; /** Count bound. Oldest-by-receivedAt records past this are reaped regardless of age. */ readonly maxRecords: number; /** Body excerpt cap. Clamped at construction to never exceed MAX_BODY_EXCERPT_CHARS. */ readonly maxBodyExcerptChars: number; } /** * What write-time bounding has removed since this process started. * * §9 rule 5 is "disclose what was reaped", and applying the bounds on write * would otherwise delete records with nothing anywhere saying so, the sweep * report itemises what IT removed, and a record the write already dropped is a * record the sweep never sees. This is the counterpart disclosure. * * Deliberately in-memory and deliberately labelled `since`: it counts this * daemon's own writes, and a restart resets it. Persisting it would make the * tally a second store needing its own reaping and bounding, which is the * defect two lines up in this same file's history (see housekeeping.ts). A * count that says what window it covers is honest; a count that implies "ever" * would not be. */ export interface InboundMailWriteReapTally { /** Records a write dropped for being past `retentionMs`. */ readonly expired: number; /** Records a write dropped for being past `maxRecords`. */ readonly overCap: number; /** When this tally started counting, this store's construction. */ readonly since: number; } export declare const DEFAULT_INBOUND_MAIL_RECORD_POLICY: InboundMailRecordPolicy; interface InboundMailSnapshot extends Record { readonly version: 1; readonly records: readonly InboundMailRecord[]; } export interface InboundMailStoreOptions { readonly policy?: Partial | undefined; readonly now?: (() => number) | undefined; } /** * `Omit` distributed across the union. * * A plain `Omit` on a union collapses to the keys the * variants SHARE, which would silently drop `uid`, `resourceId` and * `historyId` from the input type, every caller would then compile while * passing an identity the store cannot use. Distributing keeps each arm whole. */ type DistributiveOmit = T extends unknown ? Omit : never; export type InboundMailRecordInput = DistributiveOmit & { readonly body: string; }; /** * Durable inbound-mail record store, named to match `InboundMailContext` * (docs/inbound-email.md §2.1), which carries it as `records`. */ export declare class InboundMailStore { private readonly store; private readonly policy; private readonly now; private writeChain; /** The last unreadable-file event, latched so status can name it. */ private corruption; private writeReapedExpired; private writeReapedOverCap; private readonly tallySince; constructor(storeOrPath: PersistentStore | string, options?: InboundMailStoreOptions); getPolicy(): InboundMailRecordPolicy; /** What write-time bounding has removed since this store was constructed. See `InboundMailWriteReapTally`. */ getWriteReapTally(): InboundMailWriteReapTally; /** The unreadable-file event this store last saw, or null. See `MailboxCursorStore.getCorruption`. */ getCorruption(): PersistentStoreCorruption | null; private readWithDrops; private mutate; /** * WHAT THE FILE HOLDS, not what a read is willing to serve. * * `list()` filters by age and by count, so a caller counting its result was * counting a VIEW: with `maxRecords: 2`, ten writes left ten records on disk * and `list()` answered 2. `email.inbound.status` computed its * `retention.records.kept` that way, so the owner was told their store was * bounded while the file grew without limit, a disclosure that reads as * reassurance and is not one. * * `stored` counts EVERY entry in the file, malformed ones included: they * occupy the file, so a count that skipped them would be the same class of * comfortable answer. `live` is what a read serves. Both are returned * because the GAP between them is itself the fact worth disclosing, * records past their window that no write or sweep has reached yet. */ count(): Promise<{ readonly stored: number; readonly live: number; }>; /** * Apply BOTH policy bounds to the set about to be written. * * This is the fix for the finding this store existed for six weeks without: * `record()` wrote `[...records, entry]` and nothing else, so the bounds * lived only in `sweep()`, and `facade-inbound-mail.ts` runs the sweep every * SIX HOURS. Between two sweeps the file was unbounded in both axes, and * every read hid it. * * Age first, then count, so the reason a record went is the bound that * actually bound first, the same order and the same precedence `sweep()` * uses, because two orders would mean two answers to "why is this gone". */ private applyBounds; /** * Live, content-validated records, newest first. Read-time filter for age * and count (does not persist the drop, `sweep()` does that), so a read * between sweeps never serves a record past either bound. */ list(input?: { readonly account?: string | undefined; readonly limit?: number | undefined; }): Promise; get(id: string): Promise; /** * The record of ONE message, by the identity the receiving server assigned. * * This is the store's answer to "have I already dealt with this message, and * what happened to it", a question the in-memory dedup cache cannot answer * across a restart, because it is a `Map` in a process that just died. The * intake asks it before announcing, so a message redelivered because the * cursor had not advanced when the daemon restarted is not announced to the * owner a second time (§6). * * Bounded by the same age filter `list()` applies: a record past the * retention window is not a live fact about this message, and answering with * one would let a 30-day-old row suppress a notice. * * Note the consequence of write-time bounding, stated rather than left to be * discovered: a record written already past `retentionMs` is bounded out by * the same write that made it, so this answers `null` for it. That is the * policy working, a record too old to keep is too old to have kept, not an * inconsistency between two methods. */ findByMessage(key: InboundMailMessageKey): Promise; /** * Record one inbound message. The body excerpt is redacted of card shapes and * then truncated to the policy cap at write time. * * Redaction happens BEFORE the truncation, over the WHOLE body rather than * a window. Truncating first and redacting the result would leave a card * number straddling the cap as a still-readable prefix of up to eighteen * digits, a shorter leak, not a redaction. A window sized to one span is * not enough either, because redaction shortens and several of them slide * later text back inside the cap; see the note at the call site. * * ONE MESSAGE, ONE RECORD. A write whose message key already exists replaces * that row in place and keeps its `id`; it does not append a second row. That * is not tidiness, it is what makes the intake's ordering safe to retry: the * record now goes in BEFORE the notice, in a `pending` state, and every * retried pass, a `delivery-failed` transport, a daemon restarted mid-pass, * writes the same message again. Appending would turn each retry into another * row, and `email.inbound.status` would report five arrivals for one message * while the owner's phone had buzzed once. The `id` is kept so a reference * taken from an earlier read still resolves through `get()`. */ record(input: InboundMailRecordInput): Promise; /** * One housekeeping pass: drop malformed records, drop records past the age * bound, and enforce the count bound (oldest by `receivedAt` first). * Whichever bound removes a record first is the reason recorded. */ sweep(trigger?: HousekeepingTrigger): Promise; runRecoverySweep(): Promise; } //# sourceMappingURL=record-store.d.ts.map