feat: use mail manager and mail providers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/* System Mail Provider module styles */
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user