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
+265
View File
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { serviceService } from '@KTXM/MailManager/main'
import type { ServiceObject } from '@KTXM/MailManager/models/service'
const SYSTEM_USER = 'system'
const SYSTEM_PROVIDER = 'system'
const SYSTEM_DOMAIN = '@system'
const props = defineProps<{
modelValue: boolean
rule: ServiceObject | null
accountOptions: { title: string; value: string }[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const dialogOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => props.rule !== null)
const saving = ref(false)
const saveError = ref<string | null>(null)
const formValid = ref(false)
// Form fields
const label = ref('')
const localPart = ref('')
const catchAll = ref(false)
const targetAccount = ref<string | null>(null)
const fromAddress = ref('')
const fromLabel = ref('')
const enabled = ref(true)
const systemAddress = computed(() =>
catchAll.value ? `*${SYSTEM_DOMAIN}` : `${localPart.value.trim().toLowerCase()}${SYSTEM_DOMAIN}`
)
const localPartRules = [
(v: string) => catchAll.value || !!v?.trim() || 'Function name is required',
(v: string) => catchAll.value || /^[a-z0-9._-]+$/i.test(v?.trim() || '') || 'Only letters, numbers, dots, dashes and underscores',
]
const targetRules = [
(v: string | null) => !!v || 'A delivery account is required',
]
const fromAddressRules = [
(v: string) => !!v?.trim() || 'Sender address is required',
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v?.trim() || '') || 'Must be a valid email address',
]
// Populate the form when the dialog opens
watch(dialogOpen, (isOpen) => {
if (!isOpen) {
return
}
saveError.value = null
if (props.rule) {
const address = props.rule.primaryAddress?.address || ''
label.value = props.rule.label || ''
catchAll.value = address.startsWith('*@')
localPart.value = catchAll.value ? '' : address.replace(SYSTEM_DOMAIN, '')
const aux = props.rule.auxiliary || {}
targetAccount.value = aux.targetProvider && aux.targetService
? `${aux.targetProvider}:${aux.targetService}`
: null
fromAddress.value = aux.fromAddress || ''
fromLabel.value = aux.fromLabel || ''
enabled.value = props.rule.enabled
} else {
label.value = ''
localPart.value = ''
catchAll.value = false
targetAccount.value = null
fromAddress.value = ''
fromLabel.value = ''
enabled.value = true
}
})
async function save() {
if (!formValid.value || !targetAccount.value) {
return
}
const separatorIndex = targetAccount.value.indexOf(':')
const targetProvider = targetAccount.value.slice(0, separatorIndex)
const targetService = targetAccount.value.slice(separatorIndex + 1)
const data = {
label: label.value.trim() || systemAddress.value,
enabled: enabled.value,
primaryAddress: { address: systemAddress.value },
auxiliary: {
targetProvider,
targetService,
fromAddress: fromAddress.value.trim(),
fromLabel: fromLabel.value.trim() || null,
},
}
saving.value = true
saveError.value = null
try {
if (isEdit.value && props.rule) {
await serviceService.update(
{
provider: SYSTEM_PROVIDER,
identifier: props.rule.identifier as string | number,
delta: false,
data,
},
SYSTEM_USER
)
} else {
await serviceService.create({ provider: SYSTEM_PROVIDER, data }, SYSTEM_USER)
}
emit('saved')
} catch (error: any) {
console.error('[System Mail] Failed to save rule:', error)
saveError.value = error.message || 'Failed to save rule'
} finally {
saving.value = false
}
}
function close() {
dialogOpen.value = false
}
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="600" persistent scrollable>
<v-card>
<v-card-title class="d-flex justify-space-between align-center pa-6">
<span class="text-h5">{{ isEdit ? 'Edit Routing Rule' : 'Add Routing Rule' }}</span>
<v-btn icon="mdi-close" variant="text" @click="close" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form v-model="formValid" @submit.prevent="save">
<v-alert v-if="saveError" type="error" variant="tonal" class="mb-4">
{{ saveError }}
</v-alert>
<v-text-field
v-model="label"
label="Label"
placeholder="e.g. Authentication mail"
variant="outlined"
density="comfortable"
class="mb-2"
hint="Optional display name for this rule"
persistent-hint
/>
<v-switch
v-model="catchAll"
label="Default rule (catch-all)"
color="primary"
density="comfortable"
hint="Handles every system address that has no dedicated rule"
persistent-hint
class="mb-2"
/>
<v-text-field
v-if="!catchAll"
v-model="localPart"
label="Function"
placeholder="e.g. authentication, notification, billing"
:suffix="SYSTEM_DOMAIN"
:rules="localPartRules"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The logical address consumers send from"
persistent-hint
/>
<v-text-field
v-else
:model-value="`*${SYSTEM_DOMAIN}`"
label="Address"
variant="outlined"
density="comfortable"
class="mb-2"
readonly
disabled
/>
<v-select
v-model="targetAccount"
:items="accountOptions"
:rules="targetRules"
label="Deliver via account"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The system account used for actual delivery"
persistent-hint
/>
<v-text-field
v-model="fromAddress"
label="Send as (From address)"
placeholder="e.g. no-reply@example.com"
:rules="fromAddressRules"
variant="outlined"
density="comfortable"
class="mb-2"
hint="The real address recipients will see"
persistent-hint
/>
<v-text-field
v-model="fromLabel"
label="Sender name"
placeholder="e.g. Example Security"
variant="outlined"
density="comfortable"
class="mb-2"
hint="Optional display name for the sender"
persistent-hint
/>
<v-switch
v-model="enabled"
label="Enabled"
color="primary"
density="comfortable"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-6">
<v-spacer />
<v-btn variant="text" @click="close">Cancel</v-btn>
<v-btn
color="primary"
:loading="saving"
:disabled="!formValid"
@click="save"
>
<v-icon start>mdi-content-save</v-icon>
{{ isEdit ? 'Save Changes' : 'Add Rule' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleIntegrations } from '@KTXC/types/moduleTypes'
const integrations: ModuleIntegrations = {
admin_settings_menu: [
{
id: 'system_mail',
label: 'System Mail',
path: '/system-mail',
icon: 'mdi-email-lock',
priority: 70,
caption: 'Manage system mail accounts and routing rules',
},
],
mail_provider_details: [
{
id: 'system',
label: 'System Mail',
description: 'Rule-based routing for system-generated mail',
icon: 'mdi-email-lock',
},
],
}
export default integrations
+13
View File
@@ -0,0 +1,13 @@
import '@/style.css'
import routes from '@/routes'
import integrations from '@/integrations'
import type { App as VueApp } from 'vue'
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(_app: VueApp) {
}
}
+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>
+14
View File
@@ -0,0 +1,14 @@
const routes = [
{
name: 'system-mail',
path: '/system-mail',
component: () => import('@/pages/Main.vue'),
meta: {
title: 'System Mail',
requiresAuth: true,
permission: 'mail_manager.system',
}
},
]
export default routes
+1
View File
@@ -0,0 +1 @@
/* System Mail Provider module styles */
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />