From d94b1ea2117688711ca74efef0e7842e16f6b6b3 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Tue, 23 Jun 2026 16:26:53 -0400 Subject: [PATCH] refactor: remove remote service Signed-off-by: Sebastian Krupinski --- lib/Console/DiscoverCommand.php | 1 - lib/Console/TestCommand.php | 10 +- lib/Providers/Provider.php | 7 +- lib/Providers/Service.php | 82 +++++++++++- lib/Service/Remote/RemoteMailService.php | 155 +++++++++++++++++------ lib/Service/Remote/RemoteService.php | 61 --------- 6 files changed, 200 insertions(+), 116 deletions(-) delete mode 100644 lib/Service/Remote/RemoteService.php diff --git a/lib/Console/DiscoverCommand.php b/lib/Console/DiscoverCommand.php index 9b716a8..6d4c08e 100644 --- a/lib/Console/DiscoverCommand.php +++ b/lib/Console/DiscoverCommand.php @@ -14,7 +14,6 @@ use KTXM\ProviderImap\Providers\Service; use KTXM\ProviderImap\Providers\ServiceIdentityBasic; use KTXM\ProviderImap\Providers\ServiceLocation; use KTXM\ProviderImap\Service\Discovery; -use KTXM\ProviderImap\Service\Remote\RemoteService; use KTXC\SessionTenant; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/lib/Console/TestCommand.php b/lib/Console/TestCommand.php index 2b4196c..9aba99e 100644 --- a/lib/Console/TestCommand.php +++ b/lib/Console/TestCommand.php @@ -17,7 +17,7 @@ use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\SequenceSet; use KTXM\ProviderImap\Providers\Provider; use KTXM\ProviderImap\Providers\Service; -use KTXM\ProviderImap\Service\Remote\RemoteService; +use KTXM\ProviderImap\Service\Remote\RemoteMailService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -119,8 +119,7 @@ class TestCommand extends Command $startedAt = microtime(true); try { - $client = RemoteService::freshClient($service); - $mailService = RemoteService::mailService($service, $client); + $mailService = new RemoteMailService($service); $mailboxes = $mailService->collectionList(); } catch (\Throwable $e) { $io->error('IMAP diagnostic failed: ' . $e->getMessage()); @@ -184,9 +183,8 @@ class TestCommand extends Command $io->section(sprintf('Recent Messages: %s', $mailboxName)); try { - $mailboxClient = RemoteService::freshClient($service); - $mailboxService = RemoteService::mailService($service, $mailboxClient); - $selectedMailbox = $mailboxClient->perform(new SelectCommand($mailboxName, true)); + $mailboxService = new RemoteMailService($service); + $selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true)); } catch (\Throwable $e) { $io->error('Mailbox inspection failed: ' . $e->getMessage()); return Command::FAILURE; diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index e57c175..5ed0044 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -18,7 +18,7 @@ use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceMutateInterface; use KTXM\ProviderImap\Service\Discovery; -use KTXM\ProviderImap\Service\Remote\RemoteService; +use KTXM\ProviderImap\Service\Remote\RemoteMailService; use KTXM\ProviderImap\Stores\ServiceStore; /** @@ -192,9 +192,8 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, $service->fromStore(['sid' => 'test']); // Attempt to authenticate and list mailboxes as a connectivity check - $client = RemoteService::freshClient($service); - $service = RemoteService::mailService($service, $client); - $mailboxes = iterator_to_array($service->collectionList()); + $remote = new RemoteMailService($service); + $mailboxes = iterator_to_array($remote->collectionList()); $latency = (int) round((microtime(true) - $startTime) * 1000); diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index f8c387b..360a601 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -17,8 +17,10 @@ use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Resource\BinaryResource; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; @@ -35,8 +37,8 @@ use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderImap\Providers\ServiceIdentityBasic; use KTXM\ProviderImap\Providers\ServiceLocation; +use KTXM\ProviderImap\Mime\MessageBuilder; use KTXM\ProviderImap\Service\Remote\RemoteMailService; -use KTXM\ProviderImap\Service\Remote\RemoteService; use KTXM\ProviderImap\Providers\CollectionResource; use KTXF\Mail\Collection\CollectionRoles; use KTXF\Mail\Object\MessagePropertiesMutableInterface; @@ -44,7 +46,7 @@ use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXM\ProviderImap\Providers\EntityResource; use KTXM\ProviderImap\Client\Mailbox; -class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface { private const PROVIDER_IDENTIFIER = 'imap'; @@ -116,8 +118,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC private function initialize(): void { if (!isset($this->remoteService)) { - $wrapper = RemoteService::freshClient($this); - $this->remoteService = RemoteService::mailService($this, $wrapper); + $this->remoteService = new RemoteMailService($this); } } @@ -624,6 +625,79 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return new EntityResource($this->provider(), $this->identifier()); } + public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult + { + if ($message === null) { + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_ERROR, + errorCode: 'invalid_message', + errorMessage: 'No message properties were provided for submission.', + ); + } + + // Outbound submission via SMTP — independent of the IMAP connection. + try { + $raw = (new MessageBuilder())->build($message); + $recipients = MessageBuilder::recipients($message); + if ($recipients === []) { + throw new \RuntimeException('Message has no recipients.'); + } + + $this->initialize(); + $smtp = $this->remoteService->submitClient(); + try { + $queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw); + } finally { + $smtp->quit(); + } + } catch (\Throwable $e) { + return new EntitySubmitResult( + EntitySubmitResult::DISPOSITION_ERROR, + errorCode: 'submission_failed', + errorMessage: $e->getMessage(), + ); + } + + // Best-effort: store a copy in the Sent collection. A failure here must + // not turn a successful delivery into an error. + $sentEntity = null; + try { + $this->initialize(); + $sentCollection = $this->resolveSentCollection(); + if ($sentCollection !== null) { + $uid = $this->remoteService->entityCreate($sentCollection, $raw, ['\\Seen']); + if ($uid !== null && $uid > 0) { + $sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid); + } + } + } catch (\Throwable) { + // ignore — the message was already delivered + } + + return new EntitySubmitResult( + disposition: EntitySubmitResult::DISPOSITION_SENT, + transportId: $queueId !== '' ? $queueId : null, + sentEntity: $sentEntity, + ); + } + + /** + * Resolve the native name of the collection flagged with the Sent role. + */ + private function resolveSentCollection(): ?string + { + $filter = $this->collectionListFilter(); + $filter->condition('role', CollectionRoles::Sent->value); + + /** @var Mailbox[] $mailboxes */ + $mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null)); + if ($mailboxes === []) { + return null; + } + $mailbox = reset($mailboxes); + return $mailbox === false ? null : $mailbox->name(); + } + public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource { throw new \RuntimeException('Entity creation is not supported in this service'); diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 7a809af..30e75bd 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -11,7 +11,9 @@ namespace KTXM\ProviderImap\Service\Remote; use DateTimeImmutable; use Generator; -use KTXM\ProviderImap\Client\Client; +use KTXC\Server; +use KTXC\Logger\PlainFileLogger; +use KTXM\ProviderImap\Client\Client as ImapClient; use KTXM\ProviderImap\Client\Command\AppendCommand; use KTXM\ProviderImap\Client\Command\FetchManyCommand; use KTXM\ProviderImap\Client\Command\FetchOneCommand; @@ -48,6 +50,9 @@ use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; use KTXF\Resource\BinaryResource; +use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Smtp\Client as SmtpClient; +use RuntimeException; /** * IMAP Remote Mail Service @@ -58,10 +63,80 @@ class RemoteMailService private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription']; private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']; + private ?ImapClient $imapClient = null; + private ?SmtpClient $smtpClient = null; + public function __construct( - private readonly Client $client, + private readonly Service $service, ) {} + /** + * The connected IMAP client, established on first use. + */ + public function imapClient(): ImapClient + { + if ($this->imapClient instanceof ImapClient) { + return $this->imapClient; + } + + $location = $this->service->getLocation(); + if ($location === null || $location->getInboundHost() === '') { + throw new RuntimeException('No IMAP (inbound) host is configured for this service.'); + } + $identity = $this->service->getIdentity(); + + $config = $location->toConnectionConfig( + $identity?->getIdentity(), + $identity?->getSecret(), + ); + + $client = new ImapClient(logger: $this->logger('imap')); + $client->connect($config); + + return $this->imapClient = $client; + } + + /** + * The connected SMTP client, established on first use. + */ + public function smtpClient(): SmtpClient + { + if ($this->smtpClient instanceof SmtpClient) { + return $this->smtpClient; + } + + $location = $this->service->getLocation(); + if ($location === null || $location->getOutboundHost() === '') { + throw new RuntimeException('No SMTP submission (outbound) host is configured for this service.'); + } + + $identity = $this->service->getIdentity(); + + $config = $location->toSmtpConnectionConfig( + $identity?->getIdentity(), + $identity?->getSecret(), + ); + + $client = new SmtpClient(logger: $this->logger('smtp')); + $client->connect($config); + + return $this->smtpClient = $client; + } + + /** + * Build a per-protocol file logger when the service has debug enabled. + */ + private function logger(string $channel): ?PlainFileLogger + { + if (!$this->service->getDebug()) { + return null; + } + + $logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log'; + + return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier()); + } + /** * list of collections in remote storage * @@ -79,7 +154,7 @@ class RemoteMailService $location = ''; } // construct the most efficient LIST command based on server capabilities - if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) { + if ($this->imapClient()->hasCapability('LIST-STATUS') && !empty($depth)) { $command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)); $rfc5258 = true; } else { @@ -88,7 +163,7 @@ class RemoteMailService } // retrieve list of mailboxes from remote $mailboxes = []; - foreach ($this->client->perform($command) as $mailbox) { + foreach ($this->imapClient()->perform($command) as $mailbox) { // apply filter if ($filter === null || $this->mailboxFilter($mailbox, $filter)) { if ($rfc5258) { @@ -106,7 +181,7 @@ class RemoteMailService continue; } try { - $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); + $status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); $mailbox = $mailbox->fromStatus($status); } catch (ImapException) { // do nothing @@ -126,13 +201,13 @@ class RemoteMailService public function collectionFetch(string $identifier): ?Mailbox { // retrieve mailbox from remote - $mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)))); + $mailbox = iterator_to_array($this->imapClient()->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)))); if (empty($mailbox)) { return null; } $mailbox = reset($mailbox); // enrich with STATUS - $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); + $status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); $mailbox = $mailbox->fromStatus($status); return $mailbox; @@ -146,7 +221,7 @@ class RemoteMailService */ public function collectionCreate(string $name): Mailbox { - $result = $this->client->perform(new CreateCommand($name)); + $result = $this->imapClient()->perform(new CreateCommand($name)); if (!$result->isOk()) { throw new ImapException('Failed to create mailbox: ' . $name); @@ -166,7 +241,7 @@ class RemoteMailService */ public function collectionRename(string $oldName, string $newName): Mailbox { - $result = $this->client->perform(new RenameCommand($oldName, $newName)); + $result = $this->imapClient()->perform(new RenameCommand($oldName, $newName)); if (!$result->isOk()) { throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); @@ -186,7 +261,7 @@ class RemoteMailService */ public function collectionDestroy(string $name): bool { - $result = $this->client->perform(new DeleteCommand($name)); + $result = $this->imapClient()->perform(new DeleteCommand($name)); if (!$result->isOk()) { throw new ImapException('Failed to delete mailbox: ' . $name); @@ -207,18 +282,18 @@ class RemoteMailService $nativeFilter = $this->buildEntitySearchCriteria($filter); $nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : []; - $this->client->perform(new SelectCommand($collection, true)); - $rfc5258 = $this->client->hasCapability('SORT'); + $this->imapClient()->perform(new SelectCommand($collection, true)); + $rfc5258 = $this->imapClient()->hasCapability('SORT'); $uids = []; if ($nativeSort !== [] && $rfc5258) { - $uids = $this->client->perform(new SortCommand( + $uids = $this->imapClient()->perform(new SortCommand( $nativeSort, $nativeFilter, IdentifierMode::Uid, ))->matches(); } else { - $uids = $this->client->perform(new SearchCommand( + $uids = $this->imapClient()->perform(new SearchCommand( $nativeFilter, IdentifierMode::Uid, ))->matches(); @@ -246,13 +321,13 @@ class RemoteMailService // fast path: fetch all messages without filtering, sorting or pagination if ($filter === null && $sort === null && $range === null) { - $mailbox = $this->client->perform(new SelectCommand($collection, true)); + $mailbox = $this->imapClient()->perform(new SelectCommand($collection, true)); if ($mailbox === null) { return []; } - yield from $this->client->perform(new FetchManyCommand( + yield from $this->imapClient()->perform(new FetchManyCommand( FetchTarget::all(), $options, )); @@ -283,14 +358,14 @@ class RemoteMailService } $options ??= FetchOptions::message()->withBodyText(); - $this->client->perform(new SelectCommand($collection, true)); + $this->imapClient()->perform(new SelectCommand($collection, true)); $request = new FetchManyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $options, ); - foreach ($this->client->perform($request) as $message) { + foreach ($this->imapClient()->perform($request) as $message) { $uid = $message->uid() ?: $message->sequence(); yield $uid => $message; } @@ -308,7 +383,7 @@ class RemoteMailService */ public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource { - $this->client->perform(new SelectCommand($collection, true)); + $this->imapClient()->perform(new SelectCommand($collection, true)); $encoding = null; @@ -317,7 +392,7 @@ class RemoteMailService $mimeType = 'message/rfc822'; } else { // Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred) - $message = $this->client->perform(new FetchOneCommand( + $message = $this->imapClient()->perform(new FetchOneCommand( FetchTarget::uid(SequenceSet::items($uid)), FetchOptions::of('BODYSTRUCTURE'), )); @@ -333,7 +408,7 @@ class RemoteMailService // Start download stream $stream = $this->decodeStream( - $this->client->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''), + $this->imapClient()->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''), $encoding ); @@ -413,7 +488,7 @@ class RemoteMailService */ public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int { - return $this->client->perform(new AppendCommand($collection, $rawMessage, $flags)); + return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags)); } /** @@ -429,8 +504,8 @@ class RemoteMailService return; } - $this->client->perform(new SelectCommand($collection, false)); - $this->client->perform(new StoreCommand( + $this->imapClient()->perform(new SelectCommand($collection, false)); + $this->imapClient()->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $flags, $action, @@ -448,9 +523,9 @@ class RemoteMailService $target = FetchTarget::uid(SequenceSet::items(...array_values($uids))); - $this->client->perform(new SelectCommand($collection, false)); - $this->client->perform(new StoreCommand($target, ['\\Deleted'], '+')); - $this->client->perform(new ExpungeCommand($target)); + $this->imapClient()->perform(new SelectCommand($collection, false)); + $this->imapClient()->perform(new StoreCommand($target, ['\\Deleted'], '+')); + $this->imapClient()->perform(new ExpungeCommand($target)); // TODO: find a way to determine which actual UID's were deleted return array_fill_keys($uids, true); @@ -462,13 +537,13 @@ class RemoteMailService return; } - $this->client->perform(new SelectCommand($collection, false)); + $this->imapClient()->perform(new SelectCommand($collection, false)); $flagsToAdd = $this->normalizeFlags($flagsToAdd); $flagsToRemove = $this->normalizeFlags($flagsToRemove); if (!empty($flagsToAdd)) { - $this->client->perform(new StoreCommand( + $this->imapClient()->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $flagsToAdd, '+', @@ -476,7 +551,7 @@ class RemoteMailService } if (!empty($flagsToRemove)) { - $this->client->perform(new StoreCommand( + $this->imapClient()->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $flagsToRemove, '-', @@ -490,28 +565,28 @@ class RemoteMailService return []; } - $rfc6851 = $this->client->hasCapability('MOVE'); + $rfc6851 = $this->imapClient()->hasCapability('MOVE'); // if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE if ($rfc6851) { - $this->client->perform(new SelectCommand($sourceCollection, false)); - $response = $this->client->perform(new MoveCommand( + $this->imapClient()->perform(new SelectCommand($sourceCollection, false)); + $response = $this->imapClient()->perform(new MoveCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $targetCollection, )); } else { - $this->client->perform(new SelectCommand($sourceCollection, false)); - $response = $this->client->perform(new CopyCommand( + $this->imapClient()->perform(new SelectCommand($sourceCollection, false)); + $response = $this->imapClient()->perform(new CopyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $targetCollection, )); if ($response->isOk()) { - $this->client->perform(new StoreCommand( + $this->imapClient()->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), ['\\Deleted'], '+', )); - $this->client->perform(new ExpungeCommand( + $this->imapClient()->perform(new ExpungeCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), )); } @@ -542,8 +617,8 @@ class RemoteMailService return []; } - $this->client->perform(new SelectCommand($sourceCollection, false)); - $response = $this->client->perform(new CopyCommand( + $this->imapClient()->perform(new SelectCommand($sourceCollection, false)); + $response = $this->imapClient()->perform(new CopyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $targetCollection, )); @@ -768,7 +843,7 @@ class RemoteMailService } } - $messages = iterator_to_array($this->client->perform(new FetchManyCommand( + $messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $options, ))); diff --git a/lib/Service/Remote/RemoteService.php b/lib/Service/Remote/RemoteService.php deleted file mode 100644 index 5e4011f..0000000 --- a/lib/Service/Remote/RemoteService.php +++ /dev/null @@ -1,61 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImap\Service\Remote; - -use KTXC\Server; -use KTXC\Logger\PlainFileLogger; -use KTXM\ProviderImap\Client\Client; -use KTXM\ProviderImap\Providers\Service; - -/** - * Static factory for IMAP remote service objects. - * - * - freshClient() → builds a gricob Client from service config - * - mailService() → constructs a RemoteMailService from the client - */ -class RemoteService -{ - /** - * Build and bootstrap a fully-configured IMAP client from a Service. - */ - public static function freshClient(Service $service): Client - { - $location = $service->getLocation(); - $identity = $service->getIdentity(); - - // Build a file logger when debug mode is enabled, otherwise pass null - $logger = null; - if ($service->getDebug()) { - $logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log'; - $logger = new PlainFileLogger($logDir . '/imap', $service->identifier()); - } - - $config = $location->toConnectionConfig( - $identity?->getIdentity(), - $identity?->getSecret(), - ); - - $client = new Client(logger: $logger); - $client->connect($config); - - return $client; - } - - /** - * Build a RemoteMailService from a Service and a pre-authenticated client. - * - * The provider identifier and service ID are taken directly from the Service - * object so the caller does not have to repeat them. - */ - public static function mailService(Service $service, Client $client): RemoteMailService - { - return new RemoteMailService($client, $service->provider(), $service->identifier()); - } -}