@molecule/api-emails-inbound
Core interface · emails-inbound · API (Node) · v1.0.3 · Apache-2.0
Inbound email-to-ticket bond (Mailgun Routes / SES Inbound) — sibling to outbound @molecule/api-emails-*.
npm install @molecule/api-emails-inboundHow it works
@molecule/api-emails-inbound is the emails-inbound core interface on the API (Node) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 3 providers: @molecule/api-emails-inbound-agentmail, @molecule/api-emails-inbound-mailgun, @molecule/api-emails-inbound-ses.
import { setProvider, parseWebhookPayload, verifySignature } from '@molecule/api-emails-inbound'
import { provider as mailgunInbound } from '@molecule/api-emails-inbound-mailgun'
setProvider(mailgunInbound)
// In an HTTP handler bound to the inbound webhook URL:
const ok = await verifySignature(req.headers, req.rawBody)
if (!ok) return res.status(401).end()
const email = await parseWebhookPayload(req.headers, req.rawBody)
await createTicketFromEmail(email)Providers (3): @molecule/api-emails-inbound-agentmail, @molecule/api-emails-inbound-mailgun, @molecule/api-emails-inbound-ses
Works with: @molecule/api-bond, @molecule/api-i18n
Reference
Provider-agnostic inbound-emails interface for molecule.dev.
Defines the InboundEmailProvider interface for receiving and
replying to email-to-ticket / email-to-reply traffic. Bond packages
(Mailgun Routes, SES Inbound, etc.) implement this interface.
Application code uses the convenience functions (parseWebhookPayload,
verifySignature, replyTo, supportsReply) which delegate to the
bonded provider.
Webhook payloads vary wildly between providers; the contract here
normalizes them to a single InboundEmail shape so handler code
does not need to branch by provider.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider, parseWebhookPayload, verifySignature } from '@molecule/api-emails-inbound'
import { provider as mailgunInbound } from '@molecule/api-emails-inbound-mailgun'
setProvider(mailgunInbound)
// In an HTTP handler bound to the inbound webhook URL:
const ok = await verifySignature(req.headers, req.rawBody)
if (!ok) return res.status(401).end()
const email = await parseWebhookPayload(req.headers, req.rawBody)
await createTicketFromEmail(email)
Type
core
Installation
npm install @molecule/api-emails-inbound @molecule/api-bond @molecule/api-i18n
API
Interfaces
InboundEmailAttachment
A binary attachment carried by an inbound email.
Providers normalize whatever multipart/MIME representation they receive into this neutral shape. The body is base64-encoded so the type is JSON-serializable across IPC, queue, and webhook boundaries.
interface InboundEmailAttachment {
/**
* The original filename as supplied by the sender, or a provider-derived
* fallback when the sender omitted one.
*/
name: string
/**
* MIME type of the attachment (e.g. `'application/pdf'`, `'image/png'`).
* Defaults to `'application/octet-stream'` when the provider cannot
* determine the type.
*/
contentType: string
/**
* Attachment payload, base64-encoded.
*/
contentBase64: string
/**
* Optional size hint in bytes of the decoded payload. Providers MAY set
* this from upstream headers without decoding the payload themselves.
*/
sizeBytes?: number
/**
* Optional Content-ID, used for inline images referenced from the HTML
* body via `cid:` URLs.
*/
contentId?: string
}
InboundEmail
A normalized inbound email, produced by parsing a provider webhook
payload through InboundEmailProvider.parseWebhookPayload.
All providers (Mailgun Routes, SES Inbound, fixtures, etc.) return this same shape so handler code can treat inbound mail uniformly. Provider specifics (raw MIME, signing tokens, etc.) MUST NOT leak into this type.
interface InboundEmail {
/**
* Stable provider-supplied identifier for the message. Used for
* deduplication when the same webhook is retried.
*/
id: string
/**
* Sender address (RFC 5322 mailbox), e.g. `'alice@example.com'`.
*/
from: string
/**
* Primary recipient addresses (the values from the `To:` header).
*/
to: string[]
/**
* Carbon-copy recipient addresses, if present.
*/
cc?: string[]
/**
* Subject line, decoded to a plain string. May be empty.
*/
subject: string
/**
* Plain-text body of the message, if present.
*/
textBody?: string
/**
* HTML body of the message, if present.
*/
htmlBody?: string
/**
* Decoded attachments. An empty array when the message has none.
*/
attachments?: InboundEmailAttachment[]
/**
* All headers from the raw message, lowercased keys to canonicalize the
* many capitalizations that mail servers use. Multi-value headers
* (`Received:`, etc.) are joined with newlines or returned as arrays at
* provider discretion — see provider docs.
*/
headers: Record<string, string | string[]>
/**
* Server-side timestamp the inbound provider received the message.
*/
receivedAt: Date
/**
* Optional `Message-ID` header value for threading. Surfaced separately
* from `headers` because helpdesk handlers almost always need it.
*/
messageId?: string
/**
* Optional `In-Reply-To` header value for threading replies into an
* existing ticket.
*/
inReplyTo?: string
/**
* Optional `References` header values for threading.
*/
references?: string[]
}
InboundEmailReply
Outgoing reply produced by handler code in response to an
InboundEmail. Providers that support the optional
InboundEmailProvider.replyTo method translate this into whatever
outbound mechanism their upstream offers (Mailgun reply route, SES
SendEmail, etc.).
For providers that do NOT expose an outbound reply path, handler code
SHOULD fall back to the regular @molecule/api-emails outbound bond.
interface InboundEmailReply {
/**
* Subject line for the outbound reply. If omitted, providers SHOULD
* default to the original subject prefixed with `'Re: '` (locale-aware
* prefixing is the caller's responsibility).
*/
subject?: string
/**
* Plain-text body of the reply, if any.
*/
textBody?: string
/**
* HTML body of the reply, if any.
*/
htmlBody?: string
/**
* Attachments to send with the reply.
*/
attachments?: InboundEmailAttachment[]
/**
* Optional override for the `From:` address. Defaults to the address
* the original message was sent to (the inbound mailbox).
*/
from?: string
/**
* Optional additional headers to set on the outbound message.
*/
headers?: Record<string, string>
}
InboundEmailReplyResult
Result of a successful reply dispatch via
InboundEmailProvider.replyTo.
interface InboundEmailReplyResult {
/**
* Provider-supplied identifier for the dispatched outbound message.
*/
id: string
}
InboundEmailProvider
Inbound-email provider interface.
Implementations (Mailgun Routes, SES Inbound, etc.) live in separate
bond packages (@molecule/api-emails-inbound-mailgun-routes,
@molecule/api-emails-inbound-ses). The interface is deliberately
minimal: a webhook arrives at the host application's HTTP layer, the
raw headers and body are handed to the provider, and the provider
returns a normalized InboundEmail.
Signature verification is mandatory for any provider that runs against
a public webhook endpoint; verifySignature is the hook for
that. Providers without signed webhooks SHOULD return false rather
than true so callers can decide whether to accept unsigned mail.
interface InboundEmailProvider {
/**
* Parses the raw webhook payload (HTTP headers + body) into a
* normalized `InboundEmail`.
*
* @param headers - HTTP request headers received by the webhook
* endpoint. Lowercased keys are recommended but not required;
* implementations MUST handle either casing.
* @param body - Raw HTTP request body. May be a `Buffer` (e.g. from a
* raw body parser), a `string`, or an already-parsed object provided
* by an upstream JSON middleware.
* @returns The normalized inbound email.
*/
parseWebhookPayload(
headers: Record<string, string | string[] | undefined>,
body: Buffer | string | Record<string, unknown>,
): Promise<InboundEmail>
/**
* Verifies the signature of a webhook request, using whatever scheme
* the provider exposes (Mailgun HMAC, SES SNS subscription
* confirmation, etc.). Implementations MUST be constant-time when
* comparing secrets.
*
* A genuinely invalid webhook (forged, stale, malformed, tampered
* signature) resolves `false` — that is the normal, expected failure
* path and callers map it to a `401`. Implementations MAY instead THROW
* a tagged configuration error (e.g. via `configNotConfiguredError()`
* from `@molecule/api-secrets`) when the provider itself is
* misconfigured — for example a missing signing key/secret. This is a
* DISTINCT failure class from a `false` return: a misconfigured server
* is not the same problem as a forged request, and collapsing both into
* the same `false` makes a broken deployment indistinguishable from an
* attack, with no trace either way. `@molecule/api-emails-inbound-mailgun`
* follows this pattern — `verifySignature` throws the tagged
* `config.notConfigured` error when `MAILGUN_API_KEY` is unset, and
* resolves `false` for every other verification failure.
*
* @param headers - HTTP request headers received by the webhook
* endpoint.
* @param body - Raw HTTP request body. Implementations that need the
* exact bytes (e.g. for HMAC) MUST be passed a `Buffer`.
* @returns `true` when the signature is valid, `false` for an
* invalid/forged/stale/malformed webhook.
* @throws {Error} Implementations MAY throw a tagged configuration error
* when the provider is missing required configuration (e.g. an unset
* signing key) — a server misconfiguration, not an invalid request.
* @example
* ```typescript
* // In an HTTP handler bound to the inbound webhook URL:
* const ok = await verifySignature(req.headers, req.rawBody)
* if (!ok) return res.status(401).end()
* // A thrown configuration error (server misconfigured) is deliberately
* // NOT caught above — do not wrap this call in a try/catch that maps
* // every failure to the same 401. Let it propagate to standard error
* // middleware, which maps a tagged config error to a 503, distinct
* // from the 401 an invalid/forged webhook gets.
* ```
*/
verifySignature(
headers: Record<string, string | string[] | undefined>,
body: Buffer | string,
): Promise<boolean>
/**
* Optional: dispatches an outbound reply through the provider's own
* reply mechanism. Providers that do not support reply dispatch (e.g.
* pure inbound-only adapters) SHOULD omit this method; callers MUST
* use `InboundEmailProvider.supportsReply` to detect support.
*
* @param email - The original inbound email being replied to.
* @param reply - The reply payload.
* @returns Result of the dispatch.
*/
replyTo?(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
/**
* Indicates whether the provider supports outbound reply dispatch via
* `replyTo`. Implementations SHOULD return a stable `true` /
* `false` based on their own configuration; the property is a function
* so providers can defer to runtime configuration if needed.
*
* @returns `true` when `replyTo` is implemented and ready to use.
*/
supportsReply(): boolean
}
Functions
setProvider(provider)
Registers an inbound-emails provider as the active singleton. Called by
bond packages (e.g. @molecule/api-emails-inbound-mailgun-routes)
during application startup.
function setProvider(provider: InboundEmailProvider): void
provider— The inbound-emails provider implementation to bond.
getProvider()
Retrieves the bonded inbound-emails provider, throwing if none is configured.
function getProvider(): InboundEmailProvider
Returns: The bonded inbound-emails provider.
hasProvider()
Checks whether an inbound-emails provider is currently bonded.
function hasProvider(): boolean
Returns: true if an inbound-emails provider is bonded.
parseWebhookPayload(headers, body)
Parses the raw webhook payload (HTTP headers + body) into a normalized
InboundEmail using the bonded provider.
function parseWebhookPayload(
headers: Record<string, string | string[] | undefined>,
body: string | Buffer<ArrayBufferLike> | Record<string, unknown>,
): Promise<InboundEmail>
headers— HTTP request headers received by the webhook endpoint.body— Raw HTTP request body.
Returns: The normalized inbound email.
verifySignature(headers, body)
Verifies the signature of a webhook request via the bonded provider.
function verifySignature(
headers: Record<string, string | string[] | undefined>,
body: string | Buffer<ArrayBufferLike>,
): Promise<boolean>
headers— HTTP request headers received by the webhook endpoint.body— Raw HTTP request body.
Returns: true when the signature is valid, false otherwise.
supportsReply()
Indicates whether the bonded provider supports outbound reply dispatch
via replyTo.
function supportsReply(): boolean
Returns: true when the bonded provider exposes a replyTo() method and reports supportsReply() === true.
replyTo(email, reply)
Dispatches an outbound reply through the bonded provider's reply mechanism.
function replyTo(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
email— The original inbound email being replied to.reply— The reply payload.
Returns: Result of the dispatch.
Available Providers
| Provider | Package |
|---|---|
| AgentMail | @molecule/api-emails-inbound-agentmail |
| Mailgun | @molecule/api-emails-inbound-mailgun |
| AWS SES Inbound | @molecule/api-emails-inbound-ses |
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-i18n^1.0.1
Runtime Dependencies
@molecule/api-bond@molecule/api-i18n
The inbound webhook endpoint is PUBLIC and unauthenticated — treat every request as forgeable:
- Verify BEFORE trusting:
verifySignature()first, thenparseWebhookPayload(). Reject failures with a 401 — never create tickets/replies from an unverified payload. - Signature verification needs the EXACT bytes received. Wire the route
so the raw request body is available (the fleet's Express body-parser bond
exposes
req.rawBody; on other stacks capture the raw body alongside parsing). A re-serialized/parsed-then-stringified body breaks HMAC verification. - Don't map every failure to 401. A thrown tagged configuration error (e.g. unset signing key) is a server misconfiguration — let it propagate to error middleware (→ 503) instead of catching it into the same 401 a forged webhook gets.
- Replying is optional per provider. Check
supportsReply()beforereplyTo()(which throws otherwise), and reply dispatch composes onto the outbound@molecule/api-emailsbond — wire a transport or replies fail. - Respond 2xx promptly once verified and make handling idempotent (dedupe on
InboundEmail.messageId) — providers retry slow or 5xx webhooks.
E2E Tests
Integration checklist — drive the real inbound endpoint (live preview, no
mocks), adapt each item to this app's actual inbox/ticket/thread flows, and
check every box off one by one. A box you can't check is an integration bug
to fix — not a skip. COUNTERPARTY: the app can't receive a real email in the
sandbox, so YOU play the mail provider — POST a realistic inbound-email
webhook to the app's inbound endpoint with sandbox_fetch (curl runs inside
the container). Model the payload on the BONDED provider's real format
(Mailgun: form fields sender/recipient/subject/body-plain/body-html/
attachment-N plus the timestamp/token/signature triple; SES/Postmark:
their JSON), and the happy-path POST must carry a VALID signature — compute it
the way the provider does (Mailgun signs HMAC-SHA256 of timestamp+token with
MAILGUN_API_KEY inside the replay window; read the key from the Environment
panel / .env.molecule). Never disable verifySignature() or mock
parseWebhookPayload() to go green — that proves nothing.
- A signed sample webhook to the inbound endpoint parses into the
normalized fields (from / to / subject / textBody / htmlBody) AND the app
ACTS on it — it files the mail into the right place (creates a ticket, a
comment on a thread, or a reply-thread) keyed off the recipient (
support@) or a plus-address / thread token (reply+<id>@). Verify the CREATED record (a DB row, and it shows up in the UI) — not just a 200. - Routing is correct: an email to
support@opens a NEW ticket, whilereply+<id>@(or anIn-Reply-To/Referencesmatch) threads onto the EXISTING one — each lands in the right user's / conversation's place, never a stranger's. - Attachments survive: an inbound message with an attachment has it
decoded from
attachments[].contentBase64and stored on the app's OWN storage (the uploads bond), not left as a provider link — the stored file opens from the ticket. - Retries don't duplicate: re-POST the SAME webhook (providers retry slow
/ 5xx deliveries) and confirm handling is idempotent — one ticket, not two
(dedupe on
id/messageId). - Malformed / empty payloads (missing
body-plain, no attachments, absent headers) are handled without a crash — a clean response, not a 500 stack trace. - SECURITY — the endpoint is AUTHENTICATED: a forged POST with a bad or
missing signature (or a
timestampoutside the replay window) is REJECTED (401) and creates NO record, so an attacker can't inject mail into another user's thread. A missing signing key is a DISTINCT 503, not a 401 — a server misconfig must not masquerade as an accepted or forged webhook. - SECURITY — the parsed
htmlBodyis sanitized before it is rendered anywhere: a<script>/onerror=in an inbound body must NOT execute when the ticket is viewed (no stored XSS from an inbound email body).