Skip to content

feat(api): report what each domain still needs to receive mail - #247

Open
Waiel5 wants to merge 3 commits into
choyiny:mainfrom
Waiel5:feat/domains
Open

feat(api): report what each domain still needs to receive mail#247
Waiel5 wants to merge 3 commits into
choyiny:mainfrom
Waiel5:feat/domains

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Independent of my other PRs — branches from main.

Why

saasmail has no concept of a domain: inboxes are keyed by full address. So an operator who has just pointed a new domain at their deployment has no way to see whether it actually works, and nothing tells them what is wrong when mail bounces.

That is not hypothetical — on my own instance Email Routing was enabled and ready, the MX records were correct, and every message still bounced 550 5.1.1 because the catch-all rule was disabled, action: drop. Nothing in saasmail could have told me that.

What

GET /api/domains, admin-only. Domains are derived from the addresses that already exist — DISTINCT emails.recipient UNION sender_identities.email, split at the @ — so there is no new table and no migration. Per domain: inbox count, message count, and a live DNS status.

The DNS part is the useful half. A Worker cannot do raw DNS, but it can query Cloudflare's DoH resolver, so MX, SPF and DKIM are each resolved independently and reported as cloudflare / elsewhere / none / unknown, with the exact records still missing.

Two things I want to draw attention to because getting them wrong is worse than not shipping the feature:

SPF is merged, never appended. Only one SPF record per name is legal, so a domain already carrying v=spf1 include:_spf.google.com ~all gets back that record with the Cloudflare include spliced in before the terminal all, marked action: "replace". An earlier revision emitted the canonical standalone value, which an operator following it literally would have published as a second record — breaking their outbound deliverability. Cloudflare's own docs say the same: "If you have existing SPF records, merge them".

DKIM's value is null, deliberately. The selector cf2024-1._domainkey is documented and checked, but the p= key is generated per zone, so the response reports the record as required with a null value and a note telling the operator to copy it from the dashboard. A wrong DKIM value is worse than an absent one.

unknown is never a verdict about the zone. A resolver timeout, a non-2xx, an unparseable body and SERVFAIL all read as "could not check", and NXDOMAIN reads as "no record" — those are different answers and are not conflated. The three checks resolve independently, so a failed TXT lookup does not suppress what MX established.

Tests

23 in worker/src/__tests__/domains-router.test.ts. fetch is stubbed and the helper throws on any domain not declared, so an accidental real lookup fails loudly rather than passing by luck.

Two defects found while writing them, both fixed here:

  • the SPF include check was a bare includes(), so include:_spf.mx.cloudflare.net.evil.example read as ours and the domain showed green. It now matches whole terms with the qualifier stripped, and the same predicate decides both the status and whether a merge is needed, so the two cannot disagree.
  • txtValue claimed to concatenate long TXT strings and actually stripped quotes, which corrupts any record split across chunks.
Test Files  77 passed (77)
     Tests  771 passed | 1 skipped (772)

yarn tsc --noEmit clean.

Note on scope

worker/src/lib/oauth-scope-policy.ts does not exist on main — it arrives with #238. When these meet, GET /api/domains needs an admin rule with requiresAdminRole, since unclassified routes are denied to bearer callers. Happy to fold that in whenever #238 lands.

Waiel5 added 3 commits August 6, 2026 12:09
saasmail has no concept of a domain. Inboxes are keyed by full address —
`sender_identities.email`, `emails.recipient` — so an operator who has
just pointed a new domain at their deployment has nothing to look at, and
when mail bounces there is no page that says why. The answer is almost
always a DNS record that was never added, and finding that out currently
means leaving the product for `dig`.

`GET /api/domains` derives the list from the same universe the inbox list
uses: DISTINCT `emails.recipient` UNION `sender_identities.email`, split
at the `@` and lower-cased so `Sales@Acme.com` and `sales@acme.com` are
one inbox on one domain. Domains are implied by the addresses that exist,
so there is no table and no migration to drift out of sync with them.
Each row carries the inbox count, the received-message count, and the DNS
status.

The live check is the part worth building. Workers have no raw DNS, but
they can reach Cloudflare's DoH resolver over HTTPS, which is enough to
read MX and TXT and say whether the domain routes to Cloudflare
(`*.mx.cloudflare.net` — the per-zone hostnames vary, so the suffix is
the test, not `route1`), somewhere else, or nowhere, and whether SPF
exists and includes `_spf.mx.cloudflare.net`. What is still missing comes
back as `{name, type, value}` records rather than prose, so the worker
ships data and the client renders and copies it.

Every failure path answers `unknown` rather than a verdict: a thrown
request, a non-JSON body, and any DNS status that is neither NOERROR nor
NXDOMAIN. Telling an operator their DNS is wrong when the resolver simply
did not answer sends them to edit records that were already correct,
which is worse than admitting the check did not happen. `unknown` also
emits no missing records, since there is nothing to base them on. MX and
TXT resolve independently, so one failing does not cloud the other.

Answers are held in `caches.default` for 60 seconds. A dashboard refresh
reuses them instead of re-querying, and a record the operator has just
fixed still appears within the minute. That cache outlives the per-test
database reset, so each test owns distinct domains.

Admin-only, guarded the same way suppressions and webhook config are: it
reads deployment-wide configuration rather than anyone's mail.

