Files
provider_system_mail/lib/Providers/Service.php
2026-02-10 20:34:26 -05:00

654 lines
18 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 KTXF\Mail\Entity\Address;
use KTXF\Mail\Entity\IAddress;
use KTXF\Mail\Entity\IMessageMutable;
use KTXF\Mail\Entity\Message;
use KTXF\Mail\Exception\SendException;
use KTXF\Mail\Service\IServiceIdentity;
use KTXF\Mail\Service\IServiceLocation;
use KTXF\Mail\Service\IServiceSend;
use KTXF\Mail\Service\ServiceScope;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
/**
* SMTP Mail Service
*
* Outbound-only mail service using Symfony Mailer for SMTP delivery.
*
* @since 2025.05.01
*/
class Service implements IServiceSend {
private array $capabilities = [
self::CAPABILITY_SEND => true,
];
/** @var array<int, IAddress> */
private array $secondaryAddresses = [];
private ?Mailer $mailer = null;
/**
* @param string $providerId Provider identifier
* @param string|int $id Service identifier
* @param string $label Human-friendly label
* @param ServiceScope $scope Service scope
* @param string|null $owner Owner user ID (for User scope)
* @param bool $enabled Whether service is enabled
* @param IAddress|null $primaryAddress Primary sending address
* @param IServiceLocation|null $location Connection location
* @param IServiceIdentity|null $identity Authentication credentials
*/
public function __construct(
private string $providerId,
private string|int $id,
private string $label,
private ServiceScope $scope = ServiceScope::System,
private ?string $owner = null,
private bool $enabled = true,
private ?IAddress $primaryAddress = null,
private ?IServiceLocation $location = null,
private ?IServiceIdentity $identity = null,
) {}
/**
* @inheritDoc
*/
public function capable(string $value): bool {
return $this->capabilities[$value] ?? false;
}
/**
* @inheritDoc
*/
public function capabilities(): array {
return $this->capabilities;
}
/**
* @inheritDoc
*/
public function in(): string {
return $this->providerId;
}
/**
* @inheritDoc
*/
public function id(): string|int {
return $this->id;
}
/**
* @inheritDoc
*/
public function getLabel(): string {
return $this->label;
}
/**
* Sets the service label
*
* @param string $label
*
* @return self
*/
public function setLabel(string $label): self {
$this->label = $label;
return $this;
}
/**
* @inheritDoc
*/
public function getScope(): ServiceScope {
return $this->scope;
}
/**
* Sets the service scope
*
* @param ServiceScope $scope
*
* @return self
*/
public function setScope(ServiceScope $scope): self {
$this->scope = $scope;
return $this;
}
/**
* @inheritDoc
*/
public function getOwner(): ?string {
return $this->owner;
}
/**
* Sets the service owner
*
* @param string|null $owner
*
* @return self
*/
public function setOwner(?string $owner): self {
$this->owner = $owner;
return $this;
}
/**
* @inheritDoc
*/
public function getEnabled(): bool {
return $this->enabled;
}
/**
* Sets the enabled status
*
* @param bool $enabled
*
* @return self
*/
public function setEnabled(bool $enabled): self {
$this->enabled = $enabled;
return $this;
}
/**
* @inheritDoc
*/
public function getPrimaryAddress(): IAddress {
return $this->primaryAddress ?? new Address('');
}
/**
* Sets the primary address
*
* @param IAddress $address
*
* @return self
*/
public function setPrimaryAddress(IAddress $address): self {
$this->primaryAddress = $address;
return $this;
}
/**
* @inheritDoc
*/
public function getSecondaryAddresses(): array {
return $this->secondaryAddresses;
}
/**
* Sets the secondary addresses
*
* @param array<int, IAddress> $addresses
*
* @return self
*/
public function setSecondaryAddresses(array $addresses): self {
$this->secondaryAddresses = $addresses;
return $this;
}
/**
* Adds a secondary address
*
* @param IAddress $address
*
* @return self
*/
public function addSecondaryAddress(IAddress $address): self {
$this->secondaryAddresses[] = $address;
return $this;
}
/**
* Gets the service location
*
* @return IServiceLocation|null
*/
public function getLocation(): ?IServiceLocation {
return $this->location;
}
/**
* Sets the service location
*
* @param IServiceLocation $location
*
* @return self
*/
public function setLocation(IServiceLocation $location): self {
$this->location = $location;
$this->mailer = null; // Reset mailer when location changes
return $this;
}
/**
* Gets the service identity
*
* @return IServiceIdentity|null
*/
public function getIdentity(): ?IServiceIdentity {
return $this->identity;
}
/**
* Sets the service identity
*
* @param IServiceIdentity $identity
*
* @return self
*/
public function setIdentity(IServiceIdentity $identity): self {
$this->identity = $identity;
$this->mailer = null; // Reset mailer when identity changes
return $this;
}
/**
* @inheritDoc
*/
public function handlesAddress(string $address): bool {
$address = strtolower(trim($address));
// Check primary address (exact match or catch-all)
if ($this->primaryAddress !== null) {
if ($this->matchesAddress($this->primaryAddress->getAddress(), $address)) {
return true;
}
}
// Check secondary addresses (exact match or catch-all)
foreach ($this->secondaryAddresses as $secondary) {
if ($this->matchesAddress($secondary->getAddress(), $address)) {
return true;
}
}
return false;
}
/**
* Check if a pattern matches an address (supports catch-all with *)
*
* @param string $pattern Pattern like "noreply@system" or "*@system"
* @param string $address Address to check
*
* @return bool
*/
private function matchesAddress(string $pattern, string $address): bool {
$pattern = strtolower(trim($pattern));
$address = strtolower(trim($address));
// Exact match
if ($pattern === $address) {
return true;
}
// Catch-all pattern (e.g., *@system)
if (str_starts_with($pattern, '*@')) {
$domain = substr($pattern, 2);
return str_ends_with($address, '@' . $domain);
}
return false;
}
/**
* @inheritDoc
*/
public function messageFresh(): IMessageMutable {
$message = new Message();
// Pre-populate from address if available
if ($this->primaryAddress !== null) {
$message->setFrom($this->primaryAddress);
}
return $message;
}
/**
* @inheritDoc
*/
public function messageSend(IMessageMutable $message): string {
if (!$this->enabled) {
throw SendException::permanent('Service is disabled');
}
if ($this->location === null) {
throw SendException::permanent('Service location not configured');
}
if (!$message->hasRecipients()) {
throw SendException::permanent('Message has no recipients');
}
if (!$message->hasBody()) {
throw SendException::permanent('Message has no body content');
}
// Build Symfony Email
$email = $this->buildSymfonyEmail($message);
// Get or create mailer
$mailer = $this->getMailer();
try {
$mailer->send($email);
// Return message ID (generate one if not available)
return $email->getHeaders()->get('Message-ID')?->getBodyAsString()
?? $this->generateMessageId();
} catch (TransportExceptionInterface $e) {
// Determine if this is a permanent or temporary failure
$message = $e->getMessage();
$isPermanent = $this->isPermanentFailure($e);
throw new SendException(
"SMTP delivery failed: $message",
$e->getCode(),
$e,
null,
$isPermanent
);
}
}
// Collection operations - SMTP is outbound-only, these are not supported
public function collectionList(?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array {
return [];
}
public function collectionListFilter(): \KTXF\Resource\Filter\IFilter {
throw new \BadMethodCallException('SMTP service does not support collection operations');
}
public function collectionListSort(): \KTXF\Resource\Sort\ISort {
throw new \BadMethodCallException('SMTP service does not support collection operations');
}
public function collectionExtant(string|int ...$identifiers): array {
return [];
}
public function collectionFetch(string|int $identifier): ?\KTXF\Mail\Collection\ICollectionBase {
return null;
}
// Message operations - SMTP is outbound-only, these are not supported
public function messageList(string|int $collection, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null, ?array $properties = null): array {
return [];
}
public function messageListFilter(): \KTXF\Resource\Filter\IFilter {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageListSort(): \KTXF\Resource\Sort\ISort {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageListRange(\KTXF\Resource\Range\RangeType $type): \KTXF\Resource\Range\IRange {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageDelta(string|int $collection, string $signature, string $detail = 'ids'): array {
return ['signature' => $signature, 'added' => [], 'modified' => [], 'removed' => []];
}
public function messageExtant(string|int $collection, string|int ...$identifiers): array {
return [];
}
public function messageFetch(string|int $collection, string|int ...$identifiers): array {
return [];
}
public function messageSearch(string $query, ?array $collections = null, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null): array {
return [];
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
return [
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_PROVIDER => $this->providerId,
self::JSON_PROPERTY_ID => $this->id,
self::JSON_PROPERTY_LABEL => $this->label,
self::JSON_PROPERTY_SCOPE => $this->scope->value,
self::JSON_PROPERTY_OWNER => $this->owner,
self::JSON_PROPERTY_ENABLED => $this->enabled,
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities,
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
];
}
/**
* Build a Symfony Email from IMessageMutable
*/
private function buildSymfonyEmail(IMessageMutable $message): Email {
$email = new Email();
// From
$from = $message->getFrom() ?? $this->primaryAddress;
if ($from !== null) {
$email->from(new \Symfony\Component\Mime\Address(
$from->getAddress(),
$from->getName() ?? ''
));
}
// Reply-To
$replyTo = $message->getReplyTo();
if ($replyTo !== null) {
$email->replyTo(new \Symfony\Component\Mime\Address(
$replyTo->getAddress(),
$replyTo->getName() ?? ''
));
}
// To
foreach ($message->getTo() as $to) {
$email->addTo(new \Symfony\Component\Mime\Address(
$to->getAddress(),
$to->getName() ?? ''
));
}
// CC
foreach ($message->getCc() as $cc) {
$email->addCc(new \Symfony\Component\Mime\Address(
$cc->getAddress(),
$cc->getName() ?? ''
));
}
// BCC
foreach ($message->getBcc() as $bcc) {
$email->addBcc(new \Symfony\Component\Mime\Address(
$bcc->getAddress(),
$bcc->getName() ?? ''
));
}
// Subject
$email->subject($message->getSubject());
// Body
if ($message->getBodyText() !== null) {
$email->text($message->getBodyText());
}
if ($message->getBodyHtml() !== null) {
$email->html($message->getBodyHtml());
}
// Attachments
foreach ($message->getAttachments() as $attachment) {
if ($attachment->isInline()) {
$email->embed(
$attachment->getContent(),
$attachment->getName(),
$attachment->getMimeType()
);
} else {
$email->attach(
$attachment->getContent(),
$attachment->getName(),
$attachment->getMimeType()
);
}
}
// Custom headers
foreach ($message->getHeaders() as $name => $value) {
$email->getHeaders()->addTextHeader($name, $value);
}
return $email;
}
/**
* Get or create the Symfony Mailer instance
*/
private function getMailer(): Mailer {
if ($this->mailer === null) {
$dsn = $this->buildDsn();
$transport = Transport::fromDsn($dsn);
$this->mailer = new Mailer($transport);
}
return $this->mailer;
}
/**
* Build the DSN string for Symfony Mailer
*/
private function buildDsn(): string {
if ($this->location === null) {
throw new \RuntimeException('Service location not configured');
}
$host = $this->location->getOutboundHost() ?? $this->location->getInboundHost();
$port = $this->location->getOutboundPort() ?? $this->location->getInboundPort();
$security = $this->location->getOutboundSecurity() ?? $this->location->getInboundSecurity();
if ($host === null) {
throw new \RuntimeException('SMTP host not configured');
}
// Determine scheme based on security setting
$scheme = match($security) {
IServiceLocation::SECURITY_SSL => 'smtps',
IServiceLocation::SECURITY_TLS, IServiceLocation::SECURITY_STARTTLS => 'smtp',
default => 'smtp',
};
// Build DSN
$dsn = $scheme . '://';
// Add credentials if available
if ($this->identity !== null) {
$username = $this->identity->getUsername ?? null;
$password = $this->identity->getPassword ?? null;
if (method_exists($this->identity, 'getUsername')) {
$username = $this->identity->getUsername();
}
if (method_exists($this->identity, 'getPassword')) {
$password = $this->identity->getPassword();
}
if ($username !== null) {
$dsn .= urlencode($username);
if ($password !== null) {
$dsn .= ':' . urlencode($password);
}
$dsn .= '@';
}
}
$dsn .= $host;
if ($port !== null) {
$dsn .= ':' . $port;
}
// Disable certificate verification
$dsn .= '?verify_peer=0';
return $dsn;
}
/**
* Generate a unique message ID
*/
private function generateMessageId(): string {
$domain = 'ktrix.local';
if ($this->primaryAddress !== null) {
$parts = explode('@', $this->primaryAddress->getAddress());
if (count($parts) === 2) {
$domain = $parts[1];
}
}
return sprintf('<%s.%s@%s>',
bin2hex(random_bytes(8)),
time(),
$domain
);
}
/**
* Determine if an exception represents a permanent failure
*/
private function isPermanentFailure(TransportExceptionInterface $e): bool {
$message = strtolower($e->getMessage());
// Common permanent failure indicators
$permanentPatterns = [
'user unknown',
'mailbox not found',
'invalid recipient',
'relay access denied',
'authentication failed',
'bad recipient',
'550 ',
'551 ',
'552 ',
'553 ',
'554 ',
];
foreach ($permanentPatterns as $pattern) {
if (str_contains($message, $pattern)) {
return true;
}
}
return false;
}
}