A webmail client optimized for self-hosted email across multiple domains.
Try the demo · mainly.crnst8.com
Docker is required for this application
git clone https://github.com/crnst8/mainly && cd mainly
./mainly.sh start
./mainly.sh user you@yourdomain.com # client login userOpen http://localhost:5274 and sign in with the password the second command printed, then add your mailboxes from the account screen.
mainly.sh start generates its own secrets into .env, pulls the image, brings
up Postgres, runs migrations and waits until the app answers. Re-running it is
safe.
cp .env.example .env
# fill in SECRET_KEY, SESSION_SECRET and POSTGRES_PASSWORD — the file says how
docker compose up -d
docker compose exec app node dist/cli/create-user.js you@yourdomain.comTo build from source instead of pulling the published image, add --build.
This is a webmail client specifically built for those that self-host multiple email domains.
- puts everything in one list, with the owning address always legible
- gives every domain a colour, and lets you change all of them
- ranks accounts by priority tier, so critical mail outranks a newsletter
- makes sort, grouping, density and the row contents yours to set
- is keyboard-first, with
⌘Kfor anything you have not memorised
It does not run a mail server, and it never changes one
The backend mirrors message metadata into Postgres and serves every read from that index. IMAP is used only to sync in, sync out, and fetch a body on demand.
- no MTA, no DNS, no DKIM, no Sieve
- Labels, snooze and saved views are implemented in the app precisely so that nothing has to be reconfigured on the mail host.
- A unified query across twelve mailboxes is 5–20ms of SQL instead of 3–8 seconds of sequential
SELECT/SEARCH/FETCH.
Everything else follows from that: cross-account search, faceting, grouping and priority sort are all just SQL, and the list still answers when the mail server is unreachable. The reasoning, the costs and the rejected alternatives are in docs/architecture.md.
Unified across every account, or scoped to a domain, an account, a folder or a saved view. Sort by date, priority, sender, subject, size or unread. Group by date, account, domain, priority, sender or folder. Threaded or flat. Three densities. Facet counts on every filter.
One syntax, executed identically on the client and in Postgres:
from:, to:, subject:, body:, label:, folder:, domain:,
account:, has:attachment, is:unread, before:, after:, larger:.
Bare words match subject, sender and body. See docs/search.md.
Add one at a time with autoconfig discovery, or bulk-import many mailboxes that share a server in a single pass. Five priority tiers. Per-domain colour. A mailbox whose password stops working reports the mail server's own error text and offers to fix it, without stopping the other eleven.
Flags, labels, moves, archive, trash and snooze, all replayed to IMAP where they
have an IMAP meaning and kept app-side where they do not. One-click unsubscribe
via List-Unsubscribe, with every attempt recorded.
Below 720px the app mounts a separate touch shell rather than reflowing the desktop one: a single full-bleed list, an account colour stripe on every row, pull to refresh, and swipe actions on every row.
Swipes are one action per side, both configurable in Settings → Mobile: archive, trash, pin, read, or nothing. Archive left and read right by default. A short swipe reveals the action as a button; carrying it past 40% of the row arms it — the pane fills and names what it is about to do — and letting go there commits without a second tap. Destructive actions keep the same undo window as the desktop.
Composing is a full screen of its own rather than the desktop's docked card: recipient chips, a From row that is never collapsed, and a send bar that tracks the on-screen keyboard so nothing is ever typed underneath it. Backing out of a draft with anything in it asks before discarding.
The frontend is a PWA, so it can be installed to a home screen or a dock and run without browser chrome. Nothing needs enabling; it is served with the app.
| Platform | How |
|---|---|
| iOS / iPadOS | Safari → Share → Add to Home Screen |
| Android | Chrome → menu → Install app |
| Desktop | Chrome or Edge → install icon in the address bar |
A service worker caches the app shell, so a cold launch paints without waiting on the network. The API is never cached: the message list is either current or visibly absent, never quietly stale. Installing changes nothing on the server and is not required to use the app in a browser tab.
An MCP server exposing the same mailbox over the same HTTP API with scoped tokens. See docs/mcp.md.
Every setting is an environment variable, and every one has a default except the
three secrets. .env.example documents all of them;
docs/configuration.md is the reference.
The ones that matter on day one:
| Variable | Default | What it does |
|---|---|---|
APP_ORIGIN |
http://localhost:5274 |
The frontend, cookies and CORS are checked against it, so it must match exactly. |
PORT |
5274 |
Host port. |
BIND_ADDRESS |
127.0.0.1 |
Set to 0.0.0.0 only if you are not putting a reverse proxy in front. |
SECRET_KEY |
— | 32 random bytes, base64. Encrypts stored mailbox passwords. Back this up. |
SESSION_SECRET |
— | Session cookie signing key. |
POSTGRES_PASSWORD |
— | Generated for you on first run. |
ALLOW_PRIVATE_IMAP_HOSTS |
false |
Turn on only if your mail server is on a LAN, a VPN or Tailscale. |
MAIL_HOST_OVERRIDE |
— | mail.example.com=100.64.0.1 reach a server by private address while still validating its public certificate. |
./mainly.sh start | stop | restart | status
./mainly.sh logs [app|db]
./mainly.sh user <email> # create a login (no open registration)
./mainly.sh update # pull the current image and restart
./mainly.sh backup [dir] # pg_dump to ./backups
./mainly.sh restore <file> # replace the database. asks first
./mainly.sh reset # delete the database volume. asks twice./dev.sh start # Postgres in Docker, API and web on the host, both reloading
./dev.sh mock # the whole UI against seeded data — no backend, no Docker
./dev.sh check # typecheck + contract + url + search + smoke + queryThen http://localhost:5273.
./dev.sh mockexists because the frontend ships a complete in-memory adapter implementing the sameMailApiinterface as the real one. The UI cannot tell them apart, which is what let the interface be built and demoed before the backend existed — and is exactly whydev.shwrites the choice tofrontend/.env.localand the app logs which adapter it built.
frontend/
src/lib/ types (the API contract), api adapters, store, query engine
src/components/ shared primitives — buttons, popovers, fields, icons
src/features/ shell · mail-list · reader · compose · accounts · settings · mobile
src/styles/ tokens.css is the source of truth for every visual value
backend/
src/contract/ byte-identical copies of the frontend's types.ts and search.ts
src/modules/ auth · accounts · messages · unsubscribe · folders · drafts · views · prefs
src/sync/ IMAP pool, sync engine, folders, envelopes, threads, bodies, replay, idle
src/smtp/ outbound send
migrations/ numbered, forward-only SQL
mcp/ MCP server — the same HTTP API, exposed to agents over stdio
site/ the landing page and the hosted demo's shell
scripts/ site build and deploy, release
| What | Where |
|---|---|
| API contract | frontend/src/lib/types.ts |
| Search syntax (shared, executable spec) | frontend/src/lib/search.ts |
| List semantics (executable spec) | frontend/src/lib/query.ts |
| URL ⇄ view state | frontend/src/lib/url.ts · router.ts |
| Design tokens | frontend/src/styles/tokens.css |
| Touch shell (below 720px) | frontend/src/features/mobile/ |
| App state | frontend/src/lib/store.ts |
| Server composition root | backend/src/server.ts |
| The hot path | backend/src/modules/messages/query.ts |
| Sync loop | backend/src/sync/engine.ts |
| Threading | backend/src/sync/threading.ts |
| MCP tools | mcp/src/index.ts |
frontend/src/lib/types.ts and search.ts are copied byte-for-byte into
backend/src/contract/. ./dev.sh check fails if they drift, which is what
lets the frontend and backend be deployed and rolled back independently.
More in CONTRIBUTING.md.
Report vulnerabilities privately — see SECURITY.md. Please do not open a public issue.
MIT © Current State Projects 2026. See LICENSE.