---
title: Giving an Inbox to Each Agent
date: '2026-07-23'
description: 'Password resets, invoices, and support replies still arrive by email, and my agents live on HTTP. Mailingest is the Rust mail platform I built so that an inbox costs one API call: an address whose mail shows up as a signed webhook.'
author: Pablo Fernandez
tags:
  - Rust
  - Email
  - SMTP
  - Webhooks
  - Agents
source: 'https://www.pablofr.com/blog/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 Mailingest logo](/blog/mailingest/mailingest.png "Mailingest: a self-hosted mail platform that turns email into HTTP.")

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](https://www.twilio.com/docs/sendgrid/for-developers/parsing-email/setting-up-the-inbound-parse-webhook), Mailgun's [receiving routes](https://documentation.mailgun.com/docs/mailgun/user-manual/receive-forward-store/), and Postmark's [inbound stream](https://postmarkapp.com/developer/user-guide/inbound/parse-an-email) 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](https://nats.io), 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:

```bash
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:

```rust:crates/db/src/queries.rs /generate_secret/
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](https://datatracker.ietf.org/doc/html/rfc5233) 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

```
   the world's MTAs
             │  SMTP :25
             ▼
   ┌───────────────────┐    raw RFC 822    ┌───────────────────┐
   │    smtp_ingest    │ ────────────────▶ │    S3  inbox/     │
   └─────────┬─────────┘                   └─────────┬─────────┘
             │ emails.received                       │
             │ (s3_key, envelope)                    │ download once
             ▼                                       │
   ┌───────────────────┐ ◀───────────────────────────┘
   │      router       │  SPF · DKIM · DMARC · rule match
   └─────────┬─────────┘
             │ webhooks.request.{shard}
             ▼
   ┌───────────────────┐    signed POST    ┌───────────────────┐
   │  webhook_worker   │ ────────────────▶ │  the agent's URL  │
   └───────────────────┘                   └───────────────────┘
```

*An inbound message: SMTP accepts and stages to S3, the router decides, the webhook worker delivers.*

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](https://datatracker.ietf.org/doc/html/rfc7208), [DKIM](https://datatracker.ietf.org/doc/html/rfc6376), [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), and ARC once per message with the [mail-auth](https://docs.rs/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](https://rspamd.com) 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:

```rust:services/router/src/router.rs /base_local/
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.

```
   billing+inv-2207@agents.example
                  │
                  │  split on '+', keep "billing"
                  ▼
   ┌────────────────────────────────┐
   │  rank 0  exact      billing    │ ◀ wins
   ├────────────────────────────────┤
   │  rank 1  wildcard   bill*      │   longest literal first
   ├────────────────────────────────┤
   │  rank 2  catch-all  *          │   always matches, never favored
   └────────────────────────────────┘
```

*Resolution order for a tagged address: exact match first, weighted wildcards next, catch-all last.*

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:

```json
{
  "version": "1.0",
  "delivery": {
    "delivery_id": "0198c1...",
    "inbound_rule_id": "0197f3...",
    "domain": "agents.example",
    "recipient": "billing+inv-2207@agents.example",
    "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": "<19c0a7@sender.example>",
    "from": [{ "name": "Acme Billing", "address": "invoices@sender.example" }],
    "to": [{ "name": null, "address": "billing+inv-2207@agents.example" }],
    "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:

```rust:services/webhook_worker/src/deliverer.rs /signed_payload/
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),
        &timestamp,
    );
    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.

```
   attempt 1 ──▶ fail   · wait 60s
   attempt 2 ──▶ fail   · wait 120s
   attempt 3 ──▶ fail   · wait 240s
   attempt 4 ──▶ fail   · wait 300s
   attempt 5 ──▶ fail   · wait 300s
   attempt 6 ──▶ fail
                   │
                   ▼
     webhook_failed_events  (the DLQ)
                   │  admin replays when the endpoint is back
                   ▼
               delivered
```

*The retry ladder: exponential backoff capped at five minutes, then the dead-letter queue.*

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](https://parquet.apache.org) 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](/blog/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.
