Email SDK
Guides

Receive email

Verify and parse inbound email from Resend, Mailgun, SendGrid, SES, Cloudflare, Microsoft Graph, Postmark, and 10 more providers into one typed InboundEmail.

Point your provider's inbound webhook at a route, then hand the request to handleInbound. It checks the provider's signature, parses the payload, and returns one InboundEmail shape for every provider:

handleInbound takes a web-standard Request, so the same code works in Next.js, Hono, Bun, Cloudflare Workers, and Convex HTTP actions. Import from @opencoredev/email-sdk/inbound. Nothing here loads unless you import it, so sending-only apps pay nothing.

Return a 2xx quickly. Providers retry on errors and timeouts, so store the email (deduplicate on email.id or email.messageId) and process it in the background.

What you get back

Every provider produces the same fields. Fields a provider does not send stay empty.

attachment.read() returns inline bytes directly and downloads them when the provider only sends a link (Resend, Brevo, Lettermint signed URLs). email.raw keeps the original payload. See the inbound reference for every field.

Provider setup

Each config takes the secret the provider gives you. Signed providers reject requests with a missing or wrong signature, and timestamped schemes also reject requests older than 5 minutes (toleranceSeconds).

Resend

The email.received webhook carries metadata only. Pass api to fetch the body, headers, and attachments in the same call.

Mailgun

Create a route whose action is forward("https://your.app/api/inbound"). To receive the raw message instead of parsed fields, end the URL in mime, for example /api/inbound/mime. Both forms are parsed the same way.

Mailgun signs only the timestamp and token, not the message. Store each token and reject repeats if replay matters to you.

SendGrid Inbound Parse

Turn on signature verification in the Inbound Parse security policy and pass the public key it returns. Both the default and "Send Raw" modes work.

Without a security policy, protect the URL with Basic auth and pass auth: { basic: { username, password } } instead.

Amazon SES

Add a receipt rule with an SNS action (content up to 150 KB), or an S3 action with an SNS topic, and subscribe your HTTPS endpoint to the topic. handleInbound verifies the SNS signature, confirms the subscription the first time, and reads S3 objects when you pass credentials.

handleInbound cannot read mail stored with SES receipt-rule message encryption, because only the S3 encryption client can decrypt it. Those requests fail with decryption_required. Return a 5xx for it, as in the route above, so SNS retries instead of dropping the message. To keep encryption on, decrypt the object yourself and pass the MIME to sesEmailFromNotification.

topicArn is required. A valid SNS signature only proves that some AWS account's topic sent the message, so without it anyone could post mail to your endpoint. Pass allowAnyTopic: true only if you check the topic yourself. SNS messages older than one hour are rejected; change that with toleranceSeconds.

Cloudflare Email Workers

Email Workers receive mail inside your Worker, so there is no request to verify:

The raw stream can be read once. Parse before calling message.forward(), or keep email.raw.bytes. To fill email.security from the Authentication-Results header your zone adds, pass its authserv-id: parseCloudflareInbound(message, { trustedAuthServIds: ["mx.example.com"] }).

Microsoft Graph

Create a subscription on /users/{id}/mailFolders/inbox/messages with changeType: "created" and a random clientState. handleInbound answers the validation request, checks clientState, and fetches each new message as MIME.

Graph expects a reply within 3 seconds. For busy mailboxes, call parseGraphNotifications in the route and fetchGraphInboundEmail from a queue.

Postmark and Brevo

Neither signs inbound requests. Put credentials in the webhook URL (https://user:pass@your.app/api/inbound) or configure a token on the Brevo webhook, then choose how handleInbound checks them:

auth: { none: true } turns the check off. Use it only behind another layer, such as an IP allowlist.

Other providers

ProviderConfig
Mailchimp Transactional{ provider: "mailchimp", secret: webhookKey, url: "https://your.app/api/inbound" }. url must match the URL saved in Mandrill exactly. Mandrill checks a new URL with a HEAD request, then an empty test POST; handleInbound answers both with a handshake. Export the route for HEAD too (export { POST as HEAD } in Next.js) to pass the first check.
SparkPost{ provider: "sparkpost", secret: relayAuthToken }
Lettr{ provider: "lettr", secret: forwardingToken }
MailerSend{ provider: "mailersend", secret: routeSigningSecret }. Saving a route sends a webhook.test request signed with MailerSend's public test secret; handleInbound answers it with a handshake and never returns it as an email.
MailPace{ provider: "mailpace", publicKey: domainVerificationKey }
Lettermint{ provider: "lettermint", secret: "whsec_…" }
Primitive{ provider: "primitive", secret: webhookSecret }
JetEmail{ provider: "jetemail", secret: inboundWebhookSecret }

Mailtrap's webhook only announces a new message. Verify it with verifyMailtrapInbound, then load the message with fetchMailtrapInboundEmail(inboxId, messageId, { apiToken }).

Plunk, Sequenzy, Unosend, Loops, Iterable, Scaleway, ZeptoMail, Sendheron, Helo, and SMTP cannot receive mail through a supported API. The reference page explains why for each.

Without handleInbound

Each provider also has separate verify…Inbound and parse…Inbound functions for when you already have the raw body:

Verify the exact bytes the provider sent. Parsing JSON and serializing it again changes them and breaks the signature. Copy the headers before reading the body: Bun can drop a multipart Content-Type from headers first read after the body.

On this page