Initial commit
This commit is contained in:
653
lib/Providers/Service.php
Normal file
653
lib/Providers/Service.php
Normal file
@@ -0,0 +1,653 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user