`oauth-scope-policy.ts` does not exist on this base, so there is no rule
to add here. When this meets `feat/oauth-bearer-api` the route needs
classifying as `admin` scope with `requiresAdminRole` — unmatched routes
are denied to bearer callers, so without it the endpoint is unreachable
over OAuth.
Four defects in the domain DNS check, in descending order of how much
damage they do to someone who acts on the output.

The SPF advice was dangerous. When a domain already published an SPF
record that did not include Cloudflare, `missingRecords` told the
operator to add `v=spf1 include:_spf.mx.cloudflare.net ~all` as a new
TXT record. Only one SPF record per name is legal — a name with two is
a `permerror`, which fails the check the operator was trying to pass and
takes their existing outbound mail down with it. An existing record is
now merged instead: the include is spliced in ahead of the `all`
mechanism and the record comes back with `action: "replace"`, so a
client can say "replace this" rather than "add this". The qualifier is
left exactly as written, since rewriting someone's `-all` to `~all`
quietly weakens a policy they chose. A record with no `all` mechanism
gets the include appended; one that already carries it produces no entry
at all.

That last case now holds for the right reason. The include test matched
a bare substring, so `include:_spf.mx.cloudflare.net.example.com` read
as ours and the domain showed green. It matches whole terms now, with
any qualifier stripped, and the same predicate decides both the status
and whether a merge is needed — they cannot disagree.

DKIM was not checked at all. Email Routing needs a third record beyond
MX and SPF, a TXT at `cf2024-1._domainkey.<domain>` (its own selector,
separate from Email Sending's `cf-bounce._domainkey`), so a domain
Cloudflare considers unconfigured reported a clean bill of health.
`dkim` joins `routing` and `spf` with the same four states, resolved
independently. The value is deliberately `null`: Cloudflare generates
that key per zone, so nothing here can know it, and a guessed DKIM key
is worse than an absent one — it publishes a public key that matches no
private key and turns every signed message into a verification failure.
The record ships as name + type with a `note` telling the operator to
copy the value from Email Routing → Settings. A TXT sitting on that name
that is not a `v=DKIM1` record, or a `v=DKIM1` record whose `p=` is
empty (DKIM's revocation signal — present, verifying nothing), reads as
`elsewhere` rather than configured.

Reassembling TXT values became load-bearing with DKIM in the mix. The
splitter stripped quotes but left the space between them, so a value the
resolver returned as several strings came back with spaces injected at
the chunk boundaries. Nothing shorter than 255 characters noticed; a
2048-bit DKIM key is always split. Chunks now join with no separator,
which is what the wire format means.

A rejected body read defeated the module's own promise. `cached.text()`
and `res.text()` sat outside the try/catch that produces "unknown", so a
body that failed after its headers had arrived rejected
`lookupDomainDns`, which took down the `Promise.all` in the router and
500'd the entire endpoint — every domain lost because one zone's
response was cut short. Both reads, and the cache calls around them, are
inside the catch now.

And a comment claimed `missingRecords` was "Empty while anything is
unknown", which the code never did: the lookups resolve independently,
so a domain can be routing `none` with spf `unknown` and still carry the
three MX records. The behaviour is right — a failed lookup should not
suppress what the other two learned — so the claim is corrected rather
than the code, here and in the CHANGELOG, and a test pins the case that
disproved it.
`GET /api/domains` has been serving the native app since the DNS check
landed, and the web dashboard — the surface the person who deployed this
actually opens — had no page for it. The owner went looking for domain
management on their own site and found nothing. This is parity, not a
new feature.

The page shows, per domain, whether mail can arrive at all, how many
inboxes and messages sit under it, and the records still needed, each
value copyable through the existing `CodeBlock` helper rather than a
second clipboard implementation.

Everything on it is rendered exactly as the worker sends it, which is
the whole trick. `action: "replace"` means edit the record already at
that name rather than adding a second, and an SPF entry arrives
pre-merged; the native screen originally recomputed that merge itself
and, because DKIM is also TXT, wrote the merged SPF string into the DKIM
row. So `record.value` goes out verbatim and the client decides nothing.
A null value happens only on DKIM, whose key Cloudflare generates per
zone: that row shows `record.note` and offers no copy button for a value
that does not exist. `unknown` is "the lookup did not finish", never a
verdict about the zone, so it reads "Couldn't check" in a neutral tone
and never in red — and an empty `missingRecords` is not reported as an
all-clear while any of routing/spf/dkim is still unknown, which is the
one case where saying nothing would be read as saying everything is
fine.

The Cloudflare section states the part that cost this deployment hours.
DNS only gets mail as far as Cloudflare; Email Routing then needs its
catch-all rule Active with the action "Send to a Worker" pointed at this
deployment's Worker, and until it is, every message bounces 550 5.1.1
however correct the records above look. That step is also the one that
cannot be automated away: wrangler will not set a Worker as the
catch-all action, so it can only be done in the dashboard. Both links go
through `dash.cloudflare.com/?to=`, whose `:account` and `:zone`
placeholders Cloudflare resolves for whoever is signed in, so no ids of
ours are baked into the page. They are the same URLs the native screen
uses.

Reachability is the defect this exists to fix, so the route and the nav
entry are gated the way the existing admin surfaces are: `/admin/domains`
alongside `/admin/users` and `/admin/suppressions`, the entry inside the
`isAdmin` block of both the desktop dropdown and the mobile menu, and the
page redirecting a non-admin to `/` the way InboxesPage does. The fetch
is skipped for non-admins as well — the endpoint is admin-only behind
`requireAdmin`, so a member landing there would only collect a 403.
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.

1 participant