An email alias/proxy service: create a unique address per sign-up, revoke it when leaked or abused. Bun + TypeScript rewrite of the legacy PHP/postfix stack. SimpleLogin-compatible API.
The design doc is PLAN.md — architecture, work breakdown, and decisions live there. STATE.md tracks how far along the build is: what's done, verified, stubbed, and not yet started.
server/— one package, several entrypoints (api,mx,submission,deliverd). Fastify + zod-openapi; Drizzle over Bun's native postgres.server/spec/openapi.jsonis the committed API contract.client/— React SPA (rsbuild + TanStack Router/Query + Mantine), served under/app. SDK generated from the spec by Kubb intoclient/src/gen(gitignored).www/— static marketing homepage (Astro), served at/.- A reverse proxy (Caddy) fronts all three at one origin —
/homepage,/appthe SPA,/apithe API — the same path topology in dev and prod. justfile/bin/— one-liner recipes delegating to scripts.
bun install # root tooling (biome, lefthook)
(cd server && bun install)
(cd client && bun install)
bunx lefthook install # pre-commit format hook (once per machine)
cp server/.env.example server/.env # optional; every var has a dev default
just up # docker compose: db, api, client, homepage, proxy
# (the api applies pending DB migrations on boot)Schema changes: edit server/src/db/schema.ts, run just db-generate
(diffs against the last snapshot into a new server/drizzle/*.sql, asking
rename-vs-recreate questions here, once), commit the generated files, then
just db-migrate (or restart the stack). Migrations are the only way schema
reaches a database — dev, test net, and deploy boxes all apply the same
committed SQL, unattended.
Everything is behind one origin (the Caddy proxy), matching production:
- http://localhost:8080/ — homepage (Astro)
- http://localhost:8080/app — the SPA (login, aliases, …)
- http://localhost:8080/api — the API (spec:
server/spec/openapi.json)
(Direct access still works for debugging: the SPA at http://localhost:9000/app, the API at http://localhost:3000/api.)
Config lives in server/.env (gitignored); server/.env.example documents
every variable and which ones production must override.
Login is passwordless: one email field for login and signup, confirmed by an
emailed 6-digit code — and the dev stack runs no deliverd, so codes sit in
the outbound queue. Two shortcuts:
just user-create # log in wes@qmail.com through the code flow
# (prints the API key; idempotent; takes [email])
just operator-create # same, for ops@qmail.com + the admin flag —
# the standard dev operator (takes [email])
just login-code <email> # print the newest emailed code for an addressThen sign in at http://localhost:8080/app/login with the email + the code
from just login-code. An operator (admin flag) additionally gets the
Admin nav section — queue overview at /app/admin; inspect, drop
(silent kill), bounce (kill + failure notice to the sender), requeue, and
delete at /app/admin/queue. The same controls exist headless: just queue-stats, just queue-list [status], just queue-drop <id...>,
just queue-bounce <id...>, just queue-requeue <id...>,
just queue-delete <id...>.
Billing is fully offloaded to Stripe (PLAN Lane I): Checkout to subscribe,
Customer Portal to manage, one webhook keeping the subscriptions table in
sync. All four vars are optional — leave them unset and the billing routes
answer 503 while the rest of the app runs normally.
STRIPE_SECRET_KEY=sk_... # secret key for the Checkout/Portal REST calls
STRIPE_WEBHOOK_SECRET=whsec_... # endpoint secret for POST /webhooks/stripe
STRIPE_PRICE_ID=price_... # the premium subscription price
BILLING_RETURN_URL=... # browser return origin (default http://localhost:9000)Point a Stripe webhook endpoint at POST /webhooks/stripe with the events
checkout.session.completed and customer.subscription.created/updated/deleted
(unknown events are acknowledged and ignored).
just check # format-check + typecheck + unit tests (what CI runs)
just test-unit # pure-function tests, no docker
just test-int # route tests against the dockerized postgres (just up first)
just gen # regenerate spec + client SDK after changing routes/schemaEnd-to-end mail tests run against a self-contained fake internet — BIND with
fake zones, peer MTAs (a pretend Gmail, a DMARC-strict correspondent, an open
relay), and our own mail service — so SPF/DKIM/DMARC are verified for real, no
external network. Messages are found by an X-Virtu-Test-Id header in
Maildir, so tests run in any order without resets.
(cd server && bun install) # containers bind-mount server/node_modules
just test-net-up # build + start the fake internet
just test-story # forwards, replies, bounces, DSNs, custom-domain DKIM
just test-net-logs # follow the mail pipeline (best debugging view)
just test-net-down # tear downStatic Astro site in www/: just www-dev (dev server) or just www-build
(static output to www/dist/).
One universal Caddyfile serves every environment — it fronts the built
www/dist (/) + client/dist (/app) and proxies the API (/api), with
the host and TLS driven by env. The only per-box difference is VIRTU_HOST.
Environments are named zinc (prod, zinc.email) and lmnop (staging,
lmnop.email) — never "prod"/"staging". The plan is a single box per
environment, vertically scaled, running the whole stack via docker compose.
docker-compose.serve.yml runs the whole stack — db, api, built frontends
behind Caddy, and maild (mx + submission + deliverd in one process, ports
25/587/465):
# Local prod-like preview (own project, self-signed cert; won't touch dev;
# mail listeners on loopback high ports):
just preview # = bin/compose -p virtu-serve -f docker-compose.serve.yml up --build -d
# -> https://localhost:8443 (curl -k)
# `just preview-down` tears it down, `just preview-logs` follows logs.Deploy env vars (all optional; sensible defaults): VIRTU_HOST (the box's
hostname), VIRTU_TLS_MODE (default internal self-signed; set an ACME
account email — that is Caddy's "use ACME" spelling, e.g.
VIRTU_TLS_MODE=you@example.com),
VIRTU_TLS_CHALLENGE / VIRTU_TLS_RESOLVERS (DNS-challenge providers),
HTTP_PUBLISH/HTTPS_PUBLISH/MX_PUBLISH/SUBMISSION_PUBLISH/
SUBMISSION_TLS_PUBLISH (host port bindings). Caddyfile.dev is the
dev-only variant (proxies the HMR dev servers).
Caddy owns TLS for the box, including the MX hostname: the Caddyfile has a
mail.{VIRTU_HOST} site purely so Caddy obtains + renews that cert, and the
mail-certs one-shot copies it into the mail_certs volume as
/mail-certs/fullchain.pem + privkey.pem, which server/.env points the
SMTP listeners at. The listeners read cert files once at startup, so
renewals need bin/mail-certs-sync (sync + maild restart) — run it from a
weekly root cron on the box:
printf '#!/bin/sh\ncd /opt/virtu && runuser -u virtu -- bin/mail-certs-sync\n' \
> /etc/cron.weekly/virtu-mail-certs && chmod +x /etc/cron.weekly/virtu-mail-certsDNS first (see each.email.zone for the full annotated record set): apex A +
mail A → the box IP (DNS-only if Cloudflare — the orange cloud breaks
both SMTP and ACME), MX 10 mail.{domain}, SPF, DMARC. Then rDNS — outbound
IP must reverse-resolve to the MX hostname (Linode:
linode-cli networking ip-update <ip> --rdns mail.{domain}) — and verify
outbound port 25 isn't blocked by the host
(ssh root@box 'bash -c "exec 3<>/dev/tcp/gmail-smtp-in.l.google.com/25 && head -1 <&3"').
# 1. Provision (root): swap, docker, git, bun + just, the virtu app user
# (uid 1000). Re-runnable — that's also how you apply a bun version bump.
ssh root@box 'bash -s' < bin/host-provision
# 2. Clone as virtu, then write the two env files (both gitignored):
ssh root@box 'runuser -u virtu -- git clone https://github.com/zinc-email/virtu.git /opt/virtu'
# /opt/virtu/.env — compose interpolation: VIRTU_HOST=each.email,
# VIRTU_TLS_MODE=<acme account email>, and the
# five *_PUBLISH vars bound to 0.0.0.0
# (80/443/25/587/465). Also point compose at
# the serve stack, so every recipe and bin/
# tool targets it without a -f flag:
# COMPOSE_FILE=docker-compose.serve.yml
# COMPOSE_PROJECT_NAME=virtu-serve
# /opt/virtu/server/.env — MAIL_DOMAIN, MAIL_HOSTNAME, a real VERP_SECRET,
# SMTP_TLS_CERT_FILE=/mail-certs/fullchain.pem,
# SMTP_TLS_KEY_FILE=/mail-certs/privkey.pem, and
# DATABASE_URL pointed at 127.0.0.1:5433 (the
# host-published port) for the host-side dev
# tools (bin/login-code, bin/user-create); the
# ops tools run in-stack and ignore it, and
# api/maild get @db:5432 from compose, which
# wins over this file.
# See server/.env.example.
# 3. Deploy (as virtu, from /opt/virtu) — build, migrate, up, cert sync.
# The serve stack's db-migrate one-shot applies server/drizzle/ before
# api and maild start; a fresh box gets the whole schema here.
bin/host-deploy
# 3b. ONLY for a box whose DB predates generated migrations (created by the
# old `drizzle-kit push`): baseline it once — marks the committed
# migrations applied without running them. Do it BEFORE the first
# host-deploy of a migrations-era release, and make sure the schema is
# current first (`just db push` one last time on the pre-migrations code).
just db-baseline
# 4. Once per domain: mint the DKIM key and publish the TXT it prints:
bin/dkim-ensure
# 5. Mint the first admin (the flag gates /api/admin and the SPA's Admin
# section; no admin exists yet, so this is direct-DB by design):
bin/admin-grant you@example.comRedeploys are step 3 alone. bin/host-deploy is idempotent: fetch +
checkout (an optional ref argument — a v* tag or sha; default
origin/main), rebuild, up -d (which runs pending migrations first), cert
sync. Schema review is human but happens at generate time: just db-generate
on the workstation answers drizzle-kit's questions and the resulting SQL is
reviewed in the PR; the box only ever applies committed SQL. A failing
migration leaves api and maild stopped on the old code rather than running
on a half-applied schema — check bin/compose -f docker-compose.serve.yml logs db-migrate.
Pushing a v* tag deploys that tag to each.email:
.github/workflows/deploy.yml SSHes in as virtu using the EACH_SSH_KEY
repo secret and runs bin/host-deploy <tag>. The box side is locked down in
~virtu/.ssh/authorized_keys: a restrict,command= forced command pins the
key to bin/host-deploy "$SSH_ORIGINAL_COMMAND", so leaking the secret leaks
"can redeploy" and nothing else; the workflow pins the box's host keys.
Schema changes are part of the automatic deploy: the tagged release carries
its server/drizzle/ migrations and the stack applies them before starting.
git tag v0.2.0 && git push origin v0.2.0The mail daemons log JSON lines and every process exposes Prometheus
metrics (api at /meta/metrics, maild on the unpublished :9100, which is
also its container healthcheck). A box opts into shipping them by adding to
/opt/virtu/.env:
COMPOSE_PROFILES=observe
GRAFANA_CLOUD_PROM_URL=https://prometheus-xxx.grafana.net/api/prom/push
GRAFANA_CLOUD_PROM_USERNAME=<prometheus instance id>
GRAFANA_CLOUD_LOKI_URL=https://logs-xxx.grafana.net/loki/api/v1/push
GRAFANA_CLOUD_LOKI_USERNAME=<loki instance id>
GRAFANA_CLOUD_API_KEY=<cloud access policy token with metrics+logs write>…then redeploying. That starts one Grafana Alloy container
(alloy/config.alloy) which scrapes both metric endpoints every 60s and
tails all of this stack's container logs into Grafana Cloud Loki (query
JSON fields with | json). Every series/stream carries env=$VIRTU_HOST.
Nothing else runs on the box — no local Prometheus/Grafana (PLAN decision
#15). The local preview and dev stack skip the profile; curl the endpoints
directly instead.
Operator tooling without the dashboard: just queue-stats, just queue-list failed, just queue-drop <id...>, just queue-bounce <id...>,
just queue-requeue <id...>, just queue-delete <id...>, just admin-grant <email> — all direct-DB break-glass that works when the API is
down (on a box: docker compose -f docker-compose.serve.yml exec api bun run src/scripts/<name>.ts …).
AGPL-3.0. Network use counts as distribution: run a modified version as a service and you must offer users its source.