* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderMailSystem\Providers; use BadMethodCallException; use Generator; use KTXC\Resource\ProviderManager; use KTXF\Mail\Entity\EntityMutableInterface; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Resource\BinaryResource; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\Provider\ProviderInterface; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\ISort; use RuntimeException; /** * System Mail Route Service * * A submit-only service that maps a logical system address * (e.g. authentication@system, or the *@system catch-all) to a backing * service on a real mail provider. Submission is delegated to the target * service with the From identity rewritten to the configured real address. * * @since 2026.07.01 */ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceEntitySubmitInterface { public const PROVIDER_IDENTIFIER = 'system'; // Route target configuration keys (stored in auxiliary) public const AUX_TARGET_PROVIDER = 'targetProvider'; public const AUX_TARGET_SERVICE = 'targetService'; public const AUX_FROM_ADDRESS = 'fromAddress'; public const AUX_FROM_LABEL = 'fromLabel'; private ?string $serviceTenantId = null; private ?string $serviceIdentifier = null; private ?string $serviceLabel = null; private bool $serviceEnabled = false; private array $primaryAddress = []; private array $secondaryAddresses = []; private array $auxiliary = []; private array $serviceAbilities = [ self::CAPABILITY_ENTITY_SUBMIT_FRESH => true, ]; public function __construct( private readonly ?ProviderManager $providerManager = null, ) {} public function toStore(): array { return array_filter([ 'tid' => $this->serviceTenantId, 'sid' => $this->serviceIdentifier, 'enabled' => $this->serviceEnabled, 'label' => $this->serviceLabel, 'primaryAddress' => $this->primaryAddress, 'secondaryAddresses' => $this->secondaryAddresses, 'auxiliary' => $this->auxiliary, ], fn($v) => $v !== null); } public function fromStore(array $data): static { $this->serviceTenantId = $data['tid'] ?? null; $this->serviceIdentifier = $data['sid'] ?? null; $this->serviceLabel = $data['label'] ?? ''; $this->serviceEnabled = $data['enabled'] ?? false; if (isset($data['primaryAddress']) && is_array($data['primaryAddress'])) { $this->primaryAddress = $data['primaryAddress']; } if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { $this->secondaryAddresses = $data['secondaryAddresses']; } if (isset($data['auxiliary']) && is_array($data['auxiliary'])) { $this->auxiliary = $data['auxiliary']; } return $this; } public function jsonSerialize(): array { return array_filter([ self::PROPERTY_TYPE => self::JSON_TYPE, self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::PROPERTY_IDENTIFIER => $this->serviceIdentifier, self::PROPERTY_LABEL => $this->serviceLabel, self::PROPERTY_ENABLED => $this->serviceEnabled, self::PROPERTY_CAPABILITIES => $this->serviceAbilities, self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, self::PROPERTY_AUXILIARY => $this->auxiliary, ], fn($v) => $v !== null); } public function jsonDeserialize(array|string $data, bool $delta = false): static { if (is_string($data)) { $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } if (isset($data[self::PROPERTY_ENABLED])) { $this->setEnabled((bool)$data[self::PROPERTY_ENABLED]); } if (isset($data[self::PROPERTY_LABEL])) { $this->setLabel($data[self::PROPERTY_LABEL]); } if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) { $value = $data[self::PROPERTY_PRIMARY_ADDRESS]; $this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value)); } if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) { $this->setSecondaryAddresses(array_map( fn($addr) => $addr instanceof AddressInterface ? $addr : (is_array($addr) ? Address::fromArray($addr) : new Address((string)$addr)), $data[self::PROPERTY_SECONDARY_ADDRESSES] )); } if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) { $this->setAuxiliary($delta ? array_merge($this->auxiliary, $data[self::PROPERTY_AUXILIARY]) : $data[self::PROPERTY_AUXILIARY]); } return $this; } public function capable(string $value): bool { return !empty($this->serviceAbilities[$value]); } public function capabilities(): array { return $this->serviceAbilities; } public function provider(): string { return self::PROVIDER_IDENTIFIER; } public function identifier(): string|int { return $this->serviceIdentifier ?? ''; } public function getLabel(): ?string { return $this->serviceLabel; } public function setLabel(string $value): static { $this->serviceLabel = $value; return $this; } public function getEnabled(): bool { return $this->serviceEnabled; } public function setEnabled(bool $value): static { $this->serviceEnabled = $value; return $this; } public function getPrimaryAddress(): AddressInterface { return Address::fromArray($this->primaryAddress); } public function setPrimaryAddress(AddressInterface $value): static { $this->primaryAddress = $value->toArray(); return $this; } public function getSecondaryAddresses(): array { return array_map( fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string)$addr]), $this->secondaryAddresses ); } public function setSecondaryAddresses(array $value): static { $this->secondaryAddresses = array_map( fn($addr) => $addr instanceof AddressInterface ? $addr->toArray() : (is_array($addr) ? $addr : ['address' => (string)$addr]), $value ); return $this; } public function getLocation(): ResourceServiceLocationInterface|null { return null; } public function getIdentity(): ResourceServiceIdentityInterface|null { return null; } public function getAuxiliary(): array { return $this->auxiliary; } public function setAuxiliary(array $value): static { $this->auxiliary = $value; return $this; } // ==================== Route Target Configuration ==================== public function getTargetProvider(): string { return (string)($this->auxiliary[self::AUX_TARGET_PROVIDER] ?? ''); } public function setTargetProvider(string $value): static { $this->auxiliary[self::AUX_TARGET_PROVIDER] = $value; return $this; } public function getTargetService(): string { return (string)($this->auxiliary[self::AUX_TARGET_SERVICE] ?? ''); } public function setTargetService(string $value): static { $this->auxiliary[self::AUX_TARGET_SERVICE] = $value; return $this; } public function getFromAddress(): string { return (string)($this->auxiliary[self::AUX_FROM_ADDRESS] ?? ''); } public function setFromAddress(string $value): static { $this->auxiliary[self::AUX_FROM_ADDRESS] = $value; return $this; } public function getFromLabel(): ?string { $label = $this->auxiliary[self::AUX_FROM_LABEL] ?? null; return $label !== null ? (string)$label : null; } public function setFromLabel(?string $value): static { $this->auxiliary[self::AUX_FROM_LABEL] = $value; return $this; } // ==================== Address Matching ==================== public function hasAddress(string $address): bool { return $this->hasAddressExact($address) || $this->hasAddressPattern($address); } /** * Checks whether an address exactly matches the primary or a secondary address */ public function hasAddressExact(string $address): bool { $address = strtolower(trim($address)); if ($address === '') { return false; } foreach ($this->allAddressPatterns() as $pattern) { if (!str_starts_with($pattern, '*@') && $pattern === $address) { return true; } } return false; } /** * Checks whether an address matches a catch-all pattern (e.g. *@system) */ public function hasAddressPattern(string $address): bool { $address = strtolower(trim($address)); if ($address === '') { return false; } foreach ($this->allAddressPatterns() as $pattern) { if (str_starts_with($pattern, '*@') && str_ends_with($address, substr($pattern, 1))) { return true; } } return false; } /** * @return array lowercase primary + secondary address patterns */ private function allAddressPatterns(): array { $patterns = []; if (($this->primaryAddress['address'] ?? '') !== '') { $patterns[] = strtolower(trim($this->primaryAddress['address'])); } foreach ($this->secondaryAddresses as $secondary) { $value = is_array($secondary) ? ($secondary['address'] ?? '') : (string)$secondary; if ($value !== '') { $patterns[] = strtolower(trim($value)); } } return $patterns; } // ==================== Submission (delegated) ==================== public function entityFresh(): EntityMutableInterface { return $this->resolveTarget()->entityFresh(); } public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult { if (!$this->serviceEnabled) { return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorCode: 'service_disabled', errorMessage: 'System mail route is disabled.', ); } $fromAddress = $this->getFromAddress(); if ($fromAddress === '') { return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorCode: 'route_misconfigured', errorMessage: 'System mail route has no sender address configured.', ); } try { $target = $this->resolveTarget(); } catch (\Throwable $e) { return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorCode: 'route_unresolved', errorMessage: $e->getMessage(), ); } // Rewrite the sender identity: the logical @system address never // appears on the wire, only the configured real address does. $from = new Address($fromAddress, $this->getFromLabel()); $message?->setFrom($from); return $target->entitySubmit($from, $source, $message); } /** * Resolves the backing service this route delivers through * * The target service is looked up under the reserved system user, so * routes can only deliver through tenant/system owned accounts. * * @throws RuntimeException when the route target cannot be resolved */ private function resolveTarget(): ServiceEntitySubmitInterface { if ($this->providerManager === null) { throw new RuntimeException('System mail route is not bound to a provider manager'); } if ($this->serviceTenantId === null) { throw new RuntimeException('System mail route is not bound to a tenant'); } $providerId = $this->getTargetProvider(); $serviceId = $this->getTargetService(); if ($providerId === '' || $serviceId === '') { throw new RuntimeException('System mail route has no delivery target configured'); } if ($providerId === self::PROVIDER_IDENTIFIER) { throw new RuntimeException('System mail route cannot target the system provider'); } $provider = $this->providerManager->resolve(ProviderInterface::TYPE_MAIL, $providerId); if ($provider instanceof ProviderBaseInterface === false) { throw new RuntimeException("Target mail provider '$providerId' is not available"); } $service = $provider->serviceFetch($this->serviceTenantId, ProviderBaseInterface::USER_SYSTEM, $serviceId); if ($service === null) { throw new RuntimeException("Target mail service '$providerId/$serviceId' was not found"); } if (!$service->getEnabled()) { throw new RuntimeException("Target mail service '$providerId/$serviceId' is disabled"); } if ($service instanceof ServiceEntitySubmitInterface === false) { throw new RuntimeException("Target mail service '$providerId/$serviceId' does not support submission"); } return $service; } // ==================== Collections / Entities (submit-only) ==================== public function collectionList(string|int $location, ?IFilter $filter = null, ?ISort $sort = null): array { return []; } public function collectionListFilter(): IFilter { throw new BadMethodCallException('System mail route does not support collection operations'); } public function collectionListSort(): ISort { throw new BadMethodCallException('System mail route does not support collection operations'); } public function collectionExtant(string|int $location, string|int ...$identifiers): array { return array_fill_keys($identifiers, false); } public function collectionFetch(string|int $identifier): null { return null; } public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { return []; } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { yield from []; } public function entityListFilter(): IFilter { throw new BadMethodCallException('System mail route does not support entity listing'); } public function entityListSort(): ISort { throw new BadMethodCallException('System mail route does not support entity listing'); } public function entityListRange(RangeType $type): IRange { throw new BadMethodCallException('System mail route does not support entity listing'); } public function entityDelta(string|int $collection, string $signature): Delta { throw new BadMethodCallException('System mail route does not support entity synchronization'); } public function entityExtant(string|int $collection, string|int ...$identifiers): array { return array_fill_keys($identifiers, false); } public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { return []; } public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { yield from []; } public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource { throw new BadMethodCallException('System mail route does not support entity download'); } }