feat(portal): one-click Apple Mail setup via .mobileconfig
Apple Mail ignores RFC 6186 SRV autodiscovery and 'Microsoft Exchange' needs EWS/EAS that Stalwart doesn't speak — so custom-domain users were stuck typing IMAP/SMTP servers manually. New session-gated portal route generates an Apple configuration profile (IMAP 993 + SMTP 465 on the runtime mail host, username = address, NO password embedded — profiles are plaintext, Apple prompts at install). 'Add to Apple Mail' buttons on the three credential screens (invite result, mailbox created, password reset). CalDAV/CardDAV payloads join when DAV is reachable from outside (the node's :443 belongs to Traefik for now).
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
const mailHost = new URL(useRuntimeConfig().public.mailUrl as string).host
|
||||
|
||||
// One-click Apple Mail setup: downloads a .mobileconfig with the IMAP/SMTP
|
||||
// settings prefilled (no password inside — Apple prompts on install).
|
||||
function downloadAppleProfile(email: string, name?: string) {
|
||||
const params = new URLSearchParams({ email, ...(name ? { name } : {}) })
|
||||
window.location.href = `/api/apple-mailconfig?${params.toString()}`
|
||||
}
|
||||
// Users & groups. The Users tab is real — workspace members come from
|
||||
// /api/tenants/:slug/users (platform-api UserDocument). The Groups,
|
||||
// Invitations and Service-accounts tabs have no backend yet (no Group /
|
||||
@@ -914,6 +921,12 @@ async function submitCreateMailbox() {
|
||||
<button class="copy" @click="copyText(resetResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apple-row">
|
||||
<UiButton variant="secondary" @click="downloadAppleProfile(resetResult.email, undefined)">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Add to Apple Mail (.mobileconfig)
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="flex: 1" />
|
||||
@@ -1023,6 +1036,12 @@ async function submitCreateMailbox() {
|
||||
<button class="copy" @click="copyText(mailboxResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apple-row">
|
||||
<UiButton variant="secondary" @click="downloadAppleProfile(mailboxResult.email, undefined)">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Add to Apple Mail (.mobileconfig)
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="flex: 1" />
|
||||
@@ -1057,6 +1076,12 @@ async function submitCreateMailbox() {
|
||||
<button class="copy" @click="copyText(inviteResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apple-row">
|
||||
<UiButton variant="secondary" @click="downloadAppleProfile(inviteResult.email, undefined)">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Add to Apple Mail (.mobileconfig)
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="prov">
|
||||
<Badge :tone="provTone(inviteResult.provisioning.authentik)" dot>SSO login</Badge>
|
||||
<Badge :tone="provTone(inviteResult.provisioning.stalwart)" dot>Mailbox</Badge>
|
||||
@@ -1401,4 +1426,5 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
||||
}
|
||||
.role-row.active { border-color: var(--text); background: var(--bg); }
|
||||
.role-name { font-size: 13px; font-weight: 500; }
|
||||
.apple-row { margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Apple configuration profile (.mobileconfig) for one-click Mail setup on
|
||||
// macOS/iOS — the Apple answer to autodiscovery (Apple Mail ignores RFC 6186
|
||||
// SRV records, and "Microsoft Exchange" needs EWS/EAS which Stalwart doesn't
|
||||
// speak). 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. CalDAV/CardDAV payloads join once DAV is
|
||||
// reachable from outside (the node's :443 belongs to Traefik today).
|
||||
|
||||
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
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
|
||||
const localPart = email.split('@')[0]
|
||||
const accountUuid = randomUUID()
|
||||
const profileUuid = randomUUID()
|
||||
const e = xmlEscape(email)
|
||||
const n = xmlEscape(name)
|
||||
const h = xmlEscape(mailHost)
|
||||
|
||||
const profile = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>EmailAccountDescription</key><string>dezky mail (${e})</string>
|
||||
<key>EmailAccountName</key><string>${n}</string>
|
||||
<key>EmailAccountType</key><string>EmailTypeIMAP</string>
|
||||
<key>EmailAddress</key><string>${e}</string>
|
||||
<key>IncomingMailServerAuthentication</key><string>EmailAuthPassword</string>
|
||||
<key>IncomingMailServerHostName</key><string>${h}</string>
|
||||
<key>IncomingMailServerPortNumber</key><integer>993</integer>
|
||||
<key>IncomingMailServerUseSSL</key><true/>
|
||||
<key>IncomingMailServerUsername</key><string>${e}</string>
|
||||
<key>OutgoingMailServerAuthentication</key><string>EmailAuthPassword</string>
|
||||
<key>OutgoingMailServerHostName</key><string>${h}</string>
|
||||
<key>OutgoingMailServerPortNumber</key><integer>465</integer>
|
||||
<key>OutgoingMailServerUseSSL</key><true/>
|
||||
<key>OutgoingMailServerUsername</key><string>${e}</string>
|
||||
<key>OutgoingPasswordSameAsIncomingPassword</key><true/>
|
||||
<key>PreventAppSheet</key><false/>
|
||||
<key>PreventMove</key><false/>
|
||||
<key>SMIMEEnabled</key><false/>
|
||||
<key>PayloadDescription</key><string>Configures the ${e} mail account.</string>
|
||||
<key>PayloadDisplayName</key><string>dezky mail</string>
|
||||
<key>PayloadIdentifier</key><string>eu.dezky.mail.${xmlEscape(localPart)}</string>
|
||||
<key>PayloadType</key><string>com.apple.mail.managed</string>
|
||||
<key>PayloadUUID</key><string>${accountUuid}</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key><string>Sets up ${e} in Apple Mail. You'll be asked for the mailbox password during installation.</string>
|
||||
<key>PayloadDisplayName</key><string>dezky mail — ${e}</string>
|
||||
<key>PayloadIdentifier</key><string>eu.dezky.profile.${xmlEscape(localPart)}</string>
|
||||
<key>PayloadOrganization</key><string>dezky</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>${profileUuid}</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
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 profile
|
||||
})
|
||||
Reference in New Issue
Block a user