Email SDK
Reference

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

FieldTypeNotes
providerInboundProviderWhich provider delivered it
idstring?The provider's ID for the received message
messageIdstring?RFC 5322 Message-ID, without angle brackets
fromInboundAddress?{ address, name? }, with RFC 2047 names decoded
to, cc, replyToInboundAddress[]From headers or the provider's parsed fields
bccInboundAddress[]Only when the provider exposes it (Postmark, Resend, JetEmail, Mailtrap)
subject, text, htmlstring?Decoded to Unicode
headersInboundHeadersOrdered and case-insensitive: get, getAll, has, entries, iteration
attachmentsInboundAttachment[]See below
inReplyTostring?First In-Reply-To ID
referencesstring[]References IDs in order
dateDate?Date header, or the provider's receive time
securityInboundSecurityspf, dkim, dmarc, spam, virus verdicts and spamScore when known
envelopeInboundEnvelope?SMTP from and to, which can differ from the headers
rawInboundRawPayload{ 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

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.

providerRequired configOptional
"resend"secretapi: { apiKey, baseUrl? } to fetch bodies and attachments
"postmark"auth
"mailgun"secret (HTTP webhook signing key)
"sendgrid"publicKey, or auth
"ses"topicArn, or allowAnyTopic: trues3, confirmSubscription (default true), certificateCache. toleranceSeconds defaults to 3600 for SNS.
"graph"clientState, api: { getAccessToken }api.baseUrl
"mailchimp"secreturl (defaults to request.url)
"sparkpost", "lettr"secret (webhook token)
"mailersend", "lettermint", "jetemail"secret
"primitive"secretdownloadOrigins for self-hosted download URLs
"mailpace"publicKey
"brevo"authapiKey, 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.

ProviderVerifyParse
ResendverifyResendInboundparseResendInbound
PostmarkverifyBasicAuthparsePostmarkInbound
MailgunverifyMailgunInboundparseMailgunInbound
SendGridverifySendGridInboundparseSendGridInbound
SESverifySnsMessageparseSesInbound, parseSesNotification
CloudflareparseCloudflareInbound
Microsoft GraphparseGraphNotifications
Mailchimp TransactionalverifyMandrillInboundparseMandrillInbound
SparkPost, LettrverifySparkPostInboundparseSparkPostInbound, parseLettrInbound
MailerSendverifyMailerSendInboundparseMailerSendInbound
BrevoverifyBasicAuth, verifyBearerTokenparseBrevoInbound
MailPaceverifyMailPaceInboundparseMailPaceInbound
LettermintverifyLettermintInboundparseLettermintInbound
PrimitiveverifyPrimitiveInboundparsePrimitiveInbound
JetEmailverifyJetEmailInboundparseJetEmailInbound
MailtrapverifyMailtrapInbound

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 whose read() downloads them.
  • fetchPostmarkInboundEmail(id, { serverToken }) returns the message details. That API lists attachments without their bytes, so their read() throws; read attachments from the webhook payload.
  • fetchMailgunStoredEmail(storageUrl, { apiKey }) reads a message a route's store() action kept.
  • fetchSesInboundFromS3({ bucket, key }, credentials) downloads mail an S3 receipt action stored, and confirmSnsSubscription(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 needs apiKey.
  • parsePrimitiveInbound downloads the raw message when the webhook does not include it, only from https://*.primitive.dev. A self-hosted Primitive lists its origins in downloadOrigins.

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

ProviderWhat is checked
ResendSvix HMAC-SHA256 over svix-id.svix-timestamp.body with the whsec_ secret, plus timestamp tolerance
MailgunHMAC-SHA256 hex over timestamp + token, plus timestamp tolerance. The message is not covered.
SendGridECDSA P-256 / SHA-256 over X-Twilio-Email-Event-Webhook-Timestamp + body, plus timestamp tolerance
SESSNS 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).
GraphclientState equality, in constant time
Mailchimp TransactionalBase64 HMAC-SHA1 over the URL followed by each sorted POST key and value
SparkPost, LettrX-MessageSystems-Webhook-Token equality
MailerSendHex HMAC-SHA256 of the body in Signature
MailPaceEd25519 over the body in X-MailPace-Signature
Lettermintt=…,v1=… HMAC-SHA256 over t.body, X-Lettermint-Delivery equal to t, plus tolerance
Primitivet=…,v1=… HMAC-SHA256 over t.body, plus tolerance
JetEmailHex HMAC-SHA256 over X-Webhook-ID.X-Webhook-Timestamp.body, plus tolerance
MailtrapHex 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).

reasonMeaningSuggested response
signature_missingNo signature or credentials on the request401
signature_invalidSignature, token, or certificate check failed401
timestamp_expiredTimestamp outside toleranceSeconds401
secret_missingNo secret or key configured, or the key is malformed500 (fix the config)
payload_invalidBody is not the provider's documented format400
unsupportedFor example an SNS message that is not an email notification200, then ignore
decryption_requiredSES stored the message with receipt-rule encryption, which only the S3 encryption client can decrypt500, so SNS retries; decrypt it yourself and call sesEmailFromNotification
fetch_failedA provider API or download failed; see status502, 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.

On this page