/** * inbound-notice.ts, the ONE thing the owner is allowed to read about an * arriving email. This is a security boundary, not a formatting helper. * * ── Why this file exists ────────────────────────────────────────────────── * * The delivery entry point, `DaemonSurfaceDeliveryHelper.deliverSurfaceNotice` * (platform/daemon/surface-delivery.ts), takes a PLAIN STRING and hands it to * whichever channel the owner's notice route binding points at, Telegram, * Slack, Discord, ntfy, a webhook, whatever the owner has configured. Whatever * reaches that string is what the owner reads on their phone. Inbound mail is written * entirely by strangers, so the SDK, not the adapter, not the daemon, not a * convention documented somewhere, owns turning one arriving message into * that string. * * ── Escaping belongs to the channel, not the producer (docs/inbound-email.md §7.2) ── * * An earlier version of this module returned one STRING for every channel, * sanitized by one shared trigger-character set. That is an architectural * defect, not a tuning problem: Telegram MarkdownV2 reserves * `` _*[]()~`>#+-=|{}.! ``, Discord additionally turns a bare `@everyone` / * `@here` into a real mention, Slack uses `` and has no backslash * escape at all, an HTML notice needs entity escaping instead of stripping, * and ntfy carries fields in HTTP headers where a bare newline is the * injection and markup is irrelevant. A set tuned for one channel is silently * wrong on another, and it goes wrong on the day someone adds a channel, in a * module they never opened. Stripping is also lossy in the wrong direction: * the owner sees a mangled subject and cannot tell whether the mail said that * or we did. * * So `renderInboundMailNotice` returns `StructuredNotice`, literal spans * (our own words, safe by construction) and untrusted spans (attacker text, * unmodified beyond removing control characters and line breaks, which are * unsafe on every channel including plain text, because a raw newline lets * attacker text forge what reads as an extra labeled line). Only when a * specific channel is about to turn the notice into a wire string does an * ESCAPER run, `\[` in Telegram MarkdownV2 renders as a literal `[`, so the * owner sees `[Approved](https://evil.example)` exactly as the mail wrote * it, doing nothing, rather than either a live link or a row of blanks. The * producer never holds a channel-formatted string, so "forgot to escape" is * not a mistake available in the wrong place, only the escaper for a given * channel's own syntax can make that mistake, in a file whose only job is to * know that syntax. * * `renderNoticeAsPlainText` is the conservative fallback, full markup * neutralization plus mention-breaking, the same shape the old single-string * renderer used, and is what any unregistered channel gets. It is never * the raw concatenation of span text. * * ── The field audit (§7.1), every field is attacker-chosen until a written * reason says otherwise ────────────────────────────────────────────────── * * `deliveredTo`'s local part looked safe because the mailbox source is * verified; it is not, because this design runs on per-signup aliases * (catch-all domain or plus-addressing), so the local part is whatever the * SENDER addressed the mail to. `outcome.purpose` looked safe because it * comes from an authorized workstream; authority over the CALL is not * authority over the STRING, a signup flow that lifted a service name off a * web page is still passing untrusted text through a trusted caller. Both are * `untrusted` spans below. `receivedAt` is the one field that must NEVER be * attacker-reachable, see `ReceiptTimestamp`. */ import type { LinkRefusalReason } from '../security/link-validation.js'; import type { DeliveredRecipient } from '../google/delivery-evidence.js'; import type { InboundCapabilityReason } from './inbound/ports.js'; declare const RECEIPT_TIME_BRAND: unique symbol; /** * The moment the DAEMON received this message, never a sender-supplied * timestamp. `ImapEnvelope.date` is `extractHeader(raw, 'Date')`, a string * the sender wrote inside the message; it must never reach this field, and a * timestamp is the last field anyone thinks to suspect. The brand is not * exported, so no value can satisfy `ReceiptTimestamp` from outside this * module, and the only constructor takes a real `Date`, not a string of any * kind, so passing a header value through requires an explicit unsafe cast, * never an accidental one. Same shape as `DeliveredRecipient` * (platform/google/delivery-evidence.ts) for the same reason. */ export interface ReceiptTimestamp { readonly iso: string; readonly [RECEIPT_TIME_BRAND]: true; } /** The only constructor for `ReceiptTimestamp`. Takes a `Date`, never a string. */ export declare function receiptTimestamp(receivedAt: Date): ReceiptTimestamp; /** * One piece of notice text. `literal` is ours, assembled by this module, * safe by construction, never escaped. `untrusted` is anyone else's, * escaped by whichever channel is about to render it, never stripped. */ export type NoticeSpan = { readonly kind: 'literal'; readonly text: string; } | { readonly kind: 'untrusted'; readonly text: string; }; /** One labeled row. The label is always ours; the value is spans, in order. */ export interface NoticeField { readonly label: string; readonly value: readonly NoticeSpan[]; } /** The whole notice, before any channel has rendered it to a wire string. */ export interface StructuredNotice { readonly title: readonly NoticeSpan[]; readonly fields: readonly NoticeField[]; } /** * What happened to an arriving message, for display purposes only. This is * NOT the authority decision itself, that lives in the expectation book * (platform/google/verification-expectations.ts), it is a report of what * that decision already was, reduced to what the owner needs to read. */ export type InboundOutcome = { /** The message satisfied an expectation an authorized workstream registered in advance. */ readonly kind: 'matched-expectation'; /** * Why the expectation was opened, e.g. "Create a GitHub account for * the owner". Rendered as `untrusted`: authority over the call that * registered the expectation is not authority over this string, a * signup flow that lifted a service name off a web page is passing * untrusted text through an authorized caller. */ readonly purpose: string; /** The domain the expectation was scoped to. Punycode-normalized before use, same as a link host, and rendered `untrusted` anyway. */ readonly serviceDomain: string; } | { /** A candidate expectation existed but had already expired; nothing was renewed or matched. */ readonly kind: 'expired-expectation'; } | { /** No expectation matched anything in the message. Recorded, notice sent, nothing else happened. */ readonly kind: 'inert'; } | { /** * A link in the message was refused by link validation * (platform/security/link-validation.ts). `reason` is the fixed * `LinkRefusalReason` enum our OWN validation code assigns, never * text quoting a server's wording, so it is rendered `literal`. */ readonly kind: 'refused-link'; readonly reason: LinkRefusalReason; } | { /** * The mailbox reported it cannot do what inbound mail requires, for * example a Gmail grant that authorizes listing but not reading * message bodies, or a mailbox that no longer exists * (`surfaces.email.inbound.onInsufficientCapability: 'notice-only'`). * This message was read from envelope fields ALONE; nothing here * matched or could have matched an expectation, because matching * requires the body. The renderer marks this outcome visibly, see * `renderInboundMailNotice`, so a degraded notice never reads as a * normal one. */ readonly kind: 'capability-degraded'; /** * What the account currently cannot do, e.g. "read message bodies * under the granted scope". Produced entirely by the daemon's own * capability probe (checked OAuth scopes, IMAP CAPABILITY response), * it never echoes attacker-supplied text, so it is rendered `literal`. */ readonly missingCapability: string; }; /** How a single link in the message was treated. Never carries a URL, see the module header. */ export type LinkVerdict = 'authorized' | 'refused' | 'unrecognized'; /** * One link's rendering summary. `host` is the raw hostname the link pointed * at (punycode or Unicode, either is fine, this module normalizes it before * display), never a full URL: there is no field here a caller could put a * path or query string into, so an assembled clickable URL cannot reach the * output no matter what the caller passes. `refusalReason`, when set, is the * fixed `LinkRefusalReason` enum from link validation, our own vocabulary, * never attacker text, so it renders `literal`. */ export interface ValidatedLinkSummary { readonly host: string; readonly verdict: LinkVerdict; readonly refusalReason?: LinkRefusalReason | undefined; } export interface InboundMailNoticeInput { /** Sender's registrable domain and local part. Attacker-written, rendered `untrusted`. */ readonly senderDisplay: string; /** The message subject. Attacker-written, rendered `untrusted`. */ readonly subject: string; /** Evidence-backed delivery address, or null when none was available. Never a `To:` header claim. */ readonly deliveredTo: DeliveredRecipient | null; readonly outcome: InboundOutcome; /** Every link found in the message, already run through link validation. */ readonly links: readonly ValidatedLinkSummary[]; /** The daemon's own receipt clock, see `ReceiptTimestamp`. Never the sender's `Date:` header. */ readonly receivedAt: ReceiptTimestamp; } /** * Every ASCII control character, DEL, and the Unicode line/paragraph * separators, not just `\n` and `\r`. A subject containing ` ` renders * as a real line break in several renderers even though it is not `\n`, so * treating only `\n`/`\r` as "a newline" would leave an equivalent forgery * path open. */ export declare const CONTROL_OR_LINE_BREAK: RegExp; export declare const REPEATED_SPACE: RegExp; /** * Collapse control characters and line breaks to spaces. * * Exported because the escaper layer needs the SAME rule: a newline surviving * into a rendered notice forges a labelled line, and the producer stripping it * only protects the fields the producer writes. See `flattenSpans`. */ export declare function stripControlAndLineBreaks(raw: string): string; /** * Render an inbound-mail owner notice as STRUCTURE, never a channel-formatted * string. A channel's delivery path runs this through `renderNoticeForChannel` * (or its own equivalent escaper) immediately before calling * `DaemonSurfaceDeliveryHelper.deliverSurfaceNotice`, that is the only * allowed path from an arriving message to what the owner reads. * * When `outcome.kind === 'capability-degraded'`, the title itself changes * (`New mail, LIMITED VIEW` rather than plain `New mail`) so a degraded * notice is never visually indistinguishable from a normal one. */ export declare function renderInboundMailNotice(input: InboundMailNoticeInput): StructuredNotice; /** * What the owner is told when inbound mail has stopped for good. * * Fields, not a sentence, for the same reason arriving mail is fields: `detail` * carries the MAIL SERVER'S OWN wording, which is attacker-influenceable text * (§7.1) and must reach a channel as an `untrusted` span so the channel escapes * it. `reason` and `fix` are ours, a fixed enum and a fixed sentence from * `capability.ts`, so they are `literal`. */ export interface InboundMailStoppedNoticeInput { /** Config account id, never an address. */ readonly account: string; readonly mailbox: string; /** Our own machine-readable reason. Never server text. */ readonly reason: InboundCapabilityReason; /** What the server (or the platform) said. Attacker-influenceable, untrusted. */ readonly detail: string; /** The one remedial step, in our words. '' when there is none. */ readonly fix: string; /** When this was reached, ISO, from the daemon's own clock. */ readonly at: string; } /** * Render "inbound mail has stopped" as structure. * * §3.4b: *a terminal state is announced, not merely recorded.* The tracker that * fires once per transition already existed; its only consumer was a log line, * which is the same "rendered and never sent" shape this round exists to * remove, in the one place where it means no mail will ever arrive again. */ export declare function renderInboundMailStoppedNotice(input: InboundMailStoppedNoticeInput): StructuredNotice; export {}; //# sourceMappingURL=inbound-notice.d.ts.map