* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderMailSystem\Providers; use InvalidArgumentException; use KTXC\Resource\ProviderManager; use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Mail\Provider\ProviderServiceMutateInterface; use KTXF\Resource\Provider\ResourceServiceMutateInterface; use KTXM\ProviderMailSystem\Stores\ServiceStore; /** * System Mail Provider * * Rule-based routing provider for system-generated mail. Owns the reserved * "@system" address namespace: each service is a route that maps a logical * system address (e.g. authentication@system) to a backing service on a real * mail provider, with the sender identity rewritten on submission. A * "*@system" route acts as the tenant-wide default (catch-all). * * Routes are tenant scoped and resolvable only under the reserved system * user context. * * @since 2026.07.01 */ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface { protected const PROVIDER_IDENTIFIER = 'system'; protected const PROVIDER_LABEL = 'System Mail Provider'; protected const PROVIDER_DESCRIPTION = 'Routes system-generated mail to configured mail services'; protected const PROVIDER_ICON = 'mdi-email-lock'; protected const ADDRESS_DOMAIN_SUFFIX = '@system'; protected array $providerAbilities = [ self::CAPABILITY_SERVICE_LIST => true, self::CAPABILITY_SERVICE_FETCH => true, self::CAPABILITY_SERVICE_EXTANT => true, self::CAPABILITY_SERVICE_CREATE => true, self::CAPABILITY_SERVICE_MODIFY => true, self::CAPABILITY_SERVICE_DESTROY => true, ]; public function __construct( private readonly ServiceStore $serviceStore, private readonly ProviderManager $providerManager, ) {} public function jsonSerialize(): array { return [ self::PROPERTY_TYPE => self::JSON_TYPE, self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, self::PROPERTY_LABEL => self::PROVIDER_LABEL, self::PROPERTY_CAPABILITIES => $this->providerAbilities, ]; } public function jsonDeserialize(array|string $data): static { return $this; } public function type(): string { return self::TYPE_MAIL; } public function identifier(): string { return self::PROVIDER_IDENTIFIER; } public function label(): string { return self::PROVIDER_LABEL; } public function description(): string { return self::PROVIDER_DESCRIPTION; } public function icon(): string { return self::PROVIDER_ICON; } public function capable(string $value): bool { return !empty($this->providerAbilities[$value]); } public function capabilities(): array { return $this->providerAbilities; } public function serviceList(string $tenantId, string $userId, array $filter = []): array { // routes are tenant scoped and only visible in the system user context, // keeping them out of regular user account listings if ($userId !== self::USER_SYSTEM) { return []; } $list = []; foreach ($this->serviceStore->list($tenantId, $filter ?: null) as $serviceData) { $serviceInstance = $this->serviceFresh()->fromStore($serviceData); $list[$serviceInstance->identifier()] = $serviceInstance; } return $list; } public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service { if ($userId !== self::USER_SYSTEM) { return null; } $serviceData = $this->serviceStore->fetch($tenantId, $identifier); if ($serviceData === null) { return null; } return $this->serviceFresh()->fromStore($serviceData); } /** * Finds the route that handles a logical system address * * Only addresses in the reserved "@system" namespace are considered, and * only when asked in the reserved system user context — real users can * not resolve (and therefore not send through) system routes. An exact * address match always wins over the "*@system" catch-all. */ public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service { $address = strtolower(trim($address)); if (!str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) { return null; } if ($userId !== self::USER_SYSTEM) { return null; } $catchAll = null; /** @var Service $service */ foreach ($this->serviceList($tenantId, $userId) as $service) { if (!$service->getEnabled()) { continue; } if ($service->hasAddressExact($address)) { return $service; } if ($catchAll === null && $service->hasAddressPattern($address)) { $catchAll = $service; } } return $catchAll; } public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array { if ($userId !== self::USER_SYSTEM) { return array_fill_keys(array_map('strval', $identifiers), false); } return $this->serviceStore->extant($tenantId, $identifiers); } public function serviceFresh(): Service { return new Service($this->providerManager); } public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { $this->assertSystemContext($userId); if (!($service instanceof Service)) { throw new InvalidArgumentException('Service must be an instance of System Mail Service'); } $this->validateRoute($service); $created = $this->serviceStore->create($tenantId, $service); return (string)$created['sid']; } public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { $this->assertSystemContext($userId); if (!($service instanceof Service)) { throw new InvalidArgumentException('Service must be an instance of System Mail Service'); } $this->validateRoute($service); $this->serviceStore->modify($tenantId, $service); return (string)$service->identifier(); } public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool { $this->assertSystemContext($userId); if (!($service instanceof Service)) { return false; } return $this->serviceStore->delete($tenantId, $service->identifier()); } /** * Ensures the operation runs in the reserved system user context * * @throws InvalidArgumentException */ protected function assertSystemContext(string $userId): void { if ($userId !== self::USER_SYSTEM) { throw new InvalidArgumentException('System mail routes can only be managed in the system user context'); } } /** * Validates route configuration before persisting * * @throws InvalidArgumentException */ protected function validateRoute(Service $service): void { $address = strtolower(trim($service->getPrimaryAddress()->getAddress())); if ($address === '' || !str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) { throw new InvalidArgumentException('Route address must be within the reserved "@system" namespace (e.g. authentication@system or *@system)'); } foreach ($service->getSecondaryAddresses() as $secondary) { $secondaryAddress = strtolower(trim($secondary->getAddress())); if ($secondaryAddress === '' || !str_ends_with($secondaryAddress, self::ADDRESS_DOMAIN_SUFFIX)) { throw new InvalidArgumentException('Route alias addresses must be within the reserved "@system" namespace'); } } if ($service->getTargetProvider() === '' || $service->getTargetService() === '') { throw new InvalidArgumentException('Route requires a delivery target (provider and service)'); } if ($service->getTargetProvider() === self::PROVIDER_IDENTIFIER) { throw new InvalidArgumentException('Route cannot target the system provider itself'); } $fromAddress = $service->getFromAddress(); if ($fromAddress === '' || filter_var($fromAddress, FILTER_VALIDATE_EMAIL) === false) { throw new InvalidArgumentException('Route requires a valid sender (from) address'); } } }