diff --git a/src/dev/dev-inbound-email.test.ts b/src/dev/dev-inbound-email.test.ts new file mode 100644 index 0000000..d672739 --- /dev/null +++ b/src/dev/dev-inbound-email.test.ts @@ -0,0 +1,71 @@ +/** + * `createDevInboundEmailProvider` against no real provider — proves the + * fake's shape satisfies `InboundEmailProvider` (a compile-time check, via + * the first test's variable annotation) and that queued raw messages + * (inline bytes AND blob references, metadata included) round-trip through + * `receiveDelivery` unchanged, in order, matching the interface's "0..N + * messages per delivery" contract (`src/providers/inbound-email.ts`). + */ + +import { describe, expect, it } from 'vitest' +import type { InboundEmailProvider, RawInboundMessage } from '../providers/index.js' +import { createDevInboundEmailProvider } from './dev-inbound-email.js' + +const DUMMY_REQUEST = new Request('https://example.test/webhook') + +const inlineMessage: RawInboundMessage = { + content: { kind: 'inline', bytes: new TextEncoder().encode('From: a@example.test\r\n\r\nHi') }, + mailboxId: 'mbox-1', + providerMessageId: 'provider-msg-1', + receivedAt: new Date('2026-07-13T12:00:00.000Z'), +} + +const blobRefMessage: RawInboundMessage = { + content: { kind: 'blobRef', blobKey: 'mbox-1/raw/provider-msg-2' }, + mailboxId: 'mbox-1', + providerMessageId: 'provider-msg-2', + receivedAt: new Date('2026-07-13T12:05:00.000Z'), +} + +describe('createDevInboundEmailProvider', () => { + it('satisfies the InboundEmailProvider interface', () => { + // Assigning to the narrower interface type is a compile-time proof the + // fake's shape satisfies it — if the two drift apart, `npm run + // typecheck` fails right here, not at some future call site. + const provider: InboundEmailProvider = createDevInboundEmailProvider() + expect(typeof provider.verifySignature).toBe('function') + expect(typeof provider.receiveDelivery).toBe('function') + }) + + it('round-trips a delivery of several raw messages (inline and blobRef) unchanged, in order', async () => { + const fake = createDevInboundEmailProvider() + fake.enqueue([inlineMessage, blobRefMessage]) + + const received = await fake.receiveDelivery(DUMMY_REQUEST) + + expect(received).toEqual([inlineMessage, blobRefMessage]) + }) + + it('returns [] when nothing is queued, matching a real delivery that resolves to zero messages', async () => { + const fake = createDevInboundEmailProvider() + await expect(fake.receiveDelivery(DUMMY_REQUEST)).resolves.toEqual([]) + }) + + it('drains queued batches FIFO across separate receiveDelivery calls', async () => { + const fake = createDevInboundEmailProvider() + fake.enqueue([inlineMessage]) + fake.enqueue([blobRefMessage]) + + await expect(fake.receiveDelivery(DUMMY_REQUEST)).resolves.toEqual([inlineMessage]) + await expect(fake.receiveDelivery(DUMMY_REQUEST)).resolves.toEqual([blobRefMessage]) + await expect(fake.receiveDelivery(DUMMY_REQUEST)).resolves.toEqual([]) + }) + + it('verifySignature resolves the configured result (default true, overridable to false)', async () => { + const defaulted = createDevInboundEmailProvider() + await expect(defaulted.verifySignature(DUMMY_REQUEST)).resolves.toBe(true) + + const rejecting = createDevInboundEmailProvider({ verifySignatureResult: false }) + await expect(rejecting.verifySignature(DUMMY_REQUEST)).resolves.toBe(false) + }) +}) diff --git a/src/dev/dev-inbound-email.ts b/src/dev/dev-inbound-email.ts new file mode 100644 index 0000000..05a0c1d --- /dev/null +++ b/src/dev/dev-inbound-email.ts @@ -0,0 +1,61 @@ +/** + * A dev-only, in-memory `InboundEmailProvider` (`src/providers/inbound- + * email.ts`) fake: no real provider, no network call, no webhook wire + * format. Messages are queued directly via `enqueue` rather than decoded + * from a `Request` body, so downstream tests (the ingest pipeline, HT-36+; + * specs/mail/inbound-ingestion.md §8's acceptance suite) can drive + * `InboundEmailProvider` consumers without a real provider or its adapter — + * mirrors `dev-sender.ts`'s role for `EmailSender`. + * + * `receiveDelivery` ignores its `request` argument entirely and returns the + * next queued batch (FIFO), or `[]` if nothing is queued — matching a real + * provider's "zero messages" delivery case (see the interface doc). + * `verifySignature` resolves a fixed, constructor-supplied result (default + * `true`) for every call, since this fake has no signature scheme of its + * own to check. + */ + +import type { InboundEmailProvider, RawInboundMessage } from '../providers/index.js' + +/** Options for {@link createDevInboundEmailProvider}. */ +export interface DevInboundEmailProviderOptions { + /** What `verifySignature` resolves to for every call. Defaults to `true`. */ + verifySignatureResult?: boolean +} + +/** The `InboundEmailProvider` fake this module builds. See the module doc. */ +export interface DevInboundEmailProvider extends InboundEmailProvider { + /** + * Queue `messages` to be returned by the NEXT `receiveDelivery` call. Each + * `receiveDelivery` call drains exactly one previously-queued batch + * (FIFO), so a test can simulate a delivery containing zero, one, or + * several messages simply by choosing what it queues — including queueing + * `[]` explicitly to simulate a delivery that resolved to nothing new. + */ + enqueue(messages: RawInboundMessage[]): void +} + +/** + * Build the dev `InboundEmailProvider` fake. See the module doc for what it + * does and does not simulate. + */ +export function createDevInboundEmailProvider( + options: DevInboundEmailProviderOptions = {}, +): DevInboundEmailProvider { + const { verifySignatureResult = true } = options + const queue: RawInboundMessage[][] = [] + + return { + async verifySignature(_request: Request): Promise { + return verifySignatureResult + }, + + async receiveDelivery(_request: Request): Promise { + return queue.shift() ?? [] + }, + + enqueue(messages: RawInboundMessage[]): void { + queue.push(messages) + }, + } +} diff --git a/src/mail/parse.ts b/src/mail/parse.ts index 3dd21ea..bf82c34 100644 --- a/src/mail/parse.ts +++ b/src/mail/parse.ts @@ -7,13 +7,13 @@ * provenance), a modern serverless-friendly MIME parser with zero * dependencies of its own. * - * `ParsedEmail` is deliberately RICHER than - * `NormalizedInboundEmail` (`src/providers/inbound-email.ts`): it carries - * attachment BYTES inline (`ParsedAttachment.content`), whereas - * `NormalizedInboundEmail` carries only a `BlobStore` key (`contentRef`). - * Writing attachment bytes to blob storage is a later step at the store - * layer, downstream of this pure parse — this module knows nothing about - * `BlobStore` and never will. + * This is the pipeline's single parse (specs/mail/inbound-ingestion.md §1): + * an `InboundEmailProvider` hands over the raw RFC822 bytes untouched + * (`RawInboundMessage`, `src/providers/inbound-email.ts`), and this turns + * them into a structured `ParsedEmail`. `ParsedEmail` carries attachment + * BYTES inline (`ParsedAttachment.content`); writing those bytes to blob + * storage is a later step at the store layer, downstream of this pure + * parse — this module knows nothing about `BlobStore` and never will. */ import PostalMime, { type Address, type Attachment, type Header } from 'postal-mime' @@ -26,9 +26,9 @@ export interface ParsedAddress { } /** - * One attachment, bytes included. See the module doc above for why this - * differs from `NormalizedInboundAttachment` (which carries a `contentRef` - * instead of `content`). + * One attachment, bytes included (`content`). Blob-referencing those bytes + * is a later store-layer step, not this pure parse's concern (see the + * module doc). */ export interface ParsedAttachment { filename: string | null @@ -108,8 +108,7 @@ export interface ParsedEmail { * individual entries with no built-in multi-value join, so this is this * module's own convention, not postal-mime's. Consumers that need exact * multi-value semantics (order, repetition) should not rely on this bag - * for those headers — same caveat `NormalizedInboundEmail.headers` - * documents. + * for those headers. */ headers: Record diff --git a/src/providers/inbound-email.ts b/src/providers/inbound-email.ts index 7cf813b..9982008 100644 --- a/src/providers/inbound-email.ts +++ b/src/providers/inbound-email.ts @@ -6,79 +6,96 @@ * CHARTER.md §2/§4, inbound mail arrives via **push webhooks**, never IMAP * polling — "no daemons, no polling loops" applies to inbound mail first * and foremost. This is where Gmail-push-via-Pub/Sub plugs in today, and - * where later providers (Postmark inbound, SES inbound, etc.) plug in - * without the engine changing: regardless of provider, the engine only - * ever sees a `NormalizedInboundEmail`. + * where later providers (Postmark inbound, SES inbound, a forwarding- + * address transport, ...) plug in without the engine changing. + * + * ## Raw bytes in, nothing pre-parsed + * + * specs/mail/inbound-ingestion.md §2 is the contract this interface + * implements, in full. A provider's job is narrow: authenticate a + * delivery, and produce, per message, the raw RFC822 bytes (or a reference + * to them) plus the metadata the transport authoritatively knows. A + * provider MUST NOT parse the MIME, and MUST NOT extract attachments — + * both require parsing the message, and that spec's invariant #1 ("parse + * exactly once, by our own code", §1) reserves to the pipeline's single + * `parseInboundEmail` call (`src/mail/parse.ts`). A second, provider- + * specific parser living inside an adapter is exactly the divergence that + * invariant forbids — threading would end up depending on how faithfully a + * given provider happened to preserve headers we never controlled. + * + * This is a correction (HT-35) of the interface as first drafted, which + * returned a `NormalizedInboundEmail` — headers and body already parsed, + * attachments already blob-referenced — putting the parse inside the + * provider and handing attachment ownership to the transport. See + * specs/mail/inbound-ingestion.md §2's "Correction (HT-35)" note. + * + * A provider MAY still need to interpret its OWN transport envelope to do + * its job — e.g. a Gmail-push adapter reads a Pub/Sub JSON body to learn a + * `historyId`, then calls the Gmail API to resolve which messages that + * batch contains. That is not the parsing this boundary forbids: the line + * is the RFC822/MIME content of the message itself, which stays untouched + * bytes all the way to `receiveDelivery`'s return value. */ -/** A normalized attachment reference. Bytes live in the `BlobStore`, not inline. */ -export interface NormalizedInboundAttachment { - filename: string - contentType: string - /** Size in bytes. */ - size: number - /** - * Key into a `BlobStore` where the attachment's bytes have already been - * written by the provider adapter. Attachments are never carried inline - * in `NormalizedInboundEmail` — the adapter is responsible for writing - * bytes to the `BlobStore` (with a correctly tenant/conversation- - * namespaced key, per `BlobStore`'s key-namespacing contract) before - * producing this reference. - */ - contentRef: string -} - /** - * The provider-agnostic shape the engine consumes for every inbound - * email, regardless of which provider webhook produced it. + * The raw RFC822 message bytes for one inbound message, or a reference to + * them. * - * `inReplyTo` and `references` are carried through unmodified from the - * inbound message's headers for the threading engine to consume — this - * interface only normalizes and transports them; it does not interpret - * them. Per CHARTER.md §2 ("Threading authority lives on the outbound - * side"), these inbound headers are not trusted as the authority for - * threading — the engine's outbound-Message-ID signed-reply-token scheme - * is. See HT-8 for that spec; this type does not re-specify it. + * A discriminated union tagged on `kind` — matching this codebase's + * convention for result-shape polymorphism (see `QueueHandlerResult`, + * `src/providers/queue.ts`) — rather than a plain `Uint8Array | { blobKey: + * string }` union, so consumers narrow with a `kind` check instead of an + * `instanceof` test, and a future third representation (e.g. a stream) + * could be added without disturbing existing narrowing code. + * + * `blobRef` exists for a delivery whose payload makes holding the full raw + * message in memory impractical (e.g. one large message inside a Gmail + * history batch of many). The provider writes the raw bytes to the shared + * `BlobStore` (`src/providers/blob.ts`) under a mailbox-namespaced key + * BEFORE returning, and `blobKey` is that key — read back with + * `BlobStore.get`. This is a DIFFERENT blob than any attachment blob the + * pipeline writes after parsing (specs/mail/inbound-ingestion.md §3): this + * one holds the whole unparsed message and is written by the provider, not + * the pipeline. */ -export interface NormalizedInboundEmail { - /** The `Message-ID` of the inbound message, as received. */ - messageId: string - - /** The `In-Reply-To` header, if present, verbatim. */ - inReplyTo?: string +export type RawMessageContent = + | { kind: 'inline'; bytes: Uint8Array } + | { kind: 'blobRef'; blobKey: string } - /** The `References` header, split into individual message-ids, verbatim order preserved. */ - references: string[] - - from: string - to: string[] - cc: string[] - subject: string - - /** When the provider recorded/delivered the message (not a header-parsed date). */ - receivedAt: Date - - /** Plain-text body, if the message provided one. */ - text?: string +/** + * One inbound message as handed off by the provider: raw, unparsed content + * (see the module doc) plus the minimum metadata the pipeline needs and the + * transport authoritatively knows (specs/mail/inbound-ingestion.md §2). + */ +export interface RawInboundMessage { + /** The raw RFC822 bytes for this message, or a reference to them. */ + content: RawMessageContent - /** HTML body, if the message provided one. */ - html?: string + /** + * Which connected mailbox this arrived at — the namespace anchor for + * storage, blobs, dedup, and, later, tenancy (HT-36). The provider + * resolves this to a known mailbox and rejects a delivery it cannot; the + * pipeline receives an already-resolved `mailboxId`, never a raw provider + * address. + */ + mailboxId: string /** - * Raw headers as received, lower-cased keys, for any header the engine - * needs beyond the fields already normalized above. Multi-value headers - * are joined per the provider adapter's convention; consumers that need - * exact multi-value semantics should not rely on this bag for those - * headers. + * The transport's own stable id for this message (for Gmail, the Gmail + * message id). This is the idempotency authority + * (specs/mail/inbound-ingestion.md §4) — NOT the RFC `Message-ID`, which + * is optional (`NewThread.messageId` permits `null`, + * `src/store/conversations.ts`) and entirely sender-controlled. */ - headers: Record + providerMessageId: string - attachments: NormalizedInboundAttachment[] + /** When the provider recorded/delivered the message — not a header-parsed `Date`. */ + receivedAt: Date } /** * Provider for turning one inbound-mail provider's webhook delivery into - * the engine's normalized shape. One implementation per provider (Gmail + * the raw message(s) it contains. One implementation per provider (Gmail * push/Pub/Sub, Postmark inbound, SES inbound, ...). */ export interface InboundEmailProvider { @@ -86,9 +103,9 @@ export interface InboundEmailProvider { * Verify that `request` is an authentic webhook delivery from this * provider (signature/token/shared-secret check, as the provider * requires). MUST be called — and MUST resolve `true` — before - * `parseWebhook` is trusted to run against `request`'s body; - * implementations of `parseWebhook` may assume the caller has already - * verified the request and are not required to re-verify internally. + * `receiveDelivery` is trusted to run against `request`; implementations + * of `receiveDelivery` may assume the caller has already verified the + * request and are not required to re-verify internally. * * Async by contract: the first adapter (Gmail push) verifies a Google * OIDC JWT, which may require fetching/refreshing signing certificates. @@ -98,12 +115,22 @@ export interface InboundEmailProvider { verifySignature(request: Request): Promise /** - * Parse and normalize one webhook delivery into a - * `NormalizedInboundEmail`. Rejects if the payload cannot be parsed as a - * valid message for this provider. Any attachment bytes present in the - * payload are written to a `BlobStore` by the implementation before - * this resolves, so the returned attachments carry `contentRef`s rather - * than inline bytes. + * Read one webhook delivery and return the raw message(s) it carries — + * unparsed, per the module doc — plus each one's provider metadata. A + * single delivery may carry zero messages (e.g. a Gmail Pub/Sub + * notification whose history batch resolved to nothing new) up to N (e.g. + * a history batch spanning several new messages); callers MUST NOT assume + * exactly one. Rejects if `request` cannot be recognized as a valid + * delivery notification for this provider — recognizing the transport's + * own envelope (e.g. Pub/Sub JSON) is not the MIME-parsing this boundary + * forbids; see the module doc. + * + * A `Request` body can only be read once. Whichever call site invokes + * both `verifySignature` and `receiveDelivery` against the same incoming + * request owns making sure each still has a readable body if its + * implementation needs one (e.g. by passing `request.clone()` to one of + * the two calls) — this interface does not thread a pre-read body between + * them. */ - parseWebhook(request: Request): Promise + receiveDelivery(request: Request): Promise } diff --git a/src/providers/index.ts b/src/providers/index.ts index 780c104..734eb3e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -8,8 +8,8 @@ export type { BlobStore } from './blob.js' export type { EmailSender, EmailSendResult, OutboundEmail } from './email-sender.js' export type { InboundEmailProvider, - NormalizedInboundAttachment, - NormalizedInboundEmail, + RawInboundMessage, + RawMessageContent, } from './inbound-email.js' export type { EnqueueOptions,