Merge pull request 'chore: bunch of improvements' (#13) from refactor/bunch-of-improvements into main
Some checks failed
Renovate / renovate (push) Failing after 1m39s

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-04-24 02:01:16 +00:00
28 changed files with 1717 additions and 1297 deletions

View File

@@ -160,7 +160,7 @@ class DefaultController extends ControllerAbstract {
'entity.extant' => $this->entityExtant($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data),
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction), 'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction),
'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data),
@@ -640,6 +640,24 @@ class DefaultController extends ControllerAbstract {
return $this->mailManager->entityDelta($tenantId, $userId, $sources); return $this->mailManager->entityDelta($tenantId, $userId, $sources);
} }
private function entityDelete(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['sources'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
}
if (!is_array($data['sources'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
}
$sources = ResourceIdentifiers::fromArray($data['sources']);
foreach ($sources as $source) {
if (!$source instanceof EntityIdentifier) {
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
}
}
return $this->mailManager->entityDelete($tenantId, $userId, $sources);
}
private function entityMove(string $tenantId, string $userId, array $data): mixed { private function entityMove(string $tenantId, string $userId, array $data): mixed {
if (!isset($data['target'])) { if (!isset($data['target'])) {
throw new InvalidArgumentException(self::ERR_MISSING_TARGET); throw new InvalidArgumentException(self::ERR_MISSING_TARGET);

View File

@@ -981,24 +981,105 @@ class Manager {
return $responseData; return $responseData;
} }
public function entityDelete(string $tenantId, string $userId, ResourceIdentifiers $sources): array {
$operationOutcome = [];
foreach ($sources->providers() as $providerName) {
$providerSources = $sources->byProvider($providerName);
foreach ($providerSources->services() as $serviceName) {
$serviceSources = $providerSources->byService($serviceName);
$service = null;
try {
$service = $this->serviceFetch($tenantId, $userId, $providerName, $serviceName);
} catch (\Throwable $e) {
// Service not found, mark all identifiers as failed
}
if ($service === null) {
foreach ($serviceSources as $identifier) {
$operationOutcome[(string)$identifier] = [
'success' => false,
'error' => 'serviceNotFound',
];
}
continue;
}
// Temporarily disabled check until all methods are properly implemented from ServiceEntityMutableInterface
/*
if (!($service instanceof ServiceEntityMutableInterface)) {
foreach ($serviceSources as $identifier) {
$operationOutcome[(string)$identifier] = [
'success' => false,
'error' => 'serviceNotEntityMutable',
];
}
continue;
}
if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) {
foreach ($serviceSources as $identifier) {
$operationOutcome[(string)$identifier] = [
'success' => false,
'error' => 'serviceCannotDeleteEntities',
];
}
continue;
}
*/
try {
$operationResult = $service->entityDelete(...$serviceSources->all());
foreach ($serviceSources as $identifier) {
$sourceIdentifier = (string)$identifier;
$entityIdentifier = $identifier->entity();
$result = $operationResult[$entityIdentifier] ?? null;
if ($result === true) {
$operationOutcome[$sourceIdentifier] = [
'success' => true,
];
continue;
}
$operationOutcome[$sourceIdentifier] = [
'success' => false,
'error' => is_string($result) && $result !== '' ? $result : 'unknownError',
];
}
} catch (\Throwable $e) {
foreach ($serviceSources as $identifier) {
$operationOutcome[(string)$identifier] = [
'success' => false,
'error' => $e->getMessage(),
];
}
}
}
}
return $operationOutcome;
}
public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array { public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array {
$targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
// Check if service supports entity move // Check if service supports entity move
// Temporarily disabled check until all methods are properly implemented from ServiceEntityMutableInterface
/*
if ($targetService instanceof ServiceEntityMutableInterface === false) { if ($targetService instanceof ServiceEntityMutableInterface === false) {
//return []; return [];
} }
*/
$operationOutcome = []; $operationOutcome = [];
$destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier()); $destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier());
if (!$destinationSources->isEmpty()) { if (!$destinationSources->isEmpty()) {
$entitiesToMove = []; $operationResult = $targetService->entityMove($target, ...$destinationSources->all());
foreach ($destinationSources as $identifier) {
$entitiesToMove[$identifier->collection()][] = $identifier->entity();
}
$operationResult = $targetService->entityMove($target->collection(), $entitiesToMove);
foreach ($destinationSources as $identifier) { foreach ($destinationSources as $identifier) {
$sourceIdentifier = (string)$identifier; $sourceIdentifier = (string)$identifier;

View File

@@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore' import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ProviderDiscoveryStatus, ServiceLocation, ServiceIdentity } from '@MailManager/types' import { ServiceObject, type ProviderObject } from '@MailManager/models'
import type { ServiceObject } from '@MailManager/models/service' import type { ProviderDiscoveryStatus, ServiceInterface, ServiceLocation } from '@MailManager/types'
import DiscoveryStatusStep from '@MailManager/components/steps/DiscoveryStatusStep.vue' import DiscoveryEntryPanel from '@MailManager/components/steps/DiscoveryEntryPanel.vue'
import ProviderSelectionStep from '@MailManager/components/steps/ProviderSelectionStep.vue' import DiscoveryStatusPanel from '@MailManager/components/steps/DiscoveryStatusPanel.vue'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue' import ProviderSelectionPanel from '@MailManager/components/steps/ProviderSelectionPanel.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue' import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue' import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
import DiscoveryEntryStep from '@MailManager/components/steps/DiscoveryEntryStep.vue' import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
// ==================== Step Constants ==================== // ==================== Step Constants ====================
// Discovery flow: Entry → Discovery → Auth → Test // Discovery flow: Entry → Discovery → Auth → Test
@@ -38,6 +39,7 @@ const emit = defineEmits<{
'saved': [] 'saved': []
}>() }>()
const integrationStore = useIntegrationStore()
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
const providersStore = useProvidersStore() const providersStore = useProvidersStore()
@@ -56,19 +58,10 @@ const discoverSecret = ref<string | null>(null)
const discoverHostname = ref<string | null>(null) const discoverHostname = ref<string | null>(null)
// Step 2: Discovery Status / Provider Selection // Step 2: Discovery Status / Provider Selection
const selectedProviderId = ref<string | undefined>(undefined) const selectedProvider = ref<ProviderObject | null>(null)
const selectedProviderLabel = ref<string>('') const selectedService = ref<ServiceObject | null>(null)
// Step 3: Config (manual only) OR Auth (both paths)
const configuredLocation = ref<ServiceLocation | null>(null)
// Step 4: Auth (both paths)
const configuredIdentity = ref<ServiceIdentity | null>(null)
const authValid = ref(false)
// Step 5: Test & Save // Step 5: Test & Save
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
const testAndSaveValid = ref(false) const testAndSaveValid = ref(false)
// Local discovery state (not stored in global store) // Local discovery state (not stored in global store)
@@ -137,19 +130,61 @@ const showSaveButton = computed(() => {
const canProceedToNext = computed(() => { const canProceedToNext = computed(() => {
if (isManualMode.value) { if (isManualMode.value) {
if (currentStep.value === MANUAL_STEPS.CONFIG) { if (currentStep.value === MANUAL_STEPS.CONFIG) {
return !!configuredLocation.value return !!selectedService.value?.location
} }
if (currentStep.value === MANUAL_STEPS.AUTH) { if (currentStep.value === MANUAL_STEPS.AUTH) {
return authValid.value return !!selectedService.value?.identity
} }
} else { } else {
if (currentStep.value === DISCOVERY_STEPS.AUTH) { if (currentStep.value === DISCOVERY_STEPS.AUTH) {
return authValid.value return !!selectedService.value?.identity
} }
} }
return false return false
}) })
function createServiceObject(
providerId: string,
data: Partial<ServiceInterface> = {}
): ServiceObject {
const model: ServiceInterface = {
'@type': 'mail:service',
version: 1,
provider: providerId,
identifier: null,
label: data.label ?? null,
enabled: data.enabled ?? true,
primaryAddress: data.primaryAddress ?? (discoverAddress.value || null),
secondaryAddresses: data.secondaryAddresses ?? null,
location: data.location ?? null,
identity: data.identity ?? null,
capabilities: data.capabilities ?? {},
auxiliary: data.auxiliary ?? {}
}
const factoryItem = integrationStore.getItemById('mail_service_factory', providerId) as any
const factory = factoryItem?.factory
return factory ? factory(model) : new ServiceObject().fromJson(model)
}
function setSelectedProviderAndService(providerId: string, service: ServiceObject) {
selectedProvider.value = providersStore.provider(providerId)
selectedService.value = service
testAndSaveValid.value = false
}
function handleServiceUpdate(service: ServiceObject) {
selectedService.value = service
}
function handleServiceTested(success: boolean) {
testAndSaveValid.value = success
}
watch(selectedService, () => {
testAndSaveValid.value = false
}, { deep: true })
// Navigation methods // Navigation methods
function handlePreviousStep() { function handlePreviousStep() {
if (currentStep.value > 1) { if (currentStep.value > 1) {
@@ -259,12 +294,18 @@ function extractLocationMetadata(location: ServiceLocation) {
async function handleProviderSelect(identifier: string) { async function handleProviderSelect(identifier: string) {
// User clicked "Select" on discovered provider - skip config, go to auth // User clicked "Select" on discovered provider - skip config, go to auth
const service = discoveredServices.value.find(s => s.provider === identifier) const discovered = discoveredServices.value.find(s => s.provider === identifier)
if (!service || !service.location) return if (!discovered || !discovered.location) return
selectedProviderId.value = identifier const discoveredJson = discovered.toJson()
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier const service = createServiceObject(identifier, {
configuredLocation.value = service.location ...discoveredJson,
label: discoveredJson.label || discoverAddress.value,
enabled: discoveredJson.enabled ?? true,
primaryAddress: discoveredJson.primaryAddress || discoverAddress.value,
location: discoveredJson.location
})
setSelectedProviderAndService(identifier, service)
// Discovery path: Entry → Discovery → Auth → Test // Discovery path: Entry → Discovery → Auth → Test
currentStep.value = DISCOVERY_STEPS.AUTH // Go to auth step currentStep.value = DISCOVERY_STEPS.AUTH // Go to auth step
@@ -272,11 +313,17 @@ async function handleProviderSelect(identifier: string) {
function handleProviderAdvanced(identifier: string) { function handleProviderAdvanced(identifier: string) {
// User clicked "Advanced" - show manual config with pre-filled values // User clicked "Advanced" - show manual config with pre-filled values
selectedProviderId.value = identifier const discovered = discoveredServices.value.find(s => s.provider === identifier)
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier const discoveredJson = discovered?.toJson()
const service = discoveredServices.value.find(s => s.provider === identifier) const service = createServiceObject(identifier, {
...discoveredJson,
configuredLocation.value = service?.location || null label: discoveredJson?.label || discoverAddress.value,
enabled: discoveredJson?.enabled ?? true,
primaryAddress: discoveredJson?.primaryAddress || discoverAddress.value,
location: discoveredJson?.location ?? null
})
setSelectedProviderAndService(identifier, service)
isManualMode.value = true isManualMode.value = true
// Manual path: Entry → Discovery → Config → Auth → Test // Manual path: Entry → Discovery → Config → Auth → Test
@@ -293,8 +340,15 @@ function handleManualMode() {
function handleProviderManualSelect(identifier: string) { function handleProviderManualSelect(identifier: string) {
// User selected a provider in manual mode // User selected a provider in manual mode
selectedProviderId.value = identifier const service = createServiceObject(identifier, {
selectedProviderLabel.value = providersStore.provider(identifier)?.label || identifier label: discoverAddress.value,
enabled: true,
primaryAddress: discoverAddress.value,
location: null,
identity: null
})
setSelectedProviderAndService(identifier, service)
currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config currentStep.value = MANUAL_STEPS.CONFIG // Go to manual config
} }
@@ -303,10 +357,21 @@ function goBackToIdentity() {
isManualMode.value = false isManualMode.value = false
discoveredServices.value = [] discoveredServices.value = []
discoveryStatus.value = {} discoveryStatus.value = {}
selectedProvider.value = null
selectedService.value = null
testAndSaveValid.value = false
} }
async function testConnection() { async function testConnection() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) { if (!selectedProvider.value || !selectedService.value) {
return {
success: false,
message: 'Missing configuration'
}
}
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) {
return { return {
success: false, success: false,
message: 'Missing configuration' message: 'Missing configuration'
@@ -314,31 +379,35 @@ async function testConnection() {
} }
const testResult = await servicesStore.test( const testResult = await servicesStore.test(
selectedProviderId.value, selectedProvider.value.identifier,
null, null,
configuredLocation.value, serviceData.location,
configuredIdentity.value serviceData.identity
) )
return testResult return testResult
} }
async function saveAccount() { async function saveAccount() {
if (!selectedProviderId.value || !configuredLocation.value || !configuredIdentity.value) return if (!selectedProvider.value || !selectedService.value) return
const serviceData = selectedService.value.toJson()
if (!serviceData.location || !serviceData.identity) return
saving.value = true saving.value = true
try { try {
const accountData = { const accountData = {
label: accountLabel.value || discoverAddress.value, label: serviceData.label || discoverAddress.value,
email: discoverAddress.value, primaryAddress: serviceData.primaryAddress || discoverAddress.value,
enabled: accountEnabled.value, enabled: serviceData.enabled,
location: configuredLocation.value, location: serviceData.location,
identity: configuredIdentity.value identity: serviceData.identity,
auxiliary: serviceData.auxiliary
} }
await servicesStore.create( await servicesStore.create(
selectedProviderId.value, selectedProvider.value.identifier,
accountData accountData
) )
@@ -364,13 +433,8 @@ function resetForm() {
discoverAddress.value = '' discoverAddress.value = ''
discoverSecret.value = null discoverSecret.value = null
discoverHostname.value = null discoverHostname.value = null
selectedProviderId.value = undefined selectedProvider.value = null
selectedProviderLabel.value = '' selectedService.value = null
configuredLocation.value = null
configuredIdentity.value = null
authValid.value = false
accountLabel.value = ''
accountEnabled.value = true
testAndSaveValid.value = false testAndSaveValid.value = false
discoveredServices.value = [] discoveredServices.value = []
discoveryStatus.value = {} discoveryStatus.value = {}
@@ -407,7 +471,7 @@ function resetForm() {
<!-- Step 1: Discovery Entry --> <!-- Step 1: Discovery Entry -->
<template #item.1> <template #item.1>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<DiscoveryEntryStep <DiscoveryEntryPanel
v-model:address="discoverAddress" v-model:address="discoverAddress"
v-model:secret="discoverSecret" v-model:secret="discoverSecret"
v-model:hostname="discoverHostname" v-model:hostname="discoverHostname"
@@ -421,7 +485,7 @@ function resetForm() {
<template #item.2> <template #item.2>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<!-- Discovery path --> <!-- Discovery path -->
<DiscoveryStatusStep <DiscoveryStatusPanel
v-if="!isManualMode" v-if="!isManualMode"
:address="discoverAddress" :address="discoverAddress"
:status="discoveryStatus" :status="discoveryStatus"
@@ -432,7 +496,7 @@ function resetForm() {
/> />
<!-- Manual path - provider picker --> <!-- Manual path - provider picker -->
<ProviderSelectionStep <ProviderSelectionPanel
v-else v-else
@select="handleProviderManualSelect" @select="handleProviderManualSelect"
@back="goBackToIdentity" @back="goBackToIdentity"
@@ -444,24 +508,18 @@ function resetForm() {
<template #item.3> <template #item.3>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<!-- Manual path: Protocol Configuration --> <!-- Manual path: Protocol Configuration -->
<ProviderConfigStep <ProviderProtocolPanel
v-if="isManualMode && selectedProviderId" v-if="isManualMode && selectedProvider && selectedService"
:provider-id="selectedProviderId" :provider="selectedProvider"
:discovered-location="configuredLocation || undefined" :service="selectedService"
v-model="configuredLocation" @update:service="handleServiceUpdate"
@valid="() => { /* Can proceed to next step */ }"
/> />
<ProviderAuthStep <ProviderAuthPanel
v-else-if="!isManualMode && selectedProviderId" v-else-if="!isManualMode && selectedProvider && selectedService"
:provider-id="selectedProviderId" :provider="selectedProvider"
:provider-label="selectedProviderLabel" :service="selectedService"
:email-address="discoverAddress" @update:service="handleServiceUpdate"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="discoverAddress"
:prefilled-secret="discoverSecret || undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
/> />
</v-card> </v-card>
</template> </template>
@@ -469,31 +527,21 @@ function resetForm() {
<!-- Step 4: Auth (manual) OR Test (discovery) --> <!-- Step 4: Auth (manual) OR Test (discovery) -->
<template #item.4> <template #item.4>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<ProviderAuthStep <ProviderAuthPanel
v-if="isManualMode && selectedProviderId" v-if="isManualMode && selectedProvider && selectedService"
:provider-id="selectedProviderId" :provider="selectedProvider"
:provider-label="selectedProviderLabel" :service="selectedService"
:email-address="discoverAddress" @update:service="handleServiceUpdate"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="discoverAddress"
:prefilled-secret="discoverSecret || undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
/> />
<!-- Discovery path: Test & Save --> <!-- Discovery path: Test & Save -->
<TestAndSaveStep <TestAndSavePanel
v-else-if="!isManualMode && selectedProviderId" v-else-if="!isManualMode && selectedProvider && selectedService"
:provider-id="selectedProviderId" :provider="selectedProvider"
:provider-label="selectedProviderLabel" :service="selectedService"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
:on-test="testConnection" :on-test="testConnection"
@update:label="(val) => accountLabel = val" @update:service="handleServiceUpdate"
@update:enabled="(val) => accountEnabled = val" @tested="handleServiceTested"
@valid="(valid) => testAndSaveValid = valid"
/> />
</v-card> </v-card>
</template> </template>
@@ -501,18 +549,13 @@ function resetForm() {
<!-- Step 5: Test & Save (manual only) --> <!-- Step 5: Test & Save (manual only) -->
<template #item.5> <template #item.5>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<TestAndSaveStep <TestAndSavePanel
v-if="selectedProviderId" v-if="selectedProvider && selectedService"
:provider-id="selectedProviderId" :provider="selectedProvider"
:provider-label="selectedProviderLabel" :service="selectedService"
:email-address="discoverAddress"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="discoverAddress"
:on-test="testConnection" :on-test="testConnection"
@update:label="(val) => accountLabel = val" @update:service="handleServiceUpdate"
@update:enabled="(val) => accountEnabled = val" @tested="handleServiceTested"
@valid="(valid) => testAndSaveValid = valid"
/> />
</v-card> </v-card>
</template> </template>

View File

@@ -2,17 +2,12 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useProvidersStore } from '@MailManager/stores/providersStore' import { useProvidersStore } from '@MailManager/stores/providersStore'
import type { ServiceLocation, ServiceIdentity } from '@MailManager/types' import type { ProviderObject, ServiceObject } from '@MailManager/models'
import type { ServiceObject } from '@MailManager/models/service' import ProviderProtocolPanel from '@MailManager/components/steps/ProviderProtocolPanel.vue'
import ProviderConfigStep from '@MailManager/components/steps/ProviderConfigStep.vue' import ProviderAuthPanel from '@MailManager/components/steps/ProviderAuthPanel.vue'
import ProviderAuthStep from '@MailManager/components/steps/ProviderAuthStep.vue' import TestAndSavePanel from '@MailManager/components/steps/TestAndSavePanel.vue'
import TestAndSaveStep from '@MailManager/components/steps/TestAndSaveStep.vue'
const EDIT_STEPS = { type EditTab = 'general' | 'protocol' | 'auth'
CONFIG: 1,
AUTH: 2,
TEST: 3
} as const
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -33,146 +28,82 @@ const dialogOpen = computed({
set: (val) => emit('update:modelValue', val) set: (val) => emit('update:modelValue', val)
}) })
const currentStep = ref<number>(EDIT_STEPS.CONFIG) const currentTab = ref<EditTab>('general')
const saving = ref(false) const saving = ref(false)
const loading = ref(false) const loading = ref(false)
const loadError = ref<string | null>(null)
// Service data const localProvider = ref<ProviderObject | null>(null)
const service = ref<ServiceObject | null>(null) const localService = ref<ServiceObject | null>(null)
const providerLabel = ref<string>('') const mutated = ref(false)
// Editable fields
const accountLabel = ref<string>('')
const accountEnabled = ref(true)
const configuredLocation = ref<ServiceLocation | null>(null)
const configuredIdentity = ref<ServiceIdentity | null>(null)
// Validation states // Validation states
const configValid = ref(false)
const authValid = ref(false)
const testAndSaveValid = ref(false) const testAndSaveValid = ref(false)
// Load service data when dialog opens const tabItems = [
watch(dialogOpen, async (isOpen) => { {
if (isOpen) { title: 'General',
await loadService() icon: 'mdi-view-dashboard-outline',
value: 'general' as const
},
{
title: 'Protocol',
icon: 'mdi-tune-vertical',
value: 'protocol' as const
},
{
title: 'Authentication',
icon: 'mdi-shield-key-outline',
value: 'auth' as const
} }
})
async function loadService() {
loading.value = true
try {
// Load providers if not already loaded
if (!providersStore.has) {
await providersStore.list()
}
// Fetch the service
service.value = await servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
// Set initial values
accountLabel.value = service.value.label || ''
accountEnabled.value = service.value.enabled
configuredLocation.value = service.value.location
configuredIdentity.value = service.value.identity
// Get provider label
const provider = providersStore.provider(props.serviceProvider)
providerLabel.value = provider?.label || props.serviceProvider
// Mark config as valid if location exists
configValid.value = !!configuredLocation.value
authValid.value = !!configuredIdentity.value
} catch (error) {
console.error('Failed to load service:', error)
} finally {
loading.value = false
}
}
// Stepper configuration
const stepperItems = [
{ title: 'Protocol', value: EDIT_STEPS.CONFIG },
{ title: 'Authentication', value: EDIT_STEPS.AUTH },
{ title: 'Test & Save', value: EDIT_STEPS.TEST }
] ]
const canSave = computed(() => { const canSave = computed(() => {
return testAndSaveValid.value return testAndSaveValid.value
}) })
// Navigation button visibility const showSaveButton = computed(() => currentTab.value === 'general')
const showPreviousButton = computed(() => currentStep.value > EDIT_STEPS.CONFIG) const accountReady = computed(() => localProvider.value !== null && localService.value !== null)
const showNextButton = computed(() => currentStep.value < EDIT_STEPS.TEST)
const showSaveButton = computed(() => currentStep.value === EDIT_STEPS.TEST)
const canProceedToNext = computed(() => { // Load service data when the dialog is open and the target account is available.
if (currentStep.value === EDIT_STEPS.CONFIG) { watch(
return configValid.value && !!configuredLocation.value () => [props.modelValue, props.serviceProvider, props.serviceIdentifier] as const,
} async ([isOpen, serviceProvider, serviceIdentifier]) => {
if (currentStep.value === EDIT_STEPS.AUTH) { if (!isOpen || !serviceProvider || !serviceIdentifier) {
return authValid.value return
}
return false
})
// Navigation methods
function handlePreviousStep() {
if (currentStep.value > EDIT_STEPS.CONFIG) {
currentStep.value--
}
}
function handleNextStep() {
if (currentStep.value < EDIT_STEPS.TEST) {
currentStep.value++
}
}
async function testConnection() {
if (!service.value || !configuredLocation.value || !configuredIdentity.value) {
return {
success: false,
message: 'Missing configuration'
} }
await load()
},
{ immediate: true }
)
async function load() {
loading.value = true
loadError.value = null
localProvider.value = null
localService.value = null
if (!props.serviceProvider || !props.serviceIdentifier) {
console.error('[Mail Manager][Edit Account Dialog] - Cannot open dialog missing service or provider identifier')
loadError.value = 'missing service or provider identifier'
loading.value = false
return
} }
const testResult = await servicesStore.test(
service.value.provider,
service.value.identifier,
configuredLocation.value,
configuredIdentity.value
)
return testResult
}
async function saveAccount() {
if (!service.value || !configuredLocation.value || !configuredIdentity.value) return
saving.value = true
try { try {
const accountData = { const [provider, service] = await Promise.all([
label: accountLabel.value || service.value.label, providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider),
enabled: accountEnabled.value, servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier)
location: configuredLocation.value, ])
identity: configuredIdentity.value
}
await servicesStore.update( localProvider.value = provider.clone()
service.value.provider, localService.value = service.clone()
service.value.identifier as string | number,
accountData
)
emit('saved')
close()
} catch (error) { } catch (error) {
console.error('Failed to save account:', error) console.error('[Mail Manager][Edit Account Dialog] - Failed to load service:', error)
// TODO: Show error message to user loadError.value = 'Failed to load service details'
} finally { } finally {
saving.value = false loading.value = false
} }
} }
@@ -183,21 +114,89 @@ function close() {
} }
function resetForm() { function resetForm() {
currentStep.value = EDIT_STEPS.CONFIG currentTab.value = 'general'
service.value = null localService.value = null
accountLabel.value = '' localProvider.value = null
accountEnabled.value = true loadError.value = null
configuredLocation.value = null
configuredIdentity.value = null
configValid.value = false
authValid.value = false
testAndSaveValid.value = false testAndSaveValid.value = false
} }
// Watch for location changes function isTabDisabled(tab: EditTab) {
watch(configuredLocation, (newLocation) => { if (tab === 'auth') {
configValid.value = !!newLocation return !localService.value?.location
}) }
return false
}
function handleUpdate(mutatedService: ServiceObject) {
localService.value = mutatedService
mutated.value = true
}
async function testConnection() {
try {
let testResult = null
if (mutated.value) {
testResult = await servicesStore.test(
localService.value.provider,
null,
localService.value.location,
localService.value.identity
)
} else {
testResult = await servicesStore.test(
localService.value.provider,
localService.value.identifier
)
}
testAndSaveValid.value = testResult.success
return testResult
} catch (error) {
console.error('[Mail Manager][Edit Account Dialog] - Test connection failed:', error)
return {
success: false,
message: 'Test failed due to an unexpected error'
}
}
}
async function saveAccount() {
// No changes made, just close the dialog
if (!mutated.value) {
close()
return
}
if (!localService.value?.location || !localService.value?.identity) return
saving.value = true
try {
const accountData = {
label: accountLabel.value || localService.value.label,
enabled: accountEnabled.value,
location: localService.value.location,
identity: localService.value.identity
}
await servicesStore.update(
localService.value.provider,
localService.value.identifier as string | number,
accountData
)
emit('saved')
close()
} catch (error) {
console.error('[Mail Manager][Edit Account Dialog] - Failed to save service:', error)
// TODO: Show error message to user
} finally {
saving.value = false
}
}
</script> </script>
<template> <template>
@@ -219,105 +218,77 @@ watch(configuredLocation, (newLocation) => {
<v-divider /> <v-divider />
<v-card-text v-if="loading" class="text-center py-8"> <v-card-text v-if="loading || (!loadError && !accountReady)" class="text-center py-8">
<v-progress-circular indeterminate color="primary" /> <v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">Loading account...</p> <p class="text-caption text-medium-emphasis mt-2">Loading account...</p>
</v-card-text> </v-card-text>
<v-card-text v-else-if="loadError" class="pa-6">
<v-alert type="error" variant="tonal">
{{ loadError }}
</v-alert>
</v-card-text>
<v-card-text v-else class="pa-0"> <v-card-text v-else class="pa-0">
<!-- Account Info Header --> <v-tabs
<div v-if="service" class="pa-6 bg-surface-variant"> v-model="currentTab"
<div class="d-flex align-center gap-3"> bg-color="transparent"
<v-avatar color="primary"> grow
<v-icon>mdi-email</v-icon> class="px-4 pt-2"
</v-avatar> >
<div> <v-tab
<div class="text-subtitle-1 font-weight-medium"> v-for="item in tabItems"
{{ service.label || 'Unnamed Account' }} :key="item.value"
</div> :value="item.value"
<div class="text-caption text-medium-emphasis"> :disabled="isTabDisabled(item.value)"
{{ service.primaryAddress || service.identifier }} >
</div> <v-icon start>{{ item.icon }}</v-icon>
<div class="text-caption text-medium-emphasis"> {{ item.title }}
Provider: {{ providerLabel }} </v-tab>
</div> </v-tabs>
</div>
</div>
</div>
<v-divider /> <v-divider />
<v-stepper <v-window v-model="currentTab">
v-model="currentStep" <v-window-item value="general">
:items="stepperItems"
alt-labels
flat
hide-actions
>
<!-- Step 1: Protocol Configuration -->
<template #item.1>
<v-card flat class="pa-6"> <v-card flat class="pa-6">
<ProviderConfigStep <TestAndSavePanel
v-if="service" v-if="localProvider && localService"
:provider-id="service.provider" :provider="localProvider"
:discovered-location="configuredLocation || undefined" :service="localService"
v-model="configuredLocation"
@valid="(valid) => configValid = valid"
/>
</v-card>
</template>
<!-- Step 2: Authentication -->
<template #item.2>
<v-card flat class="pa-6">
<ProviderAuthStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:discovered-location="configuredLocation || undefined"
:prefilled-identity="service.primaryAddress || ''"
:prefilled-secret="undefined"
v-model="configuredIdentity"
@valid="(valid) => authValid = valid"
/>
</v-card>
</template>
<!-- Step 3: Test & Save -->
<template #item.3>
<v-card flat class="pa-6">
<TestAndSaveStep
v-if="service"
:provider-id="service.provider"
:provider-label="providerLabel"
:email-address="service.primaryAddress || ''"
:location="configuredLocation"
:identity="configuredIdentity"
:prefilled-label="accountLabel"
:on-test="testConnection" :on-test="testConnection"
@update:label="(val) => accountLabel = val" @update:service="handleUpdate"
@update:enabled="(val) => accountEnabled = val"
@valid="(valid) => testAndSaveValid = valid"
/> />
</v-card> </v-card>
</template> </v-window-item>
</v-stepper>
<v-window-item value="protocol">
<v-card flat class="pa-6">
<ProviderProtocolPanel
v-if="localProvider && localService"
:provider="localProvider"
:service="localService"
@update:service="handleUpdate"
/>
</v-card>
</v-window-item>
<v-window-item value="auth">
<v-card flat class="pa-6">
<ProviderAuthPanel
v-if="localProvider && localService"
:provider="localProvider"
:service="localService"
@update:service="handleUpdate"
/>
</v-card>
</v-window-item>
</v-window>
</v-card-text> </v-card-text>
<v-divider /> <v-divider />
<v-card-actions class="pa-6"> <v-card-actions class="pa-6">
<!-- Previous Button -->
<v-btn
v-if="showPreviousButton"
variant="text"
prepend-icon="mdi-arrow-left"
@click="handlePreviousStep"
>
Previous
</v-btn>
<v-spacer /> <v-spacer />
<v-btn <v-btn
@@ -326,18 +297,7 @@ watch(configuredLocation, (newLocation) => {
> >
Cancel Cancel
</v-btn> </v-btn>
<!-- Next Button -->
<v-btn
v-if="showNextButton"
color="primary"
append-icon="mdi-arrow-right"
:disabled="!canProceedToNext"
@click="handleNextStep"
>
Next
</v-btn>
<!-- Save Button --> <!-- Save Button -->
<v-btn <v-btn
v-if="showSaveButton" v-if="showSaveButton"

View File

@@ -36,6 +36,14 @@ const rules = {
required: (v: string) => !!v || 'Required', required: (v: string) => !!v || 'Required',
email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email address' email: (v: string) => /.+@.+\..+/.test(v) || 'Invalid email address'
} }
function handleDiscoverOnEnter() {
if (!localAddress.value || rules.email(localAddress.value) !== true) {
return
}
emit('discover')
}
</script> </script>
<template> <template>
@@ -55,6 +63,7 @@ const rules = {
autocomplete="off" autocomplete="off"
:rules="[rules.required, rules.email]" :rules="[rules.required, rules.email]"
class="mb-4" class="mb-4"
@keydown.enter.prevent="handleDiscoverOnEnter"
/> />
<!-- Advanced Options --> <!-- Advanced Options -->
@@ -143,6 +152,8 @@ const rules = {
<v-btn <v-btn
variant="text" variant="text"
block block
class="manual-action-btn"
prepend-icon="mdi-tune"
@click="$emit('manual')" @click="$emit('manual')"
> >
Manual Configuration Manual Configuration
@@ -155,4 +166,8 @@ const rules = {
.gap-3 { .gap-3 {
gap: 12px; gap: 12px;
} }
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { ProviderDiscoveryStatus } from '@MailManager/types/service' import type { ProviderDiscoveryStatus } from '@MailManager/types'
const props = defineProps<{ const props = defineProps<{
address: string address: string
@@ -16,7 +16,7 @@ const emit = defineEmits<{
const sortedStatus = computed(() => { const sortedStatus = computed(() => {
const statusArray = Object.values(props.status) const statusArray = Object.values(props.status)
const order = { success: 0, discovering: 1, pending: 2, failed: 3 } const order: Record<string, number> = { success: 0, discovering: 1, pending: 2, failed: 3 }
return statusArray.sort((a, b) => order[a.status] - order[b.status]) return statusArray.sort((a, b) => order[a.status] - order[b.status])
}) })
@@ -191,6 +191,22 @@ function getProviderLabel(providerId: string): string {
</div> </div>
</div> </div>
</v-alert> </v-alert>
<div
v-if="!isDiscovering && successCount === 0"
class="mt-6"
>
<v-btn
size="large"
variant="text"
block
class="manual-action-btn"
prepend-icon="mdi-tune"
@click="$emit('manual')"
>
Manual Configuration
</v-btn>
</div>
</div> </div>
</template> </template>
@@ -217,4 +233,8 @@ function getProviderLabel(providerId: string): string {
.gap-3 { .gap-3 {
gap: 12px; gap: 12px;
} }
.manual-action-btn {
background-color: rgba(var(--v-theme-on-surface), 0.06);
}
</style> </style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import type { Component } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceObject } from '@MailManager/models/service'
import type { ProviderObject } from '@MailManager/models/provider'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
}>()
// Local state
const integrationStore = useIntegrationStore()
const panelCache = new Map<string, Component>()
const panelLoading = ref(false)
const panelActive = shallowRef<Component | null>(null)
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
// Local watchers
watch(
() => props.provider,
async (provider) => {
localProvider.value = provider
await loadProviderPanel()
}
)
watch(
() => props.service,
(service) => {
localService.value = service
}
)
watch(
() => [localProvider.value?.identifier, localService.value?.provider] as const,
async () => {
await loadProviderPanel()
},
{ immediate: true }
)
// Load provider panel
async function loadProviderPanel() {
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
if (!providerIdentifier) {
panelActive.value = null
panelLoading.value = false
return
}
// retrieve panel from cache if available
if (panelCache.has(providerIdentifier)) {
panelActive.value = panelCache.get(providerIdentifier) || null
panelLoading.value = false
return
}
panelLoading.value = true
// retrieve panel from integration store
const panel = integrationStore.getItems('mail_account_auth_panels').find((panel: any) => {
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
})
if (!panel?.component) {
console.warn(`No config panel found for provider ID: ${providerIdentifier}`)
panelActive.value = null
panelLoading.value = false
return
}
try {
const module = await panel.component()
const component = module.default || module
panelCache.set(providerIdentifier, component)
panelActive.value = component
} catch (error) {
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
panelActive.value = null
} finally {
panelLoading.value = false
}
}
function handleUpdate(service: ServiceObject) {
localService.value = service
emit('update:service', localService.value)
}
</script>
<template>
<div class="provider-auth-panel">
<h3 class="text-h6 mb-2">Authentication</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ localProvider?.label || 'this provider' }}.
</p>
<div v-if="panelLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading authentication panel...
</p>
</div>
<v-alert v-else-if="!panelActive" type="error" variant="tonal">
<v-icon start>mdi-alert-circle</v-icon>
No authentication method available for this provider.
</v-alert>
<component
v-else
:is="panelActive"
:service="localService"
@update:service="handleUpdate"
/>
</div>
</template>

View File

@@ -1,163 +0,0 @@
<template>
<div class="provider-auth-step">
<h3 class="text-h6 mb-2">Authentication</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ providerLabel }}
</p>
<!-- Loading State -->
<div v-if="loadingPanel" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading authentication panel...
</p>
</div>
<!-- Dynamic Provider Auth Panel -->
<component
v-else-if="currentAuthPanel"
:is="currentAuthPanel"
:email-address="emailAddress"
:discovered-location="discoveredLocation"
:prefilled-identity="prefilledIdentity"
:prefilled-secret="prefilledSecret"
v-model="localIdentity"
@update:model-value="handleIdentityUpdate"
@valid="handleValidChange"
@error="handleAuthError"
/>
<!-- No Panel Available -->
<v-alert v-else type="error" variant="tonal">
<v-icon start>mdi-alert-circle</v-icon>
No authentication method available for this provider.
Please contact support.
</v-alert>
<!-- Error Display -->
<v-alert
v-if="authError"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="authError = ''"
>
<v-icon start>mdi-alert</v-icon>
{{ authError }}
</v-alert>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
providerLabel: string
emailAddress: string
discoveredLocation?: ServiceLocation
prefilledIdentity?: string
prefilledSecret?: string
modelValue?: ServiceIdentity | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: ServiceIdentity]
'valid': [value: boolean]
}>()
const integrationStore = useIntegrationStore()
const loadedPanels = new Map<string, any>()
const currentAuthPanel = ref<any>(null)
const loadingPanel = ref(false)
const localIdentity = ref<ServiceIdentity | undefined>(props.modelValue)
const authError = ref('')
// The full integration ID (e.g., "jmap")
const effectiveIntegrationId = computed(() => {
return props.providerId
})
// Load provider auth panel dynamically
async function loadAuthPanel(integrationId: string) {
if (loadedPanels.has(integrationId)) {
currentAuthPanel.value = loadedPanels.get(integrationId)
return
}
loadingPanel.value = true
// Try to find panel - integration IDs are prefixed with module handle
// so we need to search for panels that match the provider ID
const panels = integrationStore.getItems('mail_account_auth_panels')
const panelConfig = panels.find((panel: any) => {
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
})
if (!panelConfig?.component) {
console.error(`No auth panel found for provider ID: ${integrationId}`)
console.error(`Available panels:`, panels.map((p: any) => p.id))
currentAuthPanel.value = null
loadingPanel.value = false
return
}
try {
const module = await panelConfig.component()
const component = module.default || module
loadedPanels.set(integrationId, component)
currentAuthPanel.value = component
} catch (error) {
console.error(`Failed to load auth panel for ${integrationId}:`, error)
currentAuthPanel.value = null
authError.value = `Failed to load authentication panel: ${error}`
} finally {
loadingPanel.value = false
}
}
// Load panel when provider changes
watch(
effectiveIntegrationId,
(newIntegrationId, oldIntegrationId) => {
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
loadAuthPanel(newIntegrationId)
}
},
{ immediate: true }
)
function handleIdentityUpdate(identity: ServiceIdentity) {
localIdentity.value = identity
emit('update:modelValue', identity)
}
function handleValidChange(valid: boolean) {
emit('valid', valid)
}
function handleAuthError(error: string) {
authError.value = error
}
// Watch for prop changes
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
localIdentity.value = newValue
}
}
)
</script>
<style scoped>
.provider-auth-step {
max-width: 800px;
}
</style>

View File

@@ -1,124 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
discoveredLocation?: ServiceLocation
modelValue?: ServiceLocation | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: ServiceLocation]
'valid': [value: boolean]
}>()
const integrationStore = useIntegrationStore()
const loadedPanels = new Map<string, any>()
const currentProviderPanel = ref<any>(null)
const loadingPanel = ref(false)
const localLocation = ref<ServiceLocation | undefined>(props.modelValue || props.discoveredLocation)
// The full integration ID (e.g., "provider_jmapc.jmap")
const effectiveIntegrationId = computed(() => {
return props.providerId
})
// Load provider panel dynamically using the integration ID
async function loadProviderPanel(integrationId: string) {
if (loadedPanels.has(integrationId)) {
currentProviderPanel.value = loadedPanels.get(integrationId)
return
}
loadingPanel.value = true
// Try to find panel - integration IDs are prefixed with module handle
// so we need to search for panels that match the provider ID
const panels = integrationStore.getItems('mail_account_config_panels')
const panelConfig = panels.find((panel: any) => {
// Check if the ID ends with the provider ID (e.g., "provider_jmapc.jmap" contains "jmap")
return panel.id === integrationId || panel.id.endsWith(`.${integrationId}`)
})
if (!panelConfig?.component) {
console.warn(`No config panel found for provider ID: ${integrationId}`)
console.warn(`Available panels:`, panels.map((p: any) => p.id))
currentProviderPanel.value = null
loadingPanel.value = false
return
}
try {
const module = await panelConfig.component()
const component = module.default || module
loadedPanels.set(integrationId, component)
currentProviderPanel.value = component
} catch (error) {
console.error(`Failed to load panel for ${integrationId}:`, error)
currentProviderPanel.value = null
} finally {
loadingPanel.value = false
}
}
watch(effectiveIntegrationId, (newIntegrationId, oldIntegrationId) => {
if (newIntegrationId && newIntegrationId !== oldIntegrationId) {
loadProviderPanel(newIntegrationId)
}
}, { immediate: true })
function handleLocationUpdate(location: ServiceLocation) {
localLocation.value = location
emit('update:modelValue', location)
// Emit valid when location is provided
emit('valid', !!location)
}
// Watch for prop changes
watch(() => props.modelValue, (newValue) => {
if (newValue) {
localLocation.value = newValue
}
})
watch(() => props.discoveredLocation, (newValue) => {
if (newValue && !props.modelValue) {
localLocation.value = newValue
emit('update:modelValue', newValue)
emit('valid', true)
}
})
</script>
<template>
<div class="provider-config-step">
<h3 class="text-h6 mb-2">Protocol Configuration</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure the connection settings for your mail service
</p>
<!-- Dynamic Provider Panel -->
<component
v-if="currentProviderPanel"
:is="currentProviderPanel"
v-model="localLocation"
:discovered-location="discoveredLocation"
@update:model-value="handleLocationUpdate"
/>
<!-- Loading state for panel -->
<div v-else-if="loadingPanel" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">Loading configuration panel...</p>
</div>
<!-- No panel available -->
<v-alert v-else type="info" variant="tonal">
<v-icon start>mdi-information</v-icon>
No configuration panel available for this provider
</v-alert>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import type { Component } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import type { ServiceObject } from '@MailManager/models/service'
import type { ProviderObject } from '@MailManager/models/provider'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
}>()
// Local state
const integrationStore = useIntegrationStore()
const panelCache = new Map<string, Component>()
const panelLoading = ref(false)
const panelActive = shallowRef<Component | null>(null)
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
// Local watchers
watch(
() => props.provider,
async (provider) => {
localProvider.value = provider
await loadProviderPanel()
}
)
watch(
() => props.service,
(service) => {
localService.value = service
}
)
watch(
() => [localProvider.value?.identifier, localService.value?.provider] as const,
async () => {
await loadProviderPanel()
},
{ immediate: true }
)
// Load provider panel
async function loadProviderPanel() {
const providerIdentifier = localProvider.value?.identifier || localService.value?.provider
if (!providerIdentifier) {
panelActive.value = null
panelLoading.value = false
return
}
// retrieve panel from cache if available
if (panelCache.has(providerIdentifier)) {
panelActive.value = panelCache.get(providerIdentifier) || null
panelLoading.value = false
return
}
panelLoading.value = true
// retrieve panel from integration store
const panel = integrationStore.getItems('mail_account_protocol_panels').find((panel: any) => {
return panel.id === providerIdentifier || panel.id.endsWith(`.${providerIdentifier}`)
})
if (!panel?.component) {
console.warn(`No config panel found for provider ID: ${providerIdentifier}`)
panelActive.value = null
panelLoading.value = false
return
}
try {
const module = await panel.component()
const component = module.default || module
panelCache.set(providerIdentifier, component)
panelActive.value = component
} catch (error) {
console.error(`Failed to load panel for ${providerIdentifier}:`, error)
panelActive.value = null
} finally {
panelLoading.value = false
}
}
function handleUpdate(service: ServiceObject) {
localService.value = service
emit('update:service', localService.value)
}
</script>
<template>
<div class="provider-protocol-panel">
<h3 class="text-h6 mb-2">Protocol Configuration</h3>
<p class="text-body-2 text-medium-emphasis mb-6">
Configure authentication for {{ localProvider?.label || 'this provider' }}.
</p>
<div v-if="panelLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
<p class="text-caption text-medium-emphasis mt-2">
Loading configuration panel...
</p>
</div>
<v-alert v-else-if="!panelActive" type="info" variant="tonal">
<v-icon start>mdi-information</v-icon>
No configuration panel available for this provider
</v-alert>
<component
v-else
:is="panelActive"
:service="localService"
@update:service="handleUpdate"
/>
</div>
</template>

View File

@@ -1,3 +1,121 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ProviderObject, ServiceObject } from '@MailManager/models'
const props = defineProps<{
provider: ProviderObject
service: ServiceObject
onTest?: () => Promise<{ success: boolean; message: string; details?: any }>
}>()
const emit = defineEmits<{
'update:service': [value: ServiceObject]
'tested': [success: boolean]
}>()
// Local state
const localProvider = ref<ProviderObject>(props.provider)
const localService = ref<ServiceObject>(props.service)
const testing = ref(false)
const testResult = ref<any>(null)
// Computed
const testSuccess = computed(() => testResult.value?.success === true)
const serviceLocation = computed(() => localService.value.location?.toJson() ?? null)
const serviceIdentity = computed(() => localService.value.identity?.toJson() ?? null)
// Helper functions
function getAuthIcon(type?: string): string {
switch (type) {
case 'NA': return 'mdi-lock-open-variant'
case 'BA': return 'mdi-account-key'
case 'TA': return 'mdi-key'
case 'OA': return 'mdi-shield-account'
case 'CC': return 'mdi-certificate'
default: return 'mdi-help-circle'
}
}
function getAuthLabel(type?: string): string {
switch (type) {
case 'NA': return 'No Authentication'
case 'BA': return 'Username & Password'
case 'TA': return 'API Token'
case 'OA': return 'OAuth 2.0'
case 'CC': return 'Client Certificate'
default: return 'Unknown'
}
}
function formatCapabilities(capabilities: any): string {
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
const caps = Object.entries(capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => key)
.slice(0, 5)
const total = caps.length
const display = caps.slice(0, 3).join(', ')
if (total > 3) {
return `${display}, +${total - 3} more`
}
return display
}
async function handleTest() {
if (!localService.value.location || !localService.value.identity) {
testResult.value = {
success: false,
message: 'Missing configuration'
}
return
}
testing.value = true
testResult.value = null
try {
if (!props.onTest) {
throw new Error('No connection test callback provided')
}
const result = await props.onTest()
testResult.value = result
emit('tested', result.success)
} catch (error: any) {
testResult.value = {
success: false,
message: error.message || 'Connection test failed'
}
emit('tested', false)
} finally {
testing.value = false
}
}
// Watch for changes and emit
watch(localService, () => {
emit('update:service', localService.value)
}, { deep: true })
watch(
() => props.provider,
(provider) => {
localProvider.value = provider
}
)
watch(
() => props.service,
(service) => {
localService.value = service
testResult.value = null
}
)
</script>
<template> <template>
<div class="test-and-save-step"> <div class="test-and-save-step">
<h3 class="text-h6 mb-2">Test & Save</h3> <h3 class="text-h6 mb-2">Test & Save</h3>
@@ -16,7 +134,7 @@
<v-icon>mdi-label</v-icon> <v-icon>mdi-label</v-icon>
</template> </template>
<v-list-item-title>Account Name</v-list-item-title> <v-list-item-title>Account Name</v-list-item-title>
<v-list-item-subtitle>{{ accountLabel }}</v-list-item-subtitle> <v-list-item-subtitle>{{ localService.label || localService.primaryAddress || 'New Account' }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
<!-- Email Address --> <!-- Email Address -->
@@ -25,7 +143,7 @@
<v-icon>mdi-email</v-icon> <v-icon>mdi-email</v-icon>
</template> </template>
<v-list-item-title>Email Address</v-list-item-title> <v-list-item-title>Email Address</v-list-item-title>
<v-list-item-subtitle>{{ emailAddress }}</v-list-item-subtitle> <v-list-item-subtitle>{{ localService.primaryAddress }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
<!-- Provider --> <!-- Provider -->
@@ -34,48 +152,48 @@
<v-icon>mdi-cloud</v-icon> <v-icon>mdi-cloud</v-icon>
</template> </template>
<v-list-item-title>Provider</v-list-item-title> <v-list-item-title>Provider</v-list-item-title>
<v-list-item-subtitle>{{ providerLabel }}</v-list-item-subtitle> <v-list-item-subtitle>{{ localProvider.label }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
<!-- Location Details --> <!-- Location Details -->
<template v-if="location"> <template v-if="serviceLocation">
<v-divider class="my-2" /> <v-divider class="my-2" />
<v-list-item v-if="location.type === 'URI'"> <v-list-item v-if="serviceLocation.type === 'URI'">
<template #prepend> <template #prepend>
<v-icon>mdi-web</v-icon> <v-icon>mdi-web</v-icon>
</template> </template>
<v-list-item-title>Service URL</v-list-item-title> <v-list-item-title>Service URL</v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ location.scheme }}://{{ location.host }}:{{ location.port }}{{ location.path || '' }} {{ serviceLocation.scheme }}://{{ serviceLocation.host }}:{{ serviceLocation.port }}{{ serviceLocation.path || '' }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
<template v-if="location.type === 'SOCKET_SOLE'"> <template v-if="serviceLocation.type === 'SOCKET_SOLE'">
<v-list-item> <v-list-item>
<template #prepend> <template #prepend>
<v-icon>mdi-server</v-icon> <v-icon>mdi-server</v-icon>
</template> </template>
<v-list-item-title>Server</v-list-item-title> <v-list-item-title>Server</v-list-item-title>
<v-list-item-subtitle>{{ location.host }}:{{ location.port }}</v-list-item-subtitle> <v-list-item-subtitle>{{ serviceLocation.host }}:{{ serviceLocation.port }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
<template #prepend> <template #prepend>
<v-icon>mdi-shield-lock</v-icon> <v-icon>mdi-shield-lock</v-icon>
</template> </template>
<v-list-item-title>Security</v-list-item-title> <v-list-item-title>Security</v-list-item-title>
<v-list-item-subtitle>{{ location.encryption.toUpperCase() }}</v-list-item-subtitle> <v-list-item-subtitle>{{ serviceLocation.encryption.toUpperCase() }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
</template> </template>
<template v-if="location.type === 'SOCKET_SPLIT'"> <template v-if="serviceLocation.type === 'SOCKET_SPLIT'">
<v-list-item> <v-list-item>
<template #prepend> <template #prepend>
<v-icon>mdi-inbox-arrow-down</v-icon> <v-icon>mdi-inbox-arrow-down</v-icon>
</template> </template>
<v-list-item-title>Incoming Mail</v-list-item-title> <v-list-item-title>Incoming Mail</v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ location.inbound.protocol.toUpperCase() }} - {{ location.inbound.host }}:{{ location.inbound.port }} ({{ location.inbound.encryption.toUpperCase() }}) {{ serviceLocation.inboundHost }}:{{ serviceLocation.inboundPort }} ({{ serviceLocation.inboundEncryption.toUpperCase() }})
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
@@ -84,7 +202,7 @@
</template> </template>
<v-list-item-title>Outgoing Mail</v-list-item-title> <v-list-item-title>Outgoing Mail</v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ location.outbound.protocol.toUpperCase() }} - {{ location.outbound.host }}:{{ location.outbound.port }} ({{ location.outbound.encryption.toUpperCase() }}) {{ serviceLocation.outboundHost }}:{{ serviceLocation.outboundPort }} ({{ serviceLocation.outboundEncryption.toUpperCase() }})
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item> </v-list-item>
</template> </template>
@@ -94,10 +212,10 @@
<v-divider class="my-2" /> <v-divider class="my-2" />
<v-list-item> <v-list-item>
<template #prepend> <template #prepend>
<v-icon>{{ getAuthIcon(identity?.type) }}</v-icon> <v-icon>{{ getAuthIcon(serviceIdentity?.type) }}</v-icon>
</template> </template>
<v-list-item-title>Authentication</v-list-item-title> <v-list-item-title>Authentication</v-list-item-title>
<v-list-item-subtitle>{{ getAuthLabel(identity?.type) }}</v-list-item-subtitle> <v-list-item-subtitle>{{ getAuthLabel(serviceIdentity?.type) }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-card-text> </v-card-text>
@@ -105,7 +223,7 @@
<!-- Account Label Input --> <!-- Account Label Input -->
<v-text-field <v-text-field
v-model="localAccountLabel" v-model="localService.label"
label="Account Name" label="Account Name"
variant="outlined" variant="outlined"
hint="A friendly name for this account (e.g., Work Email)" hint="A friendly name for this account (e.g., Work Email)"
@@ -116,7 +234,7 @@
<!-- Enable Account Toggle --> <!-- Enable Account Toggle -->
<v-switch <v-switch
v-model="accountEnabled" v-model="localService.enabled"
label="Enable this account" label="Enable this account"
color="primary" color="primary"
class="mb-4" class="mb-4"
@@ -176,120 +294,4 @@
Please test the connection before saving Please test the connection before saving
</v-alert> </v-alert>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ServiceIdentity, ServiceLocation } from '@MailManager/types/service'
const props = defineProps<{
providerId: string
providerLabel: string
emailAddress: string
location: ServiceLocation | null
identity: ServiceIdentity | null
prefilledLabel?: string
onTest: () => Promise<any>
}>()
const emit = defineEmits<{
'update:label': [value: string]
'update:enabled': [value: boolean]
'tested': [success: boolean]
'valid': [value: boolean]
}>()
// Local state
const localAccountLabel = ref(props.prefilledLabel || props.emailAddress || '')
const accountEnabled = ref(true)
const testing = ref(false)
const testResult = ref<any>(null)
// Computed
const accountLabel = computed(() => localAccountLabel.value || props.emailAddress || 'New Account')
const testSuccess = computed(() => testResult.value?.success === true)
const isValid = computed(() => {
return testSuccess.value && !!localAccountLabel.value
})
// Helper functions
function getAuthIcon(type?: string): string {
switch (type) {
case 'NA': return 'mdi-lock-open-variant'
case 'BA': return 'mdi-account-key'
case 'TA': return 'mdi-key'
case 'OA': return 'mdi-shield-account'
case 'CC': return 'mdi-certificate'
default: return 'mdi-help-circle'
}
}
function getAuthLabel(type?: string): string {
switch (type) {
case 'NA': return 'No Authentication'
case 'BA': return 'Username & Password'
case 'TA': return 'API Token'
case 'OA': return 'OAuth 2.0'
case 'CC': return 'Client Certificate'
default: return 'Unknown'
}
}
function formatCapabilities(capabilities: any): string {
if (!capabilities || typeof capabilities !== 'object') return 'N/A'
const caps = Object.entries(capabilities)
.filter(([_, value]) => value === true)
.map(([key]) => key)
.slice(0, 5)
const total = caps.length
const display = caps.slice(0, 3).join(', ')
if (total > 3) {
return `${display}, +${total - 3} more`
}
return display
}
async function handleTest() {
if (!props.location || !props.identity) {
testResult.value = {
success: false,
message: 'Missing configuration'
}
return
}
testing.value = true
testResult.value = null
try {
const result = await props.onTest()
testResult.value = result
emit('tested', result.success)
} catch (error: any) {
testResult.value = {
success: false,
message: error.message || 'Connection test failed'
}
emit('tested', false)
} finally {
testing.value = false
}
}
// Watch for changes and emit
watch(localAccountLabel, (value) => {
emit('update:label', value)
})
watch(accountEnabled, (value) => {
emit('update:enabled', value)
})
watch(isValid, (value) => {
emit('valid', value)
}, { immediate: true })
</script>

View File

@@ -2,45 +2,48 @@
* Class model for Collection Interface * Class model for Collection Interface
*/ */
import type { CollectionInterface, CollectionPropertiesInterface } from "@/types/collection"; import type { CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface, CollectionPropertiesModelInterface } from "@/types/collection";
export class CollectionObject implements CollectionInterface { export class CollectionObject implements CollectionModelInterface {
_data!: CollectionInterface; _data!: CollectionInterface<CollectionPropertiesInterface>;
_properties!: CollectionPropertiesObject;
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:collection',
version: 1,
provider: '', provider: '',
service: '', service: '',
collection: null, collection: null,
identifier: '', identifier: '',
signature: null, properties: {'@type': 'mail:folder', label: ''},
created: null,
modified: null,
properties: new CollectionPropertiesObject(),
}; };
} }
fromJson(data: CollectionInterface): CollectionObject { fromJson(data: CollectionInterface): CollectionObject {
this._data = data; this._data = data;
if (data.properties) {
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
}
return this; return this;
} }
toJson(): CollectionInterface { toJson(): CollectionInterface {
const json = { ...this._data }; const json = {
if (this._data.properties instanceof CollectionPropertiesObject) { ...this._data
json.properties = this._data.properties.toJson(); };
if (this._properties) {
json.properties = this._properties.toJson();
} }
return json; return json;
} }
clone(): CollectionObject { clone(): CollectionObject {
const cloned = new CollectionObject(); const cloned = new CollectionObject();
cloned._data = { ...this._data }; cloned._data = {
cloned._data.properties = this.properties.clone(); ...this._data,
};
if (this._properties) {
cloned._properties = this._properties.clone();
}
return cloned; return cloned;
} }
@@ -75,36 +78,30 @@ export class CollectionObject implements CollectionInterface {
} }
get properties(): CollectionPropertiesObject { get properties(): CollectionPropertiesObject {
if (this._data.properties instanceof CollectionPropertiesObject) { if (this._properties) {
return this._data.properties; return this._properties;
} }
else if (this._data.properties) {
if (this._data.properties) { const properties = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface);
const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); this._properties = properties;
this._data.properties = hydrated; return properties;
return hydrated;
} }
return new CollectionPropertiesObject(); return new CollectionPropertiesObject();
} }
set properties(value: CollectionPropertiesObject) { set properties(value: CollectionPropertiesObject) {
if (value instanceof CollectionPropertiesObject) { this._properties = value;
this._data.properties = value as any;
} else {
this._data.properties = value;
}
} }
} }
export class CollectionPropertiesObject implements CollectionPropertiesInterface { export class CollectionPropertiesObject implements CollectionPropertiesModelInterface {
_data!: CollectionPropertiesInterface; private _data!: CollectionPropertiesInterface;
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:collection', '@type': 'mail:folder',
version: 1,
total: 0, total: 0,
unread: 0, unread: 0,
role: null, role: null,
@@ -131,14 +128,6 @@ export class CollectionPropertiesObject implements CollectionPropertiesInterface
/** Immutable Properties */ /** Immutable Properties */
get '@type'(): string {
return this._data['@type'];
}
get version(): number {
return this._data.version;
}
get role(): string | null | undefined { get role(): string | null | undefined {
return this._data.role; return this._data.role;
} }

View File

@@ -2,18 +2,19 @@
* Class model for Message/Entity Interface * Class model for Message/Entity Interface
*/ */
import type { EntityInterface } from "@/types/entity"; import type { EntityInterface, EntityModelInterface } from "@/types/entity";
import type { MessageInterface, MessagePartInterface } from "@/types/message"; import type { MessageInterface } from "@/types/message";
import { MessageObject } from "./message"; import { MessageObject } from "./message";
export class EntityObject { export class EntityObject implements EntityModelInterface {
_data!: EntityInterface<MessageInterface>; private _data!: EntityInterface<MessageInterface>;
_message!: MessageObject; private _properties!: MessageObject;
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail.entity', '@type': 'mail:entity',
version: 1,
provider: '', provider: '',
service: '', service: '',
collection: '', collection: '',
@@ -21,24 +22,7 @@ export class EntityObject {
signature: null, signature: null,
created: null, created: null,
modified: null, modified: null,
properties: { properties: {'@type': 'mail:message'},
'@type': 'mail.message',
version: 1,
urid: '',
size: 0,
receivedDate: undefined,
date: undefined,
subject: '',
snippet: '',
from: undefined,
to: [],
cc: [],
bcc: [],
replyTo: [],
flags: {},
body: undefined,
attachments: [],
}
}; };
} }
@@ -53,10 +37,10 @@ export class EntityObject {
clone(): EntityObject { clone(): EntityObject {
const cloned = new EntityObject(); const cloned = new EntityObject();
cloned._data = { cloned._data = {
...this._data, ...this._data
properties: { ...this._data.properties }
}; };
cloned._properties = this.properties.clone();
return cloned; return cloned;
} }
@@ -93,15 +77,10 @@ export class EntityObject {
/** Message Object Properties */ /** Message Object Properties */
get properties(): MessageObject { get properties(): MessageObject {
if (!this._message) { if (!this._properties) {
this._message = new MessageObject(this._data.properties); this._properties = new MessageObject().fromJson(this._data.properties as MessageInterface);
} }
return this._message; return this._properties;
}
// Alias for backward compatibility
get object(): MessageObject {
return this.properties;
} }
} }

View File

@@ -16,7 +16,12 @@ import type {
*/ */
export abstract class Identity { export abstract class Identity {
abstract toJson(): ServiceIdentity; abstract toJson(): ServiceIdentity;
abstract clone(): Identity;
toJSON(): ServiceIdentity {
return this.toJson();
}
static fromJson(data: ServiceIdentity): Identity { static fromJson(data: ServiceIdentity): Identity {
switch (data.type) { switch (data.type) {
case 'NA': case 'NA':
@@ -39,16 +44,30 @@ export abstract class Identity {
* No authentication * No authentication
*/ */
export class IdentityNone extends Identity { export class IdentityNone extends Identity {
readonly type = 'NA' as const;
private _data: ServiceIdentityNone;
constructor() {
super();
this._data = {
type: 'NA'
};
}
get type(): 'NA' {
return this._data.type;
}
static fromJson(_data: ServiceIdentityNone): IdentityNone { static fromJson(_data: ServiceIdentityNone): IdentityNone {
return new IdentityNone(); return new IdentityNone();
} }
toJson(): ServiceIdentityNone { toJson(): ServiceIdentityNone {
return { return { ...this._data };
type: this.type }
};
clone(): IdentityNone {
return IdentityNone.fromJson(this.toJson());
} }
} }
@@ -56,14 +75,36 @@ export class IdentityNone extends Identity {
* Basic authentication (username/password) * Basic authentication (username/password)
*/ */
export class IdentityBasic extends Identity { export class IdentityBasic extends Identity {
readonly type = 'BA' as const;
identity: string; private _data: ServiceIdentityBasic;
secret: string;
constructor(identity: string = '', secret: string = '') { constructor(identity: string = '', secret: string = '') {
super(); super();
this.identity = identity; this._data = {
this.secret = secret; type: 'BA',
identity,
secret
};
}
get type(): 'BA' {
return this._data.type;
}
get identity(): string {
return this._data.identity;
}
set identity(value: string) {
this._data.identity = value;
}
get secret(): string {
return this._data.secret;
}
set secret(value: string) {
this._data.secret = value;
} }
static fromJson(data: ServiceIdentityBasic): IdentityBasic { static fromJson(data: ServiceIdentityBasic): IdentityBasic {
@@ -71,11 +112,11 @@ export class IdentityBasic extends Identity {
} }
toJson(): ServiceIdentityBasic { toJson(): ServiceIdentityBasic {
return { return { ...this._data };
type: this.type, }
identity: this.identity,
secret: this.secret clone(): IdentityBasic {
}; return IdentityBasic.fromJson(this.toJson());
} }
} }
@@ -83,12 +124,27 @@ export class IdentityBasic extends Identity {
* Token authentication (API key, static token) * Token authentication (API key, static token)
*/ */
export class IdentityToken extends Identity { export class IdentityToken extends Identity {
readonly type = 'TA' as const;
token: string; private _data: ServiceIdentityToken;
constructor(token: string = '') { constructor(token: string = '') {
super(); super();
this.token = token; this._data = {
type: 'TA',
token
};
}
get type(): 'TA' {
return this._data.type;
}
get token(): string {
return this._data.token;
}
set token(value: string) {
this._data.token = value;
} }
static fromJson(data: ServiceIdentityToken): IdentityToken { static fromJson(data: ServiceIdentityToken): IdentityToken {
@@ -96,10 +152,11 @@ export class IdentityToken extends Identity {
} }
toJson(): ServiceIdentityToken { toJson(): ServiceIdentityToken {
return { return { ...this._data };
type: this.type, }
token: this.token
}; clone(): IdentityToken {
return IdentityToken.fromJson(this.toJson());
} }
} }
@@ -107,12 +164,8 @@ export class IdentityToken extends Identity {
* OAuth authentication * OAuth authentication
*/ */
export class IdentityOAuth extends Identity { export class IdentityOAuth extends Identity {
readonly type = 'OA' as const;
accessToken: string; private _data: ServiceIdentityOAuth;
accessScope?: string[];
accessExpiry?: number;
refreshToken?: string;
refreshLocation?: string;
constructor( constructor(
accessToken: string = '', accessToken: string = '',
@@ -122,11 +175,58 @@ export class IdentityOAuth extends Identity {
refreshLocation?: string refreshLocation?: string
) { ) {
super(); super();
this.accessToken = accessToken; this._data = {
this.accessScope = accessScope; type: 'OA',
this.accessExpiry = accessExpiry; accessToken,
this.refreshToken = refreshToken; accessScope,
this.refreshLocation = refreshLocation; accessExpiry,
refreshToken,
refreshLocation
};
}
get type(): 'OA' {
return this._data.type;
}
get accessToken(): string {
return this._data.accessToken;
}
set accessToken(value: string) {
this._data.accessToken = value;
}
get accessScope(): string[] | undefined {
return this._data.accessScope;
}
set accessScope(value: string[] | undefined) {
this._data.accessScope = value;
}
get accessExpiry(): number | undefined {
return this._data.accessExpiry;
}
set accessExpiry(value: number | undefined) {
this._data.accessExpiry = value;
}
get refreshToken(): string | undefined {
return this._data.refreshToken;
}
set refreshToken(value: string | undefined) {
this._data.refreshToken = value;
}
get refreshLocation(): string | undefined {
return this._data.refreshLocation;
}
set refreshLocation(value: string | undefined) {
this._data.refreshLocation = value;
} }
static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { static fromJson(data: ServiceIdentityOAuth): IdentityOAuth {
@@ -143,13 +243,17 @@ export class IdentityOAuth extends Identity {
return { return {
type: this.type, type: this.type,
accessToken: this.accessToken, accessToken: this.accessToken,
...(this.accessScope && { accessScope: this.accessScope }), ...(this.accessScope !== undefined && { accessScope: [...this.accessScope] }),
...(this.accessExpiry && { accessExpiry: this.accessExpiry }), ...(this.accessExpiry !== undefined && { accessExpiry: this.accessExpiry }),
...(this.refreshToken && { refreshToken: this.refreshToken }), ...(this.refreshToken !== undefined && { refreshToken: this.refreshToken }),
...(this.refreshLocation && { refreshLocation: this.refreshLocation }) ...(this.refreshLocation !== undefined && { refreshLocation: this.refreshLocation })
}; };
} }
clone(): IdentityOAuth {
return IdentityOAuth.fromJson(this.toJson());
}
isExpired(): boolean { isExpired(): boolean {
if (!this.accessExpiry) return false; if (!this.accessExpiry) return false;
return Date.now() / 1000 >= this.accessExpiry; return Date.now() / 1000 >= this.accessExpiry;
@@ -165,16 +269,44 @@ export class IdentityOAuth extends Identity {
* Client certificate authentication (mTLS) * Client certificate authentication (mTLS)
*/ */
export class IdentityCertificate extends Identity { export class IdentityCertificate extends Identity {
readonly type = 'CC' as const; private _data: ServiceIdentityCertificate;
certificate: string;
privateKey: string;
passphrase?: string;
constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { constructor(certificate: string = '', privateKey: string = '', passphrase?: string) {
super(); super();
this.certificate = certificate; this._data = {
this.privateKey = privateKey; type: 'CC',
this.passphrase = passphrase; certificate,
privateKey,
passphrase
};
}
get type(): 'CC' {
return this._data.type;
}
get certificate(): string {
return this._data.certificate;
}
set certificate(value: string) {
this._data.certificate = value;
}
get privateKey(): string {
return this._data.privateKey;
}
set privateKey(value: string) {
this._data.privateKey = value;
}
get passphrase(): string | undefined {
return this._data.passphrase;
}
set passphrase(value: string | undefined) {
this._data.passphrase = value;
} }
static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { static fromJson(data: ServiceIdentityCertificate): IdentityCertificate {
@@ -190,7 +322,11 @@ export class IdentityCertificate extends Identity {
type: this.type, type: this.type,
certificate: this.certificate, certificate: this.certificate,
privateKey: this.privateKey, privateKey: this.privateKey,
...(this.passphrase && { passphrase: this.passphrase }) ...(this.passphrase !== undefined && { passphrase: this.passphrase })
}; };
} }
clone(): IdentityCertificate {
return IdentityCertificate.fromJson(this.toJson());
}
} }

View File

@@ -15,6 +15,7 @@ import type {
*/ */
export abstract class Location { export abstract class Location {
abstract toJson(): ServiceLocation; abstract toJson(): ServiceLocation;
abstract clone(): Location;
static fromJson(data: ServiceLocation): Location { static fromJson(data: ServiceLocation): Location {
switch (data.type) { switch (data.type) {
@@ -89,6 +90,17 @@ export class LocationUri extends Location {
const path = this.path || ''; const path = this.path || '';
return `${this.scheme}://${this.host}:${this.port}${path}`; return `${this.scheme}://${this.host}:${this.port}${path}`;
} }
clone(): LocationUri {
return new LocationUri(
this.scheme,
this.host,
this.port,
this.path,
this.verifyPeer,
this.verifyHost
);
}
} }
/** /**
@@ -138,6 +150,16 @@ export class LocationSocketSole extends Location {
...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost })
}; };
} }
clone(): LocationSocketSole {
return new LocationSocketSole(
this.host,
this.port,
this.encryption,
this.verifyPeer,
this.verifyHost
);
}
} }
/** /**
@@ -212,6 +234,21 @@ export class LocationSocketSplit extends Location {
...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost }) ...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost })
}; };
} }
clone(): LocationSocketSplit {
return new LocationSocketSplit(
this.inboundHost,
this.inboundPort,
this.inboundEncryption,
this.outboundHost,
this.outboundPort,
this.outboundEncryption,
this.inboundVerifyPeer,
this.inboundVerifyHost,
this.outboundVerifyPeer,
this.outboundVerifyHost
);
}
} }
/** /**
@@ -237,4 +274,8 @@ export class LocationFile extends Location {
path: this.path path: this.path
}; };
} }
clone(): LocationFile {
return new LocationFile(this.path);
}
} }

View File

@@ -1,15 +1,235 @@
/** /**
* Message and MessagePart model classes * Message and MessagePart model classes
*/ */
import type {
MessageAddressInterface,
MessageInterface,
MessageModelInterface,
MessagePartInterface,
MessagePartModelInterface
} from "@/types/message";
import type { MessageInterface, MessagePartInterface } from "@/types/message"; /**
* Message class for working with message objects
*/
export class MessageObject implements MessageModelInterface {
_data: MessageInterface;
_body: MessagePartObject | null = null;
constructor() {
this._data = {
'@type': 'mail:message',
};
this._body = null;
}
fromJson(data: MessageInterface): MessageObject {
this._data = data;
return this;
}
toJson(): MessageInterface {
const json = {
...this._data
};
if (this._body) {
json.body = this._body.toJson();
}
return json;
}
clone(): MessageObject {
const cloned = new MessageObject();
cloned._data = {
...this._data,
};
if (this._body) {
cloned._body = this._body.clone();
}
return cloned;
}
/** Properties */
get urid(): string | null{
return this._data.urid ?? null;
}
get size(): number {
return this._data.size ?? 0;
}
get receivedDate(): string | null {
return this._data.receivedDate ?? null;
}
get sentDate(): string | null {
return this._data.sentDate ?? null;
}
get date(): string | null {
return this._data.date ?? null;
}
get subject(): string | null {
return this._data.subject ?? null;
}
get snippet(): string | null {
return this._data.snippet ?? null;
}
get from(): MessageAddressObject | null {
return this._data.from ? new MessageAddressObject(this._data.from) : null;
}
get to(): Array<MessageAddressObject> | null {
return this._data.to ? this._data.to.map(addr => new MessageAddressObject(addr)) : null;
}
get cc(): Array<MessageAddressObject> | null {
return this._data.cc ? this._data.cc.map(addr => new MessageAddressObject(addr)) : null;
}
get bcc(): Array<MessageAddressObject> | null {
return this._data.bcc ? this._data.bcc.map(addr => new MessageAddressObject(addr)) : null;
}
get replyTo(): Array<MessageAddressObject> | null {
return this._data.replyTo ? this._data.replyTo.map(addr => new MessageAddressObject(addr)) : null;
}
get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | {} {
return this._data.flags ?? {};
}
get body(): MessagePartObject | null {
if (this._body) {
return this._body;
}
else if (this._data.body) {
this._body = new MessagePartObject(this._data.body);
return this._body;
}
return null;
}
get attachments(): Array<MessagePartObject> {
return this._data.attachments ? this._data.attachments.map(att => new MessagePartObject(att)) : [];
}
/** Helper methods */
get isRead(): boolean {
return this._data.flags?.read ?? false;
}
get isFlagged(): boolean {
return this._data.flags?.flagged ?? false;
}
get isAnswered(): boolean {
return this._data.flags?.answered ?? false;
}
get isDraft(): boolean {
return this._data.flags?.draft ?? false;
}
get hasAttachments(): boolean {
return (this._data.attachments?.length ?? 0) > 0;
}
hasRecipients(): boolean {
return (this._data.to?.length ?? 0) > 0
|| (this._data.cc?.length ?? 0) > 0
|| (this._data.bcc?.length ?? 0) > 0;
}
/** Body content helpers */
getBody(): MessagePartObject | null {
if (!this._body && this._data.body) {
this._body = new MessagePartObject(this._data.body);
}
return this._body;
}
hasContent(): boolean {
return !!this.getTextContent() || !!this.getHtmlContent();
}
hasTextContent(): boolean {
return !!this.getTextContent();
}
getTextContent(): string | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.extractTextContent() : null;
}
hasHtmlContent(): boolean {
return !!this.getHtmlContent();
}
getHtmlContent(): string | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.extractHtmlContent() : null;
}
findPartById(partId: string): MessagePartInterface | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.findPartById(partId) : null;
}
findPartsByType(type: string): MessagePartInterface[] {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.findPartsByType(type) : [];
}
}
export class MessageAddressObject implements MessageAddressInterface {
_data: MessageAddressInterface;
constructor(data: MessageAddressInterface) {
this._data = data;
}
fromJson(data: MessageAddressInterface): MessageAddressObject {
this._data = data;
return this;
}
toJson(): MessageAddressInterface {
return this._data;
}
clone(): MessageAddressObject {
return new MessageAddressObject({ ...this._data });
}
/** Properties */
get address(): string {
return this._data.address;
}
get label(): string | undefined {
return this._data.label;
}
}
/** /**
* MessagePart class for working with message body parts * MessagePart class for working with message body parts
*/ */
export class MessagePartObject { export class MessagePartObject implements MessagePartModelInterface {
_data: MessagePartInterface; _data: MessagePartInterface;
_subParts: MessagePartObject[] = [];
constructor(data?: Partial<MessagePartInterface>) { constructor(data?: Partial<MessagePartInterface>) {
this._data = { this._data = {
@@ -17,14 +237,14 @@ export class MessagePartObject {
blobId: data?.blobId ?? null, blobId: data?.blobId ?? null,
size: data?.size ?? null, size: data?.size ?? null,
name: data?.name ?? null, name: data?.name ?? null,
type: data?.type ?? undefined, type: data?.type ?? null,
charset: data?.charset ?? null, charset: data?.charset ?? null,
disposition: data?.disposition ?? null, disposition: data?.disposition ?? null,
cid: data?.cid ?? null, cid: data?.cid ?? null,
language: data?.language ?? null, language: data?.language ?? null,
location: data?.location ?? null, location: data?.location ?? null,
content: data?.content ?? undefined, content: data?.content ?? null,
subParts: data?.subParts ?? undefined, subParts: data?.subParts ?? [],
}; };
} }
@@ -34,7 +254,13 @@ export class MessagePartObject {
} }
toJson(): MessagePartInterface { toJson(): MessagePartInterface {
return this._data; const json = {
...this._data,
};
if (this._subParts.length > 0) {
json.subParts = this._subParts.map(subPart => subPart.toJson());
}
return json
} }
clone(): MessagePartObject { clone(): MessagePartObject {
@@ -43,52 +269,59 @@ export class MessagePartObject {
/** Properties */ /** Properties */
get partId(): string | null | undefined { get partId(): string | null {
return this._data.partId; return this._data.partId ?? null;
} }
get blobId(): string | null | undefined { get blobId(): string | null {
return this._data.blobId; return this._data.blobId ?? null;
} }
get size(): number | null | undefined { get size(): number | null {
return this._data.size; return this._data.size ?? null;
} }
get name(): string | null | undefined { get name(): string | null {
return this._data.name; return this._data.name ?? null;
} }
get type(): string | undefined { get type(): string | null {
return this._data.type; return this._data.type ?? null;
} }
get charset(): string | null | undefined { get charset(): string | null {
return this._data.charset; return this._data.charset ?? null;
} }
get disposition(): string | null | undefined { get disposition(): string | null {
return this._data.disposition; return this._data.disposition ?? null;
} }
get cid(): string | null | undefined { get cid(): string | null {
return this._data.cid; return this._data.cid ?? null;
} }
get language(): string | null | undefined { get language(): string | null {
return this._data.language; return this._data.language ?? null;
} }
get location(): string | null | undefined { get location(): string | null {
return this._data.location; return this._data.location ?? null;
} }
get content(): string | undefined { get content(): string | null {
return this._data.content; return this._data.content ?? null;
} }
get subParts(): MessagePartInterface[] | undefined { get subParts(): MessagePartModelInterface[] {
return this._data.subParts; if (this._subParts) {
return this._subParts;
}
else if (this._data.subParts) {
this._subParts = this._data.subParts.map((subPart) => new MessagePartObject(subPart));
return this._subParts;
}
return [];
} }
/** Helper methods */ /** Helper methods */
@@ -98,7 +331,7 @@ export class MessagePartObject {
} }
hasSubParts(): boolean { hasSubParts(): boolean {
return !!this._data.subParts && this._data.subParts.length > 0; return this.subParts.length > 0;
} }
isMultipart(): boolean { isMultipart(): boolean {
@@ -206,171 +439,3 @@ export class MessagePartObject {
} }
/**
* Message class for working with message objects
*/
export class MessageObject {
_data: MessageInterface;
_body: MessagePartObject | null = null;
constructor(data?: Partial<MessageInterface>) {
this._data = {
urid: data?.urid ?? undefined,
size: data?.size ?? undefined,
receivedDate: data?.receivedDate ?? undefined,
date: data?.date ?? undefined,
subject: data?.subject ?? undefined,
snippet: data?.snippet ?? undefined,
from: data?.from ?? undefined,
to: data?.to ?? [],
cc: data?.cc ?? [],
bcc: data?.bcc ?? [],
replyTo: data?.replyTo ?? [],
flags: data?.flags ?? {},
body: data?.body ?? undefined,
attachments: data?.attachments ?? [],
};
}
fromJson(data: MessageInterface): MessageObject {
this._data = data;
return this;
}
toJson(): MessageInterface {
return this._data;
}
clone(): MessageObject {
return new MessageObject(JSON.parse(JSON.stringify(this._data)));
}
/** Properties */
get urid(): string | undefined {
return this._data.urid;
}
get size(): number | undefined {
return this._data.size;
}
get receivedDate(): string | undefined {
return this._data.receivedDate;
}
get date(): string | undefined {
return this._data.date;
}
get subject(): string | undefined {
return this._data.subject;
}
get snippet(): string | undefined {
return this._data.snippet;
}
get from(): { address: string; label?: string } | undefined {
return this._data.from;
}
get to(): Array<{ address: string; label?: string }> | undefined {
return this._data.to;
}
get cc(): Array<{ address: string; label?: string }> | undefined {
return this._data.cc;
}
get bcc(): Array<{ address: string; label?: string }> | undefined {
return this._data.bcc;
}
get replyTo(): Array<{ address: string; label?: string }> | undefined {
return this._data.replyTo;
}
get flags(): { read?: boolean; flagged?: boolean; answered?: boolean; draft?: boolean } | undefined {
return this._data.flags;
}
get body(): MessagePartInterface | undefined {
return this._data.body;
}
get attachments(): MessageInterface['attachments'] {
return this._data.attachments;
}
/** Helper methods */
get isRead(): boolean {
return this._data.flags?.read ?? false;
}
get isFlagged(): boolean {
return this._data.flags?.flagged ?? false;
}
get isAnswered(): boolean {
return this._data.flags?.answered ?? false;
}
get isDraft(): boolean {
return this._data.flags?.draft ?? false;
}
get hasAttachments(): boolean {
return (this._data.attachments?.length ?? 0) > 0;
}
hasRecipients(): boolean {
return (this._data.to?.length ?? 0) > 0
|| (this._data.cc?.length ?? 0) > 0
|| (this._data.bcc?.length ?? 0) > 0;
}
/** Body content helpers */
getBody(): MessagePartObject | null {
if (!this._body && this._data.body) {
this._body = new MessagePartObject(this._data.body);
}
return this._body;
}
hasContent(): boolean {
return !!this.getTextContent() || !!this.getHtmlContent();
}
hasTextContent(): boolean {
return !!this.getTextContent();
}
getTextContent(): string | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.extractTextContent() : null;
}
hasHtmlContent(): boolean {
return !!this.getHtmlContent();
}
getHtmlContent(): string | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.extractHtmlContent() : null;
}
findPartById(partId: string): MessagePartInterface | null {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.findPartById(partId) : null;
}
findPartsByType(type: string): MessagePartInterface[] {
const bodyPart = this.getBody();
return bodyPart ? bodyPart.findPartsByType(type) : [];
}
}

View File

@@ -4,16 +4,18 @@
import type { import type {
ProviderInterface, ProviderInterface,
ProviderCapabilitiesInterface ProviderCapabilitiesInterface,
ProviderModelInterface
} from "@/types/provider"; } from "@/types/provider";
export class ProviderObject implements ProviderInterface { export class ProviderObject implements ProviderModelInterface {
_data!: ProviderInterface; _data!: ProviderInterface;
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:provider', '@type': 'mail:provider',
version: 1,
identifier: '', identifier: '',
label: '', label: '',
capabilities: {}, capabilities: {},
@@ -29,6 +31,12 @@ export class ProviderObject implements ProviderInterface {
return this._data; return this._data;
} }
clone(): ProviderObject {
const cloned = new ProviderObject();
cloned._data = { ...this._data };
return cloned;
}
capable(capability: keyof ProviderCapabilitiesInterface): boolean { capable(capability: keyof ProviderCapabilitiesInterface): boolean {
const value = this._data.capabilities?.[capability]; const value = this._data.capabilities?.[capability];
return value !== undefined && value !== false; return value !== undefined && value !== false;
@@ -43,10 +51,6 @@ export class ProviderObject implements ProviderInterface {
/** Immutable Properties */ /** Immutable Properties */
get '@type'(): string {
return this._data['@type'];
}
get identifier(): string { get identifier(): string {
return this._data.identifier; return this._data.identifier;
} }

View File

@@ -5,19 +5,22 @@
import type { import type {
ServiceInterface, ServiceInterface,
ServiceCapabilitiesInterface, ServiceCapabilitiesInterface,
ServiceIdentity, ServiceLocation,
ServiceLocation ServiceModelInterface
} from "@/types/service"; } from "@/types/service";
import { Identity } from './identity'; import { Identity } from './identity';
import { Location } from './location'; import { Location } from './location';
export class ServiceObject implements ServiceInterface { export class ServiceObject implements ServiceModelInterface {
_data!: ServiceInterface; _data!: ServiceInterface;
_location: Location | null = null;
_identity: Identity | null = null;
constructor() { constructor() {
this._data = { this._data = {
'@type': 'mail:service', '@type': 'mail:service',
version: 1,
provider: '', provider: '',
identifier: null, identifier: null,
label: null, label: null,
@@ -32,7 +35,35 @@ export class ServiceObject implements ServiceInterface {
} }
toJson(): ServiceInterface { toJson(): ServiceInterface {
return this._data; const json = {
...this._data,
capabilities: this._data.capabilities ? { ...this._data.capabilities } : this._data.capabilities,
secondaryAddresses: this._data.secondaryAddresses ? [...this._data.secondaryAddresses] : this._data.secondaryAddresses,
auxiliary: this._data.auxiliary ? { ...this._data.auxiliary } : this._data.auxiliary,
};
if (this._location !== null) {
json.location = this._location.toJson();
}
if (this._identity !== null) {
json.identity = this._identity.toJson();
}
return json;
}
clone(): ServiceObject {
const cloned = new ServiceObject();
cloned._data = {
...this._data,
capabilities: this._data.capabilities ? { ...this._data.capabilities } : this._data.capabilities,
secondaryAddresses: this._data.secondaryAddresses ? [...this._data.secondaryAddresses] : this._data.secondaryAddresses,
auxiliary: this._data.auxiliary ? { ...this._data.auxiliary } : this._data.auxiliary,
};
cloned._location = this._location ? this._location.clone() : null;
cloned._identity = this._identity ? this._identity.clone() : null;
return cloned;
} }
capable(capability: keyof ServiceCapabilitiesInterface): boolean { capable(capability: keyof ServiceCapabilitiesInterface): boolean {
@@ -49,10 +80,6 @@ export class ServiceObject implements ServiceInterface {
/** Immutable Properties */ /** Immutable Properties */
get '@type'(): string {
return this._data['@type'];
}
get provider(): string { get provider(): string {
return this._data.provider; return this._data.provider;
} }
@@ -61,8 +88,8 @@ export class ServiceObject implements ServiceInterface {
return this._data.identifier; return this._data.identifier;
} }
get capabilities(): ServiceCapabilitiesInterface | undefined { get capabilities(): ServiceCapabilitiesInterface {
return this._data.capabilities; return this._data.capabilities ?? {};
} }
get primaryAddress(): string | null { get primaryAddress(): string | null {
@@ -91,20 +118,38 @@ export class ServiceObject implements ServiceInterface {
this._data.enabled = value; this._data.enabled = value;
} }
get location(): ServiceLocation | null { get location(): Location | null {
return this._data.location ?? null; if (this._location) {
return this._location;
}
else if (this._location === null && this._data.location) {
const location = Location.fromJson(this._data.location as ServiceLocation);
this._location = location;
return location;
}
return null;
} }
set location(value: ServiceLocation | null) { set location(value: Location | null) {
this._data.location = value; this._location = value;
} }
get identity(): ServiceIdentity | null { get identity(): Identity | null {
return this._data.identity ?? null; if (this._identity) {
return this._identity;
}
else if (this._data.identity) {
const identity = Identity.fromJson(this._data.identity);
this._identity = identity;
return identity;
}
return null;
} }
set identity(value: ServiceIdentity | null) { set identity(value: Identity | null) {
this._data.identity = value; this._identity = value;
} }
get auxiliary(): Record<string, any> { get auxiliary(): Record<string, any> {
@@ -115,22 +160,4 @@ export class ServiceObject implements ServiceInterface {
this._data.auxiliary = value; this._data.auxiliary = value;
} }
/** Helper Methods */
/**
* Get identity as a class instance for easier manipulation
*/
getIdentity(): Identity | null {
if (!this._data.identity) return null;
return Identity.fromJson(this._data.identity);
}
/**
* Get location as a class instance for easier manipulation
*/
getLocation(): Location | null {
if (!this._data.location) return null;
return Location.fromJson(this._data.location);
}
} }

View File

@@ -26,6 +26,7 @@ import type {
EntityStreamRequest, EntityStreamRequest,
EntityStreamResponse, EntityStreamResponse,
} from '../types/entity'; } from '../types/entity';
import type { EntityIdentifier } from '../types/common';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { EntityObject } from '../models'; import { EntityObject } from '../models';
@@ -130,11 +131,11 @@ export const entityService = {
}, },
/** /**
* Delete an entity * Delete entities by their identifiers
* *
* @param request - delete request parameters * @param request - delete request parameters
* *
* @returns Promise with deletion result * @returns Promise with deletion results keyed by source entity identifier
*/ */
async delete(request: EntityDeleteRequest): Promise<EntityDeleteResponse> { async delete(request: EntityDeleteRequest): Promise<EntityDeleteResponse> {
return await transceivePost<EntityDeleteRequest, EntityDeleteResponse>('entity.delete', request); return await transceivePost<EntityDeleteRequest, EntityDeleteResponse>('entity.delete', request);

View File

@@ -7,6 +7,7 @@ import { defineStore } from 'pinia'
import { entityService } from '../services' import { entityService } from '../services'
import { EntityObject } from '../models' import { EntityObject } from '../models'
import type { import type {
EntityDeleteResponse,
EntityMoveResponse, EntityMoveResponse,
EntityStreamRequest, EntityStreamRequest,
EntityTransmitRequest, EntityTransmitRequest,
@@ -62,6 +63,35 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
return _entities.value[key] || null return _entities.value[key] || null
} }
/**
* Resolve an entity from cache by full entity identifier.
*/
function entityByIdentifier(identifier: EntityIdentifier, retrieve: boolean = false): EntityObject | null {
if (retrieve === true && !_entities.value[identifier]) {
console.debug(`[Mail Manager][Store] - Force fetching entity "${identifier}"`)
const { provider, service, collection, identifier: id } = parseEntityIdentifier(identifier)
fetch(provider, service, collection, [id])
}
return _entities.value[identifier] || null
}
/**
* Resolve multiple entities from cache by full entity identifiers.
*/
function entitiesByIdentifiers(identifiers: EntityIdentifier[], retrieve: boolean = false): Record<EntityIdentifier, EntityObject> {
const resolved: Record<EntityIdentifier, EntityObject> = {} as Record<EntityIdentifier, EntityObject>
Array.from(new Set(identifiers)).forEach(identifier => {
const entity = entityByIdentifier(identifier, retrieve)
if (entity) {
resolved[identifier] = entity
}
})
return resolved
}
/** /**
* Get all entities for a specific collection * Get all entities for a specific collection
* *
@@ -205,6 +235,58 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
} }
} }
/**
* Retrieve delta changes for entities
*
* @param sources - source selector for delta check
*
* @returns Promise with delta changes (additions, modifications, deletions)
*
* Note: Delta returns only identifiers, not full entities.
* Caller should fetch full entities for additions/modifications separately.
*/
async function delta(sources: SourceSelector) {
transceiving.value = true
try {
const response = await entityService.delta({ sources })
// Process delta and update store
Object.entries(response).forEach(([provider, providerData]) => {
// Skip if no changes for provider
if (providerData === false) return
Object.entries(providerData).forEach(([service, serviceData]) => {
// Skip if no changes for service
if (serviceData === false) return
Object.entries(serviceData).forEach(([collection, collectionData]) => {
// Skip if no changes for collection
if (collectionData === false) return
// Process deletions (remove from store)
if (collectionData.deletions && collectionData.deletions.length > 0) {
collectionData.deletions.forEach((identifier) => {
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
})
}
// Note: additions and modifications contain only identifiers
// The caller should fetch full entities using the fetch() method
})
})
})
console.debug('[Mail Manager][Store] - Successfully processed delta changes')
return response
} catch (error: any) {
console.error('[Mail Manager][Store] - Failed to process delta:', error)
throw error
} finally {
transceiving.value = false
}
}
/** /**
* Create a new entity with given provider, service, collection, and data * Create a new entity with given provider, service, collection, and data
* *
@@ -265,80 +347,31 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
} }
/** /**
* Delete an entity by provider, service, collection, and identifier * Delete entities by their identifiers.
* *
* @param provider - provider identifier for the entity to delete * Removes successfully deleted entities from the local store.
* @param service - service identifier for the entity to delete *
* @param collection - collection identifier for the entity to delete * @param sources - entity identifiers to delete
* @param identifier - entity identifier for the entity to delete *
* * @returns Promise with deletion results keyed by source identifier
* @returns Promise with deletion result
*/ */
async function remove(provider: string, service: string | number, collection: string | number, identifier: string | number): Promise<any> { async function remove(sources: EntityIdentifier[]): Promise<EntityDeleteResponse> {
transceiving.value = true transceiving.value = true
try { try {
const response = await entityService.delete({ provider, service, collection, identifier }) const response = await entityService.delete({ sources })
// Remove entity from state
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
console.debug('[Mail Manager][Store] - Successfully deleted entity:', key) Object.entries(response).forEach(([sourceIdentifier, result]) => {
return response if (!result.success) {
} catch (error: any) { return
console.error('[Mail Manager][Store] - Failed to delete entity:', error) }
throw error
} finally {
transceiving.value = false
}
}
/** delete _entities.value[sourceIdentifier]
* Retrieve delta changes for entities
*
* @param sources - source selector for delta check
*
* @returns Promise with delta changes (additions, modifications, deletions)
*
* Note: Delta returns only identifiers, not full entities.
* Caller should fetch full entities for additions/modifications separately.
*/
async function delta(sources: SourceSelector) {
transceiving.value = true
try {
const response = await entityService.delta({ sources })
// Process delta and update store
Object.entries(response).forEach(([provider, providerData]) => {
// Skip if no changes for provider
if (providerData === false) return
Object.entries(providerData).forEach(([service, serviceData]) => {
// Skip if no changes for service
if (serviceData === false) return
Object.entries(serviceData).forEach(([collection, collectionData]) => {
// Skip if no changes for collection
if (collectionData === false) return
// Process deletions (remove from store)
if (collectionData.deletions && collectionData.deletions.length > 0) {
collectionData.deletions.forEach((identifier) => {
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
})
}
// Note: additions and modifications contain only identifiers
// The caller should fetch full entities using the fetch() method
})
})
}) })
console.debug('[Mail Manager][Store] - Successfully processed delta changes') console.debug('[Mail Manager][Store] - Successfully deleted', Object.keys(response).length, 'entities')
return response return response
} catch (error: any) { } catch (error: any) {
console.error('[Mail Manager][Store] - Failed to process delta:', error) console.error('[Mail Manager][Store] - Failed to delete entities:', error)
throw error throw error
} finally { } finally {
transceiving.value = false transceiving.value = false
@@ -461,8 +494,9 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
has, has,
entities, entities,
entitiesForCollection, entitiesForCollection,
// Actions entitiesByIdentifiers,
entity, entity,
entityByIdentifier,
list, list,
fetch, fetch,
extant, extant,

View File

@@ -7,6 +7,7 @@ import { defineStore } from 'pinia'
import { serviceService } from '../services' import { serviceService } from '../services'
import { ServiceObject } from '../models/service' import { ServiceObject } from '../models/service'
import type { import type {
ServiceIdentifier,
ServiceLocation, ServiceLocation,
SourceSelector, SourceSelector,
ServiceIdentity, ServiceIdentity,
@@ -59,13 +60,23 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
* @returns Service object or null * @returns Service object or null
*/ */
function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null { function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null {
const key = identifierKey(provider, identifier) return serviceByIdentifier(identifierKey(provider, identifier), retrieve)
if (retrieve === true && !_services.value[key]) { }
console.debug(`[Mail Manager][Store] - Force fetching service "${key}"`)
/**
* Get a service from store by its unique identifier, with optional retrieval
*
* @param identifier - unique service identifier
* @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only
* @returns Service object or null
*/
function serviceByIdentifier(identifier: ServiceIdentifier, retrieve: boolean = false): ServiceObject | null {
if (retrieve === true && !_services.value[identifier]) {
console.debug(`[Mail Manager][Store] - Force fetching service "${identifier}"`)
fetch(provider, identifier) fetch(provider, identifier)
} }
return _services.value[key] || null return _services.value[identifier] ?? null
} }
/** /**
@@ -341,6 +352,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
// Actions // Actions
service, service,
serviceByIdentifier,
serviceForAddress, serviceForAddress,
list, list,
fetch, fetch,

View File

@@ -6,7 +6,9 @@ import type { ListFilter, ListSort, SourceSelector } from './common';
/** /**
* Collection information * Collection information
*/ */
export interface CollectionInterface { export interface CollectionInterface<T = CollectionPropertiesInterface> {
'@type': string;
version: number;
provider: string; provider: string;
service: string | number; service: string | number;
collection: string | number | null; collection: string | number | null;
@@ -14,12 +16,15 @@ export interface CollectionInterface {
signature?: string | null; signature?: string | null;
created?: string | null; created?: string | null;
modified?: string | null; modified?: string | null;
properties: CollectionPropertiesInterface; properties: T;
}
export interface CollectionModelInterface extends Omit<CollectionInterface<CollectionPropertiesInterface>, '@type' | 'version' | 'properties'> {
properties: CollectionPropertiesModelInterface;
} }
export interface CollectionBaseProperties { export interface CollectionBaseProperties {
'@type': string; '@type': string;
version: number;
} }
export interface CollectionImmutableProperties extends CollectionBaseProperties { export interface CollectionImmutableProperties extends CollectionBaseProperties {
@@ -36,6 +41,8 @@ export interface CollectionMutableProperties extends CollectionBaseProperties {
export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {} export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {}
export interface CollectionPropertiesModelInterface extends Omit<CollectionPropertiesInterface, '@type'> {}
/** /**
* Collection list * Collection list
*/ */

View File

@@ -9,12 +9,14 @@ import type {
ListRange, ListRange,
ListSort, ListSort,
} from './common'; } from './common';
import type { MessageInterface } from './message'; import type { MessageInterface, MessageModelInterface } from './message';
/** /**
* Entity definition * Entity definition
*/ */
export interface EntityInterface<T = MessageInterface> { export interface EntityInterface<T = MessageInterface> {
'@type': string;
version: number;
provider: string; provider: string;
service: string; service: string;
collection: string | number; collection: string | number;
@@ -25,6 +27,8 @@ export interface EntityInterface<T = MessageInterface> {
properties: T; properties: T;
} }
export interface EntityModelInterface extends Omit<EntityInterface<MessageModelInterface>, '@type' | 'version'> {}
/** /**
* Entity list * Entity list
*/ */
@@ -76,6 +80,26 @@ export interface EntityExtantResponse {
}; };
} }
/**
* Entity delta
*/
export interface EntityDeltaRequest {
sources: SourceSelector;
}
export interface EntityDeltaResponse {
[providerId: string]: false | {
[serviceId: string]: false | {
[collectionId: string]: false | {
signature: string;
additions: (string | number)[];
modifications: (string | number)[];
deletions: (string | number)[];
};
};
};
}
/** /**
* Entity create * Entity create
*/ */
@@ -105,34 +129,20 @@ export interface EntityUpdateResponse<T = MessageInterface> extends EntityInterf
* Entity delete * Entity delete
*/ */
export interface EntityDeleteRequest { export interface EntityDeleteRequest {
provider: string; sources: EntityIdentifier[];
service: string | number;
collection: string | number;
identifier: string | number;
} }
export interface EntityDeleteResponse { export interface EntityDeleteResultSuccess {
success: boolean; success: boolean;
} }
/** export interface EntityDeleteResultFailure {
* Entity delta success: boolean;
*/ error: string;
export interface EntityDeltaRequest {
sources: SourceSelector;
} }
export interface EntityDeltaResponse { export interface EntityDeleteResponse {
[providerId: string]: false | { [sourceIdentifier: EntityIdentifier]: EntityDeleteResultSuccess | EntityDeleteResultFailure;
[serviceId: string]: false | {
[collectionId: string]: false | {
signature: string;
additions: (string | number)[];
modifications: (string | number)[];
deletions: (string | number)[];
};
};
};
} }
/** /**

View File

@@ -6,25 +6,23 @@
// ==================== Provider Panel Contracts ==================== // ==================== Provider Panel Contracts ====================
/** /**
* Props all provider CONFIG panels receive * Props all provider Protocol panels receive
* Config panels handle protocol/location settings only * Protocol panels handle protocol/location settings only
*/ */
export interface ProviderConfigPanelProps { export interface ProviderProtocolPanelProps {
/** Current service value for v-model binding */
service?: import('../models').ServiceObject;
/** Pre-filled location from discovery (if available) */ /** Pre-filled location from discovery (if available) */
discoveredLocation?: import('./service').ServiceLocation; discoveredLocation?: import('./service').ServiceLocation;
/** Current location value for v-model binding */
modelValue?: import('./service').ServiceLocation;
} }
/** /**
* Events all provider CONFIG panels emit * Events all provider Protocol panels emit
* Config panels emit location configuration and validation state * Protocol panels emit service configuration and validation state
*/ */
export interface ProviderConfigPanelEmits { export interface ProviderProtocolPanelEmits {
/** Emit updated location configuration */ /** Emit updated service configuration */
'update:modelValue': [value: import('./service').ServiceLocation]; 'update:service': [value: import('../models').ServiceObject];
/** Emit validation state (true = valid, false = invalid) */
'valid': [value: boolean];
} }
/** /**
@@ -32,6 +30,8 @@ export interface ProviderConfigPanelEmits {
* Auth panels handle credentials/authentication only * Auth panels handle credentials/authentication only
*/ */
export interface ProviderAuthPanelProps { export interface ProviderAuthPanelProps {
/** Current service value for v-model binding */
service?: import('../models').ServiceObject;
/** Email address from discovery entry (for pre-filling username) */ /** Email address from discovery entry (for pre-filling username) */
emailAddress?: string; emailAddress?: string;
/** Discovered or configured location (for context/auth decisions) */ /** Discovered or configured location (for context/auth decisions) */
@@ -40,8 +40,6 @@ export interface ProviderAuthPanelProps {
prefilledIdentity?: string; prefilledIdentity?: string;
/** Pre-filled secret/password if user entered during discovery */ /** Pre-filled secret/password if user entered during discovery */
prefilledSecret?: string; prefilledSecret?: string;
/** Current identity value for v-model binding */
modelValue?: import('./service').ServiceIdentity;
} }
/** /**
@@ -49,10 +47,6 @@ export interface ProviderAuthPanelProps {
* Auth panels emit identity configuration, validation state, and errors * Auth panels emit identity configuration, validation state, and errors
*/ */
export interface ProviderAuthPanelEmits { export interface ProviderAuthPanelEmits {
/** Emit updated identity configuration */ /** Emit updated service configuration */
'update:modelValue': [value: import('./service').ServiceIdentity]; 'update:service': [value: import('../models').ServiceObject];
/** Emit validation state (true = valid, false = invalid) */
'valid': [value: boolean];
/** Emit authentication errors for user feedback */
'error': [error: string];
} }

View File

@@ -1,68 +1,64 @@
/**
* Message object interface
*/
export interface MessageModelInterface extends Omit<{
[K in keyof MessageInterface]-?: Exclude<MessageInterface[K], undefined>;
}, '@type' | 'version' | 'body'> {
body: MessagePartModelInterface | null;
attachments: Array<MessagePartModelInterface>;
}
export interface MessageInterface {
'@type': string;
urid?: string | null;
size?: number | null;
date?: string | null;
receivedDate?: string | null;
sentDate?: string | null;
subject?: string | null;
snippet?: string | null;
from?: MessageAddressInterface | null;
to?: Array<MessageAddressInterface> | null;
cc?: Array<MessageAddressInterface> | null;
bcc?: Array<MessageAddressInterface> | null;
replyTo?: Array<MessageAddressInterface> | null;
flags?: MessageFlagsInterface | null;
body?: MessagePartInterface | null;
attachments?: Array<MessagePartInterface> | [];
}
export interface MessageAddressInterface {
address: string;
label?: string;
}
export interface MessageFlagsInterface {
read?: boolean;
flagged?: boolean;
answered?: boolean;
draft?: boolean;
}
/** /**
* Message Part Interface * Message Part Interface
*/ */
export interface MessagePartModelInterface extends Omit<{
[K in keyof MessagePartInterface]-?: Exclude<MessagePartInterface[K], undefined>;
}, 'subParts'> {
subParts: MessagePartModelInterface[];
}
export interface MessagePartInterface { export interface MessagePartInterface {
partId?: string | null; partId?: string | null;
blobId?: string | null; blobId?: string | null;
size?: number | null; size?: number | null;
name?: string | null; name?: string | null;
type?: string; type?: string | null;
charset?: string | null; charset?: string | null;
disposition?: string | null; disposition?: string | null;
cid?: string | null; cid?: string | null;
language?: string | null; language?: string | null;
location?: string | null; location?: string | null;
content?: string; content?: string | null;
subParts?: MessagePartInterface[]; subParts?: MessagePartInterface[];
} }
/**
* Message object interface
*/
export interface MessageInterface {
urid?: string;
size?: number;
receivedDate?: string;
date?: string;
subject?: string;
snippet?: string;
from?: {
address: string;
label?: string;
};
to?: Array<{
address: string;
label?: string;
}>;
cc?: Array<{
address: string;
label?: string;
}>;
bcc?: Array<{
address: string;
label?: string;
}>;
replyTo?: Array<{
address: string;
label?: string;
}>;
flags?: {
read?: boolean;
flagged?: boolean;
answered?: boolean;
draft?: boolean;
};
body?: MessagePartInterface;
attachments?: Array<{
partId?: string;
blobId?: string;
size?: number;
name?: string;
type?: string;
charset?: string | null;
disposition?: string;
cid?: string | null;
language?: string | null;
location?: string | null;
}>;
}

View File

@@ -23,11 +23,14 @@ export interface ProviderCapabilitiesInterface {
*/ */
export interface ProviderInterface { export interface ProviderInterface {
'@type': string; '@type': string;
version: number;
identifier: string; identifier: string;
label: string; label: string;
capabilities: ProviderCapabilitiesInterface; capabilities: ProviderCapabilitiesInterface;
} }
export interface ProviderModelInterface extends Omit<ProviderInterface, '@type' | 'version'> {}
/** /**
* Provider list * Provider list
*/ */

View File

@@ -1,6 +1,8 @@
/** /**
* Service type definitions * Service type definitions
*/ */
import type { Identity } from '@/models/identity';
import type { Location } from '@/models/location';
import type { import type {
ListFilterComparisonOperator, ListFilterComparisonOperator,
SourceSelector, SourceSelector,
@@ -43,6 +45,7 @@ export interface ServiceCapabilitiesInterface {
*/ */
export interface ServiceInterface { export interface ServiceInterface {
'@type': string; '@type': string;
version: number;
provider: string; provider: string;
identifier: string | number | null; identifier: string | number | null;
label: string | null; label: string | null;
@@ -55,6 +58,13 @@ export interface ServiceInterface {
auxiliary?: Record<string, any>; // Provider-specific extension data auxiliary?: Record<string, any>; // Provider-specific extension data
} }
export interface ServiceModelInterface extends Omit<{
[K in keyof ServiceInterface]-?: Exclude<ServiceInterface[K], undefined>;
}, '@type' | 'version' | 'location' | 'identity'> {
location: Location | null;
identity: Identity | null;
}
/** /**
* Service list * Service list
*/ */
@@ -137,6 +147,18 @@ export interface ServiceDiscoverResponse {
location: ServiceLocation; location: ServiceLocation;
} }
export interface ProviderDiscoveryStatus {
provider: string;
status: 'pending' | 'discovering' | 'success' | 'failed';
location?: ServiceLocation;
metadata?: {
host?: string;
port?: number;
protocol?: string;
};
error?: string;
}
/** /**
* Service connection test * Service connection test
*/ */