Stalwart Mail Server Setup
Configure Stalwart Mail Server for NorthRelay's split-role relay and mailbox architecture
Stalwart Mail Server Setup
Configure Stalwart's split-role architecture for outbound relay and inbound mailbox.
Current hosted mailbox deployment
The hosted mailbox service runs Stalwart Community 0.16.23 on the former shared
relay host, with native JMAP administration. Client access is documented in
Hosted email access. The deployment source is the
platform repository's infra/mailbox-next directory.
The generic split-role examples below also describe the separately versioned
application sending instance. Do not apply an old SQL-directory configuration
or /api/principal management example to the 0.16 production mailbox. Mailbox
administration uses native x:Account/*, x:Domain/* and x:AppPassword/* methods.
What is Stalwart
Stalwart Mail Server is a modern, Rust-based mail server distributed as a single binary. It supports SMTP, IMAP, JMAP, and includes built-in spam filtering, DKIM, and a management API.
NorthRelay uses Stalwart in a split-role architecture with two separate instances:
| Instance | Role | Location | Purpose |
|---|---|---|---|
| Outbound Relay | Queue + routing | Sanctuary VPS | Accepts SMTP submissions, routes to the sending relay, manages queue |
| Inbound Mailbox | IMAP + JMAP | Mailbox VPS | Receives inbound mail, user mailboxes, dashboard inbox |
These are independent Stalwart instances with separate configurations, separate admin tokens, and separate Management APIs.
Split-Role Architecture
Outbound Relay (Sanctuary)
The relay instance handles all outbound email:
User App → SMTP submission → Stalwart Relay → [queue.route.relay] → sovereign.north-pipe.ca → Internet
(WireGuard, all tiers)Key characteristics:
- Runs on the Sanctuary VPS alongside the dashboard
- Uses host networking in Docker for SMTP port binding
- Routes every email, on every tier, to the single sending relay (
sovereign.north-pipe.ca) - DKIM signing is disabled (handled by OpenDKIM on relay VPSes)
- Management API on port 8080 (internal only)
Inbound Mailbox
The mailbox instance handles all inbound email and user inbox access:
Internet → MX record → Stalwart Mailbox → User Mailbox
↓
Dashboard ← JMAP API → User reads emailKey characteristics:
- Runs on a dedicated Mailbox VPS
- MX records point here (
mx.northrelay.ca) - Provides IMAP and JMAP access for dashboard inbox
- Uses SQL directory queries against PostgreSQL for authentication
- Each user's inbox email = their subdomain slot email
TOML Configuration Basics
Stalwart's static configuration uses TOML format. Key sections:
Server Configuration
[server]
hostname = "relay.northrelay.ca"
max-connections = 1024
[server.listener.smtp]
bind = ["0.0.0.0:25"]
protocol = "smtp"
[server.listener.submission]
bind = ["0.0.0.0:587"]
protocol = "smtp"
tls.implicit = true
[server.listener.management]
bind = ["127.0.0.1:8080"]
protocol = "http"Authentication
[authentication]
fallback-admin.user = "admin"
fallback-admin.secret = "YOUR_HEX_TOKEN"The fallback-admin.secret is the token used for Management API and CLI authentication.
Queue and Routing
[queue.outbound]
# DKIM signing disabled - handled by OpenDKIM on relay VPSes
sign.enable = false
[queue.route.relay]
type = "relay"
address = "sovereign.north-pipe.ca" # DNS hostname — never a raw IP (Stalwart v0.15 bug)
port = 25
[queue.strategy]
route = "'relay'"See Outbound routing below for the details.
Management API
The Management API runs on port 8080 and provides HTTP endpoints for administration.
Authentication
All API requests require authentication:
# Using basic auth
curl -u admin:TOKEN http://localhost:8080/api/...
# Using bearer token
curl -H "Authorization: Bearer TOKEN" http://localhost:8080/api/...Common API Operations
# List all principals (user accounts)
curl -u admin:TOKEN http://localhost:8080/api/principal
# Get a specific principal
curl -u admin:TOKEN http://localhost:8080/api/principal/user@domain.com
# Create a principal
curl -u admin:TOKEN -X POST http://localhost:8080/api/principal \
-H "Content-Type: application/json" \
-d '{
"type": "individual",
"name": "user@domain.com",
"emails": ["user@domain.com"],
"secrets": ["$2b$10$..."]
}'
# View mail queue
curl -u admin:TOKEN http://localhost:8080/api/queue/messages
# Delete a queued message
curl -u admin:TOKEN -X DELETE http://localhost:8080/api/queue/messages/MESSAGE_IDCLI Tool
The Stalwart CLI (stalwart-cli) manages runtime configuration stored in RocksDB.
Basic Usage
# General syntax (inside Docker)
docker exec CONTAINER /usr/local/bin/stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
COMMANDConfiguration Commands
# List all config keys matching a pattern
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server list-config 'server.blocked-ip'
# Add a configuration key
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server add-config 'key.name' "value"
# Delete a configuration key
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server delete-config 'key.name'
# Reload TOML configuration
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server reload-configIP Blocking Prevention
This is the most common operational issue with Stalwart.
Stalwart has an auto-ban feature that blocks IP addresses after failed authentication attempts. Blocked IPs are stored in RocksDB (not the TOML config), so they persist across restarts.
Symptoms
- SMTP connection closes immediately without a banner
- Telnet to port 25 connects then disconnects instantly
- No error in application logs - the connection is dropped at the Stalwart level
Diagnosis
# Check if your IP is blocked
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server list-config 'server.blocked-ip' | grep YOUR_IPFix: Unblock an IP
# Remove the blocked IP
docker exec stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server delete-config 'server.blocked-ip.YOUR_IP'Prevention
Add trusted IPs to the allowlist in your TOML config:
[server.security]
# IPs that should never be auto-banned
allowed-ips = ["10.0.0.0/8", "172.16.0.0/12", "YOUR_OFFICE_IP"]Or disable auto-ban entirely for development:
[server.security.auto-ban]
enable = falseSQL Directory Queries
The mailbox Stalwart instance uses SQL queries against PostgreSQL for user authentication and mail routing. These queries are stored in RocksDB and queried at runtime.
Query Types
| Query | Purpose | Supports Catch-All |
|---|---|---|
recipients | Determine if an address should be accepted | Yes |
name | Resolve a user account from an address | Yes |
emails | Get all allowed sender addresses for a user | Yes |
secrets | Get password hash for authentication | No |
members | Group membership lookup | No |
Managing SQL Queries
# List all SQL queries
docker exec mailbox_stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server list-config 'store.postgresql.query'
# Update a query (delete then add)
docker exec mailbox_stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server delete-config 'store.postgresql.query.recipients'
docker exec mailbox_stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server add-config 'store.postgresql.query.recipients' \
"SELECT email FROM users WHERE email = \$1 UNION SELECT alias FROM mailbox_aliases WHERE alias = \$1 AND active = true"
# Always reload after query changes
docker exec mailbox_stalwart stalwart-cli \
-u http://localhost:8080 -c admin:TOKEN \
server reload-configCatch-All Support
The recipients and name queries include catch-all logic: if no exact match is found, they check whether the domain has is_inbox_hosted = true in the domains table. If so, all mail to that domain routes to the domain owner.
Principal Management
Stalwart principals represent user accounts. For the mailbox instance, principals are provisioned automatically during user registration.
Creating a Principal
curl -u admin:TOKEN -X POST http://localhost:8080/api/principal \
-H "Content-Type: application/json" \
-d '{
"type": "individual",
"name": "user@domain.com",
"emails": ["user@domain.com", "alias@domain.com"],
"secrets": ["$2b$10$BCRYPT_HASH"],
"quota": 0
}'Principal Fields
| Field | Description |
|---|---|
type | individual for user accounts |
name | Primary identifier (usually email) |
emails | Array of all email addresses this principal can send/receive as |
secrets | Array containing bcrypt password hash |
quota | Mailbox size limit in bytes (0 = unlimited) |
Outbound routing
The outbound relay sends every message, on every tier, through one route to the sending relay:
| Route | Address | Relay VPS |
|---|---|---|
relay | sovereign.north-pipe.ca:25 (over WireGuard, 10.0.2.2) | 76.13.27.101 |
[queue.route.relay]
type = "relay"
address = "sovereign.north-pipe.ca"
port = 25
[queue.strategy]
route = "'relay'"Rules that matter in Stalwart v0.15:
- The route address must be a DNS hostname, never a raw IP — a raw IP triggers a "record not found for MX" error. On Sanctuary,
/etc/hostsmapssovereign.north-pipe.cato the WireGuard address10.0.2.2so the route stays inside the tunnel. queue.outbound.next-hopis ignored; onlyqueue.strategy.routeselects the route.- RocksDB keys override the TOML file. After changing the route, delete any stale
queue.strategy.route/queue.route.*keys and runserver reload-config. - Never set
queue.outbound.is_localon the relay instance — it has no mailboxes.
Tier does not affect routing. See Sending Infrastructure for what protects the relay's reputation.
Next Steps
- Configuration Guide - Environment variables and service configuration
- Monitoring - Health checks for Stalwart and related services
- Runbooks - Troubleshooting Stalwart issues