Install and operate
Troubleshooting
Organised by what you are seeing, not by what is broken. Find the symptom, work down the causes in order — they are ordered by how often they turn out to be it.
First, three commands#
systemctl status corsair
journalctl -u corsair --since "30 minutes ago" | grep -i error
psql "$DATABASE_URL" -c "SELECT status, count(*) FROM deliveries GROUP BY status"Running, no errors, queue draining. If all three are fine, the problem is probably DNS or the network, not Corsair.
No mail arrives at all#
1. Is the MX record right?
dig +short MX example.comIt must point at a name that resolves to this server. A missing or stale MX is the cause more often than everything else combined.
2. Is port 25 reachable from outside?
# From another machine
nc -zv mail.example.com 25Test from off the host. A firewall or security group blocking inbound 25 is invisible from inside.
3. Is the listener up?
sudo ss -lntp | grep ':25 'Nothing there means SMTP_ENABLED=false, or the process could not bind the port. Check the log for EACCES:
journalctl -u corsair -n 40 | grep -A3 EACCESerrno: 13 on port 25 while port 3000 came up fine is a capability problem. The unit needs:
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICEA setcap on the Bun binary will not work here. NoNewPrivileges=true blocks file capabilities, so the grant silently has no effect. This is also the failure after a bun upgrade on a setcap-based setup, since the replacement binary carries no capability.
4. Is the domain added? Corsair only accepts mail for domains it hosts. Everything else is refused, correctly.
5. Watch a live delivery.
journalctl -u corsair -fThen send yourself something. If nothing appears in the log, it never arrived — the problem is DNS or the network.
Mail arrives but the recipient does not see it#
Check where it was filed. The spam scorer may have put it in Junk.
SELECT m.subject, m.spam_score, f.name
FROM messages m JOIN folders f ON f.id = m.folder_id
WHERE m.address_id = (SELECT id FROM addresses WHERE address = 'you@example.com')
ORDER BY m.created_at DESC LIMIT 10;Check the filter. A fileinto cancels the implicit keep, so a rule that matched more than intended silently files mail somewhere else. A discard drops it entirely.
Check it is not sub-addressing. you+tag@ lands in you@, which is by design but surprises people looking for a separate mailbox.
Cannot send#
1. Is the domain active? Corsair accepts mail for a pending domain but refuses to send from it. Sending before SPF and DKIM are published damages the IP's reputation for every other domain on the server.
Panel → Domains → the domain must say active. If it does not, press Check DNS and read what it says is missing.
2. Is DELIVERY_TRANSPORT set?
grep DELIVERY_TRANSPORT .envThe default is console — it prints to stdout and delivers nothing. Right for a laptop, wrong for a server.
3. Is port 25 open outbound?
nc -zv gmail-smtp-in.l.google.com 25Hanging means blocked. Set DELIVERY_TRANSPORT=relay and point at a smarthost.
4. Is the daily limit reached? Plans cap daily_out. A quota error in the response is this.
Mail is sent but rejected by the receiver#
Read the reply. It is recorded verbatim:
SELECT rcpt_to, last_code, last_error, attempts
FROM deliveries
WHERE status <> 'sent'
ORDER BY updated_at DESC LIMIT 20;| Reply | Meaning | Fix |
|---|---|---|
550 5.7.26 |
No aligned SPF or DKIM | DNS is wrong or incomplete. Entirely fixable |
550 5.7.1 … PTR |
Reverse DNS mismatch | Set the PTR at your host |
421 4.7.0 |
Rate limited | Back off. Not a configuration error |
450 4.2.0 |
Greylisting | Normal on a first attempt. The retry is accepted |
554 … blocked |
IP reputation or a blocklist | Check listings; request delisting |
SMTP error lookup has the full table.
dkim=fail at the receiver#
dig +short CNAME corsair-1._domainkey.example.comNothing returned — the record is not published. The panel's DNS tab has it.
Returned, but still failing — you may be publishing the CNAME for a selector that is not the active one. Domains → Keys shows which is active.
Was working, now failing — a DNS edit for an unrelated reason removed it, or the provider "helpfully" flattened the CNAME.
Check what the receiver saw:
dkim=pass header.i=@example.com header.s=corsair-1header.s is the selector. That is the CNAME that must exist.
spf=fail#
dig +short TXT example.com | grep spf1Includes your host? It must include whatever MAIL_SPF_HOST is set to.
Sending from the right IP? SPF authorises addresses. If outbound leaves through a different interface or a NAT, that address must be listed.
Two v=spf1 records? That is a permanent error and SPF stops working entirely. There must be exactly one.
Over ten lookups? SPF has a hard limit of ten DNS-querying mechanisms. Every include: counts, including the ones nested inside them.
Clients cannot connect or log in#
"Certificate not trusted" — self-signed, or the chain is incomplete. You need fullchain.pem, not the leaf.
"Server does not support authentication" — you are on 587, 143, or 110. Corsair has no server-side STARTTLS (Bun cannot upgrade an accepted socket), so those ports can never become encrypted, and Corsair will not take a credential in the clear. Use 465, 993, or 995. See TLS.
"Wrong password" — for your own address, use your account password; the panel shows "signs in with your account password" on any mailbox that is linked. For anyone else's mailbox it is that mailbox's own password. Alias and group addresses have no password at all; they cannot be signed into.
Username is always the full email address. A bare local part is ambiguous across the domains on the server.
Check what is actually served:
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>&1 \
| grep -i 'verify return code'It worked, then stopped after about ninety days#
The certificate expired. Renewal ran; the reload did not — Corsair reads the files at startup and holds them.
openssl s_client -connect mail.example.com:993 </dev/null 2>/dev/null \
| openssl x509 -noout -enddateCheck the port, not the file on disk. Add a certbot deploy hook that restarts the service; TLS has one.
The queue is growing#
SELECT status, count(*), min(created_at) FROM deliveries GROUP BY status;One domain dominating the failure list is a reputation problem with that provider.
Every domain failing means outbound is broken — port 25, DNS resolution, or DELIVERY_TRANSPORT.
Nothing being claimed at all means the worker is not running. It is part of src/start.ts; if you run src/server.ts alone, nothing drains the queue.
Webhooks stopped arriving#
SELECT url, status, consecutive_failures, disabled_reason, last_success_at
FROM webhooks WHERE status <> 'active';Twenty consecutive failures disables an endpoint automatically. Fix it, re-enable it in the panel.
If deliveries are arriving but failing verification, you are almost certainly parsing the JSON and re-serialising before checking the signature. Sign over the raw bytes exactly as received.
The panel is slow#
Rate limited? A 429 carries retry-after. The default is 10 requests per second per principal.
Behind a proxy without TRUSTED_PROXIES? Every request looks like 127.0.0.1, so the limiter treats the whole internet as one client and everyone shares one bucket.
Database?
SELECT count(*), state FROM pg_stat_activity GROUP BY state;All connections active means DB_POOL_SIZE is the constraint.
A domain will not verify#
The DNS tab shows what was actually observed, which is usually enough. The recurring causes:
- The provider appended the domain to a host that was already fully qualified —
_dmarc.example.com.example.com - A quoted value stored with the quotes
- Two
v=spf1records - The record is right but has not propagated — a record can take up to the previous record's TTL to become visible
The worker re-checks pending domains every half hour on its own, so a record that appears later is picked up without you doing anything.
Migrations will not run#
bun scripts/migrate.ts status"pending" that never applies — read the error. Hand-written SQL that conflicts with existing data needs fixing in the migration, not retrying.
diff reports drift after a successful up — a table is missing from allSchemas, or a migration did not do what its schema change implies.
Never restart into a state where diff is not clean.
Getting more detail#
NODE_ENV=development bun src/start.tsDevelopment mode relaxes the security-header hardening and logs more. Do not leave a production server in it.
For a single SMTP transaction, talk to the server yourself:
openssl s_client -starttls smtp -connect mail.example.com:587 -crlfEvery reply, in order, with no client in the way.
Still stuck#
Collect these before asking:
bun --version
git rev-parse --short HEAD
bun scripts/migrate.ts status | tail -5
systemctl status corsair --no-pager | head -20
journalctl -u corsair --since "1 hour ago" | grep -i error | tail -20Then open an issue at github.com/wess/corsair/issues. Redact the addresses; keep the reply codes, which are the useful part.