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:
@@ -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 },
|
{ 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:00–08: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)
|
// Full groups list — strict port of platform-admin.jsx GROUPS_FULL (line 64)
|
||||||
export const groupsFull = [
|
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' },
|
{ id: 'g_eng', name: 'Engineering', alias: 'engineering@dezky.com', members: 4, description: 'Product engineering team', created: '14 Jan 2026', owner: 'Anne Baslund' },
|
||||||
|
|||||||
+292
-425
@@ -1,112 +1,203 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Strict port of project/platform-admin.jsx `MailSettingsScreen` (lines 76-305).
|
// Mail settings. The per-tenant parts are real: Aliases (extra addresses that
|
||||||
// 5 tabs: Aliases / Forwarding / Filters · anti-spam / Distribution lists /
|
// route to a mailbox) and Distribution lists (one address → many recipients),
|
||||||
// Compliance · retention. Each uses the source's data and copy verbatim.
|
// 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 {
|
interface AliasView {
|
||||||
orgAliases,
|
address: string
|
||||||
forwardingRules,
|
localPart: string
|
||||||
antiSpamFilters,
|
domain: string
|
||||||
distributionLists,
|
destination: string
|
||||||
} from '~/data/workspace'
|
accountId: string
|
||||||
|
enabled: boolean
|
||||||
const tab = ref<'aliases' | 'forwarding' | 'filters' | 'lists' | 'compliance'>('aliases')
|
}
|
||||||
|
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 toast = useToast()
|
||||||
|
const { tenant } = useTenant()
|
||||||
|
const slug = computed(() => tenant.value?.slug ?? '')
|
||||||
|
const { request } = useApiFetch()
|
||||||
|
|
||||||
const addAliasOpen = ref(false)
|
const tab = ref<'aliases' | 'lists' | 'forwarding' | 'filters' | 'compliance'>('aliases')
|
||||||
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)
|
|
||||||
|
|
||||||
// Track each forwarding rule's enabled flag locally so the toggle visually flips.
|
const { data: aliases, refresh: refreshAliases } = await useFetch<AliasView[]>(
|
||||||
const ruleEnabled = reactive<Record<string, boolean>>(
|
() => `/api/tenants/${slug.value}/mail/aliases`,
|
||||||
Object.fromEntries(forwardingRules.map((r) => [r.name, r.enabled])),
|
{ key: 'mail-aliases', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||||
)
|
)
|
||||||
const filterEnabled = reactive<Record<string, boolean>>(
|
const { data: lists, refresh: refreshLists } = await useFetch<ListView[]>(
|
||||||
Object.fromEntries(antiSpamFilters.map((f) => [f.name, f.enabled])),
|
() => `/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 {
|
try {
|
||||||
await navigator.clipboard.writeText(alias)
|
await request(`/api/tenants/${slug.value}/mail/aliases`, { method: 'POST', body: { ...aliasForm } })
|
||||||
toast.ok('Alias copied', alias)
|
await refreshAliases()
|
||||||
} catch {
|
toast.ok('Alias created')
|
||||||
toast.warn('Copy failed', 'Select and copy manually')
|
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) {
|
const hasDomain = computed(() => (domains.value?.length ?? 0) > 0)
|
||||||
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'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -114,7 +205,7 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Workspace"
|
eyebrow="Workspace"
|
||||||
title="Mail settings"
|
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">
|
<div class="tab-wrap">
|
||||||
<Tabs v-model="tab" :items="tabs" />
|
<Tabs v-model="tab" :items="tabs" />
|
||||||
@@ -124,25 +215,26 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
|||||||
<!-- ALIASES -->
|
<!-- ALIASES -->
|
||||||
<template v-if="tab === 'aliases'">
|
<template v-if="tab === 'aliases'">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="lead">Aliases route mail to existing users or distribution lists. They count against your domain, not your seats.</div>
|
<div class="lead">Aliases route mail to an existing mailbox. They count against your domain, not your seats.</div>
|
||||||
<UiButton variant="primary" @click="addAliasOpen = true">
|
<UiButton variant="primary" :disabled="!hasDomain || mailboxUsers.length === 0" @click="openAlias">
|
||||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||||
Add alias
|
Add alias
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</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">
|
<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>
|
<tbody>
|
||||||
<tr v-for="r in orgAliases" :key="r.alias">
|
<tr v-for="a in aliases" :key="a.address">
|
||||||
<td><Mono style="font-weight: 500">{{ r.alias }}</Mono></td>
|
<td><Mono style="font-weight: 500">{{ a.address }}</Mono></td>
|
||||||
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
|
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
|
||||||
<td>{{ r.dest }}</td>
|
<td><Mono dim>{{ a.destination }}</Mono></td>
|
||||||
<td><Badge :tone="r.active ? 'ok' : 'neutral'" dot>{{ r.active ? 'active' : 'paused' }}</Badge></td>
|
<td><Badge :tone="a.enabled ? 'ok' : 'neutral'" dot>{{ a.enabled ? 'active' : 'paused' }}</Badge></td>
|
||||||
<td><Mono dim>{{ r.created }}</Mono></td>
|
|
||||||
<td class="right">
|
<td class="right">
|
||||||
<UiButton size="sm" variant="ghost" @click="copyAlias(r.alias)"><UiIcon name="copy" :size="13" /></UiButton>
|
<UiButton size="sm" variant="ghost" @click="copyText(a.address)"><UiIcon name="copy" :size="13" /></UiButton>
|
||||||
<AdminKebabMenu :items="aliasItems" :icon-size="13" @select="(id) => aliasAction(r.alias, id)" />
|
<UiButton size="sm" variant="ghost" @click="aliasDeleteTarget = a"><UiIcon name="trash" :size="13" /></UiButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -150,288 +242,148 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
|||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- FORWARDING -->
|
<!-- DISTRIBUTION LISTS -->
|
||||||
<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 -->
|
|
||||||
<template v-else-if="tab === 'lists'">
|
<template v-else-if="tab === 'lists'">
|
||||||
<div class="row">
|
<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>
|
<div class="lead">Distribution lists send mail to many recipients via a single address. Members can be any email address.</div>
|
||||||
<UiButton variant="primary" @click="listOpen = true">
|
<UiButton variant="primary" :disabled="!hasDomain" @click="openListCreate">
|
||||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||||
New list
|
New list
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="lists">
|
<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-for="l in distributionLists" :key="l.alias">
|
<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 class="list-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="list-title">
|
<div class="list-title"><UiIcon name="users" :size="16" stroke="var(--text-mute)" /><span class="list-name">{{ l.name }}</span></div>
|
||||||
<UiIcon name="users" :size="16" stroke="var(--text-mute)" />
|
<Mono dim style="display: block; margin-top: 4px">{{ l.address }}</Mono>
|
||||||
<span class="list-name">{{ l.name }}</span>
|
|
||||||
<Badge v-if="l.external" tone="warn">external members</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<Mono dim style="display: block; margin-top: 4px">{{ l.alias }}</Mono>
|
<Badge tone="neutral">{{ l.members }} member{{ l.members === 1 ? '' : 's' }}</Badge>
|
||||||
</div>
|
|
||||||
<Badge :tone="l.moderation === 'open' ? 'ok' : 'neutral'">{{ l.moderation }}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="l.description" class="list-desc">{{ l.description }}</div>
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
<div>
|
<UiButton size="sm" variant="secondary" @click="manage(l)">Manage members</UiButton>
|
||||||
<Eyebrow>Members</Eyebrow>
|
<UiButton size="sm" variant="ghost" @click="listDeleteTarget = l"><template #leading><UiIcon name="trash" :size="13" /></template>Delete</UiButton>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- COMPLIANCE -->
|
<!-- FORWARDING / FILTERS / COMPLIANCE — platform-managed -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="compliance">
|
<Card class="managed">
|
||||||
<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)" />
|
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
||||||
<div class="empty-title">No active holds</div>
|
<div class="managed-title">{{ tab === 'forwarding' ? 'Forwarding rules' : tab === 'filters' ? 'Spam & content filtering' : 'Compliance & retention' }} are managed by Dezky</div>
|
||||||
<div class="empty-body">When legal review is needed, place a hold on specific users or date ranges to prevent deletion.</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add alias modal (stub) -->
|
<!-- Add alias modal -->
|
||||||
<Modal :open="addAliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="addAliasOpen = false">
|
<Modal :open="aliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="aliasOpen = false">
|
||||||
<div class="form-stack">
|
<div class="form-stack">
|
||||||
<label class="field"><Eyebrow>Alias address</Eyebrow>
|
<label class="field"><Eyebrow>Alias address</Eyebrow>
|
||||||
<div class="alias-row">
|
<div class="alias-row">
|
||||||
<input class="input" value="marketing" placeholder="prefix" />
|
<input class="input" v-model="aliasForm.localPart" placeholder="sales" />
|
||||||
<span class="at">@</span>
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="field"><Eyebrow>Route to user</Eyebrow><input class="input" value="frederik@dezky.com" /></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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="addAliasOpen = false">Cancel</UiButton>
|
<UiButton variant="ghost" @click="aliasOpen = false">Cancel</UiButton>
|
||||||
<UiButton variant="primary" @click="addAliasOpen = false">
|
<UiButton variant="primary" :disabled="aliasBusy || !aliasForm.localPart.trim() || !aliasForm.destinationUserId" @click="submitAlias">
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
{{ aliasBusy ? 'Creating…' : 'Create alias' }}
|
||||||
Create alias
|
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Forwarding rule modal -->
|
<ConfirmDialog
|
||||||
<Modal :open="ruleOpen" eyebrow="Mail · forwarding" title="New forwarding rule" size="md" @close="ruleOpen = false">
|
:open="!!aliasDeleteTarget"
|
||||||
<div class="form-stack">
|
eyebrow="Mail · aliases"
|
||||||
<label class="field"><Eyebrow>Rule name</Eyebrow><input class="input" placeholder="Out-of-hours to on-call" /></label>
|
:title="`Delete ${aliasDeleteTarget?.address}?`"
|
||||||
<label class="field"><Eyebrow>When</Eyebrow><input class="input" placeholder="subject: …" /></label>
|
confirm-label="Delete alias"
|
||||||
<label class="field"><Eyebrow>Forward to</Eyebrow><input class="input" placeholder="oncall@dezky.com" /></label>
|
tone="danger"
|
||||||
</div>
|
:busy="aliasDeleting"
|
||||||
<template #footer>
|
@close="aliasDeleteTarget = null"
|
||||||
<UiButton variant="ghost" @click="ruleOpen = false">Cancel</UiButton>
|
@confirm="confirmDeleteAlias"
|
||||||
<UiButton variant="primary" @click="ruleOpen = false">
|
>
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
Mail to <strong>{{ aliasDeleteTarget?.address }}</strong> will stop being delivered. The destination mailbox is unaffected.
|
||||||
Save rule
|
</ConfirmDialog>
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<UiButton variant="ghost" @click="filterOpen = false">Cancel</UiButton>
|
|
||||||
<UiButton variant="primary" @click="filterOpen = false">Create filter</UiButton>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- New list modal -->
|
<!-- New list modal -->
|
||||||
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
|
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
|
||||||
<div class="form-stack">
|
<div class="form-stack">
|
||||||
<label class="field"><Eyebrow>List name</Eyebrow><input class="input" placeholder="Engineering" /></label>
|
<label class="field"><Eyebrow>List address</Eyebrow>
|
||||||
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
|
<div class="alias-row">
|
||||||
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
|
<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 mikkel@acme.dk"></textarea>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="ghost" @click="listOpen = false">Cancel</UiButton>
|
<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>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Manage list side panel -->
|
<!-- Manage list members -->
|
||||||
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.name || ''" width="lg" @close="openList = null">
|
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.address || ''" width="lg" @close="openList = null">
|
||||||
<div v-if="openList" class="manage">
|
<div v-if="openList" class="manage">
|
||||||
<div class="manage-head">
|
<Eyebrow>Members <span class="opt">one email per line</span></Eyebrow>
|
||||||
<div class="manage-icon"><UiIcon name="users" :size="20" /></div>
|
<textarea class="input area" v-model="manageText" rows="12" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UiButton variant="danger" @click="deleteListOpen = true">
|
<UiButton variant="danger" @click="openList && (listDeleteTarget = openList)">
|
||||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||||
Delete list
|
Delete list
|
||||||
</UiButton>
|
</UiButton>
|
||||||
<div style="flex: 1" />
|
<div style="flex: 1" />
|
||||||
<UiButton variant="secondary" @click="openList = null">Discard</UiButton>
|
<UiButton variant="secondary" @click="openList = null">Cancel</UiButton>
|
||||||
<UiButton variant="primary" @click="openList = null; toast.ok('List saved')">
|
<UiButton variant="primary" :disabled="manageBusy" @click="saveList">{{ manageBusy ? 'Saving…' : 'Save members' }}</UiButton>
|
||||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
|
||||||
Save changes
|
|
||||||
</UiButton>
|
|
||||||
</template>
|
</template>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
|
|
||||||
<!-- Confirm delete list -->
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="deleteListOpen"
|
:open="!!listDeleteTarget"
|
||||||
eyebrow="Distribution list"
|
eyebrow="Distribution list"
|
||||||
:title="`Delete ${openList?.name || ''}?`"
|
:title="`Delete ${listDeleteTarget?.address}?`"
|
||||||
confirm-label="Delete list"
|
confirm-label="Delete list"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
@close="deleteListOpen = false"
|
:busy="listDeleting"
|
||||||
|
@close="listDeleteTarget = null"
|
||||||
@confirm="confirmDeleteList"
|
@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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -439,121 +391,36 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
|||||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||||
.content { padding: 20px 40px 64px 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; }
|
.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 { width: 100%; border-collapse: collapse; }
|
||||||
.tbl th {
|
.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; }
|
||||||
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 td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
.tbl tr:last-child td { border-bottom: none; }
|
.tbl tr:last-child td { border-bottom: none; }
|
||||||
.tbl .right { text-align: right; }
|
.tbl .right { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.lists { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
.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-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
.list-title { display: flex; align-items: center; gap: 8px; }
|
.list-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.list-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
.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-desc { font-size: 12px; color: var(--text-mute); margin-top: 10px; }
|
||||||
.list-num { font-family: var(--font-display); font-weight: 600; font-size: 20px; margin-top: 4px; }
|
.list-row { display: flex; gap: 8px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||||
.list-owner { flex: 1; font-size: 13px; }
|
|
||||||
|
|
||||||
.compliance { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
.managed { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 48px 32px; max-width: 640px; margin: 0 auto; }
|
||||||
.card-head { margin-bottom: 12px; }
|
.managed-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
.managed-body { font-size: 13px; color: var(--text-mute); line-height: 1.6; max-width: 480px; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
.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; }
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.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 { 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); }
|
.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; }
|
.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); }
|
.at { font-family: var(--font-mono); color: var(--text-mute); }
|
||||||
|
|
||||||
.manage { padding-bottom: 24px; }
|
.manage { padding-bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.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; }
|
|
||||||
</style>
|
</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,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -100,6 +100,9 @@ export interface TenantUserDoc {
|
|||||||
active: boolean
|
active: boolean
|
||||||
lastLoginAt?: string
|
lastLoginAt?: string
|
||||||
createdAt?: 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
|
// The card on file, as returned by GET /tenants/:slug/payment-method (null when
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DomainsModule } from './domains/domains.module.js'
|
|||||||
import { FlagsModule } from './flags/flags.module.js'
|
import { FlagsModule } from './flags/flags.module.js'
|
||||||
import { HealthModule } from './health/health.module.js'
|
import { HealthModule } from './health/health.module.js'
|
||||||
import { IngestModule } from './ingest/ingest.module.js'
|
import { IngestModule } from './ingest/ingest.module.js'
|
||||||
|
import { MailModule } from './mail/mail.module.js'
|
||||||
import { MeModule } from './me/me.module.js'
|
import { MeModule } from './me/me.module.js'
|
||||||
import { PartnersModule } from './partners/partners.module.js'
|
import { PartnersModule } from './partners/partners.module.js'
|
||||||
import { PricesModule } from './prices/prices.module.js'
|
import { PricesModule } from './prices/prices.module.js'
|
||||||
@@ -27,6 +28,7 @@ import { UsersModule } from './users/users.module.js'
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
DomainsModule,
|
DomainsModule,
|
||||||
|
MailModule,
|
||||||
PartnersModule,
|
PartnersModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
MeModule,
|
MeModule,
|
||||||
|
|||||||
@@ -268,6 +268,154 @@ export class StalwartClient {
|
|||||||
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
|
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Aliases (extra addresses that route to a mailbox) ──────────────────────
|
||||||
|
|
||||||
|
// Every mailbox + its aliases. Stalwart's account query has no domain filter,
|
||||||
|
// so the caller narrows by domainId. `aliases` is an index-keyed map on the
|
||||||
|
// wire; we hand back a plain array.
|
||||||
|
async listAccountsWithAliases(): Promise<StalwartAccountAliases[]> {
|
||||||
|
const resp = await this.jmap([
|
||||||
|
['x:Account/query', { filter: {} }, '0'],
|
||||||
|
[
|
||||||
|
'x:Account/get',
|
||||||
|
{
|
||||||
|
'#ids': { resultOf: '0', name: 'x:Account/query', path: '/ids' },
|
||||||
|
properties: ['emailAddress', 'aliases'],
|
||||||
|
},
|
||||||
|
'1',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const list = (resp[1]?.[1]?.list ?? []) as Array<{
|
||||||
|
id: string
|
||||||
|
emailAddress: string
|
||||||
|
aliases?: Record<string, StalwartAlias>
|
||||||
|
}>
|
||||||
|
return list.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
emailAddress: a.emailAddress,
|
||||||
|
aliases: aliasMapToArray(a.aliases),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an alias (localpart@domain) that delivers to this mailbox. Idempotent.
|
||||||
|
async addAlias(accountId: string, name: string, domainId: string): Promise<void> {
|
||||||
|
const aliases = await this.getAccountAliases(accountId)
|
||||||
|
if (aliases.some((a) => a.name === name && a.domainId === domainId)) return
|
||||||
|
aliases.push({ name, domainId, enabled: true })
|
||||||
|
await this.writeAliases(accountId, aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAlias(accountId: string, name: string, domainId: string): Promise<void> {
|
||||||
|
const aliases = (await this.getAccountAliases(accountId)).filter(
|
||||||
|
(a) => !(a.name === name && a.domainId === domainId),
|
||||||
|
)
|
||||||
|
await this.writeAliases(accountId, aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccountAliases(accountId: string): Promise<StalwartAlias[]> {
|
||||||
|
const resp = await this.jmap([
|
||||||
|
['x:Account/get', { ids: [accountId], properties: ['aliases'] }, '0'],
|
||||||
|
])
|
||||||
|
return aliasMapToArray(resp[0]?.[1]?.list?.[0]?.aliases)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stalwart replaces the whole `aliases` field on update, so we always write the
|
||||||
|
// full set back as an index-keyed map.
|
||||||
|
private async writeAliases(accountId: string, aliases: StalwartAlias[]): Promise<void> {
|
||||||
|
const map: Record<string, StalwartAlias> = {}
|
||||||
|
aliases.forEach((a, i) => {
|
||||||
|
map[String(i)] = { name: a.name, domainId: a.domainId, enabled: a.enabled }
|
||||||
|
})
|
||||||
|
const resp = await this.jmap([
|
||||||
|
['x:Account/set', { update: { [accountId]: { aliases: map } } }, '0'],
|
||||||
|
])
|
||||||
|
const notUpdated = resp[0][1].notUpdated?.[accountId]
|
||||||
|
if (notUpdated) {
|
||||||
|
throw new Error(`Stalwart alias update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mailing lists (one address fans out to many recipients) ────────────────
|
||||||
|
|
||||||
|
async listMailingLists(): Promise<StalwartMailingList[]> {
|
||||||
|
const resp = await this.jmap([
|
||||||
|
['x:MailingList/query', { filter: {} }, '0'],
|
||||||
|
[
|
||||||
|
'x:MailingList/get',
|
||||||
|
{
|
||||||
|
'#ids': { resultOf: '0', name: 'x:MailingList/query', path: '/ids' },
|
||||||
|
properties: ['name', 'emailAddress', 'domainId', 'recipients', 'description'],
|
||||||
|
},
|
||||||
|
'1',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const list = (resp[1]?.[1]?.list ?? []) as Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
emailAddress: string
|
||||||
|
domainId: string
|
||||||
|
recipients?: Record<string, boolean>
|
||||||
|
description?: string
|
||||||
|
}>
|
||||||
|
return list.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
emailAddress: l.emailAddress,
|
||||||
|
domainId: l.domainId,
|
||||||
|
recipients: l.recipients ? Object.keys(l.recipients) : [],
|
||||||
|
description: l.description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMailingList(input: {
|
||||||
|
domainId: string
|
||||||
|
name: string
|
||||||
|
recipients: string[]
|
||||||
|
description?: string
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
const resp = await this.jmap([
|
||||||
|
[
|
||||||
|
'x:MailingList/set',
|
||||||
|
{
|
||||||
|
create: {
|
||||||
|
l1: {
|
||||||
|
name: input.name,
|
||||||
|
domainId: input.domainId,
|
||||||
|
description: input.description ?? null,
|
||||||
|
recipients: recipientsToMap(input.recipients),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const created = resp[0][1].created?.l1
|
||||||
|
if (!created?.id) {
|
||||||
|
throw new Error(`Stalwart mailing list create failed: ${JSON.stringify(resp[0][1].notCreated)}`)
|
||||||
|
}
|
||||||
|
return { id: created.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMailingListRecipients(id: string, recipients: string[]): Promise<void> {
|
||||||
|
const resp = await this.jmap([
|
||||||
|
['x:MailingList/set', { update: { [id]: { recipients: recipientsToMap(recipients) } } }, '0'],
|
||||||
|
])
|
||||||
|
const notUpdated = resp[0][1].notUpdated?.[id]
|
||||||
|
if (notUpdated) {
|
||||||
|
throw new Error(`Stalwart mailing list update failed (id=${id}): ${JSON.stringify(notUpdated)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMailingList(id: string): Promise<void> {
|
||||||
|
const resp = await this.jmap([['x:MailingList/set', { destroy: [id] }, '0']])
|
||||||
|
const result = resp[0][1]
|
||||||
|
if ((result.destroyed as string[] | undefined)?.includes(id)) return
|
||||||
|
const notDestroyed = result.notDestroyed?.[id]
|
||||||
|
if (notDestroyed && notDestroyed.type !== 'notFound') {
|
||||||
|
throw new Error(`Stalwart mailing list delete failed (id=${id}): ${JSON.stringify(notDestroyed)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StalwartLinkedObject {
|
export interface StalwartLinkedObject {
|
||||||
@@ -275,6 +423,42 @@ export interface StalwartLinkedObject {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StalwartAlias {
|
||||||
|
name: string
|
||||||
|
domainId: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StalwartAccountAliases {
|
||||||
|
id: string
|
||||||
|
emailAddress: string
|
||||||
|
aliases: StalwartAlias[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StalwartMailingList {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
emailAddress: string
|
||||||
|
domainId: string
|
||||||
|
recipients: string[]
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasMapToArray(map?: Record<string, StalwartAlias>): StalwartAlias[] {
|
||||||
|
if (!map) return []
|
||||||
|
return Object.values(map).map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
domainId: a.domainId,
|
||||||
|
enabled: a.enabled !== false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientsToMap(recipients: string[]): Record<string, boolean> {
|
||||||
|
const map: Record<string, boolean> = {}
|
||||||
|
for (const r of recipients) map[r.trim().toLowerCase()] = true
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
|
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
|
||||||
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
|
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
|
||||||
// DKIM signatures (which we remove automatically).
|
// DKIM signatures (which we remove automatically).
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsMongoId, IsString, Matches, MaxLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Create an email alias `localPart@domain` that delivers to an existing mailbox
|
||||||
|
// user (the destination). Both must belong to the tenant.
|
||||||
|
export class CreateAliasDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
@Matches(/^[a-zA-Z0-9._-]+$/, { message: 'alias prefix may only contain letters, numbers, dots, hyphens and underscores' })
|
||||||
|
localPart!: string
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
domain!: string
|
||||||
|
|
||||||
|
// The destination mailbox user (our User _id); the alias is added to their account.
|
||||||
|
@IsMongoId()
|
||||||
|
destinationUserId!: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ArrayMaxSize, IsArray, IsEmail, IsOptional, IsString, Matches, MaxLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Create a distribution list `localPart@domain` that fans mail out to recipients.
|
||||||
|
export class CreateListDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
@Matches(/^[a-zA-Z0-9._-]+$/, { message: 'list prefix may only contain letters, numbers, dots, hyphens and underscores' })
|
||||||
|
localPart!: string
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
domain!: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(1000)
|
||||||
|
@IsEmail({}, { each: true })
|
||||||
|
recipients?: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ArrayMaxSize, IsArray, IsEmail } from 'class-validator'
|
||||||
|
|
||||||
|
// Replace a distribution list's recipients.
|
||||||
|
export class UpdateListDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(1000)
|
||||||
|
@IsEmail({}, { each: true })
|
||||||
|
recipients!: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { ActorService } from '../auth/actor.service.js'
|
||||||
|
import { clientIp } from '../auth/client-ip.js'
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
|
import type { AuditActor } from '../audit/audit.service.js'
|
||||||
|
import { TenantsService } from '../tenants/tenants.service.js'
|
||||||
|
import { CreateAliasDto } from './dto/create-alias.dto.js'
|
||||||
|
import { CreateListDto } from './dto/create-list.dto.js'
|
||||||
|
import { UpdateListDto } from './dto/update-list.dto.js'
|
||||||
|
import { MailService, type TenantRef } from './mail.service.js'
|
||||||
|
|
||||||
|
function auditActor(
|
||||||
|
user: { _id: unknown; email: string },
|
||||||
|
req: Parameters<typeof clientIp>[0],
|
||||||
|
): AuditActor {
|
||||||
|
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer-admin Mail settings: aliases + distribution lists (the per-tenant
|
||||||
|
// parts of Stalwart mail config). Forwarding / spam filters / retention are
|
||||||
|
// server-global and stay operator-managed, so they aren't exposed here.
|
||||||
|
@Controller('tenants/:slug/mail')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class MailController {
|
||||||
|
constructor(
|
||||||
|
private readonly mail: MailService,
|
||||||
|
private readonly tenants: TenantsService,
|
||||||
|
private readonly actor: ActorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<{ actor: any; tenant: TenantRef }> {
|
||||||
|
const actor = await this.actor.resolve(jwt)
|
||||||
|
const tenant = await this.tenants.findOneBySlug(slug)
|
||||||
|
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
|
||||||
|
throw new ForbiddenException(`No access to tenant "${slug}"`)
|
||||||
|
}
|
||||||
|
return { actor, tenant: { _id: tenant._id, slug: tenant.slug } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aliases ──
|
||||||
|
@Get('aliases')
|
||||||
|
async listAliases(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const { tenant } = await this.gate(slug, jwt)
|
||||||
|
return this.mail.listAliases(tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('aliases')
|
||||||
|
async createAlias(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() dto: CreateAliasDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const { actor, tenant } = await this.gate(slug, jwt)
|
||||||
|
return this.mail.createAlias(tenant, dto, auditActor(actor, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('aliases/:address')
|
||||||
|
@HttpCode(204)
|
||||||
|
async deleteAlias(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Param('address') address: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const { actor, tenant } = await this.gate(slug, jwt)
|
||||||
|
await this.mail.deleteAlias(tenant, address, auditActor(actor, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Distribution lists ──
|
||||||
|
@Get('lists')
|
||||||
|
async listLists(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
const { tenant } = await this.gate(slug, jwt)
|
||||||
|
return this.mail.listLists(tenant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lists')
|
||||||
|
async createList(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Body() dto: CreateListDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const { actor, tenant } = await this.gate(slug, jwt)
|
||||||
|
return this.mail.createList(tenant, dto, auditActor(actor, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('lists/:id')
|
||||||
|
async updateList(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateListDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const { actor, tenant } = await this.gate(slug, jwt)
|
||||||
|
return this.mail.updateListRecipients(tenant, id, dto.recipients, auditActor(actor, req))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('lists/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
async deleteList(
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
@Req() req: Parameters<typeof clientIp>[0],
|
||||||
|
) {
|
||||||
|
const { actor, tenant } = await this.gate(slug, jwt)
|
||||||
|
await this.mail.deleteList(tenant, id, auditActor(actor, req))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { AuditModule } from '../audit/audit.module.js'
|
||||||
|
import { AuthModule } from '../auth/auth.module.js'
|
||||||
|
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||||
|
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
|
||||||
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
|
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||||
|
import { MailController } from './mail.controller.js'
|
||||||
|
import { MailService } from './mail.service.js'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Domain.name, schema: DomainSchema },
|
||||||
|
{ name: User.name, schema: UserSchema },
|
||||||
|
]),
|
||||||
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
|
IntegrationsModule,
|
||||||
|
TenantsModule, // TenantsService — resolve tenant by slug for the membership gate
|
||||||
|
],
|
||||||
|
controllers: [MailController],
|
||||||
|
providers: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { Model, Types } from 'mongoose'
|
||||||
|
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||||
|
import { StalwartClient } from '../integrations/stalwart.client.js'
|
||||||
|
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
|
||||||
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
|
|
||||||
|
// Tenant-scoped view of the bits of Stalwart mail config that are per-customer:
|
||||||
|
// aliases (extra addresses on a mailbox) and distribution lists. Both are keyed
|
||||||
|
// to one of the tenant's domains, so they're naturally isolated per workspace.
|
||||||
|
|
||||||
|
export interface AliasView {
|
||||||
|
address: string // localPart@domain
|
||||||
|
localPart: string
|
||||||
|
domain: string
|
||||||
|
destination: string // the mailbox it delivers to
|
||||||
|
accountId: string // Stalwart account holding the alias
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListView {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
domain: string
|
||||||
|
recipients: string[]
|
||||||
|
members: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantRef {
|
||||||
|
_id: Types.ObjectId
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
|
||||||
|
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||||
|
private readonly stalwart: StalwartClient,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Maps between a tenant's domain names and their Stalwart domain ids, both ways.
|
||||||
|
private async tenantDomains(
|
||||||
|
tenantId: Types.ObjectId,
|
||||||
|
): Promise<{ byStalwartId: Map<string, string>; byName: Map<string, string> }> {
|
||||||
|
const domains = await this.domainModel
|
||||||
|
.find({ tenantId, stalwartId: { $exists: true } }, { domain: 1, stalwartId: 1 })
|
||||||
|
.exec()
|
||||||
|
const byStalwartId = new Map<string, string>()
|
||||||
|
const byName = new Map<string, string>()
|
||||||
|
for (const d of domains) {
|
||||||
|
if (d.stalwartId) {
|
||||||
|
byStalwartId.set(d.stalwartId, d.domain)
|
||||||
|
byName.set(d.domain, d.stalwartId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byStalwartId, byName }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aliases ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listAliases(tenant: TenantRef): Promise<AliasView[]> {
|
||||||
|
if (!this.stalwart.configured) return []
|
||||||
|
const { byStalwartId } = await this.tenantDomains(tenant._id)
|
||||||
|
if (byStalwartId.size === 0) return []
|
||||||
|
const accounts = await this.stalwart.listAccountsWithAliases()
|
||||||
|
const out: AliasView[] = []
|
||||||
|
for (const acc of accounts) {
|
||||||
|
for (const a of acc.aliases) {
|
||||||
|
const domain = byStalwartId.get(a.domainId)
|
||||||
|
if (!domain) continue // alias on a domain this tenant doesn't own
|
||||||
|
out.push({
|
||||||
|
address: `${a.name}@${domain}`,
|
||||||
|
localPart: a.name,
|
||||||
|
domain,
|
||||||
|
destination: acc.emailAddress,
|
||||||
|
accountId: acc.id,
|
||||||
|
enabled: a.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((x, y) => x.address.localeCompare(y.address))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlias(
|
||||||
|
tenant: TenantRef,
|
||||||
|
dto: { localPart: string; domain: string; destinationUserId: string },
|
||||||
|
actor: AuditActor,
|
||||||
|
): Promise<AliasView> {
|
||||||
|
const { byName } = await this.tenantDomains(tenant._id)
|
||||||
|
const domain = dto.domain.trim().toLowerCase()
|
||||||
|
const domainStalwartId = byName.get(domain)
|
||||||
|
if (!domainStalwartId) {
|
||||||
|
throw new BadRequestException(`Domain "${domain}" isn't part of this workspace.`)
|
||||||
|
}
|
||||||
|
const localPart = dto.localPart.trim().toLowerCase()
|
||||||
|
const dest = await this.userModel
|
||||||
|
.findOne({ _id: dto.destinationUserId, tenantIds: tenant._id })
|
||||||
|
.exec()
|
||||||
|
if (!dest?.stalwartAccountId) {
|
||||||
|
throw new BadRequestException('The destination must be a mailbox user in this workspace.')
|
||||||
|
}
|
||||||
|
await this.stalwart.addAlias(dest.stalwartAccountId, localPart, domainStalwartId)
|
||||||
|
const address = `${localPart}@${domain}`
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'mail.alias_created',
|
||||||
|
resourceType: 'system',
|
||||||
|
resourceId: address,
|
||||||
|
resourceName: address,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
metadata: { destination: dest.email },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return { address, localPart, domain, destination: dest.email, accountId: dest.stalwartAccountId, enabled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAlias(tenant: TenantRef, address: string, actor: AuditActor): Promise<void> {
|
||||||
|
const target = address.trim().toLowerCase()
|
||||||
|
const match = (await this.listAliases(tenant)).find((a) => a.address === target)
|
||||||
|
if (!match) throw new NotFoundException(`Alias "${address}" not found.`)
|
||||||
|
const { byName } = await this.tenantDomains(tenant._id)
|
||||||
|
await this.stalwart.removeAlias(match.accountId, match.localPart, byName.get(match.domain)!)
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'mail.alias_deleted',
|
||||||
|
resourceType: 'system',
|
||||||
|
resourceId: target,
|
||||||
|
resourceName: target,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Distribution lists ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listLists(tenant: TenantRef): Promise<ListView[]> {
|
||||||
|
if (!this.stalwart.configured) return []
|
||||||
|
const { byStalwartId } = await this.tenantDomains(tenant._id)
|
||||||
|
if (byStalwartId.size === 0) return []
|
||||||
|
const lists = await this.stalwart.listMailingLists()
|
||||||
|
return lists
|
||||||
|
.filter((l) => byStalwartId.has(l.domainId))
|
||||||
|
.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
address: l.emailAddress || `${l.name}@${byStalwartId.get(l.domainId)}`,
|
||||||
|
domain: byStalwartId.get(l.domainId)!,
|
||||||
|
recipients: l.recipients,
|
||||||
|
members: l.recipients.length,
|
||||||
|
description: l.description,
|
||||||
|
}))
|
||||||
|
.sort((x, y) => x.address.localeCompare(y.address))
|
||||||
|
}
|
||||||
|
|
||||||
|
async createList(
|
||||||
|
tenant: TenantRef,
|
||||||
|
dto: { localPart: string; domain: string; recipients?: string[]; description?: string },
|
||||||
|
actor: AuditActor,
|
||||||
|
): Promise<ListView> {
|
||||||
|
const { byName } = await this.tenantDomains(tenant._id)
|
||||||
|
const domain = dto.domain.trim().toLowerCase()
|
||||||
|
const domainStalwartId = byName.get(domain)
|
||||||
|
if (!domainStalwartId) {
|
||||||
|
throw new BadRequestException(`Domain "${domain}" isn't part of this workspace.`)
|
||||||
|
}
|
||||||
|
const localPart = dto.localPart.trim().toLowerCase()
|
||||||
|
const recipients = cleanRecipients(dto.recipients)
|
||||||
|
const { id } = await this.stalwart.createMailingList({
|
||||||
|
domainId: domainStalwartId,
|
||||||
|
name: localPart,
|
||||||
|
recipients,
|
||||||
|
description: dto.description,
|
||||||
|
})
|
||||||
|
const address = `${localPart}@${domain}`
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'mail.list_created',
|
||||||
|
resourceType: 'system',
|
||||||
|
resourceId: address,
|
||||||
|
resourceName: address,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
metadata: { members: recipients.length },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return { id, name: localPart, address, domain, recipients, members: recipients.length, description: dto.description }
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateListRecipients(
|
||||||
|
tenant: TenantRef,
|
||||||
|
id: string,
|
||||||
|
recipients: string[],
|
||||||
|
actor: AuditActor,
|
||||||
|
): Promise<ListView> {
|
||||||
|
const list = await this.assertListInTenant(tenant, id)
|
||||||
|
const clean = cleanRecipients(recipients)
|
||||||
|
await this.stalwart.updateMailingListRecipients(id, clean)
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'mail.list_updated',
|
||||||
|
resourceType: 'system',
|
||||||
|
resourceId: list.address,
|
||||||
|
resourceName: list.address,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
metadata: { members: clean.length },
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
return { ...list, recipients: clean, members: clean.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteList(tenant: TenantRef, id: string, actor: AuditActor): Promise<void> {
|
||||||
|
const list = await this.assertListInTenant(tenant, id)
|
||||||
|
await this.stalwart.deleteMailingList(id)
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
action: 'mail.list_deleted',
|
||||||
|
resourceType: 'system',
|
||||||
|
resourceId: list.address,
|
||||||
|
resourceName: list.address,
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertListInTenant(tenant: TenantRef, id: string): Promise<ListView> {
|
||||||
|
const list = (await this.listLists(tenant)).find((l) => l.id === id)
|
||||||
|
if (!list) throw new NotFoundException('Distribution list not found in this workspace.')
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRecipients(recipients?: string[]): string[] {
|
||||||
|
return [...new Set((recipients ?? []).map((r) => r.trim().toLowerCase()).filter(Boolean))]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user