From bea7c1d0fbf8f057f5b2145d3cf5213d7748a7d2 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Tue, 16 Jun 2026 13:31:23 -0400 Subject: [PATCH] refactor: service address Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 32 +++------ lib/Manager.php | 32 ++++++++- src/components/AddAccountDialog.vue | 17 +++-- src/components/steps/TestAndSavePanel.vue | 4 +- src/models/address.ts | 83 +++++++++++++++++++++++ src/models/index.ts | 2 + src/models/service.ts | 17 ++--- src/pages/AccountsPage.vue | 2 +- src/stores/servicesStore.ts | 5 +- src/types/service.ts | 18 ++++- 10 files changed, 168 insertions(+), 44 deletions(-) create mode 100644 src/models/address.ts diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 94ee34f..a378694 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -50,8 +50,6 @@ class DefaultController extends ControllerAbstract { private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; - private const STREAM_FLUSH_INTERVAL = 1; - public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, @@ -162,7 +160,7 @@ class DefaultController extends ControllerAbstract { 'entity.patch' => $this->entityPatch($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), - 'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data), + 'entity.submit' => $this->entitySubmit($tenantId, $userId, $data), 'entity.download' => $this->entityDownload($tenantId, $userId, $data), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) @@ -839,29 +837,21 @@ class DefaultController extends ControllerAbstract { return $this->mailManager->entityMove($tenantId, $userId, $target, ...$sources->all()); } - private function entityTransmit(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + private function entitySubmit(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sender'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SENDER); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_string($data['sender'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SENDER); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - - $jobId = $this->mailManager->entityTransmit( + + return $this->mailManager->entitySubmit( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['data'] + $data['sender'], + $data['source'] ?? null, + $data['message'] ?? null, ); - - return ['jobId' => $jobId]; } private function entityDownload(string $tenantId, string $userId, array $data): mixed { diff --git a/lib/Manager.php b/lib/Manager.php index d14b094..c8c1010 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -10,6 +10,8 @@ use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionPropertiesMutableInterface; use KTXF\Mail\Collection\ICollectionBase; use KTXF\Mail\Entity\IMessageBase; +use KTXF\Mail\Object\Address; +use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; @@ -19,11 +21,14 @@ use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Resource\BinaryResource; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; @@ -796,7 +801,7 @@ class Manager { } // retrieve entities for each collection foreach ($collectionSelected as $collectionId) { - $entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null); + $entities = $service->entityListBulk($collectionId, $entityFilter, $entitySort, $entityRange, null); // skip collections with no entities if ($entities === []) { continue; @@ -905,7 +910,7 @@ class Manager { // group identifiers by provider/service $groupedIdentifiers = []; foreach ($identifiers as $identifier) { - $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier->entity(); + $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier; } // retrieve each service and fetch entities $list = []; @@ -1302,4 +1307,27 @@ class Manager { return $operationOutcome; } + public function entitySubmit(string $tenantId, string $userId, AddressInterface|string $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|array|null $message = null): EntitySubmitResult { + $service = $this->serviceFindByAddress($tenantId, $userId, $sender); + if ($service === null || $service->getEnabled() === false) { + throw new InvalidArgumentException("Service not found for sender '{$sender}' or service is disabled"); + } + if ($service instanceof ServiceEntitySubmitInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity submission"); + } + + if ($source === null && $message === null) { + throw new InvalidArgumentException("At least one of source or message must be provided for entity submission"); + } + + if ($sender instanceof AddressInterface === false) { + $sender = new Address($sender); + } + if ($message !== null && $message instanceof MessagePropertiesMutableInterface === false) { + $message = $service->entityFresh()->getProperties()->jsonDeserialize($message); + } + + return $service->entitySubmit($sender, $source, $message); + } + } diff --git a/src/components/AddAccountDialog.vue b/src/components/AddAccountDialog.vue index de55080..b922cbc 100644 --- a/src/components/AddAccountDialog.vue +++ b/src/components/AddAccountDialog.vue @@ -4,7 +4,7 @@ import { useIntegrationStore } from '@KTXC/stores/integrationStore' import { useServicesStore } from '@MailManager/stores/servicesStore' import { useProvidersStore } from '@MailManager/stores/providersStore' import { ServiceObject, type ProviderObject } from '@MailManager/models' -import type { ProviderDiscoveryStatus, ServiceInterface, ServiceLocation } from '@MailManager/types' +import type { ProviderDiscoveryStatus, ServiceAddressInterface, ServiceInterface, ServiceLocation } from '@MailManager/types' import DiscoveryEntryPanel from '@MailManager/components/steps/DiscoveryEntryPanel.vue' import DiscoveryStatusPanel from '@MailManager/components/steps/DiscoveryStatusPanel.vue' import ProviderSelectionPanel from '@MailManager/components/steps/ProviderSelectionPanel.vue' @@ -57,6 +57,11 @@ const discoverAddress = ref('') const discoverSecret = ref(null) const discoverHostname = ref(null) +// Address entered during discovery, as a service address object +const discoverServiceAddress = computed(() => + discoverAddress.value ? { address: discoverAddress.value } : null +) + // Step 2: Discovery Status / Provider Selection const selectedProvider = shallowRef(null) const selectedService = shallowRef(null) @@ -154,7 +159,7 @@ function createServiceObject( identifier: null, label: data.label ?? null, enabled: data.enabled ?? true, - primaryAddress: data.primaryAddress ?? (discoverAddress.value || null), + primaryAddress: data.primaryAddress ?? discoverServiceAddress.value, secondaryAddresses: data.secondaryAddresses ?? null, location: data.location ?? null, identity: data.identity ?? null, @@ -300,7 +305,7 @@ async function handleProviderSelect(identifier: string) { ...discoveredJson, label: discoveredJson.label || discoverAddress.value, enabled: discoveredJson.enabled ?? true, - primaryAddress: discoveredJson.primaryAddress || discoverAddress.value, + primaryAddress: discoveredJson.primaryAddress ?? discoverServiceAddress.value, location: discoveredJson.location }) setSelectedProviderAndService(identifier, service) @@ -317,7 +322,7 @@ function handleProviderAdvanced(identifier: string) { ...discoveredJson, label: discoveredJson?.label || discoverAddress.value, enabled: discoveredJson?.enabled ?? true, - primaryAddress: discoveredJson?.primaryAddress || discoverAddress.value, + primaryAddress: discoveredJson?.primaryAddress ?? discoverServiceAddress.value, location: discoveredJson?.location ?? null }) @@ -341,7 +346,7 @@ function handleProviderManualSelect(identifier: string) { const service = createServiceObject(identifier, { label: discoverAddress.value, enabled: true, - primaryAddress: discoverAddress.value, + primaryAddress: discoverServiceAddress.value, location: null, identity: null }) @@ -396,7 +401,7 @@ async function saveAccount() { try { const accountData = { label: serviceData.label || discoverAddress.value, - primaryAddress: serviceData.primaryAddress || discoverAddress.value, + primaryAddress: serviceData.primaryAddress ?? discoverServiceAddress.value, enabled: serviceData.enabled, location: serviceData.location, identity: serviceData.identity, diff --git a/src/components/steps/TestAndSavePanel.vue b/src/components/steps/TestAndSavePanel.vue index 25191f3..8c810b7 100644 --- a/src/components/steps/TestAndSavePanel.vue +++ b/src/components/steps/TestAndSavePanel.vue @@ -134,7 +134,7 @@ watch( mdi-label Account Name - {{ localService.label || localService.primaryAddress || 'New Account' }} + {{ localService.label || localService.primaryAddress?.address || 'New Account' }} @@ -143,7 +143,7 @@ watch( mdi-email Email Address - {{ localService.primaryAddress }} + {{ localService.primaryAddress?.address }} diff --git a/src/models/address.ts b/src/models/address.ts new file mode 100644 index 0000000..1e308fe --- /dev/null +++ b/src/models/address.ts @@ -0,0 +1,83 @@ +/** + * Address implementation class for Mail Manager services + * + * Plain value object (no mutation tracking). ServiceObject getters return + * fresh instances built from raw data; mutating a getter result does not + * write back — assign through the setter to persist changes. + */ + +import type { ServiceAddressInterface } from '@/types/service'; + +export class ServiceAddressObject implements ServiceAddressInterface { + + _address: string; + _label: string | null; + + constructor(address: string = '', label: string | null = null) { + this._address = address; + this._label = label; + } + + static fromJson(data: ServiceAddressInterface): ServiceAddressObject { + return new ServiceAddressObject(data.address, data.label ?? null); + } + + toJson(): ServiceAddressInterface { + const label = this._label?.trim() ?? ''; + return { + address: this._address.trim(), + label: label.length > 0 ? label : null, + }; + } + + toJSON(): ServiceAddressInterface { + return this.toJson(); + } + + clone(): ServiceAddressObject { + return new ServiceAddressObject(this._address, this._label); + } + + /** Case-insensitive address comparison, ignoring labels */ + matches(address: string): boolean { + return this._address.trim().toLowerCase() === address.trim().toLowerCase(); + } + + equals(other: ServiceAddressObject | null | undefined): boolean { + if (!other) { + return false; + } + const current = this.toJson(); + const next = other.toJson(); + return current.address === next.address && (current.label ?? null) === (next.label ?? null); + } + + /** Display form: "Label
" or bare address */ + format(): string { + const { address, label } = this.toJson(); + return label ? `${label} <${address}>` : address; + } + + get empty(): boolean { + return this._address.trim().length === 0; + } + + /** Properties (raw values for editing; normalization happens in toJson) */ + + get address(): string { + return this._address; + } + + set address(value: string) { + this._address = value; + } + + get label(): string | null { + return this._label; + } + + set label(value: string | null) { + this._label = value; + } + +} diff --git a/src/models/index.ts b/src/models/index.ts index 280cc54..4abd0e9 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,6 @@ export { ProviderObject } from './provider'; export { ServiceObject } from './service'; +export { ServiceAddressObject } from './address'; export { CollectionObject, CollectionPropertiesObject @@ -7,6 +8,7 @@ export { export { EntityObject } from './entity'; export { MessageObject, + MessageAddressObject, MessagePartObject } from './message'; export { diff --git a/src/models/service.ts b/src/models/service.ts index ab09d57..c3b253f 100644 --- a/src/models/service.ts +++ b/src/models/service.ts @@ -8,6 +8,7 @@ import type { ServiceLocation, ServiceModelInterface } from "@/types/service"; +import { ServiceAddressObject } from './address'; import { Identity } from './identity'; import { Location } from './location'; import { MutationProxy } from './mutation-proxy'; @@ -118,20 +119,20 @@ export class ServiceObject implements ServiceModelInterface { return this._data.capabilities ?? {}; } - get primaryAddress(): string | null { - return this._data.primaryAddress ?? null; + get primaryAddress(): ServiceAddressObject | null { + return this._data.primaryAddress ? ServiceAddressObject.fromJson(this._data.primaryAddress) : null; } - set primaryAddress(value: string | null) { - this._data.primaryAddress = value; + set primaryAddress(value: ServiceAddressObject | null) { + this._data.primaryAddress = value ? value.toJson() : null; } - get secondaryAddresses(): string[] { - return this._data.secondaryAddresses ?? []; + get secondaryAddresses(): ServiceAddressObject[] { + return (this._data.secondaryAddresses ?? []).map(entry => ServiceAddressObject.fromJson(entry)); } - set secondaryAddresses(value: string[] | null) { - this._data.secondaryAddresses = value; + set secondaryAddresses(value: ServiceAddressObject[] | null) { + this._data.secondaryAddresses = value ? value.map(entry => entry.toJson()) : null; } /** Mutable Properties */ diff --git a/src/pages/AccountsPage.vue b/src/pages/AccountsPage.vue index 6043c39..3487302 100644 --- a/src/pages/AccountsPage.vue +++ b/src/pages/AccountsPage.vue @@ -212,7 +212,7 @@ async function handleAccountSaved() {

{{ service.label }}

- {{ service.primaryAddress || (service.identity?.type === 'BA' ? service.identity.identity : 'No email configured') }} + {{ service.primaryAddress?.address || (service.identity?.type === 'BA' ? service.identity.identity : 'No email configured') }}

diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index a46cd2f..e6f5696 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -101,7 +101,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * @returns Service object or null */ function serviceForAddress(address: string, retrieve: boolean = false): ServiceObject | null { - const service = Object.values(_services.value).find(s => s.primaryAddress === address || s.secondaryAddresses?.includes(address)) + const service = Object.values(_services.value).find(s => + s.primaryAddress?.matches(address) || + s.secondaryAddresses.some(candidate => candidate.matches(address)), + ) if (retrieve === true && !service) { console.debug(`[Mail Manager][Store] - No service found for address "${address}", discovery may be needed`) diff --git a/src/types/service.ts b/src/types/service.ts index c9d1583..0efb035 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,6 +1,7 @@ /** * Service type definitions */ +import type { ServiceAddressObject } from '@/models/address'; import type { Identity } from '@/models/identity'; import type { Location } from '@/models/location'; import type { @@ -40,6 +41,15 @@ export interface ServiceCapabilitiesInterface { [key: string]: boolean | object | string | string[] | undefined; } +/** + * Service sender address (primary or secondary/alias) + * Mirrors the shared Mail AddressInterface JSON shape + */ +export interface ServiceAddressInterface { + address: string; + label?: string | null; +} + /** * Service information */ @@ -53,16 +63,18 @@ export interface ServiceInterface { capabilities?: ServiceCapabilitiesInterface; location?: ServiceLocation | null; identity?: ServiceIdentity | null; - primaryAddress?: string | null; - secondaryAddresses?: string[] | null; + primaryAddress?: ServiceAddressInterface | null; + secondaryAddresses?: ServiceAddressInterface[] | null; auxiliary?: Record; // Provider-specific extension data } export interface ServiceModelInterface extends Omit<{ [K in keyof ServiceInterface]-?: Exclude; -}, '@type' | 'version' | 'location' | 'identity'> { +}, '@type' | 'version' | 'location' | 'identity' | 'primaryAddress' | 'secondaryAddresses'> { location: Location | null; identity: Identity | null; + primaryAddress: ServiceAddressObject | null; + secondaryAddresses: ServiceAddressObject[]; } /**