fc3f4c28db
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
517 lines
17 KiB
PHP
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');
|
|
}
|
|
|
|
}
|