Last updated: 2026-09-02 (reputation guards before launch — per-alias/
per-mailbox inbound rate limit, no-DSN-for-flagged-inbound backscatter
guard, per-destination outbound backpressure with the deliverability
Grafana board, RFC 2142 role addresses routed to opted-in operators, and
docs/reputation-telemetry.md; details in the Lane K row). Previous:
2026-09-01 (Lane K P2 first slice — durable queue
attribution columns, per-user daily send quotas at submission pre-enqueue,
the smtp_rejections refusal log + retention, notifications API + bell UI
— PLUS the merge of the parked invite lane from 2026-08-22: ABUSE.md
Tier 0, invites table, SIGNUP_INVITE_ONLY graduation gate in
/auth/verify, /api/admin/invites mint/list/revoke + AdminInvites page +
Login invite flow, bin/invite-create). Companion to PLAN.md (the
design doc); this file tracks what is built, how it was verified, and
what remains.
All nine PLAN lanes are implemented and merged, plus the outbound wave (decisions #9–#12): submission now supports reply-from-mailbox (the contact metadata picks the outbound alias), cold email straight from an alias, per-device SMTP passwords, the per-user trash inbox for "off" aliases, and one-copy-per-mailbox delivery for multi-mailbox aliases. The MVP loop is closed and verified end to end: mail proxies through the full verify → rewrite → sign/seal → queue → deliver pipeline inside the simulated internet; the SimpleLogin-compatible API drives a working dashboard; billing is live-verified against real (test-mode) Stripe. What remains is polish (client gaps, deferred endpoints), a couple of explicitly-stubbed behaviors, and the entire production/deploy story, which has not been started.
| Tier | Count | Command | Last state |
|---|---|---|---|
| Unit | ~530 tests / 43 files | just test-unit |
green |
| Contract (bridge protocol) | 14 tests / 2 files | just test-contract |
green |
| CI gauntlet | format + 2× tsc + SDK gen + unit + contract | just check |
green |
| Integration (API vs real Postgres) | 184 tests / 19 files | just up && just test-int |
green |
| Client DOM (real React vs running stack) | 18 tests / 7 files | just up && just test-client |
green |
| Stories (simulated internet) | 29 stories / 15 files | just test-net-up && just test-story |
green (2026-09-02, incl. operator mail + inbound rate limit; 2026-09-01 suppression, async-DSN threshold, scripted open.relay wire replies) |
| Live Stripe (test mode) | manual + watcher | see README billing section | verified 2026-08-08 |
Migrations (2026-09-02): schema now ships as generated SQL in
server/drizzle/ (just db-generate → commit → applied by
src/scripts/dbMigrate.ts: dev api boot, the serve stack's db-migrate
one-shot ahead of api/maild, the test net's mail container). drizzle-kit push is out of the workflow; just db-baseline bridges a push-era DB once
(each.email needs it before its first migrations-era deploy). The
create-vs-rename question is answered at generate time on the workstation,
so deploys are unattended and the tag-push deploy now carries schema.
Test-net gotcha found 2026-08-14 (moot since generated migrations — the
mail container applies committed SQL, no prompts to skip): the pg_test volume had survived since
before the domains rename, and the mail container's boot-time
drizzle-kit push --force silently skipped the interactive rename question
(the same no-TTY exit-0 pitfall the deploy lane documents), so every story
failed on column "domain_id" does not exist. After a schema rename
(not additive changes), run just test-net-down (wipes volumes) before
test-net-up.
Story coverage: forward with dkim=pass at the receiving peer (M1), authed
reply with threading and zero real-address leakage (M2), bounce → auto-disable
at threshold (M3), DSN delivery + rate-limit suppression, custom-domain
forward AND custom-domain DKIM (dkim=pass header.d=user.com at initech),
transactional login-code email delivered through our own queue AND a bounced
verification email invalidating its code (transactional intake), policy edges
(nonexistent alias 550, disabled alias accept-and-drop, relay denied),
network smoke (peers verify SPF/DKIM/DMARC independently of our server),
the custom-domain API lifecycle (create → publish the exact GET .../dns
records via nsupdate → verify all five checks green → catch-all mints an
alias through the real pipeline → tombstones stay dead), and the outbound
wave: reply with MAIL FROM = the mailbox (contact metadata picks the alias),
mixed-alias recipients refused, cold email from an alias (dkim=pass at
initech + contact minted), Cc-of-own-mailbox refused, per-device SMTP
password lifecycle (API create → real 587 send → revoke → 535), disabled
alias → trash inbox with X-Virtu-Trash (and on-alias mail unmarked), and
multi-mailbox fan-out (one send → both Maildirs, one email_log per mailbox;
a dead extra mailbox SUPPRESSES on its first 5.1.1 bounce — stays attached,
excluded from delivery — while the alias and its healthy primary keep
going). Since 2026-09-01 the bounce loop is four stories: first-strike
mailbox suppression (one 5.1.1 → paused mailbox, drop-not-bounce, in-app
notification, alias stays enabled), threshold auto-disable driven by
async 5.7.1 DSNs at the VERP return path (non-suppression codes still
accumulate to >12/24h), and — via the scripted open.relay neighbor
(smtpd template scripted-replies.pcre: a recipient
reply-<code>-<c>-<s>-<d>-<tag>@open.relay is refused at RCPT with exactly
that SMTP + enhanced code, scriptedReplyAddress() in test/personas.ts)
— the same regimes as genuine SMTP-time wire replies: a 550 5.7.1
threshold run through deliverd's permanent path, and a 450 tempfail pinning
the retry/backoff path (row back to pending, tries counted, no bounce,
no suppression). The detach-at-threshold decision table moved to
pipeline/bounce.int.test.ts.
The live Stripe pass caught a real bug the self-signed tests missed
(out-of-order subscription.created regressing status — fixed in fdf36a2
with a regression test encoding the observed sequence).
| Lane | Status | Notes |
|---|---|---|
| 0 — Scaffold + schema | ✅ done | Contract pipeline (fastify-zod-openapi → committed spec → Kubb) proven; schema at v1 + wave-3 additions |
| A — SMTP library | ✅ done | 101 loopback tests, zero deps. One workaround: server-side STARTTLS via loopback bridge until Bun ships oven-sh/bun#34598 (merged upstream 2026-07-25, unreleased) |
| B — Email auth (mailauth) | ✅ done | All verification in-process; table-driven verdicts; glts spf-milter documented as fallback, unused |
| C — Rewrite core | ✅ done | VERP byte-compatible with SimpleLogin (CPython golden vector) + constant-time compare + real expiry; forward/reply whitelists; refuse-to-leak on replies |
| D — Queue + deliverd | ✅ done* | SKIP LOCKED worker, backoff, RFC 3464 DSNs (null reverse path, rate-limited). *Gap: bounces OF transactional mail are log-only |
| E — API (SimpleLogin-compat) | ~90% | 28+ spec paths incl. custom domains, billing extras, SMTP credentials (Virtu extension) + mailbox trash flag. Auth is passwordless (PLAN decision #13): /auth/login + /auth/verify replace SL's register/activate/reactivate/login, sudo is an emailed code. Deferred: MFA, PATCH user_info, DELETE /user, cookie_token, export, apple/phone (forgot_password is moot — no passwords) |
| F — Client | ~85% | Restyled to the legacy virtu design on Panda CSS (semantic tokens in panda.config.ts, primitives in src/ui.tsx: Button/Field/Select/Switch/KeyValue/EntityList/CopyButton/Logo; root font-size 18px→24px@1200px, everything rem-based). Pages: login (the passwordless single entrypoint — one email field for login AND signup, styled after the legacy 401 page, code step with PinInput; /register redirects here), alias detail (new/used states + activities + contacts/delete), mailboxes (add/verify-by-code/default/trash/delete-with-transfer; full-width index + /mailboxes/$mailboxId detail page owning the controls as KeyValue switch rows — default is one-way so the holder's switch is ON+locked; index rows carry buttons only while unverified), settings (native selects; a pointer to the mail-client page), mail client (/smtp, 2026-09-05: setup instructions for reply mode and send mode fed by GET /smtp/settings — host/ports/username are per-box config, never hardcoded — plus the SMTP device passwords, moved here from settings: create-with-one-time-reveal, revoke), billing (key/value + Stripe actions), domains index + detail (DNS records to publish, verify with per-check errors, catch-all switch, delete). Mantine is fully removed (banned — see CLAUDE.md): overlays are native <dialog> (src/overlays.tsx), PinInput/TextArea/CheckboxGroup are ours, color scheme is src/colorScheme.ts (data-color-scheme on html). Responsive pass 2 (2026-08-10, screenshot-verified 360/700/1100/1440): index-row controls hide on small screens (aliases ≤480px, mailboxes ≤900px — the detail pages own them), drawers go full-width ≤650px, the hamburger demotes Log out to a bottom meta row (with the theme toggle), domain rows link instead of carrying a Verify button (verify lives top+bottom of the detail page), and FieldRow (ui.tsx) stacks every [input][button] pair ≤650px. Header nav (2026-09-05): desktop row = Emails/Mailboxes/Domains + the bell + a persona dropdown holding Settings/Account/Admin (AccountMenu in app.tsx — controlled menu, outside-press/Esc dismiss); the bell shares a text link's box so it sits on the links' line (it used to ride ~14px low); the ≤1000px drawer stays one flat list. Missing: search, per-alias mailbox picker UI (API supports mailbox_ids) |
| G — Homepage | ✅ done | 7 static Astro pages, verbatim legacy copy/tokens, zero client JS. Served at / behind the Caddy proxy; SPA under /app, API under /api — one origin, same topology dev and prod (dev proxy built; see below) |
| H — Simulated internet | ✅ done | Subnets 192.168.34/43 (legacy stack owned 33/42; legacy now stopped — renumbering back is optional). Maildir + X-Virtu-Test-Id; no resets, parallel-safe |
| I — Billing | ✅ done | SDK-free Stripe; live-verified checkout → webhook → premium flip; keys in gitignored server/.env. No Stripe = nothing enforced (2026-09-05): isPremium short-circuits to true when the STRIPE_* trio is unset, so the trial clock, free alias cap and free send quota only bite where an upgrade is for sale; /billing/status reports plan: premium there. Int tests that exercise the free plan pin a configured billing via setBillingConfigForTests |
| J — Observability & queue hygiene | ✅ done | 2026-08-14, decision #15. First-party structured logger (src/log.ts, all 6 daemon files migrated off console.log) + Prometheus registry (src/metrics/, virtu_* set) — api at /meta/metrics, maild on :9100 (also its liveness: listeners + worker heartbeat); /meta/health now probes the DB; compose healthchecks on api+maild. Queue: claimed_at lease + reaper, retention (raw cleared on sent; 7d/30d windows), status-guarded terminal writes, dropMessages/requeueMessages, retry horizon 6→25 tries (~4 days, 6h cap; test net pins fast values). Alloy → Grafana Cloud under compose profile observe (alloy/config.alloy); creds go in /opt/virtu/.env. Not yet verified against a real Grafana Cloud stack — needs the account + creds on lmnop first |
| K — Admin & abuse | P1 ✅ | 2026-08-14, decision #16. USER_FLAGS.admin bit + requireAdmin nested /api/admin scope; endpoints: overview, queue list (status filter + limit + total), detail (allowlisted routing headers — never Subject/body — + VERP-decoded owner), drop, requeue, delete (terminal rows only, ahead of retention), bounce (operator DSN via the shared pipeline/dsnDelivery.ts — extracted from deliverd — then failed "bounced by operator"; NO recordBounce, so the auto-disable ledger never moves on an operator action; forward diagnostics sanitized as always); is_admin on user_info. Client: Admin nav item (admins only), AdminOverview/AdminQueue/AdminQueueMessage pages, in-kit, screenshot-verified 360/700/1100/1440. Break-glass CLI: bin/admin-{grant,revoke}, bin/queue-{list,stats,drop,requeue} + just recipes (verified live against the dev DB). sudoGuard.ts seam extracted (POST /api_key refactored onto it); admin ops NOT sudo-gated until P2. Roadmap P2–P4 in PLAN Lane K. Invite lane (2026-08-22, ABUSE.md Tier 0; merged 2026-09-01): invites table (code plaintext by design, created_by→used_by kept forever = the invite graph, SET NULL never CASCADE); SIGNUP_INVITE_ONLY env gates /auth/verify at graduation — invite consumed inside graduateUser's tx after code proof (no enumeration leak; a failed invite spends the login code, Login page offers the fresh-code retry); /api/admin/invites GET/POST/DELETE (revoke = unused only, 404 otherwise) riding the shared auth/invites.ts primitive with bin/invite-create + just recipe; AdminInvites page + Overview link + Login invite panel. Verified: 7 int tests (invites.int.test.ts — wall, lifecycle, gate incl. rollback/reuse/expiry/existing-user), full int tier + just check green, and a real-browser Playwright walk (fresh email → 403 → invite + resent code → app) + screenshots 360/700/1100/1440, no overflow. P2 first slice ✅ 2026-09-01: (1) durable attribution — outbound_messages.user_id/email_log_id written by every enqueue (mx forwards + trash copies, submission, transactional via sendWithRateLimit's userId, DSNs from the email_log), admin queue DTO carries user_id, resolveQueueOwner falls back to the columns when VERP doesn't decode (DSNs/trash/expired now name their user); (2) per-user daily send quota at submission pre-enqueue — counts email_logs.is_reply recipients over a rolling 24h (forwards never count), refuses whole messages 452/4.7.1, limits users.max_daily_sends override (0 = unlimited) else SEND_QUOTA_{FREE,PREMIUM}_PER_DAY (50/500); (3) smtp_rejections — every SMTP-time refusal from both daemons (auth/mail_from/rcpt_to/data phases) recorded fail-open with envelope context + the exact reply, aged out on the retention tick (SMTP_REJECTIONS_RETAIN_DAYS=30), metric virtu_smtp_rejections_total; (4) notifications — SL-compatible GET /notifications (unread-first, 20/page, humanized created_at via routes/timeAgo.ts) + POST /notifications/:id/read, header bell with unread badge + /notifications page (screenshot-verified 360–1440), bin/notification-create dev/announce tool. Still open from P2: sudo-gating the destructive ops, admin user views, notifications for quota hits. Reputation guards ✅ 2026-09-02 (the pre-launch pass): (1) inbound rate limit — pipeline/inboundRateLimit.ts, per-alias distinct messages / per-mailbox copies over a trailing 60s off email_logs, INBOUND_RATE_LIMIT_PER_{ALIAS,MAILBOX}_PER_MINUTE (10/15, 0 = off), the last gate in decideRcpt → 450 4.7.1 (sender retries), virtu_mx_rate_limited_total{scope}, recorded in smtp_rejections; story pins RCPT 450 at the real MX and acceptance after the window; (2) backscatter guard — the mx persists the "flag" verdict on email_logs.is_spam/spam_status (forward + blocked rows), sendFailureDsn skips forward-phase DSNs for flagged rows (flagged_inbound, also an operator-bounce skip reason) while bounce accounting still runs; (3) per-destination backpressure — destination_throttles + queue/destinationThrottle.ts: 421 anywhere or 4.7.x at greeting/EHLO/MAIL FROM/DATA (RCPT-time 4.7.x = greylisting, never) pauses the recipient DOMAIN base·2^strikes (DESTINATION_PAUSE_{BASE,MAX}_MS 5m/1h, 0 disables — the test net disables it), deliverd defers paused rows without an attempt or a try, a success resets strikes; DeliveryOutcome now carries the SMTP reply (step/code/enhanced) → virtu_queue_destination_replies_total{provider,step,code,enhanced}, virtu_destination_pauses_total, virtu_queue_destination_deferred_total, virtu_destination_paused gauge; GET/DELETE /api/admin/destinations + AdminDestinations page + bin/destination-resume; overview shows paused count; Grafana board grafana/deliverability.json; (4) operator mail — pipeline/operatorMail.ts + mail/rewriteOperator.ts: OPERATOR_LOCALPARTS (postmaster,abuse,hostmaster,security,dmarc) on the service domain resolve BEFORE the alias lookup to the opted-in admins (USER_FLAGS.operatorMail; first admin by default), one DKIM+ARC-signed copy per operator's deliverable default mailbox on the null reverse path, From aligned on our domain with the sender in Reply-To, X-Virtu-Operator-Mail; accepted-and-logged when nobody is deliverable (postmaster never bounces); reserved in /v3/alias/custom/new; GET/PATCH /api/admin/operators + AdminOperators page (KVSwitch rows) + bin/operator-mail; story pins dkim=pass header.d=virtu.email at qmail; (5) docs/reputation-telemetry.md — signup runbook (DMARC rua → dmarc@, Google Postmaster v2, Microsoft SNDS/JMRP, Yahoo CFL) with the automate-or-read verdict: ingest the mailed reports (rua, ARF), leave the dashboards to humans. Screenshot-verified 360–1440, no overflow. Tier 1 mailbox suppression ✅ 2026-09-01 (ABUSE.md's "single highest-value control"): a forward-phase bounce with enhanced code 5.1.1/5.2.1 suppresses the MAILBOX first-strike (mailboxes.suppressed_at, pipeline/suppression.ts) — every alias delivering there pauses via the shared policy.ts mailboxDeliverable bar (delivery set, trash inbox, catch-all mint, detach survivors), inbound drops with email_logs.blocked_reason="mailbox_suppressed" (never bounces), in-app notification, metric virtu_mailbox_suppressed_total. Detected on BOTH bounce paths: deliverd SMTP-time (enhanced code now plumbed classifySendResult → onPermanentFailure) and the mx async-DSN intake (extractDsnStatus on the report's Status field). Resume = re-verification ONLY (never auto: recycled-domain-as-spamtrap looks like recovery): new POST /mailboxes/:id/verify/request emails a fresh code for unverified OR suppressed, the verify code-proof clears suppressed_at; Mailbox DTO gained suppressed (Virtu extension), client shows Paused tag + detail-page explainer + Re-verify flow (screenshot-verified 360–1440); bin/mailbox-suppress dev/operator tool + just recipe. Threshold auto-disable/detach still governs non-suppression codes — the M3 bounce story now drives it through fake async 5.7.1 DSNs at the VERP return path, the suppression story pins first-strike + drop-not-bounce, multiMailbox pins suppressed-extra-stays-attached-primary-delivers, and pipeline/bounce.int.test.ts pins the detach/disable/promote decision table (incl. suppressed-mailboxes-are-never-survivors) |
Milestones M1–M4 (PLAN sequencing diagram): all reached.
- Reverse aliases always mint on the service domain — SimpleLogin's
use_as_reverse_aliasper-domain option not implemented. - SimpleLogin's
block_behaviour550 option not wired — disabled-alias handling is accept-and-drop or the trash inbox (decision #11); the per-user "return 550 instead" toggle SL offers is deliberately absent (it probes alias existence). - Spam-check hook — pluggable pre-queue slot deliberately unwired (PLAN decision #4). Candidates: spamd/SPAMC or rspamd.
- API deferred endpoints — list in Lane E row above; also GET-with-body alias search, multi-window rate limits collapsed to single-window, 150-word wordlist, mailbox email-change returns 400.
- PGP — fields accepted/serialized as unsupported (
support_pgp:false). - Admin ops not sudo-gated (Lane K P1) — drop/requeue sit behind the
admin flag + a confirm dialog only; the
sudoGuard.tsseam exists and P2 flips it on together with the SudoDialog the user-facing destructive deletes also need. Queue ownership is VERP-only(resolved 2026-09-01: durableoutbound_messages.user_id/email_log_idwritten at every enqueue; admin detail falls back to them when the VERP doesn't decode. Rows enqueued before the wave stay unattributed until retention ages them out.)- Grafana Cloud shipping unverified — the Alloy service + config are
in the serve stack behind
COMPOSE_PROFILES=observe, but no Grafana Cloud stack/credentials exist yet; first real scrape should happen on lmnop. The metrics endpoints themselves are int-tested and curl-able.
(Resolved 2026-08-10: custom-domain suffixes — /v5/alias/options now offers
each verified custom domain as is_custom suffixes: the EMPTY suffix
(@domain, full local-part control, SimpleLogin-style) first, plus a
random-suffix variant; custom domains sort before shared domains, the user's
default domain first. /v3/alias/custom/new accepts them, checks ownership +
verified at creation time (the suffix signature is not user-bound) and links
custom_domain_id. Shared-domain suffixes stay forced-random (shared
namespace). The create-alias modal shows an advisory note when a no-suffix
option is selected instead of blocking; no dictionary of "guessable" local
parts by design — with catch-all off a guessed address bounces, with catch-all
on every local part already delivers, so a blocklist protects nothing.
SimpleLogin's per-domain random_prefix_generation default-flip remains
unimplemented.)
(Resolved 2026-08-10: transactional bounce intake — the VERP id now resolves
to the verification_codes row in BOTH intake paths (mx inbound VERP + deliverd
permanent failure): code invalidated, mailbox nb_failed_checks bumped,
user notified; duplicate/late bounce copies are no-ops, and the mx path only
accepts DSN-shaped mail (looksLikeDsn: multipart/report, or null reverse
path without Auto-Submitted: auto-replied) so a vacation auto-reply to the
Return-Path can't kill a live code. Also resolved: the mx prepends a
Received: trace header, and submission AUTH sits behind a per-(IP,username)
failed-attempt throttle (pipeline/authThrottle.ts) so wrong passwords can't
buy unbounded argon2id work. Known residual: an auto-responder that emits
multipart/report — nonstandard but possible — still counts as a bounce
unless its Action fields say only delayed/relayed; an async bounce with a
NON-null sender and a plain-text body is ignored at the mx intake (the
deliverd SMTP-time path catches the dominant case); the forward/reply VERP
intake paths take any mail to the VERP address at face value, as before this
wave; and a broken trash mailbox fails silently — trash copies ride the null
reverse path by design (PLAN #11), so nothing bumps its nb_failed_checks.)
First security audit pass; fixes landed on main. just check green.
- Forward-bounce DSN no longer leaks the backing mailbox (was
Critical). A permanently-failed forward sends its DSN to the outside
sender; it now names the alias as the failed recipient and emits a
sanitized diagnostic (
sanitizeForwardDiagnosticinmail/dsn.ts, applied indeliverd.ts), neverenvelope_to(the real mailbox) or the verbatim remote reply. Reply-phase DSNs (to the user's own mailbox) are unchanged.dsn.story.test.tsnow asserts the alias appears and the mailbox never does. Not yet re-run in the story tier (needs docker; runjust test-net-up && just test-story). - Production fail-closed on insecure secret defaults (was High).
config.ts:assertProductionSecretsrefuses to boot whenVIRTU_ENV/NODE_ENV=productionandVERP_SECRETorDATABASE_URLis still the known dev default. Serve stack now setsVIRTU_ENVand sourcesPOSTGRES_PASSWORD(no longer hardcodedvirtu);.env.exampledocuments both. - deliverd SSRF egress guard (was Medium).
queue/worker.tsrefuses to deliver to an MX/implicit-MX resolving to a private/loopback/link-local address and connects to the vetted IP (isBlockedAddress,SMTP_ALLOW_PRIVATE_TARGETS; the test net sets it true for its 192.168.x peers). trustProxyenabled (was Medium) so per-IP auth rate limits work behind Caddy instead of collapsing to one global bucket.- Login open-redirect closed (was Medium).
client/app.tsx:safeRedirectonly accepts same-origin/apppaths for the post-login?redirect. - CSP + HSTS added (was Medium).
Caddyfile: strict CSP site-wide (script-src 'self', no eval), HSTS on the public host only.
- Forward/reply VERP bounce intake gated by
looksLikeDsn(was Low). Themx.tsintake now applies the DSN-shape gate to ALL VERP types, not just transactional — a vacation/OOO auto-reply to a forward/reply return path no longer books a bounce (which could auto-disable a victim's alias). isOwnMailboxAddressis provider-aware (was Low). New purepipeline/addressMatch.ts:mailboxMatchKeyfolds Gmail dots + googlemail.com (dot-folding is Gmail-only, so look-alike cold emails elsewhere aren't wrongly refused); the own-mailbox refuse-to-leak guard uses it.- DNS TXT character-string clamp (was Low).
dnsTxt.tsrejects a chunk whose length overruns its record's RDATA (EBADRESP) instead of splicing adjacent answer bytes into a DKIM/ownership comparison. - Reverse-alias RCPT lookup scoped to the authed user (was Low).
resolveReverseAliastakes an optionaluserId; submissiononRcptTopasses it so another account's reverse alias can't be probed (250 vs 550). - SMTP connection caps (was Low/Medium).
smtp/server.tsbounds concurrent connections globally (1024) and per remote IP (64), refusing over the cap with421before allocating a session. ALIAS_SIGNING_SECRETrequired in production (was Low).aliasConfig.tsrefuses to boot underVIRTU_ENV=productionif it's unset, rather than deriving the suffix HMAC key fromDATABASE_URL.
custom_domains was renamed to domains and reshaped so a squatter can no
longer permanently block a domain by POSTing it first:
name_requested(varchar, NOT NULL) is the claimed FQDN — NOT globally unique (onlyUNIQUE(user_id, name_requested)), so any number of accounts can hold a provisional claim on the same name.nameis a STORED generated column =CASE WHEN verified_owner THEN name_requested END, withUNIQUE(name)(NULLs distinct). That unique index is the winner-take-all lock: exactly one account can own an FQDN, and it's whoever proves DNS control (the per-row ownership TXT token — unraceable). App code never writesname; the DB derives it fromverified_owner.- The verified flags are a
verified_*family (verified_owner,verified_mx,verified_dkim,verified_spf,verified_dmarc); the DNS re-check writes only these.verifyCustomDomainflipsverified_ownerand catches theUNIQUE(name)violation (db/pgError.ts:isUniqueViolation): the loser keeps its other checks but stays unowned (nameNULL) and is told "already verified by another account". Ownership/MX are upgrade-only in the interactive path (demotion is the future cron's job, behind nb_failed_checks). The old display-name columnnameis nowfrom_name;aliases.custom_domain_idis nowaliases.domain_id. - Capabilities are code, not columns (
pipeline/domainCapability.ts):canReceive = owner && mx(inbound forwarding, re-signed with our key),canSend = owner && dkim && spf(outboundd=customdomain— reply signing falls back to the service key when false, so we never send from a misconfigured domain). Surfaced as additivecan_receive/can_sendcomputed fields on the CustomDomain API DTO. DMARC is a quality flag, not a gate. Every mail-path lookup keys on the uniquename(a provisional row's NULL can never match); this also resolves the old "routing keys off MX, not ownership" note. - The whole change stays off the SimpleLogin wire: the rename is internal;
responses still serialize
domain_name,is_verified(now = can_receive),*_verified, andcustom_domain_id. Verified end to end: unit (capability predicates), int (customDomains.int.test.tstwo-accounts-race: provisional coexist → first verify wins → second locked out), and the custom-domain story tier.
Still deferred (need a product/UX/architecture decision, or are accept-only):
sudo gating on destructive deletes (UX), moving the API key out of
localStorage into an httpOnly cookie (architecture + CSRF), 64-bit VERP MAC
(accepted, SL-compat), the postcss dev-only advisory, flipping the CSP from
reasoned-safe to violation-verified after a deploy, and the domain
DNS-re-check cron (writes debounced verified_* flags; name + capabilities
auto-derive — demotes a domain whose ownership TXT lapses, freeing the name
for the next controller).
Red-team pass a week before zinc goes live; findings and the fix plan in
plans/2026-09-05-security-review.md. The two P0s landed 2026-09-05, the
three P1s 2026-09-06, and the P2 batch (plus the Caddyfile webhook deploy
bug found on the way) 2026-09-06; only the noted-not-chased timing side
channel remains, by design.
- SMTP smuggling closed (was High, CVE-2023-51764 class).
smtp/wire.ts:DataDecoderended DATA on a lone "." line after ANY line break, so<LF>.<LF>(and the mixed forms) let a bare-LF-passing relay end our DATA early and pipeline a second envelope that the mx then authenticated against the relay's IP. End-of-data is now<CRLF>.<CRLF>only; a dot line off a bare LF is content (unstuffed to an empty line), bare LF elsewhere still normalizes to CRLF. Pinned inwire.unit.test.ts(each terminator variant) and at the socket level inserver.unit.test.ts(the smuggled envelope lands in the one message's body, no second transaction runs). - Forwarding loop closed (was High). A mailbox on a domain whose MX is
us (a trial user's own catch-all domain, or a stranger's) looped
mx → queue → deliverd → mx forever, retried for four days, with nothing
surviving the forward whitelist to count hops. Two doors:
routes/mailboxRoutes.tsrefuses a mailbox on anydomainsrow withverified_mx(int test), andmail/rewriteForward.tsstampsX-Virtu-Hops(whitelisted, +1 per forward;MAX_FORWARD_HOPS = 2) — the mx accept-and-drops at the limit with aforward_loopblocked log and aloop_droppedoutcome, and logsforward_chainedat the second hop so telemetry shows who chains.loop.story.test.tspins both sides. - Login endpoint: the resend lockout and the cannon closed (P1,
2026-09-06).
pipeline/transactional.ts: the newestMAX_ACTIVE_CODES(=ACTIVATION_RESEND_MAX, 3) unused codes of a scope are ALL valid, so a stranger's /auth/login for someone's address no longer kills the code that address is about to type; a match consumes the whole scope, a wrong guess spends each live code's own 3-try budget (the oldest dies first,410only once none is left). Every route mails a code throughroutes/verificationMail.ts issueVerificationCode— budget check, mint, send, and a 429 (with the minted code retired) if the send's own re-check refuses — so no route can answer "code sent" with nothing queued. Provisional users (activated=false, not disabled) idle pastPROVISIONAL_USER_RETAIN_HOURS(24, byupdated_at— a reused row is touched) are pruned on the retention tick (provisional_userslabel);sent_alertsages out afterSENT_ALERTS_RETAIN_DAYS(30, newcreated_atindex, migration 0001).TRANSACTIONAL_MAIL_HOURLY_MAX(500, 0 = off — dev compose +server/.env.testset 0) is the global circuit breaker insidesendBudget(pure): 429 for every code request, checked before the provisional insert;noteCeilingRefusal(route layer, once per 429) logs, countsvirtu_transactional_ceiling_refused_totaland notifies the operators once an hour. Pinned intransactional.int.test.ts,auth.int.test.ts,hygiene.int.test.ts. Still open, by design for now: two lockout paths the review named survive — three wrong/auth/verifyguesses from a stranger still kill the target's live codes (the brute-force bound), and the 3/h send budget is keyed on the target address, so three stranger requests 429 the target's own for the rest of the hour (the mail-bombing bound). Both are limited by the per-IP auth rate; fixing them means a requester dimension on those budgets, a design call not yet made. - Unbounded storage closed (P1, 2026-09-06).
policy.tsgathers the inbound rate count for drops too, so accept-and-drop tempfails 450 at the per-alias budget like a delivery (the blocked rows already counted).queue/quota.ts:pendingUsage(pending+sending rows andoctet_length(raw)per user) +decideQueueQuotaagainstQUEUE_MAX_PENDING_{ROWS,BYTES}_PER_USER(500 / 256 MiB, 0 = off) →RcptFacts.queueFull→452 4.3.1at RCPT; submission checks the same cap at DATA before any write (virtu_submission_queue_full_total, its own "your outbound queue" wording). Transactional mail, DSNs and operator mail bypass the cap on purpose (documented inquota.ts). The mx now tempfails the WHOLE message when a DELIVERY re-evaluates to a 4xx at DATA (before verify/enqueue, so no duplicates) — previously such a recipient was silently skipped; drops are budgeted at RCPT only (budgetDrops: falseat DATA), so a flooded disabled alias never tempfails its healthy co-recipients. Pinned inquota.unit.test.ts,quota.int.test.ts(usage +evaluateRcpt452),policy.unit.test.ts. - Plaintext SMTP AUTH closed (P1, 2026-09-06). Submission passes
requireAuthTlsunconditionally — no TLS means no AUTH, not clear-text AUTH — unless the dev-onlySUBMISSION_ALLOW_PLAINTEXT_AUTHis set; andconfig.ts assertProductionSmtpTls(called first thing inmaild— before port 25 binds, so a cert-less production box comes up with nothing listening rather than a flapping MX — and instartSubmission; the api still boots without certs) refuses to start in production withoutSMTP_TLS_*or with that flag. Pinned insubmission.unit.test.ts(socket level: no AUTH advertised,538on attempt) andconfig.unit.test.ts. - argon2 amplification in SMTP AUTH closed (P2, 2026-09-06, design
decided in the plan doc; hardened same day by a fresh-eyes review). The
device password's first group is now a plaintext selector
(
smtp_credentials.selector, migration 0002, unique per user):verifyCredentialssplits it off (auth/smtpPassword.ts), does one indexed lookup and ONE argon2id verify — never a loop over the user's rows — and any miss (unknown user, unsplittable paste, no selector match) burns a dummy verify so misses cost like wrong secrets. The password splits BEFORE any DB work; the dummy hash is promise-memoized (concurrent cold starts share one computation), warmed at submission startup, never caches a rejection (a failed computation retries next attempt, and the miss still refuses — degraded timing beats a downed mail path). Creation (routes/smtpCredentialRoutes.ts) stores the selector and hashes only the remaining groups, retrying the negligible per-user collision viadb/pgError.ts isUniqueViolation(the driver wraps SQLSTATE inerr.cause).selectoris NOT NULL (decision 2026-09-07: clear to blow out all old device passwords) — migration 0002 deletes every pre-selector row before adding the column, so the dead-credential class (looks healthy in the dashboard, counts against MAX_SMTP_CREDENTIALS, can never authenticate) is unrepresentable at the schema level; there is deliberately NO fallback verify loop. On top, a process-wide bulkhead (pipeline/verifyBulkhead.ts, 4 slots) meters ONLY the argon2id call — DB I/O never holds a slot, so DB latency can't starve real clients — whileonAuthsheds a pinned pool before any per-attempt work with454 4.7.0(virtu_submission_auth_total result="saturated"; deliberately NOsmtp_rejectionsrow on that path, which is globally triggerable and unpaced — a per-attempt INSERT would be its own amplification).verifyCredentialsnow returns"ok" | "invalid" | "saturated". Story fixtures mint selector-shaped credentials (stale/duplicate rows are cleared and re-minted, never healed in place — an UPDATE could collide on the unique index). ClientSmtp.tsxcopy says to paste the whole password. Pinned insmtpPassword.unit.test.ts,verifyBulkhead.unit.test.ts,submission.unit.test.ts(454 at the socket),smtpCredentials.int.test.ts(partial pastes),outbound.story.test.ts(real AUTH end-to-end). - P2 hardening sweep (2026-09-06). Look-alike service aliases:
routes/aliasText.ts isReservedAliasPrefixrefuses shared-domain prefixes that LEAD with a reserved word under a fuzzy walk — separators skipped (no.reply), look-alike digits read as letters (supp0rt,b1lling), trailing digit runs as a boundary (admin2,billing2024) — whilesupportive/account4ustay mintable;aliasNew.tsfoldsconfig.operatorLocalpartsinto the word list so the two reserved sets can't drift (custom domains exempt — the user's own namespace). Best-effort by design (decision 2026-09-07, documented inaliasText.ts): the guard stops at the mechanical variants; creative misspellings are accepted residual risk owned by abuse handling, not an ever-growing minting rule. Pinned inaliasText.unit.test.ts+aliases.int.test.ts. Length caps:deviceandemailon/auth/login+/auth/verifyand mailboxemailgo throughroutes/schema.ts trimmedString— trim first (what users expect from pasted input; the RFC-purist untrimmed reading lost, decision 2026-09-07), then the cap read off the drizzle-zod select schemas (apiKeySelect/userSelect/mailboxSelect), per that file's derive-don't-retype mandate — Zod 400s instead of Postgres varchar 500s, and a column resize can't reopen the gap (spec + SDK regenerated). DRY sweep on the way (the review noticed security-sensitive helpers being copy-pasted): SQLSTATE-23505 classification now has ONE definition (db/pgError.ts, cause-chain aware — the four local copies in auth/aliasNew/mailboxRoutes/ smtpCredentialRoutes checked only the top level, a latent 500 on wrapped errors), andnormalizeEmail/looksLikeEmailmoved toroutes/emailText.ts(three identical private copies). The fourtimingSafeEqualsites were audited and left alone — each length-guards correctly and verifies a different secret. The timing side channel on address existence stays noted-not-chased. Deploy bug: both Caddyfiles now proxyhandle /webhooks/*to the api, so Stripe's POSTs to/webhooks/stripe(outside/apiby design) no longer 404 on the www file server — still verify on lmnop before enabling billing on zinc.
- Backups — planned 2026-09-02 in
plans/backups.md: nightlypg_dump→age(pubkey on the box, private key on the workstation) → Backblaze B2 via rclone with a write-only key, gated onBACKUP_AGE_PUBKEY; retention by B2 lifecycle rules;bin/db-restoredrill into a scratch container;bin/backup-provisionfor the bucket. - Production/deploy story — mostly built (2026-08-10), first target
each.email: the serve stack (
docker-compose.serve.yml) now runs the whole app — db, api, built frontends behind the universalCaddyfile, andmaild(25/587/465, restart policies, aserver-depsone-shot so api + maild don't racebun install). Mail TLS reuses Caddy's cert: amail.{VIRTU_HOST}site exists for issuance, themail-certsone-shot copies it into themail_certsvolume,bin/mail-certs-syncre-syncs + bounces maild after renewals (listeners read certs once at startup — weekly cron on the box). Provisioning + deploy arebin/host-provision(swap, docker, virtu user) andbin/host-deploy(fetch + checkout, build, up, explicitrestart api— api is a stock bun image over the bind-mounted tree, soupalone left the old process running (caught 2026-09-05 at v0.3.5: two-day-old api, new code on disk; fixed v0.3.6), cert sync (which bounces maild) — takes an optional ref, defaults origin/main; since 2026-09-02 the serve stack'sdb-migrateone-shot applies committedserver/drizzle/migrations before api/maild start, so schema rides the deploy; push-era boxes are baselined once withjust db-baseline);bin/dkim-ensuremints/prints the service-domain DKIM key. Runbook: README "Deploy"; annotated DNS record set:each.email.zone. Each-box facts (2026-08-10): each.email rDNS → mail.each.email, outbound 25 open at Linode, Cloudflare DNS-only records live. Deploy trigger (2026-08-13):.github/workflows/deploy.yml— everyv*tag push SSHes to each.email asvirtuwith theEACH_SSH_KEYrepo secret; an authorized_keys forced command (restrict,command=) pins that key tobin/host-deploy "$SSH_ORIGINAL_COMMAND", host keys pinned in the workflow. What remains: backups, Linode Cloud Firewall, the zinc/lmnop boxes themselves. Secrets management:server/.envis gitignored, so nothing sensitive is in git — CI and any deploy must provide the production secrets out-of-band (VERP_SECRET, TLS cert/key paths, and the Stripe keys if billing is on).server/.env.exampleis the checklist of what to inject; on a box the compose-interpolation vars live in/opt/virtu/.env(also gitignored). - Mobile shells — post-MVP by decision #7 (native Swift/Kotlin over the
web client; share-extension is the flagship feature + App Store 4.2 answer).
Execution plan drafted 2026-08-12 in
plans/mobile.md: store-policy research findings, the bridge protocol contract, and nine workstreams (six parallelizable). Apple dev account + D-U-N-S already in hand; Play org account is the remaining paperwork. Track A landed (2026-08-12): bridge protocol v1 spec (client/src/shell.md) + shell seam (client/src/shell.ts—window.virtuShelldetection,apiKey.store/clear,share,external.open, web fallbacks). Wired: login/logout mirror the API key over the bridge (auth.ts), Billing hides all purchase UI in-shell (consumption-only posture). Verified:just check+ full dom tier green, incl. new shell-stub tests (Login bridge handoff,Billing.dom.test.tsx). Track A remainder: safe-area/touch/hover audit of the SPA in a real WebView; the blob-URL zone-file viewer needs shell-side handling (noted in shell.md). Track C scaffolded (2026-08-13): complete Android shell project inmobile/android/(Gradle/Kotlin, not a Bun package). Bridge protocol v1 native side:ShellProtocol.kt(pure-JVM message layer + JUnit conformance tests),ShellBridge.kt(origin-allowlisted WebMessageListener + document-start shim, neveraddJavascriptInterface),ApiKeyStore.kt(Keystore AES-GCM at rest — the storage Tracks E/F will read), plus a localStorage healing script re-seedingvirtu.apiKeyfrom Keystore.MainActivitycovers the plan's platform gotchas: native inset padding (edge-to-edge/targetSdk 36), native offline screen + retry, external links to the system browser, window.open/blob URLs in an in-app child WebView (the shell.md known gap), splash, rotation without SPA reload. Debug builds front the local dev stack (10.0.2.2:8080), release fronts zinc (-PvirtuWebOrigin=https://lmnop.emailfor staging). Verified on this box (no JDK/SDK locally): the shim + realclient/src/shell.tsseam pass the bun contract suitemobile/android/contract/shim.contract.test.ts(id correlation, error slugs, key round-trip — runnable with bare bun); Kotlin compile +./gradlew test+ the on-device checklist inmobile/android/README.mdawait a machine with Android Studio.applicationId(email.zinc.virtu) is placeholder-permanent — settle branding before the first Play upload. Review pass (2026-08-13): subagent direction review found no blockers; hardening applied: seam reply-timeout (10s) +openExternalhttp/https guard with error-reply fallback (shell.ts/shell.md); Billing's visit-the-web line is now Android-only (Apple anti-steering — decided in plans/mobile.md, enforced by a new iOS dom test); Android shell got arestoreState-empty fallback, an external-scheme allowlist for link navigations (http/https/mailto/tel — bridge parity), connectivity-only offline-screen triggers, error replies instead of silent drops for subframe bridge requests, gesture-only popups, and origin compare normalization. The contract tier is now official:just test-contract, wired intobin/check+ CI + CLAUDE.md's tier list. Left alone deliberately: the paraspace root-package changes (user tooling, just keep them out of the mobile commit). Track E scaffolded (2026-08-13): Android share target —ShareActivity(dialog-themed mini-activity: options → reuse the server's per-site recommendation or mint random, copy, done — never launches the main app),SharedHostname.kt(pure-JVM hostname extraction from shared text, JUnit suite),VirtuApi.kt(the two-endpoint native API slice,Authenticationheader), sharing-shortcut plumbing (shortcuts.xml+ dynamic "New alias" shortcut published from MainActivity). The API flow the native code bakes in was verified live against the dev stack via curl: options?hostname → mint random?hostname → options returns the minted alias asrecommendation. Zero server changes, as the plan promised. On-device checklist items 11–12 added to mobile/android/README.md. Track B scaffolded (2026-08-13): complete iOS shell inmobile/ios/— XcodeGenproject.ymlis the committed project definition (the .xcodeproj is generated on the Mac, gitignored). Protocol v1 native side:ShellProtocol.swift(pure message layer + XCTest conformance suite mirroring the Kotlin one),ShellBridge.swift(WKScriptMessageHandlerWithReplyin the page world — the reply promise replaces Android's id envelope — plus the document-start shim and the Keychain-backed localStorage healing script),KeychainStore.swift(shared access group…virtu.shared, entitlements committed, ready for Tracks D/G),ShellViewController(offline screen filtered to connectivity URLErrors, external-scheme allowlist matching Android, window.open/blob child WKWebView sharing the parent's browsing context, login kept in-webview, iPad share-sheet popover anchor),PrivacyInfo.xcprivacy. Verified on this box (no Mac): the iOS shim + real seam pass their own bun contract suite;just test-contract(and bin/check + CI) now runsbun test mobile— 14/14 across both platforms in one process (suites scrub each other's globals). Xcode compile + simulator checklist (mobile/ios/README.md) await a Mac;PRODUCT_BUNDLE_IDENTIFIERis placeholder-permanent like the Android applicationId. Track F scaffolded (2026-08-13): Android autofill — the plan's open competitive lane (SimpleLogin ships none).VirtuAutofillServicewalks the AssistStructure withEmailField.kt(pure-JVM heuristics, JUnit-tested: autofill/autocomplete hints → InputType variations → HTML input type → name fallback, password fields excluded at every layer) and answers email fields with ONE dataset — dropdown RemoteViews + inline keyboard chip (InlineSuggestionUi, API 30+, androidx.autofill) — whose value comes from dataset authentication: no network inonFillRequest; tapping the chip launches the translucentAutofillMintActivity, which runs the same options→reuse-recommendation-or-mint flow as the share target (VirtuApi+ApiKeyStore, the building blocks Tracks C/E laid) and returns the filled dataset viaEXTRA_AUTHENTICATION_RESULT. Site context = browserwebDomain(normalized bySharedHostname.normalize, now public); native apps mint hostname-less. Logged out → the service stays silent. Onboarding:AutofillSetupActivity(deep-link to the system autofill picker, live status viahasEnabledAutofillServices, Chrome's "Autofill using another service" walkthrough), reachable via a static long-press shortcut, as the service'ssettingsActivitygear, and as the inline chip's attribution target. Verified on this box: JUnit heuristics suite authored (runs with./gradlew teston a JDK machine); on-device checklist items 13–15 added to mobile/android/README.md. No client or server changes. - Browser extension (2026-09-05) — the legacy Chrome/Firefox extension
(
tmp/virtu/browser-extension, already Manifest V3) ported toextension/as its own Bun package. The toolbar popup is the built client SPA verbatim (bin/extension-buildcopiesclient/disttobuild/app/and injects the shim + a popup stylesheet into its<head>), so one client build serves the site, the webviews, and the extension. The extension is a third shell of the seam:ShellPlatformgained"extension",VirtuShellan optionalapiOrigin(the popup ischrome-extension://…, where the SDK's relative/apipoints nowhere —api/client.tsprefixes it), andisExtension()switches the router to hash history with no basepath (the popup is a file) and the 401 bounce to#/login— the one place the platform changes routing rather than a capability (shell.md updated). Shimsrc/popup-shell.ts:apiKey.store/clear↔chrome.storage.local,external.open→ new tab,share→ Web Share API orfailed, plus localStorage healing fromchrome.storage(the Android shell's Keystore trick). Content scriptsrc/content.ts(the legacy inject.js on the SimpleLogin API, Popper dropped, UI in a closed shadow root): the [Z] button on email fields, a menu of aliases used on this site (POST /v2/aliases {query: hostname}+/v5/alias/options?hostname=recommendation first), "New alias for " via/alias/random/new ?hostname=with aUsed on <host>note, right-click items (fill with latest-or-mint / show button) routed to the right-clicked FRAME, per-site cache inchrome.storage.local, 401 → "Sign in" opens the app in a tab. API calls relay through the background worker (src/background.ts,src/api.ts,src/messages.ts) — MV3 host permissions don't cover content scripts.VIRTU_ORIGINat build time picks the deployment (bakes the API origin + the single host permission). Verified:bin/checkgreen (extension typecheck added tobin/typecheck;extension/contract/ shim.contract.test.ts— 7 tests — drives the real seam through the real shim, now injust test-contract/bin/check/CI),bin/extension-buildproduces a loadableextension/build/. Not yet loaded in a real browser on this box — the load-unpacked walk inextension/README.mdis the next step; Firefox signing (web-ext sign) not wired. Login in the popup is separate from the website's (own origin) and the pending emailed-code step doesn't survive the popup closing (deliberately left simple; a persisted pending-login is the follow-up).
- Bun server-side STARTTLS (
server/src/smtp/server.ts,upgradeServerSocket): loopback tls.Server bridge until a Bun release ships PR #34598 (merged 2026-07-25; latest release 1.3.14 predates it). On upgrade: swap to native path, runbun test server/src/smtp, delete bridge; also pin theoven/buncompose image to the host version. - Bun
node:dnsflattens multi-string TXT records (server/src/pipeline/dnsTxt.ts): wire-format TCP client used for all TXT lookups (DKIM keys span multiple character-strings). Upstream-reportable; remove if Bun fixes grouping. - Bun
node:httpWebSocket upgrades hang (oven-sh/bun#35325: the upgrade event fires but bytes written to the handed-off socket are silently discarded — verified fixed upstream for 1.4.0, unreleased as of 1.3.14). rsbuild's HMR socket never completes its handshake under bun (diagnosed 2026-08-10 by running the identical server under node, which connects instantly). The client dev container is thereforenode:26-bookworm-slimwith a one-shotclient-depsservice (oven/bun:1.3) doing thebun install— bun remains the only package manager, node is only the dev-server runtime. HMR ws moved under the base (/app/rsbuild-hmr) so the dev proxy needs no extra route. When a Bun release ≥1.4.0 ships: collapse the two services back to oneoven/buncontainer and re-verify HMR live (workaround #1 lifts on the same release). - Stripe account API version is 2018-02-28 — webhook payload shapes are
version-pinned per account; the handler reads both pre-Basil and current
current_period_endshapes, so upgrading the account's API version later is safe but should be followed by a re-run of the live loop. - Astro dev-server lock is pid-based and can't cross a pid namespace
(diagnosed 2026-08-10, fixed same day):
www/.astro/dev.jsonrecords the dev server's pid, and the file lives in the bind mount, so it outlives the container. Astro's staleness check isprocess.kill(pid, 0)— in a fresh pid namespace that pid is reliably alive (it's this astro process; the tree is deterministic, so it landed on 14 every time), so the lock never reads as stale.--forcethen "replaces the running server" by SIGTERMing that pid, i.e. itself:wwwdied 0.5s after every start with exit 143, andjust up --waitfailed on it. Fixed by running the container's dev server with--ignore-lock(docker-compose.ymlwww.command) — the lock is neither read nor written, so no staledev.jsonis ever left behind; host-sidejust www-devkeeps normal lock semantics. Upstream-reportable (the lock should carry a boot id / process start-time, not a bare pid); revisit if Astro hardens it.
API keys stored sha256 (every verify mints a new key; SL returns the stored
one) · auth is passwordless (PLAN decision #13): one login/verify code
flow replaces SL's register/activate/reactivate/login, users has no
password column, sudo re-auth is an emailed code (PATCH /sudo two-step) and
SMTP AUTH takes device credentials only ·
mailbox verification is a code endpoint (SL: web link) · logout revokes the
API key · ownership token prefix vt-verification= · per-domain DKIM TXT
instead of SL's CNAME (we sign with the domain's own key — better alignment) ·
custom-domain DELETE immediate (SL schedules) · GET /api/billing/* endpoints
exist beyond SL's surface (SL used Paddle) · default list sort matches SL
pinned-then-activity.
- Design doc + settled decisions:
PLAN.md - Mail pipeline:
server/src/{smtp,mailauth,mail,pipeline,queue}/, entrypointsserver/src/{mx,submission,deliverd,maild,api}.ts - API + spec:
server/src/routes/, committedserver/spec/openapi.json - Simulated internet:
docker-compose.test.yml,server/docker/test/, harness + stories inserver/test/ - Client:
client/src/(SDK regenerated from spec, gitignored) - Homepage:
www/