Corsair

Start here

Quickstart

Ten minutes to a running install on your own machine. Nothing here touches the public internet: mail is printed to the console instead of delivered, the ports are unprivileged, and no DNS is involved.

Do this before you provision a server. It is much cheaper to learn the panel on a laptop than on a host you are also debugging.

What you need#

Requirement Version Notes
Bun 1.3 or newer The runtime. Nothing else is needed
Docker Any recent For PostgreSQL. A local Postgres 17 works too
Disk About 500 MB including the database
sh
bun --version    # 1.3.x or newer
docker --version

Install#

sh
git clone https://github.com/wess/corsair
cd corsair
bun install
cp .env.example .env

.env.example is already set up for local work: DELIVERY_TRANSPORT=console, unprivileged ports, and no TLS. You do not need to edit anything yet.

Start the database#

sh
bun run db:up

That runs PostgreSQL 17 in Docker on port 55433 — deliberately not 5432, to stay clear of any system install you already have.

sh
bun run migrate

Migrations do not run on startup. Two instances coming up at once would race on the migration table, and this is the one step worth being able to run — and fail — by itself.

Seed the first account#

sh
bun run seed

This creates the default plan ladder and one account, then prints its credentials:

plans: trial, startup, small_business, mini_tycoon

  email     [email protected]
  password  corsair-dev-password

Set SEED_PASSWORD before running it if you would rather choose. The first account created owns the instance — that is what users.is_owner records, and there can only ever be one.

Run it#

sh
bun run dev
[corsair] api         http://localhost:3000
[corsair] panel       http://localhost:3000/app
[corsair] webmail     http://localhost:3000/webmail
[corsair] smtp mx     :2525
[corsair] submission  :2587 / :2465
[corsair] imap        :2143 / :2993
[corsair] pop3        :2110 / :2995

Open <http://localhost:3000/app> and sign in with the seeded credentials.

Add a domain#

In the panel: Domains → New domain. Use anything; example.test is fine locally. Corsair generates a verification token, three DKIM key pairs, and the full record set, then shows you the DNS Setup tab.

Locally you cannot publish those records and the domain will stay pending. That is expected. Corsair still accepts mail for a pending domain — it just refuses to send from it, because sending before SPF and DKIM are published damages the IP's reputation for every other domain on the server.

Create a mailbox#

Domains → your domain → New mailbox. Give it a local part.

If the address is your own — the same one you signed up with — Corsair does not ask for a password. It signs in with your account password, the one you just used for the panel, and there is nothing else to remember.

For anyone else's mailbox you set a password here. That is a mailbox credential and nothing more: it opens mail, never the panel.

Deliver a message to it#

Corsair's MX is listening on 2525. Talk to it directly:

sh
printf 'EHLO test\r\nMAIL FROM:<[email protected]>\r\nRCPT TO:<[email protected]>\r\nDATA\r\nFrom: Someone <[email protected]>\r\nTo: [email protected]\r\nSubject: First message\r\n\r\nIt works.\r\n.\r\nQUIT\r\n' | nc localhost 2525

You should see 250 2.0.0 after the dot. Now open <http://localhost:3000/webmail>, sign in with the mailbox address and password, and the message is there.

Read it over IMAP#

The same message, over the protocol a real client uses:

sh
printf 'a LOGIN [email protected] yourpassword\r\nb SELECT INBOX\r\nc FETCH 1 (ENVELOPE)\r\nd LOGOUT\r\n' | nc localhost 2143

What you just proved#

The full inbound path ran: the SMTP state machine accepted the message, SPF and DKIM were evaluated, the spam scorer looked at it, the recipient was resolved, any filter ran, and it was written to a folder — then IMAP and the webmail read the same row.

Where to go next#

Tearing it down#

sh
bun run db:down          # stops Postgres, keeps the volume
docker volume rm corsair-pgdata   # deletes the data