Install and operate
Configuration
Everything is configured through environment variables, read once at startup from the process environment or .env. There is no configuration file and no settings table — a mail server's behaviour should be reproducible from its deployment, not from a row somebody edited.
Defaults below are what the shipped code uses, not suggestions.
Core#
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL |
postgres://corsair:corsair@localhost:55433/corsair |
PostgreSQL connection string |
DB_POOL_SIZE |
10 |
Connections in the pool |
JWT_SECRET |
corsair-dev-secret-change-me |
Signs session tokens. Change this |
PORT |
3000 |
HTTP listener |
PUBLIC_URL |
http://localhost:3000 |
Base URL in emails and redirects |
SIGNUPS |
open |
open lets anyone sign up; closed allows only the first account |
TRUSTED_PROXIES |
(empty) | Proxies whose X-Forwarded-For is believed |
TRUSTED_PROXIES matters more than it looks. Behind a reverse proxy with it unset, every request appears to come from 127.0.0.1: the rate limiter sees one client, and the ban list bans your own proxy.
Mail identity#
| Variable | Default | What it does |
|---|---|---|
CORSAIR_HOSTNAME |
mail.corsair.local |
The name this server gives in EHLO and stamps into Received headers |
This must resolve to the IP you send from, and that IP must reverse-resolve back to it. Without that, the large providers reject on connect.
The next group is what the DNS Setup screen tells customers to point records at — the public names of this installation, not of the customer's domain. On a single-host install, point them all at the same name.
| Variable | Default |
|---|---|
MAIL_MX_HOST |
mx1.corsair.local |
MAIL_SMTP_HOST |
smtp.corsair.local |
MAIL_IMAP_HOST |
imap.corsair.local |
MAIL_POP_HOST |
pop.corsair.local |
MAIL_SPF_HOST |
spf.corsair.local |
MAIL_AUTOCONFIG_HOST |
autoconfig.corsair.local |
MAIL_AUTODISCOVER_HOST |
autodiscover.corsair.local |
MAIL_DKIM_HOSTS |
dkim-1.corsair.local,dkim-2.corsair.local,dkim-3.corsair.local |
MAIL_DKIM_HOSTS is a comma-separated list of three. Customers publish a CNAME per selector pointing at these names and this server answers the lookup — three selectors let a key be rotated without a gap in signing.
Listeners#
| Variable | Default (dev) | Production |
|---|---|---|
SMTP_MX_PORT |
2525 |
25 |
SMTP_SUBMISSION_PORT |
2587 |
587 |
SMTP_SUBMISSION_TLS_PORT |
2465 |
465 |
IMAP_PORT |
2143 |
143 |
IMAP_TLS_PORT |
2993 |
993 |
POP3_PORT |
2110 |
110 |
POP3_TLS_PORT |
2995 |
995 |
SMTP_ENABLED |
true |
|
IMAP_ENABLED |
true |
|
POP3_ENABLED |
true |
The defaults are unprivileged so development needs no root. Ports below 1024 need CAP_NET_BIND_SERVICE — granted in the systemd unit with AmbientCapabilities, or with setcap on the binary when running outside a service manager. See Installation; the two are not interchangeable.
The *_ENABLED flags accept true, 1, or yes. Turning listeners off is how you split the deployment.
TLS#
| Variable | Default | What it does |
|---|---|---|
TLS_CERT_PATH |
(empty) | PEM certificate chain |
TLS_KEY_PATH |
(empty) | PEM private key |
Used by the implicit-TLS listeners and by STARTTLS. Leave them empty to run plaintext-only, which is fine locally and unacceptable anywhere else — Corsair advertises LOGINDISABLED and refuses SMTP AUTH and IMAP LOGIN on an unencrypted connection when a certificate is configured.
See TLS certificates.
Delivery#
| Variable | Default | What it does |
|---|---|---|
DELIVERY_TRANSPORT |
console |
direct, relay, or console |
SMTP_RELAY_HOST |
(empty) | Smarthost, for relay |
SMTP_RELAY_PORT |
587 |
|
SMTP_RELAY_USER |
(empty) | |
SMTP_RELAY_PASS |
(empty) | |
SMTP_RELAY_SECURE |
starttls |
| Transport | Behaviour |
|---|---|
direct |
Look up the recipient's MX and talk to it. Needs port 25 outbound |
relay |
Hand everything to an upstream smarthost |
console |
Print to stdout, deliver nothing. The default, for local development |
Storage#
| Variable | Default | What it does |
|---|---|---|
STORAGE_BUCKET |
(empty) | S3-compatible bucket. Empty keeps bodies in Postgres |
STORAGE_REGION |
nyc3 |
|
STORAGE_ENDPOINT |
(empty) | e.g. https://nyc3.digitaloceanspaces.com |
STORAGE_ACCESS_KEY_ID |
(empty) | |
STORAGE_SECRET_ACCESS_KEY |
(empty) | |
STORAGE_PREFIX |
corsair |
Key prefix inside the bucket |
Objects are written with no ACL and inherit the bucket's default. Check that default is private before pointing Corsair at a bucket you already use.
Workers#
| Variable | Default | What it does |
|---|---|---|
WORKER_CONCURRENCY |
8 |
Jobs processed at once |
WORKER_POLL_MS |
1000 |
How often the queue is polled when idle |
The queue claims work with FOR UPDATE SKIP LOCKED, so any number of worker processes can drain it without coordinating and without delivering anything twice. Raise concurrency before adding processes.
Payments#
| Variable | Default | What it does |
|---|---|---|
STRIPE_SECRET_KEY |
(empty) | Enables hosted checkout |
STRIPE_WEBHOOK_SECRET |
(empty) | Verifies settlement webhooks |
Leave both empty and Corsair runs unmetered: plans still gate features and an operator can record payment methods by hand, but nothing is charged. That is the right default for hosting mail for yourself.
Card details never reach this server. The customer enters them on the provider's hosted page; a brand, four digits, and an opaque reference come back. There is no code path here that could accept a card number.
Webhooks#
| Variable | Default | What it does |
|---|---|---|
WEBHOOK_ALLOW_PRIVATE |
false |
Allow endpoints on private, loopback, and link-local addresses |
The customer supplies the URL and this server fetches it, which is a server-side request forgery primitive. assertDeliverable refuses private ranges by default. Turn it on only when your consumers are genuinely on the same private network.
Limits#
| Variable | Default | What it does |
|---|---|---|
RATE_LIMIT_PER_SECOND |
10 |
API requests per second, per principal |
MAX_MESSAGE_BYTES |
52428800 |
50 MB. Advertised in the SMTP SIZE extension |
Authenticated requests are limited per user; unauthenticated ones per IP. Sign-in and sign-up have their own tighter limit of 5 per second per IP, since there is no principal yet.
A 429 carries retry-after, ratelimit-limit, ratelimit-remaining, and ratelimit-reset.
MAX_MESSAGE_BYTES is the wire size after encoding. Base64 costs about a third, so 50 MB on the wire is roughly a 35 MB attachment.
Seeding#
| Variable | Default | What it does |
|---|---|---|
SEED_PASSWORD |
corsair-dev-password |
Password for the account scripts/seed.ts creates |
Read only by the seed script. Set it on any host that is not your laptop.
A production starting point#
DATABASE_URL=postgres://corsair:LONG_PASSWORD@localhost:5432/corsair
JWT_SECRET=<openssl rand -base64 48>
PUBLIC_URL=https://mail.example.com
SIGNUPS=closed
TRUSTED_PROXIES=127.0.0.1
CORSAIR_HOSTNAME=mail.example.com
MAIL_MX_HOST=mail.example.com
MAIL_SMTP_HOST=mail.example.com
MAIL_IMAP_HOST=mail.example.com
MAIL_POP_HOST=mail.example.com
MAIL_SPF_HOST=mail.example.com
MAIL_AUTOCONFIG_HOST=mail.example.com
MAIL_AUTODISCOVER_HOST=mail.example.com
MAIL_DKIM_HOSTS=dkim-1.mail.example.com,dkim-2.mail.example.com,dkim-3.mail.example.com
SMTP_MX_PORT=25
SMTP_SUBMISSION_PORT=587
SMTP_SUBMISSION_TLS_PORT=465
IMAP_PORT=143
IMAP_TLS_PORT=993
POP3_PORT=110
POP3_TLS_PORT=995
TLS_CERT_PATH=/etc/corsair/certs/fullchain.pem
TLS_KEY_PATH=/etc/corsair/certs/privkey.pem
DELIVERY_TRANSPORT=direct
STORAGE_BUCKET=my-mail-bucket
STORAGE_REGION=nyc3
STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com
STORAGE_ACCESS_KEY_ID=...
STORAGE_SECRET_ACCESS_KEY=...Then walk the production checklist.
Changing configuration#
Every value is read at startup. Restart after any edit:
sudo systemctl restart corsair
# or
docker compose up -dThere is no reload signal. A mail server that reconfigures itself mid-connection is a source of bugs nobody enjoys.