diff --git a/apps/portal/server/api/apple-mailconfig.get.ts b/apps/portal/server/api/apple-mailconfig.get.ts index 0eeadc3..0ea788a 100644 --- a/apps/portal/server/api/apple-mailconfig.get.ts +++ b/apps/portal/server/api/apple-mailconfig.get.ts @@ -6,8 +6,9 @@ // // Session-gated like every portal API. The mail host comes from runtime // config (public.mailUrl), so dev (.local) and prod (.eu) generate correct -// profiles from one build. CalDAV/CardDAV payloads join once DAV is -// reachable from outside (the node's :443 belongs to Traefik today). +// profiles from one build. Includes CalDAV + CardDAV payloads (Traefik +// routes /dav + the well-knowns on the mail host through to Stalwart), so +// one install configures Mail, Calendar and Contacts. import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js' import { randomUUID } from 'node:crypto' @@ -39,6 +40,8 @@ export default defineEventHandler(async (event) => { // The regex above guarantees an @, but noUncheckedIndexedAccess doesn't know. const localPart = email.split('@')[0] ?? email const accountUuid = randomUUID() + const caldavUuid = randomUUID() + const carddavUuid = randomUUID() const profileUuid = randomUUID() const e = xmlEscape(email) const n = xmlEscape(name) @@ -76,8 +79,34 @@ export default defineEventHandler(async (event) => { PayloadUUID${accountUuid} PayloadVersion1 + + CalDAVAccountDescriptiondezky calendar (${e}) + CalDAVHostName${h} + CalDAVPort443 + CalDAVUseSSL + CalDAVUsername${e} + PayloadDescriptionConfigures the ${e} calendar account. + PayloadDisplayNamedezky calendar + PayloadIdentifiereu.dezky.caldav.${xmlEscape(localPart)} + PayloadTypecom.apple.caldav.account + PayloadUUID${caldavUuid} + PayloadVersion1 + + + CardDAVAccountDescriptiondezky contacts (${e}) + CardDAVHostName${h} + CardDAVPort443 + CardDAVUseSSL + CardDAVUsername${e} + PayloadDescriptionConfigures the ${e} contacts account. + PayloadDisplayNamedezky contacts + PayloadIdentifiereu.dezky.carddav.${xmlEscape(localPart)} + PayloadTypecom.apple.carddav.account + PayloadUUID${carddavUuid} + PayloadVersion1 + - PayloadDescriptionSets up ${e} in Apple Mail. You'll be asked for the mailbox password during installation. + PayloadDescriptionSets up ${e} in Apple Mail, Calendar and Contacts. You'll be asked for the mailbox password during installation. PayloadDisplayNamedezky mail — ${e} PayloadIdentifiereu.dezky.profile.${xmlEscape(localPart)} PayloadOrganizationdezky diff --git a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml index acc5177..9cc5ec4 100644 --- a/infrastructure/production/fleet/apps/mail-autodiscovery.yaml +++ b/infrastructure/production/fleet/apps/mail-autodiscovery.yaml @@ -75,3 +75,36 @@ spec: - path: /mail/config-v1.1.xml pathType: Exact backend: { service: { name: stalwart-http, port: { number: 8080 } } } +--- +# CalDAV/CardDAV for mail.dezky.eu — Apple Calendar/Contacts, Thunderbird and +# every other DAV client. Separate Ingress from the autodiscovery one because +# DAV gets the HTTPS-redirect middleware (safe for GET/PROPFIND; the +# autodiscover Ingress must stay redirect-free for Outlook's POST). Only the +# well-knowns + /dav are routed — the admin surface stays internal. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mail-dav + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: dezky-apps-redirect-https@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - mail.dezky.eu + secretName: mail-dezky-eu-traefik-tls + rules: + - host: mail.dezky.eu + http: + paths: + - path: /.well-known/caldav + pathType: Exact + backend: { service: { name: stalwart-http, port: { number: 8080 } } } + - path: /.well-known/carddav + pathType: Exact + backend: { service: { name: stalwart-http, port: { number: 8080 } } } + - path: /dav + pathType: Prefix + backend: { service: { name: stalwart-http, port: { number: 8080 } } } diff --git a/services/platform-api/src/domains/domains.service.ts b/services/platform-api/src/domains/domains.service.ts index 44d386e..6c73438 100644 --- a/services/platform-api/src/domains/domains.service.ts +++ b/services/platform-api/src/domains/domains.service.ts @@ -433,12 +433,13 @@ function classify(z: StalwartZoneRecord, domain: string): RecordKind | null { if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf' if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim' if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc' - // RFC 6186 client autodiscovery. Only the services that are actually - // reachable in production: IMAPS 993, SMTP submission 465, POP3S 995. - // The zone also offers _jmap/_caldavs/_carddavs SRVs targeting :443 — - // 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' + // RFC 6186/6764 client autodiscovery. Only services actually reachable in + // production: IMAPS 993, submission 465, POP3S 995 (Stalwart direct), and + // CalDAV/CardDAV on 443 (Traefik routes /dav + the well-knowns through to + // Stalwart). _jmap stays unpublished until the webmail/JMAP story. + if (z.type === 'SRV' && /^_(imaps|submissions|pop3s|caldavs|carddavs)\._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}`)) {