Email inbox API for AI agents. Give every agent its own real email address, receive mail as structured webhooks, and reply in-thread over REST - then run thousands of those inboxes side by side, isolated per tenant.
Agents increasingly need to be an email participant: receive a customer thread, act on it, reply as themselves. That normally means wiring an IMAP poller to a parser to an SMTP relay, and inheriting a legacy MTA's operational surface. Sentio is the whole path in one service - an inbox per agent, parsed and authenticated inbound delivered as a webhook, and sending over the same API.
flowchart LR
customer([customer]) -- SMTP --> IN
subgraph Sentio["Sentio · multi-tenant"]
IN["inbound<br/>authenticate → scan → score → route"]
OUT["outbound<br/>sign (DKIM) → queue → deliver"]
end
IN -- webhook --> agent([your agent])
agent -- REST --> OUT
OUT -- SMTP --> customer
Built for platforms. Tenancy reaches every layer: each domain, mailbox, API key, rate limit, suppression list, and spam profile belongs to a tenant. If you run an agentic platform or sell email as a feature, each of your customers gets isolated sending reputation, their own domains, and their own inboxes on one deployment.
Scaling down to focused instances. We are developing an embedded deployment mode that runs Sentio as a single Rust service, with Turso for durable state and local workers for queued work, plus local blob storage or an optional Google Cloud Storage bucket. The aim is to support many independently operated instances, each responsible for a narrow context: a project, a team, an agent workflow, or a small group of tenants. Each instance owns its database and work queue, limiting the impact of a process failure to its own scope; after restart, it can recover committed state and resume pending work while other instances continue running. Instances sharing a host or storage still share those failure risks. This mode is under development alongside the existing PostgreSQL-based deployment; see the implementation plan for progress and remaining work.
It is also a complete mail server. Sentio implements the full protocol - inbound and outbound, DKIM/SPF/DMARC/ARC, MTA-STS, DANE, three-tier anti-spam - so the agent inbox rests on real mail infrastructure rather than a wrapper around someone else's API.
- Quick start with Docker - running in ~5 minutes
- Installing without Docker - requirements and manual setup
- Configuration
- API reference and testing UI - browse and call every endpoint
- MCP: give agents email as native tools - sentio-mcp server
- Give an agent its own inbox - the flagship walkthrough
- Building agents - patterns for running agents in production
- Sending your first message
- Receiving mail
- Beyond sending and receiving - tracking, suppressions, webhooks, warmup
- Running a real mail server - DNS, PTR, port 25
- Testing
- Architecture
- Contributing
The compose stack brings up Sentio plus every service it needs: PostgreSQL, Redis, NATS/JetStream, MinIO, ClamAV, and rspamd.
Requirements: Docker Engine 24+ with the Compose plugin, ~4 GB RAM, ~8 GB disk.
git clone https://github.com/truespar/sentio.git
cd sentio
docker compose up -dThat pulls a prebuilt image - ghcr.io/truespar/sentio, published for
linux/amd64 and linux/arm64 - so there is nothing to compile. To build from
your own checkout instead:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --buildExpect that to take a while: it is a release build of the whole workspace.
Pin a version with SENTIO_TAG=1.2.3 docker compose up -d.
Watch it come up - migrations run once, then the server starts:
docker compose logs -f sentio-migrate # schema + bootstrap tenant
docker compose logs -f sentio # the serverConfirm it is healthy:
curl localhost:8080/health/ready
# {"status":"ok","database":"ok","kv":"ok"}Migration 002 seeds one admin tenant and one API key:
| Tenant ID | 00000000-0000-0000-0000-000000000001 |
| API key | sentio_bootstrap_admin_CHANGE_ME |
curl -H "Authorization: Bearer sentio_bootstrap_admin_CHANGE_ME" \
localhost:8080/v1/tenantsRotate this key before exposing the host to anything untrusted - it has
wildcard (*) scope. Create a replacement via
POST /v1/tenants/{id}/api-keys, then delete the bootstrap one.
| Port | Purpose | Notes |
|---|---|---|
| 25 | SMTP (MX) | Inbound mail from other servers |
| 465 | SMTPS | Implicit TLS - only binds when certificates are present |
| 587 | Submission | STARTTLS |
| 8080 | REST API | Also serves /openapi.json |
| 9001 | MinIO console | Optional; remove from compose to hide |
Already running something on port 25 (Postfix, Exim)? Either stop it or remap,
by creating a docker-compose.override.yml:
services:
sentio:
ports: !override
- "2525:25"
- "4465:465"
- "5587:587"
- "8080:8080"!override replaces the port list instead of appending to it. The file is
gitignored and loaded automatically.
No certificates ship with the image, so Sentio starts in plaintext and skips port 465. Mount a certificate and key to enable TLS:
services:
sentio:
volumes:
- ./tls:/etc/sentio/tls:ro # expects cert.pem and key.pemdocker compose down # stop, keep data
docker compose down -v # stop and delete all volumesBuild toolchain
| Requirement | Notes |
|---|---|
| Rust stable | Edition 2021. Install via rustup |
cmake |
Needed by aws-lc-rs, the rustls crypto backend |
libclang-dev |
Needed by bindgen-based dependencies |
| A C toolchain | build-essential or equivalent |
Required services
| Service | Default address | Purpose |
|---|---|---|
| PostgreSQL 18+ | localhost:5432 |
All persistent state |
| Redis or Valkey | localhost:6379 |
Rate limits, bans, greylist, reputation |
| NATS with JetStream | localhost:4222 |
Delivery, retry, webhook, and event pipelines |
| S3-compatible storage | localhost:9000 |
Raw .eml and attachment blobs |
PostgreSQL 18 is a hard floor: the schema uses uuidv7(), which arrived as a
built-in in 18. On an older server the migration stops with
function uuidv7() does not exist, which does not otherwise explain itself.
Check with psql -tAc 'SHOW server_version' before you start.
Any S3-compatible endpoint works - AWS S3, Cloudflare R2, MinIO, SeaweedFS,
Ceph. For NATS, JetStream must be enabled (nats-server -js); Sentio creates
its own streams on startup.
All four are required, and Sentio exits at startup if the KV store or the queue is unreachable. Install them before step 6, or run just those four from the compose file and keep Sentio itself on the host:
docker compose up -d postgres redis nats minioOptional services - Sentio degrades gracefully if these are absent.
| Service | Default address | Without it |
|---|---|---|
ClamAV (clamd) |
localhost:3310 |
Attachments are not virus-scanned |
| rspamd | localhost:11333 |
Falls back to the built-in Rust scorer |
# Debian / Ubuntu
sudo apt-get install -y build-essential cmake libclang-dev pkg-config
# Fedora / RHEL
sudo dnf install -y gcc gcc-c++ cmake clang-devel pkgconf
# macOS
brew install cmake llvmgit clone https://github.com/truespar/sentio.git
cd sentio
cargo build --releaseThe binary lands at target/release/sentio-smtp.
Compile-time-checked SQL is served from the committed .sqlx/ cache, so no
database is needed to build. If you change a query, regenerate it with a live
database:
echo 'DATABASE_URL=postgres://sentio:sentio@localhost:5432/sentio' > .env
cargo sqlx prepare --workspacesudo -u postgres createuser --pwprompt sentio
sudo -u postgres createdb --owner=sentio sentio--pwprompt asks for a password interactively. It has to match the one in
database.url, which ships as sentio. To script it, or to use a different
password, set both together:
sudo -u postgres psql -c "CREATE ROLE sentio LOGIN PASSWORD 'sentio';"
sudo -u postgres createdb --owner=sentio sentioThe binary embeds its migrations and applies them in order:
./target/release/sentio-smtp --config config/default.toml migrateThis creates the schema and seeds the bootstrap tenant and API key described above.
Copy the shipped defaults and edit:
sudo mkdir -p /etc/sentio
sudo cp config/default.toml /etc/sentio/sentio.tomlAt minimum set server.hostname, database.url, and the [storage]
credentials. See Configuration.
./target/release/sentio-smtp --config /etc/sentio/sentio.toml serveThis needs PostgreSQL, Redis, NATS and the object store already running - see Requirements.
Binding ports below 1024 as a non-root user needs the capability rather than root. Grant it on whichever copy you are running:
sudo setcap 'cap_net_bind_service=+ep' target/release/sentio-smtpUnder systemd this is unnecessary - the unit grants
AmbientCapabilities=CAP_NET_BIND_SERVICE instead.
A unit file is included at deploy/sentio-smtp.service:
sudo useradd --system --home-dir /var/lib/sentio --shell /usr/sbin/nologin sentio
sudo install -d -o sentio -g sentio /var/lib/sentio
sudo cp target/release/sentio-smtp /usr/local/bin/
sudo cp deploy/sentio-smtp.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sentio-smtp
journalctl -u sentio-smtp -fThe unit runs as the sentio user created above and reads
/etc/sentio/sentio.toml, so make that file readable by it. Check the
Requires=/After= lines before enabling: they name postgresql.service,
nats.service and redis-server.service, and systemd refuses to start a unit
whose Requires= target does not exist. Drop or rename any you run
differently - in a container, on another host, or under a different unit name.
TOML, with every value overridable by environment variable. The env form is
SENTIO__SECTION__KEY - a double underscore between levels, uppercased:
SENTIO__DATABASE__URL="postgres://sentio:secret@db.internal:5432/sentio"
SENTIO__SERVER__HOSTNAME="mail.example.com"
SENTIO__STORAGE__SECRET_KEY="…"
SENTIO__SPAM__RSPAMD__URL="http://127.0.0.1:11333" # [spam.rspamd] urlEnvironment overrides win over the file, which makes secrets easy to keep out of config: leave them unset in TOML and inject them at runtime.
Key sections:
| Section | Controls |
|---|---|
[server] |
Hostname, listener addresses, worker counts, session limits |
[tls] |
Certificate paths, minimum version, SNI, optional ACME |
[database] |
PostgreSQL URL and pool sizing |
[kv] / [redis] |
KV backend selection and connection |
[nats] |
JetStream URL, prefetch, stream retention |
[storage] |
S3 endpoint, credentials, bucket, path-style addressing |
[scanning] |
ClamAV host, size limits |
[spam] |
Backend choice, score thresholds |
[abuse] |
Rate limits, DNSBLs, greylisting, reputation thresholds |
[delivery] |
Retry schedule, connection pooling, optional smart-host relay |
[auth] |
DKIM/SPF/DMARC/ARC behaviour |
[llm] |
Provider, model, and when classification runs |
[observability] |
Log format and level, metrics, tracing |
[tracking] |
Open and click tracking, branded tracking domains |
[webhooks] |
Event dispatch: signing, retries, concurrency caps |
[deliverability] |
FBL/ARF handling, BATV, one-click unsubscribe |
[analytics] |
Rollup and retention for engagement data |
[error_events] |
Error capture and how long it is kept |
[listmonk] |
Optional bounce bridge to a Listmonk instance |
Defaults live in config/default.toml; the container
image ships config/oss.toml.
The server documents itself. Two endpoints, both live as soon as it starts:
| Endpoint | What it is |
|---|---|
/docs |
Interactive API reference with a built-in request client |
/openapi.json |
The OpenAPI 3.1 document - 116 operations across 85 paths |
Open http://localhost:8080/docs and you get every endpoint with its schemas, examples, and a Test Request button that calls your running server. Set the bearer token once in the auth panel and you can exercise the whole API from the browser without writing a line of curl.
The page is self-contained: its front-end bundle is embedded in the binary
and served by Sentio itself, so /docs renders fine on a host with no
outbound internet access.
A generated copy of the specification is committed at
docs/openapi.json, so you can read the API, diff it
across versions, or generate a client without starting anything. Export it
yourself at any time - no config, database, or network required:
cargo run -- openapi > openapi.json # from source
docker compose exec sentio sentio-smtp openapi # from the containerBecause it is a standard OpenAPI document, the usual generators work directly:
npx @openapitools/openapi-generator-cli generate \
-i docs/openapi.json -g typescript-fetch -o ./client| Group | Ops | What it covers |
|---|---|---|
| Messages | 9 | Submit single, batch, raw, or multipart mail; read status, events, and raw source |
| Domains | 7 | Register sending/receiving domains, fetch the DNS records to publish, verify them |
| Mailboxes | 5 | Per-address inboxes on a domain, with forwarding and auto-reply |
| Inbound Routes | 4 | Match inbound mail (exact, domain, regex, catch-all) to a webhook |
| Tenants | 6 | Create and manage tenants, tiers, and status |
| API Keys | 3 | Scoped keys per tenant |
| SMTP Credentials | 4 | Username/password pairs for SMTP submission (argon2-hashed) |
| OAuth | 5 | OAuth 2.0 clients - authorization code with PKCE, and client credentials |
| DKIM Keys | 5 | Generate, rotate, and retire signing keys; export the DNS record |
| Webhooks | 7 | Subscribe to delivery and engagement events, HMAC-signed with retries |
| Suppressions | 5 | Bounce, complaint, and unsubscribe lists; check an address before sending |
| IP Pools | 12 | Dedicated and shared pools, tenant assignment, and IP warmup schedules |
| Reputation | 3 | Per-IP and per-domain reputation scores |
| Abuse | 8 | IP bans, whitelists, and reputation controls for the connection tier |
| Spam Training | 2 | Train the Bayesian classifier on spam and ham |
| Tracking | 2 | Open-pixel and click-redirect endpoints |
| Tracking Domains | 7 | Branded CNAME tracking domains with managed certificates |
| Queues | 4 | Inspect depth, list deferred mail, pause and resume delivery |
| Reports | 7 | Ingest and read DMARC aggregate, FBL/ARF, and TLS-RPT reports |
| Analytics | 4 | Delivery, engagement, and volume summaries |
| Errors | 3 | Captured error events with a summary endpoint |
| Health | 2 | Liveness and readiness probes |
116 operations across 85 paths.
Authentication is a bearer token on every /v1/** route:
Authorization: Bearer <your-api-key>
sentio-mcp is a standalone Model Context Protocol
server that exposes the REST API as agent-callable tools, so MCP clients
(Claude Desktop, Cursor, opencode, ...) get email capabilities without writing
any integration code. It talks stdio, authenticates with a regular API key scoped to one
tenant, and inherits all of Sentio's existing auth and rate limiting.
| Tool | What it does |
|---|---|
list_messages |
List recent inbound/outbound messages |
get_message |
Fetch one message by ID |
send_message |
Send from an owned domain address |
reply_message |
Reply in-thread (sets In-Reply-To/References) |
list_mailboxes |
List mailboxes on a domain |
create_mailbox |
Create a mailbox on an owned domain |
Every release ships sentio-mcp as its own archive for Linux x86_64, Linux
aarch64 and Windows x86_64, so an MCP client host needs no Rust toolchain:
VERSION=0.1.5 # or whatever the latest release tag is
curl -LO "https://github.com/truespar/sentio/releases/download/v${VERSION}/sentio-mcp-${VERSION}-x86_64-unknown-linux-gnu.tar.gz"
tar -xzf sentio-mcp-*.tar.gzOr build it from a checkout:
cargo build --release -p sentio-mcpEither way, point it at a running Sentio. It keeps no state of its own:
SENTIO_BASE_URL="http://localhost:8080" \
SENTIO_API_KEY="your-api-key" \
./sentio-mcpWire it into an MCP client config (example for Claude Desktop / opencode):
{
"mcpServers": {
"sentio": {
"command": "/path/to/sentio-mcp",
"env": {
"SENTIO_BASE_URL": "http://localhost:8080",
"SENTIO_API_KEY": "your-api-key"
}
}
}
}A mailbox is an address that belongs to a tenant's domain. Give each agent one, and mail addressed to it arrives at your webhook already parsed, authenticated, and scored.
KEY="sentio_bootstrap_admin_CHANGE_ME"
API="http://localhost:8080"
TENANT="00000000-0000-0000-0000-000000000001"
# 1. A receiving domain for this tenant
DOMAIN_ID=$(curl -s -X POST "$API/v1/domains" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"domain_name":"acme.example.com","use_for_receiving":true,"use_for_sending":true}' \
| jq -r .data.id)
# 2. One mailbox per agent. metadata is yours - put the agent id in it.
curl -X POST "$API/v1/domains/$DOMAIN_ID/mailboxes" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"address": "support-agent",
"display_name": "Acme Support Agent",
"metadata": {"agent_id": "agt_01H8..."}
}'
# 3. Route the domain to your application
curl -X POST "$API/v1/tenants/$TENANT/inbound-routes" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"match_type": "domain",
"pattern": "acme.example.com",
"webhook_url": "https://your-platform.example.com/hooks/inbound",
"priority": 100
}'support-agent@acme.example.com is now live. Inbound mail is SPF/DKIM/DMARC
verified, virus-scanned, and spam-scored before your webhook fires, and those
verdicts arrive with the payload - so an agent never has to reason about
whether a sender was forged.
Pass the inbound Message-ID back as in_reply_to and the reply threads
correctly in the recipient's client:
curl -X POST "$API/v1/messages/send" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"from": "support-agent@acme.example.com",
"to": ["customer@example.net"],
"subject": "Re: Order #1234",
"text": "Refund processed - you should see it in 3-5 days.",
"in_reply_to": "<abc123@example.net>",
"references": ["<abc123@example.net>"]
}'Outbound is DKIM-signed with the tenant's own key, so replies authenticate as the customer's domain rather than yours.
| Field | Effect |
|---|---|
metadata |
Free-form JSON - the natural place for your agent_id |
forward_to |
Forward inbound mail on to external addresses (see below) |
auto_reply |
Immediate acknowledgement, threaded via In-Reply-To, while the agent works |
status |
disabled stops delivery without deleting history |
Set forward_to and everything arriving at that mailbox is forwarded on, to any
address anywhere - a personal Gmail account, a shared team inbox, a helpdesk:
curl -X PUT "$API/v1/domains/$DOMAIN_ID/mailboxes/$MAILBOX_ID" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"address": "support-agent",
"forward_to": ["oncall@gmail.com", "team@helpdesk.example.net"]
}'Forwarding is where most mail servers quietly break DMARC: they relay the
message unchanged, so the original From: no longer aligns with the forwarding
host's SPF, and the receiver rejects it. Sentio rewrites the envelope instead -
From: becomes the mailbox, and the message is re-signed with that domain's
DKIM key, so it authenticates as yours and survives the trip. The
original sender is preserved in Reply-To: and Resent-From:, so hitting reply
still answers the person who wrote in, and Resent-To: / Resent-Date: record
the hop per RFC 5322. The body is untouched.
That combination makes forwarding useful for more than escalation: point a
mailbox at a human while an agent is being tuned, fan one address out to a
team, or run a catch-all that lands in an inbox somebody already reads. Pair it
with auto_reply to acknowledge the sender immediately while the mail is on its
way to a human.
Give the forwarding domain its own DKIM key. Re-signing is what makes the
rewritten From: authenticate; without an active key the forward still goes
out, just unsigned.
Create a tenant per customer, then give each its own domains, mailboxes, and API key:
curl -X POST "$API/v1/tenants" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"name":"Acme Corp","tier":"shared_premium"}'
curl -X POST "$API/v1/tenants/$NEW_TENANT_ID/api-keys" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"name":"Acme production","scopes":["*"]}'Tiers (dedicated, shared_premium, shared_standard) select the isolation
level, including whether the tenant sends from a dedicated IP pool. Rate limits,
suppression lists, and Bayesian spam profiles are all per-tenant, so one noisy
customer cannot spend another's reputation.
Outbound requires a verified sending domain, which proves you control it.
KEY="sentio_bootstrap_admin_CHANGE_ME"
API="http://localhost:8080"
auth=(-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json')
# 1. Register the domain. Keep the id and the verification token it returns.
read -r DOMAIN_ID TOKEN < <(curl -s -X POST "$API/v1/domains" "${auth[@]}" \
-d '{"domain_name":"example.com","use_for_sending":true}' \
| python3 -c 'import json,sys; d=json.load(sys.stdin)["data"]; print(d["id"], d["verification_token"])')
# 2. Create a DKIM key. Do this before step 3: the DNS records only include a
# DKIM entry once a key exists.
curl -X POST "$API/v1/domains/$DOMAIN_ID/dkim-keys" "${auth[@]}" \
-d '{"selector":"s1"}'
# 3. Get the records to publish, then publish them at your DNS host
curl "${auth[@]}" "$API/v1/domains/$DOMAIN_ID/dns-records"
# 4. Claim the domain. This checks the token, not DNS.
curl -X POST "$API/v1/domains/$DOMAIN_ID/verify" "${auth[@]}" \
-d "{\"token\":\"$TOKEN\"}"
# 5. Check what has actually propagated
curl -X POST "$API/v1/domains/$DOMAIN_ID/dns-check" "${auth[@]}"
# 6. Send
curl -X POST "$API/v1/messages/send" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"from": "hello@example.com",
"to": ["someone@elsewhere.com"],
"subject": "Sent with Sentio",
"text": "Plain text body",
"html": "<p>HTML body</p>"
}'POST /v1/domains/{id}/verify marks the domain verified by matching the token
returned at registration. It performs no DNS lookup, and it is what gates
sending - status moves to verified and /v1/messages/send starts accepting
the domain.
POST /v1/domains/{id}/dns-check is the one that resolves your published
records and fills in spf_status, dkim_status, dmarc_status and
mx_status, each with an error string when it fails:
{"spf_status": "failed", "spf_error": "no TXT records on example.com",
"dkim_status": "failed", "dkim_error": "s1._domainkey.example.com not found"}A domain can be verified while every DNS check is still failing. That
combination sends, but the mail will not authenticate - so run the DNS check
after publishing and read the per-record errors. Sentio also re-runs it
periodically in the background.
Also available: /v1/messages/send-batch (up to 500), /v1/messages/send-raw
(pre-built EML), and /v1/messages/send-multipart (file upload). Delivery
status and per-message events come from /v1/messages/{id} and
/v1/messages/{id}/events.
The full API - 116 operations across 85 paths - is described by the OpenAPI
document the server serves at /openapi.json.
Register a receiving domain, then attach a route that POSTs to your application when mail arrives:
curl -X POST "$API/v1/domains" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"domain_name":"example.com","use_for_receiving":true}'
curl -X POST "$API/v1/tenants/$TENANT_ID/inbound-routes" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{
"match_type": "domain",
"pattern": "example.com",
"webhook_url": "https://your-app.example.com/hooks/inbound",
"priority": 100
}'match_type is one of exact, domain, regex, or catch_all; lower
priority wins, and the first match stops the search. A regex pattern that
fails to compile is logged and skipped rather than failing the delivery.
The payload is metadata, not content. It identifies the message and carries the verdicts, then points at the stored original:
{
"message_id": "01a0…", "tenant_id": "…", "domain_id": "…",
"envelope_from": "sender@example.net",
"envelope_to": ["support@example.com"],
"raw_eml_key": "…",
"spam_score": -0.1, "spam_action": "accept",
"llm_category": null, "llm_summary": null,
"in_reply_to": null, "references": [],
"auto_submitted": null, "list_id": null, "precedence": null,
"dsn_ret": null, "dsn_envid": null, "dsn_notify": null, "dsn_orcpt": null,
"queued_at": "2026-01-01T00:00:00Z"
}There are no bodies and no attachments in the payload. Fetch the content when you want it:
| You want | Call |
|---|---|
| The original message | GET /v1/messages/{message_id}/raw |
| Attachment list | GET /v1/messages/{message_id}/attachments |
| One attachment | GET /v1/messages/{message_id}/attachments/{id} |
| Stored metadata | GET /v1/messages/{message_id} |
That split is deliberate: a webhook stays small and fast regardless of a 25 MB attachment, and a handler that only needs the spam verdict never moves the body at all.
Every message is SPF/DKIM/DMARC/ARC verified, virus-scanned and spam-scored
before the webhook fires, so spam_score / spam_action are usable as an
intake gate. auto_submitted, list_id and precedence are the loop guards -
check them before auto-replying, or two mail systems will talk to each other
forever.
Your endpoint's response decides what happens next:
| Response | Treated as |
|---|---|
| 2xx | Delivered |
| 5xx, 408, 429 | Transient - retried |
| Any other 4xx | Permanent - not retried |
| Connection error or timeout | Transient - retried |
Retries are scheduled through the queue rather than held in memory, so they survive a restart of the server. Return 2xx once you have durably accepted the message and do the work asynchronously; a slow handler earns duplicate deliveries.
Features that matter once mail is actually flowing, all driven from the same API.
Engagement tracking. Open-pixel injection and click-through URL rewriting,
with bot detection so mail-scanner opens are not counted as human ones, plus
device/client parsing. Serve it from your own branded CNAME via
/v1/tracking-domains rather than a shared host.
Suppression management. Hard bounces and ISP complaint (FBL/ARF) reports
suppress addresses automatically, RFC 8058 one-click unsubscribe is honoured,
and /v1/suppressions lets you check an address before you spend a send on it.
Event webhooks. Distinct from inbound routing: subscribe to lifecycle events
(delivered, bounced, deferred, dropped, opened, clicked,
unsubscribed, and more), signed with HMAC-SHA256 in an X-Sentio-Signature
header over {timestamp}.{nonce}. plus the raw body, with retries, per-endpoint
concurrency caps, delivery logs, and a test-dispatch endpoint. See
building agents on Sentio.
Mailbox forwarding. Any mailbox can forward to external addresses, with
From: rewritten and re-signed so the message keeps authenticating after the
hop, and the original sender kept in Reply-To:. See
above.
IP pools and warmup. Assign tenants to dedicated or shared pools and ramp new addresses on a schedule with per-ISP overrides, instead of sending an untrusted IP straight to full volume.
Reports. DMARC aggregate, FBL/ARF, and TLS-RPT reports are ingested and readable over the API, so authentication failures and TLS problems surface as data rather than as unexplained delivery loss.
Queue control. Inspect queue depth, list deferred mail, and pause or resume delivery without stopping the server.
Observability. Prometheus metrics, OpenTelemetry traces spanning submission
through delivery, structured JSON logs with per-component levels, a per-message
trace ID, and captured error events with a summary endpoint. /health/live and
/health/ready are the probes; the metrics endpoint is unauthenticated and is
deliberately not published by the compose stack.
Software is the easy half. Mail deliverability depends on DNS and IP reputation, and skipping this is the usual reason self-hosted mail lands in spam folders.
DNS records you need, for mail.example.com serving example.com:
| Record | Example | Why |
|---|---|---|
A/AAAA |
mail.example.com → 203.0.113.10 |
Reachability |
MX |
example.com → 10 mail.example.com |
Where inbound mail goes |
PTR |
203.0.113.10 → mail.example.com |
Reverse DNS; set by your hosting provider, not your DNS host |
SPF |
v=spf1 mx -all |
Which hosts may send for the domain |
DKIM |
from /v1/domains/{id}/dns-records |
Signature verification |
DMARC |
v=DMARC1; p=quarantine; rua=mailto:… |
Alignment policy and reports |
Sentio generates the SPF, DKIM, and DMARC records for you -
GET /v1/domains/{id}/dns-records, which includes the DKIM entry once the
domain has a key - and POST /v1/domains/{id}/dns-check resolves them and
reports which ones are actually visible.
Two things that catch people out:
- Port 25 outbound is blocked by most residential ISPs and by several cloud
providers by default (AWS, GCP, Azure, Oracle, and Hetzner all restrict it).
You may need to request a limit lift, or relay through a smart host -
see
[delivery.relay]. - Forward and reverse DNS must agree. Many receivers reject mail from a host
whose
PTRdoes not resolve back to its address. Only your hosting provider can setPTR.
Unit tests need no infrastructure:
cargo test --workspaceAn end-to-end harness exercises both directions against a running stack:
docker compose -f docker-compose.yml -f docker-compose.test.yml up -d
scripts/e2e/run-e2e.shIt sends real mail into the SMTP listener from the host and asserts that it is stored and routed, then submits via the API and asserts the delivered message reaches the sink carrying a DKIM signature. Captured outbound mail is browsable at http://localhost:8025.
Nothing escapes to the internet: the overlay enables [delivery.relay], which
bypasses MX resolution entirely, and all fixtures use the reserved .test TLD.
scripts/e2e/smtp-send.py also works standalone for poking at a running
server. See docs/testing.md.
A Rust workspace of 13 crates:
| Crate | Responsibility |
|---|---|
sentio-core |
Shared types, error model, configuration, repository traits |
sentio-store |
PostgreSQL repositories and the Redis KV pool |
sentio-smtp-server |
Inbound SMTP state machine, TLS, SASL AUTH |
sentio-smtp-client |
Outbound delivery, MX resolution, connection pooling |
sentio-auth |
DKIM, SPF, DMARC, ARC, MTA-STS, DANE, BIMI |
sentio-queue |
NATS/JetStream producers and consumers |
sentio-storage |
S3-compatible blob storage, ClamAV scanning |
sentio-spam |
rspamd integration and the built-in scoring engine |
sentio-abuse |
Rate limiting, IP bans, greylisting, reputation |
sentio-llm |
LLM classification (Anthropic, OpenAI, Ollama) |
sentio-webhooks |
HMAC-signed event dispatch with retries |
sentio-observe |
Structured logging, Prometheus metrics, OpenTelemetry |
sentio-api |
Axum REST API with generated OpenAPI |
Anti-spam runs in three tiers, so expensive checks only see traffic that cheap ones could not decide:
- Connection level, sub-millisecond - IP bans, connection and AUTH rate limits, DNSBL lookups, greylisting, reputation scoring, reverse DNS.
- Content scoring, tens of milliseconds - rspamd or the built-in engine: Bayesian classification, fuzzy hashes, URL reputation, header heuristics.
- LLM tiebreak, borderline scores only -
classifier.rsskips any message scoring outside the configurable review band (score_llm_review_min..score_llm_review_max, 4.0-6.0 by default), so clear ham and clear spam never reach a model.
Standards. Core SMTP (RFC 5321/5322 and the ESMTP extensions), transport
security (STARTTLS, MTA-STS, DANE, TLS-RPT), authentication (SASL, DKIM, SPF,
DMARC, ARC, BIMI), and deliverability (one-click unsubscribe, FBL/ARF, BATV,
DNSBL/URIBL). Per-RFC notes live in docs/.
| Document | Contents |
|---|---|
| Building agents | Addressing, receiving and replying, untrusted input, event verification |
| Capabilities | Full feature catalogue |
| Testing | Unit tests and the end-to-end harness |
| OpenAPI spec | Generated API description |
| RFC compliance | Line-by-line audits of 5321, 3207, 4954 |
| Changelog | Notable changes |
Issues and pull requests are welcome - see CONTRIBUTING.md for the development workflow, build commands, and code conventions. By participating you agree to the Code of Conduct.
Please do not report security vulnerabilities through public issues. See SECURITY.md.
Dual-licensed under either MIT or Apache-2.0, at your option. MIT is shorter and is compatible with GPLv2; Apache-2.0 additionally grants an explicit patent licence. Take whichever suits you - you do not have to satisfy both.
Contributions are accepted under the same dual licence unless you say otherwise.