Start here
Core concepts
Six ideas. Everything else in the manual is built on them, and the first one is the one people get wrong.
Users and addresses are different things#
There are two identities in Corsair, and conflating what they own is the bug to avoid.
| User | Address | |
|---|---|---|
| What it is | A control-panel login | A mailbox |
| Signs into | The panel at /app |
SMTP, IMAP, POP3, JMAP, webmail |
| Authenticated by | Session cookie (corsair_session) |
Password on the protocol |
| Owns | Domains, plans, webhooks, filters | Messages, folders |
They remain distinct. What they can share is the password.
Your own mailbox uses your account password#
If you sign up as you@example.com and then create the mailbox you@example.com on your own domain, that is one person. Corsair links the two and there is a single password: the one you use for the panel is the one your mail client uses. Change it in Account settings and it changes everywhere, because there is only one of it.
The mailbox is created without asking for a password at all — the panel says so when it recognises your own address.
Everyone else keeps a mailbox-only credential#
A mailbox that is not a control-panel account — the other people on a family or team domain — has its own password and no panel login whatsoever. Merging those into the owner's account would hand every one of them the ability to edit your domains.
The first user created owns the instance (users.is_owner). The claim is made inside the INSERT and guarded by a partial unique index, so two simultaneous signups cannot both win.
A domain is a routing decision plus proof#
Adding a domain does three things: it generates a verification token, it creates three DKIM key pairs, and it produces the record set you have to publish.
A domain has a status. Until it is active, Corsair will accept mail for it but refuses to send from it. Sending from a domain whose SPF and DKIM are not published damages the sending IP's reputation for every other domain on the server, so this is enforced rather than advised.
The worker re-checks pending domains every half hour, because people publish records and never come back to press the button.
Addresses come in four kinds#
| Kind | Password | Mailbox | What it does |
|---|---|---|---|
standard |
Yes | Yes | An ordinary mailbox |
catchall |
Yes | Yes | A mailbox that also receives anything unmatched in the domain |
alias |
No | No | Forwards to exactly one destination |
group |
No | No | Forwards to several destinations at once |
Only standard and catchall carry a password hash. Aliases and groups are routing entries — there is nothing to sign into, because there is no mailbox behind them.
To send as an alias, sign in as a real mailbox on the same account and set the From address in your client.
How a recipient is resolved#
For anything@example.com, in order:
- An exact address match.
- Sub-addressing —
user+tag@routes touser@, with no setup at all. - The domain's catch-all, if one exists.
- The domain's fallback domain, followed exactly once. (Following it twice is how you build a loop.)
postmaster@andabuse@, which forward to the account that owns the domain.
If none match, the message is rejected at SMTP time with a 550. Corsair does not accept-then-bounce: a bounce to a forged sender is backscatter, and refusing during the transaction puts the problem back where it belongs.
Role accounts answer whether or not you create them#
postmaster@ is required by RFC 5321 §4.5.1 and abuse@ by RFC 2142. The second is the one that matters day to day: blocklist operators and ISP abuse desks reach an installation through it, so a domain that 550s abuse@ is unreachable at exactly the moment reachability decides whether the MX keeps delivering anywhere.
Both are resolved as a fallback rather than created as addresses on a new domain. That distinction is worth understanding, because it is what makes the guarantee hold:
- It applies to every domain, including ones added before the rule existed. Nothing needs backfilling.
- There is no row to delete, so a domain cannot drift back out of compliance.
- It runs last, after every route you configured. Creating a real
postmasteraddress, or a catch-all, takes precedence — the fallback can only ever turn a 550 into a delivery, never divert mail away from something you set up on purpose.
The one case it declines is forwarding an address to itself, which is what it would otherwise do when the owning account's own email is postmaster@ on the domain being resolved. That resolves to a 550 rather than a loop.
Because these forward, mail sent to them lands in whatever inbox the owning account uses, and it arrives unfiltered — forwarding relays the message as-is. Both addresses are heavily harvested, so point the owning account somewhere you are willing to have receive spam, or create real postmaster and abuse addresses and let them take precedence.
Folders, UIDs, and why they are fussy#
Every mailbox is provisioned with six folders: INBOX, Drafts, Sent, Junk, Trash, and Archive, each tagged with its IMAP special-use attribute so clients put things in the right place without being told.
Two properties of IMAP shape a lot of the code:
A UID is permanent and must be unique. Corsair allocates one with UPDATE folders SET uid_next = uid_next + 1 … RETURNING, which takes a row lock. Two deliveries arriving at the same instant cannot be handed the same UID, and a duplicate UID is the one thing an IMAP client never recovers from.
Sequence numbers renumber. They index into the folder's live messages in UID order, so deleting message 3 makes the old 4 into the new 3. This is why EXPUNGE is emitted highest-sequence-first — ascending order makes a client delete the wrong messages.
A move keeps the message id. moveTo updates folder_id and allocates a fresh UID in the target, writing a tombstone in the source. It is deliberately not implemented as copy-then-expunge, which would mint a new row id — and JMAP requires an Email's id to survive a change of mailbox.
One store, five ways in#
SMTP, IMAP, JMAP, POP3, and the webmail all read and write the same rows.
That is why a message delivered over SMTP is instantly visible over all of them, and why there is no "sync" anywhere in the product. It is also why a change to the store affects every protocol at once, which is the trade you are making.
| Protocol | Port | Identity | Notes |
|---|---|---|---|
| SMTP (MX) | 25 | None — anyone may deliver | Reference |
| SMTP (submission) | 587, 465 | Address | Requires TLS |
| IMAP | 143, 993 | Address | Reference |
| POP3 | 110, 995 | Address | Reference |
| JMAP | 443 (HTTP) | Address, via Basic or cookie | Reference |
| Webmail | 443 (HTTP) | Address, via cookie | Guide |
| Panel API | 443 (HTTP) | User, via cookie | Reference |
Plans gate features, even when nothing is charged#
An account's entitlement is its plan plus its live subscription. Plans are rows in a table, not constants, so a self-hoster can price, rename, or delete them without a deploy.
An account with no subscription falls back to the trial plan. An instance with no plans at all is unmetered: every feature on, no caps. That is a legitimate way to run a private server, and it is what you get if you never touch billing.
A feature the plan does not include raises a 402, not a 403, so the panel can render an upgrade prompt rather than an error. Validation always runs first — a malformed input is invalid regardless of the plan.
See Plans and billing.
Things that are deliberately never stored#
Three credentials pass through Corsair and are never persisted. Do not "fix" any of them by adding a column:
- DNS API tokens — used for one publish and discarded. One can usually rewrite every record on every domain in the account.
- Card details — never touch the server at all. The customer enters them on the provider's hosted page; a brand, four digits, and an opaque reference come back.
- Transfer source passwords — encrypted at rest and erased the moment the transfer reaches a terminal state. They are someone else's credential.
Reset and recovery tokens are stored only as SHA-256 hashes, and redeemed with a used_at IS NULL predicate inside the UPDATE — checking it in a separate read lets two concurrent requests both redeem the same link.