feat: use mail manager and mail providers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+226
-200
@@ -9,228 +9,254 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderMailSystem\Providers;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXF\Mail\Provider\ProviderBaseInterface;
|
||||
use KTXF\Mail\Selector\ServiceSelector;
|
||||
use KTXF\Mail\Service\IServiceBase;
|
||||
use KTXF\Mail\Service\ServiceScope;
|
||||
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||
use KTXM\ProviderMailSystem\Stores\ServiceStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* SMTP Mail Provider
|
||||
*
|
||||
* Provider for SMTP-based mail services. Supports multiple configured
|
||||
* services per tenant with system and user scopes.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
* 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 {
|
||||
class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface
|
||||
{
|
||||
|
||||
private const PROVIDER_ID = 'system';
|
||||
private const PROVIDER_LABEL = 'System Mail Provider';
|
||||
private const PROVIDER_DESCRIPTION = 'Outbound-only System mail provider for system notifications';
|
||||
private const PROVIDER_ICON = 'fa-envelope';
|
||||
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';
|
||||
|
||||
private array $capabilities = [
|
||||
self::CAPABILITY_SERVICE_LIST => true,
|
||||
self::CAPABILITY_SERVICE_FETCH => true,
|
||||
self::CAPABILITY_SERVICE_EXTANT => true,
|
||||
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 LoggerInterface $logger,
|
||||
private ServiceStore $serviceStore,
|
||||
private readonly ServiceStore $serviceStore,
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function capable(string $value): bool {
|
||||
return $this->capabilities[$value] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function capabilities(): array {
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function id(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function label(): string {
|
||||
return self::PROVIDER_LABEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function type(): string {
|
||||
return self::TYPE_MAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function identifier(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function description(): string {
|
||||
return self::PROVIDER_DESCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function icon(): string {
|
||||
return self::PROVIDER_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceList(string $tenantId, string $userId, ?ServiceSelector $selector = null): array {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($identifiers as $id) {
|
||||
$result[$id] = $this->serviceFetch($tenantId, $userId, $id) !== null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase {
|
||||
$identifier = (string)$identifier;
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle @system addresses for this provider
|
||||
if (!str_ends_with(strtolower($identifier), '@system')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch by primary address (which is the sid)
|
||||
$service = $this->serviceStore->getService($tenantId, $identifier);
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enforce scope visibility
|
||||
if ($service->getScope() === ServiceScope::System) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($service->getScope() === ServiceScope::User) {
|
||||
if ($userId !== null && $service->getOwner() === $userId) {
|
||||
return $service;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase {
|
||||
$address = strtolower(trim($address));
|
||||
if ($address === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle @system addresses for this provider
|
||||
if (!str_ends_with($address, '@system')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use store's findServiceByAddress which checks primary, secondary, and catch-all patterns
|
||||
$service = $this->serviceStore->findServiceByAddress($tenantId, $address);
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enforce scope visibility
|
||||
if ($service->getScope() === ServiceScope::System) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($service->getScope() === ServiceScope::User) {
|
||||
if ($userId !== null && $service->getOwner() === $userId) {
|
||||
return $service;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::JSON_PROPERTY_ID => $this->id(),
|
||||
self::JSON_PROPERTY_LABEL => $this->label(),
|
||||
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities(),
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selector filters to services
|
||||
* 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.
|
||||
*/
|
||||
private function applySelector(array $services, ServiceSelector $selector): array {
|
||||
return array_filter($services, function(IServiceBase $service) use ($selector) {
|
||||
if ($selector->getScope() !== null && $service->getScope() !== $selector->getScope()) {
|
||||
return false;
|
||||
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 ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
|
||||
return false;
|
||||
if ($service->hasAddressExact($address)) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
|
||||
return false;
|
||||
if ($catchAll === null && $service->hasAddressPattern($address)) {
|
||||
$catchAll = $service;
|
||||
}
|
||||
|
||||
if ($selector->getCapabilities() !== null) {
|
||||
foreach ($selector->getCapabilities() as $cap) {
|
||||
if (!$service->capable($cap)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user