From 88ac5e620cd442eb68b0c369495d4862795bfaf8 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Thu, 11 Jun 2026 08:04:55 +0200 Subject: [PATCH] feat(mail): Outlook/Thunderbird autodiscovery over HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook autodiscovers via POST https://autodiscover./autodiscover/ autodiscover.xml and Thunderbird via autoconfig./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. --- .../production/fleet/apps/kustomization.yaml | 1 + .../fleet/apps/mail-autodiscovery.yaml | 74 +++++++++++++++++++ .../src/domains/dns-verifier.service.ts | 26 ++++++- .../src/domains/domains.service.ts | 5 ++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 infrastructure/production/fleet/apps/mail-autodiscovery.yaml diff --git a/infrastructure/production/fleet/apps/kustomization.yaml b/infrastructure/production/fleet/apps/kustomization.yaml index 66fff05..c2d0531 100644 --- a/infrastructure/production/fleet/apps/kustomization.yaml +++ b/infrastructure/production/fleet/apps/kustomization.yaml @@ -7,6 +7,7 @@ namespace: dezky-apps resources: - namespace.yaml - redirect-middleware.yaml + - mail-autodiscovery.yaml - platform-api-config.yaml - platform-api.yaml - portal.yaml diff --git a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml new file mode 100644 index 0000000..86fcc7e --- /dev/null +++ b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml @@ -0,0 +1,74 @@ +# Mail-client autodiscovery for dezky.eu — routes ONLY the discovery paths +# through Traefik to host-Stalwart's HTTP listener (10.42.0.1:8080): +# +# autodiscover.dezky.eu POST /autodiscover/autodiscover.xml (Outlook) +# autoconfig.dezky.eu GET /mail/config-v1.1.xml (Thunderbird) +# +# Everything else on these hostnames (Stalwart's /admin, /login, /jmap …) +# falls through to Traefik's 404 — the management surface stays internal. +# No HTTPS-redirect middleware on purpose: Thunderbird probes plain HTTP and +# Outlook POSTs, which doesn't survive a 301 in all clients; both schemes +# serve the same answer. +# +# DNS at simply.com: autoconfig + autodiscover CNAME → mail.dezky.eu (the +# records are listed on the portal's Domains page). +# +# Customer domains (autodiscover..tld) need per-domain certs and an +# automated Ingress/Certificate per verified domain — follow-up feature. +apiVersion: v1 +kind: Service +metadata: + name: stalwart-http + labels: + app.kubernetes.io/name: stalwart-http + app.kubernetes.io/part-of: dezky +spec: + # No selector — Stalwart runs on the HOST, not in a pod. The Endpoints + # object below pins the cni0 gateway address pods/Traefik can reach. + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: stalwart-http +subsets: + - addresses: + - ip: 10.42.0.1 + ports: + - name: http + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mail-autodiscovery + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure +spec: + ingressClassName: traefik + tls: + - hosts: + - autodiscover.dezky.eu + - autoconfig.dezky.eu + secretName: autodiscovery-dezky-eu-tls + rules: + - host: autodiscover.dezky.eu + http: + paths: + # Outlook probes both capitalizations. + - path: /autodiscover/autodiscover.xml + pathType: Exact + backend: { service: { name: stalwart-http, port: { number: 8080 } } } + - path: /Autodiscover/Autodiscover.xml + pathType: Exact + backend: { service: { name: stalwart-http, port: { number: 8080 } } } + - host: autoconfig.dezky.eu + http: + paths: + - path: /mail/config-v1.1.xml + pathType: Exact + backend: { service: { name: stalwart-http, port: { number: 8080 } } } diff --git a/services/platform-api/src/domains/dns-verifier.service.ts b/services/platform-api/src/domains/dns-verifier.service.ts index 0507936..91160b0 100644 --- a/services/platform-api/src/domains/dns-verifier.service.ts +++ b/services/platform-api/src/domains/dns-verifier.service.ts @@ -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 { + 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 diff --git a/services/platform-api/src/domains/domains.service.ts b/services/platform-api/src/domains/domains.service.ts index 9ae35ed..44d386e 100644 --- a/services/platform-api/src/domains/domains.service.ts +++ b/services/platform-api/src/domains/domains.service.ts @@ -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 }