Appearance
Domain model
Status: implemented
Scope: domain add, DNS verification, claim transfer. DNS checks are simulated/looked up by the backend; outbound transactional email uses the Resend API.
Auth tables
users
- id: nanoid
- email: string (unique, normalized lowercase)
- password_hash: string (PBKDF2; salt + hash)
- created_at: timestamp
- updated_at: timestamp
sessions
- id: nanoid (session token; value of the
sessioncookie) - user_id: nanoid → users (
ON DELETE CASCADE) - expires_at: timestamp (30 days from creation)
- created_at: timestamp
Expired sessions are deleted on lookup. Logout deletes the session row and clears the cookie.
Constraints
domains.hostname— unique, normalized (lowercase, trimmed, punycode)user_domains (domain_id, user_id)— unique among non-deleted rows — partial unique indexuser_domains_domain_user_unique(WHERE deleted_at IS NULL)- At most one
user_domainwithstatus = verifiedper domain — partial unique indexuser_domains_one_verified_per_domain - At most one
pendingclaim perdomain_id— partial unique indexdomain_claims_one_pending_per_domain(eager-fail expired pending before starting a new claim; see ADR 008)
Current owner: query user_domains where domain_id = ?, status = 'verified', deleted_at IS NULL. No denormalized pointer on domain (ADR 014).
- No verified row → domain open for add + verify
- Verified row for another user →
domains.createstarts or refreshes a pending claim; failed claims restart viadomains.verify - Claim transfer: revoke incumbent + verify claimant + rotate DKIM in one D1 batch (never two separate steps); conditional updates prevent lost-race partial state (ADR 013)
- At most one in-window pending claim per hostname; expired pending claims are failed before a competitor can start (ADR 008)
- At most one
pendingclaim perclaimant_user_domain_id(enforce in app) - Soft-delete (
deleted_at) vs claim loss (revoked+revoked_at) are distinct end states (ADR 010) - Ownership (
verified) is separate from DNS readiness flags on the API (ADR 011)
Enums
Verification status (per record and derived domain status):
not_started | pending | verified | failed
User domain status:
not_started | pending | verified | failed | revoked
Claim status:
Claims use the same verification status lifecycle: pending | verified | failed. Challenge proof is apex TXT (ADR 007); retries reuse the same code (ADR 009).
Region (set at domain creation):
us-east-1 | eu-west-1 | sa-east-1 | ap-northeast-1
DNS provider (cached from NS lookup for UI):
cloudflare | route53 | godaddy | namecheap | vercel | google_domains | unknown
Add providers as needed; use unknown when NS pattern doesn't match.
domain
Global hostname registry. One row per hostname.
- id: nanoid
- hostname: string (unique)
- region: enum (see above)
- dns_provider: enum (nullable; cached for UI — see DNS provider enum)
- dns_provider_checked_at: timestamp (nullable; when provider was last resolved)
- created_at: timestamp
- updated_at: timestamp
dns_provider cache: resolved server-side from a live NS lookup during normal verification DNS polls (verify-dns-records.ts), then stored for UI (“DNS hosted with Cloudflare”). Not used for verification logic. Prefer this over caching raw ns_records — the UI only needs the label; detection logic stays in one place on the server.
user_domain
A user's relationship to a domain (owner, former owner, or in-progress setup). Was domain_user.
id: nanoid
domain_id: nanoid → domain
user_id: nanoid → users
status: enum (see above)
dkim_public_key: string (base64 SPKI public key; used to build DKIM TXT
p=…)dkim_private_key_encrypted: string (PKCS8 private key, encrypted)
sending_enabled: boolean (default: true; mutable via
domains.updateSettings; both flags may be false)receiving_enabled: boolean (default: false; mutable via
domains.updateSettings— when enabled,inbound_mxbecomes required for verification)dns_records: json (verification state only for email setup records; aggregate domain status derived in app)
Per record type (
dkim_txt,spf_txt,spf_mx,dmarc_txt,inbound_mx):- status: enum (verification status)
- lastError: string (nullable; shown when status = failed)
Required for verification:
spf_txt,dkim_txt,spf_mxwhensending_enabled(default). Optional (shown in UI, not blocking):dmarc_txt. Addinbound_mxwhenreceiving_enabled.Claim challenge TXT is not stored here — it is verified separately via the claim flow (see
domain_claim).
Last DNS check time: dns_checked_at on user_domain (one timestamp per verification poll). Also updated during claim verification polls.
DNS record names and values are not stored. The server builds them at read/verify time via buildDnsRecord() in build-dns-record.ts. The name field is the host label as entered in a DNS provider (@ = zone apex). FQDN examples for example.com:
| Record | Name (host label) | FQDN example | Value |
|---|---|---|---|
dkim_txt | sendagain._domainkey | sendagain._domainkey.example.com | p={dkim_public_key} |
spf_txt | send | send.example.com | global SPF constant (v=spf1 include:sendagain.com ~all) |
spf_mx | send | send.example.com | feedback.{region-host} (e.g. feedback.us-east-1.sendagain.wellg.dev) |
dmarc_txt | _dmarc | _dmarc.example.com | global DMARC template (v=DMARC1; p=none) |
inbound_mx | @ | example.com | inbound.{region-host} (e.g. inbound.us-east-1.sendagain.wellg.dev) |
Claim challenge TXT (built via buildClaimDnsRecord(), not buildDnsRecord()):
| Record | Name (host label) | FQDN example | Value |
|---|---|---|---|
| challenge TXT | @ | example.com | sendagain-claim={domain_claim.code} |
DKIM selector: hardcoded as sendagain in server code (not stored).
- verification_started_at: timestamp (nullable; set at claim start or normal verification start)
- dns_checked_at: timestamp (nullable; last verification poll — all records checked together)
- verified_at: timestamp (nullable)
- revoked_at: timestamp (nullable; set when status becomes
revoked, e.g. after a successful claim) - created_at: timestamp
- updated_at: timestamp
- deleted_at: timestamp (nullable; soft delete via
domains.delete— not the same asrevoked, which is claim-transfer only)
Readiness (API only, not stored): DomainDetail.sendingReady / receivingReady are derived from flags + required dns_records statuses (ADR 011).
domain_claim
Created by domains.create when a user tries to add a hostname already verified by someone else. Inserts a pending claimant user_domain alongside the claim when needed.
- id: nanoid
- domain_id: nanoid → domain (denormalized for pending-claim uniqueness)
- claimant_user_domain_id: nanoid → user_domain (the claimant's in-progress row)
- incumbent_user_domain_id: nanoid → user_domain (nullable; current verified owner at claim start)
- code: string (claim token from
nanoid(32); used to build the challenge TXT value — uniqueness by generation, not a DB unique index) - status: enum (see above)
- expires_at: timestamp (deadline for the current claim cycle; typically
now + CLAIM_VERIFICATION_WINDOW_MS— ADR 012) - verified_at: timestamp (nullable)
- created_at: timestamp
- updated_at: timestamp
Verification (time-based, no attempt counter):
- Set
user_domain.verification_started_atat claim start; setexpires_aton the claim - Poll via cron or
domains.verifyuntil success orexpires_at, respectingDNS_POLL_INTERVAL_MS - DNS miss during the window: claim stays
pending; updatedns_checked_atonly - Window ends without success:
claim.status = failed; claimantuser_domain.status = failed - Retry after failure: call
domains.verifyagain; revive the same claim with the samecode, a newexpires_at, refreshed incumbent, resetverification_started_at, and cleareddns_checked_at - Lost transfer race:
claim.status = failed; claimantuser_domain.status = failed
DNS verification cron
A scheduled task polls DNS for rows due for a check. Indexed queries:
Normal domain verification (email records on user_domains.dns_records):
sql
SELECT * FROM user_domains
WHERE status = 'pending'
AND deleted_at IS NULL
AND verification_started_at IS NOT NULL
AND verification_started_at > :windowStart
AND NOT EXISTS (
SELECT 1 FROM domain_claims dc
WHERE dc.claimant_user_domain_id = user_domains.id
AND dc.status = 'pending'
)
AND (dns_checked_at IS NULL OR dns_checked_at <= :pollCutoff):windowStart=now - DOMAIN_VERIFICATION_WINDOW_MS:pollCutoff=now - DNS_POLL_INTERVAL_MS- Index:
user_domains_pending_verification_idx (status, verification_started_at)
Claim verification (challenge TXT via active claim):
sql
SELECT dc.*, ud.*
FROM domain_claims dc
JOIN user_domains ud ON ud.id = dc.claimant_user_domain_id
WHERE dc.status = 'pending'
AND dc.expires_at > :now
AND ud.deleted_at IS NULL
AND (ud.dns_checked_at IS NULL OR ud.dns_checked_at <= :pollCutoff)- Index:
domain_claims_pending_expires_idx (status, expires_at) - Claim deadline is
expires_aton the claim;verification_started_aton the claimantuser_domainis set at claim start and reset on retry.
Flows
| Step | What happens |
|---|---|
| Add fresh domain | Insert domain + user_domain; generate DKIM keypair (store public + encrypted private); init dns_records state; status → pending with verification_started_at null |
First domains.verify | Sets verification_started_at and runs an immediate DNS check (also covers not_started / restart from failed). Claim start sets the timestamp immediately when the claim is created. |
| Poll verification | Cron (or client) DNS lookup vs built expected records; update dns_records.* state; refresh domain.dns_provider cache on each poll; on all required records verified → user_domain.status = verified, set verified_at; soft-delete competing pending/not_started/failed rows for the same hostname and emit domain.verification.superseded |
| Add taken domain | domains.create inserts or reuses claimant user_domain + starts domain_claim; user publishes challenge TXT |
| Claim verify poll | DNS lookup for challenge TXT vs domain_claim.code; poll until expires_at |
| Claim retry | After failure: call domains.verify again; revive the same claim with the same code, new expires_at, refreshed incumbent, and reset claimant poll state |
| Claim succeeds | Revoke incumbent (revoked, revoked_at); verify claimant; rotate DKIM keypair (one transaction); emit claim.transferred (emails previous owner) |
| Verification window ends | Pending normal verification or claim marked failed; emit domain.verification.timed_out or claim.expired (emails the affected user) |
| Update settings | domains.updateSettings persists sending_enabled / receiving_enabled; initializes missing required dns_records keys to not_started; never demotes verified ownership |
| Verify after settings | If verified but newly required records are incomplete, domains.verify re-checks DNS and updates record state only (keeps verified) |
| Delete domain | Soft-delete user_domain (deleted_at); fail pending claim if claimant; shared domains row kept; same user may re-add hostname |
Events & notifications
Domain services and cron emit in-process events; email is a side effect, not part of the domain transaction.
Locations: apps/server/src/events/ (@/events), apps/server/src/notifications/ (@/notifications).
Wiring: createDomainServiceContext / createDomainCronContext create an EventBus (unless one is injected) and call registerEmailListeners. Handlers are awaited; failures are logged and do not fail the domain path. Outbound email uses Resend when RESEND_API_KEY is set; otherwise the mailer is a no-op.
| Event | Emitted when | |
|---|---|---|
claim.transferred | Claim TXT verified and transfer batch succeeds | mailer.sendDomainClaimedByAnotherAccount → DomainClaimed template (previous owner) |
domain.verification.superseded | Another user verifies first; competing pending/not_started/failed user_domain rows are soft-deleted | same claimed mailer method (each superseded user) |
claim.expired | Claim window ends (expires_at passed) via cron or domains.verify | mailer.sendDomainVerificationFollowUp → DomainVerificationFollowUp |
domain.verification.timed_out | Normal verification window ends via cron or domains.verify | same follow-up mailer method |
VerificationFollowUpRecipient payload: { userId, email, hostname, userDomainId } (also used by domain.verification.superseded). claim.transferred payload includes previous-owner identity and both user-domain ids.