// Apple configuration profile (.mobileconfig) for one-click Mail setup on // macOS/iOS — the Apple answer to autodiscovery (Apple Mail ignores RFC 6186 // SRV records). On iOS adding the mailbox as an "Exchange" account also // works now (the Z-Push EAS gateway, services/zpush), but this profile stays // the preferred Apple path: native IMAP+DAV, no protocol translation layer. // The profile carries server settings + the address but NO password: // profiles are plaintext XML, so Apple prompts for the password on install. // // 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. 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' import { readFileSync } from 'node:fs' import forge from 'node-forge' const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ function xmlEscape(s: string): string { return s .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } export default defineEventHandler(async (event) => { const session = await getUserSession(event).catch(() => null) if (!(session as { accessToken?: string } | null)?.accessToken) { throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) } const q = getQuery(event) const email = String(q.email ?? '').trim().toLowerCase() const name = String(q.name ?? '').trim() || email if (!EMAIL_RE.test(email)) { throw createError({ statusCode: 400, statusMessage: 'Invalid email' }) } const mailHost = new URL(useRuntimeConfig().public.mailUrl as string).host // The regex above guarantees an @, but noUncheckedIndexedAccess doesn't know. const localPart = email.split('@')[0] ?? email // All user-visible labels derive from the address's own domain — this is a // white-label platform, so neither "dezky" nor "Stalwart" may appear in a // customer's account list. const domain = email.split('@')[1] ?? mailHost const d = xmlEscape(domain) const accountUuid = randomUUID() const caldavUuid = randomUUID() const carddavUuid = randomUUID() const profileUuid = randomUUID() const e = xmlEscape(email) const n = xmlEscape(name) const h = xmlEscape(mailHost) const profile = ` PayloadContent EmailAccountDescription${e} EmailAccountName${n} EmailAccountTypeEmailTypeIMAP EmailAddress${e} IncomingMailServerAuthenticationEmailAuthPassword IncomingMailServerHostName${h} IncomingMailServerPortNumber993 IncomingMailServerUseSSL IncomingMailServerUsername${e} OutgoingMailServerAuthenticationEmailAuthPassword OutgoingMailServerHostName${h} OutgoingMailServerPortNumber465 OutgoingMailServerUseSSL OutgoingMailServerUsername${e} OutgoingPasswordSameAsIncomingPassword PreventAppSheet PreventMove SMIMEEnabled PayloadDescriptionConfigures the ${e} mail account. PayloadDisplayNameMail (${d}) PayloadIdentifiereu.dezky.mail.${xmlEscape(localPart)} PayloadTypecom.apple.mail.managed PayloadUUID${accountUuid} PayloadVersion1 CalDAVAccountDescription${d} calendar CalDAVHostName${h} CalDAVPort443 CalDAVUseSSL CalDAVUsername${e} PayloadDescriptionConfigures the ${e} calendar account. PayloadDisplayNameCalendar (${d}) PayloadIdentifiereu.dezky.caldav.${xmlEscape(localPart)} PayloadTypecom.apple.caldav.account PayloadUUID${caldavUuid} PayloadVersion1 CardDAVAccountDescription${d} contacts CardDAVHostName${h} CardDAVPort443 CardDAVUseSSL CardDAVUsername${e} PayloadDescriptionConfigures the ${e} contacts account. PayloadDisplayNameContacts (${d}) PayloadIdentifiereu.dezky.carddav.${xmlEscape(localPart)} PayloadTypecom.apple.carddav.account PayloadUUID${carddavUuid} PayloadVersion1 PayloadDescriptionSets up ${e} in Apple Mail, Calendar and Contacts. You'll be asked for the mailbox password during installation. PayloadDisplayName${d} — mail, calendar & contacts (${e}) PayloadIdentifiereu.dezky.profile.${xmlEscape(localPart)} PayloadOrganization${d} PayloadRemovalDisallowed PayloadTypeConfiguration PayloadUUID${profileUuid} PayloadVersion1 ` setHeader(event, 'Content-Type', 'application/x-apple-aspen-config') setHeader( event, 'Content-Disposition', `attachment; filename="dezky-mail-${localPart.replace(/[^a-z0-9.-]/gi, '_')}.mobileconfig"`, ) return signProfile(profile) }) // Sign the profile (PKCS#7 SignedData with embedded content) using the TLS // cert mounted from cert-manager (PROFILE_SIGN_CERT/KEY in production — // fleet/apps/portal.yaml). A publicly-trusted signature turns Apple's // "unsigned profile" warning into a green Verified badge. Unset env (dev) // → unsigned profile, still installable with the warning. function signProfile(profileXml: string): Buffer | string { const certPath = process.env.PROFILE_SIGN_CERT const keyPath = process.env.PROFILE_SIGN_KEY if (!certPath || !keyPath) return profileXml try { const certPem = readFileSync(certPath, 'utf8') const keyPem = readFileSync(keyPath, 'utf8') // cert-manager's tls.crt is the full chain — leaf first. Include every // cert so Apple can build the path to the trusted root. const certs = certPem .split(/(?=-----BEGIN CERTIFICATE-----)/) .filter((c) => c.includes('BEGIN CERTIFICATE')) .map((c) => forge.pki.certificateFromPem(c)) const p7 = forge.pkcs7.createSignedData() p7.content = forge.util.createBuffer(profileXml, 'utf8') for (const c of certs) p7.addCertificate(c) p7.addSigner({ key: forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey, certificate: certs[0]!, digestAlgorithm: forge.pki.oids.sha256!, authenticatedAttributes: [ { type: forge.pki.oids.contentType!, value: forge.pki.oids.data! }, { type: forge.pki.oids.messageDigest! }, { type: forge.pki.oids.signingTime! }, ], }) p7.sign() return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary') } catch (err) { // Never fail the download over signing — fall back to unsigned. console.error('apple-mailconfig: profile signing failed, serving unsigned:', err) return profileXml } }