Skip to content

Repository files navigation

virtu-ts

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.

Layout

  • server/ — one package, several entrypoints (api, mx, submission, deliverd). Fastify + zod-openapi; Drizzle over Bun's native postgres. server/spec/openapi.json is the committed API contract.
  • client/ — React SPA (rsbuild + TanStack Router/Query + Mantine), served under /app. SDK generated from the spec by Kubb into client/src/gen (gitignored).
  • www/ — static marketing homepage (Astro), served at /.
  • A reverse proxy (Caddy) fronts all three at one origin — / homepage, /app the SPA, /api the API — the same path topology in dev and prod.
  • justfile / bin/ — one-liner recipes delegating to scripts.

Quickstart

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:

(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.

Logging in

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 address

Then 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 (optional, Stripe)

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).

Checks & tests

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/schema

Story tests (the simulated internet)

End-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 down

Homepage

Static Astro site in www/: just www-dev (dev server) or just www-build (static output to www/dist/).

Deploy

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).

Mail TLS

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-certs

A new box, start to finish

DNS 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.com

Redeploys 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.

Automatic deploys (tag push)

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.0

Observability (Grafana Cloud, optional)

The 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 …).

License

AGPL-3.0. Network use counts as distribution: run a modified version as a service and you must offer users its source.

About

Reach inbox zero and better online privacy with email aliases

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages