Files
dezky/apps/portal/pages/admin/mail.vue
T
Ronni Baslund aee8f13899 feat(mail): tenant alias and distribution-list management via Stalwart
Customer-admin Mail settings backed by Stalwart JMAP: per-tenant aliases
(extra addresses routing to a mailbox) and distribution lists (one address
fanning out to many recipients). Adds StalwartClient x:Alias/x:MailingList
methods, a tenant-scoped MailController/MailService, the portal Mail settings
page and its proxy routes, and the mailboxAddress field on TenantUserDoc.
Removes the old mock mail data now that the page reads live data.
2026-06-07 00:16:30 +02:00

427 lines
18 KiB
Vue

<script setup lang="ts">
// Mail settings. The per-tenant parts are real: Aliases (extra addresses that
// route to a mailbox) and Distribution lists (one address → many recipients),
// both backed by Stalwart via /api/tenants/:slug/mail/*. The other three tabs —
// Forwarding, Filters, Compliance — are server-global in Stalwart (one shared
// spam engine / retention policy), so they're operator-managed, not per-customer;
// they render an honest "managed by Dezky" state rather than fake controls.
import type { TenantUserDoc } from '~/types/workspace'
interface AliasView {
address: string
localPart: string
domain: string
destination: string
accountId: string
enabled: boolean
}
interface ListView {
id: string
name: string
address: string
domain: string
recipients: string[]
members: number
description?: string
}
interface DomainLite { id: string; domain: string; isPrimary: boolean }
const toast = useToast()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { request } = useApiFetch()
const tab = ref<'aliases' | 'lists' | 'forwarding' | 'filters' | 'compliance'>('aliases')
const { data: aliases, refresh: refreshAliases } = await useFetch<AliasView[]>(
() => `/api/tenants/${slug.value}/mail/aliases`,
{ key: 'mail-aliases', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: lists, refresh: refreshLists } = await useFetch<ListView[]>(
() => `/api/tenants/${slug.value}/mail/lists`,
{ key: 'mail-lists', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: domains } = await useFetch<DomainLite[]>(
() => `/api/tenants/${slug.value}/domains`,
{ key: 'mail-domains', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'mail-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const primaryDomain = computed(() => domains.value?.find((d) => d.isPrimary) ?? domains.value?.[0])
// Only users with a real mailbox can be an alias destination.
const mailboxUsers = computed(() => (users.value ?? []).filter((u) => !!u.mailboxAddress))
function toastErr(err: unknown, title: string) {
const e = err as { data?: { message?: string | string[] }; message?: string }
const m = e?.data?.message ?? e?.message ?? 'Unknown error'
toast.bad(title, Array.isArray(m) ? m.join(', ') : m)
}
const tabs = computed(() => [
{ value: 'aliases', label: 'Aliases', count: aliases.value?.length ?? 0 },
{ value: 'lists', label: 'Distribution lists', count: lists.value?.length ?? 0 },
{ value: 'forwarding', label: 'Forwarding' },
{ value: 'filters', label: 'Filters · anti-spam' },
{ value: 'compliance', label: 'Compliance · retention' },
])
// ── Aliases ──
const aliasOpen = ref(false)
const aliasBusy = ref(false)
const aliasForm = reactive({ localPart: '', domain: '', destinationUserId: '' })
const aliasDeleteTarget = ref<AliasView | null>(null)
const aliasDeleting = ref(false)
function openAlias() {
aliasForm.localPart = ''
aliasForm.domain = primaryDomain.value?.domain ?? ''
aliasForm.destinationUserId = mailboxUsers.value[0]?._id ?? ''
aliasOpen.value = true
}
async function submitAlias() {
if (!aliasForm.localPart.trim() || !aliasForm.domain || !aliasForm.destinationUserId) return
aliasBusy.value = true
try {
await request(`/api/tenants/${slug.value}/mail/aliases`, { method: 'POST', body: { ...aliasForm } })
await refreshAliases()
toast.ok('Alias created')
aliasOpen.value = false
} catch (err) {
toastErr(err, 'Could not create alias')
} finally {
aliasBusy.value = false
}
}
async function confirmDeleteAlias() {
const a = aliasDeleteTarget.value
if (!a) return
aliasDeleting.value = true
try {
await request(`/api/tenants/${slug.value}/mail/aliases/${encodeURIComponent(a.address)}`, { method: 'DELETE' })
await refreshAliases()
toast.ok('Alias deleted', a.address)
aliasDeleteTarget.value = null
} catch (err) {
toastErr(err, 'Could not delete alias')
} finally {
aliasDeleting.value = false
}
}
function copyText(t: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(t)
toast.ok('Copied to clipboard')
}
// ── Distribution lists ──
const listOpen = ref(false)
const listBusy = ref(false)
const listForm = reactive({ localPart: '', domain: '', description: '', recipientsText: '' })
const openList = ref<ListView | null>(null)
const manageText = ref('')
const manageBusy = ref(false)
const listDeleteTarget = ref<ListView | null>(null)
const listDeleting = ref(false)
function parseRecipients(text: string): string[] {
return [...new Set(text.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter(Boolean))]
}
function openListCreate() {
listForm.localPart = ''
listForm.domain = primaryDomain.value?.domain ?? ''
listForm.description = ''
listForm.recipientsText = ''
listOpen.value = true
}
async function submitList() {
if (!listForm.localPart.trim() || !listForm.domain) return
listBusy.value = true
try {
await request(`/api/tenants/${slug.value}/mail/lists`, {
method: 'POST',
body: {
localPart: listForm.localPart.trim(),
domain: listForm.domain,
description: listForm.description.trim() || undefined,
recipients: parseRecipients(listForm.recipientsText),
},
})
await refreshLists()
toast.ok('List created')
listOpen.value = false
} catch (err) {
toastErr(err, 'Could not create list')
} finally {
listBusy.value = false
}
}
function manage(l: ListView) {
openList.value = l
manageText.value = l.recipients.join('\n')
}
async function saveList() {
const l = openList.value
if (!l) return
manageBusy.value = true
try {
await request(`/api/tenants/${slug.value}/mail/lists/${l.id}`, {
method: 'PATCH',
body: { recipients: parseRecipients(manageText.value) },
})
await refreshLists()
toast.ok('Members saved', l.address)
openList.value = null
} catch (err) {
toastErr(err, 'Could not save members')
} finally {
manageBusy.value = false
}
}
async function confirmDeleteList() {
const l = listDeleteTarget.value
if (!l) return
listDeleting.value = true
try {
await request(`/api/tenants/${slug.value}/mail/lists/${l.id}`, { method: 'DELETE' })
await refreshLists()
toast.ok('List deleted', l.address)
listDeleteTarget.value = null
openList.value = null
} catch (err) {
toastErr(err, 'Could not delete list')
} finally {
listDeleting.value = false
}
}
const hasDomain = computed(() => (domains.value?.length ?? 0) > 0)
</script>
<template>
<div>
<PageHeader
eyebrow="Workspace"
title="Mail settings"
subtitle="Aliases and distribution lists for your domains. Spam filtering and retention are managed by Dezky."
/>
<div class="tab-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div class="content">
<!-- ALIASES -->
<template v-if="tab === 'aliases'">
<div class="row">
<div class="lead">Aliases route mail to an existing mailbox. They count against your domain, not your seats.</div>
<UiButton variant="primary" :disabled="!hasDomain || mailboxUsers.length === 0" @click="openAlias">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add alias
</UiButton>
</div>
<Card v-if="!hasDomain" class="notice"><UiIcon name="globe" :size="18" stroke="var(--text-mute)" /> Add a domain first to create aliases.</Card>
<Card v-else-if="(aliases?.length ?? 0) === 0" class="notice"><UiIcon name="mail" :size="18" stroke="var(--text-mute)" /> No aliases yet. Add one to route extra addresses to a mailbox.</Card>
<Card v-else :pad="0">
<table class="tbl">
<thead><tr><th>Alias</th><th></th><th>Delivers to</th><th>State</th><th></th></tr></thead>
<tbody>
<tr v-for="a in aliases" :key="a.address">
<td><Mono style="font-weight: 500">{{ a.address }}</Mono></td>
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
<td><Mono dim>{{ a.destination }}</Mono></td>
<td><Badge :tone="a.enabled ? 'ok' : 'neutral'" dot>{{ a.enabled ? 'active' : 'paused' }}</Badge></td>
<td class="right">
<UiButton size="sm" variant="ghost" @click="copyText(a.address)"><UiIcon name="copy" :size="13" /></UiButton>
<UiButton size="sm" variant="ghost" @click="aliasDeleteTarget = a"><UiIcon name="trash" :size="13" /></UiButton>
</td>
</tr>
</tbody>
</table>
</Card>
</template>
<!-- DISTRIBUTION LISTS -->
<template v-else-if="tab === 'lists'">
<div class="row">
<div class="lead">Distribution lists send mail to many recipients via a single address. Members can be any email address.</div>
<UiButton variant="primary" :disabled="!hasDomain" @click="openListCreate">
<template #leading><UiIcon name="plus" :size="14" /></template>
New list
</UiButton>
</div>
<Card v-if="!hasDomain" class="notice"><UiIcon name="globe" :size="18" stroke="var(--text-mute)" /> Add a domain first to create lists.</Card>
<Card v-else-if="(lists?.length ?? 0) === 0" class="notice"><UiIcon name="users" :size="18" stroke="var(--text-mute)" /> No distribution lists yet.</Card>
<div v-else class="lists">
<Card v-for="l in lists" :key="l.id">
<div class="list-head">
<div>
<div class="list-title"><UiIcon name="users" :size="16" stroke="var(--text-mute)" /><span class="list-name">{{ l.name }}</span></div>
<Mono dim style="display: block; margin-top: 4px">{{ l.address }}</Mono>
</div>
<Badge tone="neutral">{{ l.members }} member{{ l.members === 1 ? '' : 's' }}</Badge>
</div>
<div v-if="l.description" class="list-desc">{{ l.description }}</div>
<div class="list-row">
<UiButton size="sm" variant="secondary" @click="manage(l)">Manage members</UiButton>
<UiButton size="sm" variant="ghost" @click="listDeleteTarget = l"><template #leading><UiIcon name="trash" :size="13" /></template>Delete</UiButton>
</div>
</Card>
</div>
</template>
<!-- FORWARDING / FILTERS / COMPLIANCE platform-managed -->
<template v-else>
<Card class="managed">
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
<div class="managed-title">{{ tab === 'forwarding' ? 'Forwarding rules' : tab === 'filters' ? 'Spam & content filtering' : 'Compliance & retention' }} are managed by Dezky</div>
<div class="managed-body">
<template v-if="tab === 'forwarding'">
Conditional forwarding runs on the shared mail platform. To route addresses today, use <strong>Aliases</strong> (one address a mailbox) or <strong>Distribution lists</strong> (one address many). Rule-based forwarding is on the roadmap.
</template>
<template v-else-if="tab === 'filters'">
Inbound mail passes through Dezky's reputation engine and Bayesian spam filter tuned and updated centrally for every workspace. Per-workspace filter rules aren't configurable here.
</template>
<template v-else>
Mail retention and legal-hold policies are applied platform-wide for EU compliance. Contact Dezky to adjust retention for your organisation.
</template>
</div>
</Card>
</template>
</div>
<!-- Add alias modal -->
<Modal :open="aliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="aliasOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Alias address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="aliasForm.localPart" placeholder="sales" />
<span class="at">@</span>
<select class="input" v-model="aliasForm.domain">
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
</div>
</label>
<label class="field"><Eyebrow>Delivers to mailbox</Eyebrow>
<select class="input" v-model="aliasForm.destinationUserId">
<option v-for="u in mailboxUsers" :key="u._id" :value="u._id">{{ u.name }} · {{ u.email }}</option>
</select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="aliasOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="aliasBusy || !aliasForm.localPart.trim() || !aliasForm.destinationUserId" @click="submitAlias">
{{ aliasBusy ? 'Creating' : 'Create alias' }}
</UiButton>
</template>
</Modal>
<ConfirmDialog
:open="!!aliasDeleteTarget"
eyebrow="Mail · aliases"
:title="`Delete ${aliasDeleteTarget?.address}?`"
confirm-label="Delete alias"
tone="danger"
:busy="aliasDeleting"
@close="aliasDeleteTarget = null"
@confirm="confirmDeleteAlias"
>
Mail to <strong>{{ aliasDeleteTarget?.address }}</strong> will stop being delivered. The destination mailbox is unaffected.
</ConfirmDialog>
<!-- New list modal -->
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>List address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="listForm.localPart" placeholder="team" />
<span class="at">@</span>
<select class="input" v-model="listForm.domain">
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
</div>
</label>
<label class="field"><Eyebrow>Description <span class="opt">optional</span></Eyebrow><input class="input" v-model="listForm.description" placeholder="Everyone in engineering" /></label>
<label class="field"><Eyebrow>Members <span class="opt">one email per line</span></Eyebrow>
<textarea class="input area" v-model="listForm.recipientsText" rows="4" placeholder="anne@acme.dk&#10;mikkel@acme.dk"></textarea>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="listOpen = false">Cancel</UiButton>
<UiButton variant="primary" :disabled="listBusy || !listForm.localPart.trim()" @click="submitList">
{{ listBusy ? 'Creating' : 'Create list' }}
</UiButton>
</template>
</Modal>
<!-- Manage list members -->
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.address || ''" width="lg" @close="openList = null">
<div v-if="openList" class="manage">
<Eyebrow>Members <span class="opt">one email per line</span></Eyebrow>
<textarea class="input area" v-model="manageText" rows="12" />
</div>
<template #footer>
<UiButton variant="danger" @click="openList && (listDeleteTarget = openList)">
<template #leading><UiIcon name="trash" :size="13" /></template>
Delete list
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="openList = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="manageBusy" @click="saveList">{{ manageBusy ? 'Saving' : 'Save members' }}</UiButton>
</template>
</SidePanel>
<ConfirmDialog
:open="!!listDeleteTarget"
eyebrow="Distribution list"
:title="`Delete ${listDeleteTarget?.address}?`"
confirm-label="Delete list"
tone="danger"
:busy="listDeleting"
@close="listDeleteTarget = null"
@confirm="confirmDeleteList"
>
Mail to this list will start bouncing immediately. Members' existing inboxes are unaffected.
</ConfirmDialog>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 20px 40px 64px 40px; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 16px; }
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
.notice { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-mute); }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 500; }
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
.tbl tr:last-child td { border-bottom: none; }
.tbl .right { text-align: right; white-space: nowrap; }
.lists { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.list-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.list-title { display: flex; align-items: center; gap: 8px; }
.list-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
.list-desc { font-size: 12px; color: var(--text-mute); margin-top: 10px; }
.list-row { display: flex; gap: 8px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
.managed { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 48px 32px; max-width: 640px; margin: 0 auto; }
.managed-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.managed-body { font-size: 13px; color: var(--text-mute); line-height: 1.6; max-width: 480px; }
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.opt { font-weight: 400; color: var(--text-mute); text-transform: none; letter-spacing: 0; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.area { height: auto; padding: 10px 12px; font-family: var(--font-mono); line-height: 1.5; resize: vertical; }
.alias-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
.at { font-family: var(--font-mono); color: var(--text-mute); }
.manage { padding-bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
</style>