Haitatsu is a simple email server written in Go. It receives and stores mail, exposes IMAP and SMTP submission for clients, and provides a REST API for trusted backend services to manage mailboxes and messages.
- IMAP4rev1 with stable per-folder UIDs, IDLE push, server-side SEARCH, MOVE, UIDPLUS, ESEARCH, SPECIAL-USE, LIST-STATUS, and label folders under
Labels/ - SMTP inbound with SPF, DKIM, DMARC (relaxed and strict alignment), DNSBL, sender allow/block lists, per-IP connection and rate limits
- SMTP submission with PLAIN and LOGIN auth, DKIM signing, Bcc stripping, per-mailbox outbound limits, send-as for routed aliases
- Relay delivery with exponential backoff over roughly two days, permanent failure notifications
- REST API with cursor pagination, constant-time service token auth
- TLS from automatic ACME certificates, or read straight out of a certmagic-layout S3 bucket that something else (such as Caddy) already keeps current, for any number of hostnames
- App passwords for protocol access, with database-backed login throttling shared across nodes
- Quota accounting that tracks deletes and expunges, with a recompute endpoint
- PostgreSQL, SQLite, or remote libSQL for metadata, S3-compatible storage for message blobs, versioned migrations
- Pkl configuration with hot reload of spam, relay, webhook, notification, API token, and limits settings
Mailbox users do not call the REST API directly. Integrate from your own backend using a configured service token. End users authenticate to IMAP and SMTP with app passwords.
- Go 1.26 or later
- Pkl (for local runs outside Docker)
- PostgreSQL, a writable local directory for SQLite, or a libSQL server
- S3-compatible object storage (MinIO works for development)
Copy the example config and edit it for your environment:
cp haitatsu.example.pkl haitatsu.pklThe server reads config from /etc/haitatsu/haitatsu.pkl by default and refuses to start if nothing is there. Override with -config path/to/haitatsu.pkl. The Docker image ships no config, so mount one at that path.
Any field can read from the environment with read("env:NAME"), and read?("env:NAME") ?? "" makes it optional. The example config does this for every secret, including the database DSN and libSQL auth token, and sets instance_name from HOSTNAME. See deploy/README.md for the list.
PostgreSQL remains the default. The old postgres { dsn = "..." } block is still accepted, but new configurations should use database:
database {
driver = "postgres"
dsn = "postgres://haitatsu:password@localhost:5432/haitatsu"
auth_token = ""
namespace = ""
}For a local SQLite file:
database {
driver = "sqlite"
dsn = "file:/var/lib/haitatsu/haitatsu.db"
auth_token = ""
namespace = ""
}Haitatsu enables foreign keys, WAL mode, and a five-second busy timeout for local SQLite connections. Keep the database on a local persistent volume, not a network filesystem.
For Turso Cloud:
database {
driver = "libsql"
dsn = "libsql://database-name-organization.turso.io"
auth_token = read("env:HAITATSU_DATABASE_AUTH_TOKEN")
namespace = ""
}Self-hosted sqld accepts http://, https://, ws://, and wss:// URLs. An unauthenticated local server can use:
database {
driver = "libsql"
dsn = "http://sqld:8080"
auth_token = ""
namespace = "haitatsu"
}namespace sends the x-namespace header expected by a self-hosted sqld
server. Leave it empty for Turso Cloud and single-database servers.
SQLite and libSQL use FTS5 for the REST message search endpoint. PostgreSQL keeps its GIN-backed full-text index.
Start Postgres and MinIO, then build and run Haitatsu:
docker compose up -d postgres minio minio-init
task build
./haitatsu -config haitatsu.pklOr run the full stack (Postgres, MinIO, and Haitatsu in Docker):
task compose:upThe compose stack publishes:
| Service | Port |
|---|---|
| HTTP API | 8080 |
| SMTP | 2525 |
| IMAP | 1143 |
| Submission (STARTTLS) | 1587 |
| Submission (TLS) | 1465 |
Health checks: GET /health, GET /ready. Metrics: GET /metrics.
Listener addresses, database settings, S3, TLS, and worker enablement require a restart. Everything else reloads on SIGHUP or POST /api/v1/admin/reload.
| Block | Keys |
|---|---|
database |
driver, dsn, auth_token, namespace |
limits |
max_message_size_bytes, max_inbound_recipients, max_submission_recipients, max_connections_per_ip, inbound_messages_per_minute_per_ip, default_outbound_per_hour, default_outbound_per_day, default_outbound_recipients_per_message |
relay |
addr, username, password, from_host, max_attempts, max_retry_minutes |
webhooks |
default_timeout_seconds, secret, endpoints, max_attempts |
spam |
junk_threshold, reject_threshold, dnsbl_zones, dnsbl_score, require_helo |
imap |
addr, max_connections_per_ip |
TLS has four modes. manual loads cert_file and key_file. acme obtains a certificate for public_hostname itself using HTTP-01 or TLS-ALPN-01 on the listener host, cached under acme_cache_path (default /var/lib/haitatsu/certmagic). storage issues nothing and instead reads certificates from the S3 bucket in tls.storage, in the layout certmagic writes (<prefix>/certificates/<issuer>/<host>/<host>.crt and .key), for public_hostname plus every name in storage.hostnames, re-reading them every refresh_interval_minutes (and every 30 seconds while any are still missing, so it can start before the issuer has produced them). Use storage when a reverse proxy such as Caddy already owns ports 80 and 443 and keeps a shared certificate store, so replicas serve the same certificate without any of them talking to the CA; the bucket credentials only need read access. off disables TLS and allows plaintext authentication, which is only for local development.
Per-mailbox outbound limits override the defaults through the outbound_limits field on the mailbox API using the keys per_hour, per_day, and recipients_per_message.
Every list endpoint accepts limit (max 100) and cursor. The response includes pagination.next, which is an opaque cursor to pass back for the following page. An empty next means the listing is complete.
IMAP IDLE notifications and login throttling are shared between nodes. PostgreSQL uses LISTEN/NOTIFY; libSQL uses a short-lived database change log that is polled every 250 milliseconds. Local SQLite is intended for one Haitatsu process. PostgreSQL remains the better choice for sustained concurrent writes.
Stop the stack with task compose:down. Reset volumes with task compose:reset.
Cluster deployment lives in the infra repo; the image is built here and shipped to the machines with uc image push through unregistry. See deploy/README.md.
| Task | Command |
|---|---|
| Build binary | task build |
| Build Docker image | task docker:build |
| Print build version | ./haitatsu -version |
| Start compose stack | task compose:up |
| Wipe database schema | task db:wipe |
| Run tests | task test |
| Regenerate ent code | task generate |