Skip to content

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

SettingDefaultSource
Wrangler cron*/5 * * * * (every 5 minutes)wrangler.toml
Poll interval5 minDNS_POLL_INTERVAL_MS env (default in _constants.ts)
Domain verification window6 hours from verification_started_atDOMAIN_VERIFICATION_WINDOW_MS
Claim verification window6 hours → claim expires_atCLAIM_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:

  1. Fail expired pending claims → emit claim.expired
  2. Fail stale pending domain verifications → emit domain.verification.timed_out
  3. Poll due domain DNS records (verify-domains-job.ts)
  4. 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:

ModuleUsed by
utils/poll-policy.tscron, domains.verify
services/verify-dns-records.tscron, domains.verify
services/claim.ts + utils/verify-claim-dns.tscron, domains.verify claim path
utils/dns-lookup.tsall of the above
repositories/*cron (due-row queries), services (reads/writes)
@/events + @/notificationscron 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)
ParameterValue
:windowStartnow - DOMAIN_VERIFICATION_WINDOW_MS
:pollCutoffnow - 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

  1. Build expected records via buildDnsRecord() (names and values computed at runtime, not stored).
  2. Resolve FQDNs and query DNS (TXT for SPF/DKIM/DMARC, MX for mail records).
  3. Compare results → update user_domains.dns_records per-key status and lastError.
  4. Set dns_checked_at = now.
  5. Refresh domains.dns_provider from NS lookup (best-effort).
  6. 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 resultRow state
All required records matchverified
Some records missing or wrongstays 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

  1. Build expected apex TXT: sendagain-claim={code} via buildClaimDnsRecord().
  2. Query apex TXT records for the domain hostname.
  3. Set claimant user_domains.dns_checked_at = now.
  4. 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
  5. On miss → claim stays pending; only dns_checked_at updates.

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_at is null
  • now - dns_checked_at >= DNS_POLL_INTERVAL_MS
  • First verification start or restart from failed (tRPC domains.verify runs 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.tsrun-due-verification.ts.

Cron jobs live under apps/server/src/modules/domains/cron-jobs/:

FileRole
run-due-verification.tsEntry point — fail stale windows, run both jobs
verify-domains-job.tsJob 1: due user_domains → DNS verification service
verify-claims-job.tsJob 2: due claims → claim verification service

Environment

Variable / bindingPurpose
DBD1 database binding
DKIM_ENCRYPTION_KEYAES-GCM key for encrypted DKIM private keys (required; use wrangler secret put in production)
DNS_MOCKSet to "1" in local dev to use canned DNS answers
RESEND_API_KEYOptional; when unset/empty, outbound email is a no-op
EMAIL_FROMFrom address for transactional email (e.g. SendAgain <onboarding@sendagain.wellg.dev>)
APP_URLApp base URL used in email links

Local development

TaskCommand / setting
Run serverpnpm dev:server (or pnpm --filter @send-again/server dev)
Enable scheduled testingwrangler dev --test-scheduled (from apps/server)
Trigger cron manuallyVisit http://localhost:8787/__scheduled while wrangler dev --test-scheduled is running
Mock DNS answersDNS_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 lastError on affected records; the row remains eligible on the next tick.