From 7485e4c89741e3ee22296bd0ac23849718221cbc Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 25 Apr 2026 15:43:56 -0400 Subject: [PATCH] feat: lots more improvements Signed-off-by: Sebastian Krupinski --- lib/Providers/Provider.php | 52 +++-- lib/Providers/Service.php | 2 +- lib/Stores/ServiceStore.php | 92 ++++----- src/components/ImapAuthPanel.vue | 40 ++-- src/components/ImapAuxiliaryPanel.vue | 268 ++++++++++++++++++++++++++ src/components/ImapProtocolPanel.vue | 39 ++-- src/integrations.ts | 23 ++- src/main.ts | 4 +- vite.config.ts | 8 + 9 files changed, 403 insertions(+), 125 deletions(-) create mode 100644 src/components/ImapAuxiliaryPanel.vue diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index fafbd5b..bd751f1 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -22,14 +22,11 @@ use KTXM\ProviderImap\Stores\ServiceStore; /** * IMAP Mail Provider - * - * Registers IMAP as a mail provider and handles service lifecycle: - * list / fetch / create / modify / destroy / discover / test. */ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface { - public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; + public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; protected const PROVIDER_IDENTIFIER = 'imap'; protected const PROVIDER_LABEL = 'IMAP Mail Provider'; protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol'; @@ -49,8 +46,6 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove private readonly ServiceStore $serviceStore, ) {} - // ── ProviderBaseInterface ───────────────────────────────────────────────── - public function jsonSerialize(): array { return [ @@ -101,23 +96,24 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove return $this->providerAbilities; } - // ── ProviderServiceMutateInterface ──────────────────────────────────────── - public function serviceList(string $tenantId, string $userId, array $filter = []): array { $list = $this->serviceStore->list($tenantId, $userId, $filter); - $result = []; - foreach ($list as $entry) { - $service = new Service(); - $service->fromStore($entry); - $result[$service->identifier()] = $service; + foreach ($list as $serviceData) { + $serviceInstance = $this->serviceFresh()->fromStore($serviceData); + $list[$serviceInstance->identifier()] = $serviceInstance; } - return $result; + return $list; } public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service { - return $this->serviceStore->fetch($tenantId, $userId, $identifier); + $serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier); + if ($serviceData === null) { + return null; + } + $serviceInstance = $this->serviceFresh()->fromStore($serviceData); + return $serviceInstance; } public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service @@ -137,7 +133,7 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove return $this->serviceStore->extant($tenantId, $userId, $identifiers); } - public function serviceFresh(): ResourceServiceMutateInterface + public function serviceFresh(): Service { return new Service(); } @@ -145,21 +141,21 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { - throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); + throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } $created = $this->serviceStore->create($tenantId, $userId, $service); - return (string) $created->identifier(); + return (string) $created['sid']; } public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { - throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); + throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } $updated = $this->serviceStore->modify($tenantId, $userId, $service); - return (string) $updated->identifier(); + return (string) $updated['sid']; } public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool @@ -171,23 +167,19 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); } - // ── ProviderServiceDiscoverInterface ────────────────────────────────────── - public function serviceDiscover( - string $tenantId, - string $userId, - string $identity, + string $tenantId, + string $userId, + string $identity, ?string $location = null, - ?string $secret = null, - ): ?ResourceServiceLocationInterface { + ?string $secret = null + ): ResourceServiceLocationInterface|null { $discovery = new Discovery(); - // TODO: Make SSL verification configurable per-tenant $verifySSL = true; + return $discovery->discover($identity, $location, $secret, $verifySSL); } - // ── ProviderServiceTestInterface ────────────────────────────────────────── - public function serviceTest(ServiceBaseInterface $service, array $options = []): array { $startTime = microtime(true); diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index 8ec2415..fa1c7c9 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -168,7 +168,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC ], fn($v) => $v !== null); } - public function jsonDeserialize(array|string $data): static + public function jsonDeserialize(array|string $data, bool $delta = false): static { if (is_string($data)) { $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); diff --git a/lib/Stores/ServiceStore.php b/lib/Stores/ServiceStore.php index 88cee7d..7c75027 100644 --- a/lib/Stores/ServiceStore.php +++ b/lib/Stores/ServiceStore.php @@ -26,45 +26,39 @@ class ServiceStore public function __construct( protected readonly DataStore $dataStore, - protected readonly Crypto $crypto, + protected readonly Crypto $crypto, ) {} - // ── List ───────────────────────────────────────────────────────────────── - /** - * List services for a tenant+user, optionally filtered to specific IDs. - * - * @param string[]|null $filter Service IDs to restrict results to - * @return array Keyed by service ID + * List services for a tenant and user, optionally filtered by service IDs */ public function list(string $tenantId, string $userId, ?array $filter = null): array { - $condition = ['tid' => $tenantId, 'uid' => $userId]; + $filterCondition = [ + 'tid' => $tenantId, + 'uid' => $userId, + ]; if ($filter !== null && !empty($filter)) { - $condition['sid'] = ['$in' => $filter]; + $filterCondition['sid'] = ['$in' => $filter]; } - $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($condition); + $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition); $list = []; foreach ($cursor as $entry) { + if (isset($entry['identity']['secret'])) { $entry['identity']['secret'] = $this->crypto->decrypt($entry['identity']['secret']); } + $list[$entry['sid']] = $entry; } - return $list; } - // ── Extant ─────────────────────────────────────────────────────────────── - /** - * Check which of the supplied service IDs exist for the given tenant/user. - * - * @param string[]|int[] $identifiers - * @return array + * Check existence of services by IDs for a tenant and user */ public function extant(string $tenantId, string $userId, array $identifiers): array { @@ -76,27 +70,29 @@ class ServiceStore [ 'tid' => $tenantId, 'uid' => $userId, - 'sid' => ['$in' => array_map('strval', $identifiers)], + 'sid' => ['$in' => array_map('strval', $identifiers)] ], - ['projection' => ['sid' => 1]], + ['projection' => ['sid' => 1]] ); - $existing = []; - foreach ($cursor as $doc) { - $existing[] = $doc['sid']; + $existingIds = []; + foreach ($cursor as $document) { + $existingIds[] = $document['sid']; } + // Build result map: all identifiers default to false, existing ones set to true $result = []; foreach ($identifiers as $id) { - $result[(string)$id] = in_array((string)$id, $existing, true); + $result[(string) $id] = in_array((string) $id, $existingIds, true); } return $result; } - // ── Fetch ──────────────────────────────────────────────────────────────── - - public function fetch(string $tenantId, string $userId, string|int $serviceId): ?Service + /** + * Retrieve a single service by ID + */ + public function fetch(string $tenantId, string $userId, string|int $serviceId): ?array { $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([ 'tid' => $tenantId, @@ -112,57 +108,64 @@ class ServiceStore $document['identity']['secret'] = $this->crypto->decrypt($document['identity']['secret']); } - return (new Service())->fromStore($document); + return $document; } - // ── Create ─────────────────────────────────────────────────────────────── - - public function create(string $tenantId, string $userId, Service $service): Service + /** + * Create a new service + */ + public function create(string $tenantId, string $userId, Service $service): array { $document = $service->toStore(); + + // prepare document for insertion $document['tid'] = $tenantId; $document['uid'] = $userId; $document['sid'] = UUID::v4(); - $document['createdOn'] = new \MongoDB\BSON\UTCDateTime(); + $document['createdOn'] = new \MongoDB\BSON\UTCDateTime(); $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); - if (isset($document['identity']['secret'])) { $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); } - $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document); - return (new Service())->fromStore($document); + return $document; } - // ── Modify ─────────────────────────────────────────────────────────────── - - public function modify(string $tenantId, string $userId, Service $service): Service + /** + * Modify an existing service + */ + public function modify(string $tenantId, string $userId, Service $service): array { $serviceId = $service->identifier(); if (empty($serviceId)) { throw new \InvalidArgumentException('Service ID is required for update'); } + // prepare document for modification $document = $service->toStore(); $document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime(); - if (isset($document['identity']['secret'])) { $document['identity']['secret'] = $this->crypto->encrypt($document['identity']['secret']); } - unset($document['sid'], $document['tid'], $document['uid'], $document['createdOn']); $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( - ['tid' => $tenantId, 'uid' => $userId, 'sid' => (string)$serviceId], - ['$set' => $document], + [ + 'tid' => $tenantId, + 'uid' => $userId, + 'sid' => (string)$serviceId, + ], + ['$set' => $document] ); - return (new Service())->fromStore($document); + return $document; } - // ── Delete ─────────────────────────────────────────────────────────────── - + /** + * Delete a service + */ public function delete(string $tenantId, string $userId, string|int $serviceId): bool { $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ @@ -173,4 +176,5 @@ class ServiceStore return $result->getDeletedCount() > 0; } + } diff --git a/src/components/ImapAuthPanel.vue b/src/components/ImapAuthPanel.vue index 1429ead..28670f2 100644 --- a/src/components/ImapAuthPanel.vue +++ b/src/components/ImapAuthPanel.vue @@ -2,7 +2,7 @@ import { computed, ref, watch } from 'vue' import { IdentityBasic } from '@KTXM/MailManager/models/identity' import { ServiceObject } from '@KTXM/MailManager/models/service' -import type { ServiceIdentity } from '@KTXM/MailManager/types/service' +import type { ServiceIdentity, ServiceIdentityBasic } from '@KTXM/MailManager/types/service' import type { ProviderAuthPanelProps, ProviderAuthPanelEmits } from '@KTXM/MailManager/types/integration' const props = defineProps() @@ -15,7 +15,7 @@ const rules = { required: (value: unknown) => !!value || 'This field is required' } -const currentIdentity = computed((): ServiceIdentity | null => { +const currentIdentity = computed((): ServiceIdentityBasic | null => { if (!identity.value || !secret.value) { return null } @@ -53,8 +53,21 @@ watch( return } - const nextService = createServiceObject(props.service) - nextService.identity = value ? new IdentityBasic(value.identity, value.secret) : null + const nextService = props.service ?? new ServiceObject() + + if (value === null) { + nextService.identity = null + emit('update:service', nextService) + return + } + + if (nextService.identity instanceof IdentityBasic) { + nextService.identity.identity = value.identity + nextService.identity.secret = value.secret + } else { + nextService.identity = new IdentityBasic(value.identity, value.secret) + } + emit('update:service', nextService) }, { immediate: true, deep: true } @@ -73,16 +86,6 @@ function syncFromService(service?: ServiceObject) { secret.value = props.prefilledSecret || '' } -function createServiceObject(service?: ServiceObject): ServiceObject { - const nextService = new ServiceObject() - - if (service) { - nextService.fromJson(service.toJson()) - } - - return nextService -} - function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boolean { if (a === null || b === null) { return a === b @@ -100,13 +103,8 @@ function sameIdentity(a: ServiceIdentity | null, b: ServiceIdentity | null): boo

Authentication

Provide the username and password your IMAP server expects.

- - -
- Most IMAP servers use your full email address as the username. Use an app password if your mail host requires one. -
+ + Most IMAP servers use your full email address as the username. Use an app password if your mail host requires one. +import { computed, ref, watch } from 'vue' +import { ServiceObject } from '@KTXM/MailManager/models/service' + +type AuxiliaryTab = 'addresses' | 'messages' | 'sync' +type DeleteMode = 'soft' | 'hard' + +const props = defineProps<{ + service?: ServiceObject +}>() + +const emit = defineEmits<{ + 'update:service': [value: ServiceObject] +}>() + +const activeTab = ref('addresses') +const deleteMode = ref('soft') +const deleteDestination = ref('Trash') +const primaryAddress = ref('') +const secondaryAddresses = ref('') + +const settingGroups = [ + { + title: 'Addresses', + value: 'addresses' as const, + icon: 'mdi-at', + description: 'Configure the primary mailbox identity and any sender aliases exposed by this service.' + }, + { + title: 'Messages', + value: 'messages' as const, + icon: 'mdi-email-outline', + description: 'Control how message actions should be translated to operations.' + }, + { + title: 'Sync', + value: 'sync' as const, + icon: 'mdi-sync', + description: 'Reserved for future synchronization settings.' + }, +] + +const deleteModeOptions = [ + { + title: 'Move to another mailbox', + value: 'soft' as const, + subtitle: 'Marks delete as a move operation and keeps the message in a destination mailbox.' + }, + { + title: 'Permanently delete', + value: 'hard' as const, + subtitle: 'Removes the message immediately without moving it first.' + } +] + +const destinationHint = computed(() => { + if (deleteMode.value === 'hard') { + return 'Not used when messages are deleted permanently.' + } + + return 'Mailbox identifier or well-known role target, for example Trash.' +}) + +const secondaryAddressesHint = computed(() => { + return 'Use one address per line. Commas are also accepted.' +}) + +watch( + () => props.service, + service => { + syncFromService(service) + }, + { immediate: true } +) + +watch( + [deleteMode, deleteDestination, primaryAddress, secondaryAddresses], + () => { + const nextService = props.service ?? new ServiceObject() + const nextAuxiliary = { + ...(nextService.auxiliary ?? {}), + deleteMode: deleteMode.value, + deleteDestination: deleteMode.value === 'soft' + ? normalizeDeleteDestination(deleteDestination.value) + : undefined, + } + + if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) { + if (sameAddresses(nextService, primaryAddress.value, secondaryAddresses.value)) { + return + } + } + + nextService.primaryAddress = normalizePrimaryAddress(primaryAddress.value) + nextService.secondaryAddresses = normalizeSecondaryAddresses(secondaryAddresses.value) + nextService.auxiliary = nextAuxiliary + emit('update:service', nextService) + }, + { immediate: true } +) + +function syncFromService(service?: ServiceObject) { + const auxiliary = service?.auxiliary ?? {} + deleteMode.value = auxiliary.deleteMode === 'hard' ? 'hard' : 'soft' + deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0 + ? auxiliary.deleteDestination + : 'Trash' + primaryAddress.value = service?.primaryAddress ?? '' + secondaryAddresses.value = (service?.secondaryAddresses ?? []).join('\n') +} + +function normalizeDeleteDestination(value: string): string { + const trimmedValue = value.trim() + return trimmedValue.length > 0 ? trimmedValue : 'Trash' +} + +function normalizePrimaryAddress(value: string): string | null { + const trimmedValue = value.trim() + return trimmedValue.length > 0 ? trimmedValue : null +} + +function normalizeSecondaryAddresses(value: string): string[] { + return value + .split(/\r?\n|,/) + .map(entry => entry.trim()) + .filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index) +} + +function sameAuxiliary(current: Record, next: Record): boolean { + return (current.deleteMode === 'hard' ? 'hard' : 'soft') === next.deleteMode + && (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined) +} + +function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextSecondaryAddresses: string): boolean { + return (service.primaryAddress ?? null) === normalizePrimaryAddress(nextPrimaryAddress) + && JSON.stringify(service.secondaryAddresses) === JSON.stringify(normalizeSecondaryAddresses(nextSecondaryAddresses)) +} + + + + + \ No newline at end of file diff --git a/src/components/ImapProtocolPanel.vue b/src/components/ImapProtocolPanel.vue index 8c0da5b..3fd7837 100644 --- a/src/components/ImapProtocolPanel.vue +++ b/src/components/ImapProtocolPanel.vue @@ -72,8 +72,24 @@ watch( return } - const nextService = createServiceObject(props.service) - nextService.location = location ? LocationSocketSole.fromJson(location) : null + const nextService = props.service ?? new ServiceObject() + + if (location === null) { + nextService.location = null + emit('update:service', nextService) + return + } + + if (nextService.location instanceof LocationSocketSole) { + nextService.location.host = location.host + nextService.location.port = location.port + nextService.location.encryption = location.encryption + nextService.location.verifyPeer = location.verifyPeer ?? true + nextService.location.verifyHost = location.verifyHost ?? true + } else { + nextService.location = LocationSocketSole.fromJson(location) + } + emit('update:service', nextService) }, { immediate: true, deep: true } @@ -96,16 +112,6 @@ function syncFromLocation(location: ServiceLocation | null) { verifyHost.value = socketLocation?.verifyHost ?? true } -function createServiceObject(service?: ServiceObject): ServiceObject { - const nextService = new ServiceObject() - - if (service) { - nextService.fromJson(service.toJson()) - } - - return nextService -} - function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null { return location?.type === 'SOCKET_SOLE' ? location : null } @@ -201,13 +207,8 @@ function defaultPortFor(nextEncryption: ImapEncryption): number { - - -
- STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available. -
+ + STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available. diff --git a/src/integrations.ts b/src/integrations.ts index f0b641d..e429219 100644 --- a/src/integrations.ts +++ b/src/integrations.ts @@ -3,32 +3,39 @@ import type { ServiceInterface } from '@KTXM/MailManager/types/service' import { ServiceObject } from '@KTXM/MailManager/models/service' const integrations: ModuleIntegrations = { - mail_account_protocol_panels: [ + mail_provider_panels_auxiliary: [ { id: 'imap', - label: 'IMAP', - icon: 'mdi-email', - caption: 'Internet Message Access Protocol', + label: 'IMAP Settings', + component: () => import('@/components/ImapAuxiliaryPanel.vue'), + priority: 20, + } + ], + mail_provider_panels_protocol: [ + { + id: 'imap', + label: 'IMAP Protocol', component: () => import('@/components/ImapProtocolPanel.vue'), priority: 20, } ], - mail_account_auth_panels: [ + mail_provider_panels_auth: [ { id: 'imap', + label: 'IMAP Authentication', component: () => import('@/components/ImapAuthPanel.vue'), } ], - mail_service_factory: [ + mail_provider_factory_service: [ { id: 'imap', factory: (data: ServiceInterface) => new ServiceObject().fromJson(data) } ], - mail_provider_metadata: [ + mail_provider_details: [ { id: 'imap', - label: 'IMAP', + label: 'IMAP Protocol', description: 'Classic mailbox access over IMAP', icon: 'mdi-email', } diff --git a/src/main.ts b/src/main.ts index a99e83a..b07c372 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,6 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__'] export { routes, integrations } export default { - install(_app: VueApp) { - } + install(_app: VueApp) { + } } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 4ec6d24..846f73f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,8 +44,16 @@ export default defineConfig({ 'vue', 'vue-router', 'pinia', + /^@KTXM\/MailManager\//, ], output: { + paths: (id) => { + if (id.startsWith('@KTXM/MailManager/')) { + return '/modules/mail_manager/static/module.mjs' + } + + return id + }, assetFileNames: assetInfo => { if (assetInfo.name?.endsWith('.css')) { return 'provider_imap-[hash].css' -- 2.39.5