feat(mail): CalDAV/CardDAV exposed + in the Apple profile
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 23s
ci / tc_portal (push) Successful in 26s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_portal (push) Successful in 43s
ci / build_platform_api (push) Successful in 16s
ci / deploy (push) Successful in 43s
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 23s
ci / tc_portal (push) Successful in 26s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_portal (push) Successful in 43s
ci / build_platform_api (push) Successful in 16s
ci / deploy (push) Successful in 43s
DAV was internal-only (the node's :443 is Traefik's). New mail-dav Ingress routes /.well-known/caldav, /.well-known/carddav and /dav on mail.dezky.eu through to Stalwart — with the HTTPS-redirect middleware (safe for DAV's GET/PROPFIND; kept OFF the autodiscover Ingress whose POSTs don't survive redirects). The _caldavs/_carddavs SRV records are now legitimate, so the Domains page surfaces them, and the Apple .mobileconfig gains CalDAV + CardDAV payloads: one install sets up Mail, Calendar and Contacts on Mac/iPhone. Stalwart's STALWART_PUBLIC_URL is set to https://mail.dezky.eu on the host (discovery documents).
This commit is contained in:
@@ -6,8 +6,9 @@
|
|||||||
//
|
//
|
||||||
// Session-gated like every portal API. The mail host comes from runtime
|
// Session-gated like every portal API. The mail host comes from runtime
|
||||||
// config (public.mailUrl), so dev (.local) and prod (.eu) generate correct
|
// config (public.mailUrl), so dev (.local) and prod (.eu) generate correct
|
||||||
// profiles from one build. CalDAV/CardDAV payloads join once DAV is
|
// profiles from one build. Includes CalDAV + CardDAV payloads (Traefik
|
||||||
// reachable from outside (the node's :443 belongs to Traefik today).
|
// 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 { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@@ -39,6 +40,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
// The regex above guarantees an @, but noUncheckedIndexedAccess doesn't know.
|
// The regex above guarantees an @, but noUncheckedIndexedAccess doesn't know.
|
||||||
const localPart = email.split('@')[0] ?? email
|
const localPart = email.split('@')[0] ?? email
|
||||||
const accountUuid = randomUUID()
|
const accountUuid = randomUUID()
|
||||||
|
const caldavUuid = randomUUID()
|
||||||
|
const carddavUuid = randomUUID()
|
||||||
const profileUuid = randomUUID()
|
const profileUuid = randomUUID()
|
||||||
const e = xmlEscape(email)
|
const e = xmlEscape(email)
|
||||||
const n = xmlEscape(name)
|
const n = xmlEscape(name)
|
||||||
@@ -76,8 +79,34 @@ export default defineEventHandler(async (event) => {
|
|||||||
<key>PayloadUUID</key><string>${accountUuid}</string>
|
<key>PayloadUUID</key><string>${accountUuid}</string>
|
||||||
<key>PayloadVersion</key><integer>1</integer>
|
<key>PayloadVersion</key><integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CalDAVAccountDescription</key><string>dezky calendar (${e})</string>
|
||||||
|
<key>CalDAVHostName</key><string>${h}</string>
|
||||||
|
<key>CalDAVPort</key><integer>443</integer>
|
||||||
|
<key>CalDAVUseSSL</key><true/>
|
||||||
|
<key>CalDAVUsername</key><string>${e}</string>
|
||||||
|
<key>PayloadDescription</key><string>Configures the ${e} calendar account.</string>
|
||||||
|
<key>PayloadDisplayName</key><string>dezky calendar</string>
|
||||||
|
<key>PayloadIdentifier</key><string>eu.dezky.caldav.${xmlEscape(localPart)}</string>
|
||||||
|
<key>PayloadType</key><string>com.apple.caldav.account</string>
|
||||||
|
<key>PayloadUUID</key><string>${caldavUuid}</string>
|
||||||
|
<key>PayloadVersion</key><integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CardDAVAccountDescription</key><string>dezky contacts (${e})</string>
|
||||||
|
<key>CardDAVHostName</key><string>${h}</string>
|
||||||
|
<key>CardDAVPort</key><integer>443</integer>
|
||||||
|
<key>CardDAVUseSSL</key><true/>
|
||||||
|
<key>CardDAVUsername</key><string>${e}</string>
|
||||||
|
<key>PayloadDescription</key><string>Configures the ${e} contacts account.</string>
|
||||||
|
<key>PayloadDisplayName</key><string>dezky contacts</string>
|
||||||
|
<key>PayloadIdentifier</key><string>eu.dezky.carddav.${xmlEscape(localPart)}</string>
|
||||||
|
<key>PayloadType</key><string>com.apple.carddav.account</string>
|
||||||
|
<key>PayloadUUID</key><string>${carddavUuid}</string>
|
||||||
|
<key>PayloadVersion</key><integer>1</integer>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>PayloadDescription</key><string>Sets up ${e} in Apple Mail. You'll be asked for the mailbox password during installation.</string>
|
<key>PayloadDescription</key><string>Sets up ${e} in Apple Mail, Calendar and Contacts. You'll be asked for the mailbox password during installation.</string>
|
||||||
<key>PayloadDisplayName</key><string>dezky mail — ${e}</string>
|
<key>PayloadDisplayName</key><string>dezky mail — ${e}</string>
|
||||||
<key>PayloadIdentifier</key><string>eu.dezky.profile.${xmlEscape(localPart)}</string>
|
<key>PayloadIdentifier</key><string>eu.dezky.profile.${xmlEscape(localPart)}</string>
|
||||||
<key>PayloadOrganization</key><string>dezky</string>
|
<key>PayloadOrganization</key><string>dezky</string>
|
||||||
|
|||||||
@@ -75,3 +75,36 @@ spec:
|
|||||||
- path: /mail/config-v1.1.xml
|
- path: /mail/config-v1.1.xml
|
||||||
pathType: Exact
|
pathType: Exact
|
||||||
backend: { service: { name: stalwart-http, port: { number: 8080 } } }
|
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 } } }
|
||||||
|
|||||||
@@ -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 === 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.endsWith(`._domainkey.${domain}`)) return 'dkim'
|
||||||
if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
|
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
|
// RFC 6186/6764 client autodiscovery. Only services actually reachable in
|
||||||
// reachable in production: IMAPS 993, SMTP submission 465, POP3S 995.
|
// production: IMAPS 993, submission 465, POP3S 995 (Stalwart direct), and
|
||||||
// The zone also offers _jmap/_caldavs/_carddavs SRVs targeting :443 —
|
// CalDAV/CardDAV on 443 (Traefik routes /dav + the well-knowns through to
|
||||||
// that port belongs to Traefik on the node, not Stalwart, so publishing
|
// Stalwart). _jmap stays unpublished until the webmail/JMAP story.
|
||||||
// them would advertise endpoints that 404. Revisit with the webmail story.
|
if (z.type === 'SRV' && /^_(imaps|submissions|pop3s|caldavs|carddavs)\._tcp\./.test(z.fqdn)) {
|
||||||
if (z.type === 'SRV' && /^_(imaps|submissions|pop3s)\._tcp\./.test(z.fqdn)) return 'autodiscovery'
|
return 'autodiscovery'
|
||||||
|
}
|
||||||
// HTTP autodiscovery hosts (Outlook's autodiscover, Thunderbird's
|
// HTTP autodiscovery hosts (Outlook's autodiscover, Thunderbird's
|
||||||
// autoconfig) — routed through Traefik to Stalwart's discovery endpoints.
|
// autoconfig) — routed through Traefik to Stalwart's discovery endpoints.
|
||||||
if (z.type === 'CNAME' && (z.fqdn === `autoconfig.${domain}` || z.fqdn === `autodiscover.${domain}`)) {
|
if (z.type === 'CNAME' && (z.fqdn === `autoconfig.${domain}` || z.fqdn === `autodiscover.${domain}`)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user