Giving an Inbox to Each Agent
My agents can serve an endpoint, call an API, and hold state in Postgres. The one interface the rest of the world insists on, they cannot speak: email. The signup confirmation, the invoice, the support reply, the "confirm your address" link that gates half the internet. All of it terminates in an inbox, and an inbox assumes a person with a mail client and a pair of eyes.

The usual fix is to hand the agent a mailbox built for a human. A Gmail account with OAuth polling works until it doesn't: tokens expire, quotas bite, and every agent ends up wearing the same sad service account. IMAP is worse in a more interesting way, because the protocol itself assumes a person: folders, flags, a long-lived connection that asks what changed since last time. The hosted services have the right shape instead. SendGrid's Inbound Parse, Mailgun's receiving routes, and Postmark's inbound stream all turn a message into an HTTP POST, which is a language an agent actually speaks.
That shape is what I wanted, but as a primitive I own end to end: my hardware, my domains, my database, no per-address dashboard ceremony. The bar: an inbox should cost one API call. Mailingest is the system that clears it, a Rust workspace of nineteen crates and thirteen services that talk to each other over NATS, with Postgres, Redis, and S3 underneath. Mail arrives on port 25 and leaves the other side as a signed JSON POST to whatever URL the address belongs to. The agent polls nothing. Its inbox is an endpoint it already serves.
An inbox is a row#
Two objects make an inbox. The first is a domain, created through the API and verified through DNS; the MX refuses mail for any domain that has not proven its records. The second is an inbound rule, and this is the one that matters:
curl -X POST https://api.mailingest.com/v1/inbound_rules \
-H "Authorization: Bearer mx_..." \
-H "Content-Type: application/json" \
-d '{
"domain_id": "0197b8e2-1f4a-7c3d-9e5b-2a6f8c1d4e70",
"local": "billing",
"webhook_url": "https://agent.internal.example/inbox"
}'The response echoes the rule back with a secret the system just minted: 64 hex characters from the OS random source, and the HMAC key for every webhook this address will ever send. Underneath, the whole operation is one insert:
pub async fn create(
pool: &DbPool,
domain_id: Uuid,
local: &str,
webhook_url: Option<&str>,
) -> Result<Self> {
let secret = mailingest_crypto::generate_secret();
let query = r#"
INSERT INTO inbound_rules (id, domain_id, local, webhook_url, secret, retention_days, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 30, NOW(), NOW())
RETURNING *
"#;
let rule = sqlx::query_as::<_, Self>(query)
.bind(Uuid::now_v7())
.bind(domain_id)
.bind(local)
.bind(webhook_url)
.bind(&secret)
.fetch_one(pool)
.await?;
Ok(rule)
}local is what turns this from an address book into an inbox factory. It can be a literal name (billing), a wildcard (bill*), or a catch-all (*), and plus-addressing comes free: mail for billing+inv-2207@ matches the billing rule, subaddressing in the RFC 5233 sense. One catch-all rule hands an agent an entire namespace. It can give signup-github@, alerts-prod@, and replies-crawler@ to different counterparties without asking permission for each, then sort the replies by the address they came back on.
The path a message takes#
Delivery starts blunt. The world's mail servers connect to smtp_ingest on port 25, and the consequential decision happens at RCPT TO, before any content arrives: the recipient's domain has to exist and be verified, and Redis counters enforce per-user and per-domain receive quotas right there in the session. The caps are ordinary and load-bearing: 100 recipients per message, 25 MB per message, 5,000 concurrent connections per instance.
The part I would defend hardest in a design review is what happens at the end of DATA. The raw RFC 822 bytes go to S3 first, under a date-plus-UUIDv7 key in a transient inbox/ prefix. Only then does the service publish an event on the NATS subject emails.received, and the event carries the S3 key, not the message. NATS tops out around 1 MB per message and email does not, so the queue moves references while the object store moves bytes. The sender's 250 OK goes out after both writes succeed, which means accepted mail is durable mail. Routers consume the subject in a queue group, so adding an instance splits the load; each one works through up to 100 messages concurrently.
Answer fast, verify off the line#
Notice what the SMTP session did not do: SPF, DKIM, DMARC. Those are DNS lookups and signature checks performed while a remote peer holds a connection open, and a slow banner is how you end up greylisted by the rest of the internet. So the session keeps only the cheap, connection-level defenses, both optional: a DNSBL check on the peer IP, and greylisting that defers first-contact (ip, sender, recipient) triplets.
Authentication happens where nobody is waiting. The router verifies SPF, DKIM, DMARC, and ARC once per message with the mail-auth crate, and the verdicts ride inside the webhook payload. Set DMARC_ENFORCE and mail failing a p=reject policy is dropped; point RSPAMD_URL at an rspamd instance and its verdict joins the payload too. There is also a small built-in content scorer, zero to ten: empty subjects, shouting capitals, link shorteners, too many URLs. The score is advisory. The webhook still arrives, carrying spam.score and its flags, and the agent decides what to do with a suspicious message, which is exactly the kind of judgment call agents are for.
Which address wins#
When the router has the message, it has to answer one question per recipient: which rule owns this address? The resolution is one ranking loop, short enough to quote:
let base_local = local_part
.split('+')
.next()
.unwrap_or(&local_part)
.to_string();
let mut best: Option<(u8, usize, InboundRule)> = None;
for rule in rules {
let rule_local = rule.local.trim().to_lowercase();
let match_rank = if rule_local == local_part || rule_local == base_local {
Some((0u8, rule_local.len()))
} else if is_catch_all(&rule_local) {
Some((2u8, 0))
} else if rule_local.contains('*') {
if wildcard_match(&rule_local, &local_part)
|| wildcard_match(&rule_local, &base_local)
{
let weight = rule_local.replace('*', "").len();
Some((1u8, weight))
} else {
None
}
} else {
None
};Rank 0 is an exact hit, tried against both the full local part and the plus-stripped base, so billing+inv-2207 finds a literal billing+inv-2207 rule if you made one and falls back to billing if you didn't. Rank 1 is a wildcard, weighted by how many literal characters it contains, so bill* beats b* for the same address. Rank 2 is the catch-all, which matches everything and wins nothing it doesn't have to. Lowest rank wins; ties go to the longest literal.
Matching is also where the other inbound machinery hangs: domain aliases substitute one domain for another before resolution, address rewrites and email aliases can rename the local part, and a rule can fan out to up to 25 forwarding targets besides its webhook.
The webhook is the inbox#
What the agent finally receives is a versioned JSON document, assembled in the router and delivered by a separate worker. Abridged, with the real field names:
{
"version": "1.0",
"delivery": {
"delivery_id": "0198c1...",
"inbound_rule_id": "0197f3...",
"domain": "agents.example",
"recipient": "[email protected]",
"local": "billing+inv-2207",
"received_at": "2026-07-23T10:41:07Z",
"attempt": 0
},
"network": {
"remote_ip": "203.0.113.42",
"encryption": "starttls",
"security": {
"spf": { "result": "pass" },
"dkim": { "result": "pass" },
"dmarc": { "policy": "none", "dkim_result": "pass", "spf_result": "pass" },
"arc": { "result": "none" }
}
},
"envelope": {
"message_id": "<[email protected]>",
"from": [{ "name": "Acme Billing", "address": "[email protected]" }],
"to": [{ "name": null, "address": "[email protected]" }],
"subject": "Invoice INV-2207"
},
"content": {
"text_body": "Your July invoice is attached.",
"html_body": "<html>...</html>",
"attachments": [{ "filename": "INV-2207.pdf", "content_type": "application/pdf", "size": 102400 }]
},
"spam": { "score": 0.5, "flags": [] }
}Every delivery is signed. The worker concatenates the Unix timestamp, a dot, and the exact body bytes, then computes HMAC-SHA256 over that with the rule's secret:
let body = serde_json::to_vec(&payload)?;
let timestamp = timestamp.to_string();
let mut signed_payload = Vec::with_capacity(timestamp.len() + 1 + body.len());
signed_payload.extend_from_slice(timestamp.as_bytes());
signed_payload.push(b'.');
signed_payload.extend_from_slice(&body);
let mut request = self
.client
.post(&event.webhook_url)
.header("Content-Type", "application/json");
if let Some(secret) = event.secret.as_deref() {
let signature = mailingest_crypto::signature_header(secret, &signed_payload);
request = request.header(
format!("X-{}-Timestamp", self.config.brand.name),
×tamp,
);
request = request.header(format!("X-{}-Signature", self.config.brand.name), signature);
}The agent verifies by recomputing the same HMAC from the X-Mailingest-Timestamp header and the raw body, and comparing it to the sha256= value in X-Mailingest-Signature. Because the timestamp sits under the signature, a captured request goes stale on its own: reject anything outside your freshness window and a replay fails without any nonce bookkeeping.
Then there is the part every webhook system gets wrong until it hurts: the receiving endpoint will be down. The worker makes six attempts, backing off 60, 120, 240, 300, and 300 seconds between them. After the sixth failure the delivery is written to a webhook_failed_events table, a dead-letter queue an admin can list and replay once the endpoint is back.
One more thing the worker refuses to be: a confused deputy. A service that POSTs to user-supplied URLs is an internal-network scanner waiting to be asked nicely, so it rejects non-HTTP schemes, blocks literal private and loopback addresses before opening a socket, and resolves hostnames through a guard resolver that discards any answer pointing inside the network. That last one closes DNS rebinding, where a hostname is public at validation time and private at connect time.
Replying, and a human over the shoulder#
Receiving is half an inbox. The agent can also answer: POST /v1/emails for the HTTP-native path, or the authenticated submission port if it wants to behave like a real mail client. Either way the message becomes an outbound job, a delivery worker signs it with the domain's DKIM key, and an egress service hands it to the remote MX from a chosen source IP, so one tenant's sending behavior cannot burn another tenant's reputation.
And because the router archives accepted mail as it goes (Parquet batches on S3, an index row per message in Postgres), the same inbox has human doors. IMAP and POP3 serve straight from that archive, so I can open an agent's mailbox in a normal mail client and scroll through what it has been up to. The inbox is one dataset with several doors: the webhook for the agent, IMAP for me, the API for everything else.
Where it isn't worth it#
- It is a mail platform, not a feature. Thirteen services, NATS, Postgres, Redis, S3. I run infrastructure for a living and enjoy it; if you don't, the hosted inbound parsers I named at the top get you the same webhook in an afternoon, and that is the right call.
- Receiving is the forgiving half of email. Nobody checks the reputation of a server that only accepts mail. Sending at volume is a reputation game played against blocklists and opaque scoring, and while the egress service has per-IP isolation, warmed-up IPs and blocklist tending are labor, not code.
- The spam story is heuristics. A DNSBL, greylisting, and a ten-point content scorer. There is a design doc in the repo describing a much bigger engine, with behavioral scoring, similarity hashing, and reputation feedback; it is not shipped. The design doc outran the code, which is worth admitting out loud.
- No toy mode. The MX rejects mail for any domain that has not proven its DNS, which is the correct default and also means "spin up an inbox in a test" needs a real domain with real records. You cannot try this on localhost the way you can try a web framework.
What an address buys#
I wrote recently about the distance from a message to your code, and how each chat platform sets that distance for you. Email is the longest distance of all, four decades of protocol between a sender and your handler, and also the only interface the entire world has already integrated. Nothing on the other side has to adopt anything. Every SaaS, every billing department, every government portal, every human with a Send button can reach an agent the moment it has an address.
That is what the machinery in this post actually buys. Not mail for its own sake, but reachability: the inbox stopped being a place my agents check and became a thing that happens to them, one signed POST at a time. And the address itself costs what I wanted it to cost from the start. One API call. One row.
Related articles
whatsapp-web.js runs a whole headless Chromium to act like a phone. The other door skips the browser and speaks WhatsApp's native protocol over a plain WebSocket, then hands you a raw socket and a firehose of events. whatsweb is the thin layer I built to make that socket feel like a bot.
The same bot, a user's message answered by code I wrote, costs a handful of lines on Telegram, a gateway and a bag of intents on Discord, and a whole headless browser pretending to be a phone on WhatsApp. Four libraries in, the pattern was clear, and the hard part was never the bot.