collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] */ public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // determine filter from sources $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; // retrieve providers from provider manager return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter); } /** * Confirm which providers are available * * @param SourceSelector|null $sources collection of provider identifiers to confirm * * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] */ public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { // determine which providers are available $providersResolved = $this->providerList($tenantId, $userId, $sources); $providersAvailable = array_keys($providersResolved); $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); // construct response data $responseData = array_merge( array_fill_keys($providersAvailable, true), array_fill_keys($providersUnavailable, false) ); return $responseData; } /** * Retrieve specific provider for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $provider provider identifier * * @return ProviderBaseInterface * @throws InvalidArgumentException */ public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { // retrieve provider $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); if (!isset($providers[$provider])) { throw new InvalidArgumentException("Provider '$provider' not found"); } return $providers[$provider]; } /** * Retrieve available services for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param SourceSelector|null $sources list of provider and service identifiers * * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] */ public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // retrieve providers $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; $services = $provider->serviceList($tenantId, $userId, $serviceFilter); $responseData[$provider->identifier()] = $services; } return $responseData; } /** * Confirm which services are available * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param SourceSelector|null $sources collection of provider and service identifiers to confirm * * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] */ public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { // retrieve providers $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); // retrieve services for each available provider foreach ($providers as $provider) { $serviceSelector = $sources[$provider->identifier()]; $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); $responseData[$provider->identifier()] = $serviceAvailability; } return $responseData; } /** * Retrieve service for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier * * @return ServiceBaseInterface * @throws InvalidArgumentException */ public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface { // retrieve provider and service $service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'"); } // retrieve services return $service; } /** * Find a service that handles a specific email address * * Searches all providers for a service that handles the given address, * respecting the user context for scope filtering. * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier * @param string $address Email address to find service for * * @return ?ServiceBaseInterface */ public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBaseInterface { // retrieve providers $providers = $this->providerList($tenantId, $userId); foreach ($providers as $providerId => $provider) { $service = $provider->serviceFindByAddress($tenantId, $userId, $address); if ($service !== null) { return $service; } } return null; } /** * Create a new service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param string $providerId Provider identifier * @param array $data Service configuration data * * @return ServiceBaseInterface Created service * * @throws InvalidArgumentException If provider doesn't support service creation */ public function serviceCreate(string $tenantId, ?string $userId, string $providerId, array $data): ServiceBaseInterface { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } // Create a fresh service instance $service = $provider->serviceFresh(); // Deserialize the data into the service $service->jsonDeserialize($data); // Create the service $serviceId = $provider->serviceCreate($tenantId, $userId, $service); // Fetch and return the created service return $provider->serviceFetch($tenantId, $userId, $serviceId); } /** * Update an existing service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param array $data Updated service configuration data * * @return ServiceBaseInterface Updated service * * @throws InvalidArgumentException If provider doesn't support service modification or service not found */ public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data): ServiceBaseInterface { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } // Update with new data $service->jsonDeserialize($data); // Modify the service $provider->serviceModify($tenantId, $userId, $service); // Fetch and return the updated service return $provider->serviceFetch($tenantId, $userId, $serviceId); } /** * Delete a service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * * @return bool True if service was deleted * * @throws InvalidArgumentException If provider doesn't support service deletion or service not found */ public function serviceDelete(string $tenantId, string $userId, string $providerId, string|int $serviceId): bool { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } // Delete the service return $provider->serviceDestroy($tenantId, $userId, $service); } /** * Discover mail service settings from identity * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier * @param string|null $providerId Specific provider to use for discovery (or null for all) * @param string $identity Identity to discover configuration for (e.g., email address) * @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup) * @param string|null $secret Optional password/token to validate discovered service * * @return array Array of discovered service locations keyed by provider ID * [ * 'jmap' => ResourceServiceLocationInterface, * 'smtp' => ResourceServiceLocationInterface, * // Only providers that successfully discovered (non-null) * ] */ public function serviceDiscover( string $tenantId, string $userId, string|null $providerId, string $identity, string|null $location = null, string|null $secret = null ): array { $locations = []; $providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null); foreach ($providers as $providerId => $provider) { if (!($provider instanceof ProviderServiceDiscoverInterface)) { continue; } try { $location = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret); if ($location !== null) { $locations[$providerId] = $location; } } catch (\Throwable $e) { $this->logger->warning('Provider autodiscovery failed', [ 'provider' => $providerId, 'identity' => $identity, 'error' => $e->getMessage(), ]); } } return $locations; } /** * Test a mail service connection * * Tests connectivity and authentication for either an existing service * or a fresh configuration. Delegates to the appropriate provider. * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier for context * @param string $providerId Provider ID (for existing service or targeted test) * @param string|int|null $serviceId Service ID (for existing service test) * @param ResourceServiceLocationInterface|array|null $location Service location (for fresh config test) * @param ResourceServiceIdentityInterface|array|null $identity Service credentials (for fresh config test) * * @return array Test results * * @throws InvalidArgumentException If invalid parameters */ public function serviceTest( string $tenantId, string $userId, string $providerId, string|int|null $serviceId = null, ResourceServiceLocationInterface|array|null $location = null, ResourceServiceIdentityInterface|array|null $identity = null ): array { // retrieve provider $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceTestInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service testing"); } // Testing existing service if ($providerId !== null && $serviceId !== null) { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); } try { return $provider->serviceTest($service); } catch (\Throwable $e) { throw new InvalidArgumentException('Service test failed: ' . $e->getMessage()); } } // Testing fresh configuration if ($location !== null && $identity !== null) { if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support fresh service configuration testing"); } if (empty($location['type'])) { throw new InvalidArgumentException('Service location not valid'); } if (empty($identity['type'])) { throw new InvalidArgumentException('Service identity not valid'); } /** @var ServiceMutableInterface $service */ $service = $provider->serviceFresh(); if ($location instanceof ResourceServiceLocationInterface === false) { $location = $service->freshLocation($location['type'], (array)$location); $service->setLocation($location); } if ($identity instanceof ResourceServiceIdentityInterface === false) { $identity = $service->freshIdentity($identity['type'], (array)$identity); $service->setIdentity($identity); } return $provider->serviceTest($service); } throw new InvalidArgumentException( 'Either (provider + service) or (provider + location + identity) must be provided' ); } // ==================== Collection Operations ==================== /** * List collections across services * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param SourceSelector|null $sources Provider/service sources * @param IFilter|null $filter Collection filter * @param ISort|null $sort Collection sort * * @return array>> Collections grouped by provider/service */ public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { // confirm that sources are provided if ($sources === null) { $sources = new SourceSelector([]); } // retrieve providers $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; /** @var ServiceBaseInterface[] $services */ $services = $provider->serviceList($tenantId, $userId, $serviceFilter); // retrieve collections for each service foreach ($services as $service) { // construct filter for collections $collectionFilter = null; if ($filter !== null && $filter !== []) { $collectionFilter = $service->collectionListFilter(); foreach ($filter as $attribute => $value) { $collectionFilter->condition($attribute, $value); } } // construct sort for collections $collectionSort = null; if ($sort !== null && $sort !== []) { $collectionSort = $service->collectionListSort(); foreach ($sort as $attribute => $direction) { $collectionSort->condition($attribute, $direction); } } $collections = $service->collectionList('', $collectionFilter, $collectionSort); if ($collections !== []) { $responseData[$provider->identifier()][$service->identifier()] = $collections; } } } return $responseData; } /** * Check if collections exist * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param SourceSelector $sources Collection sources with identifiers * * @return array>> Existence map grouped by provider/service */ public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { // retrieve available providers $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); // check services and collections for each available provider foreach ($providers as $provider) { $serviceSelector = $sources[$provider->identifier()]; $servicesRequested = $serviceSelector->identifiers(); /** @var ServiceBaseInterface[] $servicesAvailable */ $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); // mark unavailable services as false if ($servicesUnavailable !== []) { $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); } // confirm collections for each available service foreach ($servicesAvailable as $service) { $collectionSelector = $serviceSelector[$service->identifier()]; $collectionsRequested = $collectionSelector->identifiers(); if ($collectionsRequested === []) { continue; } // check each requested collection $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); $responseData[$provider->identifier()][$service->identifier()] = array_merge( $collectionsAvailable, array_fill_keys($collectionsUnavailable, false) ); } } return $responseData; } /** * Fetch a specific collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param string|int $collectionId Collection identifier * * @return CollectionBaseInterface|null */ public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); if ($service === null) { return null; } // retrieve collection return $service->collectionFetch($collectionId); } /** * Create a new collection for a specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier * @param string|int|null $collectionId collection identifier (parent collection) * @param CollectionMutableInterface|array $object collection to create * @param array $options additional options for creation * * @return CollectionBaseInterface * @throws InvalidArgumentException */ public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); // Check if service supports collection creation if (!($service instanceof ServiceCollectionMutableInterface)) { throw new InvalidArgumentException("Service does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { throw new InvalidArgumentException("Service is not capable of creating collections"); } if (is_array($object)) { $collection = $service->collectionFresh(); $collection->getProperties()->jsonDeserialize($object); } else { $collection = $object; } // Create collection return $service->collectionCreate($collectionId, $collection, $options); } /** * Modify an existing collection for a specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier * @param string|int $collectionId collection identifier * @param CollectionMutableInterface|array $object collection to modify * * @return CollectionBaseInterface * @throws InvalidArgumentException */ public function collectionModify(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); // Check if service supports collection creation if (!($service instanceof ServiceCollectionMutableInterface)) { throw new InvalidArgumentException("Service does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MODIFY)) { throw new InvalidArgumentException("Service is not capable of modifying collections"); } if (is_array($object)) { $collection = $service->collectionFresh(); $collection->getProperties()->jsonDeserialize($object); } else { $collection = $object; } // Modify collection return $service->collectionModify($collectionId, $collection); } /** * Destroy a specific collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param string|int $collectionId Collection identifier * * @return CollectionBaseInterface|null */ public function collectionDestroy(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); // Check if service supports collection destruction if (!($service instanceof ServiceCollectionMutableInterface)) { throw new InvalidArgumentException("Service does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DESTROY)) { throw new InvalidArgumentException("Service is not capable of destroying collections"); } $force = $options['force'] ?? false; $recursive = $options['recursive'] ?? false; // destroy collection return $service->collectionDestroy($collectionId, $force, $recursive); } // ==================== Message Operations ==================== /** * List messages in a collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier * @param SourceSelector $sources Message sources with collection identifiers * @param array|null $filter Message filter * @param array|null $sort Message sort * @param array|null $range Message range/pagination * * @return array>>> Messages grouped by provider/service/collection */ public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { // retrieve providers $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { // retrieve services for each provider $serviceSelector = $sources[$provider->identifier()]; $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); /** @var ServiceBaseInterface $service */ foreach ($servicesSelected as $service) { // retrieve collections for each service $collectionSelector = $serviceSelector[$service->identifier()]; $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; if ($collectionSelected === []) { $collections = $service->collectionList(''); $collectionSelected = array_map( fn($collection) => $collection->identifier(), $collections ); } if ($collectionSelected === []) { continue; } // construct filter for entities $entityFilter = null; if ($filter !== null && $filter !== []) { $entityFilter = $service->entityListFilter(); foreach ($filter as $attribute => $value) { $entityFilter->condition($attribute, $value); } } // construct sort for entities $entitySort = null; if ($sort !== null && $sort !== []) { $entitySort = $service->entityListSort(); foreach ($sort as $attribute => $direction) { $entitySort->condition($attribute, $direction); } } // construct range for entities $entityRange = null; if ($range !== null && $range !== [] && isset($range['type'])) { $entityRange = $service->entityListRange(RangeType::from($range['type'])); // Cast to IRangeTally if the range type is TALLY if ($entityRange->type() === RangeType::TALLY) { /** @var IRangeTally $entityRange */ if (isset($range['anchor'])) { $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); } if (isset($range['position'])) { $entityRange->setPosition($range['position']); } if (isset($range['tally'])) { $entityRange->setTally($range['tally']); } } } // retrieve entities for each collection foreach ($collectionSelected as $collectionId) { $entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null); // skip collections with no entities if ($entities === []) { continue; } $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities; } } } return $responseData; } /** * Get message delta/changes * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param SourceSelector $sources Message sources with signatures * * @return array>> Delta grouped by provider/service/collection */ public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { // confirm that sources are provided if ($sources === null) { $sources = new SourceSelector([]); } // retrieve providers $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); // iterate through available providers foreach ($providers as $provider) { $serviceSelector = $sources[$provider->identifier()]; $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; /** @var ServiceBaseInterface[] $services */ $services = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); } // iterate through available services foreach ($services as $service) { $collectionSelector = $serviceSelector[$service->identifier()]; $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; if ($collectionsRequested === []) { $responseData[$provider->identifier()][$service->identifier()] = false; continue; } foreach ($collectionsRequested as $collection) { $entitySelector = $collectionSelector[$collection] ?? null; $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); } } } return $responseData; } /** * Check if messages exist * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param SourceSelector $sources Message sources with identifiers * * @return array>>> Existence map grouped by provider/service/collection */ public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array { // confirm that sources are provided if ($sources === null) { $sources = new SourceSelector([]); } // retrieve available providers $providers = $this->providerList($tenantId, $userId, $sources); $providersRequested = $sources->identifiers(); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); // check services, collections, and entities for each available provider foreach ($providers as $provider) { $serviceSelector = $sources[$provider->identifier()]; $servicesRequested = $serviceSelector->identifiers(); /** @var ServiceBaseInterface[] $servicesAvailable */ $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); // mark unavailable services as false if ($servicesUnavailable !== []) { $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); } // check collections and entities for each available service foreach ($servicesAvailable as $service) { $collectionSelector = $serviceSelector[$service->identifier()]; $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; if ($collectionsRequested === []) { continue; } // check entities for each requested collection foreach ($collectionsRequested as $collectionId) { // first check if collection exists $collectionExists = $service->collectionExtant((string)$collectionId); if (!$collectionExists) { // collection doesn't exist, mark as false $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; continue; } // extract entity identifiers from collection selector $entitySelector = $collectionSelector[$collectionId]; // handle both array of entity IDs and boolean true (meaning check if collection exists) if ($entitySelector instanceof EntitySelector) { // check specific entities within the collection $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); } elseif ($entitySelector === true) { // just checking if collection exists (already confirmed above) $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; } } } } return $responseData; } /** * Fetch specific messages * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param string|int $collectionId Collection identifier * @param array $identifiers Message identifiers * * @return array Messages indexed by ID */ public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); // retrieve collection return $service->entityFetch($collectionId, ...$identifiers); } /** * Send a mail message * * Routes the message to the appropriate service based on the `from` address. * By default, messages are queued; use SendOptions::immediate() for urgent messages. * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param IMessageMutable $message Message to send * @param SendOptions|null $options Delivery options (defaults to queued) * * @return string Job ID for queued messages, or Message ID for immediate sends * * @throws SendException On immediate send failure * @throws InvalidArgumentException If no suitable service found */ public function entityTransmit(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, array $data): string { $options = $options ?? new SendOptions(); // Find the appropriate service $from = $message->getFrom(); if ($from !== null) { $service = $this->serviceFindByAddress($tenantId, $userId, $from->getAddress()); } if ($service === null) { throw new InvalidArgumentException('No mail service found for the message sender address'); } // Verify service can send if (!($service instanceof IServiceSend) || !$service->capable(IServiceSend::CAPABILITY_SEND)) { throw new InvalidArgumentException('Selected mail service does not support sending'); } // replace internal address for external 'from' $message->setFrom((new Address())->setAddress('system@ktrix.local')); // Immediate send bypasses queue if ($options->immediate) { $this->logger->debug('Sending mail immediately', [ 'tenant' => $tenantId, 'provider' => $service->in(), 'service' => $service->id(), 'to' => array_map(fn($a) => $a->getAddress(), $message->getTo()), ]); return $service->messageSend($message); } // Queue the message $jobId = $this->queue->enqueue( $tenantId, $service->in(), $service->id(), $message, $options ); $this->logger->debug('Mail queued for delivery', [ 'tenant' => $tenantId, 'jobId' => $jobId, 'provider' => $service->in(), 'service' => $service->id(), 'priority' => $options->priority, ]); return $jobId; } /** * Process queued mail for a tenant * * Called by the mail daemon to process pending messages. * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param int $batchSize Maximum messages to process * * @return array{processed: int, failed: int} */ public function queueProcess(string $tenantId, int $batchSize = 50): array { $processed = 0; $failed = 0; $jobs = $this->queue->dequeue($tenantId, $batchSize); foreach ($jobs as $job) { try { $service = $this->serviceFetch($tenantId, null, $job->providerId, $job->serviceId); if ($service === null || !($service instanceof IServiceSend)) { throw new SendException("Service not found or cannot send: {$job->providerId}/{$job->serviceId}"); } $messageId = $service->messageSend($job->message); $this->queue->acknowledge($job->id, $messageId); $processed++; $this->logger->debug('Mail sent from queue', [ 'tenant' => $tenantId, 'jobId' => $job->id, 'messageId' => $messageId, ]); } catch (\Throwable $e) { $isPermanent = $e instanceof SendException && $e->permanent; $this->queue->reject($job->id, $e->getMessage(), !$isPermanent); $failed++; $this->logger->warning('Mail send failed', [ 'tenant' => $tenantId, 'jobId' => $job->id, 'error' => $e->getMessage(), 'permanent' => $isPermanent, ]); } } return ['processed' => $processed, 'failed' => $failed]; } }