Amazon SES inbound email
Inbound parsing turns the email your users send into structured data your app can act on: a reply becomes a support ticket, a forwarded PDF becomes a row in your database, an incoming message becomes a task for an agent. This guide walks through receiving and parsing email on Amazon SES end to end, then shows the same flow on MailKite.
What you'll need
- A domain you control, with access to its DNS records.
- An AWS account with SES available in a Region that supports email receiving.
- A place to run your parser — a Lambda function is the common choice for the first half.
Part 1 — Receive and parse email with Amazon SES
Receiving on SES is a few moving parts wired together: an MX record, a receipt rule set, and one or more actions that store or forward each message. That's the honest shape of it — here's each piece.
1. Verify your domain and add the MX record
Verify the domain in SES first, then publish an MX record pointing
at the SES inbound endpoint. The host is
inbound-smtp.<region>.amazonaws.com and is
region-specific — use the endpoint for the Region where you run SES
(for example inbound-smtp.us-east-1.amazonaws.com in US East /
N. Virginia). SES uses priority 10:
Host Type Priority Value
example.com MX 10 inbound-smtp.us-east-1.amazonaws.com DNS can take a few hours to propagate, so add this early. Note the endpoint is not an IMAP or POP3 server — it only hands mail to your receipt rules.
2. Create a receipt rule set with actions
SES processes incoming mail through the active receipt rule set. Create
a rule set, make it active, and add a rule that matches your recipients and runs
one or more actions — an S3 action to store the raw MIME, an
SNS action to notify you, and/or a Lambda action to
process it:
# Create a rule set and make it the active one.
aws ses create-receipt-rule-set --rule-set-name inbound-rules
aws ses set-active-receipt-rule-set --rule-set-name inbound-rules
# Add a rule: match a recipient, store the raw MIME in S3, notify via SNS.
aws ses create-receipt-rule \
--rule-set-name inbound-rules \
--rule '{
"Name": "support-inbound",
"Enabled": true,
"TlsPolicy": "Optional",
"Recipients": ["support@example.com"],
"Actions": [
{ "S3Action": { "BucketName": "my-inbound-bucket" } },
{ "SNSAction": { "TopicArn": "arn:aws:sns:us-east-1:012345678912:inbound-topic" } }
]
}' You can do the same in the SES console under Email receiving. A single rule can chain several actions, and they run in order.
3. Parse the delivered message
With an SNS action, SES publishes a JSON notification whose
notificationType is Received. The mail
object carries commonHeaders (from, to, subject, messageId, date)
and the receipt object carries the auth verdicts
(spfVerdict, dkimVerdict, spamVerdict,
virusVerdict). The full message rides along in the
content field as raw MIME:
{
"notificationType": "Received",
"receipt": {
"timestamp": "2015-09-11T20:32:33.936Z",
"processingTimeMillis": 222,
"recipients": ["support@example.com"],
"spamVerdict": { "status": "PASS" },
"virusVerdict": { "status": "PASS" },
"spfVerdict": { "status": "PASS" },
"dkimVerdict": { "status": "PASS" },
"action": {
"type": "SNS",
"topicArn": "arn:aws:sns:us-east-1:012345678912:inbound-topic"
}
},
"mail": {
"timestamp": "2015-09-11T20:32:33.936Z",
"source": "ada@example.com",
"messageId": "d6iitobk75ur44p8kdnnp7g2n800",
"destination": ["support@example.com"],
"headersTruncated": false,
"commonHeaders": {
"from": ["Ada Lovelace <ada@example.com>"],
"to": ["support@example.com"],
"date": "Fri, 11 Sep 2015 20:32:32 +0000",
"messageId": "<61967230-7A45-4A9D-BEC9-87CBCF2211C9@example.com>",
"subject": "Can't update my card"
}
},
"content": "Return-Path: <ada@example.com>\r\nFrom: Ada Lovelace <ada@example.com>\r\nTo: support@example.com\r\nSubject: Can't update my card\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHi — my payment keeps failing…\r\n"
}
Note that the body and any attachments are inside that raw MIME string (or, with
the S3 action, in the bucket under the messageId key) —
SES doesn't decode them for you, so you parse the MIME yourself, for example with
mailparser in a Lambda:
import { simpleParser } from "mailparser";
// SNS action → Lambda: each record's Message is the SES notification JSON,
// and its "content" field is the raw MIME message you have to parse yourself.
export async function handler(event) {
for (const record of event.Records ?? []) {
const ses = JSON.parse(record.Sns.Message);
// commonHeaders gives you from/to/subject/messageId already split out…
console.log("from", ses.mail.source, "·", ses.mail.commonHeaders.subject);
// …but the body and attachments live inside the raw MIME. Parse it.
const parsed = await simpleParser(ses.content);
const body = parsed.text ?? parsed.html;
for (const att of parsed.attachments ?? []) {
// att.filename, att.contentType, att.content (a Buffer you must store).
}
// ...open a ticket, reply, hand to an agent.
}
return { statusCode: 200 };
} That's inbound on SES: MX to the SES endpoint, an active receipt rule with S3 / SNS / Lambda actions, and a handler that pulls headers from the notification and parses the raw MIME for the body and attachments.
Part 2 — The same flow on MailKite
When we built inbound into MailKite, we wanted three things to be true: one record to receive on your whole domain — no rule sets, no S3 / SNS / Lambda to wire up — a payload that's already decoded so there's no MIME to parse, and a webhook you can actually trust. Here's the same parse-a-reply flow.
1. Point your domain at MailKite
One MX record makes every address on the domain
receivable — no dedicated subdomain, no second record to prioritise:
Host Type Priority Value
yourdomain.com MX 10 mx.mailkite.dev The domain verifies as soon as that record resolves. See Domains & DNS for the SPF/DKIM/DMARC records that let you send from it too.
2. Register your webhook
Add your endpoint URL in the dashboard or via the
API and MailKite POSTs an email.received event
the moment mail arrives. Want specific addresses to hit specific endpoints?
Add a route — otherwise a catch-all sends the whole domain to one webhook.
3. Parse the payload
The message arrives already decoded — flat from,
to, subject, text, and html
fields, edge auth verdicts (SPF/DKIM/DMARC/spam), and attachments as
signed, time-limited links rather than raw MIME you have to unpack:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai", "name": "Support" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
} Because the URL is public, MailKite signs every request. Verify the signature over the raw body first, then handle the event — one message per call, no notification to unwrap:
import express from "express";
import { MailKite } from "mailkite";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type === "email.received"
// Confirm receipt: returns the JSON body {"status":"ok"}.
res.type("application/json").send(MailKite.replyOk());
});import os
from flask import Flask, request, abort
from mailkite import verify_webhook, reply_ok
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/hooks/mailkite")
def hook():
sig = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(sig, request.get_data(), SECRET):
abort(401)
event = request.get_json()
# ...handle event["type"] == "email.received"
# Confirm receipt: returns the JSON body {"status":"ok"}.
return reply_ok(), 200, {"content-type": "application/json"}<?php
$mk = new \MailKite\Client(""); // no API key needed to verify
$secret = getenv("MAILKITE_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_MAILKITE_SIGNATURE"] ?? "";
$rawBody = file_get_contents("php://input");
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// ...handle $event["type"] === "email.received"
header("content-type: application/json");
echo $mk->replyOk(); // {"status":"ok"}// Spring Boot
@PostMapping(value = "/hooks/mailkite", produces = "application/json")
public ResponseEntity<String> mailkite(
@RequestHeader("x-mailkite-signature") String signature,
@RequestBody byte[] rawBody) throws Exception {
MailKite mk = new MailKite(""); // no API key needed to verify
String body = new String(rawBody, StandardCharsets.UTF_8);
if (!mk.verifyWebhook(signature, body, System.getenv("MAILKITE_WEBHOOK_SECRET"))) {
return ResponseEntity.status(401).build();
}
// ...handle the event
return ResponseEntity.ok(mk.replyOk()); // {"status":"ok"}
}http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sig := r.Header.Get("x-mailkite-signature")
if !mailkite.VerifyWebhook(sig, string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(rawBody, &event)
// ...handle event["type"] == "email.received"
w.Header().Set("content-type", "application/json")
w.Write([]byte(mailkite.ReplyOk())) // {"status":"ok"}
})require "sinatra"
require "json"
require "mailkite"
post "/hooks/mailkite" do
raw_body = request.body.read
sig = request.env["HTTP_X_MAILKITE_SIGNATURE"]
halt 401 unless Mailkite.verify_webhook(sig, raw_body, ENV["MAILKITE_WEBHOOK_SECRET"])
event = JSON.parse(raw_body)
# ...handle event["type"] == "email.received"
content_type :json
Mailkite.reply_ok # {"status":"ok"}
end
The verifyWebhook helper recomputes the HMAC and rejects forged
or stale events — see Verifying signatures
for the header format and a no-SDK version. Fields and attachment links are
documented in full under Inbound webhooks.
A little about MailKite
MailKite is programmable email for developers and AI agents. Publish one
MX record and every address on your domain becomes a signed
webhook of clean, decoded JSON — no IMAP, no polling, no MIME wrangling.
The same API sends mail, and any inbox can be handed straight to an
agent that reads and replies on its own.
Inbound and receiving are on the free tier.
Next: Inbound webhooks for routing, retries, and attachments, or the Quickstart to get a verified domain and API key.