The project's README from github.com/truespar/sentio. As one markdown file: README.md.
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.
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.
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 -d
That 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 --build
Expect 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 server
Confirm 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/tenants
Rotate 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.pem
docker compose down # stop, keep data
docker compose down -v # stop and delete all volumes
Build 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 minio
Optional 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 llvm
git clone https://github.com/truespar/sentio.git
cd sentio
cargo build --release
The 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 --workspace
sudo -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 sentio
The binary embeds its migrations and applies them in order:
./target/release/sentio-smtp --config config/default.toml migrate
This 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.toml
At minimum set server.hostname, database.url, and the [storage]
credentials. See Configuration.
./target/release/sentio-smtp --config /etc/sentio/sentio.toml serve
This 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-smtp
Under 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 -f
The 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:[email protected]:5432/sentio"
SENTIO__SERVER__HOSTNAME="mail.example.com"
SENTIO__STORAGE__SECRET_KEY="…"
SENTIO__SPAM__RSPAMD__URL="http://127.0.0.1:11333" # [spam.rspamd] url
Environment 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 container
Because 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.gz
Or build it from a checkout:
cargo build --release -p sentio-mcp
Either 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-mcp
Wire 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
}'
[email protected] 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": "[email protected]",
"to": ["[email protected]"],
"subject": "Re: Order #1234",
"text": "Refund processed - you should see it in 3-5 days.",
"in_reply_to": "<[email protected]>",
"references": ["<[email protected]>"]
}'
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 ev