Repository navigation
Commit 36a3697
feat(email): let administrators add outbound-only custom domains (#551)
Fixes #510
## What it does
An admin can now register an apex whose mail lives with Microsoft 365 or
Google Workspace, for **sending only**:
```sh
void platform email domain-add brand.example --project hr-portal --outbound-only
```
```
managed (today) outbound-only (new)
─────────────── ───────────────────
apex MX → Cloudflare Email Routing untouched (M365 / Workspace)
catch-all → Void ingress Worker untouched
ingress Worker deployed never deployed
steps routing_enable … publish sending_onboard → publish
receive mail yes no (inbound_status = disabled)
send mail yes yes
Sending refused → verified_only fallback → blocked, with the reason
```
## Answers to the 4 questions
1. **Shape.** A column, `email_domain_routes.inbound_mode` (`managed` |
`none`, default `managed`), plus a check that `none` is only allowed
with `addressing = 'custom'`. `addressing` already plays the "route
kind" role, so a second kind would duplicate it.
2. **Who.** Admin only. Owners get a 403 from the service. The owner CLI
points them at the admin command when their apex already has foreign MX.
3. **DMARC/SPF.** They live together; Void never edits DMARC or SPF.
Before onboarding, the step calls Cloudflare's read-only `POST
/email/sending/subdomains/preview`. If it reports any real conflict (not
just `*.missing`), the operation is **blocked** with a list
(`dmarc.multiple at _dmarc.brand.example; …`). Once the admin fixes
those records, `domain-sync` resumes. A domain that is already onboarded
skips the preview.
4. **Subdomains.** Not in this PR; apex only. Subdomain senders need
several routes per connection, so that is a follow-up.
```
domain-add --outbound-only
│
├─ admin? apex? not the shared zone? ── no ──▶ 4xx, no Cloudflare writes
│
▼
sending_onboard
├─ already onboarded? ─ yes ─▶ onboard (idempotent)
├─ preview conflicts? ─ yes ─▶ blocked + checklist
├─ onboard 4xx ─────────▶ blocked ("needs Workers Paid")
├─ onboard 5xx/408 ─────────▶ unknown (operator recovery, as today)
▼
publish ─▶ outbound ready, inbound disabled
```
## Safety details
- **Removal** keeps the usual step list, but `catchall_detach` and
`ingress_delete` finish locally for outbound-only routes, so no Email
Routing or Workers calls are made. The stored credential is still
cleared, so a Sending-only token can finish removal.
- **Mode switch** on an active route returns 409 ("remove it first").
The add dry-run says so (`plan: 'refuse'`).
- **Race guard.** If the MX preflight was skipped because the route was
active, and the route is no longer active by the time the transaction
runs, the add returns 409 "retry" instead of reactivating it without an
MX check.
- **Status refresh** no longer overwrites a newer Sending result
recorded by a setup step. Otherwise, on an outbound-only route, a stale
`blocked` would stop all sending.
- **Older servers.** The CLI aborts unless the dry-run echoes
`target.inboundMode === 'none'`, so an old platform that ignores the
flag cannot run full managed setup.
- **Rollback.** `compatibility.json` lists 1.1.11 through 1.2.1 as
rollback-safe (the column is additive and defaults to `managed`).
`platform/meta/operations.md` warns not to run `domain-add` or
`domain-sync` on outbound-only domains after a rollback below this
release, because an older runtime would treat them as managed and take
over the MX.
- `domain-rotate-secret` returns 409 for these domains, in both the
preview and the real request.
## Not verified live
- That Cloudflare onboarding keeps a single existing `_dmarc` record
instead of replacing it. The preview is Cloudflare's own check, but this
still needs one run against a real test zone.
- Removal still does not offboard Email Sending in Cloudflare. That was
already true for managed domains and is unchanged here.
## Tests
- `admin-upgrade-compatibility.test.ts`: 0061 upgrade from the 0060
schema, defaults, and the check constraint.
- `email-connections.test.ts`: foreign-MX apex runs 2 steps with no
inbound calls; preview conflict blocks, then resumes; 403 blocks (not
`verified_only`), then resumes; managed still falls back; 504 stays
unknown; refusals; removal makes no inbound calls.
- `operator-email-domains.test.ts`: HTTP flag validation, dry-run text,
blocked → sync → ready, status, previews, rotate refused, audit, remove;
status-refresh race (confirmed to fail without the fix).
- `email-admission.test.ts`: sending admitted, receipt refused.
- `operator-cli.test.ts`, `email-cli-domain.test.ts`: flag parsing,
old-server guard, owner hint.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>1 parent cd480dd commit 36a3697
26 files changed
Lines changed: 7951 additions & 75 deletions
File tree
- docs
- guide
- email
- platform/administration
- reference/cli
- packages/void
- src/cli
- test/unit
- platform
- meta
- packages
- api
- drizzle
- meta
- src
- email
- operator
- test
- integration
- unit
- proxy/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
| 69 | + | |
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
| 76 | + | |
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
451 | 451 | | |
452 | 452 | | |
453 | 453 | | |
| 454 | + | |
| 455 | + | |
454 | 456 | | |
455 | 457 | | |
456 | 458 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
470 | 470 | | |
471 | 471 | | |
472 | 472 | | |
473 | | - | |
| 473 | + | |
474 | 474 | | |
475 | 475 | | |
476 | 476 | | |
477 | 477 | | |
478 | 478 | | |
479 | 479 | | |
480 | 480 | | |
481 | | - | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
482 | 486 | | |
483 | 487 | | |
484 | 488 | | |
| |||
493 | 497 | | |
494 | 498 | | |
495 | 499 | | |
496 | | - | |
497 | 500 | | |
| 501 | + | |
498 | 502 | | |
499 | 503 | | |
500 | 504 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
496 | 496 | | |
497 | 497 | | |
498 | 498 | | |
499 | | - | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
500 | 503 | | |
501 | 504 | | |
502 | 505 | | |
| |||
847 | 850 | | |
848 | 851 | | |
849 | 852 | | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
850 | 856 | | |
851 | 857 | | |
852 | 858 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
689 | 689 | | |
690 | 690 | | |
691 | 691 | | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
692 | 704 | | |
693 | 705 | | |
694 | 706 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| |||
303 | 303 | | |
304 | 304 | | |
305 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
306 | 321 | | |
307 | 322 | | |
308 | 323 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
521 | 521 | | |
522 | 522 | | |
523 | 523 | | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
524 | 537 | | |
525 | 538 | | |
526 | 539 | | |
| |||
1260 | 1273 | | |
1261 | 1274 | | |
1262 | 1275 | | |
| 1276 | + | |
| 1277 | + | |
| 1278 | + | |
| 1279 | + | |
| 1280 | + | |
| 1281 | + | |
| 1282 | + | |
| 1283 | + | |
| 1284 | + | |
| 1285 | + | |
| 1286 | + | |
| 1287 | + | |
| 1288 | + | |
| 1289 | + | |
| 1290 | + | |
| 1291 | + | |
| 1292 | + | |
| 1293 | + | |
| 1294 | + | |
| 1295 | + | |
| 1296 | + | |
| 1297 | + | |
| 1298 | + | |
| 1299 | + | |
| 1300 | + | |
| 1301 | + | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
| 1305 | + | |
| 1306 | + | |
| 1307 | + | |
| 1308 | + | |
1263 | 1309 | | |
1264 | 1310 | | |
1265 | 1311 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| 187 | + | |
| 188 | + | |
187 | 189 | | |
188 | 190 | | |
189 | 191 | | |
| |||
0 commit comments