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}`)) {