Files
2026-07-19 07:08:48 -04:00

517 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<int,string> 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');
}
}