Inbound
Types, functions, errors, and provider coverage for @opencoredev/email-sdk/inbound.
Import everything on this page from @opencoredev/email-sdk/inbound. The module uses only Request, Headers, Uint8Array, TextDecoder, and Web Crypto, so it runs on Node 20+, Bun, Cloudflare Workers, and Convex. It adds no runtime dependency and is not part of the root entrypoint. For a walkthrough, see Receive email.
InboundEmail
| Field | Type | Notes |
|---|---|---|
provider | InboundProvider | Which provider delivered it |
id | string? | The provider's ID for the received message |
messageId | string? | RFC 5322 Message-ID, without angle brackets |
from | InboundAddress? | { address, name? }, with RFC 2047 names decoded |
to, cc, replyTo | InboundAddress[] | From headers or the provider's parsed fields |
bcc | InboundAddress[] | Only when the provider exposes it (Postmark, Resend, JetEmail, Mailtrap) |
subject, text, html | string? | Decoded to Unicode |
headers | InboundHeaders | Ordered and case-insensitive: get, getAll, has, entries, iteration |
attachments | InboundAttachment[] | See below |
inReplyTo | string? | First In-Reply-To ID |
references | string[] | References IDs in order |
date | Date? | Date header, or the provider's receive time |
security | InboundSecurity | spf, dkim, dmarc, spam, virus verdicts and spamScore when known |
envelope | InboundEnvelope? | SMTP from and to, which can differ from the headers |
raw | InboundRawPayload | { kind: "json", value }, { kind: "form", fields }, or { kind: "mime", bytes } |
Verdicts are "pass" | "fail" | "softfail" | "neutral" | "none" | "temperror" | "permerror" | "gray" | "processing_failed" | "unknown". They come from fields the provider adds after its own checks (SES receipt verdicts, SendGrid SPF/dkim, Mandrill spf/dkim, Lettermint and Primitive auth results, Mailgun X-Mailgun-* headers). Authentication-Results headers count only when their authserv-id is trusted, because a sender can write that header into the message: JetEmail trusts smtp.jetsmtp.net, and parseCloudflareInbound and emailFromMime take a trustedAuthServIds option. Only the topmost header from a trusted server counts, so trust an authserv-id only if that server removes incoming headers that claim it. Spam scores use each provider's scale, so compare them per provider.
InboundAttachment has filename?, contentType, size?, contentId?, disposition? ("attachment" | "inline"), content? (bytes when delivered inline), url?, and read(): Promise<Uint8Array>. read() downloads from the provider when the content is remote and throws InboundError when it cannot, for example a Brevo attachment without apiKey. Mandrill sends no Content-ID for an image when the event has no raw_msg, and keys images by filename, so the SDK sets contentId only when the HTML links the image's key or name as cid:.
handleInbound
handleInbound(request: Request, config: InboundHandlerConfig): Promise<InboundHandlerResult>Verifies the request, parses it, and returns { type: "emails", emails } or { type: "handshake", response }. Return a handshake response unchanged: it answers Graph's validationToken request, SNS subscription confirmations, MailerSend's webhook.test route check, and Mandrill's HEAD probe and empty test POST. The provider test secrets (MailerSend's published key, Mandrill's test-webhook) only ever produce a handshake for a body that carries no message; they never authenticate an email. A Graph handshake fails with secret_missing when clientState is empty, so a subscription is only confirmed once its notifications can be checked. Every config also accepts toleranceSeconds (default 300), now, fetch, and signal.
provider | Required config | Optional |
|---|---|---|
"resend" | secret | api: { apiKey, baseUrl? } to fetch bodies and attachments |
"postmark" | auth | |
"mailgun" | secret (HTTP webhook signing key) | |
"sendgrid" | publicKey, or auth | |
"ses" | topicArn, or allowAnyTopic: true | s3, confirmSubscription (default true), certificateCache. toleranceSeconds defaults to 3600 for SNS. |
"graph" | clientState, api: { getAccessToken } | api.baseUrl |
"mailchimp" | secret | url (defaults to request.url) |
"sparkpost", "lettr" | secret (webhook token) | |
"mailersend", "lettermint", "jetemail" | secret | |
"primitive" | secret | downloadOrigins for self-hosted download URLs |
"mailpace" | publicKey | |
"brevo" | auth | apiKey, baseUrl |
auth is { basic: { username, password } }, { bearer: token }, or { none: true }. Secrets and keys accept an array during rotation; any match passes, and a malformed entry is skipped.
Functions by provider
verify… functions return { ok: true } or { ok: false, reason } and never throw for bad input. parse… functions do not verify; call them only after a successful check. Every fetch… function takes fetch and signal options.
| Provider | Verify | Parse |
|---|---|---|
| Resend | verifyResendInbound | parseResendInbound |
| Postmark | verifyBasicAuth | parsePostmarkInbound |
| Mailgun | verifyMailgunInbound | parseMailgunInbound |
| SendGrid | verifySendGridInbound | parseSendGridInbound |
| SES | verifySnsMessage | parseSesInbound, parseSesNotification |
| Cloudflare | parseCloudflareInbound | |
| Microsoft Graph | parseGraphNotifications | |
| Mailchimp Transactional | verifyMandrillInbound | parseMandrillInbound |
| SparkPost, Lettr | verifySparkPostInbound | parseSparkPostInbound, parseLettrInbound |
| MailerSend | verifyMailerSendInbound | parseMailerSendInbound |
| Brevo | verifyBasicAuth, verifyBearerToken | parseBrevoInbound |
| MailPace | verifyMailPaceInbound | parseMailPaceInbound |
| Lettermint | verifyLettermintInbound | parseLettermintInbound |
| Primitive | verifyPrimitiveInbound | parsePrimitiveInbound |
| JetEmail | verifyJetEmailInbound | parseJetEmailInbound |
| Mailtrap | verifyMailtrapInbound |
isMailerSendRouteTest(payload) and isMandrillWebhookTest(payload, { url }) detect the providers' setup checks when you verify by hand; answer those with a 200 and do not parse them. verifySnsMessage takes the result of parseSnsMessage(body). parseGraphNotifications checks clientState on every notification, and graphValidationResponse(url) answers the subscription handshake. parseMandrillInbound, parseSparkPostInbound, parseLettrInbound, and parseBrevoInbound return arrays, because one post can carry several messages.
Fetching from provider APIs
Some providers keep the message, or parts of it, behind their API:
fetchResendInboundEmail(id, { apiKey })returns bodies, headers, and attachments whoseread()downloads them.fetchPostmarkInboundEmail(id, { serverToken })returns the message details. That API lists attachments without their bytes, so theirread()throws; read attachments from the webhook payload.fetchMailgunStoredEmail(storageUrl, { apiKey })reads a message a route'sstore()action kept.fetchSesInboundFromS3({ bucket, key }, credentials)downloads mail an S3 receipt action stored, andconfirmSnsSubscription(message)confirms an SNS subscription.fetchGraphInboundEmail(resource, { getAccessToken })fetches a Graph message as MIME.fetchMailtrapInboundEmail(inboxId, messageId, { apiToken })fetches a Mailtrap message.- Brevo and Lettermint attachments download through
read(). Brevo needsapiKey. parsePrimitiveInbounddownloads the raw message when the webhook does not include it, only fromhttps://*.primitive.dev. A self-hosted Primitive lists its origins indownloadOrigins.
parseInbound(provider, payload, options?) dispatches to the right parser and always returns an array. It covers every provider above except Cloudflare, Graph, and Mailtrap, which have no webhook payload to parse.
Signature schemes
| Provider | What is checked |
|---|---|
| Resend | Svix HMAC-SHA256 over svix-id.svix-timestamp.body with the whsec_ secret, plus timestamp tolerance |
| Mailgun | HMAC-SHA256 hex over timestamp + token, plus timestamp tolerance. The message is not covered. |
| SendGrid | ECDSA P-256 / SHA-256 over X-Twilio-Email-Event-Webhook-Timestamp + body, plus timestamp tolerance |
| SES | SNS RSA signature (v1 SHA-1, v2 SHA-256). The certificate must come from https://sns.<region>.amazonaws.com[.cn]/….pem, the topic must be in topicArn, and the signed Timestamp must be within toleranceSeconds (default 1 hour). |
| Graph | clientState equality, in constant time |
| Mailchimp Transactional | Base64 HMAC-SHA1 over the URL followed by each sorted POST key and value |
| SparkPost, Lettr | X-MessageSystems-Webhook-Token equality |
| MailerSend | Hex HMAC-SHA256 of the body in Signature |
| MailPace | Ed25519 over the body in X-MailPace-Signature |
| Lettermint | t=…,v1=… HMAC-SHA256 over t.body, X-Lettermint-Delivery equal to t, plus tolerance |
| Primitive | t=…,v1=… HMAC-SHA256 over t.body, plus tolerance |
| JetEmail | Hex HMAC-SHA256 over X-Webhook-ID.X-Webhook-Timestamp.body, plus tolerance |
| Mailtrap | Hex HMAC-SHA256 of the body in mailtrap-signature |
All comparisons run in constant time. A valid signature does not stop a replay inside the tolerance window, so deduplicate on email.id or email.messageId.
Errors
InboundError has reason, provider, and status (HTTP status for fetch_failed).
reason | Meaning | Suggested response |
|---|---|---|
signature_missing | No signature or credentials on the request | 401 |
signature_invalid | Signature, token, or certificate check failed | 401 |
timestamp_expired | Timestamp outside toleranceSeconds | 401 |
secret_missing | No secret or key configured, or the key is malformed | 500 (fix the config) |
payload_invalid | Body is not the provider's documented format | 400 |
unsupported | For example an SNS message that is not an email notification | 200, then ignore |
decryption_required | SES stored the message with receipt-rule encryption, which only the S3 encryption client can decrypt | 500, so SNS retries; decrypt it yourself and call sesEmailFromNotification |
fetch_failed | A provider API or download failed; see status | 502, or retry |
MIME parsing
parseMimeMessage(raw) and emailFromMime({ provider, raw, ... }) are exported for your own sources, such as an IMAP fetch or a stored .eml. parseMimeMessage returns headers with encoded words decoded for display, and rawHeaders as sent; addresses are always parsed from the raw values, so an encoded display name cannot change the address. The parser handles nested multipart (mixed, alternative, related, digest), base64, quoted-printable, 7bit/8bit/binary, RFC 2047 encoded words, RFC 2231 parameters and continuations, message/rfc822 parts as attachments, and charsets through TextDecoder. Unknown charsets fall back to UTF-8, then Latin-1. It stops at 32 nesting levels and 2,000 parts.
It does not verify DKIM or S/MIME, decode TNEF (winmail.dat), or decode uuencode bodies. Within multipart/alternative it keeps the last (preferred) text and HTML part. Elsewhere, several inline text or HTML parts are joined with a newline in message order.
Provider coverage
Plunk, Sequenzy, Unosend, Loops, Iterable, Scaleway, ZeptoMail, Sendheron, Helo, and SMTP have no inbound support. Plunk's receiving feature fires a workflow event with a body you template yourself. Sequenzy tracks replies but does not return messages. The others document send-only APIs, and SMTP is a sending transport. The full audit, with provider documentation links, is in packages/email-sdk/INBOUND.md.