* 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 */ 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 $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; } }