325 lines
10 KiB
Vue
325 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
|
import { ServiceAddressObject } from '@KTXM/MailManager/models/address'
|
|
|
|
type AuxiliaryTab = 'addresses' | 'messages' | 'sync'
|
|
type DeleteMode = 'soft' | 'hard'
|
|
|
|
const props = defineProps<{
|
|
service?: ServiceObject
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:service': [value: ServiceObject]
|
|
}>()
|
|
|
|
const activeTab = ref<AuxiliaryTab>('addresses')
|
|
const deleteMode = ref<DeleteMode>('soft')
|
|
const deleteDestination = ref('Trash')
|
|
const primaryAddress = ref<ServiceAddressObject>(new ServiceAddressObject())
|
|
const secondaryAddresses = ref<ServiceAddressObject[]>([])
|
|
|
|
const settingGroups = [
|
|
{
|
|
title: 'Addresses',
|
|
value: 'addresses' as const,
|
|
icon: 'mdi-at',
|
|
description: 'Configure the primary mailbox identity and any sender aliases exposed by this service.'
|
|
},
|
|
{
|
|
title: 'Messages',
|
|
value: 'messages' as const,
|
|
icon: 'mdi-email-outline',
|
|
description: 'Control how message actions should be translated to operations.'
|
|
},
|
|
{
|
|
title: 'Sync',
|
|
value: 'sync' as const,
|
|
icon: 'mdi-sync',
|
|
description: 'Reserved for future synchronization settings.'
|
|
},
|
|
]
|
|
|
|
const deleteModeOptions = [
|
|
{
|
|
title: 'Move to another mailbox',
|
|
value: 'soft' as const,
|
|
subtitle: 'Marks delete as a move operation and keeps the message in a destination mailbox.'
|
|
},
|
|
{
|
|
title: 'Permanently delete',
|
|
value: 'hard' as const,
|
|
subtitle: 'Removes the message immediately without moving it first.'
|
|
}
|
|
]
|
|
|
|
const destinationHint = computed(() => {
|
|
if (deleteMode.value === 'hard') {
|
|
return 'Not used when messages are deleted permanently.'
|
|
}
|
|
|
|
return 'Mailbox identifier or well-known role target, for example Trash.'
|
|
})
|
|
|
|
watch(
|
|
() => props.service,
|
|
service => {
|
|
syncFromService(service)
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
watch(
|
|
[deleteMode, deleteDestination, primaryAddress, secondaryAddresses],
|
|
() => {
|
|
const nextService = props.service ?? new ServiceObject()
|
|
const nextAuxiliary = {
|
|
...(nextService.auxiliary ?? {}),
|
|
deleteMode: deleteMode.value,
|
|
deleteDestination: deleteMode.value === 'soft'
|
|
? normalizeDeleteDestination(deleteDestination.value)
|
|
: undefined,
|
|
}
|
|
|
|
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
|
|
if (sameAddresses(nextService)) {
|
|
return
|
|
}
|
|
}
|
|
|
|
nextService.primaryAddress = primaryAddress.value.empty ? null : primaryAddress.value
|
|
nextService.secondaryAddresses = dedupeAddresses(secondaryAddresses.value)
|
|
nextService.auxiliary = nextAuxiliary
|
|
emit('update:service', nextService)
|
|
},
|
|
{ deep: true, immediate: true }
|
|
)
|
|
|
|
function syncFromService(service?: ServiceObject) {
|
|
const auxiliary = service?.auxiliary ?? {}
|
|
deleteMode.value = auxiliary.deleteMode === 'hard' ? 'hard' : 'soft'
|
|
deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0
|
|
? auxiliary.deleteDestination
|
|
: 'Trash'
|
|
primaryAddress.value = service?.primaryAddress ?? new ServiceAddressObject()
|
|
secondaryAddresses.value = service?.secondaryAddresses ?? []
|
|
}
|
|
|
|
function normalizeDeleteDestination(value: string): string {
|
|
const trimmedValue = value.trim()
|
|
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
|
|
}
|
|
|
|
function dedupeAddresses(entries: ServiceAddressObject[]): ServiceAddressObject[] {
|
|
const populated = entries.filter(entry => !entry.empty)
|
|
return populated.filter((entry, index) =>
|
|
populated.findIndex(candidate => candidate.matches(entry.address)) === index)
|
|
}
|
|
|
|
function addSecondaryAddress() {
|
|
secondaryAddresses.value.push(new ServiceAddressObject())
|
|
}
|
|
|
|
function removeSecondaryAddress(index: number) {
|
|
secondaryAddresses.value.splice(index, 1)
|
|
}
|
|
|
|
function validAddress(value: string): boolean | string {
|
|
const trimmedValue = value.trim()
|
|
return trimmedValue.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedValue) || 'Invalid email address'
|
|
}
|
|
|
|
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
|
|
return (current.deleteMode === 'hard' ? 'hard' : 'soft') === next.deleteMode
|
|
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
|
|
}
|
|
|
|
function sameAddresses(service: ServiceObject): boolean {
|
|
const nextPrimary = primaryAddress.value.empty ? null : primaryAddress.value
|
|
const nextSecondary = dedupeAddresses(secondaryAddresses.value)
|
|
return sameAddress(service.primaryAddress, nextPrimary)
|
|
&& service.secondaryAddresses.length === nextSecondary.length
|
|
&& service.secondaryAddresses.every((entry, index) => sameAddress(entry, nextSecondary[index]))
|
|
}
|
|
|
|
function sameAddress(current: ServiceAddressObject | null, next: ServiceAddressObject | null): boolean {
|
|
if (current === null || next === null) {
|
|
return current === next
|
|
}
|
|
return current.equals(next)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="jmap-auxiliary-panel">
|
|
<div class="jmap-auxiliary-shell">
|
|
<v-tabs
|
|
v-model="activeTab"
|
|
direction="vertical"
|
|
color="primary"
|
|
class="jmap-auxiliary-tabs"
|
|
>
|
|
<v-tab
|
|
v-for="group in settingGroups"
|
|
:key="group.value"
|
|
:value="group.value"
|
|
class="justify-start"
|
|
>
|
|
<v-icon start>{{ group.icon }}</v-icon>
|
|
{{ group.title }}
|
|
</v-tab>
|
|
</v-tabs>
|
|
|
|
<v-window v-model="activeTab" class="flex-1-1">
|
|
<v-window-item value="addresses">
|
|
<div class="jmap-settings-card">
|
|
<h3 class="text-h6 mb-2">Addresses</h3>
|
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
|
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
|
|
</p>
|
|
|
|
<div class="text-subtitle-2 mb-2">Primary Address</div>
|
|
<div class="d-flex ga-2 mb-6">
|
|
<v-text-field
|
|
v-model="primaryAddress.label"
|
|
label="Display Name"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details="auto"
|
|
/>
|
|
<v-text-field
|
|
v-model="primaryAddress.address"
|
|
label="Email Address"
|
|
variant="outlined"
|
|
density="compact"
|
|
prepend-inner-icon="mdi-email-outline"
|
|
:rules="[validAddress]"
|
|
hide-details="auto"
|
|
/>
|
|
</div>
|
|
|
|
<div class="text-subtitle-2 mb-2">Secondary Addresses</div>
|
|
<div
|
|
v-for="(entry, index) in secondaryAddresses"
|
|
:key="index"
|
|
class="d-flex ga-2 mb-2"
|
|
>
|
|
<v-text-field
|
|
v-model="entry.label"
|
|
label="Display Name"
|
|
variant="outlined"
|
|
density="compact"
|
|
hide-details="auto"
|
|
/>
|
|
<v-text-field
|
|
v-model="entry.address"
|
|
label="Email Address"
|
|
variant="outlined"
|
|
density="compact"
|
|
prepend-inner-icon="mdi-email-multiple-outline"
|
|
:rules="[validAddress]"
|
|
hide-details="auto"
|
|
/>
|
|
<v-btn
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
@click="removeSecondaryAddress(index)"
|
|
>
|
|
<v-icon>mdi-delete-outline</v-icon>
|
|
<v-tooltip activator="parent" location="bottom">Remove Alias</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<v-btn
|
|
variant="tonal"
|
|
prepend-icon="mdi-plus"
|
|
@click="addSecondaryAddress"
|
|
>
|
|
Add Alias
|
|
</v-btn>
|
|
</div>
|
|
</v-window-item>
|
|
|
|
<v-window-item value="messages">
|
|
<div class="jmap-settings-card">
|
|
<h3 class="text-h6 mb-2">Message Deletion</h3>
|
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
|
Choose how the mail system should react when a delete command is issued.
|
|
</p>
|
|
|
|
<v-radio-group
|
|
v-model="deleteMode"
|
|
color="primary"
|
|
class="mb-4"
|
|
>
|
|
<v-radio
|
|
v-for="option in deleteModeOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
<template #label>
|
|
<div>
|
|
<div class="text-body-1">{{ option.title }}</div>
|
|
<div class="text-caption text-medium-emphasis">{{ option.subtitle }}</div>
|
|
</div>
|
|
</template>
|
|
</v-radio>
|
|
</v-radio-group>
|
|
|
|
<v-text-field
|
|
v-model="deleteDestination"
|
|
label="Delete Destination"
|
|
variant="outlined"
|
|
prepend-inner-icon="mdi-folder-move-outline"
|
|
:disabled="deleteMode === 'hard'"
|
|
:hint="destinationHint"
|
|
persistent-hint
|
|
/>
|
|
</div>
|
|
</v-window-item>
|
|
|
|
<v-window-item value="sync">
|
|
<div class="jmap-settings-card">
|
|
<h3 class="text-h6 mb-2">Sync Settings</h3>
|
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
Additional synchronization controls can be added here as the provider grows.
|
|
</p>
|
|
</div>
|
|
</v-window-item>
|
|
</v-window>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.jmap-auxiliary-shell {
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
|
gap: 24px;
|
|
align-items: start;
|
|
}
|
|
|
|
.jmap-auxiliary-tabs {
|
|
border-right: 1px solid rgba(var(--v-theme-outline), 0.16);
|
|
padding-right: 12px;
|
|
}
|
|
|
|
.jmap-settings-card {
|
|
padding: 4px 4px 4px 0;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.jmap-auxiliary-shell {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.jmap-auxiliary-tabs {
|
|
border-right: 0;
|
|
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.16);
|
|
padding-right: 0;
|
|
padding-bottom: 12px;
|
|
}
|
|
}
|
|
</style> |