Appearance
Cron jobs
Status: implemented
The server runs a scheduled Cloudflare Worker that polls DNS for domains and claims awaiting verification. It reuses the same verification logic as the tRPC mutation domains.verify — cron is the background path when the client is not actively polling.
Schedule
| Setting | Default | Source |
|---|---|---|
| Wrangler cron | */5 * * * * (every 5 minutes) | wrangler.toml |
| Poll interval | 5 min | DNS_POLL_INTERVAL_MS env (default in _constants.ts) |
| Domain verification window | 6 hours from verification_started_at | DOMAIN_VERIFICATION_WINDOW_MS |
| Claim verification window | 6 hours → claim expires_at | CLAIM_VERIFICATION_WINDOW_MS |
See ADR 012. Cron frequency matches the default poll interval so due rows are picked up on the next tick without waiting longer than necessary.
Architecture
Each tick of runDueVerification runs in this order:
- Fail expired pending claims → emit
claim.expired - Fail stale pending domain verifications → emit
domain.verification.timed_out - Poll due domain DNS records (
verify-domains-job.ts) - Poll due claim TXT records (
verify-claims-job.ts)
Email side effects are wired through the in-process event bus; see Events & notifications.
Shared modules
These modules are used by cron and the tRPC mutations:
| Module | Used by |
|---|---|
utils/poll-policy.ts | cron, domains.verify |
services/verify-dns-records.ts | cron, domains.verify |
services/claim.ts + utils/verify-claim-dns.ts | cron, domains.verify claim path |
utils/dns-lookup.ts | all of the above |
repositories/* | cron (due-row queries), services (reads/writes) |
@/events + @/notifications | cron and tRPC contexts (event bus + email listeners) |
Job 1: Domain verification poll
Polls email setup records (SPF, DKIM, MX, etc.) for user_domains in active verification.
Selection query
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)| Parameter | Value |
|---|---|
:windowStart | now - DOMAIN_VERIFICATION_WINDOW_MS |
:pollCutoff | now - DNS_POLL_INTERVAL_MS |
Index: user_domains_pending_verification_idx (status, verification_started_at)
Soft-deleted rows (deleted_at IS NOT NULL) are excluded from both cron jobs. domains.delete also fails any pending claim for the deleted claimant so claim polls do not keep selecting them.
See domain-model.md for table definitions.
Per-row actions
- Build expected records via
buildDnsRecord()(names and values computed at runtime, not stored). - Resolve FQDNs and query DNS (TXT for SPF/DKIM/DMARC, MX for mail records).
- Compare results → update
user_domains.dns_recordsper-keystatusandlastError. - Set
dns_checked_at = now. - Refresh
domains.dns_providerfrom NS lookup (best-effort). - If all required records pass →
user_domains.status = verified,verified_at = now.
Required records depend on flags: spf_txt, dkim_txt, spf_mx when sending_enabled; add inbound_mx when receiving_enabled. dmarc_txt is optional (shown in UI, not blocking).
Outcomes
| DNS result | Row state |
|---|---|
| All required records match | verified |
| Some records missing or wrong | stays pending; failed keys get status = failed + lastError |
Rows whose verification window has elapsed are not selected by this job. The failure pass at the start of the tick marks them failed and emits domain.verification.timed_out. The client can restart via domains.verify.
Job 2: Claim verification poll
Polls the challenge TXT record for pending ownership claims.
Selection query
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 domain_claims.expires_at (set at claim start, typically now + CLAIM_VERIFICATION_WINDOW_MS). Starting a new claim eager-fails another user's expired pending claim so competitors are not blocked until the next cron tick (ADR 008).
Per-row actions
- Build expected apex TXT:
sendagain-claim={code}viabuildClaimDnsRecord(). - Query apex TXT records for the domain hostname.
- Set claimant
user_domains.dns_checked_at = now. - On match → atomic D1 batch:
- Incumbent:
status = revoked,revoked_at = now - Claimant:
status = verified,verified_at = now, rotate DKIM keypair - Claim:
status = verified,verified_at = now
- Incumbent:
- On miss → claim stays
pending; onlydns_checked_atupdates.
Window end
When expires_at <= now, the row is no longer selected by the claim poll query. The failure pass at the start of each tick (before Jobs 1 and 2) sets claim.status = failed and claimant user_domain.status = failed, then emits claim.expired for each transitioned claimant. The same pass fails stale normal verifications (verification_started_at outside the window) and emits domain.verification.timed_out. Email listeners send the verification follow-up for both events. The user retries by calling domains.verify again, which revives the same claim with the same challenge code and a new window.
Poll policy (shared)
A DNS lookup runs when any of these is true:
dns_checked_atisnullnow - dns_checked_at >= DNS_POLL_INTERVAL_MS- First verification start or restart from
failed(tRPCdomains.verifyruns immediately on those transitions)
Otherwise the handler returns the current DB state without a network call.
Configuration
Wrangler
toml
[triggers]
crons = ["*/5 * * * *"]
[[d1_databases]]
binding = "DB"
database_name = "send-again"Worker entry point
ts
export default {
fetch(...) { ... },
scheduled(_event, env, ctx) {
ctx.waitUntil(runDueVerification(env));
},
};Implementation: apps/server/src/index.ts → run-due-verification.ts.
Cron jobs live under apps/server/src/modules/domains/cron-jobs/:
| File | Role |
|---|---|
run-due-verification.ts | Entry point — fail stale windows, run both jobs |
verify-domains-job.ts | Job 1: due user_domains → DNS verification service |
verify-claims-job.ts | Job 2: due claims → claim verification service |
Environment
| Variable / binding | Purpose |
|---|---|
DB | D1 database binding |
DKIM_ENCRYPTION_KEY | AES-GCM key for encrypted DKIM private keys (required; use wrangler secret put in production) |
DNS_MOCK | Set to "1" in local dev to use canned DNS answers |
RESEND_API_KEY | Optional; when unset/empty, outbound email is a no-op |
EMAIL_FROM | From address for transactional email (e.g. SendAgain <onboarding@sendagain.wellg.dev>) |
APP_URL | App base URL used in email links |
Local development
| Task | Command / setting |
|---|---|
| Run server | pnpm dev:server (or pnpm --filter @send-again/server dev) |
| Enable scheduled testing | wrangler dev --test-scheduled (from apps/server) |
| Trigger cron manually | Visit http://localhost:8787/__scheduled while wrangler dev --test-scheduled is running |
| Mock DNS answers | DNS_MOCK = "1" in wrangler vars |
With DNS_MOCK=1, lookups return canned answers from an in-memory map keyed by FQDN. Use this to test verification without publishing real DNS records.
Idempotency and limits
- Poll cutoff prevents duplicate DoH queries within
DNS_POLL_INTERVAL_MS. - Claim transfer runs in a single D1 batch so concurrent cron + client poll cannot double-transfer.
- Cron processes all due rows each tick; no per-tick row cap in v1 (acceptable at challenge scale).
- DoH failures set
lastErroron affected records; the row remains eligible on the next tick.
Related docs
- Domain model — tables, enums, indexes, business flows, events & notifications
- API reference —
domains.verify(client-driven polling)