feat: use mail manager and mail providers

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-19 07:08:48 -04:00
parent d8f0b4073c
commit fc3f4c28db
19 changed files with 3622 additions and 2171 deletions
+446
View File
@@ -0,0 +1,446 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
AddAccountDialog,
EditAccountDialog,
serviceService,
providerService,
} from '@KTXM/MailManager/main'
import type { ServiceObject } from '@KTXM/MailManager/models/service'
import type { ProviderObject } from '@KTXM/MailManager/models/provider'
import RuleDialog from '@/components/RuleDialog.vue'
const SYSTEM_USER = 'system'
const SYSTEM_PROVIDER = 'system'
const loading = ref(false)
const loadError = ref<string | null>(null)
const providers = ref<Record<string, ProviderObject>>({})
const rules = ref<ServiceObject[]>([])
const accounts = ref<ServiceObject[]>([])
const showAddAccountDialog = ref(false)
const showEditAccountDialog = ref(false)
const showRuleDialog = ref(false)
const showDeleteConfirm = ref(false)
const showResult = ref(false)
const selectedAccount = ref<ServiceObject | null>(null)
const selectedRule = ref<ServiceObject | null>(null)
const deleteTarget = ref<ServiceObject | null>(null)
const deleteKind = ref<'rule' | 'account'>('rule')
const deleting = ref(false)
const testingId = ref<string | number | null>(null)
const resultMessage = ref<{ success: boolean; message: string } | null>(null)
const hasRules = computed(() => rules.value.length > 0)
const hasAccounts = computed(() => accounts.value.length > 0)
const accountOptions = computed(() =>
accounts.value.map(account => ({
title: `${account.label || account.primaryAddress?.address || account.identifier} (${providerLabel(account.provider)})`,
value: `${account.provider}:${account.identifier}`,
}))
)
onMounted(load)
async function load() {
loading.value = true
loadError.value = null
try {
const [providerList, serviceList] = await Promise.all([
providerService.list(),
serviceService.list({}, SYSTEM_USER),
])
providers.value = providerList
const ruleList: ServiceObject[] = []
const accountList: ServiceObject[] = []
Object.entries(serviceList).forEach(([providerId, services]) => {
Object.values(services).forEach(service => {
if (providerId === SYSTEM_PROVIDER) {
ruleList.push(service)
} else {
accountList.push(service)
}
})
})
rules.value = ruleList
accounts.value = accountList
} catch (error: any) {
console.error('[System Mail] Failed to load:', error)
loadError.value = error.message || 'Failed to load system mail configuration'
} finally {
loading.value = false
}
}
function providerLabel(providerId: string): string {
return providers.value[providerId]?.label || providerId.toUpperCase()
}
function ruleAddress(rule: ServiceObject): string {
return rule.primaryAddress?.address || ''
}
function ruleIsCatchAll(rule: ServiceObject): boolean {
return ruleAddress(rule).startsWith('*@')
}
function ruleTargetLabel(rule: ServiceObject): string {
const targetProvider = rule.auxiliary?.targetProvider
const targetService = rule.auxiliary?.targetService
if (!targetProvider || !targetService) {
return 'Not configured'
}
const account = accounts.value.find(
a => a.provider === targetProvider && String(a.identifier) === String(targetService)
)
return account
? `${account.label || account.primaryAddress?.address || targetService} (${providerLabel(targetProvider)})`
: `${targetProvider}:${targetService} (missing)`
}
// ==================== Rules ====================
function addRule() {
selectedRule.value = null
showRuleDialog.value = true
}
function editRule(rule: ServiceObject) {
selectedRule.value = rule
showRuleDialog.value = true
}
// ==================== Accounts ====================
function editAccount(account: ServiceObject) {
selectedAccount.value = account
showEditAccountDialog.value = true
}
async function testAccount(account: ServiceObject) {
testingId.value = account.identifier
try {
const result = await serviceService.test(
{ provider: account.provider, identifier: account.identifier as string | number },
SYSTEM_USER
)
resultMessage.value = result
} catch (error: any) {
resultMessage.value = { success: false, message: error.message || 'Connection test failed' }
} finally {
testingId.value = null
showResult.value = true
}
}
// ==================== Delete ====================
function confirmDelete(target: ServiceObject, kind: 'rule' | 'account') {
deleteTarget.value = target
deleteKind.value = kind
showDeleteConfirm.value = true
}
async function performDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await serviceService.delete(
{
provider: deleteTarget.value.provider,
identifier: deleteTarget.value.identifier as string | number,
},
SYSTEM_USER
)
showDeleteConfirm.value = false
deleteTarget.value = null
await load()
} catch (error: any) {
resultMessage.value = { success: false, message: error.message || 'Delete failed' }
showResult.value = true
} finally {
deleting.value = false
}
}
async function handleSaved() {
showRuleDialog.value = false
showAddAccountDialog.value = false
showEditAccountDialog.value = false
await load()
}
</script>
<template>
<v-container fluid>
<!-- Page Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<h1 class="text-h4 mb-1">System Mail</h1>
<p class="text-body-2 text-medium-emphasis">
Route system-generated mail (verification codes, password resets, notifications)
through dedicated sending accounts
</p>
</div>
</div>
<!-- Loading State -->
<v-row v-if="loading" class="mt-4">
<v-col v-for="i in 2" :key="i" cols="12" md="6">
<v-skeleton-loader type="card" />
</v-col>
</v-row>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="mb-6">
{{ loadError }}
</v-alert>
<template v-else>
<!-- ==================== Routing Rules ==================== -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center">
<v-icon icon="mdi-routes" class="mr-2" />
<h2 class="text-h6">Routing Rules</h2>
<v-chip size="small" class="ml-2" variant="text">
{{ rules.length }} rule{{ rules.length !== 1 ? 's' : '' }}
</v-chip>
</div>
<v-btn
color="primary"
prepend-icon="mdi-plus"
:disabled="!hasAccounts"
@click="addRule"
>
Add Rule
</v-btn>
</div>
<v-card v-if="!hasRules" class="text-center pa-8 mb-8" variant="flat">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-routes</v-icon>
<h3 class="text-h6 mb-1">No Routing Rules</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
Add a rule to map a system address (e.g. <code>authentication@system</code>)
to a sending account. A <code>*@system</code> rule acts as the default for
all unmatched system addresses.
<template v-if="!hasAccounts"><br>Create a system account first.</template>
</p>
</v-card>
<v-row v-else class="mb-6">
<v-col
v-for="rule in rules"
:key="String(rule.identifier)"
cols="12"
md="6"
lg="4"
>
<v-card :class="{ 'border-error': !rule.enabled }" variant="outlined" hover>
<v-card-text>
<div class="d-flex align-center justify-space-between mb-2">
<div class="d-flex align-center">
<v-avatar size="40" :color="rule.enabled ? 'primary' : 'grey'" class="mr-3">
<v-icon color="white">
{{ ruleIsCatchAll(rule) ? 'mdi-asterisk' : 'mdi-email-fast' }}
</v-icon>
</v-avatar>
<div>
<h3 class="text-h6">{{ rule.label || ruleAddress(rule) }}</h3>
<p class="text-caption text-medium-emphasis mb-0">
{{ ruleAddress(rule) }}
<v-chip v-if="ruleIsCatchAll(rule)" size="x-small" class="ml-1" variant="tonal">
default
</v-chip>
</p>
</div>
</div>
<v-chip :color="rule.enabled ? 'success' : 'error'" size="small" variant="flat">
{{ rule.enabled ? 'Enabled' : 'Disabled' }}
</v-chip>
</div>
<v-divider class="my-3" />
<div class="text-caption text-medium-emphasis">
<div class="d-flex align-center mb-1">
<v-icon size="small" class="mr-1">mdi-send</v-icon>
Delivers via: {{ ruleTargetLabel(rule) }}
</div>
<div class="d-flex align-center">
<v-icon size="small" class="mr-1">mdi-account-arrow-right</v-icon>
Sends as: {{ rule.auxiliary?.fromAddress || 'Not configured' }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn variant="text" size="small" prepend-icon="mdi-pencil" @click="editRule(rule)">
Edit
</v-btn>
<v-spacer />
<v-btn
variant="text"
size="small"
color="error"
icon="mdi-delete"
@click="confirmDelete(rule, 'rule')"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- ==================== System Accounts ==================== -->
<div class="d-flex align-center justify-space-between mb-4">
<div class="d-flex align-center">
<v-icon icon="mdi-email-lock" class="mr-2" />
<h2 class="text-h6">System Accounts</h2>
<v-chip size="small" class="ml-2" variant="text">
{{ accounts.length }} account{{ accounts.length !== 1 ? 's' : '' }}
</v-chip>
</div>
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddAccountDialog = true">
Add Account
</v-btn>
</div>
<v-card v-if="!hasAccounts" class="text-center pa-8" variant="flat">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-email-off-outline</v-icon>
<h3 class="text-h6 mb-1">No System Accounts</h3>
<p class="text-body-2 text-medium-emphasis mb-0">
System accounts are dedicated sending accounts owned by this tenant.
Routing rules deliver system mail through them.
</p>
</v-card>
<v-row v-else>
<v-col
v-for="account in accounts"
:key="`${account.provider}:${account.identifier}`"
cols="12"
md="6"
lg="4"
>
<v-card :class="{ 'border-error': !account.enabled }" variant="outlined" hover>
<v-card-text>
<div class="d-flex align-center justify-space-between mb-2">
<div class="d-flex align-center">
<v-avatar size="40" :color="account.enabled ? 'primary' : 'grey'" class="mr-3">
<v-icon color="white">
{{ account.enabled ? 'mdi-email' : 'mdi-email-off' }}
</v-icon>
</v-avatar>
<div>
<h3 class="text-h6">{{ account.label }}</h3>
<p class="text-caption text-medium-emphasis mb-0">
{{ account.primaryAddress?.address || 'No email configured' }}
</p>
</div>
</div>
<v-chip :color="account.enabled ? 'success' : 'error'" size="small" variant="flat">
{{ account.enabled ? 'Enabled' : 'Disabled' }}
</v-chip>
</div>
<v-divider class="my-3" />
<div class="text-caption text-medium-emphasis">
<div class="d-flex align-center">
<v-icon size="small" class="mr-1">mdi-connection</v-icon>
{{ providerLabel(account.provider) }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn variant="text" size="small" prepend-icon="mdi-pencil" @click="editAccount(account)">
Edit
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-connection"
:loading="testingId === account.identifier"
@click="testAccount(account)"
>
Test
</v-btn>
<v-spacer />
<v-btn
variant="text"
size="small"
color="error"
icon="mdi-delete"
@click="confirmDelete(account, 'account')"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</template>
<!-- Rule Dialog -->
<RuleDialog
v-model="showRuleDialog"
:rule="selectedRule"
:account-options="accountOptions"
@saved="handleSaved"
/>
<!-- Add Account Dialog (system user context) -->
<AddAccountDialog
v-model="showAddAccountDialog"
user="system"
@saved="handleSaved"
/>
<!-- Edit Account Dialog (system user context) -->
<EditAccountDialog
v-model="showEditAccountDialog"
:service-provider="selectedAccount?.provider || ''"
:service-identifier="selectedAccount?.identifier || ''"
user="system"
@saved="handleSaved"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteConfirm" max-width="400">
<v-card>
<v-card-title class="text-h6">
Delete {{ deleteKind === 'rule' ? 'Rule' : 'Account' }}?
</v-card-title>
<v-card-text>
Are you sure you want to delete
<strong>{{ deleteTarget?.label || deleteTarget?.identifier }}</strong>?
<template v-if="deleteKind === 'account'">
Routing rules targeting this account will stop delivering.
</template>
This action cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteConfirm = false">Cancel</v-btn>
<v-btn color="error" variant="flat" :loading="deleting" @click="performDelete">
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Result Snackbar -->
<v-snackbar
v-model="showResult"
:color="resultMessage?.success ? 'success' : 'error'"
:timeout="5000"
>
<v-icon start>
{{ resultMessage?.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ resultMessage?.message || 'Operation completed' }}
</v-snackbar>
</v-container>
</template>