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.
This commit is contained in:
Ronni Baslund
2026-06-07 00:16:30 +02:00
parent 04191193c2
commit aee8f13899
18 changed files with 1035 additions and 461 deletions
-32
View File
@@ -72,38 +72,6 @@ export const sampleAudit = [
{ id: 'a8', when: '09:30:00', actor: 'Anne Baslund', action: 'branding.color_set', target: '#D4FF3A', ip: '92.43.118.4', tone: 'info' as const },
]
// Org-wide mail aliases — strict port of platform-admin.jsx ORG_ALIASES (line 9)
export const orgAliases = [
{ alias: 'info@dezky.com', dest: 'Distribution · Everyone', active: true, created: '14 Jan 2026' },
{ alias: 'sales@dezky.com', dest: 'frederik@dezky.com', active: true, created: '22 Jan 2026' },
{ alias: 'support@dezky.com', dest: 'sofie@dezky.com', active: true, created: '22 Jan 2026' },
{ alias: 'no-reply@dezky.com', dest: '(discard)', active: true, created: '14 Jan 2026' },
{ alias: 'careers@dezky.com', dest: 'anne@dezky.com', active: true, created: '04 Mar 2026' },
{ alias: 'legal@dezky.com', dest: 'mikkel@dezky.com', active: false, created: '12 Apr 2026' },
]
// Forwarding rules — strict port of platform-admin.jsx FORWARDING_RULES (line 18)
export const forwardingRules = [
{ name: 'Out-of-hours to on-call', match: 'support@dezky.com · 18:0008:00 CET', fwd: 'oncall@dezky.com', enabled: true },
{ name: 'Vendor invoices', match: 'subject: "invoice" · from: *@vendors', fwd: 'finance@dezky.com', enabled: true },
{ name: 'Legal threads', match: 'cc: legal@*', fwd: 'mikkel@dezky.com', enabled: false },
]
// Distribution lists — strict port of platform-admin.jsx DISTRIBUTION_LISTS (line 24)
export const distributionLists = [
{ name: 'Everyone', alias: 'everyone@dezky.com', members: 11, owner: 'Anne Baslund', moderation: 'open' as const, external: false },
{ name: 'Engineering', alias: 'eng@dezky.com', members: 4, owner: 'Anne Baslund', moderation: 'closed' as const, external: false },
{ name: 'Leadership', alias: 'leads@dezky.com', members: 3, owner: 'Anne Baslund', moderation: 'closed' as const, external: false },
{ name: 'Customers VIP', alias: 'vip-customers@dezky.com', members: 0, owner: 'Frederik Madsen', moderation: 'closed' as const, external: true },
]
// Anti-spam content filters — strict port of platform-admin.jsx ANTI_SPAM_FILTERS (line 31)
export const antiSpamFilters = [
{ name: 'Block executable attachments', match: 'attachment ext in (.exe, .scr, .bat, .cmd)', action: 'reject' as const, enabled: true },
{ name: 'Quarantine cryptocurrency mail', match: 'body contains "wallet address"', action: 'quarantine' as const, enabled: true },
{ name: 'Tag external mail', match: 'from outside @dezky.com', action: 'add tag' as const, enabled: true },
]
// Full groups list — strict port of platform-admin.jsx GROUPS_FULL (line 64)
export const groupsFull = [
{ id: 'g_eng', name: 'Engineering', alias: 'engineering@dezky.com', members: 4, description: 'Product engineering team', created: '14 Jan 2026', owner: 'Anne Baslund' },
+296 -429
View File
@@ -1,112 +1,203 @@
<script setup lang="ts">
// Strict port of project/platform-admin.jsx `MailSettingsScreen` (lines 76-305).
// 5 tabs: Aliases / Forwarding / Filters · anti-spam / Distribution lists /
// Compliance · retention. Each uses the source's data and copy verbatim.
// 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'
import {
orgAliases,
forwardingRules,
antiSpamFilters,
distributionLists,
} from '~/data/workspace'
const tab = ref<'aliases' | 'forwarding' | 'filters' | 'lists' | 'compliance'>('aliases')
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 addAliasOpen = ref(false)
const ruleOpen = ref(false)
const filterOpen = ref(false)
const listOpen = ref(false)
const holdOpen = ref(false)
const openList = ref<typeof distributionLists[number] | null>(null)
const deleteListOpen = ref(false)
const tab = ref<'aliases' | 'lists' | 'forwarding' | 'filters' | 'compliance'>('aliases')
// Track each forwarding rule's enabled flag locally so the toggle visually flips.
const ruleEnabled = reactive<Record<string, boolean>>(
Object.fromEntries(forwardingRules.map((r) => [r.name, r.enabled])),
const { data: aliases, refresh: refreshAliases } = await useFetch<AliasView[]>(
() => `/api/tenants/${slug.value}/mail/aliases`,
{ key: 'mail-aliases', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const filterEnabled = reactive<Record<string, boolean>>(
Object.fromEntries(antiSpamFilters.map((f) => [f.name, f.enabled])),
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] },
)
async function copyAlias(alias: string) {
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 navigator.clipboard.writeText(alias)
toast.ok('Alias copied', alias)
} catch {
toast.warn('Copy failed', 'Select and copy manually')
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
}
}
function aliasAction(alias: string, id: string) {
if (id === 'edit') addAliasOpen.value = true
else if (id === 'copy') copyAlias(alias)
else if (id === 'disable') toast.info(`${alias} disabled`)
else if (id === 'delete') toast.bad(`${alias} deleted`)
}
const aliasItems = [
{ id: 'edit', label: 'Edit alias', icon: 'brush' as const },
{ id: 'copy', label: 'Copy address', icon: 'copy' as const },
{ id: 'disable', label: 'Disable alias', icon: 'x' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete alias', icon: 'trash' as const, danger: true },
]
function ruleAction(name: string, id: string) {
if (id === 'edit') ruleOpen.value = true
else if (id === 'run') toast.info(`Running "${name}" once`)
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
else if (id === 'delete') toast.bad(`"${name}" deleted`)
}
const ruleItems = [
{ id: 'edit', label: 'Edit rule', icon: 'brush' as const },
{ id: 'run', label: 'Run once now', icon: 'refresh' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete rule', icon: 'trash' as const, danger: true },
]
function filterAction(name: string, id: string) {
if (id === 'edit') filterOpen.value = true
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
else if (id === 'delete') toast.bad(`"${name}" deleted`)
}
const filterItems = [
{ id: 'edit', label: 'Edit filter', icon: 'brush' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'sep1', separator: true },
{ id: 'delete', label: 'Delete filter', icon: 'trash' as const, danger: true },
]
function confirmDeleteList() {
deleteListOpen.value = false
const name = openList.value?.name
openList.value = null
toast.bad(`${name} deleted`)
}
const retention = ref<'30d' | '1year' | '3year' | 'unlimited'>('3year')
const retentionOptions = [
{ v: '30d' as const, label: '30 days', d: 'Standard retention. Anything older is permanently deleted.' },
{ v: '1year' as const, label: '1 year', d: 'Mid-term. Suitable for most non-regulated businesses.' },
{ v: '3year' as const, label: '3 years · recommended', d: 'Danish bookkeeping retention compliant (5-year option also available).' },
{ v: 'unlimited' as const, label: 'Unlimited', d: 'Required for regulated industries (legal, healthcare, public sector).' },
]
const tabs = [
{ value: 'aliases', label: 'Aliases', count: orgAliases.length },
{ value: 'forwarding', label: 'Forwarding', count: forwardingRules.length },
{ value: 'filters', label: 'Filters · anti-spam', count: antiSpamFilters.length },
{ value: 'lists', label: 'Distribution lists', count: distributionLists.length },
{ value: 'compliance', label: 'Compliance · retention' },
]
function toneFor(action: string): 'bad' | 'warn' | 'info' {
return action === 'reject' ? 'bad' : action === 'quarantine' ? 'warn' : 'info'
}
const hasDomain = computed(() => (domains.value?.length ?? 0) > 0)
</script>
<template>
@@ -114,7 +205,7 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
<PageHeader
eyebrow="Workspace"
title="Mail settings"
subtitle="Organization-level aliases, forwarding, content filters, and compliance policies."
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" />
@@ -124,25 +215,26 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
<!-- ALIASES -->
<template v-if="tab === 'aliases'">
<div class="row">
<div class="lead">Aliases route mail to existing users or distribution lists. They count against your domain, not your seats.</div>
<UiButton variant="primary" @click="addAliasOpen = true">
<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 :pad="0">
<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>Destination</th><th>State</th><th>Created</th><th></th></tr></thead>
<thead><tr><th>Alias</th><th></th><th>Delivers to</th><th>State</th><th></th></tr></thead>
<tbody>
<tr v-for="r in orgAliases" :key="r.alias">
<td><Mono style="font-weight: 500">{{ r.alias }}</Mono></td>
<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>{{ r.dest }}</td>
<td><Badge :tone="r.active ? 'ok' : 'neutral'" dot>{{ r.active ? 'active' : 'paused' }}</Badge></td>
<td><Mono dim>{{ r.created }}</Mono></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="copyAlias(r.alias)"><UiIcon name="copy" :size="13" /></UiButton>
<AdminKebabMenu :items="aliasItems" :icon-size="13" @select="(id) => aliasAction(r.alias, id)" />
<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>
@@ -150,288 +242,148 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
</Card>
</template>
<!-- FORWARDING -->
<template v-else-if="tab === 'forwarding'">
<div class="row">
<div class="lead">Conditional rules applied to all incoming mail. Useful for routing customer inquiries or auto-escalating.</div>
<UiButton variant="primary" @click="ruleOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New rule
</UiButton>
</div>
<div class="rules">
<Card v-for="r in forwardingRules" :key="r.name" :pad="16">
<div class="rule-row">
<button class="toggle" :class="{ on: ruleEnabled[r.name] }" @click="ruleEnabled[r.name] = !ruleEnabled[r.name]"><span /></button>
<div class="rule-meta">
<div class="rule-name">{{ r.name }}</div>
<div class="rule-line">
<Mono dim>WHEN</Mono>
<span class="rule-match">{{ r.match }}</span>
<UiIcon name="arrowRight" :size="11" stroke="var(--text-mute)" />
<Mono dim>FORWARD TO</Mono>
<Mono>{{ r.fwd }}</Mono>
</div>
</div>
<UiButton size="sm" variant="ghost" @click="ruleOpen = true">Edit</UiButton>
<AdminKebabMenu :items="ruleItems" @select="(id) => ruleAction(r.name, id)" />
</div>
</Card>
</div>
</template>
<!-- FILTERS -->
<template v-else-if="tab === 'filters'">
<div class="row">
<div class="lead">Org-wide content filters apply <i>before</i> user-level rules. Stalwart's spam engine handles the rest automatically.</div>
<UiButton variant="primary" @click="filterOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New filter
</UiButton>
</div>
<Card :pad="0">
<table class="tbl">
<thead><tr><th>Filter</th><th>Match</th><th>Action</th><th></th></tr></thead>
<tbody>
<tr v-for="f in antiSpamFilters" :key="f.name">
<td>
<div class="filter-name">
<button class="toggle" :class="{ on: filterEnabled[f.name] }" @click="filterEnabled[f.name] = !filterEnabled[f.name]"><span /></button>
<span>{{ f.name }}</span>
</div>
</td>
<td><Mono dim>{{ f.match }}</Mono></td>
<td><Badge :tone="toneFor(f.action)">{{ f.action }}</Badge></td>
<td class="right"><AdminKebabMenu :items="filterItems" @select="(id) => filterAction(f.name, id)" /></td>
</tr>
</tbody>
</table>
</Card>
<div class="builtin">
<UiIcon name="shield" :size="16" stroke="var(--ok)" />
<div>
<div class="builtin-title">Built-in spam protection · enabled</div>
<div class="builtin-sub">
Stalwart's reputation engine and Bayesian filter block ~94% of spam at the edge. <Mono>last 7d: 12,840 blocked · 18 quarantined · 0 false positives reported</Mono>
</div>
</div>
</div>
</template>
<!-- LISTS -->
<!-- DISTRIBUTION LISTS -->
<template v-else-if="tab === 'lists'">
<div class="row">
<div class="lead">Distribution lists send mail to many recipients via a single alias. Members can be internal users, groups, or external addresses.</div>
<UiButton variant="primary" @click="listOpen = true">
<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>
<div class="lists">
<Card v-for="l in distributionLists" :key="l.alias">
<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>
<Badge v-if="l.external" tone="warn">external members</Badge>
</div>
<Mono dim style="display: block; margin-top: 4px">{{ l.alias }}</Mono>
<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="l.moderation === 'open' ? 'ok' : 'neutral'">{{ l.moderation }}</Badge>
<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">
<div>
<Eyebrow>Members</Eyebrow>
<div class="list-num">{{ l.members }}</div>
</div>
<div class="list-owner">
<Eyebrow>Owner</Eyebrow>
<div>{{ l.owner }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="openList = l">Manage</UiButton>
<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>
<!-- COMPLIANCE -->
<!-- FORWARDING / FILTERS / COMPLIANCE platform-managed -->
<template v-else>
<div class="compliance">
<Card>
<div class="card-head">
<Eyebrow>Retention</Eyebrow>
<div class="card-title">Mail retention policy</div>
<div class="card-sub">Applied org-wide. Compliance requirements override user-level deletion.</div>
</div>
<div class="radio-big">
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
<span class="radio-dot"><span v-if="retention === o.v" /></span>
<input type="radio" :value="o.v" v-model="retention" />
<div>
<div class="radio-label">{{ o.label }}</div>
<div class="radio-d">{{ o.d }}</div>
</div>
</label>
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Journaling</Eyebrow>
<div class="card-title">Mail journaling</div>
<div class="card-sub">Copy every inbound and outbound mail to a journal mailbox for e-discovery.</div>
</div>
<button class="toggle"><span /></button>
</div>
<div class="muted">
When enabled, journals are written to <Mono>journal@dezky.com</Mono>. Storage usage counts against your plan. Available on Business and Enterprise plans.
</div>
</Card>
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Legal hold</Eyebrow>
<div class="card-title">Legal hold cases</div>
</div>
<UiButton size="sm" variant="secondary" @click="holdOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
New hold
</UiButton>
</div>
<div class="empty">
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">No active holds</div>
<div class="empty-body">When legal review is needed, place a hold on specific users or date ranges to prevent deletion.</div>
</div>
</Card>
</div>
<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 (stub) -->
<Modal :open="addAliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="addAliasOpen = false">
<!-- 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" value="marketing" placeholder="prefix" />
<input class="input" v-model="aliasForm.localPart" placeholder="sales" />
<span class="at">@</span>
<select class="input"><option>dezky.com</option><option>baslund.dk</option></select>
<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>Route to user</Eyebrow><input class="input" value="frederik@dezky.com" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="addAliasOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="addAliasOpen = false">
<template #leading><UiIcon name="check" :size="13" /></template>
Create alias
</UiButton>
</template>
</Modal>
<!-- Forwarding rule modal -->
<Modal :open="ruleOpen" eyebrow="Mail · forwarding" title="New forwarding rule" size="md" @close="ruleOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Rule name</Eyebrow><input class="input" placeholder="Out-of-hours to on-call" /></label>
<label class="field"><Eyebrow>When</Eyebrow><input class="input" placeholder="subject: …" /></label>
<label class="field"><Eyebrow>Forward to</Eyebrow><input class="input" placeholder="oncall@dezky.com" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="ruleOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="ruleOpen = false">
<template #leading><UiIcon name="check" :size="13" /></template>
Save rule
</UiButton>
</template>
</Modal>
<!-- New filter modal -->
<Modal :open="filterOpen" eyebrow="Mail · filters" title="New content filter" size="md" @close="filterOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Filter name</Eyebrow><input class="input" placeholder="Block executable attachments" /></label>
<label class="field"><Eyebrow>Match expression</Eyebrow><input class="input" placeholder="attachment ext in (.exe, .scr, .bat)" /></label>
<label class="field"><Eyebrow>Action</Eyebrow>
<select class="input"><option>reject</option><option>quarantine</option><option>add tag</option></select>
<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="filterOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="filterOpen = false">Create filter</UiButton>
<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 name</Eyebrow><input class="input" placeholder="Engineering" /></label>
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
<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" @click="listOpen = false">Create list</UiButton>
<UiButton variant="primary" :disabled="listBusy || !listForm.localPart.trim()" @click="submitList">
{{ listBusy ? 'Creating' : 'Create list' }}
</UiButton>
</template>
</Modal>
<!-- Manage list side panel -->
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.name || ''" width="lg" @close="openList = null">
<!-- Manage list members -->
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.address || ''" width="lg" @close="openList = null">
<div v-if="openList" class="manage">
<div class="manage-head">
<div class="manage-icon"><UiIcon name="users" :size="20" /></div>
<div class="manage-meta">
<div class="manage-name">{{ openList.name }}</div>
<Mono dim>{{ openList.alias }}</Mono>
</div>
<Badge :tone="openList.moderation === 'open' ? 'ok' : 'neutral'">{{ openList.moderation }}</Badge>
</div>
<div class="manage-stats">
<div><Eyebrow>Members</Eyebrow><div class="ms-v">{{ openList.members }}</div></div>
<div><Eyebrow>Owner</Eyebrow><div class="ms-v">{{ openList.owner }}</div></div>
<div><Eyebrow>Posts this week</Eyebrow><div class="ms-v">{{ openList.members > 8 ? '142' : openList.members > 2 ? '38' : '6' }}</div></div>
</div>
<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="deleteListOpen = true">
<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">Discard</UiButton>
<UiButton variant="primary" @click="openList = null; toast.ok('List saved')">
<template #leading><UiIcon name="check" :size="13" /></template>
Save changes
</UiButton>
<UiButton variant="secondary" @click="openList = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="manageBusy" @click="saveList">{{ manageBusy ? 'Saving' : 'Save members' }}</UiButton>
</template>
</SidePanel>
<!-- Confirm delete list -->
<ConfirmDialog
:open="deleteListOpen"
:open="!!listDeleteTarget"
eyebrow="Distribution list"
:title="`Delete ${openList?.name || ''}?`"
:title="`Delete ${listDeleteTarget?.address}?`"
confirm-label="Delete list"
tone="danger"
@close="deleteListOpen = false"
:busy="listDeleting"
@close="listDeleteTarget = null"
@confirm="confirmDeleteList"
>
Mail sent to this list will start bouncing immediately. Existing replies in members' inboxes are unaffected.
Mail to this list will start bouncing immediately. Members' existing inboxes are unaffected.
</ConfirmDialog>
<!-- Legal hold modal -->
<Modal :open="holdOpen" eyebrow="Compliance · legal hold" title="Place legal hold" size="md" @close="holdOpen = false">
<div class="form-stack">
<label class="field"><Eyebrow>Case name</Eyebrow><input class="input" placeholder="Case 2026-Q3-DPA-001" /></label>
<label class="field"><Eyebrow>Scope · users</Eyebrow><input class="input" placeholder="anne@, mikkel@, frederik@" /></label>
<label class="field"><Eyebrow>Date range</Eyebrow><input class="input" placeholder="2026-01-01 → 2026-12-31" /></label>
</div>
<template #footer>
<UiButton variant="ghost" @click="holdOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="holdOpen = false">Place hold</UiButton>
</template>
</Modal>
</div>
</template>
@@ -439,121 +391,36 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
.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; }
.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 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; }
.rules { display: flex; flex-direction: column; gap: 10px; }
.rule-row { display: flex; align-items: center; gap: 14px; }
.rule-meta { flex: 1; }
.rule-name { font-size: 14px; font-weight: 500; }
.rule-line { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; }
.rule-match { font-family: var(--font-mono); color: var(--text-dim); }
.toggle {
width: 32px;
height: 18px;
border-radius: 999px;
background: var(--border);
border: none;
position: relative;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.toggle span {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 999px;
background: var(--bg);
transition: left 120ms;
}
.toggle.on { background: var(--text); }
.toggle.on span { left: 16px; background: var(--accent); }
.filter-name { display: flex; align-items: center; gap: 10px; }
.builtin {
margin-top: 16px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
display: flex;
align-items: flex-start;
gap: 12px;
}
.builtin-title { font-size: 13px; font-weight: 600; }
.builtin-sub { color: var(--text-mute); margin-top: 4px; font-size: 13px; }
.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-row { display: flex; gap: 18px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); align-items: flex-end; }
.list-num { font-family: var(--font-display); font-weight: 600; font-size: 20px; margin-top: 4px; }
.list-owner { flex: 1; font-size: 13px; }
.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); }
.compliance { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
.card-head { margin-bottom: 12px; }
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
.radio-big { display: flex; flex-direction: column; gap: 8px; }
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.radio-big label.active { border-color: var(--text); background: var(--bg); }
.radio-big input { display: none; }
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
.radio-big label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
.radio-label { font-size: 14px; font-weight: 500; }
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
.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; }
.empty { padding: 36px 24px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; }
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
/* Modal forms */
.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: 24px; }
.manage-head { display: flex; align-items: center; gap: 14px; }
.manage-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--bg);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
}
.manage-meta { flex: 1; min-width: 0; }
.manage-name { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.manage-stats { display: flex; gap: 24px; margin-top: 16px; }
.ms-v { font-size: 13px; margin-top: 4px; font-weight: 500; }
.manage { padding-bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
</style>
@@ -0,0 +1,13 @@
// List the tenant's email aliases. Proxies GET /tenants/:slug/mail/aliases.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mail/aliases`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,16 @@
// Create an email alias. Proxies POST /tenants/:slug/mail/aliases.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mail/aliases`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,16 @@
// Delete an email alias. Proxies DELETE /tenants/:slug/mail/aliases/:address.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const address = getRouterParam(event, 'address')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/mail/aliases/${encodeURIComponent(address ?? '')}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,13 @@
// List the tenant's distribution lists. Proxies GET /tenants/:slug/mail/lists.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mail/lists`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,16 @@
// Create a distribution list. Proxies POST /tenants/:slug/mail/lists.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mail/lists`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,16 @@
// Delete a distribution list. Proxies DELETE /tenants/:slug/mail/lists/:id.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const id = getRouterParam(event, 'id')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/mail/lists/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,17 @@
// Update a distribution list's recipients. Proxies PATCH /tenants/:slug/mail/lists/:id.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/mail/lists/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
+3
View File
@@ -100,6 +100,9 @@ export interface TenantUserDoc {
active: boolean
lastLoginAt?: string
createdAt?: string
// Present when the user has a provisioned Stalwart mailbox (i.e. can receive
// mail / be an alias destination). Absent for SSO-only users.
mailboxAddress?: string
}
// The card on file, as returned by GET /tenants/:slug/payment-method (null when