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