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

263 lines
8.7 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 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');
}
}
}