From c736e3ebf9c1a7488cffd5ec90559d114cde5e15 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sun, 19 Jul 2026 06:55:01 -0400 Subject: [PATCH] feat: create system user accounts Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 19 +++++++++ lib/Manager.php | 20 ++++++--- lib/Module.php | 5 +++ src/components/AddAccountDialog.vue | 10 +++-- src/components/EditAccountDialog.vue | 17 ++++++-- src/services/serviceService.ts | 34 +++++++-------- src/stores/servicesStore.ts | 59 ++++++++++++++++----------- 7 files changed, 112 insertions(+), 52 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 0371e24..2447665 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -23,6 +23,7 @@ use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ServiceIdentifier; +use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\MailManager\Manager; @@ -82,6 +83,24 @@ class DefaultController extends ControllerAbstract { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); + // acting-user override: only the reserved system context is permitted, + // gated on the system mail management permission + if ($user !== null && $user !== $userId) { + if ($user !== ProviderBaseInterface::USER_SYSTEM || !$this->userIdentity->hasPermission('mail_manager.system')) { + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => [ + 'code' => JsonResponse::HTTP_FORBIDDEN, + 'message' => 'Not permitted to act as user: ' . $user + ] + ], JsonResponse::HTTP_FORBIDDEN); + } + $userId = $user; + } + try { if ($operation !== null) { diff --git a/lib/Manager.php b/lib/Manager.php index bee924b..bf1a3f2 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -236,7 +236,14 @@ class Manager { $serviceId = $provider->serviceCreate($tenantId, $userId, $service); // Fetch and return the created service - return $provider->serviceFetch($tenantId, $userId, $serviceId); + $createdService = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($createdService === null) { + throw new \RuntimeException( + "Provider '$providerId' created service '$serviceId', but it could not be fetched" + ); + } + + return $createdService; } /** @@ -1264,9 +1271,13 @@ class Manager { } 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 ($sender instanceof AddressInterface === false) { + $sender = new Address($sender); + } + + $service = $this->serviceFindByAddress($tenantId, $userId, $sender->getAddress()); if ($service === null || $service->getEnabled() === false) { - throw new InvalidArgumentException("Service not found for sender '{$sender}' or service is disabled"); + throw new InvalidArgumentException("Service not found for sender '{$sender->getAddress()}' or service is disabled"); } if ($service instanceof ServiceEntitySubmitInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity submission"); @@ -1276,9 +1287,6 @@ class Manager { 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); } diff --git a/lib/Module.php b/lib/Module.php index da9e400..a6923df 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -50,6 +50,11 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface 'description' => 'View and access the mail manager module', 'group' => 'Mail Management' ], + 'mail_manager.system' => [ + 'label' => 'Manage System Mail', + 'description' => 'Manage system mail accounts and routing rules (act in the reserved system user context)', + 'group' => 'Mail Management' + ], ]; } diff --git a/src/components/AddAccountDialog.vue b/src/components/AddAccountDialog.vue index b922cbc..c991467 100644 --- a/src/components/AddAccountDialog.vue +++ b/src/components/AddAccountDialog.vue @@ -32,6 +32,7 @@ const MANUAL_STEPS = { const props = defineProps<{ modelValue: boolean + user?: string }>() const emit = defineEmits<{ @@ -242,7 +243,8 @@ async function handleDiscover() { discoverSecret.value || undefined, discoverHostname.value || undefined, identifier, - (service) => { discoveredService = service } + (service) => { discoveredService = service }, + props.user ) // Success - check if we got results for this provider @@ -384,7 +386,8 @@ async function testConnection() { selectedProvider.value.identifier, null, selectedService.value.location, - selectedService.value.identity + selectedService.value.identity, + props.user ) return testResult @@ -410,7 +413,8 @@ async function saveAccount() { await servicesStore.create( selectedProvider.value.identifier, - accountData + accountData, + props.user ) emit('saved') diff --git a/src/components/EditAccountDialog.vue b/src/components/EditAccountDialog.vue index 9951f44..6e4b925 100644 --- a/src/components/EditAccountDialog.vue +++ b/src/components/EditAccountDialog.vue @@ -14,6 +14,7 @@ const props = defineProps<{ modelValue: boolean serviceProvider: string serviceIdentifier: string | number + user?: string }>() const emit = defineEmits<{ @@ -103,7 +104,10 @@ async function load() { try { const [provider, service] = await Promise.all([ providersStore.provider(props.serviceProvider) ?? providersStore.fetch(props.serviceProvider), - servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier) + // acting-user context always fetches fresh, bypassing the shared cache + props.user + ? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier, props.user) + : servicesStore.service(props.serviceProvider, props.serviceIdentifier) ?? servicesStore.fetch(props.serviceProvider, props.serviceIdentifier) ]) localProvider.value = provider.clone() @@ -161,12 +165,16 @@ async function testConnection() { localService.value.provider, null, localService.value.location, - localService.value.identity + localService.value.identity, + props.user ) } else { testResult = await servicesStore.test( localService.value.provider, - localService.value.identifier + localService.value.identifier, + undefined, + undefined, + props.user ) } @@ -199,7 +207,8 @@ async function saveAccount() { localService.value.provider, localService.value.identifier as string | number, true, // delta update - localService.value + localService.value, + props.user ) emit('saved') diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts index bd01de1..f6d6a58 100644 --- a/src/services/serviceService.ts +++ b/src/services/serviceService.ts @@ -47,8 +47,8 @@ export const serviceService = { * * @returns Promise with service object list grouped by provider and keyed by service identifier */ - async list(request: ServiceListRequest = {}): Promise>> { - const response = await transceivePost('service.list', request); + async list(request: ServiceListRequest = {}, user?: string): Promise>> { + const response = await transceivePost('service.list', request, user); // Convert nested response to ServiceObject instances const providerList: Record> = {}; @@ -70,8 +70,8 @@ export const serviceService = { * * @returns Promise with service object */ - async fetch(request: ServiceFetchRequest): Promise { - const response = await transceivePost('service.fetch', request); + async fetch(request: ServiceFetchRequest, user?: string): Promise { + const response = await transceivePost('service.fetch', request, user); return createServiceObject(response); }, @@ -82,8 +82,8 @@ export const serviceService = { * * @returns Promise with service availability status */ - async extant(request: ServiceExtantRequest): Promise { - return await transceivePost('service.extant', request); + async extant(request: ServiceExtantRequest, user?: string): Promise { + return await transceivePost('service.extant', request, user); }, /** @@ -96,7 +96,8 @@ export const serviceService = { */ async discover( request: ServiceDiscoverRequest, - onService: (service: ServiceObject) => void + onService: (service: ServiceObject) => void, + user?: string ): Promise<{ total: number }> { return await transceiveStream( 'service.discover', @@ -111,7 +112,8 @@ export const serviceService = { location: service.location, }; onService(createServiceObject(serviceData)); - } + }, + user ); }, @@ -121,8 +123,8 @@ export const serviceService = { * @param request - Service test request * @returns Promise with test results */ - async test(request: ServiceTestRequest): Promise { - return await transceivePost('service.test', request); + async test(request: ServiceTestRequest, user?: string): Promise { + return await transceivePost('service.test', request, user); }, /** @@ -132,8 +134,8 @@ export const serviceService = { * * @returns Promise with created service object */ - async create(request: ServiceCreateRequest): Promise { - const response = await transceivePost('service.create', request); + async create(request: ServiceCreateRequest, user?: string): Promise { + const response = await transceivePost('service.create', request, user); return createServiceObject(response); }, @@ -144,8 +146,8 @@ export const serviceService = { * * @returns Promise with updated service object */ - async update(request: ServiceUpdateRequest): Promise { - const response = await transceivePost('service.update', request); + async update(request: ServiceUpdateRequest, user?: string): Promise { + const response = await transceivePost('service.update', request, user); return createServiceObject(response); }, @@ -156,8 +158,8 @@ export const serviceService = { * * @returns Promise with deletion result */ - async delete(request: { provider: string; identifier: string | number }): Promise { - return await transceivePost('service.delete', request); + async delete(request: { provider: string; identifier: string | number }, user?: string): Promise { + return await transceivePost('service.delete', request, user); }, }; diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index a5a28f3..35049f3 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -131,10 +131,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with service object list keyed by provider and service identifier */ - async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[]): Promise> { + async function list(targets?: ServiceIdentifier[] | CollectionIdentifier[], user?: string): Promise> { transceiving.value = true try { - const response = await serviceService.list({ targets }) + const response = await serviceService.list({ targets }, user) // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object const services: Record = {} @@ -145,8 +145,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => { }) }) - // Merge retrieved services into state - _services.value = { ..._services.value, ...services } + // Merge retrieved services into state (acting-user context stays out of the shared cache) + if (!user) { + _services.value = { ..._services.value, ...services } + } console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services') return services @@ -166,14 +168,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with service object */ - async function fetch(provider: string, identifier: string | number): Promise { + async function fetch(provider: string, identifier: string | number, user?: string): Promise { transceiving.value = true try { - const service = await serviceService.fetch({ provider, identifier }) + const service = await serviceService.fetch({ provider, identifier }, user) - // Merge fetched service into state + // Merge fetched service into state (acting-user context stays out of the shared cache) const key = identifierKey(service.provider, service.identifier) - _services.value[key] = service + if (!user) { + _services.value[key] = service + } console.debug('[Mail Manager][Store] - Successfully fetched service:', key) return service @@ -192,10 +196,10 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with service availability status */ - async function extant(targets: ServiceIdentifier[]) { + async function extant(targets: ServiceIdentifier[], user?: string) { transceiving.value = true try { - const response = await serviceService.extant({ targets }) + const response = await serviceService.extant({ targets }, user) console.debug('[Mail Manager][Store] - Successfully checked', targets?.length ?? 0, 'services') return response @@ -215,14 +219,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with created service object */ - async function create(provider: string, data: Partial): Promise { + async function create(provider: string, data: Partial, user?: string): Promise { transceiving.value = true try { - const service = await serviceService.create({ provider, data }) + const service = await serviceService.create({ provider, data }, user) - // Merge created service into state + // Merge created service into state (acting-user context stays out of the shared cache) const key = identifierKey(service.provider, service.identifier) - _services.value[key] = service + if (!user) { + _services.value[key] = service + } console.debug('[Mail Manager][Store] - Successfully created service:', key) return service @@ -244,7 +250,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with updated service object */ - async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial): Promise { + async function update(provider: string, identifier: string | number, delta: boolean, data: ServiceObject | Partial, user?: string): Promise { transceiving.value = true try { // convert ServiceObject to JSON if needed @@ -255,11 +261,13 @@ export const useServicesStore = defineStore('mailServicesStore', () => { payload = data } - const service = await serviceService.update({ provider, identifier, delta, data: payload }) + const service = await serviceService.update({ provider, identifier, delta, data: payload }, user) - // Merge updated service into state + // Merge updated service into state (acting-user context stays out of the shared cache) const key = identifierKey(service.provider, service.identifier) - _services.value[key] = service + if (!user) { + _services.value[key] = service + } console.debug('[Mail Manager][Store] - Successfully updated service:', key) return service @@ -279,14 +287,16 @@ export const useServicesStore = defineStore('mailServicesStore', () => { * * @returns Promise with deletion result */ - async function remove(provider: string, identifier: string | number): Promise { + async function remove(provider: string, identifier: string | number, user?: string): Promise { transceiving.value = true try { - await serviceService.delete({ provider, identifier }) + await serviceService.delete({ provider, identifier }, user) // Remove deleted service from state const key = identifierKey(provider, identifier) - delete _services.value[key] + if (!user) { + delete _services.value[key] + } console.debug('[Mail Manager][Store] - Successfully deleted service:', key) } catch (error: any) { @@ -314,6 +324,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => { location: string | undefined, provider: string | undefined, onService?: (service: ServiceObject) => void, + user?: string, ): Promise<{ total: number }> { transceiving.value = true @@ -322,7 +333,8 @@ export const useServicesStore = defineStore('mailServicesStore', () => { { identity, secret, location, provider }, (service: ServiceObject) => { onService?.(service) - } + }, + user ) console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services') @@ -350,6 +362,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => { identifier?: string | number | null, location?: ServiceLocation | Location | null, identity?: ServiceIdentity | Identity | null, + user?: string, ): Promise { transceiving.value = true try { @@ -372,7 +385,7 @@ export const useServicesStore = defineStore('mailServicesStore', () => { identity = identity.toJson() } - const response = await serviceService.test({ provider, identifier, location, identity }) + const response = await serviceService.test({ provider, identifier, location, identity }, user) console.debug('[Mail Manager][Store] - Successfully tested service:', provider, identifier || location) return response