Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/dev/dev-inbound-email.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
61 changes: 61 additions & 0 deletions src/dev/dev-inbound-email.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return verifySignatureResult
},

async receiveDelivery(_request: Request): Promise<RawInboundMessage[]> {
return queue.shift() ?? []
},

enqueue(messages: RawInboundMessage[]): void {
queue.push(messages)
},
}
}
23 changes: 11 additions & 12 deletions src/mail/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string>

Expand Down
163 changes: 95 additions & 68 deletions src/providers/inbound-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,89 +6,106 @@
* 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<string, string>
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 {
/**
* 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.
Expand All @@ -98,12 +115,22 @@ export interface InboundEmailProvider {
verifySignature(request: Request): Promise<boolean>

/**
* 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<NormalizedInboundEmail>
receiveDelivery(request: Request): Promise<RawInboundMessage[]>
}
4 changes: 2 additions & 2 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading