# Corsair > Self-hostable email hosting: SMTP, IMAP, POP3, JMAP, and a webmail on your own domains, with a control panel, an HTTP sending API, and mailboxes for AI agents. # Documentation Source: https://wess.io/corsair/docs/index.html # Documentation Corsair is a mail server and the control panel that manages it, in one process. This manual covers the whole of it: getting a copy running, getting mail to actually flow, keeping it healthy, and every protocol and endpoint it exposes. ## Pick a starting point | If you want to | Read | | --- | --- | | See it working on your laptop in ten minutes | [Quickstart](quickstart.html) | | Understand what it is before installing anything | [Introduction](introduction.html) | | Take a blank VPS to delivered mail | [Your first production server](tutorials/first-server.html) | | Move a domain off an existing provider | [Migrating from Google Workspace](tutorials/migrate-from-google.html) | | Send mail from an application, without a mailbox | [Sending API](sending.html) | | Know what the host needs first | [Prerequisites](prerequisites.html) | | Look up an environment variable | [Configuration](configuration.html) | | Fix something that is broken | [Troubleshooting](troubleshooting.html) | ## How this manual is organised **Start here** is the shortest path from nothing to a working install, plus the vocabulary the rest of the manual assumes. If you read three pages, read [Introduction](introduction.html), [Quickstart](quickstart.html), and [Core concepts](concepts.html). **Tutorials** are start-to-finish builds. They tell you what to type and what you should see, and they end with something that works. Every one of them has been written against a real install, not sketched. **Install and operate** is the reference an operator lives in: installation methods, every setting, TLS, backups, monitoring, upgrades, and what to do at three in the morning. **Using Corsair** covers the product surface — domains, addresses, clients, filters, webmail, migrations, deliverability, the sending API, and event hooks. **Reference** is exhaustive rather than narrative: the architecture, the HTTP API, each mail protocol's supported command set, the database schema, and the SMTP reply-code lookup. ## What Corsair will not do for you Running a mail server is four things that are not software: a static IP with a matching PTR record, port 25 unblocked outbound, a real TLS certificate, and the ability to bind privileged ports. No amount of good code substitutes for any of them, and [Prerequisites](prerequisites.html) says so at length before you spend an afternoon finding out. tip Reading order Every page in this manual has previous and next links at the bottom, following the order in the sidebar. Read straight through and you will have covered the whole system. ## Conventions `example.com` is always *your* domain — the one whose mail you are hosting. `mail.example.com` is the Corsair host itself. Commands prefixed with `$` are run on the server; anything else is a file's contents or a protocol transcript. Where a page states a default, it is the default in the shipped code, not a suggestion. Where a page says something is deliberate, there is a reason given — usually because the obvious alternative breaks something subtle. --- # Introduction Source: https://wess.io/corsair/docs/introduction.html # Introduction Corsair is self-hostable email hosting. It is both halves of the job: the mail server that speaks SMTP, IMAP, POP3, and JMAP, and the control panel where you add domains, create mailboxes, and watch the queue drain. You add a domain, publish the DNS records it generates, create mailboxes, and point any mail client at it. That is the whole product. ## What it is **One process.** `bun src/start.ts` starts every listener — the MX on port 25, submission on 587 and 465, IMAP on 143 and 993, POP3 on 110 and 995, the HTTP API, the panel, the webmail, and the background worker. You can split the HTTP tier out later; you do not have to start there. **Two dependencies.** PostgreSQL holds every row. An S3-compatible bucket holds message bodies. The bucket is optional — without one, bodies stay inline in Postgres, which works and is simpler, but puts mail volume through the WAL. **One copy of the mail.** IMAP, JMAP, POP3, and the webmail all read the same `messages` and `folders` rows. There is no per-protocol copy and no synchronisation step, which is why a message delivered over SMTP is instantly visible everywhere else. **No telemetry.** Nothing phones home. There is no code path that sends your data anywhere except to the SMTP servers you are delivering mail to, and to a webhook endpoint if you configure one. ## What it is not **Not a managed service.** Nobody is watching your queue. If your IP lands on a blocklist, you are the one who files the delisting request. **Not a groupware suite.** There is no calendar, no contacts server, no chat. Corsair does email. **Not a spam-filtering product.** There is a heuristic scorer that files obvious junk into the Junk folder, and it is deliberately conservative — a false positive on real mail is far worse than a false negative. If you need aggressive filtering, put a dedicated filter in front of it. **Not zero-work.** See [Prerequisites](prerequisites.html). ## The shape of it ``` ┌──────────────┐ port 25 ────────►│ SMTP (MX) │──┐ 587 / 465 ──────►│ submission │ │ └──────────────┘ │ ▼ 993 / 143 ──────►┌──────────────┐ ┌──────────┐ ┌────────────┐ 995 / 110 ──────►│ IMAP / POP3 │◄─┤ Postgres │ │ bucket │ └──────────────┘ │ metadata │ │ bodies │ └──────────┘ └────────────┘ 3000 ───────────►┌──────────────┐ ▲ ▲ │ API + panel │────────┤ │ │ webmail │ │ │ │ JMAP │────────┘ │ └──────────────┘ │ ┌──────────────┐ │ │ worker │────────────────────────┘ └──────────────┘ ``` Metadata — folder, UID, flags, envelope, size, a searchable text extract — lives in Postgres. Bodies live in the bucket. That split is what makes IMAP fast: `SELECT`, `FETCH FLAGS`, `SEARCH`, and `SORT` are what a client runs constantly and none of them need a body. Only `FETCH BODY[…]` does, and then exactly one object is read. [The architecture page](architecture.html) goes through this properly. ## Who it is for **Someone hosting their own mail.** A domain, a handful of mailboxes, a small VPS. This is the case Corsair is best at, and the one where the unmetered defaults are correct — leave billing unconfigured and no limits apply. **A team or a household.** Aliases, groups, a catch-all, per-mailbox filters, and optional self-service password recovery so you are not the help desk. **Someone running mail *for* other people.** Plans gate storage, daily message counts, and features. With `STRIPE_SECRET_KEY` set, checkout is hosted by the provider and settled over a signed webhook — card details never reach this server, and there is no code path here that could accept a card number. ## Why self-host mail at all The usual answer is cost, and at a few dozen mailboxes that is a real answer. The better answer is that email is the root of every other account you own. Password resets go there. If your mail is a free tier at a company that can close your account by algorithm, then so is everything else you own. The honest counterweight: deliverability is a reputation system, and a new IP has no reputation. Corsair does everything on its side correctly — SPF, DKIM, DMARC, SRS on forwards, a matching PTR — but you still start from zero and build up. If you send newsletters to strangers, use a relay that already has reputation. If you send mail to people who know you, you will be fine. ## License MIT. Fork it, run it, sell it. There is no open-core split, no feature held back for a paid tier, and no license key. --- # Quickstart Source: https://wess.io/corsair/docs/quickstart.html # 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](https://bun.sh) | 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 admin@corsair.local 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 and sign in with the seeded credentials. note Why the odd ports Ports below 1024 need root or `CAP_NET_BIND_SERVICE`. Development uses 2525 / 2587 / 2465, 2143 / 2993, and 2110 / 2995 so none of this needs privileges. Production uses the real ones — see [Installation](installation.html). ## 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:\r\nRCPT TO:\r\nDATA\r\nFrom: Someone \r\nTo: you@example.test\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 , 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 you@example.test yourpassword\r\nb SELECT INBOX\r\nc FETCH 1 (ENVELOPE)\r\nd LOGOUT\r\n' | nc localhost 2143 ``` warning Plaintext login only works because there is no certificate Corsair refuses `LOGIN` and SMTP `AUTH` over an unencrypted connection *when TLS is configured* — it advertises `LOGINDISABLED` instead. With no certificate at all, as here, there is nothing to upgrade to and plaintext is allowed so local development is possible. Never run a real server without [TLS](tls.html). ## 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 - [Core concepts](concepts.html) — the vocabulary the rest of the manual uses - [Your first production server](tutorials/first-server.html) — the real thing, on a VPS, with DNS and TLS - [Prerequisites](prerequisites.html) — read before you buy a server ## Tearing it down ```sh bun run db:down # stops Postgres, keeps the volume docker volume rm corsair-pgdata # deletes the data ``` --- # Core concepts Source: https://wess.io/corsair/docs/concepts.html # 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. note The rule that makes this safe A mailbox is linked to an account only when that account **already owns the domain**. Without that condition, someone could register a panel account as `ceo@your-company.com` before you added your domain, and the mailbox would authenticate against their password the moment you created it. tip Turn on two-factor authentication A mailbox password is typed into phones, laptops, and printers, and one of those will eventually be lost. Where it is also your account password, the panel is what you do not want it to reach — so put a second factor in front of the panel. Mail protocols cannot present one, which is exactly why it works: a stolen mailbox password alone will not get into the panel. Account → Two-factor authentication. 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. See [Domains](domains.html) and [DNS setup](dns-setup.html). ## 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: 1. An exact address match. 2. **Sub-addressing** — `user+tag@` routes to `user@`, with no setup at all. 3. The domain's **catch-all**, if one exists. 4. The domain's **fallback domain**, followed exactly once. (Following it twice is how you build a loop.) 5. `postmaster@` and `abuse@`, 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 `postmaster` address, 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.html) | | SMTP (submission) | 587, 465 | Address | Requires TLS | | IMAP | 143, 993 | Address | [Reference](imap.html) | | POP3 | 110, 995 | Address | [Reference](pop3.html) | | JMAP | 443 (HTTP) | Address, via Basic or cookie | [Reference](jmap.html) | | Webmail | 443 (HTTP) | Address, via cookie | [Guide](webmail.html) | | Panel API | 443 (HTTP) | User, via cookie | [Reference](api.html) | ## 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](plans-billing.html). ## 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. --- # Prerequisites Source: https://wess.io/corsair/docs/prerequisites.html # Prerequisites Read this before you buy a server. Four things decide whether your mail is delivered or silently binned, and none of them are code. Corsair cannot fix any of them for you and does not pretend it can. ## 1. A static IP with a matching PTR record `CORSAIR_HOSTNAME` must resolve to the IP you send from, and that IP must reverse-resolve back to the same name. Both directions. ```sh $ dig +short mail.example.com 203.0.113.9 $ dig +short -x 203.0.113.9 mail.example.com. ``` The PTR is set through your **hosting provider's** control panel — it lives in their reverse zone, not yours, and you cannot publish it yourself. Every provider worth using offers it: DigitalOcean sets it from the droplet name, Hetzner and Vultr have a field, AWS requires a request form for Elastic IPs. Without a matching PTR the large providers reject on connect, before they have seen a single message. This is the single most common reason a new mail server does not work. danger Dynamic IPs will not work Residential and dynamic addresses are on the Policy Block List by construction. No configuration makes them deliverable. If that is what you have, run Corsair anyway and set `DELIVERY_TRANSPORT=relay` to hand outbound mail to a smarthost. ## 2. Port 25 outbound Most cloud providers block outbound 25 by default to limit the damage from compromised instances. They will usually unblock it on request: | Provider | How | | --- | --- | | DigitalOcean | Support ticket, usually approved for an established account | | Hetzner | Support ticket, generally granted after a few days of account age | | Vultr | Support ticket | | AWS | A request form against the Elastic IP, plus a matching PTR request | | Google Cloud | Not granted. Use a relay | | Azure | Not granted for most subscriptions. Use a relay | | OVH, Scaleway | Usually open by default | Test it from the host before you commit: ```sh $ nc -zv gmail-smtp-in.l.google.com 25 Connection to gmail-smtp-in.l.google.com port 25 [tcp/smtp] succeeded! ``` If it hangs, it is blocked. That is not fatal — set `DELIVERY_TRANSPORT=relay` and point Corsair at a smarthost. Inbound mail on port 25 still works; only outbound delivery changes. ## 3. A real TLS certificate ``` TLS_CERT_PATH=/etc/letsencrypt/live/mail.example.com/fullchain.pem TLS_KEY_PATH=/etc/letsencrypt/live/mail.example.com/privkey.pem ``` Corsair refuses SMTP `AUTH` and IMAP `LOGIN` without one. That is deliberate: a password crossing the network in the clear is worse than no service, and discovering it here is better than discovering it afterwards. The certificate must be for `CORSAIR_HOSTNAME`. If your clients connect to `imap.example.com` and `smtp.example.com`, either include those as SANs or point them at the same name with CNAMEs. Let's Encrypt via DNS-01 is the least painful route because it does not need port 80 open. See [TLS certificates](tls.html). ## 4. Permission to bind privileged ports Ports below 1024 need root or the capability. Granting the capability is the alternative to running a mail server as root, which it should not be: Under **systemd**, grant it in the unit — this is the usual case, and the only one that works alongside `NoNewPrivileges=true`: ```ini AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE ``` Running it **by hand**, outside a service manager, put the capability on the binary instead: ```sh sudo setcap 'cap_net_bind_service=+ep' "$(which bun)" ``` Then set the ports to the real ones: ``` 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 ``` The provided `Dockerfile` already does the `setcap` and then drops to an unprivileged user. ## Sizing the host Corsair is not demanding. The floor is set by PostgreSQL and by how much mail you keep. | Mailboxes | vCPU | RAM | Disk (excluding bodies) | | --- | --- | --- | --- | | 1–10 | 1 | 1 GB | 10 GB | | 10–100 | 2 | 2 GB | 25 GB | | 100–1000 | 4 | 8 GB | 100 GB | With `STORAGE_BUCKET` configured, message bodies live in object storage and the disk figure above is metadata only — a few kilobytes per message. Without a bucket, add the full size of every message you intend to keep. See [Scaling and performance](scaling.html) for the detail. ## A clean IP Check the address against the common blocklists **before** you commit to it. Cheap VPS ranges are frequently listed before you ever boot the machine, and a provider will usually reassign you a different address if you ask early. ```sh # Reverse the octets and query. 203.0.113.9 → 9.113.0.203 dig +short 9.113.0.203.zen.spamhaus.org dig +short 9.113.0.203.bl.spamcop.net ``` An answer means listed. No answer means not listed by that service. ## A domain you control the DNS for You need to publish ten records. If the domain's DNS is at Cloudflare or DigitalOcean, Corsair can write them itself given an API token — used once, never stored. Otherwise you paste them, or import the zone file it exports. ## The checklist Before you install anything: - [ ] Static IP, PTR set, forward and reverse agree - [ ] Outbound port 25 confirmed open, or a relay chosen - [ ] Hostname decided and resolving - [ ] DNS control for the domain you will host - [ ] IP checked against blocklists - [ ] A plan for certificates With those, [Your first production server](tutorials/first-server.html) takes about an hour. --- # Tutorials Source: https://wess.io/corsair/docs/tutorials/index.html # Tutorials Each of these is a complete build. They say what to type, what you should see, and what to do when you do not see it. Work through one and you end with something that runs. The reference pages tell you what every setting does. These tell you what to do. ## The main path **[Your first production server](first-server.html)** — about an hour A blank VPS to delivered, authenticated mail: host setup, DNS, TLS, the first domain, the first mailbox, and a verified round trip with a real provider. Every other tutorial assumes you have done this one, or the [Quickstart](../quickstart.html) at least. ## Moving in **[Migrating from Google Workspace](migrate-from-google.html)** — an evening, plus a wait Copy the mail across with the MX still pointing at Google, verify the counts, cut over, then catch the tail. Written for Google because it is the most common source and the fussiest about app passwords; the shape is the same for anyone. **[Mail for a household or small team](household.html)** — 30 minutes Real mailboxes for the people who need them, aliases for the roles, a group for `family@`, a catch-all for everything else, and self-service recovery so you stop being the help desk. ## Making it do work **[A filter cookbook](filter-cookbook.html)** — reference-style Sieve scripts that solve actual problems: newsletters, sub-address filing, vacation-shaped rules, flagging by sender, and quarantining without losing mail. Copy, paste, adjust. **[Consuming webhooks](webhook-consumer.html)** — 45 minutes Build a small service that receives Corsair's events, verifies the signature properly, and stays idempotent under retries. Includes the failure modes that only appear in production. ## Not losing it **[A backup and restore drill](backup-drill.html)** — 90 minutes Take a backup, destroy the install, and bring it back. A backup you have never restored is not a backup, and the parts people forget — the DKIM keys, the object store, the `.env` — are exactly the parts that make mail unrecoverable. tip Do the drill Of everything on this page, the restore drill is the one people skip and the one that matters most at three in the morning. It takes ninety minutes once. --- # Your first production server Source: https://wess.io/corsair/docs/tutorials/first-server.html # Your first production server A blank VPS to real mail, in about an hour of work plus DNS propagation. At the end you will have a domain whose mail arrives at your own machine, sends with a valid DKIM signature, and passes a receiver's DMARC check. ## Before you start Work through [Prerequisites](../prerequisites.html) first and have these in hand: - A VPS with a **static IP** and **outbound port 25** confirmed open - A **hostname** you control, e.g. `mail.example.com` - **DNS control** for the domain whose mail you are hosting - Root or sudo on the host Throughout: `example.com` is the domain whose mail you are hosting, and `mail.example.com` is the server. ## 1. Point the hostname at the box Publish an A record for the server itself, then set the PTR in your hosting provider's control panel. Both directions must agree. ```sh $ dig +short mail.example.com 203.0.113.9 $ dig +short -x 203.0.113.9 mail.example.com. ``` danger Do not continue until these match A mismatched PTR is rejected on connect by every large provider. Nothing later in this tutorial can compensate for it, and you will spend the afternoon debugging the wrong layer. ## 2. Install the runtime and the database ```sh sudo apt update && sudo apt install -y unzip postgresql-17 git curl -fsSL https://bun.sh/install | bash ``` Create the database and a role for it: ```sh sudo -u postgres psql <<'SQL' CREATE ROLE corsair LOGIN PASSWORD 'pick-something-long'; CREATE DATABASE corsair OWNER corsair; SQL ``` If you would rather run Postgres in a container, the shipped `compose.yaml` does the whole stack — see [Installation](../installation.html). ## 3. Get Corsair ```sh sudo adduser --system --group --home /opt/corsair corsair sudo -u corsair git clone https://github.com/wess/corsair /opt/corsair/app cd /opt/corsair/app sudo -u corsair ~/.bun/bin/bun install ``` ## 4. Get a certificate Corsair refuses SMTP `AUTH` and IMAP `LOGIN` without TLS, so this comes before the first start. DNS-01 avoids needing port 80 open: ```sh sudo apt install -y certbot sudo certbot certonly --standalone -d mail.example.com ``` ```sh sudo mkdir -p /etc/corsair/certs sudo cp /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem /etc/corsair/certs/ sudo chown -R corsair:corsair /etc/corsair/certs ``` [TLS certificates](../tls.html) covers renewal, which you will want automated before the ninety days are up. ## 5. Configure ```sh sudo -u corsair cp .env.example .env sudo -u corsair ${EDITOR:-nano} .env ``` The settings that matter for a first server: ```sh DATABASE_URL=postgres://corsair:pick-something-long@localhost:5432/corsair JWT_SECRET=<64 random characters> PUBLIC_URL=https://mail.example.com CORSAIR_HOSTNAME=mail.example.com # These are the public names of *this* installation, quoted back to you on the # DNS Setup screen. Point them all at this host. 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 SIGNUPS=closed ``` Generate the secret properly — it signs every session: ```sh openssl rand -base64 48 ``` warning SIGNUPS=closed Leave signups open only if you intend to run mail for strangers. On a personal server, `closed` means the first account — yours — is the only one that can be created. Every setting is listed in [Configuration](../configuration.html). ## 6. Decide how it binds the privileged ports Ports below 1024 need a capability. Under systemd — which is what step 8 sets up — that is granted in the unit with `AmbientCapabilities`, so there is nothing to do here. danger Do not use `setcap` under systemd `NoNewPrivileges=true` blocks file capabilities. A `setcap cap_net_bind_service=+ep` on the Bun binary silently does nothing, and the service dies with `EACCES` on port 25 while the HTTP tier on 3000 starts normally — which sends you looking at the mail code instead of the unit file. Use `setcap` only when running Corsair outside systemd. ## 7. Migrate and seed ```sh sudo -u corsair ~/.bun/bin/bun scripts/migrate.ts up sudo -u corsair SEED_PASSWORD='something-long' ~/.bun/bin/bun scripts/seed.ts ``` Migrations are deliberately not run on startup — two instances coming up at once would race, and this is the step worth being able to run and fail on its own. ## 8. Run it as a service ```ini # /etc/systemd/system/corsair.service [Unit] Description=Corsair mail server After=network-online.target postgresql.service Wants=network-online.target [Service] Type=simple User=corsair Group=corsair WorkingDirectory=/opt/corsair/app ExecStart=/opt/corsair/.bun/bin/bun src/start.ts Restart=always RestartSec=5 Environment=NODE_ENV=production # Ports below 1024. This is what makes 25/465/587/143/993/110/995 bindable by # an unprivileged user, and it survives `bun upgrade` replacing the binary. AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/corsair/app [Install] WantedBy=multi-user.target ``` ```sh sudo systemctl daemon-reload sudo systemctl enable --now corsair sudo systemctl status corsair ``` Check every listener came up: ```sh sudo ss -lntp | grep bun ``` You should see 25, 465, 587, 143, 993, 110, 995, and 3000. ## 9. Put the panel behind HTTPS Corsair serves plain HTTP on 3000. Terminate TLS in front of it: ```nginx server { listen 443 ssl http2; server_name mail.example.com; ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem; # JMAP blob upload and large attachments. client_max_body_size 60m; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Then tell Corsair which proxy to believe, or every request will appear to come from `127.0.0.1` and the rate limiter will treat the whole internet as one client: ```sh TRUSTED_PROXIES=127.0.0.1 ``` Restart, and open `https://mail.example.com/app`. ## 10. Add the domain Sign in with the seeded credentials. **Domains → New domain →** `example.com`. Corsair generates a verification token, three DKIM key pairs, and the full record set, then shows you the DNS Setup tab. ## 11. Publish the records If `example.com`'s DNS is at Cloudflare or DigitalOcean, use **Publish automatically**, paste an API token, and Corsair writes all of them. The token is used once and discarded — it is never stored, because a DNS token can usually rewrite every record on every domain in the account. Otherwise, copy them or export the zone file. The set is: | Type | Host | Purpose | | --- | --- | --- | | TXT | `@` | Ownership verification | | TXT | `@` | SPF | | TXT | `_dmarc` | DMARC policy | | CNAME | `corsair-1._domainkey` | DKIM key 1 | | CNAME | `corsair-2._domainkey` | DKIM key 2 (rotation spare) | | CNAME | `corsair-3._domainkey` | DKIM key 3 (rotation spare) | | CNAME | `mta-sts` | MTA-STS policy host | | CNAME | `autoconfig` | Thunderbird setup | | CNAME | `autodiscover` | Outlook setup | | MX | `@` | Where your mail is delivered | [DNS setup](../dns-setup.html) explains each one and what breaks without it. warning The MX record is the cutover Publishing the MX is the moment mail starts arriving here instead of wherever it went before. If you are migrating an existing domain, do the [transfer](migrate-from-google.html) first and leave the MX until last. ## 12. Check DNS Press **Check DNS**. Once the required records resolve, the domain flips to **active** and sending is unlocked. If a record does not match, the panel shows what it actually observed — usually a provider that appended the domain to a host that was already fully qualified, or a value stored with its quotes. Propagation is real: a record can take up to the previous record's TTL to become visible. The worker also re-checks pending domains every half hour on its own. ## 13. Create a mailbox **Domains → example.com → New mailbox.** Local part `you`, and a password. That password is the **mailbox** credential. It is not your control-panel password, and it never will be — see [Core concepts](../concepts.html). ## 14. Prove it works **Receive.** Send a message to `you@example.com` from an outside account. Then open `https://mail.example.com/webmail` and sign in with the mailbox address and password. It should be there. **Send.** Configure a client, or use the webmail, and send to an address you can read at a large provider. Then check the headers on what arrives: ``` Authentication-Results: mx.google.com; spf=pass (google.com: domain of you@example.com designates 203.0.113.9 ...) dkim=pass header.i=@example.com header.s=corsair-1 dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) ``` Three passes. That is the goal, and it is the thing to re-check any time deliverability goes strange. **Connect a client.** Full email address as the username, mailbox password, and: | Protocol | Host | Port | Security | | --- | --- | --- | --- | | IMAP | `mail.example.com` | 993 | SSL/TLS | | SMTP | `mail.example.com` | 465 | SSL/TLS | ## 15. Before you call it done - [ ] **Backups configured** — [Backups and restore](../backups.html), then actually [run the drill](backup-drill.html) - [ ] **Certificate renewal automated** — [TLS](../tls.html) - [ ] **Object storage** if you expect volume — [Configuration](../configuration.html) - [ ] **Monitoring** on the queue and the certificate — [Monitoring](../monitoring.html) - [ ] **The production checklist** — [read it through](../production-checklist.html) ## If mail does not flow | Symptom | Look at | | --- | --- | | Nothing arrives | MX record published? Port 25 open *inbound*? `journalctl -u corsair` | | You cannot send | Is the domain active? Port 25 open *outbound*? | | `dkim=fail` at the receiver | DKIM CNAME published for the **active** selector | | `spf=fail` | SPF record includes `MAIL_SPF_HOST`, and you are sending from the right IP | | Client refuses to log in | Certificate valid for the hostname the client is using | [Troubleshooting](../troubleshooting.html) goes symptom by symptom. --- # Migrating from Google Workspace Source: https://wess.io/corsair/docs/tutorials/migrate-from-google.html # Migrating from Google Workspace Move a domain off Google without losing mail and without a window where messages land nowhere. The shape is the same for Fastmail, Zoho, Microsoft 365, or any other host that speaks IMAP — Google is used here because it is the most common source and the fussiest about credentials. The plan: copy everything while the MX still points at Google, verify, cut over, then run a second pass to catch what arrived during the DNS change. warning Order matters Do not change the MX first. If you do, mail arrives at Corsair while the bulk of the mailbox is still at Google, and you spend the migration reconciling two live mailboxes instead of one live and one frozen. ## Before you start - Corsair running, with the domain **added and active** ([first server](first-server.html)) - The destination mailbox **already created** — a transfer copies *into* an existing address - Admin access to the Google account, or the user's cooperation - A plan that includes transfers, or an unmetered instance ([Plans](../plans-billing.html)) ## 1. Create the destination addresses For every mailbox you are moving, create the matching address in Corsair first. **Domains → example.com → New mailbox.** Match the local parts exactly. `sam@example.com` at Google becomes `sam@example.com` here, or the mail arrives correctly and the person's own references to their address stop working. Aliases and groups do not need transfers — they have no mailbox. Recreate them as [alias or group addresses](../addresses.html) and move on. ## 2. Get an app password from Google Google will not accept an account password over IMAP. For each mailbox: 1. Sign in to the Google account. 2. Turn on 2-Step Verification if it is not already on. App passwords are not offered without it. 3. Go to **App passwords**, generate one, and copy the sixteen characters. 4. Confirm IMAP is enabled in **Gmail → Settings → Forwarding and POP/IMAP**. note Why an app password It is a credential scoped to one client that you can revoke without changing anything else. Corsair encrypts it at rest and erases it the moment the transfer reaches a terminal state — it is someone else's credential and there is no reason to keep it once the copy is done. ## 3. Start the transfer **Transfers → New transfer.** | Field | Value | | --- | --- | | Source host | `imap.gmail.com` | | Source port | `993` | | Security | SSL/TLS | | Username | the full address, `sam@example.com` | | Password | the app password | | Destination | the Corsair address you created | Leave the limits empty for the first pass — you want everything. Press **Start**. The worker picks it up, connects, lists the folders, and begins copying. The panel shows progress per folder. ## 4. What gets copied Every selectable folder. Common names are mapped onto the local special-use folders so you do not end up with two of each: | Google | Corsair | | --- | --- | | `INBOX` | `INBOX` | | `[Gmail]/Sent Mail` | `Sent` | | `[Gmail]/Drafts` | `Drafts` | | `[Gmail]/Trash` | `Trash` | | `[Gmail]/Spam` | `Junk` | | `[Gmail]/All Mail` | skipped — every message is already in its own folder | | anything else | a folder of the same name | Flags are preserved, so read stays read and flagged stays flagged. **Message dates are preserved**, which matters more than it sounds: without it a transferred mailbox sorts as though every message arrived today. Labels are the one thing that does not survive cleanly. Gmail labels are not folders — a message with three labels appears in three IMAP folders — so a multi-labelled message is copied into each. Deduplicate afterwards or accept it. ## 5. Verify before you cut over Compare counts per folder. In the panel, the address's folder list shows message counts; in Gmail, each label shows its own. They will not match exactly, and that is expected: - `[Gmail]/All Mail` is skipped, so its count has no counterpart - Multi-labelled messages appear more than once on the Corsair side - Chat and Google-internal messages are not real mail What you are checking is that INBOX and Sent are close, and that nothing is obviously empty. Sign into the webmail as the mailbox and read a few old messages. Check that attachments open and that dates look right. ## 6. Cut over Now change the MX. In the domain's DNS, replace the Google MX records with the one Corsair gave you. ``` example.com. MX 10 mail.example.com. ``` Delete the five `aspmx.l.google.com` records. Leaving them in place with a worse priority means Google keeps receiving mail whenever your server is briefly unreachable, which is the opposite of a clean cutover. Also update SPF. If your record was: ``` v=spf1 include:_spf.google.com ~all ``` it becomes: ``` v=spf1 include:mail.example.com -all ``` Keep any other legitimate sender you had in there — a marketing platform, a ticketing system — or their mail starts failing SPF the moment you tighten to `-all`. ## 7. Wait out the TTL For as long as the old MX's TTL, some senders will still resolve to Google. During that window mail can arrive at either host. This is unavoidable and it is why there is a step 8. Watch it arrive: ```sh journalctl -u corsair -f | grep -i 'accepted\|rcpt' ``` ## 8. Run a second pass Once the TTL has expired and new mail is clearly arriving at Corsair, run the transfer again with **Newer than** set to the day you changed the MX. The date filter is applied by the *source* server, so old mail is never transferred and then discarded — it is never fetched at all. That makes the second pass fast. Already-copied messages are copied again, so without the date filter you get duplicates for the whole mailbox. Set it. ## 9. Turn Google off Wait a week. Watch for anything still arriving at Google — a forgotten alias, a group, a calendar invite path. Then delete the Workspace account. Do not skip the week. Cancelling immediately is how you discover that `billing@example.com` was a Google group nobody had written down. ## Transfer limits For very large mailboxes, the three limits exist to make a transfer resumable rather than all-or-nothing: | Limit | Effect | | --- | --- | | Message limit | Stop after this many messages | | Size limit | Stop once this many megabytes have been copied | | Newer than | Only messages at or after this date, filtered by the source | Run repeatedly with a moving **Newer than** date to walk backwards through a mailbox that is too big for one sitting. ## When it fails | Message | Cause | | --- | --- | | `AUTHENTICATIONFAILED` | Account password used instead of an app password, or IMAP disabled | | Connection refused | Wrong host or port. It is `imap.gmail.com:993`, not the webmail address | | Stalls partway | Google rate-limits sustained IMAP. It resumes; let it | | `transfer.failed` webhook | The panel shows the error against the transfer | A failed transfer erases the stored source password, same as a successful one. Restarting means entering it again — that is deliberate. ## What you did not migrate Filters, forwarding rules, and vacation responders do not come across. Rebuild them as [Sieve filters](../filters.html); the [filter cookbook](filter-cookbook.html) has the common shapes. Contacts and calendars are not email and Corsair does not host them. Export them from Google and put them somewhere that does. --- # Mail for a household or small team Source: https://wess.io/corsair/docs/tutorials/household.html # Mail for a household or small team Thirty minutes to a domain that serves several people properly: individual mailboxes, role addresses that forward, a group that fans out, a catch-all for everything else, and password recovery that does not route through you. The example is a household on `example.com`. A five-person company is the same shape with different words. ## The plan | Address | Kind | Goes to | | --- | --- | --- | | `sam@` | Mailbox | Sam | | `alex@` | Mailbox | Alex | | `kid@` | Mailbox | The teenager | | `hello@` | Alias | `sam@` | | `bills@` | Alias | `alex@` | | `family@` | Group | `sam@`, `alex@`, `kid@` | | everything else | Catch-all | `sam@` | Three real mailboxes. Everything else is routing, which costs nothing and cannot be signed into. ## 1. Create the mailboxes **Domains → example.com → New mailbox**, once per person. Give each a real password and hand it over out of band. This is the credential that goes into their phone and their laptop — not a control-panel login, which they do not need and should not have. Each mailbox is provisioned with `INBOX`, `Drafts`, `Sent`, `Junk`, `Trash`, and `Archive`, tagged with their IMAP special-use attributes so clients file things correctly without being configured. ## 2. Add the role aliases **New address → Alias.** An alias forwards to exactly one destination and has no password, because there is no mailbox behind it. - `hello@example.com` → `sam@example.com` - `bills@example.com` → `alex@example.com` Why an alias rather than a second mailbox: nobody has to check it, it cannot be compromised, and when Sam stops handling `hello@` you repoint it in one place. note Forwarded mail is SRS-rewritten An alias that forwards to an outside address keeps the original sender, whose SPF does not list your server — so the next hop sees a forgery. Corsair rewrites the envelope sender with SRS so it survives. This is automatic and there is nothing to configure. ## 3. Add the group **New address → Group**, `family@example.com`, with all three mailboxes as destinations. A group fans out: one message in, one copy to each destination. Use it for anything that should reach everyone — the school, the landlord, the vet. Groups have no password either. To *send* as `family@`, sign in as a real mailbox and set the From address in the client. ## 4. Set the catch-all **Domains → example.com → Settings → Catch-all**, pointed at `sam@`. A catch-all is a mailbox that also receives anything in the domain that matched nothing else. It is what makes `plumber@example.com` work when you invented it at the door. warning A catch-all collects spam Once a domain is known to accept everything, dictionary attacks start filing into it. If the volume gets bad, drop the catch-all and use sub-addressing instead — `sam+plumber@example.com` needs no setup at all and routes to `sam@`. ## 5. Understand the resolution order For any address at the domain, Corsair tries, in order: 1. An exact match — `sam@`, `hello@`, `family@` 2. Sub-addressing — `sam+anything@` routes to `sam@` 3. The catch-all 4. The domain's fallback domain, followed exactly once The first match wins, so an exact alias always beats the catch-all. That is why adding `bills@` later changes nothing else. ## 6. Turn on self-service recovery Without this, every forgotten mailbox password is your problem. **Domains → example.com → Settings → Self-service recovery.** Then for each mailbox, set a **recovery address** — a phone number's carrier address, a personal Gmail, another mailbox on the domain. Anywhere the person can actually read that is not the mailbox they are locked out of. They then use `/recover` to reset their own mailbox password. The reset link goes to the recovery address, never to the mailbox itself, which would be useless. The endpoint always answers the same way whether or not the address exists. Making it more helpful would turn it into a way to enumerate your mailboxes. note This is a plan feature `self_service` is gated. On an unmetered instance — no plans configured — every feature is on. On a metered one, check the plan. ## 7. Give each person their client settings Full email address as the username, mailbox password, and: | Protocol | Port | Security | | --- | --- | --- | | IMAP | 993 | SSL/TLS | | SMTP | 587 | STARTTLS | If you published the `autoconfig` and `autodiscover` CNAMEs, Thunderbird and Outlook find all of this from the address alone. [Client settings](../client-setup.html) has the per-client notes, including the two clients that need help. ## 8. Filters worth setting up on day one Give each mailbox a filter that files the noise. **Filters → New filter**, then attach it to the mailboxes that want it — a filter belongs to the account and can be attached to any number of mailboxes. ``` require ["fileinto"]; if anyof (exists "list-id", header :contains "precedence" "bulk") { fileinto :create "Newsletters"; stop; } ``` The [filter cookbook](filter-cookbook.html) has more. ## 9. Sub-addressing, explained once to everyone Tell the household this, because it is the single most useful thing about running your own mail: > Any address works with a `+tag` on the end. `sam+netflix@example.com` arrives in > Sam's inbox. Nothing needs to be set up. If that tag starts getting spam, you > know exactly who sold it, and a filter can bin it. File by tag: ``` require ["fileinto", "envelope"]; if envelope :localpart :matches "to" "*+receipts" { fileinto :create "Receipts"; } ``` ## What you ended up with Three passwords to look after instead of seven addresses. Roles you can repoint without telling anyone. A group that does not need a mailbox. A catch-all for the long tail, and sub-addressing so the catch-all stays optional. And a recovery path that does not run through you at eleven at night. ## Adding someone later New mailbox, add them to `family@`, set a recovery address, hand over the password. Two minutes. ## Removing someone Delete the mailbox — the messages go with it, so [back them up first](backup-drill.html) if that matters. Remove them from `family@`. Repoint any alias that pointed at them. If mail should keep flowing to a successor, turn the old address into an alias rather than deleting it, and their mail forwards on. --- # A filter cookbook Source: https://wess.io/corsair/docs/tutorials/filter-cookbook.html # A filter cookbook Working Sieve scripts for the things people actually want. Copy one, change the strings, save it. [Filters](../filters.html) is the reference for what the language supports; this page is the recipes. Every script here compiles against Corsair's Sieve subset. A script that fails to compile is rejected on save, so you find out immediately rather than at delivery time. ## The rules you must know first **Implicit keep.** If a script takes no filing action, the message is delivered to the inbox anyway. You cannot lose mail by forgetting a `keep`. **`fileinto` cancels the implicit keep.** File somewhere and it goes there, not both places. Write an explicit `keep` alongside if you want both. **`stop` ends the script.** Everything filed so far stands. Without it, later rules keep matching and you get surprising results. **A broken filter never loses mail.** A script that throws at delivery time is treated as absent — the message is delivered to the inbox and the error is shown against the filter. ## Newsletters out of the inbox The single most useful filter. Bulk mail almost always identifies itself. ``` require ["fileinto"]; if anyof ( exists "list-id", exists "list-unsubscribe", header :contains "precedence" ["bulk", "list"] ) { fileinto :create "Newsletters"; stop; } ``` `:create` makes the folder if it does not exist, so this works on a fresh mailbox with no setup. ## File by sub-address `you+receipts@example.com` into a Receipts folder, with no per-sender rules at all. This is the filter that makes sub-addressing worth explaining to people. ``` require ["fileinto", "envelope"]; if envelope :localpart :matches "to" "*+receipts" { fileinto :create "Receipts"; stop; } if envelope :localpart :matches "to" "*+shopping" { fileinto :create "Shopping"; stop; } ``` Match on the **envelope**, not the `To:` header. The header is whatever the sender typed; the envelope is what the server was actually asked to deliver to, and a message can reach you with your address nowhere in the headers at all. ### One rule for every tag Rather than a block per tag, file every tagged message into a folder named after the tag: ``` require ["fileinto", "envelope"]; if envelope :localpart :matches "to" "*+*" { fileinto :create "Tagged"; stop; } ``` Sieve has no variables in this subset, so the folder name cannot be built from the match. Either list the tags you care about, or collect them all in one place. ## Flag mail from people who matter ``` require ["imap4flags"]; if anyof ( address :is "from" "sam@example.com", address :domain :is "from" "clientcompany.com" ) { addflag "\\Flagged"; } ``` No `fileinto` and no `stop`, so the message still lands in the inbox — it just arrives flagged. `addflag` adds to existing flags; `setflag` replaces them all, which is almost never what you want. ## Route by recipient on a shared domain Useful when a group address fans out but you want the copies filed separately. ``` require ["fileinto", "envelope"]; if envelope :is "to" "support@example.com" { fileinto :create "Support"; stop; } if envelope :is "to" "billing@example.com" { fileinto :create "Billing"; stop; } ``` ## Quarantine without losing anything For senders you distrust but will not silently drop: ``` require ["fileinto"]; if anyof ( header :contains "subject" ["urgent action required", "verify your account"], header :matches "from" "*@*.top" ) { fileinto :create "Quarantine"; stop; } ``` A folder, not `discard`. You can review a folder; you cannot review a message that was dropped. ## Actually drop something ``` if header :contains "subject" "unmissable opportunity" { discard; } ``` `discard` accepts the message and throws it away. The sender learns nothing, which is what you want for spam — a bounce confirms the address is live. Use `reject` instead to refuse it with a 550 during the SMTP transaction: ``` if address :is "from" "expartner@example.net" { reject "This address does not accept mail from you."; } ``` `reject` tells the sender they were filtered. `discard` does not. Pick deliberately: telling a spammer is pointless, telling a colleague their mail was binned is polite. ## Big attachments to their own folder ``` require ["fileinto"]; if size :over 10M { fileinto :create "Large"; stop; } ``` `size` is the whole message including encoding overhead, so a 7 MB attachment is about 9.5 MB on the wire. Set the threshold above what you mean. ## Everything from a domain, except one person `allof` with a negated test: ``` require ["fileinto"]; if allof ( address :domain :is "from" "noisy-vendor.com", not address :is "from" "myrep@noisy-vendor.com" ) { fileinto :create "Vendors"; stop; } ``` ## A staged inbox Rules run top to bottom and the first `stop` wins, so order encodes priority. ``` require ["fileinto", "imap4flags"]; # 1. Anything from the team stays in the inbox, flagged. if address :domain :is "from" "example.com" { addflag "\\Flagged"; stop; } # 2. Automated mail goes to Robots. if anyof ( header :contains "auto-submitted" "auto-generated", address :localpart :matches "from" ["noreply*", "no-reply*", "notifications*"] ) { fileinto :create "Robots"; stop; } # 3. Bulk goes to Newsletters. if anyof (exists "list-id", header :contains "precedence" "bulk") { fileinto :create "Newsletters"; stop; } # 4. Everything else falls through to the inbox by implicit keep. ``` ## Regex, when the wildcards are not enough ``` if header :regex "subject" "^\\[(TICKET|INCIDENT)-[0-9]{4,}\\]" { fileinto :create "Tickets"; stop; } ``` Prefer `:matches` with `*` and `?` where it will do. Regexes are harder to read six months later and easier to get subtly wrong. ## Testing before you trust it **Validate.** The panel validates on save, and there is a validate endpoint behind it. A script that does not compile is never stored. **Attach to one mailbox first.** A filter belongs to the account and can be attached to any number of mailboxes. Start with one. **Watch the folder, not the inbox.** The failure mode of a too-broad rule is mail you never see, so check that the target folder is getting what you expect and nothing else. **Remember the action cap.** Scripts stop after 100 actions. No realistic filter reaches it, but a rule that fires in a loop of `fileinto` will. ## What Sieve deliberately cannot do No loops, no recursion, no network calls, no shelling out. That is the whole reason it is safe to run a user's script on the delivery path. If you need something Sieve cannot express, do it downstream — subscribe to a [webhook](webhook-consumer.html) and act on the event outside the mail path. --- # Consuming webhooks Source: https://wess.io/corsair/docs/tutorials/webhook-consumer.html # Consuming webhooks Build a small service that receives Corsair's events and acts on them. About forty-five minutes, and most of that is getting the signature verification right — which is the part that matters, because an unverified endpoint is one anybody who learns the URL can write to. [Event hooks](../webhooks.html) is the reference. This is the build. ## What we are building A service that receives `message.received` and `message.bounced`, verifies the signature, deduplicates retries, and writes a line to a log. Small enough to read; complete enough that the failure modes show up. ## 1. Write the receiver ```ts import { createHmac, timingSafeEqual } from "node:crypto" const SECRET = process.env.CORSAIR_WEBHOOK_SECRET! const TOLERANCE_SECONDS = 300 const verify = (headers: Headers, body: string): boolean => { const id = headers.get("webhook-id") const timestamp = headers.get("webhook-timestamp") const signature = headers.get("webhook-signature") if (!id || !timestamp || !signature) return false // Without the age check, a captured delivery replays forever. const ts = Number(timestamp) if (!Number.isFinite(ts)) return false if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64") const expected = "v1," + createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64") // The header may carry several space-separated signatures during a secret // rotation. Any one matching is a pass. return signature.split(" ").some((candidate) => { const a = Buffer.from(expected) const b = Buffer.from(candidate.trim()) return a.length === b.length && timingSafeEqual(a, b) }) } const seen = new Set() Bun.serve({ port: 8080, async fetch(req) { if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }) // Read the body verbatim. The signature covers the exact bytes. const body = await req.text() if (!verify(req.headers, body)) { return new Response("Bad signature", { status: 401 }) } const id = req.headers.get("webhook-id")! if (seen.has(id)) return new Response("ok") // a retry of something we did seen.add(id) const event = JSON.parse(body) console.log(`[${event.type}]`, JSON.stringify(event.data)) // Answer immediately. Do the slow part elsewhere. queueMicrotask(() => handle(event)) return new Response("ok") }, }) const handle = (event: { type: string; data: Record }) => { switch (event.type) { case "message.received": console.log(` → ${event.data.sender} to ${event.data.recipient}`) break case "message.bounced": console.log(` → bounced: ${event.data.recipient}`) break } } ``` ```sh CORSAIR_WEBHOOK_SECRET=whsec_... bun run receiver.ts ``` danger Sign over the raw body Parse the JSON and re-serialise it and the bytes change — different key order, different whitespace — and the signature will never match. Read the body as text, verify, *then* parse. This is the single most common webhook bug. ## 2. Expose it The endpoint must be reachable from the public internet. Private, loopback, and link-local addresses are refused at creation, because the URL is customer-supplied and Corsair is what fetches it — an open one is a server-side request forgery primitive. For development, tunnel it: ```sh ssh -R 80:localhost:8080 nokey@localhost.run ``` For a self-hosted setup where the consumer really is on the same private network as Corsair, set `WEBHOOK_ALLOW_PRIVATE=true` on the server. It is off by default because the safe case is the rarer one. ## 3. Register the endpoint **Webhooks → New endpoint** in the panel. | Field | Value | | --- | --- | | URL | your public HTTPS URL | | Description | something you will recognise in a list | | Events | `message.received`, `message.bounced` | The signing secret is shown **once**, at creation. It is a credential; an endpoint that has lost it should rotate rather than read it back. Copy it into `CORSAIR_WEBHOOK_SECRET` now. Subscribe to a family with `message.*` or to everything with `*`. A family wildcard is usually right — it picks up new event types in that family without you changing anything. ## 4. Test it Press **Send test**. It delivers a signed sample synchronously and shows you the exact status and body that came back, which is far more useful than watching a queue. If it fails: | What you see | Cause | | --- | --- | | Connection refused | Tunnel down, or the URL is wrong | | 401 from your service | Secret mismatch, or you parsed before verifying | | Timeout | Your handler is doing the work inline. Answer first | ## 5. Make it survive retries Corsair guarantees **at least once**, not exactly once. Your endpoint must be idempotent. Retries reuse the same `webhook-id`, so recording the id is enough to tell a retry from a new event. The `Set` above works for a demo; use a table with a unique index in anything real: ```sql CREATE TABLE webhook_events ( id text PRIMARY KEY, type text NOT NULL, received_at timestamptz NOT NULL DEFAULT now() ); ``` ```ts const fresh = await db.query( "INSERT INTO webhook_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id", [id, event.type], ) if (!fresh.rows.length) return new Response("ok") // already handled ``` The insert is the deduplication. Checking first and inserting after is a race two concurrent retries will win. ## 6. Answer fast Answer with any `2xx`, quickly. The body is ignored. Anything else is retried after **5s, 30s, 5m, 30m, 2h, 5h, 10h, and 24h**, then given up on. An endpoint that fails **twenty times in a row** is disabled automatically, and the panel says so — Corsair will not hammer a dead endpoint forever. So: verify, deduplicate, enqueue, return. Never do the work inside the request. ## 7. Watch it in the panel **Webhooks → your endpoint → Events** lists every delivery with its status, attempt count, and next attempt time. Open one to see the payload that was sent and replay it if you need to. Replay redelivers the same event with the same id — which is exactly the case your deduplication is for. Test it: replay something you have already handled and confirm nothing happens twice. ## The events you get | Event | When | | --- | --- | | `message.received` | Accepted and delivered to a mailbox | | `message.spam` | Accepted, scored as spam, filed in Junk | | `message.rejected` | Refused at SMTP time | | `message.sent` | Accepted from one of your mailboxes for delivery | | `message.delivered` | The receiving server accepted it | | `message.deferred` | Temporarily refused; Corsair will retry | | `message.bounced` | Permanently failed, or retries ran out | | `address.created`, `address.deleted`, `address.password_changed` | Mailbox lifecycle | | `domain.created`, `domain.verified`, `domain.verification_failed`, `domain.deleted` | Domain lifecycle | | `quota.warning`, `quota.exceeded` | Storage | | `transfer.completed`, `transfer.failed` | Mailbox migration | A `message.received` payload: ```json { "type": "message.received", "created_at": "2026-08-10T12:00:00.000Z", "data": { "recipient": "me@example.com", "sender": "someone@elsewhere.com", "subject": "Hello", "message_id": "", "size": 4821, "spam_score": 1.5, "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" }, "remote_ip": "203.0.113.9" } } ``` ## Things worth building on this - **A bounce suppression list.** Record every `message.bounced` recipient and stop sending to it. Repeatedly delivering to an address that hard-bounces is one of the fastest ways to damage a sending reputation. - **A quota alarm.** `quota.warning` into whatever pages you. - **An archive.** `message.received` into cold storage, keyed by `message_id`. - **Deployment gating.** `domain.verification_failed` is usually someone editing DNS. Find out from a webhook rather than from a customer. ## Using an off-the-shelf verifier The scheme is [Standard Webhooks](https://www.standardwebhooks.com), and the `svix-id`, `svix-timestamp`, and `svix-signature` aliases go out alongside the standard headers. A Svix verification library works unchanged: ```ts import { Webhook } from "svix" const wh = new Webhook(process.env.CORSAIR_WEBHOOK_SECRET!) const event = wh.verify(body, Object.fromEntries(req.headers)) ``` Do not invent a different scheme, and do not skip verification because the URL is hard to guess. It is not a secret; it is in your logs, your proxy's logs, and anywhere the URL has ever been pasted. --- # A backup and restore drill Source: https://wess.io/corsair/docs/tutorials/backup-drill.html # A backup and restore drill Ninety minutes, once. Take a full backup, destroy the install, and restore it onto a clean host. A backup you have never restored is not a backup — it is a file you hope about. Do this on a **staging host**, not on the server currently receiving your mail. ## What actually has to survive Losing any one of these makes the restore incomplete, and the ones people forget are not the database. | Thing | Where | Lose it and… | | --- | --- | --- | | PostgreSQL | The database | Everything. Mailboxes, folders, flags, users, domains | | Message bodies | The bucket, or Postgres if none | The mail itself | | DKIM private keys | In the database | Signing breaks; every domain needs new keys published | | `.env` | The host | `JWT_SECRET` is gone, every session dies, and config is guesswork | | TLS certificate | The host | Reissuable, but not instantly | The DKIM keys live in the database, so a database backup covers them — which is exactly why the database dump is not optional and why it must be treated as secret material. danger A database dump is a credential store It contains DKIM private keys, password hashes, and encrypted transfer credentials. Encrypt it at rest and keep it off any bucket that is world-readable. ## 1. Take the backup ### The database ```sh pg_dump --format=custom --no-owner \ "postgres://corsair:PASSWORD@localhost:5432/corsair" \ > /backup/corsair-$(date +%F).dump ``` `--format=custom` compresses and lets you restore selectively. Verify it is not empty and that it lists the tables you expect: ```sh pg_restore --list /backup/corsair-2026-08-10.dump | head -30 ``` ### The configuration ```sh cp /opt/corsair/app/.env /backup/corsair-env-$(date +%F) ``` Then encrypt it. It holds `JWT_SECRET`, the database password, and your storage keys. ```sh age -p -o /backup/corsair-env-$(date +%F).age /backup/corsair-env-$(date +%F) shred -u /backup/corsair-env-$(date +%F) ``` ### The message bodies With `STORAGE_BUCKET` set, bodies are in the bucket and are not in the database dump. Back the bucket up separately, or rely on the provider's versioning — but know which you are doing. ```sh aws s3 sync s3://your-bucket/corsair /backup/bodies --endpoint-url "$STORAGE_ENDPOINT" ``` Without a bucket, bodies are inline in Postgres and the dump has everything. ## 2. Note what you are about to prove Before destroying anything, write down what must be true afterwards: ```sh psql "$DATABASE_URL" -Atc "SELECT count(*) FROM messages" psql "$DATABASE_URL" -Atc "SELECT count(*) FROM addresses" psql "$DATABASE_URL" -Atc "SELECT address, uid_next FROM folders JOIN addresses ON addresses.id = folders.address_id ORDER BY 1,2 LIMIT 10" ``` Keep the output. A restore that "looks fine" is not the same as a restore whose numbers match. Also note one specific message you will look for by hand — a subject and a date. ## 3. Destroy it On the staging host, genuinely destroy it. Half-measures let a leftover file carry the restore and you learn nothing. ```sh sudo systemctl stop corsair sudo -u postgres dropdb corsair sudo rm -rf /opt/corsair ``` ## 4. Restore ### Rebuild the host Install Bun, PostgreSQL, and clone the repository — [steps 2 and 3 of the first-server tutorial](first-server.html). ### Restore the configuration first ```sh age -d -o /opt/corsair/app/.env /backup/corsair-env-2026-08-10.age sudo chown corsair:corsair /opt/corsair/app/.env sudo chmod 600 /opt/corsair/app/.env ``` Configuration before database, because the restore needs `DATABASE_URL` and the storage keys, and because a mismatched `JWT_SECRET` is a confusing failure to debug later. ### Restore the database ```sh sudo -u postgres createdb -O corsair corsair pg_restore --no-owner --dbname "$DATABASE_URL" /backup/corsair-2026-08-10.dump ``` ### Bring the schema up to date If the backup predates a version upgrade, apply any migrations it is missing: ```sh bun scripts/migrate.ts status bun scripts/migrate.ts up ``` Then confirm the schema matches what the code expects: ```sh bun scripts/migrate.ts diff # schema in sync ``` ### Restore the bodies ```sh aws s3 sync /backup/bodies s3://your-bucket/corsair --endpoint-url "$STORAGE_ENDPOINT" ``` Skip if you never had a bucket. ## 5. Start it and check the numbers ```sh sudo systemctl start corsair ``` Run the same three queries. They must match what you wrote down. ```sh psql "$DATABASE_URL" -Atc "SELECT count(*) FROM messages" ``` warning `uid_next` must not go backwards If you restore an older dump onto a folder that has since received mail, UIDs get reissued — and a duplicate UID is the one thing an IMAP client never recovers from. After any restore that is not the newest dump, clients on affected mailboxes should be told to resynchronise from scratch. ## 6. Check the parts a count does not cover **Read a message.** Sign into the webmail and open the specific message you noted. If bodies are in a bucket, this is the test that the bucket restore worked — the count came from Postgres and proves nothing about the object store. **Send one.** Send from a restored mailbox to an outside address and check the headers for `dkim=pass`. That proves the DKIM private keys survived, which is the failure people discover a week later. **Log into the panel.** Proves `JWT_SECRET` and the users table came back together. Every existing session is invalid after a restore — expected, since sessions are rows and the old cookies point at rows that no longer exist. **Connect a real client** over IMAP. Proves TLS and the folder structure. ## 7. Write down how long it took The number you want is not "do we have backups" but "how long until mail flows again". Time the restore from step 4 to a verified send. That figure is your real recovery objective, and it is usually longer than people guess. ## Automating it Once the manual drill works, script it: ```sh #!/usr/bin/env sh # /usr/local/bin/corsair-backup — run nightly from cron set -eu STAMP=$(date +%F) DEST=/backup pg_dump --format=custom --no-owner "$DATABASE_URL" > "$DEST/corsair-$STAMP.dump" age -r "$BACKUP_RECIPIENT" -o "$DEST/corsair-$STAMP.dump.age" "$DEST/corsair-$STAMP.dump" rm "$DEST/corsair-$STAMP.dump" # Fail loudly rather than silently keeping a zero-byte file. test -s "$DEST/corsair-$STAMP.dump.age" find "$DEST" -name 'corsair-*.dump.age' -mtime +30 -delete ``` ``` 15 3 * * * /usr/local/bin/corsair-backup || echo "corsair backup FAILED" | mail -s "backup" you@example.com ``` A backup job that fails silently is worse than no backup job, because it removes the worry without removing the risk. Alert on failure, and alert on the *absence* of a success — a cron that never ran sends no failure mail either. ## Repeat it Twice a year, and after any upgrade that touched migrations. It takes ninety minutes and it is the only way the answer stays true. [Backups and restore](../backups.html) is the reference version of this page. --- # Installation Source: https://wess.io/corsair/docs/installation.html # Installation Three ways to run Corsair. All of them need the same four things from the host — see [Prerequisites](prerequisites.html) — and all of them run the same code. ## Entrypoints ```sh bun src/dev.ts # everything, with a summary of where it is listening bun src/start.ts # everything, quietly — this is what the container runs bun src/server.ts # the HTTP tier only, for a split deployment ``` ```sh bun scripts/migrate.ts up | down | status | diff bun scripts/seed.ts ``` Migrations deliberately 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 — on its own. ## Docker Compose The shipped `compose.yaml` is the whole stack: Postgres, a one-shot migration container, and Corsair with the real ports mapped. ```sh git clone https://github.com/wess/corsair cd corsair cp .env.example .env ``` Edit `.env` for production values ([Configuration](configuration.html)), then: ```sh export POSTGRES_PASSWORD='pick-something-long' mkdir -p certs # fullchain.pem and privkey.pem go here docker compose up -d ``` The compose file overrides the ports to the real ones and mounts `./certs` read-only at `/certs`: ```yaml environment: 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: /certs/fullchain.pem TLS_KEY_PATH: /certs/privkey.pem volumes: - ./certs:/certs:ro ``` The `migrate` service runs `scripts/migrate.ts up` once and exits; `corsair` waits for it with `service_completed_successfully`, so a fresh stack comes up in the right order without a race. Seed the first account: ```sh docker compose run --rm corsair bun scripts/seed.ts ``` ### The image The `Dockerfile` builds on `oven/bun:1.3-alpine`, installs `libcap`, grants `cap_net_bind_service` to the Bun binary, then drops to an unprivileged `corsair` user. Ports below 1024 bind without the process being root. ```sh docker compose logs -f corsair docker compose restart corsair ``` warning Mount certificates, do not bake them in `./certs` is a bind mount so renewal on the host is picked up by restarting the container. A certificate copied into the image expires inside it. ## Bare metal with systemd The route with the fewest moving parts, and what [the first-server tutorial](tutorials/first-server.html) walks through in full. ```sh sudo adduser --system --group --home /opt/corsair corsair sudo -u corsair git clone https://github.com/wess/corsair /opt/corsair/app cd /opt/corsair/app sudo -u corsair bun install sudo -u corsair cp .env.example .env ``` ```ini # /etc/systemd/system/corsair.service [Unit] Description=Corsair mail server After=network-online.target postgresql.service Wants=network-online.target [Service] Type=simple User=corsair Group=corsair WorkingDirectory=/opt/corsair/app ExecStart=/opt/corsair/.bun/bin/bun src/start.ts Restart=always RestartSec=5 Environment=NODE_ENV=production # Ports below 1024, granted to the service rather than to the binary. AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/opt/corsair/app [Install] WantedBy=multi-user.target ``` ```sh sudo systemctl daemon-reload sudo systemctl enable --now corsair ``` danger Under systemd, use AmbientCapabilities — not `setcap` `NoNewPrivileges=true` **blocks file capabilities outright**. A `setcap cap_net_bind_service=+ep` on the Bun binary silently does nothing under this unit, and the service fails with `EACCES` on port 25 while the HTTP tier on 3000 comes up fine — which makes it look like a mail-specific problem rather than a permissions one. `AmbientCapabilities` is the correct mechanism and is strictly better anyway: it survives `bun upgrade` replacing the binary, and nothing on disk carries a capability. Use `setcap` only when running Corsair **outside** systemd — by hand, or under a supervisor that does not set `NoNewPrivileges`. ## Split deployment The HTTP tier and the mail listeners can run as separate processes against the same database. ```sh bun src/server.ts # HTTP only: API, panel, webmail, JMAP SMTP_ENABLED=true IMAP_ENABLED=false POP3_ENABLED=false bun src/start.ts ``` Reasons to bother: - **Restart the panel without dropping IMAP sessions.** A deploy of the web tier no longer disconnects every mail client. - **Different machines.** The HTTP tier behind a load balancer, the mail listeners on the host that owns the reputable IP. - **Scale the worker separately.** Several instances can drain the delivery queue; it claims work with `FOR UPDATE SKIP LOCKED`, so they do not coordinate and cannot deliver anything twice. The listener toggles are `SMTP_ENABLED`, `IMAP_ENABLED`, and `POP3_ENABLED`. ## Reverse proxy Corsair serves plain HTTP on `PORT` (default 3000). Terminate TLS in front of it. ```nginx server { listen 443 ssl http2; server_name mail.example.com; ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem; # JMAP blob upload and webmail attachments. client_max_body_size 60m; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Then set `TRUSTED_PROXIES` or every request appears to come from `127.0.0.1` and the rate limiter treats the entire internet as one client: ```sh TRUSTED_PROXIES=127.0.0.1 ``` danger Never proxy the mail ports SMTP, IMAP, and POP3 must reach Corsair directly. Putting an HTTP proxy in front of them does not work, and putting a TCP proxy in front without PROXY protocol support hides the client IP — which SPF, the rate limiter, and the ban list all depend on. ## Object storage Set the bucket and message bodies go to object storage, with only headers and flags in Postgres. ```sh STORAGE_BUCKET=my-mail-bucket STORAGE_REGION=nyc3 STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com STORAGE_ACCESS_KEY_ID=... STORAGE_SECRET_ACCESS_KEY=... STORAGE_PREFIX=corsair ``` Leave `STORAGE_BUCKET` empty and bodies stay inline in Postgres. That works and keeps a single-container install to one dependency, but it puts mail volume through the WAL. Configure a bucket for anything real. Objects are written with **no ACL**, so they inherit the bucket's default of private. Mail must never be publicly readable — check the bucket's default before pointing Corsair at it. ## Verifying the install ```sh sudo ss -lntp | grep bun # every enabled listener curl -s localhost:3000/api/plans # the API answers bun scripts/migrate.ts status # every migration applied bun scripts/migrate.ts diff # "schema in sync" ``` Then open `/app`, sign in with the seeded account, and add a domain. ## Upgrading See [Upgrading](upgrading.html). The short version: pull, install, run migrations, restart — and read the migration list before you do. --- # Configuration Source: https://wess.io/corsair/docs/configuration.html # 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 | | `HOST` | `0.0.0.0` | Interface the HTTP listener binds | | `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 | danger JWT_SECRET Anyone who knows it can mint a session for any account. Generate 48 random bytes and treat it as a credential: `openssl rand -base64 48` Corsair **refuses to start** with the default, with any value copied from the repository, or with fewer than 32 characters. (`bun run dev` is exempt.) It is the root of every key Corsair uses, but not used directly: panel sessions, webmail sessions, the SRS signature that stops a forwarding address being an open relay, and the encryption of stored transfer credentials each get their own key, derived with HKDF. Knowing one of those tells you nothing about the others. It also means the secret alone cannot mint a session — a token has to name a live row on the server. Changing it invalidates every existing session, which is also how you revoke everything at once. `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. `HOST` defaults to `0.0.0.0` because a container that binds loopback is a container nothing can reach. If your reverse proxy runs on the same box, set it to `127.0.0.1`. A firewall rule that closes port 3000 is a promise you have to keep on every rebuild; never listening on the public interface in the first place is not. The mail listeners ignore `HOST` and always bind every interface. They are the ones the internet is supposed to reach. ## 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` | | `SMTP_BIND` | `0.0.0.0` | `127.0.0.1` behind the terminator | | `SMTP_TRUSTED_PROXIES` | empty | `127.0.0.1` behind the terminator | | `SMTP_STARTTLS_FRONTED` | `false` | `true` behind the terminator | | `SMTP_PUBLIC_SUBMISSION_PORT` | the listening port | `587` behind the terminator | | `SMTP_PUBLIC_SUBMISSION_TLS_PORT` | the listening port | `465` | ### STARTTLS on 25 and 587 Bun cannot upgrade a socket it accepted, so Corsair alone cannot answer STARTTLS: port 25 carries cleartext and port 587 cannot authenticate. `engine/` is a small Rust process that holds those two ports, performs the upgrade, and relays to Corsair on loopback. It makes no policy decisions — every rejection, every check, and the whole mail path stay in Corsair. Deploying it means four settings, and all four matter: - `SMTP_MX_PORT=2525` and `SMTP_SUBMISSION_PORT=2587` move Corsair off the public ports so the terminator can take them. - `SMTP_BIND=127.0.0.1` keeps those listeners off the internet. Without it the backend still answers on a public address, which is a way to reach this server without TLS. Submission on 465 is unaffected — it has no plaintext phase. - `SMTP_TRUSTED_PROXIES=127.0.0.1` lets the terminator say which address a session is really from, using `XCLIENT`. Leave it empty and every relayed message appears to come from loopback, which breaks SPF for everyone. **Only list a proxy you run.** Anything in this list can claim to be any sender. - `SMTP_STARTTLS_FRONTED=true` tells the server to describe itself accurately: it changes nothing about what Corsair does, but without it autoconfig steers clients away from 587 and the MTA-STS policy stays at `mode: none`. `SMTP_PUBLIC_SUBMISSION_PORT=587` is what clients are told to connect to, as opposed to what Corsair listens on. | `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](installation.html); the two are not interchangeable. The `*_ENABLED` flags accept `true`, `1`, or `yes`. Turning listeners off is how you [split the deployment](installation.html). ## 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](tls.html). ## 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 | warning `console` is the default A fresh `.env` delivers nothing. That is right for a laptop and wrong for a server — set `DELIVERY_TRANSPORT=direct` (or `relay`) as part of going live, or mail silently goes to the log. ## 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. Corsair resolves the host and refuses private ranges by default, and does not follow redirects. 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` | `26214400` | 25 MB. Advertised in the SMTP `SIZE` extension. Raising it costs memory: the pipeline holds several copies of a message at once | 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 25 MB on the wire is roughly an 18 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 ```sh DATABASE_URL=postgres://corsair:LONG_PASSWORD@localhost:5432/corsair JWT_SECRET= PUBLIC_URL=https://mail.example.com HOST=127.0.0.1 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](production-checklist.html). ## Changing configuration Every value is read at startup. Restart after any edit: ```sh sudo systemctl restart corsair # or docker compose up -d ``` There is no reload signal. A mail server that reconfigures itself mid-connection is a source of bugs nobody enjoys. --- # DNS setup Source: https://wess.io/corsair/docs/dns-setup.html # DNS setup Ten records. Five are required; the rest make things work properly. This page covers what each one does, what happens without it, and how to verify it. | Record | Host | Purpose | Required | | --- | --- | --- | --- | | TXT | `@` | Ownership verification | Yes | | TXT | `@` | SPF — which servers may send as you | Yes | | TXT | `_dmarc` | DMARC — what to do when SPF and DKIM fail | Yes | | CNAME | `corsair-1._domainkey` | DKIM signing key | Yes | | CNAME | `corsair-2._domainkey` | Spare DKIM key, for rotation | No | | CNAME | `corsair-3._domainkey` | Spare DKIM key, for rotation | No | | CNAME | `mta-sts` | MTA-STS policy host | No | | CNAME | `autoconfig` | Thunderbird automatic setup | No | | CNAME | `autodiscover` | Outlook automatic setup | No | | MX | `@` | Where your mail is delivered | Yes | The panel generates all of them with the values for your installation, and marks which are required. Only the first DKIM selector has to exist — a domain should not sit unverified because someone added one CNAME. This page explains them; it is not a substitute for the values on the DNS Setup tab, which are the ones to publish. ## Verification A TXT record containing a token unique to your domain. It proves you control the domain before Corsair routes mail for it. Without that check, anyone could add someone else's domain and start receiving their mail. Keep it published. Removing it after verification means the domain fails its next periodic re-check. ## SPF ``` v=spf1 include:spf.example-host.com -all ``` Lists which servers may send as your domain. `-all` means "and nobody else". The `include:` value is whatever the operator set as `MAIL_SPF_HOST`. The check is by prefix rather than exact match, so you can add other senders: ``` v=spf1 include:spf.example-host.com include:_spf.google.com -all ``` warning The ten-lookup limit SPF allows ten DNS-querying mechanisms. Every `include:` counts, **and so does every `include:` inside them** — a single `include:_spf.google.com` costs three or four on its own. Blowing the budget is a permanent error, and the effect is that SPF stops working entirely rather than degrading. Check with an SPF flattening or validation tool if you have more than three includes. **There must be exactly one `v=spf1` record.** Two is a permanent error. This happens constantly when a domain is migrated and the old record is left behind. `-all` versus `~all`: `-all` is a hard fail, `~all` a soft one. Start with `~all` if you are unsure whether you have listed every sender, and tighten to `-all` once you are. ## DKIM Published as a **CNAME**, not a TXT record. A 2048-bit key does not fit in a single TXT string, half the DNS control panels in the world mangle the chunked form, and a CNAME lets the key rotate on the server without you touching DNS again. ``` corsair-1._domainkey.example.com. CNAME dkim-1.mail.example.com. ``` Corsair answers the lookup at the target and serves the current public key. Three selectors are created per domain. **Only the first has to exist**; the others are there so a rotation is a flag flip rather than a support ticket. See [Domains](domains.html) for the rotation order that avoids a gap. Check what a receiver sees: ```sh dig +short CNAME corsair-1._domainkey.example.com dig +short TXT corsair-1._domainkey.example.com ``` The TXT lookup should follow the CNAME and return `v=DKIM1; k=rsa; p=…`. danger Some providers flatten CNAMEs Cloudflare's proxy and a few managed DNS products will resolve a CNAME at publish time and store the result. That freezes the key, and rotation then silently breaks signing. If your provider does this, turn proxying **off** for the `_domainkey` records. ## DMARC ``` v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com ``` Tells receivers what to do when a message claiming to be from you passes neither an aligned SPF nor an aligned DKIM check. | Policy | Effect | | --- | --- | | `p=none` | Do nothing. Monitoring only | | `p=quarantine` | Treat as suspicious — usually the spam folder | | `p=reject` | Refuse it outright | `quarantine` is the sensible default. Move to `reject` once you are confident every legitimate sender for the domain is covered — including the marketing platform, the ticketing system, and whatever else sends as you. `rua=` gives you aggregate reports, which is how you find the sender you forgot. Add it before tightening the policy, not after. **DMARC passes if *either* SPF or DKIM passes and aligns with the From domain.** One is enough by design — that is what makes forwarding survivable, since forwarding breaks SPF but preserves DKIM. ## MX ``` example.com. MX 10 mail.example.com. ``` Where mail for the domain is delivered. danger The MX record is the cutover If you have an existing MX pointing somewhere else, replacing it is the moment your mail starts arriving here. Do the [mailbox transfer](transfers.html) first, verify it, then change the MX. Remove the old provider's MX records entirely. Leaving them at a worse priority means the old host keeps receiving whenever yours is briefly unreachable, which is the opposite of a clean cutover. Two MX records at equal priority is how you run two mail hosts — senders pick one and retry the other on failure. ## MTA-STS Tells senders to require TLS when delivering to you, and to refuse to fall back to plaintext if it fails. Corsair serves the policy at `/.well-known/mta-sts.txt`. The mode reflects whether the MX can actually offer STARTTLS — on a runtime where it cannot, the policy is published as `none` rather than claiming a capability that is not there. See [Deliverability](./deliverability.html). ``` version: STSv1 mode: none mx: mail.example.com max_age: 604800 ``` Corsair generates the `mta-sts` CNAME. The **`_mta-sts` TXT record carrying the policy id is not generated for you** — MTA-STS requires one, and senders will not notice a policy change without it, so add it by hand if you are using MTA-STS seriously: ``` _mta-sts.example.com. TXT "v=STSv1; id=20260810000000" ``` Bump the `id` whenever the policy changes. warning Do not go straight to enforce Going to `enforce` with a wrong MX list silently blackholes your inbound mail — senders refuse to deliver rather than falling back. Watch the reports, confirm the MX list is right, and only then change it. ## autoconfig and autodiscover CNAMEs that let Thunderbird and Outlook configure themselves from an email address alone. Optional, and worth publishing — the alternative is telling every user six hostnames. ## Checking everything ```sh dig +short MX example.com dig +short TXT example.com dig +short TXT _dmarc.example.com dig +short CNAME corsair-1._domainkey.example.com dig +short TXT corsair-1._domainkey.example.com ``` The panel's DNS Setup tab does this for you and, when a record does not match, shows **what it actually observed**. That is usually enough to spot the problem immediately. ## When a record will not verify **The provider appended the domain.** You entered `_dmarc.example.com` into a field that already appends the zone, giving `_dmarc.example.com.example.com`. Enter just `_dmarc`. **Quotes were stored literally.** Some panels want the value without quotes and store them if you paste them. `"v=spf1 …"` with the quotes in the value is not a valid SPF record. **Two SPF records.** Exactly one. **It has not propagated.** A record can take up to the *previous* record's TTL to become visible. If you lowered the TTL only when you made the change, you are waiting for the old one. **Proxying is on.** Cloudflare's orange cloud on a `_domainkey` record breaks it. ## Before a migration, lower your TTLs A day before you plan to change the MX, drop its TTL to 300 seconds. Then the cutover window is five minutes rather than however long the old TTL was. Raise them again afterwards. --- # TLS certificates Source: https://wess.io/corsair/docs/tls.html # TLS certificates Corsair refuses SMTP `AUTH` and IMAP `LOGIN` on an unencrypted connection when a certificate is configured. It advertises `LOGINDISABLED` instead, so a client fails at connect rather than after sending the password in the clear. That is deliberate and it is not configurable. A password crossing the network in plaintext is worse than no service. ## What you need One certificate for `CORSAIR_HOSTNAME`. If clients connect to different names — `imap.example.com`, `smtp.example.com` — either include those as subject alternative names or point them at `CORSAIR_HOSTNAME` with CNAMEs and let clients follow. ```sh TLS_CERT_PATH=/etc/corsair/certs/fullchain.pem TLS_KEY_PATH=/etc/corsair/certs/privkey.pem ``` Both are PEM. `fullchain.pem` must be the **full chain**, not just the leaf — a leaf-only certificate validates in a browser that already has the intermediate cached, and fails against a mail server that does not. ## Getting one from Let's Encrypt ### DNS-01, the least painful route DNS-01 does not need port 80 open and issues wildcards, which matters if you are serving several hostnames off one install. ```sh sudo apt install -y certbot python3-certbot-dns-cloudflare sudo certbot certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ -d mail.example.com \ -d '*.mail.example.com' ``` ```ini # /etc/letsencrypt/cloudflare.ini — chmod 600 dns_cloudflare_api_token = your-scoped-token ``` Scope the token to **Zone → DNS → Edit** for that zone only. It lives on disk permanently, unlike the token you paste into Corsair's DNS publish screen, which is used once and discarded. ### HTTP-01, if port 80 is free ```sh sudo certbot certonly --standalone -d mail.example.com ``` Simpler, but it needs port 80 reachable at renewal time and cannot issue wildcards. ### Behind an existing reverse proxy If nginx or Caddy already terminates TLS for the panel, point Corsair at the same files. Caddy stores them under its data directory: ``` /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/ ``` Copy or symlink, and make sure the `corsair` user can read them. ## Permissions Let's Encrypt writes `privkey.pem` root-owned and mode 600. Corsair does not run as root, so it cannot read it. ```sh sudo mkdir -p /etc/corsair/certs sudo cp /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem /etc/corsair/certs/ sudo chown corsair:corsair /etc/corsair/certs/*.pem sudo chmod 640 /etc/corsair/certs/privkey.pem ``` Copying rather than symlinking is deliberate: a symlink into `/etc/letsencrypt/live` still lands on a root-only file, and `ProtectSystem` in the unit blocks the path anyway. ## Renewal Certificates last ninety days. Renewal is not the hard part; **reloading** is — Corsair reads the files at startup and holds them. ```sh # /etc/letsencrypt/renewal-hooks/deploy/corsair.sh — chmod +x #!/usr/bin/env sh set -eu install -o corsair -g corsair -m 644 \ /etc/letsencrypt/live/mail.example.com/fullchain.pem /etc/corsair/certs/fullchain.pem install -o corsair -g corsair -m 640 \ /etc/letsencrypt/live/mail.example.com/privkey.pem /etc/corsair/certs/privkey.pem systemctl restart corsair ``` Certbot runs deploy hooks only when a certificate actually renewed, so this restarts roughly every sixty days rather than twice a day. Test the whole path before you depend on it: ```sh sudo certbot renew --dry-run ``` warning Restarting drops connections A restart disconnects every IMAP IDLE session and every in-flight SMTP transaction. Clients reconnect and senders retry, so the cost is small — but schedule it away from your busiest hour, and consider a [split deployment](installation.html) if even that is too much. ### Docker With `./certs` bind-mounted, the hook writes to the host path and restarts the container: ```sh #!/usr/bin/env sh set -eu cp /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem /opt/corsair/certs/ docker compose -f /opt/corsair/compose.yaml restart corsair ``` ## Checking what is actually served ```sh # Implicit TLS openssl s_client -connect mail.example.com:993 -servername mail.example.com /dev/null \ | openssl x509 -noout -subject -dates -ext subjectAltName # STARTTLS on submission openssl s_client -starttls smtp -connect mail.example.com:587 -servername mail.example.com /dev/null \ | openssl x509 -noout -subject -dates ``` Check that the subject matches what clients connect to, that the dates are current, and that the chain is complete: ```sh echo | openssl s_client -connect mail.example.com:993 2>&1 | grep -i 'verify\|chain' ``` `Verify return code: 0 (ok)` is what you want. `unable to get local issuer certificate` means the chain is incomplete — you gave it the leaf, not `fullchain.pem`. ## Which ports use which | Port | Protocol | TLS | Usable | | --- | --- | --- | --- | | 25 | SMTP (MX) | none — plaintext | Yes, for server-to-server delivery | | 465 | Submission | Implicit, from the first byte | **Yes** | | 993 | IMAP | Implicit | **Yes** | | 995 | POP3 | Implicit | **Yes** | | 587 | Submission | STARTTLS, where the runtime supports it | Only after a successful upgrade | | 143 | IMAP | — | No: cannot authenticate | | 110 | POP3 | — | No: cannot authenticate | warning STARTTLS depends on the runtime Corsair can only offer STARTTLS if the runtime can upgrade an already-accepted socket to TLS. Older builds of Bun cannot, and throw at the moment of upgrade. Corsair **probes this at startup** — it opens a loopback listener and tries — rather than testing for a version or a function name. An earlier version checked for an API that never shipped, which would have kept STARTTLS switched off long after the runtime had learned to do it. Where the probe says no, STARTTLS is not advertised on any protocol, the three plaintext ports cannot be used to log in, and autoconfig hands clients **465, 993, and 995** instead. The MTA-STS policy publishes `mode: none` to match, because a policy is a promise the MX has to keep. Where it says yes, all of that flips: STARTTLS is advertised, 587/143/110 accept a login after upgrading, autoconfig names 587, and the policy moves to `testing`. Nothing needs configuring either way, and `bun run test:starttls:server` reports which case you are in. ## Self-signed, for local testing only `test:mailflow` needs a certificate because Corsair refuses authentication without one: ```sh mkdir -p certs openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout certs/privkey.pem -out certs/fullchain.pem \ -subj "/CN=mail.corsair.local" TLS_CERT_PATH=certs/fullchain.pem TLS_KEY_PATH=certs/privkey.pem bun run test:mailflow ``` Real clients will refuse a self-signed certificate, and they are right to. ## When it goes wrong | Symptom | Cause | | --- | --- | | `LOGINDISABLED` in the IMAP greeting | Working as designed on a plaintext port. Use 993, or STARTTLS first | | Client: "certificate not trusted" | Self-signed, or the chain is incomplete | | Client: "name does not match" | Certificate is for a different host than the client dialled | | Corsair starts but TLS ports do not listen | Paths wrong, or the `corsair` user cannot read the key | | Worked, then stopped after ~90 days | Renewal ran; the restart did not. Add the deploy hook | The last one is the common one. Renewal without a reload is the failure mode of almost every mail server on the internet. --- # Production checklist Source: https://wess.io/corsair/docs/production-checklist.html # Production checklist Work down this before you publish an MX record that real people depend on. Each item has a command that either passes or does not — nothing here relies on believing the configuration is right. ## Host - [ ] **Static IP.** Not a dynamic or residential address. Those are on the Policy Block List by construction and no configuration fixes it. - [ ] **PTR matches, both directions.** ```sh dig +short mail.example.com # → 203.0.113.9 dig +short -x 203.0.113.9 # → mail.example.com. ``` - [ ] **Outbound port 25 open.** ```sh nc -zv gmail-smtp-in.l.google.com 25 ``` If it hangs, set `DELIVERY_TRANSPORT=relay` and point at a smarthost. - [ ] **Inbound port 25 reachable.** From somewhere else: ```sh nc -zv mail.example.com 25 ``` - [ ] **IP not on a blocklist.** ```sh dig +short 9.113.0.203.zen.spamhaus.org # reversed octets; no answer = clean ``` - [ ] **Clock synchronised.** DKIM signatures, webhook timestamps, and TOTP all depend on it. ```sh timedatectl status | grep -i synchronized ``` ## Configuration - [ ] **`JWT_SECRET` changed** from the default and generated randomly. Anyone who knows it can mint a session for any account. - [ ] **`DELIVERY_TRANSPORT` is not `console`.** The default delivers nothing. ```sh grep DELIVERY_TRANSPORT .env ``` - [ ] **`SIGNUPS=closed`** unless you intend to host mail for strangers. - [ ] **`TRUSTED_PROXIES` set** if anything sits in front of the HTTP port. Without it every request looks like `127.0.0.1` and the rate limiter treats the internet as one client. - [ ] **`PUBLIC_URL` is the real HTTPS URL.** It goes into password reset and verification emails. - [ ] **Ports are the real ones** — 25, 587, 465, 143, 993, 110, 995 — and the binary can bind them. ```sh sudo ss -lntp | grep bun ``` - [ ] **`.env` is mode 600** and owned by the service user. It holds the database password, the JWT secret, and your storage keys. ## TLS - [ ] **Certificate valid for `CORSAIR_HOSTNAME`**, chain complete. ```sh openssl s_client -connect mail.example.com:993 -servername mail.example.com &1 \ | grep -i 'verify return code' ``` `0 (ok)` or it is not done. - [ ] **Renewal automated *and* the reload hook installed.** Renewal without a restart is the failure mode of almost every mail server on the internet. ```sh sudo certbot renew --dry-run ls /etc/letsencrypt/renewal-hooks/deploy/ ``` - [ ] **Panel behind HTTPS.** Corsair serves plain HTTP; terminate in front. ## Database - [ ] **Migrations applied.** ```sh bun scripts/migrate.ts status # every row "applied" bun scripts/migrate.ts diff # "schema in sync" ``` - [ ] **Not using the default password.** `postgres://corsair:corsair@…` is the development default. - [ ] **Postgres not listening on a public interface** unless you meant it. ```sh sudo ss -lntp | grep 5432 ``` ## Storage - [ ] **Bucket configured** if you expect any volume. Without one, bodies go through the WAL. - [ ] **Bucket is private.** Objects are written with no ACL and inherit the bucket default. Check it, especially on a bucket you already use for something else. ```sh curl -sI "https://BUCKET.REGION.digitaloceanspaces.com/corsair/..." | head -1 ``` A `403` is correct. A `200` means your mail is public. ## DNS, per domain - [ ] **Domain shows active** in the panel. - [ ] **SPF** published and includes `MAIL_SPF_HOST`. - [ ] **DKIM CNAME** published for the **active** selector. - [ ] **DMARC** published, starting at `p=quarantine`. - [ ] **MX** points here — and this is the cutover, so do it last. ```sh dig +short TXT example.com dig +short TXT _dmarc.example.com dig +short CNAME corsair-1._domainkey.example.com dig +short MX example.com ``` ## A real round trip The only test that matters. Send to an address at a large provider and read the headers of what arrives: ``` Authentication-Results: mx.google.com; spf=pass ... dkim=pass header.i=@example.com header.s=corsair-1 dmarc=pass (p=QUARANTINE ...) ``` Three passes. Then send *to* the domain from outside and confirm it lands. - [ ] Outbound: `spf=pass`, `dkim=pass`, `dmarc=pass` - [ ] Inbound: arrives, and the `Authentication-Results` header Corsair stamped shows what it made of the sender - [ ] A client connects over IMAP 993 and SMTP 587 ## Operations - [ ] **Backups running**, encrypted, and **restored at least once**. See the [restore drill](tutorials/backup-drill.html). A backup you have never restored is a file you hope about. - [ ] **Backup failures alert.** A cron that never ran sends no failure mail either — alert on the absence of success. - [ ] **Monitoring** on: the process, the queue depth, certificate expiry, disk, and the bounce rate. See [Monitoring](monitoring.html). - [ ] **Log retention** decided. Mail logs contain sender and recipient addresses. - [ ] **Service restarts on boot.** ```sh systemctl is-enabled corsair ``` - [ ] **You know how to read the queue** before you need to at 3am. ## Security - [ ] **Two-factor on the owner account.** It controls every domain. - [ ] **Firewall** allows only 25, 465, 587, 143, 993, 110, 995, 443, and SSH. - [ ] **SSH keys only**, password authentication off. - [ ] **`WEBHOOK_ALLOW_PRIVATE` left `false`** unless consumers really are on the same private network. - [ ] **Rate limits reviewed.** `RATE_LIMIT_PER_SECOND` defaults to 10. See [Security model](security.html) for what Corsair defends against and what it does not. ## Deliverability - [ ] **Volume ramped, not dumped.** A new IP sending a thousand messages on day one looks exactly like a compromised host. - [ ] **MTA-STS mode matches reality.** Corsair publishes `none` while STARTTLS is unavailable, because a policy is a promise the MX has to keep. Once it is available, `testing` first — going straight to `enforce` with a wrong MX list silently blackholes inbound mail. - [ ] **A plan for bounces.** Treat a `5xx` as permanent and stop sending there. [Deliverability](deliverability.html) covers the reputation side. ## The last one - [ ] **Someone other than you can find the runbook.** Where the backups are, how to restart it, and who to call about the IP. Self-hosted mail has a bus factor of one by default. --- # Backups and restore Source: https://wess.io/corsair/docs/backups.html # Backups and restore Five things have to survive. Losing any one makes a restore incomplete, and the ones people forget are not the database. | Thing | Where it lives | Lose it and… | | --- | --- | --- | | PostgreSQL | The database | Everything: mailboxes, folders, flags, users, domains | | Message bodies | The bucket, or Postgres if none | The mail itself | | DKIM private keys | In the database | Signing breaks; every domain needs new keys published | | `.env` | The host filesystem | `JWT_SECRET` is gone, every session dies, config is guesswork | | TLS certificate | The host filesystem | Reissuable, but not instantly | DKIM keys living in the database is why the database dump is not optional and why it must be treated as secret material. danger A database dump is a credential store It contains DKIM private keys, password hashes, and encrypted transfer credentials. Encrypt it at rest, and never put it in a bucket whose default is public. ## The database ```sh pg_dump --format=custom --no-owner "$DATABASE_URL" > corsair-$(date +%F).dump ``` `--format=custom` compresses and allows selective restore. `--no-owner` means the dump restores cleanly into a database with a different role name. Verify rather than assume: ```sh pg_restore --list corsair-2026-08-10.dump | head -30 ``` An empty or truncated dump lists nothing. Check the size too — a zero-byte file is the classic silent backup failure. ### How often Continuously, if the mail matters. Nightly at minimum. Anything between the last dump and a failure is gone: messages received, messages sent, flag changes, new mailboxes. For a household, nightly is fine. For a business, set up WAL archiving and point-in-time recovery — that is a Postgres topic, not a Corsair one, and Postgres documents it well. ## Message bodies With `STORAGE_BUCKET` set, bodies are in the bucket and **are not in the database dump**. This is the part that gets missed: the dump restores, the counts match, and every message opens empty. ```sh aws s3 sync "s3://$STORAGE_BUCKET/$STORAGE_PREFIX" /backup/bodies \ --endpoint-url "$STORAGE_ENDPOINT" ``` Or rely on the provider's versioning and lifecycle rules — but know which you are doing, and test that you can retrieve a deleted object. Without a bucket, bodies are inline in Postgres and the dump has everything. ## Configuration ```sh cp /opt/corsair/app/.env ./corsair-env-$(date +%F) age -r "$BACKUP_RECIPIENT" -o corsair-env-$(date +%F).age corsair-env-$(date +%F) shred -u corsair-env-$(date +%F) ``` `.env` holds `JWT_SECRET`, the database password, and the storage keys. Encrypt it. A backup of your secrets in plaintext is a breach waiting for a misconfigured bucket. ## Certificates Reissuable from Let's Encrypt in minutes, so back them up for speed rather than necessity. If you use DNS-01, back up the API credentials file too — that one is not reissuable without console access to the DNS provider. ## A nightly script ```sh #!/usr/bin/env sh # /usr/local/bin/corsair-backup set -eu STAMP=$(date +%F) DEST=/backup pg_dump --format=custom --no-owner "$DATABASE_URL" > "$DEST/corsair-$STAMP.dump" age -r "$BACKUP_RECIPIENT" -o "$DEST/corsair-$STAMP.dump.age" "$DEST/corsair-$STAMP.dump" rm "$DEST/corsair-$STAMP.dump" # Fail loudly rather than keeping a zero-byte file that looks like a backup. test -s "$DEST/corsair-$STAMP.dump.age" find "$DEST" -name 'corsair-*.dump.age' -mtime +30 -delete ``` ``` 15 3 * * * /usr/local/bin/corsair-backup || echo "corsair backup FAILED" | mail -s "backup" you@example.com ``` warning Alert on the absence of success, not only on failure A cron job that never ran sends no failure mail either. Have the script touch a heartbeat file or ping a dead-man's-switch, and alert when that goes quiet. ## Restoring Order matters: configuration, then database, then migrations, then bodies. ### 1. Configuration first ```sh age -d -o /opt/corsair/app/.env /backup/corsair-env-2026-08-10.age chown corsair:corsair /opt/corsair/app/.env chmod 600 /opt/corsair/app/.env ``` The restore needs `DATABASE_URL` and the storage keys, and a mismatched `JWT_SECRET` is a confusing thing to debug later. ### 2. Database ```sh sudo -u postgres createdb -O corsair corsair pg_restore --no-owner --dbname "$DATABASE_URL" /backup/corsair-2026-08-10.dump ``` ### 3. Migrations If the backup predates a version upgrade: ```sh bun scripts/migrate.ts status bun scripts/migrate.ts up bun scripts/migrate.ts diff # "schema in sync" ``` ### 4. Bodies ```sh aws s3 sync /backup/bodies "s3://$STORAGE_BUCKET/$STORAGE_PREFIX" \ --endpoint-url "$STORAGE_ENDPOINT" ``` ### 5. Start and verify ```sh sudo systemctl start corsair psql "$DATABASE_URL" -Atc "SELECT count(*) FROM messages" ``` Then check the things a count cannot: **open a message** (proves the bodies came back), **send one and check for `dkim=pass`** (proves the DKIM keys came back), and **sign into the panel** (proves `JWT_SECRET` and the users table came back together). ## What a restore breaks **Every session is invalid.** Sessions are rows; old cookies point at rows that no longer exist. Everyone signs in again. Expected. **UIDs may go backwards.** This is the one with teeth. danger Restoring an older dump onto a live folder `uid_next` comes back to its value at dump time. New messages then reuse UIDs that clients have already seen against different messages — and a duplicate UID is the one thing an IMAP client never recovers from. After restoring anything other than the newest dump, tell clients on affected mailboxes to remove and re-add the account so they resynchronise from scratch. **In-flight outbound mail is gone** if it was queued after the dump. Senders whose messages were accepted but not yet delivered will not know. There is no way around this short of continuous archiving. ## Migrating to a new host A restore onto a different machine, with two extra steps: 1. Restore normally onto the new host. 2. Update `CORSAIR_HOSTNAME` and the `MAIL_*` hosts if the names changed. 3. **Set the PTR on the new IP** and wait for it to propagate. 4. Update the A record, then the MX. Lower the TTLs a day beforehand. 5. Keep the old host accepting mail until the old MX TTL has fully expired. The old host receiving a few messages after cutover is normal. Run a final `pg_dump` from it and reconcile, or leave it running for a week — the second is easier and safer. ## Testing it A backup you have never restored is not a backup. The [restore drill](tutorials/backup-drill.html) walks through destroying an install and bringing it back, and tells you what to measure. Do it twice a year and after any upgrade that touched migrations. --- # Monitoring Source: https://wess.io/corsair/docs/monitoring.html # Monitoring Mail fails quietly. A queue that stops draining, a certificate that expired, a disk that filled — none of them announce themselves, and all of them look like "my email is a bit slow today" until someone notices a week of missing messages. These are the things worth watching, in the order they matter. ## Alert on these | Signal | Threshold | Why | | --- | --- | --- | | Process not running | Any | Nothing else matters | | Queue depth climbing | Growing for 30 min | Delivery is stuck | | Oldest queued message | Older than 1 hour | Something is failing repeatedly | | Certificate expiry | Under 14 days | Renewal or the reload hook broke | | Disk free | Under 20% | Postgres stops writing when it fills | | Bounce rate | Over 5% of sends | Reputation damage in progress | | Webhook endpoints disabled | Any | Twenty consecutive failures disabled one | | Backup heartbeat | Missing for 36 hours | The backup silently stopped | ## Is it running ```sh systemctl is-active corsair curl -sf localhost:3000/.well-known/mta-sts.txt >/dev/null && echo "http ok" ``` `/.well-known/mta-sts.txt` is served without authentication, so it is a clean HTTP liveness probe. It does **not** touch the database — every `/api` route either requires a session or is a POST, so there is no single endpoint that proves both. Check Postgres separately: ```sh psql "$DATABASE_URL" -Atc "SELECT 1" >/dev/null && echo "db ok" ``` Probing an authenticated endpoint and treating 401 as healthy works too, but it proves only that the HTTP tier is answering — a 401 is returned before anything queries the database. For the mail listeners, check the sockets: ```sh for p in 25 587 465 143 993 110 995; do nc -z localhost $p && echo "$p ok" || echo "$p DOWN" done ``` ## The delivery queue The single most useful thing to watch. If it drains, mail is flowing. ```sql SELECT status, count(*), min(created_at) AS oldest FROM deliveries GROUP BY status; ``` Depth alone is not the alarm — a spike after a burst of sending is normal. What matters is **depth that keeps growing** and **an oldest entry that keeps getting older**. Failures back off from one minute out to about five days, so a genuinely undeliverable message sits in the queue for days by design. A pile of them appearing at once usually means one destination is refusing you. ```sql -- What is failing, and where to. last_error is the verbatim final SMTP reply, -- which is what distinguishes a greylist from a block. SELECT split_part(rcpt_to, '@', 2) AS domain, count(*), max(last_code) AS code, max(last_error) AS reply FROM deliveries WHERE attempts > 2 AND status <> 'sent' GROUP BY 1 ORDER BY 2 DESC LIMIT 10; ``` One domain dominating that list is a reputation problem with that provider. Every domain appearing means your outbound path is broken. ## Inbound flow ```sql SELECT date_trunc('hour', created_at) AS hour, count(*) FROM mail_log WHERE direction = 'inbound' AND created_at > now() - interval '24 hours' GROUP BY 1 ORDER BY 1; ``` An hour with zero inbound on a domain that normally receives is worth investigating. It is usually DNS or a firewall, not the application. ## Storage ```sql -- Per account. Tombstoned rows still occupy the folder until the retention -- sweep clears them, so exclude them or the numbers read high. SELECT u.email, pg_size_pretty(sum(m.size)::bigint) AS used FROM messages m JOIN addresses a ON a.id = m.address_id JOIN domains d ON d.id = a.domain_id JOIN users u ON u.id = d.user_id WHERE m.expunged_at IS NULL GROUP BY 1 ORDER BY sum(m.size) DESC LIMIT 20; ``` Corsair emits `quota.warning` and `quota.exceeded` webhooks; subscribing to those is easier than polling. Host disk matters separately: ```sh df -h /var/lib/postgresql ``` Postgres stops accepting writes when the filesystem fills, and a mail server that cannot write is a mail server that rejects. Alert well before it happens. ## Certificate expiry ```sh openssl s_client -connect mail.example.com:993 -servername mail.example.com /dev/null \ | openssl x509 -noout -enddate ``` Check the port, not the file. The file on disk being fresh proves nothing if the process is still holding the old one — which is exactly the failure mode of renewal without a reload hook. ```sh # Days remaining, for a monitoring check expiry=$(openssl s_client -connect mail.example.com:993 /dev/null \ | openssl x509 -noout -enddate | cut -d= -f2) echo $(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 )) ``` ## Webhook health ```sql SELECT url, status, consecutive_failures, last_success_at FROM webhooks WHERE status <> 'active' OR consecutive_failures > 0; ``` An endpoint that fails twenty times in a row is disabled automatically. Nothing tells you except the panel, so query for it. ## Bounce rate ```sql -- mail_log.status is one of accepted, rejected, delivered, deferred, bounced, spam SELECT count(*) FILTER (WHERE status = 'bounced')::float / NULLIF(count(*), 0) AS bounce_rate FROM mail_log WHERE direction = 'outbound' AND created_at > now() - interval '24 hours'; ``` Above a few percent and receivers start treating you as a source of junk. Corsair suppresses nothing automatically, but it records every bounce — repeatedly delivering to an address that hard-bounces is one of the fastest ways to damage a sending reputation. ## Logs ```sh journalctl -u corsair -f journalctl -u corsair --since "1 hour ago" | grep -i error docker compose logs -f corsair # if containerised ``` Worth grepping for: | Pattern | Meaning | | --- | --- | | `unhandled route error` | An API bug. Should be rare; investigate each one | | `job .* failed` | A worker job threw. It retries | | `rejected` | Inbound refusals — no such user, over quota, filtered | warning Mail logs contain addresses Sender and recipient addresses are personal data. Decide your retention period deliberately and configure `journald` or your log shipper to honour it. ## The worker Four periodic jobs, guarded by a Postgres advisory lock so several workers do not all start the same sweep: | Job | Does | | --- | --- | | `domain.verify` | Re-checks pending domains, every half hour | | `transfer.run` | Runs mailbox migrations | | `quota.recompute` | Recalculates account storage | | `retention.sweep` | Expires tombstones, logs, bans, and sessions | ```sql SELECT kind, status, count(*), max(updated_at) FROM jobs GROUP BY 1, 2; ``` Jobs stuck in `running` with an old `updated_at` mean a worker died mid-job. ## External checks Things a check on the host cannot see: - **Deliverability.** Send to a seed address at a large provider on a schedule and assert `dkim=pass`. This catches an expired DKIM record, which nothing local will. - **Blocklists.** Query your IP against Spamhaus and SpamCop daily. Being listed is silent until mail starts bouncing. - **Inbound reachability.** Connect to port 25 from outside. A firewall rule that changed is invisible from inside. - **DNS.** Assert the MX, SPF, DKIM, and DMARC records still resolve. Someone editing DNS for an unrelated reason is a common cause of sudden failure. ## A minimal setup If you do only four things: 1. Alert when the process is down. 2. Alert when the oldest queued message is over an hour old. 3. Alert when the certificate has under fourteen days. 4. Alert when the backup heartbeat goes quiet. Those four cover the failures that lose mail. Everything else on this page is refinement. --- # Upgrading Source: https://wess.io/corsair/docs/upgrading.html # Upgrading The sequence is pull, install, migrate, restart. The order matters and the migration step is the one that can hurt. ## Before you start ```sh pg_dump --format=custom --no-owner "$DATABASE_URL" > pre-upgrade-$(date +%F).dump ``` Take it every time. Migrations are forward-only in practice — `migrate down` rolls back exactly one migration, and only if that migration wrote a `down` — so the dump is your actual undo. Read what changed: ```sh git fetch origin git log --oneline HEAD..origin/main git diff --stat HEAD..origin/main -- migrations/ ``` A changed `migrations/` directory means schema work. Read those files before running anything; hand-written SQL is easier to review than it is to reverse. ## The upgrade ```sh cd /opt/corsair/app sudo -u corsair git pull sudo -u corsair bun install ``` Check what is pending, then apply: ```sh sudo -u corsair bun scripts/migrate.ts status sudo -u corsair bun scripts/migrate.ts up ``` Confirm the schema matches what the code expects: ```sh sudo -u corsair bun scripts/migrate.ts diff # schema in sync ``` `diff` compares the live database against `allSchemas`. Anything other than "schema in sync" means the migration did not fully land — do not restart into that state. ```sh sudo systemctl restart corsair ``` Then verify: ```sh systemctl status corsair sudo ss -lntp | grep bun # every listener came back curl -sf localhost:3000/api/plans # the API answers ``` ## Upgrading to 0.3 Four things in 0.3 can surprise an existing install: warning Corsair will not start with a default `JWT_SECRET` It refuses the shipped default, any value copied from the repository, and anything shorter than 32 characters, and says so in the log. Set a real one first (`openssl rand -base64 48`). Changing it signs everyone out, and invalidates the SRS signatures on bounces for mail forwarded in the last few days, which is harmless but noisy. - **Sessions are stored on the server and signed with their own keys.** Everyone signed in — to the panel and to webmail — signs in once more after the upgrade. Forwarded mail already in flight, and transfers already running, keep working: the old signature and encryption are still accepted for them until they age out. - **The message size limit defaults to 25 MB** (it was 50 MB). If you set `MAX_MESSAGE_BYTES` yourself, nothing changes; if you relied on the default and want the old limit, set it, mindful that the pipeline holds several copies of a message and one 50 MB message once peaked near 900 MB. - **Four migrations** (`…011` to `…014`) add agent mailboxes and webmail sessions. All additive; each has a `down.sql`. Run them before restarting, as always. - **If you run the Rust STARTTLS front,** rebuild and replace it. The new one refuses a backend that does not accept its XCLIENT (check `127.0.0.1` is in `SMTP_TRUSTED_PROXIES`), and has idle, handshake and connection limits. ## Why migrations do not run at startup Two instances coming up at once would race on the migration table. Running it separately also means a failed migration fails *there*, visibly, rather than inside a service that then restart-loops. On a single-host install this is a small inconvenience. On anything with more than one instance it is the difference between a controlled upgrade and a corrupted schema. ## With Docker Compose The compose file already sequences it: the `migrate` service runs once and exits, and `corsair` waits for `service_completed_successfully`. ```sh cd /opt/corsair git pull docker compose build docker compose up -d ``` To run migrations by hand instead: ```sh docker compose run --rm corsair bun scripts/migrate.ts up docker compose up -d --force-recreate corsair ``` ## Zero-downtime, roughly Corsair is one process, so a restart drops connections. You can narrow the window rather than eliminate it. **Split the tiers.** Run `src/server.ts` for HTTP and `src/start.ts` with the listeners for mail. Deploy the HTTP tier without touching IMAP sessions. **Two mail hosts behind one MX.** Publish two MX records at equal priority. Senders retry the other on a refused connection, so restarting one at a time loses nothing — SMTP is built to retry, and this is the case it was built for. **Accept the blip.** A restart drops IMAP IDLE sessions, which clients reconstruct within seconds, and in-flight SMTP transactions, which senders retry for days. For most installs this is the right answer. note What a restart actually costs Queued outbound mail is safe — it is rows in `deliveries`, not memory. IMAP clients reconnect. SMTP senders retry. The visible cost is a few seconds where new connections are refused. ## Rolling back **Application only**, no migration involved: ```sh git checkout bun install sudo systemctl restart corsair ``` **After a migration** — restore the dump. A newer schema with older code is not a supported combination, and rolling the schema back by hand while mail is arriving is not a good afternoon. ```sh sudo systemctl stop corsair sudo -u postgres dropdb corsair sudo -u postgres createdb -O corsair corsair pg_restore --no-owner --dbname "$DATABASE_URL" pre-upgrade-2026-08-10.dump git checkout sudo systemctl start corsair ``` Everything that happened between the dump and the rollback is lost — messages received, messages sent, flags changed. That is why the dump is taken immediately before the upgrade and not that morning. ## Upgrading PostgreSQL Corsair targets PostgreSQL 17. A major version upgrade is a Postgres operation: ```sh pg_dump --format=custom --no-owner "$DATABASE_URL" > full.dump # install the new major version, create the cluster pg_restore --no-owner --dbname "postgres://…/corsair" full.dump ``` Stop Corsair first. A dump taken while mail is arriving is a dump of a moving target, and the one thing you cannot tolerate is a folder whose `uid_next` is older than its messages. ## Upgrading Bun ```sh bun upgrade sudo systemctl restart corsair ``` note Nothing to re-grant, if you are on systemd `bun upgrade` replaces the binary, and file capabilities do not survive that — so a `setcap`-based setup breaks on every upgrade, with the service failing to bind port 25 while the HTTP tier on 3000 comes up fine. The unit in [Installation](installation.html) uses `AmbientCapabilities`, which is granted to the service rather than the file and is therefore unaffected. If you inherited a `setcap` setup, this is a good moment to switch. ## After any upgrade - [ ] Send a message out, check for `dkim=pass` - [ ] Receive one from outside - [ ] Connect a client over IMAP - [ ] `bun scripts/migrate.ts diff` reports in sync - [ ] The queue is draining - [ ] Nothing new in `journalctl -u corsair --since "10 minutes ago" | grep -i error` Then delete the pre-upgrade dump — or keep it for a week, which is cheaper than regretting it. --- # Scaling and performance Source: https://wess.io/corsair/docs/scaling.html # Scaling and performance Corsair is one process against one database. That takes you further than people expect, and the things that break first are rarely the ones people plan for. ## Where the limits are | Limit | Reached at roughly | What happens | | --- | --- | --- | | Postgres connections | `DB_POOL_SIZE` concurrent queries | Requests queue | | Worker throughput | `WORKER_CONCURRENCY` deliveries at once | The queue grows | | Disk, without a bucket | Mail volume through the WAL | Writes slow, then stop | | IMAP sessions | Memory, one snapshot per selected folder | RSS climbs | | Receiver rate limits | Their policy, not yours | Deferrals | The last one is the real ceiling for outbound volume, and no amount of hardware moves it. ## Sizing | Mailboxes | vCPU | RAM | Disk (metadata only) | | --- | --- | --- | --- | | 1–10 | 1 | 1 GB | 10 GB | | 10–100 | 2 | 2 GB | 25 GB | | 100–1000 | 4 | 8 GB | 100 GB | With `STORAGE_BUCKET` set, bodies are in object storage and the disk figure is metadata only — a few kilobytes per message. Without a bucket, add the full size of every message you keep, and expect the WAL to carry all of it. ## Configure the bucket The single highest-value change for anything beyond a personal server. ```sh STORAGE_BUCKET=my-mail-bucket STORAGE_ENDPOINT=https://nyc3.digitaloceanspaces.com STORAGE_REGION=nyc3 STORAGE_ACCESS_KEY_ID=... STORAGE_SECRET_ACCESS_KEY=... ``` Metadata — folder, UID, flags, envelope, size, a searchable text extract — stays in Postgres. Bodies go to the bucket. That split is what makes IMAP fast. `SELECT`, `FETCH FLAGS`, `SEARCH`, and `SORT` are what a client runs constantly, and none of them need a body. Only `FETCH BODY[…]` does, and then exactly one object is read. It also takes mail volume out of the WAL, which is what actually kills a busy inline install. ## The database **Raise the pool** before anything else. The default of 10 is conservative: ```sh DB_POOL_SIZE=25 ``` Keep `DB_POOL_SIZE` × instances comfortably under Postgres's `max_connections`. Above a few dozen, put PgBouncer in transaction mode between them. **Watch for slow queries.** ```sql SELECT calls, mean_exec_time, left(query, 90) FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10; ``` **Autovacuum matters here.** `messages` churns — flags update on every read, rows are tombstoned on expunge. Bloat shows up as IMAP getting slower for no visible reason. ```sql SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables WHERE n_dead_tup > 10000 ORDER BY n_dead_tup DESC; ``` For a busy `messages` table, make autovacuum more aggressive on it specifically: ```sql ALTER TABLE messages SET (autovacuum_vacuum_scale_factor = 0.05); ``` ## The worker ```sh WORKER_CONCURRENCY=16 WORKER_POLL_MS=500 ``` Raise concurrency before adding processes. The queue claims work with `FOR UPDATE SKIP LOCKED`, so any number of workers can drain it without coordinating and without delivering anything twice — but each one is another set of database connections. Lower `WORKER_POLL_MS` only if queue latency matters to you; it is a poll against Postgres, and 100 ms costs ten times what 1000 ms costs for a benefit nobody perceives in email. warning Concurrency does not beat a receiver's rate limit Sixteen parallel connections to one provider gets you `421 4.7.0` faster than one does. The limits that matter for bulk outbound are theirs. ## Splitting the tiers ```sh bun src/server.ts # HTTP only SMTP_ENABLED=true IMAP_ENABLED=false bun src/start.ts # mail only ``` Worth doing when: - **Deploys are disrupting mail clients.** Restart HTTP without dropping IMAP. - **One tier needs different hardware.** HTTP behind a load balancer; the mail listeners on the host that owns the reputable IP. - **The worker needs its own capacity.** Run several; they cannot double-deliver. ## Running more than one mail host Publish two MX records at equal priority, both pointing at Corsair instances sharing one database. ``` example.com. MX 10 mx1.example.com. example.com. MX 10 mx2.example.com. ``` Senders pick one and retry the other on failure. Inbound distributes itself, and you can restart one at a time without losing anything. Two things to get right: - **Every host needs its own PTR** matching its own `CORSAIR_HOSTNAME`. - **SPF must list both.** They send as well as receive. The database stays single-writer. That is the actual scaling ceiling, and it is a long way up. ## Measuring Before optimising, find out which resource is the constraint. ```sh # Is it the database? psql "$DATABASE_URL" -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state" # Is it the queue? psql "$DATABASE_URL" -c "SELECT status, count(*) FROM deliveries GROUP BY status" # Is it the host? top -b -n1 | head -15 iostat -x 1 3 ``` A growing queue with an idle CPU is not a capacity problem — it is a receiver deferring you, and the fix is reputation, not hardware. ## What is genuinely expensive **IMAP SEARCH without an index hit.** A client searching a hundred-thousand-message folder for a body substring reads bodies. `search_text` is maintained alongside each row precisely so the common cases do not. **FETCH of large messages.** One object per message, so a client downloading a whole mailbox is bounded by the bucket's throughput, not by Corsair. **Sub-addressing and catch-all resolution** run per recipient on the inbound path, but they are indexed lookups. This has never been the bottleneck. **The spam scorer** reads the raw message once. It is heuristic and cheap by construction — deliberately, since it runs on every inbound message. ## Retention The worker sweeps expired tombstones, logs, bans, and sessions. If `mail_log` is growing without bound and you do not need the history, prune it — it is what the Overview charts and the daily limits are counted from, so keep at least a few days. ```sql DELETE FROM mail_log WHERE created_at < now() - interval '90 days'; ``` Do it in batches on a large table, or the lock is noticeable. ## When you have outgrown this Corsair is one Postgres away from being a much bigger system, and the honest answer is that at genuinely large scale you want software designed for shards from the start. The point at which that becomes true is far past where most people self-hosting mail will ever get — thousands of mailboxes on one machine is comfortable. --- # Troubleshooting Source: https://wess.io/corsair/docs/troubleshooting.html # 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 ```sh 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?** ```sh dig +short MX example.com ``` It 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?** ```sh # From another machine nc -zv mail.example.com 25 ``` Test from off the host. A firewall or security group blocking inbound 25 is invisible from inside. **3. Is the listener up?** ```sh sudo ss -lntp | grep ':25 ' ``` Nothing there means `SMTP_ENABLED=false`, or the process could not bind the port. Check the log for `EACCES`: ```sh journalctl -u corsair -n 40 | grep -A3 EACCES ``` `errno: 13` on port 25 while port 3000 came up fine is a capability problem. The unit needs: ```ini AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE ``` **A `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.** ```sh journalctl -u corsair -f ``` Then 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. ```sql 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?** ```sh grep DELIVERY_TRANSPORT .env ``` The default is `console` — it prints to stdout and delivers nothing. Right for a laptop, wrong for a server. **3. Is port 25 open outbound?** ```sh nc -zv gmail-smtp-in.l.google.com 25 ``` Hanging 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: ```sql 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](smtp-errors.html) has the full table. ## `dkim=fail` at the receiver ```sh dig +short CNAME corsair-1._domainkey.example.com ``` **Nothing 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-1 ``` `header.s` is the selector. That is the CNAME that must exist. ## `spf=fail` ```sh dig +short TXT example.com | grep spf1 ``` **Includes 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](tls.html). **"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:** ```sh openssl s_client -connect mail.example.com:993 -servername mail.example.com &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. ```sh openssl s_client -connect mail.example.com:993 /dev/null \ | openssl x509 -noout -enddate ``` Check the **port**, not the file on disk. Add a certbot deploy hook that restarts the service; [TLS](tls.html) has one. ## The queue is growing ```sql 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 ```sql 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?** ```sql 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=spf1` records - 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 ```sh 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 ```sh NODE_ENV=development bun src/start.ts ``` Development 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: ```sh openssl s_client -starttls smtp -connect mail.example.com:587 -crlf ``` Every reply, in order, with no client in the way. ## Still stuck Collect these before asking: ```sh 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 -20 ``` Then open an issue at [github.com/wess/corsair/issues](https://github.com/wess/corsair/issues). Redact the addresses; keep the reply codes, which are the useful part. --- # Security model Source: https://wess.io/corsair/docs/security.html # Security model What Corsair protects, how, and where the boundary is. Read the last section too — the parts that are your responsibility are not small. ## Two identities, one password where they are one person - A **user** is a control-panel login. It owns domains, plans, and billing. - An **address** is a mailbox. It owns messages. What they own never merges. The **password** does, in exactly one case: a mailbox that is its own owner's account. The address stores no hash of its own and every protocol verifies against the account's, so there is one credential rather than two for the same person. **A mailbox is linked to an account only when that account already owns the domain.** This is the whole security of the arrangement. Without it, anyone could register a control-panel account as `ceo@some-company.com` before that company added its domain, and the mailbox would authenticate against the squatter's password the moment it was created. The condition means the only account that can ever be linked is one that could read the mail anyway. A mailbox with no linked account keeps its own password and has **no panel login at all**. That is most mailboxes on a family or team domain, and merging them into the owner's account would hand each of them the domains. A terminated account cannot authenticate anywhere, including to a mailbox linked to it. warning What a shared password costs you A mailbox password is transmitted on every IMAP poll and stored in phones, laptops, and printers. Where it is also the account password, whoever recovers it from a lost device has the panel too. **Put two-factor authentication in front of the panel.** Mail protocols cannot present a second factor, so a stolen mailbox password on its own stops at the mailbox — which is the outcome you want. This is the mitigation that makes a shared credential a reasonable trade rather than a bad one. ## Passwords and tokens - Passwords are hashed, never stored or logged in the clear. - Reset and recovery tokens are stored **only as SHA-256 hashes**. - A token is redeemed with `used_at IS NULL` **inside the UPDATE**. Checking it in a separate read lets two concurrent requests both redeem the same link. - Sessions are rows, not just JWTs — the panel's *and* the webmail's. The signature alone is not enough: a token must name a live session, so a revoked one stops working immediately, logging out really logs out, and a token signed with `JWT_SECRET` but naming no session is worthless. Two-factor is TOTP. Turn it on for the owner account; it controls every domain. ## Never stored Three credentials pass through this server and are deliberately not persisted. Do not "fix" any of them by adding a column. **DNS API tokens.** Used for one publish, then discarded. One can usually rewrite every record on every domain in the account — holding it to save a paste is a bad trade. **Card details.** They never touch the 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. **Transfer source passwords.** Encrypted at rest and erased the moment the transfer reaches a terminal state, success or failure. They are someone else's credential. ## Transport Corsair refuses SMTP `AUTH` and IMAP `LOGIN` on an unencrypted connection when a certificate is configured — it advertises `LOGINDISABLED` so the client fails at connect rather than after sending the password. Port 25 is the deliberate exception: STARTTLS is offered but never required, because a sending server that does not support it still has legitimate mail, and refusing means losing that mail. ## Enumeration Several endpoints are deliberately unhelpful: - `/api/auth/password/forgot` and `/api/recover/request` answer identically whether or not the address exists. - Login gives **one** reply for both an unknown address and a wrong password. Making any of them more informative turns it into a way to enumerate accounts. There are smoke tests asserting the replies are byte-identical — they are not decoration. ## Server-side request forgery A webhook URL is attacker-supplied and this server fetches it. That is an SSRF primitive, so endpoints on private, loopback, and link-local addresses are refused at creation. `WEBHOOK_ALLOW_PRIVATE=true` exists for an operator whose consumers are on the same private network. It is off by default because the safe case is the rarer one. Turning it on means a customer can point a webhook at your metadata service. ## Content sanitisation **Rendered mail is sanitised on the server**, never in the client (`src/sanitize`). Every message a mail server accepts is attacker-supplied by definition. The policy is an **allow-list** — unknown tags and attributes are dropped rather than inspected — because a deny-list has to stay complete as browsers change. Scripts, event handlers, and `javascript:` URLs are removed before the browser is handed anything. Remote images are withheld by default. A remote image in an email is a tracking pixel; the reader asks for them or does not get them. ## Browser headers Sanitisation is the first layer. The Content Security Policy is the second, and it exists because the first one will eventually be wrong about something. Every response — the JSON API, the panel, and the webmail — carries: | Header | Value | | --- | --- | | `Content-Security-Policy` | `default-src 'self'`, `script-src 'self'`, `object-src 'none'`, `frame-ancestors 'none'` | | `X-Frame-Options` | `DENY` | | `Cross-Origin-Opener-Policy` | `same-origin` | | `Permissions-Policy` | camera, microphone, geolocation and cohort tracking all denied | | `Strict-Transport-Security` | one year, including subdomains | | `X-Content-Type-Options` | `nosniff` | `script-src 'self'` is an honest claim rather than an aspiration: the panel and the webmail are bundled with no inline script and no `eval`, and there is a test asserting it stays that way. A policy with `'unsafe-inline'` in it protects nothing. `frame-ancestors 'none'` is what stops the webmail being framed by a page that overlays its own buttons on yours. note Serving Corsair yourself These come from the application, not from your reverse proxy, so they survive a proxy misconfiguration. If you add your own, do not weaken these — a second `Content-Security-Policy` header does not replace the first, but a proxy that strips and rewrites one does. ## Header injection A bare CR or LF in a subject, display name, or custom header injects a header. `stripControls` runs on every value this codebase emits, and there are regression tests for it. If you are extending Corsair, anything you write into a header goes through it. ## Rate limiting and bans - **10 requests/second** per principal by default (`RATE_LIMIT_PER_SECOND`). - **5/second per IP** on sign-in and sign-up, where there is no principal yet. - Authentication failures are recorded, and repeated failures produce a ban. `TRUSTED_PROXIES` must be set when anything sits in front of the HTTP port. Without it every request appears to come from the proxy: the limiter sees one client, and a ban bans your own proxy. ## Outbound abuse Submission proves the From address belongs to the caller before signing anything. An authenticated user cannot send as a domain they do not own — which is the difference between a mail server and an open relay. Forwarded mail is SRS-rewritten. The HMAC in the rewritten address is not optional: without it, the rewritten address is an open relay for anyone who can construct one. Daily send limits come from the plan and are counted from `mail_log`. ## Multi-tenancy Every query for a user's resources carries the user id in the `WHERE` clause; a row that does not belong to the caller is a 404, not a 403 — the distinction leaks existence. Plan gating raises a **402** so the panel can render an upgrade prompt. Validation runs before the quota check: a malformed input is invalid regardless of the plan. ## Instance ownership The first account created owns the instance. The claim is made inside the INSERT with `NOT EXISTS (SELECT 1 FROM users)` and guarded by a partial unique index, so two simultaneous signups cannot both win. Signup catches the duplicate-key error on *that constraint specifically* and retries as a non-owner. Set `SIGNUPS=closed` on a personal server so only the first account can ever be created. ## What is yours Corsair cannot help with any of this: **The host.** SSH keys only, a firewall allowing just the mail ports and 443, and patches applied. A compromised host means compromised mail regardless of anything in the application. **`.env`.** It holds `JWT_SECRET`, the database password, and the storage keys. Mode 600, owned by the service user, never committed. **Database backups.** They contain DKIM private keys, password hashes, and encrypted transfer credentials. Encrypt them, and never put one in a bucket whose default is public. **The bucket.** Objects are written with no ACL and inherit the bucket default. Verify that default is private, especially on a bucket you already use. **`JWT_SECRET`.** It signs sessions, keys the SRS address rewriting that keeps forwarding from being an open relay, and encrypts stored transfer credentials, so keep it secret. A session also needs a live server-side row, so the secret alone cannot mint one. Generate it randomly; changing it invalidates every session, which is also how you revoke everything at once. **Physical access to mail at rest.** Message bodies are not encrypted at rest by Corsair. Use encrypted volumes and an encrypted bucket if your threat model needs it — and understand that a server which can serve IMAP can necessarily read the mail. ## What Corsair does not claim **Not end-to-end encrypted.** The server reads message content — it has to, to index, filter, and search. Use PGP or S/MIME in the client if you need content the server cannot read. **Not a spam-filtering product.** The scorer is heuristic and deliberately conservative: a false positive on real mail is far worse than a false negative. For aggressive filtering, put a dedicated filter in front. **Not hardened against a hostile operator.** An operator can read every mailbox on the instance. That is inherent to running a mail server, and it is why you should run your own rather than trusting someone else's. ## Reporting a vulnerability Do not open a public issue. Corsair serves a `security.txt` at `/.well-known/security.txt` with the current contact. Include what you did, what happened, and what you expected. --- # The control panel Source: https://wess.io/corsair/docs/control-panel.html # The control panel The panel is at `/app`. Sign in with a **user** account — the control-panel identity, not a mailbox password. If you are trying to read mail, you want [the webmail](webmail.html) at `/webmail` instead. ## Overview The landing screen. Storage against the plan, messages in and out over time, the delivery queue, and any domain that is not fully set up. The two numbers worth glancing at daily: - **Queue depth.** A spike after a burst of sending is normal. Depth that keeps growing is not — see [Monitoring](monitoring.html). - **Storage.** Plans cap storage per account, not per mailbox. Approaching the cap fires a `quota.warning` webhook before it fires a rejection. ## Domains One row per domain, with its status. A domain is **pending** until its required records resolve, then **active**. Until it is active, Corsair accepts mail for the domain but refuses to send from it. That is enforced rather than advised: sending before SPF and DKIM are published damages the sending IP's reputation for every other domain on the server. Opening a domain gives you four tabs. ### DNS Setup Every record, with its current status and — when one does not match — what was actually observed. That observation is usually enough to spot the problem: a provider that appended the domain to a host that was already fully qualified, a quoted value stored with the quotes, an SPF record with two `v=spf1` entries. Three ways to publish: | | | | --- | --- | | **Publish automatically** | Corsair detects your provider from the NS records and writes them itself. Cloudflare and DigitalOcean today | | **Copy each record** | A copy button per record | | **Export zone file** | For a provider that imports one | The API token you paste into the automatic option is used for one publish and then discarded. It is never stored, because a DNS token can usually rewrite every record on every domain in the account. **Check DNS** re-runs the check now. The worker also re-checks pending domains every half hour, because people publish records and never come back to press the button. ### Addresses Every address on the domain, and where to create more. See [Addresses](addresses.html) for the four kinds. ### Keys The three DKIM key pairs, with one marked active. Activating a different one switches signing to that selector — see [Domains](domains.html) for how to rotate without a gap. ### Settings The catch-all, the fallback domain, and self-service recovery. ## Addresses A flat list across every domain, which is the view you want when you are looking for one address rather than administering a domain. Opening an address gives you its folders with message counts, its recent activity, and the actions: change password, set a recovery address, attach a filter, delete. note Deleting an address deletes its mail There is no trash for this. Back up first if it matters. ## Filters Sieve scripts. A filter belongs to the **account** and can be attached to any number of mailboxes, so a "file newsletters" rule is written once and used everywhere. The editor validates on save — a script that does not compile is never stored, so you find out immediately rather than at delivery time. A script that throws at delivery time is treated as absent: the message goes to the inbox and the error is shown against the filter. A broken filter never loses mail. [Filters](filters.html) is the reference; the [cookbook](tutorials/filter-cookbook.html) has working examples. ## Transfers Mailbox migration from another host over IMAP. Create the destination address first — a transfer copies *into* an existing address. Each row shows progress per folder while it runs. The source password is encrypted at rest and erased the moment the transfer reaches a terminal state. [Transfers](transfers.html), and [migrating from Google](tutorials/migrate-from-google.html) end to end. ## Webhooks Endpoints, their subscribed events, and their health. The signing secret is shown **once**, at creation, and as a prefix thereafter. It is a credential; an endpoint that has lost it should rotate rather than read it back. **Send test** delivers a signed sample synchronously and shows the exact status and body that came back — far more useful than watching a queue. **Events** lists every delivery with its status, attempt count, and next attempt. Open one to see the payload and replay it. An endpoint that fails twenty times in a row is disabled automatically, and this screen says so. Nothing else will tell you. [Event hooks](webhooks.html), and [building a consumer](tutorials/webhook-consumer.html). ## Billing Plan, subscription, payment methods, transactions, and tax ID. With `STRIPE_SECRET_KEY` unset, 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. With it set, checkout is hosted by the provider. Card details never reach this server. [Plans and billing](plans-billing.html). ## Account Your control-panel identity: email, password, notification preferences, two-factor, active sessions, and referrals. **Two-factor** is TOTP. Turn it on — this account controls every domain. **Sessions** lists everywhere you are signed in, with a button to revoke all the others. Use it after losing a device. ## What the panel will not do **Read mail.** That is [the webmail](webmail.html), signed in with a mailbox credential. The separation is deliberate. **Change server configuration.** Everything is environment variables read at startup. A mail server's behaviour should be reproducible from its deployment, not from a row someone edited. See [Configuration](configuration.html). **Run migrations.** `bun scripts/migrate.ts up`, deliberately outside the serving process. --- # Domains Source: https://wess.io/corsair/docs/domains.html # Domains A domain is the unit of routing and the unit of proof. Corsair accepts mail for domains it hosts and refuses everything else, and it will not send from a domain until that domain's authentication records exist. ## Adding one **Domains → New domain.** Corsair normalises what you type — case, a trailing dot, an accidental `https://` — and then generates: - a **verification token**, unique to the domain - **three DKIM key pairs**, with the first active - the full **record set** to publish Nothing routes yet. The domain is **pending** until its required records resolve. ## Pending versus active | State | Receives mail | Sends mail | | --- | --- | --- | | Pending | Yes | No | | Active | Yes | Yes | Accepting before verification is deliberate — you may be migrating and want mail arriving before you have finished DNS. Refusing to *send* is also deliberate, and firmer: sending from a domain whose SPF and DKIM are not published damages the sending IP's reputation for every other domain on the server. ## Verifying Press **Check DNS**. The worker also re-checks pending domains every half hour on its own, because people publish records and never come back to press the button. When a record does not match, the panel shows what it actually observed. The recurring causes: - The provider appended the domain to a host that was already fully qualified, giving `_dmarc.example.com.example.com` - A quoted value stored **with** its quotes - Two `v=spf1` records, which is a permanent error that stops SPF working entirely - The record is correct but has not propagated — a record can take up to the previous record's TTL to become visible [DNS setup](dns-setup.html) covers every record and what breaks without it. ## Publishing automatically If the domain's DNS is at Cloudflare or DigitalOcean, Corsair detects the provider from the NS records and can write all ten records itself. Paste an API token and press publish. note The token is used once and discarded It is never stored. A DNS API token can usually rewrite every record on every domain in the account, and holding one to save a paste is a bad trade. If you need to publish again later, you paste it again. Scope the token as tightly as the provider allows — Cloudflare's **Zone → DNS → Edit**, restricted to the one zone. Manual setup, the live checker, and a zone-file export are always available too. ## DKIM keys and rotation Three key pairs are created per domain. Only the first has to be published; the others exist so a rotation is a flag flip rather than a support ticket. They are published as **CNAMEs**, not TXT records. A 2048-bit key does not fit in a single TXT string, half the DNS control panels in the world mangle the chunked form, and a CNAME lets the key rotate on the server without you touching DNS again. ### Rotating without a gap 1. **Publish the CNAME for the next selector** — `corsair-2._domainkey` — and wait for it to resolve. Signing has not changed; you are only making the key available. 2. **Activate it** in Domains → Keys. New mail signs with selector 2. 3. **Leave selector 1 published** for at least a week. Mail already in flight was signed with it, and a receiver that verifies late needs the record to still be there. 4. Remove selector 1's CNAME once nothing signed with it can plausibly still be in transit. Rotating in the other order — activating before the record resolves — means every message signs with a key nobody can verify, which is worse than not signing at all. ## Catch-all A catch-all is a **mailbox** that also receives anything in the domain that matched nothing else. Set it in Domains → Settings. It is the last step in recipient resolution, after an exact match and after sub-addressing. So adding a real address later always takes precedence, and nothing needs rewiring. warning A catch-all attracts spam Once a domain is known to accept everything, dictionary attacks file into it. If the volume gets bad, drop it — [sub-addressing](addresses.html) gives you `you+anything@` with no setup and no catch-all. ## Fallback domains A fallback sends unmatched recipients on to another domain, followed **exactly once**. Following it twice is how you build a loop, so Corsair does not. Use it when consolidating: `oldcompany.com` falls back to `newcompany.com`, and `sam@oldcompany.com` reaches `sam@newcompany.com` without you recreating every address. This is a plan feature (`fallback_domains`). On an unmetered instance — no plans configured — everything is on. ## Recipient resolution, in order For any address at a hosted domain: 1. An exact address match 2. Sub-addressing — `user+tag@` routes to `user@` 3. The domain's catch-all 4. The domain's fallback domain, once No match is a rejection 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. ## Self-service recovery Enable it per domain and set a **recovery address** per mailbox, and the mailbox owner can reset their own password at `/recover` without going through you. The reset link goes to the recovery address, never to the mailbox itself — which would be useless to someone locked out of it. The request endpoint answers identically whether or not the address exists. Making it more helpful turns it into a way to enumerate your mailboxes. A plan feature (`self_service`). ## Removing a domain Deleting a domain deletes its addresses, and deleting an address deletes its mail. There is no trash. Before you do: 1. **Back up.** [Backups](backups.html). 2. **Remove the MX first** and let the TTL expire, so senders stop trying to deliver here before the domain stops existing. 3. **Check for aliases pointing at it** from other domains. If you only want to stop *sending* from a domain, remove its SPF include instead. Deleting is for a domain you are finished with. ## Several domains, one account There is no limit beyond the plan's `max_domains`, and each has its own DKIM keys, catch-all, and addresses. They share the account's storage quota, daily limits, and filters — a filter belongs to the account and can be attached to mailboxes on any domain. The same server sends for all of them, which is why one domain's bad sending behaviour affects the others. That is the reason the pending/active distinction is enforced rather than advisory. --- # Addresses Source: https://wess.io/corsair/docs/addresses.html # Addresses An address is a mailbox credential or a routing entry. Which one it is depends on its kind, and the difference decides whether there is anything to sign into. ## The 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. ### Standard A person's mailbox. Created with a password, provisioned with six folders, and reachable over IMAP, POP3, JMAP, and the webmail. ### Alias `hello@example.com` → `sam@example.com`. One destination. Use an alias for a role rather than a second mailbox: nobody has to check it, it cannot be compromised, and repointing it later is one edit. An alias can forward **outside** the domain. When it does, Corsair rewrites the envelope sender with SRS — the original sender's SPF does not list your server, so without the rewrite the next hop sees a forgery. This is automatic, and so is the return trip: a bounce for forwarded mail comes back to the rewritten address, Corsair checks its signature and age (21 days), and routes it to the original sender. One that was not signed by this server, or is too old, is refused as an address that does not exist — which is what stops the rewritten form being an open relay. ### Group `family@example.com` → several mailboxes. One message in, one copy to each destination. Same SRS handling for outside destinations. No password, so to *send* as the group you sign in as a real mailbox and set the From address in the client. ### Catch-all A mailbox that also receives anything in the domain matching nothing else. One per domain, set in the domain's settings rather than on the address. ## Sub-addressing `you+anything@example.com` arrives in `you@example.com`. No configuration, no setup, and it works for every standard mailbox immediately. This is the most useful thing about running your own mail, and worth explaining to everyone who has a mailbox: > Give a different tag to every service. If `you+shoes@example.com` starts getting > spam, you know exactly who sold your address — and a one-line filter bins it > without affecting anything else. File by tag: ``` require ["fileinto", "envelope"]; if envelope :localpart :matches "to" "*+receipts" { fileinto :create "Receipts"; } ``` Match on the **envelope**, not the `To:` header. The header is whatever the sender typed; the envelope is what the server was actually asked to deliver to. note Some sites reject `+` in an address It is legal in an email address and always has been. When a form refuses it, use a dedicated alias instead — that is what aliases are for. ## Resolution order For any address at a hosted domain, in order: 1. An exact match 2. Sub-addressing — `user+tag@` → `user@` 3. The domain's catch-all 4. The domain's fallback domain, followed exactly once First match wins, so adding a real address always takes precedence over the catch-all that was covering it. No match is a **rejection at SMTP time** with a 550, not an accept-then-bounce. A bounce to a forged sender is backscatter. The one case that cannot be answered at SMTP time is a message to several recipients where one fails *after* the data is accepted (over quota, or its filter rejects). That becomes a bounce, and it is sent only if the sender's SPF passed or the message has a verified DKIM signature. Otherwise the failure is logged and nothing is sent, because the sender may be forged. ## Folders Every mailbox is provisioned with six, each tagged with its IMAP special-use attribute so clients file things correctly without being configured: | Folder | Special use | | --- | --- | | `INBOX` | `inbox` | | `Drafts` | `drafts` | | `Sent` | `sent` | | `Junk` | `junk` | | `Trash` | `trash` | | `Archive` | `archive` | Clients can create more. Hierarchy uses `/` as the delimiter, so `Projects/Corsair` is a child of `Projects`. A message scored at or above the junk threshold is filed in `Junk` rather than rejected. The scorer is deliberately conservative — a false positive on real mail is far worse than a false negative. ## Passwords The mailbox password is **not** the control-panel password. Two separate identities, deliberately: - A **user** signs into the panel and owns domains. - An **address** signs into mail clients and owns messages. A mailbox credential ends up in a phone, a laptop, and a printer. One of those will eventually be lost, and when it is, it must not also unlock the account that owns every domain. Change one in the panel: **Addresses → the address → Change password**. Every client using it will need updating; there is no way around that. ## Recovery With self-service recovery enabled on the domain, set a **recovery address** per mailbox — a personal account elsewhere, another mailbox on the domain, anywhere the person can actually read. They then reset their own password at `/recover`. The link goes to the recovery address, never to the mailbox itself, which would be useless to someone locked out of it. The request endpoint answers identically whether or not the address exists. ## Activity Each address has an activity view: what arrived, what was sent, what was rejected and why. This is the first place to look when someone says a message never arrived — it usually shows the rejection with its reason. ## Deleting Deleting an address deletes its messages. There is no trash for this. If someone is leaving and their mail should keep flowing to a successor, **convert the address to an alias** pointing at the successor rather than deleting it. Mail keeps arriving, nothing bounces, and the person's old correspondents are not generating failures. ## Limits Plans cap `max_addresses` per account. An unmetered instance — no plans configured — has no cap. Storage is per **account**, not per mailbox, matching how the quota is actually enforced. One mailbox can use all of it. --- # Client settings Source: https://wess.io/corsair/docs/client-setup.html # Client settings The settings, then the clients that need help. ## The settings | Protocol | Port | Security | Authentication | | --- | --- | --- | --- | | IMAP | **993** | SSL/TLS | Normal password | | POP3 | **995** | SSL/TLS | Normal password | | SMTP | **465** | SSL/TLS (implicit) | Normal password | Port **587 with STARTTLS** also works when the operator runs the STARTTLS terminator described in [Configuration](configuration.html). 465 is listed above because it works on every install and needs no upgrade to get wrong. tip Let your client configure itself. Autoconfig and autodiscover name the ports this particular server offers, which is the one answer that is right on every install. By hand, 993, 995, and 465 always work. note Why 587 depends on the operator. Authenticating on 587 requires the connection to become encrypted, and that upgrade cannot be performed by every runtime — Bun cannot upgrade a socket it accepted. Corsair **tests this at startup** rather than assuming it, and never advertises what it cannot perform: advertising and then failing loses mail outright, because a peer that has committed to the upgrade cannot fall back. Where the terminator is not deployed and the runtime cannot upgrade, **587, 143, and 110 cannot authenticate** — Corsair refuses a credential on an unencrypted connection, and those ports have no way to become encrypted. Port 25 is unaffected either way. It accepts mail from other servers, encrypted where STARTTLS is available and in plaintext where it is not, which is what senders fall back to. **Hostname** is whatever the operator set — `MAIL_IMAP_HOST`, `MAIL_SMTP_HOST`, and so on. On most installs they are all the same name. The panel's Client Configuration tab shows the exact values for your server. **Username is always the full email address**, not the part before the `@`. A bare local part would be ambiguous across the domains on the server. **Password** — for your own address, this is your **account password**, the same one you use for the panel. Corsair links a mailbox to the account that owns the domain when the addresses match, so there is one password, not two. For a mailbox that is not a control-panel account — anyone else on your domain — it is that mailbox's own password, which opens mail and never the panel. 993 and 465 are encrypted from the first byte. There is no upgrade to negotiate and none to get wrong, which is why they are the better ports even on a server where STARTTLS works. ## Automatic configuration If the operator published the `autoconfig` and `autodiscover` CNAMEs, Thunderbird and Outlook find all of this from the email address alone. | Endpoint | Used by | | --- | --- | | `/mail/config-v1.1.xml` | Thunderbird | | `/autodiscover/autodiscover.xml` | Outlook | Try the address first. Fall back to manual only when it does not take. ## Apple Mail and iOS Choose **Other Mail Account**, not any of the branded options. Apple Mail sometimes offers to configure automatically and gets the outgoing port wrong. If receiving works but sending fails, set the outgoing server explicitly: **port 465, SSL/TLS**, and **authentication on** — Apple defaults it off often enough to be worth checking, and it will otherwise try 587, which cannot authenticate here. On iOS: **Settings → Mail → Accounts → Add Account → Other → Add Mail Account.** Enter the address and password, then correct the hostnames on the next screen. ## Thunderbird Works from the address alone if `autoconfig` is published. Otherwise **Configure manually** and enter the table above. Thunderbird's "Re-test" button is honest — if it cannot find a working combination, the settings really are wrong. ## Outlook Modern Outlook resists non-Microsoft IMAP accounts and will try to convert you to a Microsoft-hosted account. **File → Add Account → Advanced options → Let me set up my account manually → IMAP.** If autodiscover does not take, enter the settings by hand. Outlook on the web cannot connect to third-party IMAP at all. That is a Microsoft limitation. ## Gmail app and Gmail on the web Under **Add account → Other**. Gmail will fetch over IMAP happily, but it sends through Google's servers unless you configure the SMTP settings too — which it asks for separately, and which is easy to skip. Mail sent without doing so is **not DKIM-signed by your domain** and will fail your own DMARC policy. Configure both halves or neither. ## Mutt, aerc, and friends ``` # ~/.muttrc set imap_user = "you@example.com" set imap_pass = "your-mailbox-password" set folder = "imaps://mail.example.com:993" set spoolfile = "+INBOX" set record = "+Sent" set postponed = "+Drafts" set trash = "+Trash" set smtp_url = "smtps://you@example.com@mail.example.com:465" set smtp_pass = "your-mailbox-password" set from = "you@example.com" ``` Corsair advertises `IDLE`, so `set imap_idle = yes` works. ## Any JMAP client Session resource at `https://mail.example.com/.well-known/jmap`, authenticated with HTTP Basic using the address and mailbox password. See [JMAP](jmap.html). ## Roundcube, SnappyMail, and other webmail The IMAP and SMTP servers are standard, so any of them work unchanged. Point them at **993 and 465** with the full address as the username. Corsair also ships [its own webmail](webmail.html) at `/webmail` if you would rather not run another thing. ## Alias and group addresses They have **no password** and cannot be signed into. They are routing entries: mail addressed to them is forwarded. To *send* as an alias, sign in as a real mailbox on the same account and set the From address in your client. Most clients call this an "identity" or "send mail as". ## When a client will not connect | What it says | What it means | | --- | --- | | "Certificate not trusted" | Self-signed, or the chain is incomplete. The server needs `fullchain.pem`, not the leaf | | "Name does not match" | The certificate is for a different hostname than you dialled | | "Server does not support authentication" | You are on 587, 143, or 110 and this server has no STARTTLS, so it will not accept a password in the clear. Use 993, 995, or 465 | | "Wrong password" | Mailbox password, not the panel password. Or the address is an alias, which has none | | "Cannot find server" | Check the hostname against the panel's Client Configuration tab | Verify from the command line, which removes the client from the equation: ```sh openssl s_client -connect mail.example.com:993 -servername mail.example.com a LOGIN you@example.com your-password a LIST "" "*" a LOGOUT ``` ## POP3, and why you probably do not want it POP3 downloads and, by default, deletes. It has no folders, no flags shared between devices, and no concept of a second client. It exists for the clients that still want it, and for a device that genuinely should drain a mailbox to local storage. For anything else, use IMAP. If you must: **995 with SSL/TLS** (not 110, which cannot authenticate), full address as the username, and turn **off** "delete from server" unless removal is the point. --- # Webmail Source: https://wess.io/corsair/docs/webmail.html # Webmail Corsair ships a three-pane mail client at `/webmail`. Folders, message list, reading pane. ## Signing in With the **mailbox** address and password — not a control-panel login. That is the same credential a mail client uses, and it is a deliberately different identity from the panel: a mailbox credential ends up in a phone that gets lost, and it must not also unlock the account that owns every domain. The webmail session is its own cookie (`corsair_webmail`) with a **12-hour** lifetime, shorter than the panel's fourteen days, because a browser session on a shared machine is far more likely to be left open. Every webmail session is also a row on the server, so it can be ended before it expires. Logging out revokes it (a copied cookie dies with the logout), changing the mailbox's password ends every *other* session, and disabling or deleting the address ends them all. A mailbox that signs in with its owner's account password loses its sessions when that password changes. Alias and group addresses have no password and cannot sign in. They are routing entries. ## What it does - Read, reply, reply-all, forward - Compose with attachments - Move, delete, mark read or unread, flag - Create and delete folders - Search - Drafts, saved server-side so they follow you between devices Sending goes through the same submission path as any client: the From address is proven to belong to the caller, the message is DKIM-signed with the domain's key, a copy is filed in Sent, and delivery is queued. ## Sanitisation Message bodies are sanitised on the **server**, in `src/sanitize`, never in the browser. Every message a mail server accepts is attacker-supplied by definition, and the browser is the wrong place to decide what is safe. Removed before the browser sees anything: - `