feat(mail): Outlook/Thunderbird autodiscovery over HTTPS
ci / changes (push) Successful in 4s
ci / tc_portal (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_booking (push) Has been skipped
ci / tc_platform_api (push) Successful in 21s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_platform_api (push) Successful in 19s
ci / deploy (push) Failing after 9s

Outlook autodiscovers via POST https://autodiscover.<domain>/autodiscover/
autodiscover.xml and Thunderbird via autoconfig.<domain>/mail/
config-v1.1.xml — Stalwart serves both (verified, answers carry
mail.dezky.eu:993/465) but its HTTP listener wasn't reachable from
outside (the node's :443 is Traefik's). New exact-path-only Ingress
routes JUST those discovery endpoints to host-Stalwart via a selectorless
Service + Endpoints on the cni0 gateway; the admin/management surface
stays internal, and there's no HTTPS-redirect middleware because
Thunderbird probes plain HTTP and Outlook POSTs.

Domains page now also lists the autoconfig/autodiscover CNAMEs under the
autodiscovery slot (CNAME verified against the mail host; a bare A record
warns instead of failing). Customer-domain autodiscovery (per-domain
certs + automated Ingress) is a follow-up.
This commit is contained in:
Ronni Baslund
2026-06-11 08:04:55 +02:00
parent 221179c4db
commit 88ac5e620c
4 changed files with 105 additions and 1 deletions
@@ -43,12 +43,36 @@ export class DnsVerifierService {
case 'dmarc':
return this.checkDmarc(domain)
case 'autodiscovery':
return this.checkSrv(record.fqdn, record.expected)
return record.type === 'CNAME'
? this.checkCname(record.fqdn, record.expected)
: this.checkSrv(record.fqdn, record.expected)
default:
return { status: 'pending' }
}
}
// Autodiscovery CNAME (autoconfig/autodiscover hosts). An A record pointing
// at the same place also works fine in practice, so a resolvable name that
// doesn't CNAME to the expected target is warn, not bad.
private async checkCname(fqdn: string, expected: string): Promise<CheckResult> {
const expTarget = expected.trim().replace(/\.$/, '').toLowerCase()
try {
const cnames = await this.resolver.resolveCname(fqdn)
const observed = cnames.join(', ')
const hit = cnames.some((c) => c.replace(/\.$/, '').toLowerCase() === expTarget)
return hit ? { observed, status: 'ok' } : { observed, status: 'warn' }
} catch {
// No CNAME — accept a direct A record to anything (can't cheaply prove
// it's us), warn so the page nudges toward the canonical record.
try {
const a = await this.resolver.resolve4(fqdn)
return a.length ? { observed: a.join(', '), status: 'warn' } : { status: 'bad' }
} catch {
return { status: 'bad' }
}
}
}
// Autodiscovery SRV (RFC 6186): the record must exist and point at our mail
// host on the expected port. expected is the zone's RDATA, e.g.
// "0 1 993 mail.dezky.eu." — we compare target + port and ignore
@@ -439,6 +439,11 @@ function classify(z: StalwartZoneRecord, domain: string): RecordKind | null {
// that port belongs to Traefik on the node, not Stalwart, so publishing
// them would advertise endpoints that 404. Revisit with the webmail story.
if (z.type === 'SRV' && /^_(imaps|submissions|pop3s)\._tcp\./.test(z.fqdn)) return 'autodiscovery'
// HTTP autodiscovery hosts (Outlook's autodiscover, Thunderbird's
// autoconfig) — routed through Traefik to Stalwart's discovery endpoints.
if (z.type === 'CNAME' && (z.fqdn === `autoconfig.${domain}` || z.fqdn === `autodiscover.${domain}`)) {
return 'autodiscovery'
}
return null
}