Files
dezky/apps/portal/pages/admin/domains/index.vue
T
Ronni Baslund f6bac10ff3
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 20s
ci / tc_portal (push) Failing after 27s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_portal (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Failing after 3m5s
feat(domains): surface autodiscovery SRV records (RFC 6186)
Mail clients could never autoconfigure: Stalwart's zone file contains the
_imaps/_submissions/_pop3s SRV records but classify() dropped everything
except mx/spf/dkim/dmarc, so customers never saw them and every client
needed manual server entry. New 'autodiscovery' record kind: classified
from the zone (only the services actually reachable in prod — the
_jmap/_caldavs SRVs target :443 which Traefik owns, deferred to the
webmail story), verified via resolveSrv (missing=bad, wrong target=warn),
shown as an OPTIONAL slot on the portal Domains page that never gates the
domain status or the records-to-fix nag.

Also fixed on the live server via management JMAP (x:SystemSettings):
hostname was the machine name node1.dezky.eu from the v0.16 auto-bootstrap
— MX/SRV targets and the SMTP banner now say mail.dezky.eu, and the LE
x:Certificate is set as defaultCertificateId.
2026-06-10 22:11:34 +02:00

451 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Customer-admin Domains page. Lists the tenant's email domains on real data
// from platform-api (useDomains → /api/tenants/:slug/domains). Each card shows
// the monospace name, an overall status badge, a "X records to fix" hint, a
// Re-check button, and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand
// inline detail with the exact record to publish (sourced from Stalwart's zone,
// so DKIM keys etc. are authoritative). The explanatory copy per status is
// static (DNS_FIX); the record values come from the server.
import type { DomainRecordView, DomainView, RecordStatus } from '~/composables/useDomains'
const router = useRouter()
const toast = useToast()
const { domains, refresh, recheck, remove } = useDomains()
type Tone = 'ok' | 'warn' | 'bad'
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc' | 'autodiscovery'
// Static explanatory copy per record + status. Record VALUES are no longer here
// — those come from the server (the real MX host, DKIM public key, etc.). We
// keep only the human guidance, keyed by record kind + observed tone.
const DNS_FIX: Record<RecordKey, {
label: string
purpose: string
states: Record<Tone | 'pending', { headline: string; body: string }>
}> = {
mx: {
label: 'MX · mail exchange',
purpose: 'Routes inbound mail for this domain to dezky.',
states: {
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly.' },
warn: { headline: 'Secondary MX detected', body: 'An MX outside of dezky was found. This is allowed for failover, but make sure it forwards back to dezky.' },
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
spf: {
label: 'SPF · sender policy',
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
states: {
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender.' },
warn: { headline: 'SPF present but weak', body: 'SPF resolves but ends with a softfail (~all) or is missing the dezky mechanism. Use the record below for stronger protection.' },
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
dkim: {
label: 'DKIM · message signing',
purpose: 'Cryptographic signature proving the message was not altered in transit.',
states: {
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed and verifiable.' },
warn: { headline: 'DKIM record mismatch', body: 'A DKIM record exists but its public key differs from dezkys. Replace it with the value(s) below.' },
bad: { headline: 'No DKIM record', body: 'Receiving servers cannot verify the signature on your outbound mail.' },
pending: { headline: 'Not checked yet', body: 'Add the record(s) below, then re-check.' },
},
},
dmarc: {
label: 'DMARC · policy enforcement',
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
states: {
ok: { headline: 'DMARC enforcing', body: 'Spoofed mail will be quarantined or rejected at Gmail/Outlook.' },
warn: { headline: 'DMARC at p=none', body: 'Youre collecting reports but not enforcing. Raise to quarantine once SPF/DKIM look stable.' },
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail may fail Gmail / Outlook spam checks.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
autodiscovery: {
label: 'SRV · autodiscovery',
purpose: 'Lets mail apps set themselves up from just the email address — no manual server fields.',
states: {
ok: { headline: 'Autodiscovery active', body: 'Mail clients can configure IMAP/SMTP automatically.' },
warn: { headline: 'SRV points elsewhere', body: 'An SRV record exists but targets the wrong host or port — clients may try a dead endpoint. Update it to the values below.' },
bad: { headline: 'No SRV records', body: 'Optional but recommended: without these, users type the mail server manually. Add the records below.' },
pending: { headline: 'Not checked yet', body: 'Add the records below, then re-check.' },
},
},
}
const RECORD_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc', 'autodiscovery']
// Required kinds drive the "X records to fix" nag; autodiscovery is optional
// (clients fall back to manual server entry) so it never counts as an issue.
const REQUIRED_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc']
const expanded = reactive<Record<string, RecordKey | null>>({})
const copied = ref<string | null>(null)
const rechecking = ref<string | null>(null)
// Remove flow. A domain can only be removed when no mailboxes use it (enforced
// server-side too); the button is disabled otherwise. removeTarget drives the
// confirm dialog.
const removeTarget = ref<DomainView | null>(null)
const removing = ref(false)
function toggle(domain: string, key: RecordKey) {
expanded[domain] = expanded[domain] === key ? null : key
}
function copyValue(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
toast.ok('Copied to clipboard')
}
function issuesFor(d: DomainView): RecordKey[] {
return REQUIRED_KEYS.filter((k) => d.checks[k] !== 'ok')
}
function recordsOfKind(d: DomainView, k: RecordKey): DomainRecordView[] {
return d.records.filter((r) => r.kind === k)
}
function tone(status: RecordStatus): Tone {
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : status === 'pending' ? 'warn' : 'bad'
}
function statusIcon(t: Tone): 'check' | 'shield' | 'x' {
return t === 'ok' ? 'check' : t === 'warn' ? 'shield' : 'x'
}
function recordTint(t: Tone) {
return t === 'bad' ? 'rgba(226,48,48,0.12)'
: t === 'warn' ? 'rgba(232,154,31,0.12)'
: 'rgba(91,140,90,0.12)'
}
function badgeFor(d: DomainView): { tone: 'ok' | 'warn' | 'bad'; label: string } {
if (d.status === 'active') return { tone: 'ok', label: 'verified' }
if (d.status === 'error') return { tone: 'bad', label: 'error' }
return { tone: 'warn', label: 'attention' }
}
async function recheckDomain(domain: string) {
rechecking.value = domain
try {
await recheck(domain)
await refresh()
toast.ok(`Re-checked ${domain}`)
} catch (err) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad('Could not re-check', e?.data?.message ?? e?.message ?? 'Unknown error')
} finally {
rechecking.value = null
}
}
async function confirmRemove() {
const d = removeTarget.value
if (!d) return
removing.value = true
try {
await remove(d.domain)
await refresh()
toast.ok(`Removed ${d.domain}`)
removeTarget.value = null
} catch (err) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad('Could not remove domain', e?.data?.message ?? e?.message ?? 'Unknown error')
} finally {
removing.value = false
}
}
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Domains"
subtitle="Your verified domains for mail, SSO, and user provisioning."
>
<template #actions>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-if="domains && domains.length === 0" class="empty">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div>
<div class="empty-title">No domains yet</div>
<div class="empty-sub">Add your first email domain to route mail and enable sign-in for your team.</div>
</div>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</Card>
<Card v-for="d in domains" :key="d.id">
<div class="head">
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
<div class="title">
<div class="domain-name">{{ d.domain }}</div>
<div class="domain-sub">
{{ d.mailboxes }} mailbox{{ d.mailboxes === 1 ? '' : 'es' }}
<template v-if="issuesFor(d).length">
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
</template>
</div>
</div>
<UiButton size="sm" variant="secondary" :disabled="rechecking === d.domain" @click.stop="recheckDomain(d.domain)">
<template #leading><UiIcon name="refresh" :size="12" /></template>
{{ rechecking === d.domain ? 'Checking…' : 'Re-check now' }}
</UiButton>
<button
class="remove"
:disabled="d.mailboxes > 0"
:title="d.mailboxes > 0
? `${d.mailboxes} mailbox${d.mailboxes === 1 ? '' : 'es'} use this domain — remove or reassign those users first`
: 'Remove domain'"
@click.stop="removeTarget = d"
>
<UiIcon name="trash" :size="14" />
</button>
<Badge :tone="badgeFor(d).tone" dot>{{ badgeFor(d).label }}</Badge>
</div>
<div v-if="d.stalwartError" class="prov-error">
<UiIcon name="x" :size="13" stroke="var(--bad)" />
Provisioning error: {{ d.stalwartError }}
</div>
<div class="records">
<button
v-for="k in RECORD_KEYS"
:key="k"
class="rec"
:class="{ active: expanded[d.domain] === k }"
@click="toggle(d.domain, k)"
>
<Mono>{{ k.toUpperCase() }}</Mono>
<div class="rec-right">
<Badge :tone="tone(d.checks[k])" dot>{{ d.checks[k] }}</Badge>
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
</div>
</button>
</div>
<div v-if="expanded[d.domain]" class="detail" :data-tone="tone(d.checks[expanded[d.domain]!])">
<div class="detail-head">
<div class="detail-icon" :style="{ background: recordTint(tone(d.checks[expanded[d.domain]!])), color: `var(--${tone(d.checks[expanded[d.domain]!])})` }">
<UiIcon :name="statusIcon(tone(d.checks[expanded[d.domain]!]))" :size="14" :stroke-width="d.checks[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
</div>
<div class="detail-body">
<div class="detail-title">
{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].headline }}
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
</div>
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].body }}</div>
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
</div>
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
</div>
<template v-if="d.checks[expanded[d.domain]!] !== 'ok'">
<div class="rec-action">
<Eyebrow>Add {{ recordsOfKind(d, expanded[d.domain]!).length > 1 ? 'these records' : 'this record' }} at your DNS provider</Eyebrow>
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="rec-grid">
<div class="rec-grid-label">Type</div>
<div class="rec-grid-val">{{ rec.type }}</div>
<div class="rec-grid-ttl">TTL 3600</div>
<div class="rec-grid-label sep">Host</div>
<div class="rec-grid-span sep">
<span>{{ rec.host }} <span class="muted">· resolves to {{ rec.fqdn }}</span></span>
<button class="copy" @click="copyValue(rec.host)"><UiIcon name="copy" :size="12" /></button>
</div>
<div class="rec-grid-label sep">Value</div>
<div class="rec-grid-span sep">
<span class="break">{{ rec.expected }}</span>
<button class="copy" @click="copyValue(rec.expected)"><UiIcon name="copy" :size="12" /></button>
</div>
<template v-if="rec.priority !== undefined">
<div class="rec-grid-label sep">Priority</div>
<div class="rec-grid-span sep">{{ rec.priority }}</div>
</template>
</div>
<div class="rec-actions-row">
<UiButton size="sm" variant="ghost" :disabled="rechecking === d.domain" @click="recheckDomain(d.domain)">
<template #leading><UiIcon name="refresh" :size="13" /></template>
{{ rechecking === d.domain ? 'Checking…' : 'Re-check this record' }}
</UiButton>
<div class="spacer" />
<Mono dim>changes can take up to 24h to propagate</Mono>
</div>
</div>
</template>
<template v-else>
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="currently-set">
<Eyebrow>Currently set</Eyebrow>
<div class="set-value">{{ rec.observed || rec.expected }}</div>
</div>
</template>
</div>
</Card>
</div>
<ConfirmDialog
:open="!!removeTarget"
eyebrow="Identity · Domains"
:title="`Remove ${removeTarget?.domain}?`"
confirm-label="Remove domain"
tone="danger"
:busy="removing"
@close="removeTarget = null"
@confirm="confirmRemove"
>
Mail routing and DKIM signing for <strong>{{ removeTarget?.domain }}</strong> are deleted from the mail
server immediately. Inbound mail to this domain will stop being delivered. This can't be undone — you'd
need to add the domain again and re-publish its DNS records.
</ConfirmDialog>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
.empty { display: flex; align-items: center; gap: 16px; }
.empty-title { font-weight: 600; }
.empty-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.head { display: flex; align-items: center; gap: 16px; }
.title { flex: 1; min-width: 0; }
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-mute);
cursor: pointer;
transition: color 120ms, border-color 120ms, background 120ms;
}
.remove:hover:not(:disabled) { color: var(--bad); border-color: var(--bad); }
.remove:disabled { opacity: 0.4; cursor: not-allowed; }
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.warn { color: var(--warn); }
.prov-error {
margin-top: 12px;
font-size: 12px;
color: var(--bad);
display: flex;
align-items: center;
gap: 6px;
}
.records {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.rec {
padding: 10px 12px;
background: var(--bg);
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
font-family: inherit;
text-align: left;
transition: background 120ms, border-color 120ms;
}
.rec:hover { background: var(--surface); }
.rec.active { background: var(--surface); border-color: var(--text); }
.rec-right { display: flex; align-items: center; gap: 6px; }
.detail {
margin-top: 16px;
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
}
.detail[data-tone='ok'] { border-left-color: var(--ok); }
.detail[data-tone='warn'] { border-left-color: var(--warn); }
.detail[data-tone='bad'] { border-left-color: var(--bad); }
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
.detail-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-body { flex: 1; }
.detail-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
}
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.rec-action { margin-top: 16px; }
.rec-grid {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: grid;
grid-template-columns: 70px 1fr 80px;
font-family: var(--font-mono);
font-size: 12px;
overflow: hidden;
margin-top: 8px;
}
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
.rec-grid-label.sep { border-top: 1px solid var(--border); }
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
.rec-grid-span {
padding: 10px 12px;
grid-column: 2 / 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rec-grid-span.sep { border-top: 1px solid var(--border); }
.break { word-break: break-all; }
.muted { color: var(--text-mute); }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--bg); }
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.spacer { flex: 1; }
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
</style>