Skip to content

Custom tracking domain per team - #124

Merged
clbotdev merged 4 commits into
own-esp-pacingfrom
tracking-domain
Oct 22, 2026
Merged

clbotdev merged 4 commits into
own-esp-pacingfrom
tracking-domain

Conversation

@clbotdev

Copy link
Copy Markdown
Contributor

Closes #50. Stacked on #123.

Tracking domain

  • Settings (migration 0025): tracking_domain (unique), status pending | verified | failed, last error, verified/checked times, failure count, and retired_tracking_domains.
  • Verification (settings/tracking-domain/queries.ts): the CNAME must point to TRACKING_DOMAIN_CNAME_TARGET (default: the API host), and https://<domain>/.well-known/sendlit-tracking must answer, so TLS works before links switch. Subdomains only; SendLit's own hosts can't be claimed.
  • Rechecks: pending domains hourly, verified ones daily (claimed per row so API instances don't double-check). After 3 failed daily checks a domain becomes failed and new emails fall back to the API host.
  • Links: broadcasts, sequences, test sends and tracked transactional emails put the pixel, clicks, unsubscribe, List-Unsubscribe header and view-in-browser link on the verified domain.
  • Retired domains: changing or removing a verified domain keeps the old one served, so links already sent keep working.
  • Host guard: on a tracking domain (current or retired), only /track/*, /unsubscribe/*, /view/* and the probe answer; everything else returns 404.
  • TLS: GET /tls/ask?domain= returns 200 for teams' current or retired domains, for Caddy on_demand_tls. Docs: self-hosting/tracking-domains.
  • Surfaces:
    • REST: GET/PUT /settings/tracking-domain, POST /settings/tracking-domain/verify.
    • MCP: get_tracking_domain, set_tracking_domain, verify_tracking_domain (parity manifest updated).
    • Dashboard: a "Tracking domain" card in Settings → General.
    • Docs: email-marketing/tracking-domain.

Return-path

This was already modelled and checked per provider (SES/Resend MAIL FROM, Postmark, SendGrid em<id>). The SPF section of the domain authentication dialog now names the bounce (Return-Path) domain when it differs.

Hosted rollout (outside this repo)

The hosted Caddyfile needs the on-demand TLS block from the self-hosting doc (ask http://api:4000/tls/ask plus a catch-all https:// site). Until then, domains stay pending (unreachable) and links keep using the API host.

Tests

Validation; set, taken and retire; CNAME/probe outcomes; daily recheck fallback; host guard on current and retired domains; TLS ask; worker send puts every link and the List-Unsubscribe header on the domain.

A team can put open, click, unsubscribe and view-in-browser links on its
own subdomain. The domain is verified by its CNAME and an HTTPS probe,
rechecked hourly while pending and daily once verified, and falls back to
the API host after three failed daily checks. Retired domains keep being
served so links in sent emails keep working. On a tracking domain only
the tracking routes answer; /tls/ask lets an on-demand TLS proxy issue
certificates for teams' domains. REST, MCP, dashboard card and docs
included. The domain authentication dialog now names the bounce
(Return-Path) domain in its SPF section.

Closes #50
# Conflicts:
#	apps/api/.env.example
#	apps/api/src/automation/process-ongoing-sequence.ts
#	apps/api/src/mail/worker.ts
#	apps/api/src/sequences/test-send.ts
#	apps/docs/content/docs/self-hosting/meta.json
@clbotdev
clbotdev merged commit a3c198f into own-esp-pacing Oct 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants