From a8764747fd25c64e561ba9a567cb52b3cbd7a629 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 8 May 2026 00:16:43 -0400 Subject: [PATCH] refactor: use custom imap client Signed-off-by: Sebastian Krupinski --- composer.json | 3 +- lib/Client/Client.php | 669 +-------- lib/Client/ClientInterface.php | 24 + lib/Client/Command/CapabilityCommand.php | 62 + lib/Client/Command/CommandInterface.php | 30 + lib/Client/Command/CopyCommand.php | 47 + lib/Client/Command/CreateCommand.php | 65 + lib/Client/Command/DeleteCommand.php | 68 + lib/Client/Command/ExpungeCommand.php | 106 ++ lib/Client/Command/FetchManyCommand.php | 67 + lib/Client/Command/FetchOneCommand.php | 62 + lib/Client/Command/FetchResponseParser.php | 58 + lib/Client/Command/ListCommand.php | 263 ++++ lib/Client/Command/LoginCommand.php | 67 + lib/Client/Command/LogoutCommand.php | 59 + lib/Client/Command/MessageTransferCommand.php | 243 ++++ lib/Client/Command/MoveCommand.php | 47 + lib/Client/Command/NoopCommand.php | 57 + lib/Client/Command/RenameCommand.php | 72 + .../Command/Result/CapabilityResult.php | 28 + .../Command/Result/CommandStatusResult.php | 28 + .../Command/Result/MessageTransferResult.php | 152 ++ lib/Client/Command/Result/SearchResult.php | 36 + lib/Client/Command/Result/SortResult.php | 36 + lib/Client/Command/Result/StatusResult.php | 54 + lib/Client/Command/SearchCommand.php | 126 ++ lib/Client/Command/SelectCommand.php | 124 ++ lib/Client/Command/SortCommand.php | 154 +++ lib/Client/Command/StartTlsCommand.php | 54 + lib/Client/Command/StatusCommand.php | 118 ++ lib/Client/Command/StatusResponseParser.php | 139 ++ lib/Client/Command/StoreCommand.php | 112 ++ lib/Client/Configuration.php | 20 - lib/Client/ConnectionConfig.php | 88 ++ lib/Client/ConnectionSecurity.php | 17 + lib/Client/FetchOptions.php | 152 ++ lib/Client/FetchTarget.php | 52 + lib/Client/IdentifierMode.php | 16 + lib/Client/ImapException.php | 11 + lib/Client/ListReturnOptions.php | 180 +++ lib/Client/ListSelectionOptions.php | 128 ++ lib/Client/Mailbox.php | 105 +- lib/Client/Message.php | 178 +++ lib/Client/MessageAddress.php | 50 + lib/Client/MessageNotFound.php | 11 - lib/Client/MessageParser.php | 665 +++++++++ lib/Client/MessagePart.php | 217 +++ lib/Client/Mime/LazyMessage.php | 56 - lib/Client/Mime/Message.php | 55 - lib/Client/Mime/Part/Body.php | 20 - lib/Client/Mime/Part/Disposition.php | 14 - lib/Client/Mime/Part/LazyBody.php | 26 - lib/Client/Mime/Part/MultiPart.php | 31 - lib/Client/Mime/Part/Part.php | 36 - lib/Client/Mime/Part/SinglePart.php | 62 - lib/Client/PreFetchOptions.php | 14 - lib/Client/Protocol/Command/AppendCommand.php | 39 - .../Protocol/Command/Argument/Argument.php | 10 - lib/Client/Protocol/Command/Argument/Date.php | 24 - .../Protocol/Command/Argument/DateTime.php | 24 - .../Command/Argument/ParenthesizedList.php | 28 - .../Command/Argument/QuotedString.php | 17 - .../Protocol/Command/Argument/Search/All.php | 13 - .../Command/Argument/Search/Before.php | 15 - .../Protocol/Command/Argument/Search/Body.php | 15 - .../Command/Argument/Search/Criteria.php | 11 - .../Command/Argument/Search/Flagged.php | 13 - .../Protocol/Command/Argument/Search/From.php | 15 - .../Command/Argument/Search/Header.php | 19 - .../Command/Argument/Search/Larger.php | 15 - .../Protocol/Command/Argument/Search/Not.php | 17 - .../Protocol/Command/Argument/Search/Seen.php | 13 - .../Command/Argument/Search/Since.php | 15 - .../Command/Argument/Search/Smaller.php | 15 - .../Command/Argument/Search/Subject.php | 15 - .../Protocol/Command/Argument/Search/To.php | 15 - .../Command/Argument/Search/Unflagged.php | 13 - .../Command/Argument/Search/Unseen.php | 13 - .../Protocol/Command/Argument/SequenceSet.php | 86 -- .../Protocol/Command/Argument/Store/Flags.php | 30 - .../Command/Argument/SynchronizingLiteral.php | 20 - .../Command/Authenticate/SASLMechanism.php | 12 - .../Protocol/Command/Authenticate/XOAuth2.php | 26 - .../Protocol/Command/AuthenticateCommand.php | 20 - lib/Client/Protocol/Command/Command.php | 40 - lib/Client/Protocol/Command/Continuable.php | 10 - lib/Client/Protocol/Command/CreateCommand.php | 15 - .../Protocol/Command/ExpungeCommand.php | 13 - lib/Client/Protocol/Command/FetchCommand.php | 28 - lib/Client/Protocol/Command/ListCommand.php | 19 - lib/Client/Protocol/Command/LogInCommand.php | 19 - lib/Client/Protocol/Command/SearchCommand.php | 20 - lib/Client/Protocol/Command/SelectCommand.php | 15 - .../Protocol/Command/StartTlsCommand.php | 19 - lib/Client/Protocol/Command/StoreCommand.php | 23 - lib/Client/Protocol/CommandExecutor.php | 86 ++ lib/Client/Protocol/CommandFailed.php | 16 - lib/Client/Protocol/CommandInteraction.php | 70 - lib/Client/Protocol/ConnectionRejected.php | 11 - lib/Client/Protocol/ContinuationHandler.php | 10 - lib/Client/Protocol/Imap.php | 129 -- lib/Client/Protocol/ProtocolReader.php | 121 ++ lib/Client/Protocol/ProtocolWriter.php | 44 + lib/Client/Protocol/RequestFrame.php | 22 + .../Response/ContinuationResponse.php | 23 + .../Protocol/Response/GreetingResponse.php | 29 + .../Response/Line/CommandContinuation.php | 13 - .../Response/Line/Data/CapabilityData.php | 15 - .../Protocol/Response/Line/Data/Data.php | 11 - .../Response/Line/Data/ExistsData.php | 12 - .../Response/Line/Data/ExpungeData.php | 12 - .../Response/Line/Data/Fetch/Address.php | 16 - .../Response/Line/Data/Fetch/BodySection.php | 12 - .../Line/Data/Fetch/BodyStructure.php | 15 - .../Data/Fetch/BodyStructure/Disposition.php | 15 - .../Data/Fetch/BodyStructure/MessagePart.php | 42 - .../Data/Fetch/BodyStructure/MultiPart.php | 24 - .../Line/Data/Fetch/BodyStructure/Part.php | 18 - .../Data/Fetch/BodyStructure/SinglePart.php | 28 - .../Data/Fetch/BodyStructure/TextPart.php | 38 - .../Response/Line/Data/Fetch/Envelope.php | 32 - .../Protocol/Response/Line/Data/FetchData.php | 40 - .../Protocol/Response/Line/Data/FlagsData.php | 15 - .../Protocol/Response/Line/Data/ListData.php | 18 - .../Response/Line/Data/RecentData.php | 12 - .../Response/Line/Data/SearchData.php | 15 - lib/Client/Protocol/Response/Line/Line.php | 9 - .../Line/Status/Code/AppendUidCode.php | 14 - .../Response/Line/Status/Code/Code.php | 9 - .../Line/Status/Code/PermanentFlagsCode.php | 16 - .../Line/Status/Code/ReadOnlyCode.php | 9 - .../Line/Status/Code/ReadWriteCode.php | 9 - .../Response/Line/Status/Code/UidNextCode.php | 13 - .../Line/Status/Code/UidValidityCode.php | 13 - .../Response/Line/Status/Code/UnseenCode.php | 13 - .../Protocol/Response/Line/Status/Status.php | 19 - .../Response/Line/Status/StatusType.php | 12 - lib/Client/Protocol/Response/Parser/Lexer.php | 79 -- .../Protocol/Response/Parser/ParseError.php | 24 - .../Protocol/Response/Parser/Parser.php | 1221 ----------------- .../Protocol/Response/Parser/TokenType.php | 56 - lib/Client/Protocol/Response/Response.php | 37 - .../Protocol/Response/ResponseBuilder.php | 50 - .../Protocol/Response/ResponseInterface.php | 10 + .../Protocol/Response/TaggedResponse.php | 40 + .../Protocol/Response/UntaggedResponse.php | 43 + lib/Client/Protocol/ResponseHandler.php | 130 -- lib/Client/Protocol/ResponseStream.php | 28 + lib/Client/Protocol/TagGenerator.php | 27 +- .../UnexpectedContinuationHandler.php | 15 - lib/Client/Search.php | 83 -- lib/Client/SearchCriteriaBuilder.php | 377 +++++ lib/Client/SequenceSet.php | 104 ++ lib/Client/SessionContext.php | 89 ++ lib/Client/SessionState.php | 14 + lib/Client/Transport/Connection.php | 26 - .../Transport/ConnectionFactoryInterface.php | 13 + lib/Client/Transport/ConnectionFailed.php | 11 - lib/Client/Transport/ConnectionInterface.php | 24 + lib/Client/Transport/ResponseStream.php | 12 - .../Transport/Socket/SocketConnection.php | 148 -- .../Transport/Socket/SocketResponseStream.php | 44 - lib/Client/Transport/SocketConnection.php | 162 +++ .../Transport/SocketConnectionFactory.php | 16 + .../Traceable/TraceableConnection.php | 55 - .../Traceable/TraceableResponseStream.php | 43 - lib/Console/ServiceConnectCommand.php | 202 --- lib/Console/ServiceDisconnectCommand.php | 177 --- lib/Console/ServiceDiscoverCommand.php | 226 --- lib/Console/ServiceTestCommand.php | 203 --- lib/Providers/CollectionProperties.php | 32 +- lib/Providers/CollectionResource.php | 21 +- lib/Providers/EntityResource.php | 23 +- lib/Providers/MessageProperties.php | 228 ++- lib/Providers/Provider.php | 7 +- lib/Providers/Service.php | 306 +++-- lib/Providers/ServiceLocation.php | 41 +- lib/Service/Remote/RemoteMailService.php | 899 +++++++++--- lib/Service/Remote/RemoteService.php | 20 +- 179 files changed, 6782 insertions(+), 5907 deletions(-) create mode 100644 lib/Client/ClientInterface.php create mode 100644 lib/Client/Command/CapabilityCommand.php create mode 100644 lib/Client/Command/CommandInterface.php create mode 100644 lib/Client/Command/CopyCommand.php create mode 100644 lib/Client/Command/CreateCommand.php create mode 100644 lib/Client/Command/DeleteCommand.php create mode 100644 lib/Client/Command/ExpungeCommand.php create mode 100644 lib/Client/Command/FetchManyCommand.php create mode 100644 lib/Client/Command/FetchOneCommand.php create mode 100644 lib/Client/Command/FetchResponseParser.php create mode 100644 lib/Client/Command/ListCommand.php create mode 100644 lib/Client/Command/LoginCommand.php create mode 100644 lib/Client/Command/LogoutCommand.php create mode 100644 lib/Client/Command/MessageTransferCommand.php create mode 100644 lib/Client/Command/MoveCommand.php create mode 100644 lib/Client/Command/NoopCommand.php create mode 100644 lib/Client/Command/RenameCommand.php create mode 100644 lib/Client/Command/Result/CapabilityResult.php create mode 100644 lib/Client/Command/Result/CommandStatusResult.php create mode 100644 lib/Client/Command/Result/MessageTransferResult.php create mode 100644 lib/Client/Command/Result/SearchResult.php create mode 100644 lib/Client/Command/Result/SortResult.php create mode 100644 lib/Client/Command/Result/StatusResult.php create mode 100644 lib/Client/Command/SearchCommand.php create mode 100644 lib/Client/Command/SelectCommand.php create mode 100644 lib/Client/Command/SortCommand.php create mode 100644 lib/Client/Command/StartTlsCommand.php create mode 100644 lib/Client/Command/StatusCommand.php create mode 100644 lib/Client/Command/StatusResponseParser.php create mode 100644 lib/Client/Command/StoreCommand.php delete mode 100644 lib/Client/Configuration.php create mode 100644 lib/Client/ConnectionConfig.php create mode 100644 lib/Client/ConnectionSecurity.php create mode 100644 lib/Client/FetchOptions.php create mode 100644 lib/Client/FetchTarget.php create mode 100644 lib/Client/IdentifierMode.php create mode 100644 lib/Client/ImapException.php create mode 100644 lib/Client/ListReturnOptions.php create mode 100644 lib/Client/ListSelectionOptions.php create mode 100644 lib/Client/Message.php create mode 100644 lib/Client/MessageAddress.php delete mode 100644 lib/Client/MessageNotFound.php create mode 100644 lib/Client/MessageParser.php create mode 100644 lib/Client/MessagePart.php delete mode 100644 lib/Client/Mime/LazyMessage.php delete mode 100644 lib/Client/Mime/Message.php delete mode 100644 lib/Client/Mime/Part/Body.php delete mode 100644 lib/Client/Mime/Part/Disposition.php delete mode 100644 lib/Client/Mime/Part/LazyBody.php delete mode 100644 lib/Client/Mime/Part/MultiPart.php delete mode 100644 lib/Client/Mime/Part/Part.php delete mode 100644 lib/Client/Mime/Part/SinglePart.php delete mode 100644 lib/Client/PreFetchOptions.php delete mode 100644 lib/Client/Protocol/Command/AppendCommand.php delete mode 100644 lib/Client/Protocol/Command/Argument/Argument.php delete mode 100644 lib/Client/Protocol/Command/Argument/Date.php delete mode 100644 lib/Client/Protocol/Command/Argument/DateTime.php delete mode 100644 lib/Client/Protocol/Command/Argument/ParenthesizedList.php delete mode 100644 lib/Client/Protocol/Command/Argument/QuotedString.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/All.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Before.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Body.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Criteria.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Flagged.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/From.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Header.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Larger.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Not.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Seen.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Since.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Smaller.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Subject.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/To.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Unflagged.php delete mode 100644 lib/Client/Protocol/Command/Argument/Search/Unseen.php delete mode 100644 lib/Client/Protocol/Command/Argument/SequenceSet.php delete mode 100644 lib/Client/Protocol/Command/Argument/Store/Flags.php delete mode 100644 lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php delete mode 100644 lib/Client/Protocol/Command/Authenticate/SASLMechanism.php delete mode 100644 lib/Client/Protocol/Command/Authenticate/XOAuth2.php delete mode 100644 lib/Client/Protocol/Command/AuthenticateCommand.php delete mode 100644 lib/Client/Protocol/Command/Command.php delete mode 100644 lib/Client/Protocol/Command/Continuable.php delete mode 100644 lib/Client/Protocol/Command/CreateCommand.php delete mode 100644 lib/Client/Protocol/Command/ExpungeCommand.php delete mode 100644 lib/Client/Protocol/Command/FetchCommand.php delete mode 100644 lib/Client/Protocol/Command/ListCommand.php delete mode 100644 lib/Client/Protocol/Command/LogInCommand.php delete mode 100644 lib/Client/Protocol/Command/SearchCommand.php delete mode 100644 lib/Client/Protocol/Command/SelectCommand.php delete mode 100644 lib/Client/Protocol/Command/StartTlsCommand.php delete mode 100644 lib/Client/Protocol/Command/StoreCommand.php create mode 100644 lib/Client/Protocol/CommandExecutor.php delete mode 100644 lib/Client/Protocol/CommandFailed.php delete mode 100644 lib/Client/Protocol/CommandInteraction.php delete mode 100644 lib/Client/Protocol/ConnectionRejected.php delete mode 100644 lib/Client/Protocol/ContinuationHandler.php delete mode 100644 lib/Client/Protocol/Imap.php create mode 100644 lib/Client/Protocol/ProtocolReader.php create mode 100644 lib/Client/Protocol/ProtocolWriter.php create mode 100644 lib/Client/Protocol/RequestFrame.php create mode 100644 lib/Client/Protocol/Response/ContinuationResponse.php create mode 100644 lib/Client/Protocol/Response/GreetingResponse.php delete mode 100644 lib/Client/Protocol/Response/Line/CommandContinuation.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/CapabilityData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Data.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/ExistsData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/ExpungeData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/Address.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodySection.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Disposition.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/FetchData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/FlagsData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/ListData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/RecentData.php delete mode 100644 lib/Client/Protocol/Response/Line/Data/SearchData.php delete mode 100644 lib/Client/Protocol/Response/Line/Line.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/AppendUidCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/Code.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/PermanentFlagsCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/ReadOnlyCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/ReadWriteCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/UidNextCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/UidValidityCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Code/UnseenCode.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/Status.php delete mode 100644 lib/Client/Protocol/Response/Line/Status/StatusType.php delete mode 100644 lib/Client/Protocol/Response/Parser/Lexer.php delete mode 100644 lib/Client/Protocol/Response/Parser/ParseError.php delete mode 100644 lib/Client/Protocol/Response/Parser/Parser.php delete mode 100644 lib/Client/Protocol/Response/Parser/TokenType.php delete mode 100644 lib/Client/Protocol/Response/Response.php delete mode 100644 lib/Client/Protocol/Response/ResponseBuilder.php create mode 100644 lib/Client/Protocol/Response/ResponseInterface.php create mode 100644 lib/Client/Protocol/Response/TaggedResponse.php create mode 100644 lib/Client/Protocol/Response/UntaggedResponse.php delete mode 100644 lib/Client/Protocol/ResponseHandler.php create mode 100644 lib/Client/Protocol/ResponseStream.php delete mode 100644 lib/Client/Protocol/UnexpectedContinuationHandler.php delete mode 100644 lib/Client/Search.php create mode 100644 lib/Client/SearchCriteriaBuilder.php create mode 100644 lib/Client/SequenceSet.php create mode 100644 lib/Client/SessionContext.php create mode 100644 lib/Client/SessionState.php delete mode 100644 lib/Client/Transport/Connection.php create mode 100644 lib/Client/Transport/ConnectionFactoryInterface.php delete mode 100644 lib/Client/Transport/ConnectionFailed.php create mode 100644 lib/Client/Transport/ConnectionInterface.php delete mode 100644 lib/Client/Transport/ResponseStream.php delete mode 100644 lib/Client/Transport/Socket/SocketConnection.php delete mode 100644 lib/Client/Transport/Socket/SocketResponseStream.php create mode 100644 lib/Client/Transport/SocketConnection.php create mode 100644 lib/Client/Transport/SocketConnectionFactory.php delete mode 100644 lib/Client/Transport/Traceable/TraceableConnection.php delete mode 100644 lib/Client/Transport/Traceable/TraceableResponseStream.php delete mode 100644 lib/Console/ServiceConnectCommand.php delete mode 100644 lib/Console/ServiceDisconnectCommand.php delete mode 100644 lib/Console/ServiceDiscoverCommand.php delete mode 100644 lib/Console/ServiceTestCommand.php diff --git a/composer.json b/composer.json index 887c7b3..e5ec9a8 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,7 @@ }, "autoload": { "psr-4": { - "KTXM\\ProviderImap\\": "lib/", - "Gricob\\IMAP\\": "lib/Client" + "KTXM\\ProviderImap\\": "lib/" } }, "autoload-dev": { diff --git a/lib/Client/Client.php b/lib/Client/Client.php index c630cfa..aa55f64 100644 --- a/lib/Client/Client.php +++ b/lib/Client/Client.php @@ -2,622 +2,97 @@ declare(strict_types=1); -namespace Gricob\IMAP; +namespace KTXM\ProviderImap\Client; -use DateTimeImmutable; -use DateTimeInterface; -use Exception; -use Generator; -use Gricob\IMAP\Mime\LazyMessage; -use Gricob\IMAP\Mime\Message; -use Gricob\IMAP\Mime\Part\Disposition; -use Gricob\IMAP\Mime\Part\LazyBody; -use Gricob\IMAP\Mime\Part\MultiPart; -use Gricob\IMAP\Mime\Part\Part; -use Gricob\IMAP\Mime\Part\SinglePart; -use Gricob\IMAP\Protocol\Command\AppendCommand; -use Gricob\IMAP\Protocol\Command\Argument\QuotedString; -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; -use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; -use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; -use Gricob\IMAP\Protocol\Command\Authenticate\SASLMechanism; -use Gricob\IMAP\Protocol\Command\AuthenticateCommand; -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\CreateCommand; -use Gricob\IMAP\Protocol\Command\ExpungeCommand; -use Gricob\IMAP\Protocol\Command\FetchCommand; -use Gricob\IMAP\Protocol\Command\ListCommand; -use Gricob\IMAP\Protocol\Command\LogInCommand; -use Gricob\IMAP\Protocol\Command\SearchCommand; -use Gricob\IMAP\Protocol\Command\SelectCommand; -use Gricob\IMAP\Protocol\Command\StoreCommand; -use Gricob\IMAP\Protocol\Imap; -use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure; -use Gricob\IMAP\Protocol\Response\Line\Data\FlagsData; -use Gricob\IMAP\Protocol\Response\Line\Data\ExistsData; -use Gricob\IMAP\Protocol\Response\Line\Data\RecentData; -use Gricob\IMAP\Protocol\Response\Line\Data\ListData; -use Gricob\IMAP\Protocol\Response\Line\Data\SearchData; -use Gricob\IMAP\Protocol\Response\Line\Status\Code\AppendUidCode; -use Gricob\IMAP\Protocol\Response\Line\Status\Code\PermanentFlagsCode; -use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidNextCode; -use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidValidityCode; -use Gricob\IMAP\Protocol\Response\Line\Status\Code\UnseenCode; -use Gricob\IMAP\Protocol\Response\Line\Status\Status; -use Gricob\IMAP\Protocol\Response\Response; -use Gricob\IMAP\Transport\Socket\SocketConnection; -use Gricob\IMAP\Transport\Traceable\TraceableConnection; +use KTXM\ProviderImap\Client\Command\CapabilityCommand; +use KTXM\ProviderImap\Client\Command\CommandInterface; +use KTXM\ProviderImap\Client\Command\LoginCommand; +use KTXM\ProviderImap\Client\Command\StatusCommand; +use KTXM\ProviderImap\Client\Command\StartTlsCommand; +use KTXM\ProviderImap\Client\Protocol\CommandExecutor; +use KTXM\ProviderImap\Client\Protocol\ProtocolReader; +use KTXM\ProviderImap\Client\Protocol\ProtocolWriter; +use KTXM\ProviderImap\Client\Protocol\TagGenerator; +use KTXM\ProviderImap\Client\Transport\ConnectionFactoryInterface; +use KTXM\ProviderImap\Client\Transport\SocketConnectionFactory; use Psr\Log\LoggerInterface; -use RuntimeException; -class Client +final class Client implements ClientInterface { - public Configuration $configuration; - private Imap $imap; + private ?SessionContext $session = null; + private ?CommandExecutor $executor = null; - private Mailbox $selectedMailbox; + public function __construct( + private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(), + private readonly ?LoggerInterface $logger = null, + ) {} - private function __construct( - Configuration $configuration, - ?LoggerInterface $logger, - ) { - $connection = new SocketConnection( - $configuration->transport, - $configuration->host, - $configuration->port, - $configuration->timeout, - $configuration->verifyPeer, - $configuration->verifyPeerName, - $configuration->allowSelfSigned, - ); + public function connect(ConnectionConfig $config): void + { + $connection = $this->connectionFactory->create($config, $this->logger); + $connection->connect($config); - if (null !== $logger) { - $connection = new TraceableConnection($connection, $logger); + $reader = new ProtocolReader($connection, $this->logger); + $writer = new ProtocolWriter($connection, $this->logger); + $session = new SessionContext($config, $connection); + $greeting = $reader->readGreeting(); + + $session->setGreeting($greeting); + $session->setState(match ($greeting->status()) { + 'OK' => SessionState::NotAuthenticated, + 'PREAUTH' => SessionState::Authenticated, + 'BYE' => SessionState::Logout, + default => throw new ImapException('Unexpected IMAP greeting status: ' . $greeting->status()), + }); + + if ($session->state() === SessionState::Logout) { + throw new ImapException('IMAP server rejected the connection: ' . $greeting->text()); } - $this->configuration = $configuration; - $this->imap = new Imap($connection); - $this->selectedMailbox = new Mailbox([], '', ''); - } + $this->session = $session; + $this->executor = new CommandExecutor($reader, $writer, new TagGenerator(), $this->logger); - public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self - { - return new self($configuration, $logger); - } + $this->perform(new CapabilityCommand()); - public function connect(): void - { - $this->imap->connect(); - } - - /** - * Perform STARTTLS negotiation (patch). - * - * Call after connect() but before logIn(). The underlying Imap protocol - * layer sends the STARTTLS command and upgrades the socket to TLS. - */ - public function startTls(): void - { - $this->imap->startTls(); - } - - public function disconnect(): void - { - $this->imap->disconnect(); - } - - public function logIn(string $username, string $password): void - { - $this->send(new LogInCommand($username, $password)); - } - - public function authenticate(SASLMechanism $mechanism): void - { - $this->send(new AuthenticateCommand($mechanism)); - } - - /** - * @return array - */ - public function mailboxes(string $referenceName = '', string $pattern = '*'): array - { - $response = $this->send(new ListCommand($referenceName, $pattern)); - - return array_map( - fn (ListData $data) => new Mailbox($data->nameAttributes, $data->hierarchyDelimiter, $data->name), - $response->getData(ListData::class), - ); - } - - public function select(Mailbox|string $mailbox): Mailbox - { - if (is_string($mailbox)) { - $mailbox = new Mailbox([], '', $mailbox); + if ($config->security() === ConnectionSecurity::StartTls) { + $this->perform(new StartTlsCommand()); + $this->perform(new CapabilityCommand()); } - $response = $this->send(new SelectCommand($mailbox->name)); - - if ($flagsData = $response->getData(FlagsData::class)[0] ?? null) { - $mailbox->flags = $flagsData->flags; - } - - if ($existsData = $response->getData(ExistsData::class)[0] ?? null) { - $mailbox->exists = $existsData->numberOfMessages; - } - - if ($recentData = $response->getData(RecentData::class)[0] ?? null) { - $mailbox->recent = $recentData->numberOfMessages; - } - - foreach ($response->getData(Status::class) as $status) { - if ($status->code instanceof UnseenCode) { - $mailbox->unseen = $status->code->seq; - } elseif ($status->code instanceof UidValidityCode) { - $mailbox->uidValidity = $status->code->value; - } elseif ($status->code instanceof UidNextCode) { - $mailbox->uidNext = $status->code->value; - } elseif ($status->code instanceof PermanentFlagsCode) { - $mailbox->permanentFlags = $status->code->flags; - } - } - - return $this->selectedMailbox = $mailbox; - } - - public function search(): Search - { - return new Search($this); - } - - /** - * @throws MessageNotFound - */ - public function fetch(int $id): Message - { - $response = $this->imap->send( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet($id), - ['INTERNALDATE', 'BODY[HEADER]', 'BODYSTRUCTURE'] - ) - ); - - $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); - - if (null === $internalDate = $data->internalDate) { - throw new Exception('Unable to fetch internal date from message '.$id); - } - - if (null === $part = $data->bodyStructure?->part) { - throw new Exception('Unable to fetch body structure from message '.$id); - } - - return new Message( - $id, - $this->createHeaders($data) ?? [], - $this->createMessagePart($id, '0', $part), - $internalDate, - ); - } - - /** - * Stream FetchData for a specific set of UIDs, one response line at a time. - * - * Uses the same sendStreaming path as fetchMultiple() so responses are - * processed as they arrive off the socket without buffering the entire - * server reply. Items can be tailored per call-site; defaults to a rich - * set that populates EntityResource fully (flags, envelope, body structure, - * size, arrival date). - * - * @param int[] $uids - * @param string[] $items IMAP fetch data items - * @return Generator Yields uid => FetchData - */ - public function streamByUids( - array $uids, - array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'], - ): Generator { - $gen = $this->imap->sendStreaming( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet(...$uids), - $items, - ) - ); - - foreach ($gen as $line) { - if (!$line instanceof FetchData) { - continue; - } - - $id = $line->id; - if ($this->configuration->useUid) { - $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); - } - - yield $id => $line; - } - } - - /** - * Stream every message in the currently-selected mailbox using a 1:* - * sequence set, yielding uid (or sequence number) => FetchData as each - * FETCH response arrives off the socket. - * - * @param string $mailbox Mailbox to select before fetching - * @param string[] $items IMAP FETCH data items - * @return Generator - */ - public function streamAll( - string $mailbox, - array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'], - ): Generator { - $this->select($mailbox); - - $gen = $this->imap->sendStreaming( - new FetchCommand( - $this->configuration->useUid, - SequenceSet::all(), - $items, - ) - ); - - foreach ($gen as $line) { - if (!$line instanceof FetchData) { - continue; - } - - $id = $line->id; - if ($this->configuration->useUid) { - $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); - } - - yield $id => $line; - } - } - - /** - * Stream messages from a sequence range as a Generator, yielding each - * LazyMessage as soon as its FETCH response line arrives off the socket — - * without waiting for the entire batch to complete. - * - * Usage with an NDJSON HTTP response: - * - * foreach ($client->fetchMultiple(1, 50) as $message) { - * echo json_encode($message) . "\n"; - * flush(); - * } - * - * @param int $from First sequence number (inclusive) - * @param int $to Last sequence number (inclusive) - * @return Generator - */ - public function fetchMultiple(int $from, int $to): Generator - { - $items = ['FLAGS', 'INTERNALDATE', 'BODY[HEADER]']; - - $gen = $this->imap->sendStreaming( - new FetchCommand( - $this->configuration->useUid, - SequenceSet::range($from, $to), - $items, - ) - ); - - foreach ($gen as $line) { - if (!$line instanceof FetchData) { - continue; - } - - $id = $line->id; - if ($this->configuration->useUid) { - $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); - } - - yield new LazyMessage( - $this, - $id, - $this->createHeaders($line), - $line->internalDate, - ); - } - } - - /** - * @return array - * @throws MessageNotFound - */ - public function fetchHeaders(int $id): array - { - $response = $this->imap->send( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet($id), - ['BODY[HEADER]'] - ) - ); - - /** @var FetchData $data */ - $data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound(); - - return $this->createHeaders($data) ?? []; - } - - public function fetchBody(int $id): Part - { - $response = $this->send( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet($id), - ['BODYSTRUCTURE'] - ) - ); - - $data = $response->getData(FetchData::class)[0]; - - if (null === $part = $data->bodyStructure?->part) { - throw new Exception('Unable to fetch body from message '.$id); - } - - return $this->createMessagePart($id, '0', $part); - } - - public function fetchInternalDate(int $id): DateTimeImmutable - { - $response = $this->send( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet($id), - ['INTERNALDATE'] - ) - ); - - $data = $response->getData(FetchData::class)[0]; - - if (null === $internalDate = $data->internalDate) { - throw new Exception('Unable to fetch internal date from message '.$id); - } - - return $internalDate; - } - - public function fetchSectionBody(int $id, string $section): string - { - $response = $this->send( - new FetchCommand( - $this->configuration->useUid, - new SequenceSet($id), - ["BODY[$section]"] - ) - ); - - $data = $response->getData(FetchData::class)[0]; - - return $data->getBodySection($section)?->text ?? ''; - } - - public function deleteMessage(Message|int $message): void - { - $id = $message instanceof Message ? $message->id() : $message; - - $this->send( - new StoreCommand( - $this->configuration->useUid, - new SequenceSet($id), - new Flags(['\Deleted'], '+') - ) - ); - - $this->send(new ExpungeCommand()); - } - - public function createMailbox(string $name): void - { - $this->send(new CreateCommand($name)); - } - - /** Delete a mailbox by name. */ - public function deleteMailbox(string $name): void - { - $this->send(new Command('DELETE', new QuotedString($name))); - } - - /** Rename a mailbox. */ - public function renameMailbox(string $oldName, string $newName): void - { - $this->send(new Command('RENAME', new QuotedString($oldName), new QuotedString($newName))); - } - - /** - * Copy messages to a destination mailbox. - * - * @param int[] $uids - */ - public function copyMessages(string $mailbox, array $uids, string $destination): void - { - $this->select($mailbox); - $this->send(new Command('UID COPY', new SequenceSet(...$uids), new QuotedString($destination))); - } - - /** - * Set, add, or remove flags on a set of messages in a single round-trip. - * - * @param string $action '+' to add, '-' to remove, '' to replace - * @param string[] $flags e.g. ['\\Seen', '\\Flagged'] - * @param int[] $uids - */ - public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void - { - $this->select($mailbox); - $this->send(new StoreCommand( - $this->configuration->useUid, - new SequenceSet(...$uids), - new Flags($flags, $action), - )); - } - - /** - * Permanently delete messages by UID (marks \\Deleted then EXPUNGEs). - * - * @param int[] $uids - */ - public function deleteMessages(string $mailbox, array $uids): void - { - $this->storeFlags($mailbox, $uids, '+', ['\\Deleted']); - $this->send(new ExpungeCommand()); - } - - /** - * Search a mailbox with the given criteria and return matching UIDs (or - * sequence numbers when useUid is false). - * - * @param Criteria[] $criteria Pass no criteria to match ALL messages. - * @return int[] - */ - public function searchMessages(string $mailbox, array $criteria = []): array - { - $this->select($mailbox); - $response = $this->send(new SearchCommand($this->configuration->useUid, ...$criteria)); - $ids = []; - foreach ($response->getData(SearchData::class) as $searchData) { - array_push($ids, ...$searchData->numbers); - } - return $ids; - } - - /** - * @param list|null $flags - */ - public function append( - string $message, - string $mailbox = 'INBOX', - ?array $flags = null, - ?DateTimeInterface $internalDate = null - ): int - { - $response = $this->send(new AppendCommand($mailbox, $message, $flags, $internalDate)); - - $code = $response->status->code; - if ($code instanceof AppendUidCode) { - return $code->uid; - } - - throw new RuntimeException('Unable to retrieve uid from append response'); - } - - public function send(Command $command): Response - { - $this->imap->connect(); - - return $this->imap->send($command); - } - - /** - * @param array $criteria - * @return array - */ - public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array - { - $response = $this->send( - new SearchCommand( - $this->configuration->useUid, - ...$criteria - ) - ); - - $ids = []; - foreach ($response->data as $data) { - if ($data instanceof SearchData) { - array_push($ids, ...$data->numbers); - } - } - - if (empty($ids)) { - return []; - } - - if (null !== $preFetchOptions) { - $items = []; - - if ($preFetchOptions->headers) { - $items[] = 'BODY[HEADER]'; - } - - if ($preFetchOptions->internalDate) { - $items[] = 'INTERNALDATE'; - } - - $preFetchResult = $this->send(new FetchCommand( - $this->configuration->useUid, - new SequenceSet(...$ids), - $items, + if ($config->hasCredentials()) { + $this->perform(new LoginCommand( + $config->username() ?? '', + $config->password() ?? '', )); - - $messages = []; - foreach ($preFetchResult->data as $data) { - if ($data instanceof FetchData) { - $id = $data->id; - if ($this->configuration->useUid) { - $id = $data->uid ?? throw new RuntimeException('Unable to get uid from message '.$id); - } - - $messages[] = new LazyMessage( - $this, - $id, - $this->createHeaders($data), - $data->internalDate, - ); - } - } - - return $messages; + $this->perform(new CapabilityCommand()); } - - return array_map(fn (int $id) => new LazyMessage($this, $id), $ids); } - /** - * @return array|null - */ - private function createHeaders(FetchData $data): ?array + public function capabilities(): array { - if (null === $headerSection = $data->getBodySection('HEADER')) { - return null; - } - - return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: []; + return $this->session()->capabilities(); } - private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part + public function hasCapability(string $capability): bool { - if ($part instanceof BodyStructure\SinglePart) { - return new SinglePart( - $part->type, - $part->subtype, - $part->attributes, - new LazyBody($this, $id, $section === '0' ? '1' : $section), - $part->attributes['charset'] ?? 'utf-8', - $part->encoding, - null !== $part->disposition - ? new Disposition( - $part->disposition->type, - $part->disposition->attributes['filename'] ?? null - ) : null, - ); - } - - if (!$part instanceof BodyStructure\MultiPart) { - throw new Exception('Unable to create message part from body structure part of class '.$part::class); - } - - $childParts = []; - foreach ($part->parts as $index => $childPart) { - $childIndex = (string) ($index + 1); - $childSection = $section === '0' ? $childIndex : $section.'.'.$childIndex; - $childParts[] = $this->createMessagePart($id, $childSection, $childPart); - } - - return new MultiPart($part->subtype, $part->attributes, $childParts); + return $this->session()->hasCapability($capability); } -} + + public function perform(CommandInterface $command): mixed + { + if ($this->session === null || $this->executor === null) { + throw new ImapException('IMAP client is not connected.'); + } + + return $this->executor->perform($command, $this->session); + } + + public function session(): SessionContext + { + if ($this->session === null) { + throw new ImapException('IMAP client is not connected.'); + } + + return $this->session; + } +} \ No newline at end of file diff --git a/lib/Client/ClientInterface.php b/lib/Client/ClientInterface.php new file mode 100644 index 0000000..9831d9d --- /dev/null +++ b/lib/Client/ClientInterface.php @@ -0,0 +1,24 @@ + + */ + public function capabilities(): array; + + /** + * @template TResult + * @param CommandInterface $command + * @return TResult + */ + public function perform(CommandInterface $command): mixed; +} \ No newline at end of file diff --git a/lib/Client/Command/CapabilityCommand.php b/lib/Client/Command/CapabilityCommand.php new file mode 100644 index 0000000..b052586 --- /dev/null +++ b/lib/Client/Command/CapabilityCommand.php @@ -0,0 +1,62 @@ + + */ +final class CapabilityCommand implements CommandInterface +{ + public function name(): string + { + return 'CAPABILITY'; + } + + public function allowedStates(): array + { + return [ + SessionState::NotAuthenticated, + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame('CAPABILITY'); + } + + public function handle(ResponseStream $responses, SessionContext $context): CapabilityResult + { + $capabilities = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') { + $capabilities = array_map('strtoupper', $response->payloadTokens()); + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('CAPABILITY failed: ' . $response->text()); + } + } + } + + $context->replaceCapabilities(...$capabilities); + + return new CapabilityResult($context->capabilities()); + } +} \ No newline at end of file diff --git a/lib/Client/Command/CommandInterface.php b/lib/Client/Command/CommandInterface.php new file mode 100644 index 0000000..472a66f --- /dev/null +++ b/lib/Client/Command/CommandInterface.php @@ -0,0 +1,30 @@ + + */ + public function allowedStates(): array; + + public function encode(string $tag, SessionContext $context): RequestFrame; + + /** + * @return TResult + */ + public function handle(ResponseStream $responses, SessionContext $context): mixed; +} \ No newline at end of file diff --git a/lib/Client/Command/CopyCommand.php b/lib/Client/Command/CopyCommand.php new file mode 100644 index 0000000..a0ce31d --- /dev/null +++ b/lib/Client/Command/CopyCommand.php @@ -0,0 +1,47 @@ + + */ +final class CopyCommand implements CommandInterface +{ + private readonly MessageTransferCommand $command; + + public function __construct( + FetchTarget|string|SequenceSet|null $target = null, + string $destinationMailbox = '', + ) { + $this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox); + } + + public function name(): string + { + return $this->command->name(); + } + + public function allowedStates(): array + { + return $this->command->allowedStates(); + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + return $this->command->encode($tag, $context); + } + + public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult + { + return $this->command->handle($responses, $context); + } +} \ No newline at end of file diff --git a/lib/Client/Command/CreateCommand.php b/lib/Client/Command/CreateCommand.php new file mode 100644 index 0000000..7427387 --- /dev/null +++ b/lib/Client/Command/CreateCommand.php @@ -0,0 +1,65 @@ + + */ +final class CreateCommand implements CommandInterface +{ + public function __construct( + private readonly string $mailbox, + ) {} + + public function name(): string + { + return 'CREATE'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf('CREATE %s', $this->quote($this->mailbox))); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + unset($context); + + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('CREATE failed: ' . $response->text()); + } + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('CREATE did not receive a tagged completion response.'); + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/DeleteCommand.php b/lib/Client/Command/DeleteCommand.php new file mode 100644 index 0000000..4dfcd29 --- /dev/null +++ b/lib/Client/Command/DeleteCommand.php @@ -0,0 +1,68 @@ + + */ +final class DeleteCommand implements CommandInterface +{ + public function __construct( + private readonly string $mailbox, + ) {} + + public function name(): string + { + return 'DELETE'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf('DELETE %s', $this->quote($this->mailbox))); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + if ($context->selectedMailbox() === $this->mailbox) { + $context->setSelectedMailbox(null); + $context->setState(SessionState::Authenticated); + } + + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('DELETE failed: ' . $response->text()); + } + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('DELETE did not receive a tagged completion response.'); + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/ExpungeCommand.php b/lib/Client/Command/ExpungeCommand.php new file mode 100644 index 0000000..a5ccec7 --- /dev/null +++ b/lib/Client/Command/ExpungeCommand.php @@ -0,0 +1,106 @@ +> + */ +final class ExpungeCommand implements CommandInterface +{ + private readonly ?SequenceSet $sequenceSet; + + public function __construct(FetchTarget|string|SequenceSet|null $target = null) + { + if ($target === null) { + $this->sequenceSet = null; + + return; + } + + $resolvedTarget = match (true) { + $target instanceof FetchTarget => $target, + $target instanceof SequenceSet => FetchTarget::sequence($target), + is_string($target) => FetchTarget::sequence($target), + default => null, + }; + + if ($resolvedTarget === null || $resolvedTarget->identifierMode() !== IdentifierMode::Uid) { + throw new ImapException('Targeted EXPUNGE requires a UID target.'); + } + + $this->sequenceSet = $resolvedTarget->sequenceSet(); + } + + public function name(): string + { + return 'EXPUNGE'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag); + + if ($this->sequenceSet === null) { + unset($context); + + return new RequestFrame('EXPUNGE'); + } + + if (!$context->hasCapability('UIDPLUS')) { + throw new ImapException('UID EXPUNGE requires the IMAP UIDPLUS capability.'); + } + + return new RequestFrame(sprintf( + 'UID EXPUNGE %s', + $this->sequenceSet->toCommand(), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): array + { + if ($context->selectedMailbox() === null) { + throw new ImapException('EXPUNGE requires a selected mailbox.'); + } + + $expunged = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) { + $expunged[] = (int) $matches[1]; + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException($this->sequenceSet === null + ? 'EXPUNGE failed: ' . $response->text() + : 'UID EXPUNGE failed: ' . $response->text()); + } + + return $expunged; + } + } + + throw new ImapException($this->sequenceSet === null + ? 'EXPUNGE did not receive a tagged completion response.' + : 'UID EXPUNGE did not receive a tagged completion response.'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/FetchManyCommand.php b/lib/Client/Command/FetchManyCommand.php new file mode 100644 index 0000000..4c38cef --- /dev/null +++ b/lib/Client/Command/FetchManyCommand.php @@ -0,0 +1,67 @@ +> + */ +final class FetchManyCommand implements CommandInterface +{ + private readonly FetchTarget $target; + private readonly FetchOptions $options; + + public function __construct(FetchTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null) + { + $this->target = match (true) { + $target instanceof FetchTarget => $target, + $target instanceof SequenceSet => FetchTarget::sequence($target), + is_string($target) => FetchTarget::sequence($target), + default => FetchTarget::all(), + }; + $this->options = $options ?? FetchOptions::default(); + } + + public function name(): string + { + return 'FETCH'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + '%s %s (%s)', + $this->target->toCommand(), + $this->target->sequenceSet()->toCommand(), + $this->options->toCommand(), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): Generator + { + if ($context->selectedMailbox() === null) { + throw new ImapException('FETCH requires a selected mailbox.'); + } + + return (new FetchResponseParser())->parseMany($responses); + } +} \ No newline at end of file diff --git a/lib/Client/Command/FetchOneCommand.php b/lib/Client/Command/FetchOneCommand.php new file mode 100644 index 0000000..60cb278 --- /dev/null +++ b/lib/Client/Command/FetchOneCommand.php @@ -0,0 +1,62 @@ + + */ +final class FetchOneCommand implements CommandInterface +{ + private readonly FetchTarget $target; + private readonly FetchOptions $options; + + public function __construct(FetchTarget|int|string $target, ?FetchOptions $options = null) + { + $this->target = $target instanceof FetchTarget + ? $target + : FetchTarget::sequence($target); + $this->options = $options ?? FetchOptions::default(); + } + + public function name(): string + { + return 'FETCH'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + '%s %s (%s)', + $this->target->toCommand(), + $this->target->sequenceSet()->toCommand(), + $this->options->toCommand(), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): Message + { + if ($context->selectedMailbox() === null) { + throw new ImapException('FETCH requires a selected mailbox.'); + } + + return (new FetchResponseParser())->parseOne($responses); + } +} \ No newline at end of file diff --git a/lib/Client/Command/FetchResponseParser.php b/lib/Client/Command/FetchResponseParser.php new file mode 100644 index 0000000..78e1489 --- /dev/null +++ b/lib/Client/Command/FetchResponseParser.php @@ -0,0 +1,58 @@ +parseMany($responses) as $summary) { + if ($message !== null) { + throw new ImapException('FETCH returned multiple messages for a single-message request.'); + } + + $message = $summary; + } + + if ($message === null) { + throw new ImapException('FETCH did not return a message summary.'); + } + + return $message; + } + + /** + * @return Generator + */ + public function parseMany(ResponseStream $responses): Generator + { + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) { + yield MessageParser::parse($response->raw()); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('FETCH failed: ' . $response->text()); + } + + return; + } + } + + throw new ImapException('FETCH did not receive a tagged completion response.'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/ListCommand.php b/lib/Client/Command/ListCommand.php new file mode 100644 index 0000000..cef1828 --- /dev/null +++ b/lib/Client/Command/ListCommand.php @@ -0,0 +1,263 @@ +> + */ +final class ListCommand implements CommandInterface +{ + private readonly ListSelectionOptions $selectionOptions; + private readonly ListReturnOptions $returnOptions; + private readonly StatusResponseParser $statusResponseParser; + + public function __construct( + private readonly string $reference = '', + private readonly string $pattern = '*', + ?ListSelectionOptions $selectionOptions = null, + ?ListReturnOptions $returnOptions = null, + ) { + $this->selectionOptions = $selectionOptions ?? ListSelectionOptions::none(); + $this->returnOptions = $returnOptions ?? ListReturnOptions::none(); + $this->statusResponseParser = new StatusResponseParser(); + } + + public function name(): string + { + return 'LIST'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + $command = 'LIST'; + + $selectionOptions = $this->selectionOptions->toCommand(); + if ($selectionOptions !== null) { + $command .= ' ' . $selectionOptions; + } + + $command .= sprintf( + ' %s %s', + $this->quote($this->reference), + $this->quote($this->pattern), + ); + + $returnOptions = $this->returnOptions->toCommand(); + if ($returnOptions !== null) { + $command .= ' RETURN ' . $returnOptions; + } + + return new RequestFrame($command); + } + + public function handle(ResponseStream $responses, SessionContext $context): Generator + { + unset($context); + + if (!$this->returnOptions->hasStatus()) { + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'LIST') { + yield $this->parseMailbox($response->payload()); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('LIST failed: ' . $response->text()); + } + + return; + } + } + + throw new ImapException('LIST did not receive a tagged completion response.'); + } + + $mailboxes = []; + $statuses = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'LIST') { + $mailbox = $this->parseMailbox($response->payload()); + $mailboxes[$mailbox->name()] = $this->applyStatus( + $mailbox, + $statuses[$mailbox->name()] ?? [], + ); + continue; + } + + if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') { + [$mailboxName, $status] = $this->statusResponseParser->parse($response->payload()); + $statuses[$mailboxName] = $status; + + if (isset($mailboxes[$mailboxName])) { + $mailboxes[$mailboxName] = $this->applyStatus($mailboxes[$mailboxName], $status); + } + + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('LIST failed: ' . $response->text()); + } + + foreach ($mailboxes as $mailbox) { + yield $mailbox; + } + + return; + } + } + + throw new ImapException('LIST did not receive a tagged completion response.'); + } + + private function parseMailbox(string $payload): Mailbox + { + $payload = trim($payload); + $offset = 0; + + $attributesToken = $this->readToken($payload, $offset); + $delimiterToken = $this->readToken($payload, $offset); + $nameToken = $this->readToken($payload, $offset); + + if ($attributesToken === null || $delimiterToken === null || $nameToken === null) { + throw new ImapException('Unable to parse LIST response payload: ' . $payload); + } + + $attributeString = trim($attributesToken, '() '); + $attributes = $attributeString === '' || strtoupper($attributeString) === 'NIL' + ? [] + : array_map('strtoupper', preg_split('/\s+/', $attributeString) ?: []); + + $delimiter = $this->decodeAtom($delimiterToken); + $name = $this->decodeMailboxName($nameToken); + + return new Mailbox($name, $delimiter, $attributes); + } + + /** + * @param array $status + */ + private function applyStatus(Mailbox $mailbox, array $status): Mailbox + { + return new Mailbox( + $mailbox->name(), + $mailbox->delimiter(), + $mailbox->attributes(), + $status['MESSAGES'] ?? $mailbox->messages(), + $status['UNSEEN'] ?? $mailbox->unread(), + $mailbox->state(), + $mailbox->recent(), + $mailbox->flags(), + $mailbox->readOnly(), + ); + } + + private function readToken(string $payload, int &$offset): ?string + { + $length = strlen($payload); + + while ($offset < $length && ctype_space($payload[$offset])) { + $offset++; + } + + if ($offset >= $length) { + return null; + } + + if ($payload[$offset] === '(') { + $end = strpos($payload, ')', $offset); + + if ($end === false) { + throw new ImapException('Unterminated LIST attribute block: ' . $payload); + } + + $token = substr($payload, $offset, $end - $offset + 1); + $offset = $end + 1; + + return $token; + } + + if ($payload[$offset] === '"') { + $start = $offset; + $offset++; + + while ($offset < $length) { + if ($payload[$offset] === '\\') { + $offset += 2; + continue; + } + + if ($payload[$offset] === '"') { + $offset++; + return substr($payload, $start, $offset - $start); + } + + $offset++; + } + + throw new ImapException('Unterminated quoted LIST token: ' . $payload); + } + + $start = $offset; + while ($offset < $length && !ctype_space($payload[$offset])) { + $offset++; + } + + return substr($payload, $start, $offset - $start); + } + + private function decodeAtom(string $value): ?string + { + $value = trim($value); + + if (strtoupper($value) === 'NIL') { + return null; + } + + if (str_starts_with($value, '"') && str_ends_with($value, '"')) { + return stripcslashes(substr($value, 1, -1)); + } + + return $value; + } + + private function decodeMailboxName(string $value): string + { + $name = $this->decodeAtom($value); + + // LIST may advertise the root mailbox as an empty quoted string. + return $name ?? ''; + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/LoginCommand.php b/lib/Client/Command/LoginCommand.php new file mode 100644 index 0000000..45fddce --- /dev/null +++ b/lib/Client/Command/LoginCommand.php @@ -0,0 +1,67 @@ + + */ +final class LoginCommand implements CommandInterface +{ + public function __construct( + private readonly string $username, + private readonly string $password, + ) {} + + public function name(): string + { + return 'LOGIN'; + } + + public function allowedStates(): array + { + return [SessionState::NotAuthenticated]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + 'LOGIN %s %s', + $this->quote($this->username), + $this->quote($this->password), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('LOGIN failed: ' . $response->text()); + } + + $context->setState(SessionState::Authenticated); + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('LOGIN did not receive a tagged completion response.'); + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/LogoutCommand.php b/lib/Client/Command/LogoutCommand.php new file mode 100644 index 0000000..5d54f42 --- /dev/null +++ b/lib/Client/Command/LogoutCommand.php @@ -0,0 +1,59 @@ + + */ +final class LogoutCommand implements CommandInterface +{ + public function name(): string + { + return 'LOGOUT'; + } + + public function allowedStates(): array + { + return [ + SessionState::NotAuthenticated, + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame('LOGOUT'); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('LOGOUT failed: ' . $response->text()); + } + + $context->setSelectedMailbox(null); + $context->setState(SessionState::Logout); + $context->connection()->disconnect(); + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('LOGOUT did not receive a tagged completion response.'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/MessageTransferCommand.php b/lib/Client/Command/MessageTransferCommand.php new file mode 100644 index 0000000..52d351e --- /dev/null +++ b/lib/Client/Command/MessageTransferCommand.php @@ -0,0 +1,243 @@ + + */ +final class MessageTransferCommand implements CommandInterface +{ + private readonly string $operation; + private readonly SequenceSet $sequenceSet; + private readonly IdentifierMode $identifierMode; + + public function __construct( + string $operation, + FetchTarget|string|SequenceSet|null $target = null, + private readonly string $destinationMailbox = '', + ) { + $resolvedTarget = match (true) { + $target instanceof FetchTarget => $target, + $target instanceof SequenceSet => FetchTarget::sequence($target), + is_string($target) => FetchTarget::sequence($target), + default => FetchTarget::all(), + }; + + $this->operation = strtoupper(trim($operation)); + + if (!in_array($this->operation, ['COPY', 'MOVE'], true)) { + throw new ImapException('Unsupported transfer operation: ' . $this->operation); + } + + $this->sequenceSet = $resolvedTarget->sequenceSet(); + $this->identifierMode = $resolvedTarget->identifierMode(); + } + + public function name(): string + { + return $this->operation; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + '%s%s %s %s', + $this->identifierMode === IdentifierMode::Uid ? 'UID ' : '', + $this->operation, + $this->sequenceSet->toCommand(), + $this->quote($this->destinationMailbox), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult + { + if ($context->selectedMailbox() === null) { + throw new ImapException($this->operation . ' requires a selected mailbox.'); + } + + $responseCodes = []; + $copyUid = null; + $tryCreate = false; + $highestModSeq = null; + $expunged = []; + $vanished = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse) { + $this->collectUntaggedData( + $response, + $responseCodes, + $copyUid, + $tryCreate, + $highestModSeq, + $expunged, + $vanished, + ); + + continue; + } + + if ($response instanceof TaggedResponse) { + $this->collectResponseCode( + 'tagged', + $response->text(), + $responseCodes, + $copyUid, + $tryCreate, + $highestModSeq, + ); + + $result = new MessageTransferResult( + $response->status(), + $response->text(), + $responseCodes, + $copyUid, + $tryCreate, + $highestModSeq, + $expunged, + $vanished, + ); + + if (!$response->isOk()) { + throw new ImapException($this->operation . ' failed: ' . $response->text()); + } + + return $result; + } + } + + throw new ImapException($this->operation . ' did not receive a tagged completion response.'); + } + + /** + * @param list, text:string}> $responseCodes + * @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid + * @param list $expunged + * @param list $vanished + */ + private function collectUntaggedData( + UntaggedResponse $response, + array &$responseCodes, + ?array &$copyUid, + bool &$tryCreate, + ?string &$highestModSeq, + array &$expunged, + array &$vanished, + ): void { + $label = strtoupper($response->label()); + + if (in_array($label, ['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'], true)) { + $this->collectResponseCode( + 'untagged', + $response->payload(), + $responseCodes, + $copyUid, + $tryCreate, + $highestModSeq, + ); + } + + if (preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) { + $expunged[] = (int) $matches[1]; + return; + } + + if (preg_match('/^\*\s+VANISHED(?:\s+\((EARLIER)\))?\s+(.+)$/i', $response->raw(), $matches) === 1) { + $vanished[] = [ + 'earlier' => isset($matches[1]) && strtoupper($matches[1]) === 'EARLIER', + 'knownUids' => trim($matches[2]), + ]; + } + } + + /** + * @param list, text:string}> $responseCodes + * @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid + */ + private function collectResponseCode( + string $source, + string $text, + array &$responseCodes, + ?array &$copyUid, + bool &$tryCreate, + ?string &$highestModSeq, + ): void { + $responseCode = $this->parseResponseCode($text); + if ($responseCode === null) { + return; + } + + $responseCodes[] = [ + 'source' => $source, + 'name' => $responseCode['name'], + 'arguments' => $responseCode['arguments'], + 'text' => $responseCode['text'], + ]; + + if ($responseCode['name'] === 'TRYCREATE') { + $tryCreate = true; + return; + } + + if ($responseCode['name'] === 'HIGHESTMODSEQ' && isset($responseCode['arguments'][0])) { + $highestModSeq = $responseCode['arguments'][0]; + return; + } + + if ($responseCode['name'] !== 'COPYUID' || count($responseCode['arguments']) < 3) { + return; + } + + $copyUid = [ + 'uidValidity' => $responseCode['arguments'][0], + 'sourceUids' => $responseCode['arguments'][1], + 'destinationUids' => $responseCode['arguments'][2], + ]; + } + + /** + * @return ?array{name:string, arguments:list, text:string} + */ + private function parseResponseCode(string $text): ?array + { + $text = trim($text); + + if (preg_match('/^\[([A-Z0-9.-]+)(?:\s+([^\]]+))?\](?:\s*(.*))?$/i', $text, $matches) !== 1) { + return null; + } + + $arguments = trim($matches[2] ?? ''); + + return [ + 'name' => strtoupper($matches[1]), + 'arguments' => $arguments === '' ? [] : (preg_split('/\s+/', $arguments) ?: []), + 'text' => trim($matches[3] ?? ''), + ]; + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/MoveCommand.php b/lib/Client/Command/MoveCommand.php new file mode 100644 index 0000000..ee0a1c0 --- /dev/null +++ b/lib/Client/Command/MoveCommand.php @@ -0,0 +1,47 @@ + + */ +final class MoveCommand implements CommandInterface +{ + private readonly MessageTransferCommand $command; + + public function __construct( + FetchTarget|string|SequenceSet|null $target = null, + string $destinationMailbox = '', + ) { + $this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox); + } + + public function name(): string + { + return $this->command->name(); + } + + public function allowedStates(): array + { + return $this->command->allowedStates(); + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + return $this->command->encode($tag, $context); + } + + public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult + { + return $this->command->handle($responses, $context); + } +} \ No newline at end of file diff --git a/lib/Client/Command/NoopCommand.php b/lib/Client/Command/NoopCommand.php new file mode 100644 index 0000000..7268bf7 --- /dev/null +++ b/lib/Client/Command/NoopCommand.php @@ -0,0 +1,57 @@ + + */ +final class NoopCommand implements CommandInterface +{ + public function name(): string + { + return 'NOOP'; + } + + public function allowedStates(): array + { + return [ + SessionState::NotAuthenticated, + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame('NOOP'); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + unset($context); + + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('NOOP failed: ' . $response->text()); + } + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('NOOP did not receive a tagged completion response.'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/RenameCommand.php b/lib/Client/Command/RenameCommand.php new file mode 100644 index 0000000..1a29929 --- /dev/null +++ b/lib/Client/Command/RenameCommand.php @@ -0,0 +1,72 @@ + + */ +final class RenameCommand implements CommandInterface +{ + public function __construct( + private readonly string $fromMailbox, + private readonly string $toMailbox, + ) {} + + public function name(): string + { + return 'RENAME'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + 'RENAME %s %s', + $this->quote($this->fromMailbox), + $this->quote($this->toMailbox), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('RENAME failed: ' . $response->text()); + } + + if ($context->selectedMailbox() === $this->fromMailbox) { + $context->setSelectedMailbox($this->toMailbox); + } + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('RENAME did not receive a tagged completion response.'); + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/CapabilityResult.php b/lib/Client/Command/Result/CapabilityResult.php new file mode 100644 index 0000000..82c2f85 --- /dev/null +++ b/lib/Client/Command/Result/CapabilityResult.php @@ -0,0 +1,28 @@ + $capabilities + */ + public function __construct( + private readonly array $capabilities, + ) {} + + /** + * @return list + */ + public function capabilities(): array + { + return $this->capabilities; + } + + public function has(string $capability): bool + { + return in_array(strtoupper($capability), $this->capabilities, true); + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/CommandStatusResult.php b/lib/Client/Command/Result/CommandStatusResult.php new file mode 100644 index 0000000..1789d82 --- /dev/null +++ b/lib/Client/Command/Result/CommandStatusResult.php @@ -0,0 +1,28 @@ +status; + } + + public function text(): string + { + return $this->text; + } + + public function isOk(): bool + { + return $this->status === 'OK'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/MessageTransferResult.php b/lib/Client/Command/Result/MessageTransferResult.php new file mode 100644 index 0000000..788318a --- /dev/null +++ b/lib/Client/Command/Result/MessageTransferResult.php @@ -0,0 +1,152 @@ +, text:string}> $responseCodes + * @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid + * @param list $expunged + * @param list $vanished + */ + public function __construct( + private readonly string $status, + private readonly string $text, + private readonly array $responseCodes = [], + private readonly ?array $copyUid = null, + private readonly bool $tryCreate = false, + private readonly ?string $highestModSeq = null, + private readonly array $expunged = [], + private readonly array $vanished = [], + ) {} + + public function status(): string + { + return $this->status; + } + + public function text(): string + { + return $this->text; + } + + public function isOk(): bool + { + return $this->status === 'OK'; + } + + /** + * @return list, text:string}> + */ + public function responseCodes(): array + { + return $this->responseCodes; + } + + /** + * @return ?array{uidValidity:string, sourceUids:string, destinationUids:string} + */ + public function copyUid(): ?array + { + return $this->copyUid; + } + + /** + * @return array + */ + public function copyUidMap(): array + { + if ($this->copyUid === null) { + return []; + } + + $sourceUids = $this->expandUidSet($this->copyUid['sourceUids']); + $destinationUids = $this->expandUidSet($this->copyUid['destinationUids']); + + if (count($sourceUids) !== count($destinationUids)) { + return []; + } + + $mapping = []; + foreach ($sourceUids as $index => $sourceUid) { + $mapping[$sourceUid] = $destinationUids[$index]; + } + + return $mapping; + } + + public function tryCreate(): bool + { + return $this->tryCreate; + } + + public function highestModSeq(): ?string + { + return $this->highestModSeq; + } + + /** + * @return list + */ + public function expunged(): array + { + return $this->expunged; + } + + /** + * @return list + */ + public function vanished(): array + { + return $this->vanished; + } + + public function hasResponseCode(string $name): bool + { + $name = strtoupper(trim($name)); + + foreach ($this->responseCodes as $responseCode) { + if ($responseCode['name'] === $name) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + private function expandUidSet(string $value): array + { + $expanded = []; + + foreach (explode(',', $value) as $segment) { + $segment = trim($segment); + if ($segment === '') { + continue; + } + + if (!str_contains($segment, ':')) { + $expanded[] = $segment; + continue; + } + + [$start, $end] = array_map('trim', explode(':', $segment, 2)); + + if (!ctype_digit($start) || !ctype_digit($end)) { + return []; + } + + $range = range((int) $start, (int) $end, (int) $start <= (int) $end ? 1 : -1); + foreach ($range as $uid) { + $expanded[] = (string) $uid; + } + } + + return $expanded; + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/SearchResult.php b/lib/Client/Command/Result/SearchResult.php new file mode 100644 index 0000000..e9a1381 --- /dev/null +++ b/lib/Client/Command/Result/SearchResult.php @@ -0,0 +1,36 @@ + $matches + */ + public function __construct( + private readonly array $matches, + private readonly IdentifierMode $identifierMode, + ) {} + + /** + * @return list + */ + public function matches(): array + { + return $this->matches; + } + + public function identifierMode(): IdentifierMode + { + return $this->identifierMode; + } + + public function isUidSearch(): bool + { + return $this->identifierMode === IdentifierMode::Uid; + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/SortResult.php b/lib/Client/Command/Result/SortResult.php new file mode 100644 index 0000000..96eedc4 --- /dev/null +++ b/lib/Client/Command/Result/SortResult.php @@ -0,0 +1,36 @@ + $matches + */ + public function __construct( + private readonly array $matches, + private readonly IdentifierMode $identifierMode, + ) {} + + /** + * @return list + */ + public function matches(): array + { + return $this->matches; + } + + public function identifierMode(): IdentifierMode + { + return $this->identifierMode; + } + + public function isUidSort(): bool + { + return $this->identifierMode === IdentifierMode::Uid; + } +} \ No newline at end of file diff --git a/lib/Client/Command/Result/StatusResult.php b/lib/Client/Command/Result/StatusResult.php new file mode 100644 index 0000000..99dcae7 --- /dev/null +++ b/lib/Client/Command/Result/StatusResult.php @@ -0,0 +1,54 @@ + $items + */ + public function __construct( + private readonly string $mailbox, + private readonly array $items, + ) {} + + public function mailbox(): string + { + return $this->mailbox; + } + + /** + * @return array + */ + public function items(): array + { + return $this->items; + } + + public function value(string $item, int $default = 0): int + { + return $this->items[strtoupper(trim($item))] ?? $default; + } + + public function messages(): int + { + return $this->value('MESSAGES'); + } + + public function unseen(): int + { + return $this->value('UNSEEN'); + } + + public function read(): int + { + return max(0, $this->messages() - $this->unseen()); + } + + public function state(): int + { + return $this->value('UIDVALIDITY'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/SearchCommand.php b/lib/Client/Command/SearchCommand.php new file mode 100644 index 0000000..612ad13 --- /dev/null +++ b/lib/Client/Command/SearchCommand.php @@ -0,0 +1,126 @@ + + */ +final class SearchCommand implements CommandInterface +{ + /** + * @param SearchCriteriaBuilder|list $criteria + */ + public function __construct( + private readonly SearchCriteriaBuilder|array $criteria = ['ALL'], + private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence, + private readonly ?string $charset = 'UTF-8', + ) {} + + public function name(): string + { + return 'SEARCH'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + $criteria = $this->normalizeCriteria( + $this->criteria instanceof SearchCriteriaBuilder + ? $this->criteria->toArray() + : $this->criteria, + ); + $command = $this->identifierMode === IdentifierMode::Uid ? 'UID SEARCH' : 'SEARCH'; + + if ($this->charset !== null && $this->charset !== '') { + $command .= ' CHARSET ' . strtoupper(trim($this->charset)); + } + + $command .= ' ' . implode(' ', $criteria); + + return new RequestFrame($command); + } + + public function handle(ResponseStream $responses, SessionContext $context): SearchResult + { + if ($context->selectedMailbox() === null) { + throw new ImapException('SEARCH requires a selected mailbox.'); + } + + $matches = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'SEARCH') { + $matches = $this->parseMatches($response->payloadTokens()); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('SEARCH failed: ' . $response->text()); + } + + return new SearchResult($matches, $this->identifierMode); + } + } + + throw new ImapException('SEARCH did not receive a tagged completion response.'); + } + + /** + * @param list $criteria + * @return list + */ + private function normalizeCriteria(array $criteria): array + { + $normalized = []; + + foreach ($criteria as $criterion) { + $criterion = trim($criterion); + if ($criterion === '') { + continue; + } + + $normalized[] = $criterion; + } + + return $normalized === [] ? ['ALL'] : $normalized; + } + + /** + * @param list $tokens + * @return list + */ + private function parseMatches(array $tokens): array + { + $matches = []; + + foreach ($tokens as $token) { + if (preg_match('/^[1-9]\d*$/', $token) !== 1) { + continue; + } + + $matches[] = (int) $token; + } + + return $matches; + } +} \ No newline at end of file diff --git a/lib/Client/Command/SelectCommand.php b/lib/Client/Command/SelectCommand.php new file mode 100644 index 0000000..8e28cb1 --- /dev/null +++ b/lib/Client/Command/SelectCommand.php @@ -0,0 +1,124 @@ + + */ +final class SelectCommand implements CommandInterface +{ + public function __construct( + private readonly string $mailbox, + private readonly bool $readOnly = true, + ) {} + + public function name(): string + { + return $this->readOnly ? 'EXAMINE' : 'SELECT'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + '%s %s', + $this->name(), + $this->quote($this->mailbox), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): Mailbox + { + $exists = 0; + $recent = 0; + $flags = []; + $readOnly = $this->readOnly; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse) { + $raw = $response->raw(); + + if (preg_match('/^\*\s+(\d+)\s+EXISTS$/i', $raw, $matches)) { + $exists = (int) $matches[1]; + continue; + } + + if (preg_match('/^\*\s+(\d+)\s+RECENT$/i', $raw, $matches)) { + $recent = (int) $matches[1]; + continue; + } + + if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) { + $flags = $this->parseFlags($matches[1]); + continue; + } + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException($this->name() . ' failed: ' . $response->text()); + } + + if (str_contains(strtoupper($response->text()), 'READ-ONLY')) { + $readOnly = true; + } + + $context->setSelectedMailbox($this->mailbox); + $context->setState(SessionState::Selected); + + return new Mailbox( + $this->mailbox, + null, + [], + $exists, + 0, + null, + $recent, + $flags, + $readOnly, + ); + } + } + + throw new ImapException($this->name() . ' did not receive a tagged completion response.'); + } + + /** + * @return list + */ + private function parseFlags(string $flags): array + { + $flags = trim($flags); + + if ($flags === '') { + return []; + } + + return preg_split('/\s+/', $flags) ?: []; + } + + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/SortCommand.php b/lib/Client/Command/SortCommand.php new file mode 100644 index 0000000..1758868 --- /dev/null +++ b/lib/Client/Command/SortCommand.php @@ -0,0 +1,154 @@ + + */ +final class SortCommand implements CommandInterface +{ + /** + * @param SearchCriteriaBuilder|list $criteria + * @param list $sortCriteria + */ + public function __construct( + private readonly array $sortCriteria, + private readonly SearchCriteriaBuilder|array $criteria = ['ALL'], + private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence, + private readonly string $charset = 'UTF-8', + ) {} + + public function name(): string + { + return 'SORT'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + $sortCriteria = $this->normalizeSortCriteria($this->sortCriteria); + if ($sortCriteria === []) { + throw new ImapException('SORT requires at least one sort criterion.'); + } + + $criteria = $this->normalizeCriteria( + $this->criteria instanceof SearchCriteriaBuilder + ? $this->criteria->toArray() + : $this->criteria, + ); + + $command = $this->identifierMode === IdentifierMode::Uid ? 'UID SORT' : 'SORT'; + $command .= sprintf( + ' (%s) %s %s', + implode(' ', $sortCriteria), + strtoupper(trim($this->charset)), + implode(' ', $criteria), + ); + + return new RequestFrame($command); + } + + public function handle(ResponseStream $responses, SessionContext $context): SortResult + { + if ($context->selectedMailbox() === null) { + throw new ImapException('SORT requires a selected mailbox.'); + } + + $matches = []; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'SORT') { + $matches = $this->parseMatches($response->payloadTokens()); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('SORT failed: ' . $response->text()); + } + + return new SortResult($matches, $this->identifierMode); + } + } + + throw new ImapException('SORT did not receive a tagged completion response.'); + } + + /** + * @param list $criteria + * @return list + */ + private function normalizeCriteria(array $criteria): array + { + $normalized = []; + + foreach ($criteria as $criterion) { + $criterion = trim($criterion); + if ($criterion === '') { + continue; + } + + $normalized[] = $criterion; + } + + return $normalized === [] ? ['ALL'] : $normalized; + } + + /** + * @param list $criteria + * @return list + */ + private function normalizeSortCriteria(array $criteria): array + { + $normalized = []; + + foreach ($criteria as $criterion) { + $criterion = strtoupper(trim($criterion)); + if ($criterion === '') { + continue; + } + + $normalized[] = $criterion; + } + + return $normalized; + } + + /** + * @param list $tokens + * @return list + */ + private function parseMatches(array $tokens): array + { + $matches = []; + + foreach ($tokens as $token) { + if (preg_match('/^[1-9]\d*$/', $token) !== 1) { + continue; + } + + $matches[] = (int) $token; + } + + return $matches; + } +} \ No newline at end of file diff --git a/lib/Client/Command/StartTlsCommand.php b/lib/Client/Command/StartTlsCommand.php new file mode 100644 index 0000000..7dcfad6 --- /dev/null +++ b/lib/Client/Command/StartTlsCommand.php @@ -0,0 +1,54 @@ + + */ +final class StartTlsCommand implements CommandInterface +{ + public function name(): string + { + return 'STARTTLS'; + } + + public function allowedStates(): array + { + return [SessionState::NotAuthenticated]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame('STARTTLS'); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('STARTTLS failed: ' . $response->text()); + } + + $context->connection()->upgradeToTls(); + $context->replaceCapabilities(); + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('STARTTLS did not receive a tagged completion response.'); + } +} \ No newline at end of file diff --git a/lib/Client/Command/StatusCommand.php b/lib/Client/Command/StatusCommand.php new file mode 100644 index 0000000..09a99ad --- /dev/null +++ b/lib/Client/Command/StatusCommand.php @@ -0,0 +1,118 @@ + + */ +final class StatusCommand implements CommandInterface +{ + private readonly StatusResponseParser $statusResponseParser; + + /** + * @param list $items + */ + public function __construct( + private readonly string $mailbox, + private readonly array $items = ['MESSAGES', 'UNSEEN'], + ?StatusResponseParser $statusResponseParser = null, + ) { + $this->statusResponseParser = $statusResponseParser ?? new StatusResponseParser(); + } + + public function name(): string + { + return 'STATUS'; + } + + public function allowedStates(): array + { + return [ + SessionState::Authenticated, + SessionState::Selected, + ]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + 'STATUS %s (%s)', + $this->quote($this->mailbox), + implode(' ', $this->normalizeItems($this->items)), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): StatusResult + { + unset($context); + + $items = []; + $mailbox = $this->mailbox; + + foreach ($responses as $response) { + if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') { + [$mailbox, $items] = $this->statusResponseParser->parse($response->payload()); + continue; + } + + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('STATUS failed: ' . $response->text()); + } + + return new StatusResult($mailbox, $items); + } + } + + throw new ImapException('STATUS did not receive a tagged completion response.'); + } + + /** + * @param list $items + * @return list + */ + private function normalizeItems(array $items): array + { + $normalized = []; + + foreach ($items as $item) { + $item = strtoupper(trim($item)); + if ($item === '') { + continue; + } + + if (!preg_match('/^[A-Z0-9.-]+$/', $item)) { + throw new ImapException('Invalid STATUS item: ' . $item); + } + + if (in_array($item, $normalized, true)) { + continue; + } + + $normalized[] = $item; + } + + if ($normalized === []) { + throw new ImapException('STATUS requires at least one data item.'); + } + + return $normalized; + } + private function quote(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/Command/StatusResponseParser.php b/lib/Client/Command/StatusResponseParser.php new file mode 100644 index 0000000..8c11f99 --- /dev/null +++ b/lib/Client/Command/StatusResponseParser.php @@ -0,0 +1,139 @@ +} + */ + public function parse(string $payload): array + { + $payload = trim($payload); + $offset = 0; + + $nameToken = $this->readToken($payload, $offset); + $statusToken = $this->readToken($payload, $offset); + + if ($nameToken === null || $statusToken === null) { + throw new ImapException('Unable to parse STATUS response payload: ' . $payload); + } + + $mailbox = $this->decodeAtom($nameToken); + if ($mailbox === null || $mailbox === '') { + throw new ImapException('STATUS response is missing a mailbox name: ' . $payload); + } + + return [$mailbox, $this->parseItems($statusToken, $payload)]; + } + + /** + * @return array + */ + private function parseItems(string $statusToken, string $payload): array + { + $statusToken = trim($statusToken); + + if (!str_starts_with($statusToken, '(') || !str_ends_with($statusToken, ')')) { + throw new ImapException('Invalid STATUS data payload: ' . $payload); + } + + $items = trim(substr($statusToken, 1, -1)); + if ($items === '') { + return []; + } + + $tokens = preg_split('/\s+/', $items) ?: []; + if (count($tokens) % 2 !== 0) { + throw new ImapException('Malformed STATUS item list: ' . $payload); + } + + $status = []; + + for ($index = 0; $index < count($tokens); $index += 2) { + $item = strtoupper($tokens[$index]); + $value = $tokens[$index + 1]; + + if (!preg_match('/^\d+$/', $value)) { + throw new ImapException('STATUS item value must be numeric: ' . $payload); + } + + $status[$item] = (int) $value; + } + + return $status; + } + + private function readToken(string $payload, int &$offset): ?string + { + $length = strlen($payload); + + while ($offset < $length && ctype_space($payload[$offset])) { + $offset++; + } + + if ($offset >= $length) { + return null; + } + + if ($payload[$offset] === '(') { + $end = strpos($payload, ')', $offset); + + if ($end === false) { + throw new ImapException('Unterminated STATUS item block: ' . $payload); + } + + $token = substr($payload, $offset, $end - $offset + 1); + $offset = $end + 1; + + return $token; + } + + if ($payload[$offset] === '"') { + $start = $offset; + $offset++; + + while ($offset < $length) { + if ($payload[$offset] === '\\') { + $offset += 2; + continue; + } + + if ($payload[$offset] === '"') { + $offset++; + return substr($payload, $start, $offset - $start); + } + + $offset++; + } + + throw new ImapException('Unterminated quoted STATUS token: ' . $payload); + } + + $start = $offset; + while ($offset < $length && !ctype_space($payload[$offset])) { + $offset++; + } + + return substr($payload, $start, $offset - $start); + } + + private function decodeAtom(string $value): ?string + { + $value = trim($value); + + if (strtoupper($value) === 'NIL') { + return null; + } + + if (str_starts_with($value, '"') && str_ends_with($value, '"')) { + return stripcslashes(substr($value, 1, -1)); + } + + return $value; + } +} \ No newline at end of file diff --git a/lib/Client/Command/StoreCommand.php b/lib/Client/Command/StoreCommand.php new file mode 100644 index 0000000..7a40a73 --- /dev/null +++ b/lib/Client/Command/StoreCommand.php @@ -0,0 +1,112 @@ + + */ +final class StoreCommand implements CommandInterface +{ + private readonly SequenceSet $sequenceSet; + private readonly IdentifierMode $identifierMode; + + /** + * @param list $flags + */ + public function __construct( + FetchTarget|string|SequenceSet|null $target = null, + private readonly array $flags = [], + private readonly string $action = '', + private readonly bool $silent = true, + ) { + $resolvedTarget = match (true) { + $target instanceof FetchTarget => $target, + $target instanceof SequenceSet => FetchTarget::sequence($target), + is_string($target) => FetchTarget::sequence($target), + default => FetchTarget::all(), + }; + + $normalizedAction = trim($this->action); + if (!in_array($normalizedAction, ['', '+', '-'], true)) { + throw new ImapException('STORE action must be one of "", "+", or "-".'); + } + + $normalizedFlags = array_values(array_filter(array_map( + static fn (string $flag): string => trim($flag), + $this->flags, + ), static fn (string $flag): bool => $flag !== '')); + + if ($normalizedFlags === []) { + throw new ImapException('STORE requires at least one flag.'); + } + + $this->flags = $normalizedFlags; + $this->action = $normalizedAction; + $this->sequenceSet = $resolvedTarget->sequenceSet(); + $this->identifierMode = $resolvedTarget->identifierMode(); + } + + public function name(): string + { + return 'STORE'; + } + + public function allowedStates(): array + { + return [SessionState::Selected]; + } + + public function encode(string $tag, SessionContext $context): RequestFrame + { + unset($tag, $context); + + return new RequestFrame(sprintf( + '%sSTORE %s %s (%s)', + $this->identifierMode === IdentifierMode::Uid ? 'UID ' : '', + $this->sequenceSet->toCommand(), + $this->itemName(), + implode(' ', $this->flags), + )); + } + + public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult + { + if ($context->selectedMailbox() === null) { + throw new ImapException('STORE requires a selected mailbox.'); + } + + foreach ($responses as $response) { + if ($response instanceof TaggedResponse) { + if (!$response->isOk()) { + throw new ImapException('STORE failed: ' . $response->text()); + } + + return new CommandStatusResult($response->status(), $response->text()); + } + } + + throw new ImapException('STORE did not receive a tagged completion response.'); + } + + private function itemName(): string + { + return sprintf( + '%sFLAGS%s', + $this->action, + $this->silent ? '.SILENT' : '', + ); + } +} \ No newline at end of file diff --git a/lib/Client/Configuration.php b/lib/Client/Configuration.php deleted file mode 100644 index 9c82339..0000000 --- a/lib/Client/Configuration.php +++ /dev/null @@ -1,20 +0,0 @@ -host; + } + + public function port(): int + { + return $this->port; + } + + public function security(): ConnectionSecurity + { + return $this->security; + } + + public function username(): ?string + { + return $this->username; + } + + public function password(): ?string + { + return $this->password; + } + + public function hasCredentials(): bool + { + return $this->username !== null && $this->password !== null; + } + + public function timeout(): float + { + return $this->timeout; + } + + public function verifyPeer(): bool + { + return $this->verifyPeer; + } + + public function verifyPeerName(): bool + { + return $this->verifyPeerName; + } + + public function allowSelfSigned(): bool + { + return $this->allowSelfSigned; + } + + public function endpoint(): string + { + return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port); + } + + public function streamContextOptions(): array + { + return [ + 'ssl' => [ + 'verify_peer' => $this->verifyPeer, + 'verify_peer_name' => $this->verifyPeerName, + 'allow_self_signed' => $this->allowSelfSigned, + 'SNI_enabled' => true, + 'peer_name' => $this->host, + ], + ]; + } +} \ No newline at end of file diff --git a/lib/Client/ConnectionSecurity.php b/lib/Client/ConnectionSecurity.php new file mode 100644 index 0000000..6f8a695 --- /dev/null +++ b/lib/Client/ConnectionSecurity.php @@ -0,0 +1,17 @@ + $items + */ + private function __construct( + private readonly array $items, + ) {} + + public static function default(): self + { + return self::message(); + } + + public static function summary(): self + { + return new self([ + 'UID', + 'FLAGS', + 'INTERNALDATE', + 'RFC822.SIZE', + ]); + } + + public static function message(): self + { + return self::summary() + ->withEnvelope() + ->withBodyStructure(); + } + + public static function fullMessage(): self + { + return self::message()->withBodyText(); + } + + public function withBodySection(string $section): self + { + $section = strtoupper(trim($section)); + + if ($section === '') { + return $this; + } + + return $this->with(sprintf('BODY[%s]', $section)); + } + + public static function of(string ...$items): self + { + return new self(self::normalize($items)); + } + + public function withUid(): self + { + return $this->with('UID'); + } + + public function withFlags(): self + { + return $this->with('FLAGS'); + } + + public function withInternalDate(): self + { + return $this->with('INTERNALDATE'); + } + + public function withSize(): self + { + return $this->with('RFC822.SIZE'); + } + + public function withEnvelope(): self + { + return $this->with('ENVELOPE'); + } + + public function withBodyStructure(): self + { + return $this->with('BODYSTRUCTURE'); + } + + public function withBodyText(): self + { + return $this->withBodySection('TEXT'); + } + + public function withHeaderFields(string ...$fields): self + { + $fields = array_values(array_filter(array_map( + static fn (string $field): string => strtoupper(trim($field)), + $fields, + ), static fn (string $field): bool => $field !== '')); + + if ($fields === []) { + return $this; + } + + return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields))); + } + + public function with(string $item): self + { + return new self(self::normalize([ + ...$this->items, + $item, + ])); + } + + /** + * @return list + */ + public function toArray(): array + { + return $this->items; + } + + public function toCommand(): string + { + return implode(' ', $this->items); + } + + /** + * @param list $items + * @return list + */ + private static function normalize(array $items): array + { + $normalized = []; + + foreach ($items as $item) { + $item = trim($item); + if ($item === '' || in_array($item, $normalized, true)) { + continue; + } + + $normalized[] = $item; + } + + if (!in_array('UID', $normalized, true)) { + array_unshift($normalized, 'UID'); + } + + return $normalized; + } +} \ No newline at end of file diff --git a/lib/Client/FetchTarget.php b/lib/Client/FetchTarget.php new file mode 100644 index 0000000..30957cb --- /dev/null +++ b/lib/Client/FetchTarget.php @@ -0,0 +1,52 @@ +sequenceSet; + } + + public function identifierMode(): IdentifierMode + { + return $this->identifierMode; + } + + public function toCommand(): string + { + return $this->identifierMode->toCommand(); + } + + private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet + { + return match (true) { + $target instanceof SequenceSet => $target, + is_int($target) => SequenceSet::single($target), + default => SequenceSet::parse($target), + }; + } +} \ No newline at end of file diff --git a/lib/Client/IdentifierMode.php b/lib/Client/IdentifierMode.php new file mode 100644 index 0000000..de086e4 --- /dev/null +++ b/lib/Client/IdentifierMode.php @@ -0,0 +1,16 @@ + $options + * @param list|null $statusItems + */ + private function __construct( + private readonly array $options, + private readonly ?array $statusItems = null, + ) {} + + public static function none(): self + { + return new self([]); + } + + public static function of(string ...$options): self + { + return new self(self::normalize($options)); + } + + public static function subscribed(): self + { + return self::none()->withSubscribed(); + } + + public static function children(): self + { + return self::none()->withChildren(); + } + + public static function specialUse(): self + { + return self::none()->withSpecialUse(); + } + + public static function status(string ...$items): self + { + return self::none()->withStatus(...$items); + } + + public function withSubscribed(): self + { + return $this->with(self::SUBSCRIBED); + } + + public function withChildren(): self + { + return $this->with(self::CHILDREN); + } + + public function withSpecialUse(): self + { + return $this->with(self::SPECIAL_USE); + } + + public function withStatus(string ...$items): self + { + return new self($this->options, self::normalizeStatusItems($items)); + } + + /** + * @return list + */ + public function toArray(): array + { + $options = $this->options; + + if ($this->statusItems !== null) { + $options[] = sprintf('STATUS (%s)', implode(' ', $this->statusItems)); + } + + return $options; + } + + public function toCommand(): ?string + { + $options = $this->toArray(); + if ($options === []) { + return null; + } + + return '(' . implode(' ', $options) . ')'; + } + + public function hasStatus(): bool + { + return $this->statusItems !== null; + } + + /** + * @return list + */ + public function statusItems(): array + { + return $this->statusItems ?? []; + } + + private function with(string $option): self + { + return new self(self::normalize([ + ...$this->options, + $option, + ]), $this->statusItems); + } + + /** + * @param list $options + * @return list + */ + private static function normalize(array $options): array + { + $normalized = []; + + foreach ($options as $option) { + $option = strtoupper(trim($option)); + if ($option === '') { + continue; + } + + if (!in_array($option, [ + self::SUBSCRIBED, + self::CHILDREN, + self::SPECIAL_USE, + ], true)) { + throw new ImapException('Unsupported LIST return option: ' . $option); + } + + if (in_array($option, $normalized, true)) { + continue; + } + + $normalized[] = $option; + } + + return $normalized; + } + + /** + * @param list $items + * @return list + */ + private static function normalizeStatusItems(array $items): array + { + $normalized = []; + + foreach ($items as $item) { + $item = strtoupper(trim($item)); + if ($item === '') { + continue; + } + + if (!preg_match('/^[A-Z0-9.-]+$/', $item)) { + throw new ImapException('Invalid LIST STATUS data item: ' . $item); + } + + if (in_array($item, $normalized, true)) { + continue; + } + + $normalized[] = $item; + } + + if ($normalized === []) { + throw new ImapException('LIST STATUS return option requires at least one STATUS data item.'); + } + + return $normalized; + } +} \ No newline at end of file diff --git a/lib/Client/ListSelectionOptions.php b/lib/Client/ListSelectionOptions.php new file mode 100644 index 0000000..e548c20 --- /dev/null +++ b/lib/Client/ListSelectionOptions.php @@ -0,0 +1,128 @@ + $options + */ + private function __construct( + private readonly array $options, + ) {} + + public static function none(): self + { + return new self([]); + } + + public static function of(string ...$options): self + { + return new self(self::normalize($options)); + } + + public static function subscribed(): self + { + return self::none()->withSubscribed(); + } + + public static function remote(): self + { + return self::none()->withRemote(); + } + + public static function specialUse(): self + { + return self::none()->withSpecialUse(); + } + + public function withSubscribed(): self + { + return $this->with(self::SUBSCRIBED); + } + + public function withRemote(): self + { + return $this->with(self::REMOTE); + } + + public function withRecursiveMatch(): self + { + return $this->with(self::RECURSIVEMATCH); + } + + public function withSpecialUse(): self + { + return $this->with(self::SPECIAL_USE); + } + + /** + * @return list + */ + public function toArray(): array + { + return $this->options; + } + + public function toCommand(): ?string + { + if ($this->options === []) { + return null; + } + + return '(' . implode(' ', $this->options) . ')'; + } + + private function with(string $option): self + { + return new self(self::normalize([ + ...$this->options, + $option, + ])); + } + + /** + * @param list $options + * @return list + */ + private static function normalize(array $options): array + { + $normalized = []; + + foreach ($options as $option) { + $option = strtoupper(trim($option)); + if ($option === '') { + continue; + } + + if (!in_array($option, [ + self::SUBSCRIBED, + self::REMOTE, + self::RECURSIVEMATCH, + self::SPECIAL_USE, + ], true)) { + throw new ImapException('Unsupported LIST selection option: ' . $option); + } + + if (in_array($option, $normalized, true)) { + continue; + } + + $normalized[] = $option; + } + + if (in_array(self::RECURSIVEMATCH, $normalized, true) + && !in_array(self::SUBSCRIBED, $normalized, true)) { + throw new ImapException('RECURSIVEMATCH requires SUBSCRIBED in LIST selection options.'); + } + + return $normalized; + } +} \ No newline at end of file diff --git a/lib/Client/Mailbox.php b/lib/Client/Mailbox.php index 33041db..98b7990 100644 --- a/lib/Client/Mailbox.php +++ b/lib/Client/Mailbox.php @@ -2,32 +2,101 @@ declare(strict_types=1); -namespace Gricob\IMAP; +namespace KTXM\ProviderImap\Client; -class Mailbox +use KTXM\ProviderImap\Client\Command\Result\StatusResult; + +final class Mailbox { - private const ATTRIBUTE_NOSELECT = '\Noselect'; - - public array $flags = []; - public int $exists = 0; - public int $recent = 0; - public ?int $unseen = null; - public ?int $uidValidity = null; - public ?int $uidNext = null; - public array $permanentFlags = []; - /** - * @param list $nameAttributes + * @param list $attributes + * @param list $flags */ public function __construct( - public array $nameAttributes, - public string $hierarchyDelimiter, - public string $name, - ) { + private readonly string $name, + private readonly ?string $delimiter, + private readonly array $attributes, + private readonly int $messages = 0, + private readonly int $unread = 0, + private readonly ?int $state = null, + private readonly int $recent = 0, + private readonly array $flags = [], + private readonly bool $readOnly = true, + ) {} + + public function fromStatus(StatusResult $status): self + { + return new self( + $this->name, + $this->delimiter, + $this->attributes, + $status->messages() ?? $this->messages, + $status->unseen() ?? $this->unread, + $status->state() ?? $this->state, + $this->recent, + $this->flags, + $this->readOnly, + ); + } + + public function name(): string + { + return $this->name; + } + + public function delimiter(): ?string + { + return $this->delimiter; + } + + /** + * @return list + */ + public function attributes(): array + { + return $this->attributes; + } + + public function state(): ?int + { + return $this->state; + } + + public function messages(): int + { + return $this->messages; + } + + public function unread(): int + { + return $this->unread; + } + + public function read(): int + { + return max(0, $this->messages - $this->unread); + } + + public function recent(): int + { + return $this->recent; + } + + /** + * @return list + */ + public function flags(): array + { + return $this->flags; + } + + public function readOnly(): bool + { + return $this->readOnly; } public function isSelectable(): bool { - return !in_array(self::ATTRIBUTE_NOSELECT, $this->nameAttributes); + return !in_array('\\NOSELECT', $this->attributes, true); } } \ No newline at end of file diff --git a/lib/Client/Message.php b/lib/Client/Message.php new file mode 100644 index 0000000..7615791 --- /dev/null +++ b/lib/Client/Message.php @@ -0,0 +1,178 @@ + $flags + * @param list $from + * @param list $sender + * @param list $replyTo + * @param list $to + * @param list $cc + * @param list $bcc + * @param array $bodySections + */ + public function __construct( + private readonly int $sequence, + private readonly int $uid, + private readonly int $size, + private readonly ?string $internalDate, + private readonly array $flags, + private readonly ?string $subject, + private readonly ?string $sentAt, + private readonly ?string $messageId, + private readonly ?string $inReplyTo, + private readonly array $from, + private readonly array $sender, + private readonly array $replyTo, + private readonly array $to, + private readonly array $cc, + private readonly array $bcc, + private readonly ?MessagePart $bodyStructure, + private readonly array $bodySections, + ) {} + + public function sequence(): int + { + return $this->sequence; + } + + public function uid(): int + { + return $this->uid; + } + + public function size(): int + { + return $this->size; + } + + public function internalDate(): ?string + { + return $this->internalDate; + } + + /** + * @return list + */ + public function flags(): array + { + return $this->flags; + } + + public function subject(): ?string + { + return $this->subject; + } + + public function sentAt(): ?string + { + return $this->sentAt; + } + + public function messageId(): ?string + { + return $this->messageId; + } + + public function inReplyTo(): ?string + { + return $this->inReplyTo; + } + + /** + * @return list + */ + public function from(): array + { + return $this->from; + } + + /** + * @return list + */ + public function sender(): array + { + return $this->sender; + } + + /** + * @return list + */ + public function replyTo(): array + { + return $this->replyTo; + } + + /** + * @return list + */ + public function to(): array + { + return $this->to; + } + + /** + * @return list + */ + public function cc(): array + { + return $this->cc; + } + + /** + * @return list + */ + public function bcc(): array + { + return $this->bcc; + } + + public function bodyStructure(): ?MessagePart + { + return $this->bodyStructure; + } + + public function bodyText(): ?string + { + return $this->bodyText; + } + + /** + * @return array + */ + public function bodySections(): array + { + return $this->bodySections; + } + + /** + * @param array $bodySections + */ + public function withBodyData(?MessagePart $bodyStructure, array $bodySections): self + { + return new self( + $this->sequence, + $this->uid, + $this->size, + $this->internalDate, + $this->flags, + $this->subject, + $this->sentAt, + $this->messageId, + $this->inReplyTo, + $this->from, + $this->sender, + $this->replyTo, + $this->to, + $this->cc, + $this->bcc, + $bodyStructure, + $bodySections, + ); + } +} \ No newline at end of file diff --git a/lib/Client/MessageAddress.php b/lib/Client/MessageAddress.php new file mode 100644 index 0000000..a7be76c --- /dev/null +++ b/lib/Client/MessageAddress.php @@ -0,0 +1,50 @@ +name; + } + + public function mailbox(): ?string + { + return $this->mailbox; + } + + public function host(): ?string + { + return $this->host; + } + + public function email(): ?string + { + if ($this->mailbox === null || $this->mailbox === '') { + return null; + } + + if ($this->host === null || $this->host === '') { + return $this->mailbox; + } + + return $this->mailbox . '@' . $this->host; + } + + public function toArray(): array + { + return [ + 'address' => $this->email(), + 'label' => $this->name, + ]; + } +} \ No newline at end of file diff --git a/lib/Client/MessageNotFound.php b/lib/Client/MessageNotFound.php deleted file mode 100644 index 53093c7..0000000 --- a/lib/Client/MessageNotFound.php +++ /dev/null @@ -1,11 +0,0 @@ - + */ + private static function parseAttributes(string $payload): array + { + $attributes = []; + $offset = 0; + $length = strlen($payload); + + while ($offset < $length) { + self::skipWhitespace($payload, $offset); + if ($offset >= $length) { + break; + } + + $name = self::parseAttributeName($payload, $offset); + if (!is_string($name) || $name === '') { + throw new ImapException('Unable to parse FETCH attribute name: ' . $payload); + } + + self::skipWhitespace($payload, $offset); + $attributes[strtoupper($name)] = self::parseToken($payload, $offset); + } + + return $attributes; + } + + private static function parseAttributeName(string $payload, int &$offset): string + { + self::skipWhitespace($payload, $offset); + + if (preg_match('/\GBODY(?:\.PEEK)?\[/Ai', $payload, $matches, 0, $offset) === 1) { + $start = $offset; + $offset += strlen($matches[0]); + $depth = 1; + $length = strlen($payload); + + while ($offset < $length) { + $char = $payload[$offset]; + + if ($char === '[') { + $depth++; + } elseif ($char === ']') { + $depth--; + if ($depth === 0) { + $offset++; + return substr($payload, $start, $offset - $start); + } + } + + $offset++; + } + + throw new ImapException('Unterminated FETCH BODY section attribute.'); + } + + $name = self::parseToken($payload, $offset); + if (!is_string($name)) { + throw new ImapException('Invalid FETCH attribute name.'); + } + + return $name; + } + + private static function parseToken(string $payload, int &$offset): mixed + { + self::skipWhitespace($payload, $offset); + $length = strlen($payload); + + if ($offset >= $length) { + throw new ImapException('Unexpected end of FETCH response.'); + } + + $char = $payload[$offset]; + + if ($char === '(') { + $offset++; + $items = []; + + while (true) { + self::skipWhitespace($payload, $offset); + if ($offset >= $length) { + throw new ImapException('Unterminated FETCH list response.'); + } + + if ($payload[$offset] === ')') { + $offset++; + return $items; + } + + $items[] = self::parseToken($payload, $offset); + } + } + + if ($char === '"') { + return self::parseQuotedString($payload, $offset); + } + + if ($char === '{') { + return self::parseLiteral($payload, $offset); + } + + $start = $offset; + while ($offset < $length && !ctype_space($payload[$offset]) && $payload[$offset] !== '(' && $payload[$offset] !== ')') { + $offset++; + } + + $atom = substr($payload, $start, $offset - $start); + if (strtoupper($atom) === 'NIL') { + return null; + } + + return $atom; + } + + private static function parseLiteral(string $payload, int &$offset): string + { + if (preg_match('/\G\{(\d+)\}\r\n/As', $payload, $matches, 0, $offset) !== 1 + && preg_match('/\G\{(\d+)\}\n/As', $payload, $matches, 0, $offset) !== 1) { + throw new ImapException('Invalid FETCH literal marker.'); + } + + $offset += strlen($matches[0]); + $length = (int) $matches[1]; + $literal = substr($payload, $offset, $length); + + if (strlen($literal) !== $length) { + throw new ImapException('FETCH literal length does not match payload.'); + } + + $offset += $length; + + return $literal; + } + + private static function parseQuotedString(string $payload, int &$offset): string + { + $offset++; + $length = strlen($payload); + $value = ''; + + while ($offset < $length) { + $char = $payload[$offset]; + + if ($char === '\\') { + $offset++; + if ($offset >= $length) { + break; + } + + $value .= $payload[$offset]; + $offset++; + continue; + } + + if ($char === '"') { + $offset++; + return $value; + } + + $value .= $char; + $offset++; + } + + throw new ImapException('Unterminated quoted FETCH string.'); + } + + private static function skipWhitespace(string $payload, int &$offset): void + { + $length = strlen($payload); + while ($offset < $length && ctype_space($payload[$offset])) { + $offset++; + } + } + + private static function toInt(mixed $value, string $message): int + { + if ($value === null || !preg_match('/^\d+$/', (string) $value)) { + throw new ImapException($message); + } + + return (int) $value; + } + + private static function toOptionalInt(mixed $value): ?int + { + if ($value === null || !preg_match('/^\d+$/', (string) $value)) { + return null; + } + + return (int) $value; + } + + /** + * @return list + */ + private static function parseFlags(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $flag): ?string => is_string($flag) && $flag !== '' ? $flag : null, + $value, + ))); + } + + private static function toNullableString(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + private static function envelopeString(?array $envelope, int $index): ?string + { + if ($envelope === null) { + return null; + } + + return self::toNullableString($envelope[$index] ?? null); + } + + private static function decodeMimeHeader(?string $value): ?string + { + if ($value === null || $value === '') { + return $value; + } + + return function_exists('mb_decode_mimeheader') ? mb_decode_mimeheader($value) : $value; + } + + private static function trimAngles(?string $value): ?string + { + if ($value === null) { + return null; + } + + return trim($value, '<>'); + } + + /** + * @return list + */ + private static function parseAddressList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $addresses = []; + foreach ($value as $address) { + if (!is_array($address)) { + continue; + } + + $addresses[] = new MessageAddress( + self::decodeMimeHeader(self::toNullableString($address[0] ?? null)), + self::toNullableString($address[2] ?? null), + self::toNullableString($address[3] ?? null), + ); + } + + return $addresses; + } + + private static function parseBodyPart(mixed $value, string $partId): ?MessagePart + { + if (!is_array($value) || $value === []) { + return null; + } + + if (is_array($value[0] ?? null)) { + $parts = []; + $index = 0; + while (isset($value[$index]) && is_array($value[$index])) { + $childPartId = $partId === '' ? (string) ($index + 1) : $partId . '.' . ($index + 1); + $child = self::parseBodyPart($value[$index], $childPartId); + if ($child !== null) { + $parts[] = $child; + } + $index++; + } + + $subtype = strtolower(self::toNullableString($value[$index] ?? null) ?? 'mixed'); + $parameters = self::parsePairs($value[$index + 1] ?? null); + [$disposition, $dispositionParameters] = self::parseDisposition($value[$index + 2] ?? null); + $language = self::parseStringList($value[$index + 3] ?? null); + $location = self::toNullableString($value[$index + 4] ?? null); + + return new MessagePart( + $partId, + 'multipart/' . $subtype, + $parameters, + null, + null, + null, + null, + $disposition, + $dispositionParameters, + $language, + $location, + null, + $parts, + ); + } + + $type = strtolower(self::toNullableString($value[0] ?? null) ?? 'application'); + $subtype = strtolower(self::toNullableString($value[1] ?? null) ?? 'octet-stream'); + $parameters = self::parsePairs($value[2] ?? null); + $contentId = self::trimAngles(self::toNullableString($value[3] ?? null)); + $description = self::toNullableString($value[4] ?? null); + $encoding = self::toNullableString($value[5] ?? null); + $size = self::toOptionalInt($value[6] ?? null); + + $tailOffset = in_array($type, ['text', 'message'], true) ? 8 : 7; + [$disposition, $dispositionParameters] = self::parseDisposition($value[$tailOffset + 1] ?? null); + $language = self::parseStringList($value[$tailOffset + 2] ?? null); + $location = self::toNullableString($value[$tailOffset + 3] ?? null); + + return new MessagePart( + $partId === '' ? '1' : $partId, + $type . '/' . $subtype, + $parameters, + $contentId, + $description, + $encoding, + $size, + $disposition, + $dispositionParameters, + $language, + $location, + null, + [], + ); + } + + /** + * @param array $attributes + * @return array + */ + private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null): array + { + $sections = []; + + foreach ($attributes as $name => $value) { + if (!preg_match('/^BODY(?:\.PEEK)?\[(.*)\]$/i', $name, $matches)) { + continue; + } + + if (!is_string($value)) { + continue; + } + + $section = strtoupper(trim($matches[1])); + if ($section === '') { + continue; + } + + if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) { + $section = $partMatches[1]; + } + + $sections[$section] = $value; + } + + if ($bodyStructure === null || !isset($sections['TEXT'])) { + return $bodyStructure === null ? $sections : self::decodeSections($sections, $bodyStructure); + } + + if ($bodyStructure->isMultipart()) { + $derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure); + unset($sections['TEXT']); + + foreach ($derivedSections as $section => $content) { + $sections[$section] ??= $content; + } + + return self::decodeSections($sections, $bodyStructure); + } + + if (str_starts_with($bodyStructure->mimeType(), 'text/')) { + $sections[$bodyStructure->partId()] ??= $sections['TEXT']; + unset($sections['TEXT']); + } + + return self::decodeSections($sections, $bodyStructure); + } + + /** + * @param array $sections + * @return array + */ + private static function decodeSections(array $sections, MessagePart $bodyStructure): array + { + $decodedSections = []; + + foreach ($sections as $section => $content) { + $part = self::findBodyPart($bodyStructure, (string) $section); + if ($part === null || !str_starts_with($part->mimeType(), 'text/')) { + $decodedSections[$section] = $content; + continue; + } + + $decodedSections[$section] = self::decodeSectionContent( + $content, + $part->encoding(), + $part->parameters()['charset'] ?? 'us-ascii', + ); + } + + return $decodedSections; + } + + /** + * @return array + */ + private static function sectionsFromBodyText(string $content, MessagePart $part): array + { + if ($part->isMultipart()) { + $boundary = $part->parameters()['boundary'] ?? ''; + if ($boundary === '') { + return []; + } + + $sections = []; + $segments = self::splitMultipartBody($content, $boundary); + foreach ($part->parts() as $index => $childPart) { + if (!isset($segments[$index])) { + break; + } + + foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) { + $sections[$section] = $childContent; + } + } + + return $sections; + } + + if (!str_starts_with($part->mimeType(), 'text/')) { + return []; + } + + return [$part->partId() => $content]; + } + + /** + * @return array + */ + private static function sectionsFromMimeEntity(string $content, MessagePart $part): array + { + [, $body] = self::splitMimeEntity($content); + + if ($part->isMultipart()) { + return self::sectionsFromBodyText($body, $part); + } + + if (!str_starts_with($part->mimeType(), 'text/')) { + return []; + } + + return [$part->partId() => $body]; + } + + private static function findBodyPart(MessagePart $part, string $section): ?MessagePart + { + if ($part->partId() === $section) { + return $part; + } + + foreach ($part->parts() as $childPart) { + $match = self::findBodyPart($childPart, $section); + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * @return list + */ + private static function splitMultipartBody(string $content, string $boundary): array + { + $pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/'; + if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) { + return []; + } + + $segments = []; + $segmentStart = null; + + foreach ($matches[0] as $index => [$match, $offset]) { + if ($segmentStart !== null) { + $segments[] = substr($content, $segmentStart, $offset - $segmentStart); + } + + $isClosing = isset($matches[1][$index][1]) + && $matches[1][$index][1] !== -1 + && $matches[1][$index][0] === '--'; + + if ($isClosing) { + break; + } + + $segmentStart = $offset + strlen($match); + } + + return $segments; + } + + /** + * @return array{0: string, 1: string} + */ + private static function splitMimeEntity(string $content): array + { + foreach (["\r\n\r\n", "\n\n"] as $separator) { + $position = strpos($content, $separator); + if ($position === false) { + continue; + } + + return [ + substr($content, 0, $position), + substr($content, $position + strlen($separator)), + ]; + } + + return ['', $content]; + } + + private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string + { + $decoded = match (strtolower($encoding ?? '7bit')) { + 'quoted-printable' => quoted_printable_decode($content), + 'base64' => base64_decode($content, true) ?: '', + default => $content, + }; + + if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) { + return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); + } + + try { + $converted = mb_convert_encoding($decoded, 'UTF-8', $charset); + if ($converted !== false) { + return $converted; + } + } catch (\ValueError) { + } + + $converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded); + $decoded = $converted !== false ? $converted : $decoded; + + return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); + } + + /** + * @return array + */ + private static function parsePairs(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $pairs = []; + for ($index = 0; $index < count($value); $index += 2) { + $name = self::toNullableString($value[$index] ?? null); + if ($name === null) { + continue; + } + + $pairs[strtolower($name)] = self::toNullableString($value[$index + 1] ?? null) ?? ''; + } + + return $pairs; + } + + /** + * @return array{0: ?string, 1: array} + */ + private static function parseDisposition(mixed $value): array + { + if (!is_array($value)) { + return [null, []]; + } + + return [ + strtolower(self::toNullableString($value[0] ?? null) ?? ''), + self::parsePairs($value[1] ?? null), + ]; + } + + /** + * @return list + */ + private static function parseStringList(mixed $value): array + { + if ($value === null) { + return []; + } + + if (is_string($value)) { + return [$value]; + } + + if (!is_array($value)) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $item): ?string => is_string($item) && $item !== '' ? $item : null, + $value, + ))); + } +} \ No newline at end of file diff --git a/lib/Client/MessagePart.php b/lib/Client/MessagePart.php new file mode 100644 index 0000000..5f6c4ae --- /dev/null +++ b/lib/Client/MessagePart.php @@ -0,0 +1,217 @@ + $parameters + * @param array $dispositionParameters + * @param list $language + * @param list $parts + */ + public function __construct( + private readonly string $partId, + private readonly string $mimeType, + private readonly array $parameters = [], + private readonly ?string $contentId = null, + private readonly ?string $description = null, + private readonly ?string $encoding = null, + private readonly ?int $size = null, + private readonly ?string $disposition = null, + private readonly array $dispositionParameters = [], + private readonly array $language = [], + private readonly ?string $location = null, + private readonly ?string $content = null, + private readonly array $parts = [], + ) {} + + public function partId(): string + { + return $this->partId; + } + + public function mimeType(): string + { + return $this->mimeType; + } + + /** + * @return array + */ + public function parameters(): array + { + return $this->parameters; + } + + public function contentId(): ?string + { + return $this->contentId; + } + + public function description(): ?string + { + return $this->description; + } + + public function encoding(): ?string + { + return $this->encoding; + } + + public function size(): ?int + { + return $this->size; + } + + public function disposition(): ?string + { + return $this->disposition; + } + + /** + * @return array + */ + public function dispositionParameters(): array + { + return $this->dispositionParameters; + } + + /** + * @return list + */ + public function language(): array + { + return $this->language; + } + + public function location(): ?string + { + return $this->location; + } + + public function content(): ?string + { + return $this->content; + } + + /** + * @return list + */ + public function parts(): array + { + return $this->parts; + } + + public function isMultipart(): bool + { + return str_starts_with($this->mimeType, 'multipart/'); + } + + /** + * @param array $sections + */ + public function withInjectedSections(array $sections): self + { + if ($this->parts !== []) { + $parts = []; + foreach ($this->parts as $part) { + $parts[] = $part->withInjectedSections($sections); + } + + return new self( + $this->partId, + $this->mimeType, + $this->parameters, + $this->contentId, + $this->description, + $this->encoding, + $this->size, + $this->disposition, + $this->dispositionParameters, + $this->language, + $this->location, + $this->content, + $parts, + ); + } + + if (!str_starts_with($this->mimeType, 'text/')) { + return $this; + } + + if (!array_key_exists($this->partId, $sections)) { + return $this; + } + + return new self( + $this->partId, + $this->mimeType, + $this->parameters, + $this->contentId, + $this->description, + $this->encoding, + $this->size, + $this->disposition, + $this->dispositionParameters, + $this->language, + $this->location, + self::decodeContent($sections[$this->partId], $this->encoding, $this->parameters['charset'] ?? 'us-ascii'), + [], + ); + } + + public function toArray(): array + { + $data = [ + 'partId' => $this->partId, + 'type' => $this->mimeType, + 'blobId' => $this->contentId, + 'charset' => $this->parameters['charset'] ?? null, + 'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null, + 'encoding' => $this->encoding, + 'size' => $this->size, + 'disposition' => $this->disposition, + 'language' => $this->language === [] ? null : implode(',', $this->language), + 'location' => $this->location, + 'content' => $this->content, + ]; + + $children = []; + foreach ($this->parts as $part) { + $children[] = $part->toArray(); + } + + $data['subParts'] = $children === [] ? null : $children; + + return array_filter($data, static fn (mixed $value): bool => $value !== null); + } + + private static function decodeContent(string $content, ?string $encoding, string $charset): string + { + $decoded = match (strtolower($encoding ?? '7bit')) { + 'quoted-printable' => quoted_printable_decode($content), + 'base64' => base64_decode($content, true) ?: '', + default => $content, + }; + + if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) { + return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); + } + + try { + $converted = mb_convert_encoding($decoded, 'UTF-8', $charset); + if ($converted !== false) { + return $converted; + } + } catch (\ValueError) { + } + + $converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded); + $decoded = $converted !== false ? $converted : $decoded; + + return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); + } +} \ No newline at end of file diff --git a/lib/Client/Mime/LazyMessage.php b/lib/Client/Mime/LazyMessage.php deleted file mode 100644 index 9c5c972..0000000 --- a/lib/Client/Mime/LazyMessage.php +++ /dev/null @@ -1,56 +0,0 @@ -id = $id; - - if (null !== $headers) { - $this->headers = $headers; - } - - if (null !== $internalDate) { - $this->internalDate = $internalDate; - } - } - - public function headers(): array - { - if (!isset($this->headers)) { - $this->headers = $this->client->fetchHeaders($this->id); - } - - return parent::headers(); - } - - public function body(): Part - { - if (!isset($this->body)) { - $this->body = $this->client->fetchBody($this->id); - } - - return parent::body(); - } - - public function internalDate(): DateTimeImmutable - { - if (!isset($this->internalDate)) { - $this->internalDate = $this->client->fetchInternalDate($this->id); - } - - return parent::internalDate(); - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Message.php b/lib/Client/Mime/Message.php deleted file mode 100644 index 71836c3..0000000 --- a/lib/Client/Mime/Message.php +++ /dev/null @@ -1,55 +0,0 @@ - $headers - */ - public function __construct( - protected int $id, - protected array $headers, - protected Part $body, - protected DateTimeImmutable $internalDate, - ) { - } - - public function id(): int - { - return $this->id; - } - - /** - * @return array - */ - public function headers(): array - { - return $this->headers; - } - - public function body(): Part - { - return $this->body; - } - - public function internalDate(): DateTimeImmutable - { - return $this->internalDate; - } - - public function textBody(): ?string - { - return $this->body()->findPartByMimeType('text/plain')?->decodedBody(); - } - - public function htmlBody(): ?string - { - return $this->body()->findPartByMimeType('text/html')?->decodedBody(); - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Body.php b/lib/Client/Mime/Part/Body.php deleted file mode 100644 index 49e9e50..0000000 --- a/lib/Client/Mime/Part/Body.php +++ /dev/null @@ -1,20 +0,0 @@ -value; - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Disposition.php b/lib/Client/Mime/Part/Disposition.php deleted file mode 100644 index e7d26b6..0000000 --- a/lib/Client/Mime/Part/Disposition.php +++ /dev/null @@ -1,14 +0,0 @@ -value)) { - $this->value = $this->client->fetchSectionBody($this->id, $this->section); - } - - return $this->value; - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Part/MultiPart.php b/lib/Client/Mime/Part/MultiPart.php deleted file mode 100644 index e1151cb..0000000 --- a/lib/Client/Mime/Part/MultiPart.php +++ /dev/null @@ -1,31 +0,0 @@ - $attributes - * @param list $parts - */ - public function __construct( - string $subtype, - array $attributes, - public array $parts, - ) { - parent::__construct('multipart', $subtype, $attributes); - } - - public function findPartByMimeType(string $mimeType): ?SinglePart - { - foreach ($this->parts as $part) { - if ($matchedPart = $part->findPartByMimeType(strtolower($mimeType))) { - return $matchedPart; - } - } - - return null; - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Part/Part.php b/lib/Client/Mime/Part/Part.php deleted file mode 100644 index 467d059..0000000 --- a/lib/Client/Mime/Part/Part.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public array $attributes; - - /** - * @param array $attributes - */ - public function __construct( - string $type, - string $subtype, - array $attributes, - ) { - $this->subtype = strtolower($subtype); - $this->type = strtolower($type); - $this->attributes = $attributes; - } - - abstract public function findPartByMimeType(string $mimeType): ?SinglePart; - - public function mimeType(): string - { - return $this->type.'/'.$this->subtype; - } -} \ No newline at end of file diff --git a/lib/Client/Mime/Part/SinglePart.php b/lib/Client/Mime/Part/SinglePart.php deleted file mode 100644 index d94e828..0000000 --- a/lib/Client/Mime/Part/SinglePart.php +++ /dev/null @@ -1,62 +0,0 @@ -encoding = strtolower($encoding); - parent::__construct($type, $subtype, $attributes); - } - - public function body(): string - { - return (string) $this->body; - } - - public function decodedBody(): string - { - return match ($this->encoding) { - 'quoted-printable' => quoted_printable_decode($this->body()), - 'base64' => base64_decode($this->body()), - default => $this->body(), - }; - } - - public function charset(): string - { - return $this->charset; - } - - public function encoding(): string - { - return $this->encoding; - } - - public function disposition(): ?Disposition - { - return $this->disposition; - } - - public function findPartByMimeType(string $mimeType): ?SinglePart - { - if ($this->mimeType() === strtolower($mimeType)) { - return $this; - } - - return null; - } -} diff --git a/lib/Client/PreFetchOptions.php b/lib/Client/PreFetchOptions.php deleted file mode 100644 index 753419c..0000000 --- a/lib/Client/PreFetchOptions.php +++ /dev/null @@ -1,14 +0,0 @@ -|null $flags - */ - public function __construct( - string $mailboxName, - private string $message, - ?array $flags, - ?DateTimeInterface $internalDate - ) { - parent::__construct( - 'APPEND', - ...array_filter([ - new QuotedString($mailboxName), - ParenthesizedList::tryFrom($flags), - DateTime::tryFrom($internalDate), - new SynchronizingLiteral($this->message), - ]) - ); - } - - public function continue(): string - { - return $this->message; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Argument.php b/lib/Client/Protocol/Command/Argument/Argument.php deleted file mode 100644 index f077eaf..0000000 --- a/lib/Client/Protocol/Command/Argument/Argument.php +++ /dev/null @@ -1,10 +0,0 @@ -value->format('d-M-Y'); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/DateTime.php b/lib/Client/Protocol/Command/Argument/DateTime.php deleted file mode 100644 index edd3074..0000000 --- a/lib/Client/Protocol/Command/Argument/DateTime.php +++ /dev/null @@ -1,24 +0,0 @@ -value->format('d-M-Y H:i:s O').'"'; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/ParenthesizedList.php b/lib/Client/Protocol/Command/Argument/ParenthesizedList.php deleted file mode 100644 index 46b1c3d..0000000 --- a/lib/Client/Protocol/Command/Argument/ParenthesizedList.php +++ /dev/null @@ -1,28 +0,0 @@ - $items - */ - public function __construct(public array $items) - { - } - - /** - * @param list $items - */ - public static function tryFrom(?array $items): ?self - { - return empty($items) ? null : new self($items); - } - - public function __toString(): string - { - return sprintf('(%s)', implode(' ', $this->items)); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/QuotedString.php b/lib/Client/Protocol/Command/Argument/QuotedString.php deleted file mode 100644 index 6bb178b..0000000 --- a/lib/Client/Protocol/Command/Argument/QuotedString.php +++ /dev/null @@ -1,17 +0,0 @@ -value); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/All.php b/lib/Client/Protocol/Command/Argument/Search/All.php deleted file mode 100644 index de29f88..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/All.php +++ /dev/null @@ -1,13 +0,0 @@ -value) . '"'; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/Criteria.php b/lib/Client/Protocol/Command/Argument/Search/Criteria.php deleted file mode 100644 index 3031916..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Criteria.php +++ /dev/null @@ -1,11 +0,0 @@ -value) . '"'; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/Header.php b/lib/Client/Protocol/Command/Argument/Search/Header.php deleted file mode 100644 index 6109003..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Header.php +++ /dev/null @@ -1,19 +0,0 @@ -fieldName, new QuotedString($this->value)); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/Larger.php b/lib/Client/Protocol/Command/Argument/Search/Larger.php deleted file mode 100644 index 1e79555..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Larger.php +++ /dev/null @@ -1,15 +0,0 @@ -size; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/Not.php b/lib/Client/Protocol/Command/Argument/Search/Not.php deleted file mode 100644 index 2b3756c..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Not.php +++ /dev/null @@ -1,17 +0,0 @@ -criteria.')'; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Search/Seen.php b/lib/Client/Protocol/Command/Argument/Search/Seen.php deleted file mode 100644 index cb0a4f2..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Seen.php +++ /dev/null @@ -1,13 +0,0 @@ -size; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/Subject.php b/lib/Client/Protocol/Command/Argument/Search/Subject.php deleted file mode 100644 index 8e6991b..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Subject.php +++ /dev/null @@ -1,15 +0,0 @@ -value) . '"'; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/To.php b/lib/Client/Protocol/Command/Argument/Search/To.php deleted file mode 100644 index db4dbbb..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/To.php +++ /dev/null @@ -1,15 +0,0 @@ -value) . '"'; - } -} diff --git a/lib/Client/Protocol/Command/Argument/Search/Unflagged.php b/lib/Client/Protocol/Command/Argument/Search/Unflagged.php deleted file mode 100644 index 074a64e..0000000 --- a/lib/Client/Protocol/Command/Argument/Search/Unflagged.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ - private array $numbers; - private ?string $range; - - public function __construct(int ...$numbers) - { - $this->numbers = $numbers; - $this->range = null; - } - - public static function range(int $from, int $to): self - { - $set = new self(); - $set->range = $from . ':' . $to; - return $set; - } - - /** - * Build a SequenceSet that matches every message in the mailbox (1:*). - */ - public static function all(): self - { - $set = new self(); - $set->range = '1:*'; - return $set; - } - - /** - * Build a SequenceSet from a flat array of UIDs, collapsing consecutive - * values into n:m ranges. - * - * Examples: - * [1, 2, 3, 5, 6, 10] → "1:3,5:6,10" - * [42] → "42" - * [7, 3, 4, 5] → "3:5,7" - * - * @param int[] $uids - */ - public static function list(array $uids): self - { - if (empty($uids)) { - return new self(); - } - - $uids = array_unique($uids); - sort($uids); - - $ranges = []; - $start = $end = $uids[0]; - - for ($i = 1, $count = count($uids); $i <= $count; $i++) { - $current = $uids[$i] ?? null; - if ($current !== null && $current === $end + 1) { - $end = $current; - } else { - $ranges[] = $start === $end ? (string) $start : $start . ':' . $end; - if ($current !== null) { - $start = $end = $current; - } - } - } - - $set = new self(); - $set->range = implode(',', $ranges); - return $set; - } - - public function __toString(): string - { - if ($this->range !== null) { - return $this->range; - } - - return implode(',', $this->numbers); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/Store/Flags.php b/lib/Client/Protocol/Command/Argument/Store/Flags.php deleted file mode 100644 index 62503f4..0000000 --- a/lib/Client/Protocol/Command/Argument/Store/Flags.php +++ /dev/null @@ -1,30 +0,0 @@ - $flags - */ - public function __construct( - private array $flags, - private string $modifier = '', - private bool $silent = true, - ) { - } - - public function __toString(): string - { - return sprintf( - '%sFLAGS%s (%s)', - $this->modifier, - $this->silent ? '.SILENT' : '', - implode(' ', $this->flags), - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php b/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php deleted file mode 100644 index 50a9022..0000000 --- a/lib/Client/Protocol/Command/Argument/SynchronizingLiteral.php +++ /dev/null @@ -1,20 +0,0 @@ -value) - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php b/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php deleted file mode 100644 index 97f7247..0000000 --- a/lib/Client/Protocol/Command/Authenticate/SASLMechanism.php +++ /dev/null @@ -1,12 +0,0 @@ -user, $this->accessToken) - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/AuthenticateCommand.php b/lib/Client/Protocol/Command/AuthenticateCommand.php deleted file mode 100644 index 81e3ac7..0000000 --- a/lib/Client/Protocol/Command/AuthenticateCommand.php +++ /dev/null @@ -1,20 +0,0 @@ -mechanism->continue(); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Command.php b/lib/Client/Protocol/Command/Command.php deleted file mode 100644 index 8ed7576..0000000 --- a/lib/Client/Protocol/Command/Command.php +++ /dev/null @@ -1,40 +0,0 @@ -command = $command; - $this->arguments = $arguments; - } - - public function command(): string - { - return $this->command; - } - - public function __toString(): string - { - return sprintf( - '%s %s', - $this->command, - implode(' ', $this->arguments) - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/Continuable.php b/lib/Client/Protocol/Command/Continuable.php deleted file mode 100644 index 0ea6545..0000000 --- a/lib/Client/Protocol/Command/Continuable.php +++ /dev/null @@ -1,10 +0,0 @@ - $items - */ - public function __construct( - bool $uid, - SequenceSet $sequenceSet, - array $items, - ) { - parent::__construct( - $uid ? 'UID FETCH' : 'FETCH', - $sequenceSet, - new ParenthesizedList($items), - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Command/ListCommand.php b/lib/Client/Protocol/Command/ListCommand.php deleted file mode 100644 index 720b4dd..0000000 --- a/lib/Client/Protocol/Command/ListCommand.php +++ /dev/null @@ -1,19 +0,0 @@ - $command + * @return TResult + */ + public function perform(CommandInterface $command, SessionContext $context): mixed + { + $this->assertState($command->allowedStates(), $context->state(), $command->name()); + + $this->logger?->debug('IMAP command execution started: {command} (state={state})', [ + 'command' => $command->name(), + 'state' => $context->state()->value, + ]); + + $tag = $this->tags->next(); + $frame = $command->encode($tag, $context); + $this->writer->write($tag, $frame); + + return $command->handle(new ResponseStream(function () use ($tag, $context): Generator { + yield from $this->responsesUntilCompletion($tag, $context); + }), $context); + } + + /** + * @param list $allowedStates + */ + private function assertState(array $allowedStates, SessionState $currentState, string $commandName): void + { + foreach ($allowedStates as $allowedState) { + if ($allowedState === $currentState) { + return; + } + } + + throw new ImapException(sprintf( + 'Command %s is not allowed while session is in state %s.', + $commandName, + $currentState->value, + )); + } + + private function responsesUntilCompletion(string $tag, SessionContext $context): Generator + { + while (true) { + $response = $this->reader->readResponse(); + + if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') { + $context->replaceCapabilities(...$response->payloadTokens()); + } + + yield $response; + + if ($response instanceof TaggedResponse && $response->tag() === $tag) { + $this->logger?->debug('IMAP command execution completed: tag={tag} status={status}', [ + 'tag' => $tag, + 'status' => $response->status(), + ]); + return; + } + } + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/CommandFailed.php b/lib/Client/Protocol/CommandFailed.php deleted file mode 100644 index e6688be..0000000 --- a/lib/Client/Protocol/CommandFailed.php +++ /dev/null @@ -1,16 +0,0 @@ -message); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/CommandInteraction.php b/lib/Client/Protocol/CommandInteraction.php deleted file mode 100644 index 5195564..0000000 --- a/lib/Client/Protocol/CommandInteraction.php +++ /dev/null @@ -1,70 +0,0 @@ -tag, - $this->command, - ); - - $this->connection->send($request); - $streamResponse = $this->connection->receive(); - - return $this->responseHandler->handle($this->tag, $streamResponse, $this); - } - - /** - * Like interact() but yields each untagged Line immediately as it arrives. - * The terminal Status is the generator's return value. - * - * @return Generator - */ - public function streamInteract(): Generator - { - $request = sprintf( - "%s %s\r\n", - $this->tag, - $this->command, - ); - - $this->connection->send($request); - $streamResponse = $this->connection->receive(); - - yield from $this->responseHandler->stream($this->tag, $streamResponse, $this); - } - - public function continue(): void - { - if (!$this->command instanceof Continuable) { - throw new RuntimeException( - sprintf('Command %s does not support continuable interaction', $this->command->command()) - ); - } - - $this->connection->send($this->command->continue()."\r\n"); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/ConnectionRejected.php b/lib/Client/Protocol/ConnectionRejected.php deleted file mode 100644 index ea39c9d..0000000 --- a/lib/Client/Protocol/ConnectionRejected.php +++ /dev/null @@ -1,11 +0,0 @@ -connection = $connection; - $this->tagGenerator = new TagGenerator(); - $this->responseHandler = new ResponseHandler(new Parser()); - } - - public function __destruct() - { - $this->disconnect(); - } - - public function connect(): void - { - if ($this->connection->isOpen()) { - return; - } - - $this->connection->open(); - - $responseStream = $this->connection->receive(); - - $greeting = $this->responseHandler->handle('*', $responseStream, new UnexpectedContinuationHandler()); - - match ($greeting->status->type) { - StatusType::OK => null, // Do nothing - StatusType::PREAUTH => throw new RuntimeException('pre-auth is not supported'), - StatusType::BAD, - StatusType::NO, - StatusType::BYE => throw new ConnectionRejected($greeting->status->message), - }; - } - - public function disconnect(): void - { - $this->connection->close(); - } - - /** - * Perform STARTTLS negotiation (patch). - * - * Sends the STARTTLS command and upgrades the underlying socket to TLS. - * The connection must be a SocketConnection (or any Connection that - * implements upgradeTls()). Call this after connect() but before logIn(). - * - * @throws \RuntimeException if the server rejects STARTTLS - * @throws \BadMethodCallException if the connection does not support TLS upgrade - */ - public function startTls(): void - { - if (!method_exists($this->connection, 'upgradeTls')) { - throw new \BadMethodCallException( - 'The current Connection implementation does not support STARTTLS upgrade' - ); - } - - $response = $this->send(new StartTlsCommand()); - - if ($response->status->type !== StatusType::OK) { - throw new \RuntimeException( - 'Server rejected STARTTLS: ' . $response->status->message - ); - } - - $this->connection->upgradeTls(); - } - - public function send(Command $command): Response - { - $interaction = new CommandInteraction( - $this->connection, - $this->responseHandler, - $this->tagGenerator->next(), - $command, - ); - - $response = $interaction->interact(); - - if ($response->status->type != StatusType::OK) { - throw CommandFailed::withStatus($response->status); - } - - return $response; - } - - /** - * Sends $command and returns a Generator that yields each untagged Line as - * it arrives from the socket. CommandFailed is thrown (inside the generator) - * if the server responds with NO or BAD. - * - * @return Generator - */ - public function sendStreaming(Command $command): Generator - { - $this->connect(); - - $interaction = new CommandInteraction( - $this->connection, - $this->responseHandler, - $this->tagGenerator->next(), - $command, - ); - - yield from $interaction->streamInteract(); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/ProtocolReader.php b/lib/Client/Protocol/ProtocolReader.php new file mode 100644 index 0000000..e908ecf --- /dev/null +++ b/lib/Client/Protocol/ProtocolReader.php @@ -0,0 +1,121 @@ +trimTrailingLineEnding($this->connection->readLine()); + + if (!str_starts_with($raw, '* ')) { + throw new ImapException(sprintf('Expected IMAP greeting, got: %s', $raw)); + } + + $parts = preg_split('/\s+/', substr($raw, 2), 2) ?: []; + $status = strtoupper($parts[0] ?? ''); + $text = $parts[1] ?? ''; + + $this->logger?->debug('IMAP greeting received: {raw}', [ + 'status' => $status, + 'raw' => $raw, + ]); + + return new GreetingResponse($status, $text, $raw); + } + + public function readResponse(): ResponseInterface + { + $raw = $this->readRawResponse(); + + if ($raw === '') { + throw new ImapException('Received empty IMAP response line.'); + } + + if (str_starts_with($raw, '* ')) { + $parts = preg_split('/\s+/', substr($raw, 2), 2) ?: []; + $label = strtoupper($parts[0] ?? ''); + $this->logger?->debug('IMAP untagged response received: {raw}', [ + 'label' => $label, + 'raw' => $raw, + ]); + return new UntaggedResponse( + $label, + $parts[1] ?? '', + $raw, + ); + } + + if (str_starts_with($raw, '+')) { + $this->logger?->debug('IMAP continuation response received: {raw}', [ + 'raw' => $raw, + ]); + return new ContinuationResponse(ltrim(substr($raw, 1)), $raw); + } + + $parts = preg_split('/\s+/', $raw, 3) ?: []; + + if (count($parts) < 2) { + throw new ImapException(sprintf('Malformed tagged IMAP response: %s', $raw)); + } + + $status = strtoupper($parts[1]); + $this->logger?->debug('IMAP tagged response received: {raw}', [ + 'tag' => $parts[0], + 'status' => $status, + 'raw' => $raw, + ]); + + return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw); + } + + private function readRawResponse(): string + { + $raw = $this->connection->readLine(); + + while (($literalLength = $this->trailingLiteralLength($raw)) !== null) { + $raw .= $this->connection->readBytes($literalLength); + $raw .= $this->connection->readLine(); + } + + return $this->trimTrailingLineEnding($raw); + } + + private function trailingLiteralLength(string $raw): ?int + { + if (preg_match('/\{(\d+)\}\r?\n$/', $raw, $matches) !== 1) { + return null; + } + + return (int) $matches[1]; + } + + private function trimTrailingLineEnding(string $raw): string + { + if (str_ends_with($raw, "\r\n")) { + return substr($raw, 0, -2); + } + + if (str_ends_with($raw, "\n")) { + return substr($raw, 0, -1); + } + + return $raw; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/ProtocolWriter.php b/lib/Client/Protocol/ProtocolWriter.php new file mode 100644 index 0000000..0ea5da6 --- /dev/null +++ b/lib/Client/Protocol/ProtocolWriter.php @@ -0,0 +1,44 @@ +toWire($tag); + + $this->logger?->debug('IMAP command sent: {raw}', [ + 'tag' => $tag, + 'command' => strtok($frame->commandLine(), ' ') ?: $frame->commandLine(), + 'raw' => $this->sanitizeWire($wire), + ]); + + $this->connection->write($wire); + } + + private function sanitizeWire(string $wire): string + { + $trimmed = rtrim($wire, "\r\n"); + + if (preg_match('/^(\S+\s+LOGIN\s+".*?"\s+)".*"$/i', $trimmed, $matches)) { + return $matches[1] . '"[REDACTED]"'; + } + + if (preg_match('/^(\S+\s+AUTHENTICATE\s+\S+)(?:\s+.+)?$/i', $trimmed, $matches)) { + return $matches[1] . ' [REDACTED]'; + } + + return $trimmed; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/RequestFrame.php b/lib/Client/Protocol/RequestFrame.php new file mode 100644 index 0000000..b8fda7f --- /dev/null +++ b/lib/Client/Protocol/RequestFrame.php @@ -0,0 +1,22 @@ +commandLine; + } + + public function toWire(string $tag): string + { + return $tag . ' ' . $this->commandLine . "\r\n"; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/ContinuationResponse.php b/lib/Client/Protocol/Response/ContinuationResponse.php new file mode 100644 index 0000000..852d1c8 --- /dev/null +++ b/lib/Client/Protocol/Response/ContinuationResponse.php @@ -0,0 +1,23 @@ +text; + } + + public function raw(): string + { + return $this->raw; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/GreetingResponse.php b/lib/Client/Protocol/Response/GreetingResponse.php new file mode 100644 index 0000000..ab9b567 --- /dev/null +++ b/lib/Client/Protocol/Response/GreetingResponse.php @@ -0,0 +1,29 @@ +status; + } + + public function text(): string + { + return $this->text; + } + + public function raw(): string + { + return $this->raw; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/CommandContinuation.php b/lib/Client/Protocol/Response/Line/CommandContinuation.php deleted file mode 100644 index 5305850..0000000 --- a/lib/Client/Protocol/Response/Line/CommandContinuation.php +++ /dev/null @@ -1,13 +0,0 @@ - $capabilities - */ - public function __construct(public array $capabilities) - { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Data.php b/lib/Client/Protocol/Response/Line/Data/Data.php deleted file mode 100644 index 3bd72a0..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Data.php +++ /dev/null @@ -1,11 +0,0 @@ - $attributes - */ - public function __construct( - public string $type, - public array $attributes, - ) { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php deleted file mode 100644 index e7fae11..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MessagePart.php +++ /dev/null @@ -1,42 +0,0 @@ - $attributes - * @param string[]|null $language - */ - public function __construct( - array $attributes, - ?string $id, - ?string $description, - string $encoding, - int $size, - public Envelope $envelope, - public BodyStructure $bodyStructure, - public int $textLines, - ?string $md5, - ?Disposition $disposition, - ?array $language, - ?string $location, - ) { - parent::__construct( - 'MESSAGE', - 'RFC822', - $attributes, - $id, - $description, - $encoding, - $size, - $md5, - $disposition, - $language, - $location, - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php deleted file mode 100644 index 268881a..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/MultiPart.php +++ /dev/null @@ -1,24 +0,0 @@ - $attributes - * @param string[] $language - * @param list $parts - */ - public function __construct( - string $subtype, - array $attributes, - public array $parts, - public ?Disposition $disposition, - public ?array $language, - public ?string $location, - ) { - parent::__construct('MULTIPART', $subtype, $attributes); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php deleted file mode 100644 index 78f2302..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/Part.php +++ /dev/null @@ -1,18 +0,0 @@ - $attributes - */ - public function __construct( - public string $type, - public string $subtype, - public array $attributes, - ) { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php deleted file mode 100644 index 69c3678..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/SinglePart.php +++ /dev/null @@ -1,28 +0,0 @@ - $attributes - * @param string[]|null $language - */ - public function __construct( - string $type, - string $subtype, - array $attributes, - public ?string $id, - public ?string $description, - public string $encoding, - public int $size, - public ?string $md5, - public ?Disposition $disposition, - public ?array $language, - public ?string $location, - ) { - parent::__construct($type, $subtype, $attributes); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php b/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php deleted file mode 100644 index d4c41e9..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/BodyStructure/TextPart.php +++ /dev/null @@ -1,38 +0,0 @@ - $attributes - * @param string[]|null $language - */ - public function __construct( - string $subtype, - array $attributes, - ?string $id, - ?string $description, - string $encoding, - int $size, - public int $textLines, - ?string $md5, - ?Disposition $disposition, - ?array $language, - ?string $location, - ) { - parent::__construct( - 'TEXT', - $subtype, - $attributes, - $id, - $description, - $encoding, - $size, - $md5, - $disposition, - $language, - $location, - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php b/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php deleted file mode 100644 index 162d41c..0000000 --- a/lib/Client/Protocol/Response/Line/Data/Fetch/Envelope.php +++ /dev/null @@ -1,32 +0,0 @@ -|null $flags - * @param BodySection[] $bodySections - */ - public function __construct( - public int $id, - public ?array $flags = null, - public ?\DateTimeImmutable $internalDate = null, - public ?Envelope $envelope = null, - public ?int $rfc822Size = null, - public ?string $rfc822 = null, - public ?int $uid = null, - public ?BodyStructure $bodyStructure = null, - public array $bodySections = [], - ) { - } - - public function getBodySection(string $name): ?BodySection - { - foreach (($this->bodySections ?? []) as $bodySection) { - if ($bodySection->section == $name) { - return $bodySection; - } - } - - return null; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/FlagsData.php b/lib/Client/Protocol/Response/Line/Data/FlagsData.php deleted file mode 100644 index 4761eb0..0000000 --- a/lib/Client/Protocol/Response/Line/Data/FlagsData.php +++ /dev/null @@ -1,15 +0,0 @@ - $flags - */ - public function __construct(public array $flags) - { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/ListData.php b/lib/Client/Protocol/Response/Line/Data/ListData.php deleted file mode 100644 index 99846fd..0000000 --- a/lib/Client/Protocol/Response/Line/Data/ListData.php +++ /dev/null @@ -1,18 +0,0 @@ - $nameAttributes - */ - public function __construct( - public array $nameAttributes, - public string $hierarchyDelimiter, - public string $name - ) { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Data/RecentData.php b/lib/Client/Protocol/Response/Line/Data/RecentData.php deleted file mode 100644 index 10a8431..0000000 --- a/lib/Client/Protocol/Response/Line/Data/RecentData.php +++ /dev/null @@ -1,12 +0,0 @@ - $numbers - */ - public function __construct(public array $numbers) - { - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Line/Line.php b/lib/Client/Protocol/Response/Line/Line.php deleted file mode 100644 index a650689..0000000 --- a/lib/Client/Protocol/Response/Line/Line.php +++ /dev/null @@ -1,9 +0,0 @@ - - */ -class Lexer extends AbstractLexer -{ - protected function getCatchablePatterns(): array - { - return [ - '[a-zA-Z0-9\.\-]+', - '\r\n', - ]; - } - - protected function getNonCatchablePatterns(): array - { - return []; - } - - protected function getType(string &$value) - { - $normalizedValue = strtoupper($value); - - return match($normalizedValue) { - ' ' => TokenType::SP, - '.' => TokenType::DOT, - '*' => TokenType::ASTERISK, - '%' => TokenType::PERCENT_SIGN, - '+' => TokenType::PLUS_SIGN, - '=' => TokenType::EQUALS_SIGN, - '"' => TokenType::DOUBLE_QUOTE, - '[' => TokenType::OPEN_BRACKETS, - ']' => TokenType::CLOSE_BRACKETS, - '{' => TokenType::OPEN_BRACES, - '}' => TokenType::CLOSE_BRACES, - '(' => TokenType::OPEN_PARENTHESIS, - ')' => TokenType::CLOSE_PARENTHESIS, - '\\' => TokenType::BACKSLASH, - "\r\n" => TokenType::CRLF, - 'NIL' => TokenType::NIL, - 'OK', 'NO', 'BAD', 'BYE', 'PREAUTH' => TokenType::STATUS, - 'APPENDUID' => TokenType::APPENDUID, - 'UNSEEN' => TokenType::UNSEEN, - 'UIDVALIDITY' => TokenType::UIDVALIDITY, - 'UIDNEXT' => TokenType::UIDNEXT, - 'PERMANENTFLAGS' => TokenType::PERMANENTFLAGS, - 'READ-WRITE' => TokenType::READ_WRITE, - 'READ-ONLY' => TokenType::READ_ONLY, - 'CAPABILITY' => TokenType::CAPABILITY, - 'LIST' => TokenType::LIST, - 'FLAGS' => TokenType::FLAGS, - 'RECENT' => TokenType::RECENT, - 'FETCH' => TokenType::FETCH, - 'INTERNALDATE' => TokenType::INTERNALDATE, - 'SEARCH' => TokenType::SEARCH, - 'EXISTS' => TokenType::EXISTS, - 'EXPUNGE' => TokenType::EXPUNGE, - 'BODY' => TokenType::BODY, - 'BODYSTRUCTURE' => TokenType::BODYSTRUCTURE, - 'ENVELOPE' => TokenType::ENVELOPE, - 'RFC822' => TokenType::RFC822, - 'RFC822.SIZE' => TokenType::RFC822_SIZE, - 'RFC822.TEXT' => TokenType::RFC822_TEXT, - 'RFC822.HEAD' => TokenType::RFC822_HEAD, - 'UID' => TokenType::UID, - default => match (true) { - is_numeric($value) => TokenType::NUMBER, - ctype_alnum($value) => TokenType::ALPHANUMERIC, - ctype_cntrl($value) => TokenType::CTL, - default => TokenType::UNKNOWN, - }, - }; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Parser/ParseError.php b/lib/Client/Protocol/Response/Parser/ParseError.php deleted file mode 100644 index fda30f3..0000000 --- a/lib/Client/Protocol/Response/Parser/ParseError.php +++ /dev/null @@ -1,24 +0,0 @@ - $type->name, $expected) - ), - $given?->name ?? 'null', - $input - ) - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/Parser/Parser.php b/lib/Client/Protocol/Response/Parser/Parser.php deleted file mode 100644 index f1b6cc9..0000000 --- a/lib/Client/Protocol/Response/Parser/Parser.php +++ /dev/null @@ -1,1221 +0,0 @@ - - */ - private array $literalStreams = []; - - /** Sequential index into $literalStreams consumed by literal(). */ - private int $nextLiteralIndex = 0; - - public function __construct() - { - $this->lexer = new Lexer(); - } - - /** - * @param list $literalStreams Pre-extracted large literal streams - * (see ResponseHandler::readNextRaw). - * @throws ParseError - */ - public function parse(string $raw, array $literalStreams = []): Line - { - $raw = $this->sanitizeInvalidEncoding($raw); - - $this->literalStreams = $literalStreams; - $this->nextLiteralIndex = 0; - - $this->lexer->setInput($raw); - $this->lexer->moveNext(); - - if ($this->lexer->isNextToken(TokenType::PLUS_SIGN)) { - return $this->commandContinuation(); - } - - $tag = $this->getToken(TokenType::ASTERISK, TokenType::NUMBER, TokenType::ALPHANUMERIC)->value; - $this->space(); - - if ($this->lexer->isNextToken(TokenType::NUMBER)) { - $value = $this->number(); - $this->space(); - - return match ($this->lexer->lookahead?->type) { - TokenType::EXISTS => $this->exists($value), - TokenType::EXPUNGE => $this->expunge($value), - TokenType::RECENT => $this->recent($value), - TokenType::FETCH => $this->fetch($value), - default => throw new ParseError() - }; - } - - return match ($this->lexer->lookahead?->type) { - TokenType::STATUS => $this->status($tag), - TokenType::CAPABILITY => $this->capability(), - TokenType::LIST => $this->list(), - TokenType::FLAGS => $this->flags(), - TokenType::SEARCH => $this->search(), - default => throw new ParseError() - }; - } - - /** - * @throws ParseError - */ - private function commandContinuation(): CommandContinuation - { - $this->getToken(TokenType::PLUS_SIGN); - $message = ''; - - if ($this->nextIsSpace()) { - $this->space(); - - $message = $this->getValueUntil(TokenType::CRLF); - } - - return new CommandContinuation($message); - } - - /** - * @throws ParseError - */ - private function status(string $tag): Status - { - $type = StatusType::from($this->getToken(TokenType::STATUS)->value); - - $code = null; - $message = ''; - - if ($this->nextIsSpace()) { - $this->space(); - - if ($this->lexer->isNextToken(TokenType::OPEN_BRACKETS)) { - $code = $this->statusCode(); - - if ($this->nextIsSpace()) { - $this->space(); - } - } - - $message = $this->getValueUntil(TokenType::CRLF); - } - - return new Status($tag, $type, $code, $message); - } - - /** - * @throws ParseError - */ - private function statusCode(): ?Code - { - $this->getToken(TokenType::OPEN_BRACKETS); - - switch ($this->lexer->lookahead?->type) { - case TokenType::APPENDUID: - $code = $this->appendUidStatusCode(); - break; - case TokenType::UNSEEN: - $code = $this->unseenStatusCode(); - break; - case TokenType::UIDVALIDITY: - $code = $this->uidValidityStatusCode(); - break; - case TokenType::UIDNEXT: - $code = $this->uidNextStatusCode(); - break; - case TokenType::PERMANENTFLAGS: - $code = $this->permanentFlagsStatusCode(); - break; - case TokenType::READ_WRITE: - $this->getToken(TokenType::READ_WRITE); - $code = new ReadWriteCode(); - break; - case TokenType::READ_ONLY: - $this->getToken(TokenType::READ_ONLY); - $code = new ReadOnlyCode(); - break; - default: - $this->getValueUntil(TokenType::CLOSE_BRACKETS); - $code = null; - } - - $this->getToken(TokenType::CLOSE_BRACKETS); - - return $code; - } - - /** - * @throws ParseError - */ - private function unseenStatusCode(): UnseenCode - { - $this->getToken(TokenType::UNSEEN); - $this->space(); - $seq = $this->number(); - - return new UnseenCode($seq); - } - - /** - * @throws ParseError - */ - private function uidValidityStatusCode(): UidValidityCode - { - $this->getToken(TokenType::UIDVALIDITY); - $this->space(); - $value = $this->number(); - - return new UidValidityCode($value); - } - - /** - * @throws ParseError - */ - private function uidNextStatusCode(): UidNextCode - { - $this->getToken(TokenType::UIDNEXT); - $this->space(); - $value = $this->number(); - - return new UidNextCode($value); - } - - /** - * @throws ParseError - */ - private function permanentFlagsStatusCode(): PermanentFlagsCode - { - $this->getToken(TokenType::PERMANENTFLAGS); - $this->space(); - $this->getToken(TokenType::OPEN_PARENTHESIS); - - $flags = []; - $isFirst = true; - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - if (!$isFirst) { - $this->space(); - } - $flags[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); - $isFirst = false; - } - - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return new PermanentFlagsCode($flags); - } - - /** - * @throws ParseError - */ - private function appendUidStatusCode(): AppendUidCode - { - $this->getToken(TokenType::APPENDUID); - $this->space(); - $uidValidity = $this->number(); - $this->space(); - $uid = (int) $this->getToken(TokenType::NUMBER)->value; - - return new AppendUidCode($uidValidity, $uid); - } - - /** - * @throws ParseError - */ - private function capability(): CapabilityData - { - $this->getToken(TokenType::CAPABILITY); - $capabilities = []; - - while ($this->nextIsSpace()) { - $this->space(); - $capabilities[] = $this->atom(); - } - - return new CapabilityData($capabilities); - } - - /** - * @throws ParseError - */ - private function list(): ListData - { - $this->getToken(TokenType::LIST); - $this->space(); - - $this->getToken(TokenType::OPEN_PARENTHESIS); - $attributes = []; - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - $attributes[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); - - if ($this->nextIsSpace()) { - $this->space(); - } - } - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - $this->space(); - $hierarchy = $this->string(); - $this->space(); - $name = $this->astring(); - - return new ListData($attributes, $hierarchy, $name); - } - - /** - * @throws ParseError - */ - private function flags(): FlagsData - { - return new FlagsData($this->flagList()); - } - - /** - * @throws ParseError - */ - private function search(): SearchData - { - $this->getToken(TokenType::SEARCH); - - $numbers = []; - while (!$this->lexer->isNextToken(TokenType::CRLF)) { - if ($this->nextIsSpace()) { - $this->space(); - } - - $numbers[] = $this->number(); - } - - return new SearchData($numbers); - } - - /** - * @throws ParseError - */ - private function fetch(int $id): FetchData - { - $this->getToken(TokenType::FETCH); - $this->space(); - $this->getToken(TokenType::OPEN_PARENTHESIS); - $flags = null; - $internalDate = null; - $envelope = null; - $rfc822 = null; - $rfc822Size = null; - $uid = null; - $bodyStructure = null; - $bodySections = []; - - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - switch ($this->lexer->lookahead?->type) { - case TokenType::FLAGS: - $flags = $this->flagList(); - break; - case TokenType::INTERNALDATE: - $this->getToken(TokenType::INTERNALDATE); - $this->space(); - $internalDate = $this->dateTime(); - break; - case TokenType::UID: - $this->getToken(TokenType::UID); - $this->space(); - $uid = $this->number(); - break; - case TokenType::RFC822_SIZE: - $this->getToken(TokenType::RFC822_SIZE); - $this->space(); - $rfc822Size = $this->number(); - break; - case TokenType::BODY: - $this->getToken(TokenType::BODY); - if ($this->lexer->isNextToken(TokenType::OPEN_BRACKETS)) { - $this->getToken(TokenType::OPEN_BRACKETS); - $section = $this->getValueUntil(TokenType::CLOSE_BRACKETS); - $this->getToken(TokenType::CLOSE_BRACKETS); - $this->space(); - $text = $this->literal(); - - $bodySections = $this->fetchBody($bodyStructure, $text); - } - break; - case TokenType::ENVELOPE: - $this->getToken(TokenType::ENVELOPE); - $this->space(); - $envelope = $this->envelope(); - break; - case TokenType::BODYSTRUCTURE: - $this->getToken(TokenType::BODYSTRUCTURE); - $this->space(); - $bodyStructure = $this->bodyStructure(); - break; - default: - $this->getToken(); - } - } - - return new FetchData( - $id, - $flags, - $internalDate, - $envelope, - $rfc822Size, - $rfc822, - $uid, - $bodyStructure, - bodySections: $bodySections, - ); - } - - /** - * @return BodySection[] - */ - private function fetchBody(?BodyStructure $node, string $data): array { - return $this->fetchBodyNode($node->part, $data); - } - - /** - * @return BodySection[] - */ - private function fetchBodyNode(?Part $node, string $data, string $partId = ''): array { - if ($node instanceof MultiPart) { - return $this->fetchBodyMultipart($node, $data, $partId); - } - - if ($node instanceof SinglePart) { - return [$this->fetchBodySinglePart($data, $partId)]; - } - - return []; - } - - /** - * @return BodySection - */ - private function fetchBodySinglePart(string $data, string $partId = ''): BodySection - { - $partId = empty($partId) ? '1' : $partId; - return new BodySection($partId, $data); - } - - /** - * @return BodySection[] - */ - private function fetchBodyMultipart(MultiPart $structure, string $data, string $partId = ''): array - { - $boundary = null; - foreach ($structure->attributes as $key => $value) { - if (strtolower($key) === 'boundary') { - $boundary = $value; - break; - } - } - - if ($boundary === null) { - throw new \RuntimeException('Multipart missing boundary attribute'); - } - - $chunks = $this->splitOnBoundary($data, $boundary); - - $parts = []; - foreach ($structure->parts as $i => $childStructure) { - $chunk = $chunks[$i] ?? ''; - $chunk = $this->stripPartHeaders($chunk); - $id = empty($partId) ? (string)($i + 1) : $partId . '.' . ($i + 1); - $parts = array_merge($parts, $this->fetchBodyNode($childStructure, $chunk, $id)); - } - - return $parts; - } - - /** - * Split $raw on MIME boundary delimiter lines, returning one string per - * body part. The preamble (before the first delimiter) and epilogue - * (after the close delimiter) are discarded. - * - * @return string[] - */ - private function splitOnBoundary(string $raw, string $boundary): array - { - $delimiter = '--' . $boundary; - $closeDelimiter = '--' . $boundary . '--'; - - $parts = []; - $current = null; - - // Handle both CRLF and bare-LF line endings - $lines = preg_split('/\r?\n/', $raw); - - foreach ($lines as $line) { - $trimmed = rtrim($line); - - if ($trimmed === $closeDelimiter) { - if ($current !== null) { - $parts[] = rtrim($current, "\r\n"); - } - break; - } - - if ($trimmed === $delimiter) { - if ($current !== null) { - $parts[] = rtrim($current, "\r\n"); - } - $current = ''; - continue; - } - - if ($current !== null) { - $current .= $line . "\r\n"; - } - // Lines before the first delimiter are preamble — ignored - } - - // If the close delimiter was absent, flush whatever is buffered - if ($current !== null && $current !== '') { - $trimmed = rtrim($current, "\r\n"); - if (!in_array($trimmed, $parts, true)) { - $parts[] = $trimmed; - } - } - - return $parts; - } - - /** - * Strip MIME part headers from a body chunk. - * - * Each part chunk begins with its own headers (Content-Type, - * Content-Transfer-Encoding, etc.) followed by a blank line. - * Since BODYSTRUCTURE already supplies all encoding/charset info, - * we discard the part headers and return the raw body bytes only. - */ - private function stripPartHeaders(string $raw): string - { - // Try CRLF blank line first, then bare LF - $crlfPos = strpos($raw, "\r\n\r\n"); - $lfPos = strpos($raw, "\n\n"); - - if ($crlfPos !== false && ($lfPos === false || $crlfPos <= $lfPos)) { - return substr($raw, $crlfPos + 4); - } - - if ($lfPos !== false) { - return substr($raw, $lfPos + 2); - } - - return $raw; - } - - /** - * @throws ParseError - */ - private function envelope(): Envelope - { - $this->getToken(TokenType::OPEN_PARENTHESIS); - $date = $this->envelopeDate(); - $this->space(); - $subject = match($this->lexer->lookahead?->type) { - TokenType::OPEN_BRACES => $this->literal(), - default => $this->nstring(), - }; - $this->space(); - $from = $this->nullableAddressList(); - $this->space(); - $sender = $this->nullableAddressList(); - $this->space(); - $replyTo = $this->nullableAddressList(); - $this->space(); - $to = $this->nullableAddressList(); - $this->space(); - $cc = $this->nullableAddressList(); - $this->space(); - $bcc = $this->nullableAddressList(); - $this->space(); - $inReplyTo = $this->nstring(); - $this->space(); - $messageId = $this->nstring(); - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return new Envelope( - $date, - $subject, - $from, - $sender, - $replyTo, - $to, - $cc, - $bcc, - $inReplyTo, - $messageId, - ); - } - - /** - * @throws ParseError - */ - private function envelopeDate(): ?DateTimeImmutable - { - $value = $this->nstring(); - - if (null === $value) { - return null; - } - - try { - $date = new DateTimeImmutable($value); - } catch (\Exception) { - $date = null; - } - - return $date ?: throw new ParseError('Unable to parse envelope date'); - } - - /** - * @return Address[]|null - * @throws ParseError - */ - private function nullableAddressList(): ?array - { - if ($this->lexer->isNextToken(TokenType::NIL)) { - return $this->nil(); - } - - $this->getToken(TokenType::OPEN_PARENTHESIS); - $addresses = []; - while ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { - $addresses[] = $this->address(); - } - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return $addresses; - } - - /** - * @throws ParseError - */ - private function address(): Address - { - $this->getToken(TokenType::OPEN_PARENTHESIS); - $displayName = $this->nstring(); - $this->space(); - $atDomainList = $this->nstring(); - $this->space(); - $mailboxName = $this->nstring(); - $this->space(); - $hostname = $this->nstring(); - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return new Address( - $displayName, - $atDomainList, - $mailboxName, - $hostname, - ); - } - - /** - * @throws ParseError - */ - public function bodyStructure(): BodyStructure - { - $part = $this->part(); - - return new BodyStructure($part); - } - - /** - * @throws ParseError - */ - private function part(): BodyStructure\Part - { - return $this->lexer->glimpse()?->isA(TokenType::OPEN_PARENTHESIS) - ? $this->multipart() - : $this->simplePart(); - } - - /** - * @throws ParseError - */ - private function multipart(): BodyStructure\MultiPart - { - $parts = []; - $disposition = null; - $language = null; - $location = null; - - $this->getToken(TokenType::OPEN_PARENTHESIS); - - while ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { - $parts[] = $this->part(); - } - - $this->space(); - $subtype = $this->string(); - - if ($this->nextIsSpace()) { - $this->space(); - $attributes = $this->attributeValuePairs(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $disposition = $this->disposition(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $language = $this->bodyLanguage(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $location = $this->nstring(); - } - - $this->getValueUntil(TokenType::CLOSE_PARENTHESIS); - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return new BodyStructure\MultiPart( - $subtype, - $attributes ?? [], - $parts, - $disposition, - $language, - $location - ); - } - - /** - * @throws ParseError - */ - private function simplePart(): BodyStructure\SinglePart - { - $this->getToken(TokenType::OPEN_PARENTHESIS); - $type = $this->quoted(); - $normalizedType = strtoupper($type); - $this->space(); - $subtype = $this->quoted(); - $normalizedSubtype = strtoupper($subtype); - $this->space(); - $attributes = $this->attributeValuePairs(); - $this->space(); - $id = $this->nstring(); - $this->space(); - $description = $this->nstring(); - $this->space(); - $encoding = $this->string(); - $this->space(); - $size = $this->number(); - - $textLines = 0; - $md5 = null; - $disposition = null; - $language = null; - $location = null; - - $isTextPart = $normalizedType === 'TEXT'; - $isMessagePart = $normalizedType === 'MESSAGE' && $normalizedSubtype === 'RFC822'; - - if ($isTextPart) { - $this->space(); - $textLines = $this->number(); - } - - if ($isMessagePart) { - $this->space(); - $envelope = $this->envelope(); - $this->space(); - $bodyStructure = $this->bodyStructure(); - $this->space(); - $textLines = $this->number(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $md5 = $this->nstring(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $disposition = $this->disposition(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $language = $this->bodyLanguage(); - } - - if ($this->nextIsSpace()) { - $this->space(); - $location = $this->nstring(); - } - - $this->getValueUntil(TokenType::CLOSE_PARENTHESIS); - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - if ($isTextPart) { - return new BodyStructure\TextPart( - $subtype, - $attributes, - $id, - $description, - $encoding, - $size, - $textLines, - $md5, - $disposition, - $language, - $location, - ); - } - - if ($isMessagePart) { - return new BodyStructure\MessagePart( - $attributes, - $id, - $description, - $encoding, - $size, - $envelope, - $bodyStructure, - $textLines, - $md5, - $disposition, - $language, - $location, - ); - } - - return new BodyStructure\SinglePart( - $type, - $subtype, - $attributes, - $id, - $description, - $encoding, - $size, - $md5, - $disposition, - $language, - $location, - ); - } - - /** - * @return string[]|null - * @throws ParseError - */ - private function bodyLanguage(): ?array - { - if ($this->lexer->isNextToken(TokenType::OPEN_PARENTHESIS)) { - $this->getToken(TokenType::OPEN_PARENTHESIS); - $lang = []; - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - $lang[] = $this->string(); - - if ($this->nextIsSpace()) { - $this->space(); - } - } - - $this->getToken(TokenType::CLOSE_PARENTHESIS); - return $lang; - } - - $lang = $this->nstring(); - - return $lang ? [$lang] : null; - } - - private function disposition(): ?BodyStructure\Disposition - { - if ($this->lexer->isNextToken(TokenType::NIL)) { - return $this->nil(); - } - - $this->getToken(TokenType::OPEN_PARENTHESIS); - $type = $this->string(); - $this->space(); - $attributes = $this->lexer->isNextToken(TokenType::NIL) - ? $this->nil() - : $this->attributeValuePairs(); - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return new BodyStructure\Disposition( - $type, - $attributes ?? [] - ); - } - - /** - * @return array - * @throws ParseError - */ - private function attributeValuePairs(): array - { - $values = []; - if ($this->lexer->isNextToken(TokenType::NIL)) { - $this->nil(); - return $values; - } - - $this->getToken(TokenType::OPEN_PARENTHESIS); - - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - if ($this->nextIsSpace()) { - $this->space(); - } - - $attribute = $this->quoted(); - $this->space(); - $value = $this->quoted(); - - $values[$attribute] = $value; - } - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return $values; - } - - /** - * @throws ParseError - */ - private function exists(int $numberOfMessages): ExistsData - { - $this->getToken(TokenType::EXISTS); - - return new ExistsData($numberOfMessages); - } - - /** - * @throws ParseError - */ - private function expunge(int $id): ExpungeData - { - $this->getToken(TokenType::EXPUNGE); - - return new ExpungeData($id); - } - - /** - * @throws ParseError - */ - private function recent(int $numberOfMessages): RecentData - { - $this->getToken(TokenType::RECENT); - - return new RecentData($numberOfMessages); - } - - /** - * @return string[] - * @throws ParseError - */ - private function flagList(): array - { - $flags = []; - $this->getToken(TokenType::FLAGS); - $this->space(); - $this->getToken(TokenType::OPEN_PARENTHESIS); - $isFirstFlag = true; - - while (!$this->lexer->isNextToken(TokenType::CLOSE_PARENTHESIS)) { - if (!$isFirstFlag) { - $this->space(); - } - - $flags[] = $this->getValueUntil(TokenType::SP, TokenType::CLOSE_PARENTHESIS); - - $isFirstFlag = false; - } - - $this->getToken(TokenType::CLOSE_PARENTHESIS); - - return $flags; - } - - /** - * @throws ParseError - */ - private function dateTime(): DateTimeImmutable - { - $this->getToken(TokenType::DOUBLE_QUOTE); - $value = $this->getValueUntil(TokenType::DOUBLE_QUOTE); - $this->getToken(TokenType::DOUBLE_QUOTE); - - return DateTimeImmutable::createFromFormat('d-M-Y H:i:s O', $value) - ?: throw new ParseError(sprintf('Invalid date time "%s"', $value)); - } - - /** - * @throws ParseError - */ - private function number(): int - { - return (int) $this->getToken(TokenType::NUMBER)->value; - } - - /** - * @throws ParseError - */ - private function astring(): string - { - if ($this->lexer->isNextToken(TokenType::OPEN_BRACES)) { - return $this->literal(); - } - - return $this->string(); - } - - /** - * @throws ParseError - */ - private function nstring(): ?string - { - if ($this->lexer->isNextToken(TokenType::NIL)) { - return $this->nil(); - } - - return $this->string(); - } - - /** - * @throws ParseError - */ - public function atom(): string - { - return $this->getValueUntil( - TokenType::OPEN_PARENTHESIS, - TokenType::CLOSE_PARENTHESIS, - TokenType::OPEN_BRACES, - TokenType::CTL, - TokenType::SP, - TokenType::CRLF, - TokenType::DOUBLE_QUOTE, - TokenType::BACKSLASH, - TokenType::ASTERISK, - TokenType::PERCENT_SIGN, - ); - } - - /** - * @throws ParseError - */ - private function string(): string - { - return match($this->lexer->lookahead?->type) { - TokenType::DOUBLE_QUOTE => $this->quoted(), - default => $this->atom() - }; - } - - /** - * @throws ParseError - */ - private function quoted(): string - { - $this->getToken(TokenType::DOUBLE_QUOTE); - $value = ''; - - while (!$this->lexer->isNextToken(TokenType::DOUBLE_QUOTE)) { - if ($this->lexer->isNextToken(TokenType::BACKSLASH)) { - $value .= $this->quotedSpecials(); - } else { - $value .= $this->getToken()->value; - } - } - - $this->getToken(TokenType::DOUBLE_QUOTE); - - return $value; - } - - /** - * @throws ParseError - */ - private function quotedSpecials(): string - { - $this->getToken(TokenType::BACKSLASH); - - if ($this->lexer->isNextToken(TokenType::DOUBLE_QUOTE)) { - $this->getToken(TokenType::DOUBLE_QUOTE); - return "\""; - } - - return "\\"; - } - - /** - * @throws ParseError - */ - private function literal(): string - { - $this->getToken(TokenType::OPEN_BRACES); - $size = (int) $this->getToken(TokenType::NUMBER)->value; - $this->getToken(TokenType::CLOSE_BRACES); - $this->getToken(TokenType::CRLF); - - // If ResponseHandler preloaded this literal (because it was too large - // to tokenise safely), consume from the php://temp resource instead - // of reading token-by-token through the lexer. - if (isset($this->literalStreams[$this->nextLiteralIndex])) { - $resource = $this->literalStreams[$this->nextLiteralIndex++]; - rewind($resource); - return (string) stream_get_contents($resource); - } - - $value = ''; - while (strlen($value) < $size) { - $value .= $this->getToken()->value; - } - - return $value; - } - - /** - * @throws ParseError - */ - private function nil(): null - { - $this->getToken(TokenType::NIL); - - return null; - } - - /** - * @return Token - * @throws ParseError - */ - private function getToken(TokenType ...$expected): Token - { - if (!empty($expected) && !in_array($this->lexer->lookahead?->type, $expected)) { - $position = $this->lexer->lookahead?->position; - - throw ParseError::unexpectedToken( - $this->lexer->lookahead?->type, - $expected, - $position ? $this->lexer->getInputUntilPosition($position) : '' - ); - } - - $this->lexer->moveNext(); - - return $this->lexer->token ?? throw new ParseError(); - } - - private function nextIsSpace(): bool - { - return $this->lexer->lookahead?->isA(TokenType::SP) ?? false; - } - - /** - * @throws ParseError - */ - private function space(): void - { - $this->getToken(TokenType::SP); - } - - /** - * @throws ParseError - */ - private function getValueUntil(TokenType ...$types): string - { - $value = ''; - - while (!in_array($this->lexer->lookahead?->type, $types)) { - $value .= $this->getToken()->value; - } - - return $value; - } - - private function sanitizeInvalidEncoding(string $raw): string - { - if (mb_check_encoding($raw, 'US-ASCII')) { - return $raw; - } - - $result = ''; - $pos = 0; - $len = strlen($raw); - - while ($pos < $len) { - if (preg_match('/\{(\d+)\}\r\n/', $raw, $m, PREG_OFFSET_CAPTURE, $pos)) { - $braceOff = (int) $m[0][1]; - $literalLen = (int) $m[1][0]; - $headerLen = strlen($m[0][0]); - - // Sanitize structural text that precedes this literal - $result .= $this->sanitizeChunk(substr($raw, $pos, $braceOff - $pos)); - - // Preserve the {N}\r\n marker verbatim - $result .= $m[0][0]; - - // Preserve the literal body bytes verbatim (may be UTF-8 / 8-bit) - $result .= substr($raw, $braceOff + $headerLen, $literalLen); - - $pos = $braceOff + $headerLen + $literalLen; - } else { - // No more literals — sanitize the remainder - $result .= $this->sanitizeChunk(substr($raw, $pos)); - break; - } - } - - return $result; - } - - private function sanitizeChunk(string $chunk): string - { - if (mb_check_encoding($chunk, 'US-ASCII')) { - return $chunk; - } - - for ($i = 0, $len = strlen($chunk); $i < $len; $i++) { - if (!mb_check_encoding($chunk[$i], 'US-ASCII')) { - $chunk[$i] = ' '; - } - } - - return $chunk; - } -} diff --git a/lib/Client/Protocol/Response/Parser/TokenType.php b/lib/Client/Protocol/Response/Parser/TokenType.php deleted file mode 100644 index 6622363..0000000 --- a/lib/Client/Protocol/Response/Parser/TokenType.php +++ /dev/null @@ -1,56 +0,0 @@ - $data - */ - public function __construct( - public Status $status, - public array $data, - ) { - } - - /** - * @template T of Line - * @param class-string $type - * @return T[] - */ - public function getData(string $type): array - { - $result = []; - foreach ($this->data as $data) { - if ($data instanceof $type) { - $result[] = $data; - } - } - - return $result; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/ResponseBuilder.php b/lib/Client/Protocol/Response/ResponseBuilder.php deleted file mode 100644 index 700e8cf..0000000 --- a/lib/Client/Protocol/Response/ResponseBuilder.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - private array $data = []; - - public function __construct(private readonly string $statusTag) - { - } - - public function addLine(Line $line): void - { - if ($line instanceof Status && $line->tag === $this->statusTag) { - $this->status = $line; - return; - } - - $this->data[] = $line; - } - - public function hasStatus(): bool - { - return $this->status !== null; - } - - public function build(): Response - { - if (null === $this->status) { - throw new BadMethodCallException(); - } - - return new Response( - $this->status, - $this->data, - ); - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/ResponseInterface.php b/lib/Client/Protocol/Response/ResponseInterface.php new file mode 100644 index 0000000..37f2ca5 --- /dev/null +++ b/lib/Client/Protocol/Response/ResponseInterface.php @@ -0,0 +1,10 @@ +tag; + } + + public function status(): string + { + return $this->status; + } + + public function text(): string + { + return $this->text; + } + + public function isOk(): bool + { + return $this->status === 'OK'; + } + + public function raw(): string + { + return $this->raw; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/Response/UntaggedResponse.php b/lib/Client/Protocol/Response/UntaggedResponse.php new file mode 100644 index 0000000..8b7cf17 --- /dev/null +++ b/lib/Client/Protocol/Response/UntaggedResponse.php @@ -0,0 +1,43 @@ +label; + } + + public function payload(): string + { + return $this->payload; + } + + /** + * @return list + */ + public function payloadTokens(): array + { + $payload = trim($this->payload); + + if ($payload === '') { + return []; + } + + return preg_split('/\s+/', $payload) ?: []; + } + + public function raw(): string + { + return $this->raw; + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/ResponseHandler.php b/lib/Client/Protocol/ResponseHandler.php deleted file mode 100644 index 2a6aecf..0000000 --- a/lib/Client/Protocol/ResponseHandler.php +++ /dev/null @@ -1,130 +0,0 @@ -= LARGE_LITERAL_THRESHOLD bytes) are read in 8 KB - * chunks into php://temp resources instead of being appended to $raw, - * so the body content never reaches the lexer as a plain string. - * - * @return array{string, list} [$raw, $preloadedLiterals] - */ - private function readNextRaw(ResponseStream $stream): array - { - $raw = $stream->readLine(); - $preloaded = []; - - while (preg_match('/\{(?\d+)}\r\n$/', $raw, $matches)) { - $literalSize = (int) $matches['bytes']; - - if ($literalSize >= self::LARGE_LITERAL_THRESHOLD) { - // Stream into a temp file to avoid holding a huge string in - // memory. php://temp uses RAM up to 2 MB then spills to disk. - $tmp = fopen('php://temp', 'r+'); - $remaining = $literalSize; - while ($remaining > 0) { - $chunk = $stream->read(min(8192, $remaining)); - fwrite($tmp, $chunk); - $remaining -= strlen($chunk); - } - rewind($tmp); - $preloaded[] = $tmp; - // Keep the {N}\r\n header in $raw so the parser can read the - // literal size, but do NOT append the N bytes — the parser - // will pull them from the preloaded resource instead. - } else { - $raw .= $stream->read($literalSize); - } - - $raw .= $stream->readLine(); - } - - return [$raw, $preloaded]; - } - - public function handle(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Response - { - $responseBuilder = new ResponseBuilder($statusTag); - - do { - [$raw, $preloaded] = $this->readNextRaw($stream); - $line = $this->parser->parse($raw, $preloaded); - - if ($line instanceof CommandContinuation) { - $continuationHandler->continue(); - continue; - } - - $responseBuilder->addLine($line); - } while (!$responseBuilder->hasStatus()); - - return $responseBuilder->build(); - } - - /** - * Streams parsed response lines one at a time as a Generator, yielding each - * untagged Line immediately as it arrives from the socket. The terminal - * Status line is NOT yielded; instead it is set as the generator return - * value so callers can retrieve it via $gen->getReturn() after exhaustion. - * - * @throws CommandFailed if the tagged status is NO or BAD - * - * @return Generator - */ - public function stream(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Generator - { - $status = null; - - do { - [$raw, $preloaded] = $this->readNextRaw($stream); - $line = $this->parser->parse($raw, $preloaded); - - if ($line instanceof CommandContinuation) { - $continuationHandler->continue(); - continue; - } - - if ($line instanceof Status && $line->tag === $statusTag) { - $status = $line; - break; - } - - yield $line; - } while (true); - - if ($status->type !== StatusType::OK) { - throw CommandFailed::withStatus($status); - } - - return $status; - } -} \ No newline at end of file diff --git a/lib/Client/Protocol/ResponseStream.php b/lib/Client/Protocol/ResponseStream.php new file mode 100644 index 0000000..8ef96d4 --- /dev/null +++ b/lib/Client/Protocol/ResponseStream.php @@ -0,0 +1,28 @@ +generatorFactory = $generatorFactory; + } + + public function getIterator(): Traversable + { + return ($this->generatorFactory)(); + } +} \ No newline at end of file diff --git a/lib/Client/Protocol/TagGenerator.php b/lib/Client/Protocol/TagGenerator.php index c264a85..6e212a9 100644 --- a/lib/Client/Protocol/TagGenerator.php +++ b/lib/Client/Protocol/TagGenerator.php @@ -2,35 +2,14 @@ declare(strict_types=1); -namespace Gricob\IMAP\Protocol; +namespace KTXM\ProviderImap\Client\Protocol; final class TagGenerator { - private const MAX_NUMBER = 999; - private const NUMBER_PART_LENGTH = 3; - private const INITIAL_LETTER = 'A'; - private const INITIAL_NUMBER = 0; - - private string $letter = self::INITIAL_LETTER; - private int $number = self::INITIAL_NUMBER; + private int $counter = 1; public function next(): string { - $this->number += 1; - - if ($this->number > self::MAX_NUMBER) { - $this->letter++; - $this->number = self::INITIAL_NUMBER; - } - - if (strlen($this->letter) > 1) { - $this->letter = self::INITIAL_LETTER; - } - - return sprintf( - '%s%s', - $this->letter, - str_pad((string) $this->number, self::NUMBER_PART_LENGTH, '0', STR_PAD_LEFT) - ); + return sprintf('A%04d', $this->counter++); } } \ No newline at end of file diff --git a/lib/Client/Protocol/UnexpectedContinuationHandler.php b/lib/Client/Protocol/UnexpectedContinuationHandler.php deleted file mode 100644 index 6341c69..0000000 --- a/lib/Client/Protocol/UnexpectedContinuationHandler.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - private array $criteria; - - private bool $not = false; - - public function __construct(private readonly Client $client) - { - } - - public function header(string $fieldName, string $value = ''): self - { - $this->addCriteria(new Header($fieldName, $value)); - - return $this; - } - - public function before(DateTimeInterface $date): self - { - $this->addCriteria(new Before($date)); - - return $this; - } - - public function since(DateTimeInterface $date): self - { - $this->addCriteria(new Since($date)); - - return $this; - } - - public function not(): self - { - $this->not = true; - - return $this; - } - - /** - * @return array - */ - public function get(?PreFetchOptions $preFetchOptions = null): array - { - if ($this->not) { - throw new BadMethodCallException('Not key requires to specify a search key to be applied'); - } - - $criteria = empty($this->criteria) - ? [new All()] - : $this->criteria; - - return $this->client->doSearch($criteria, $preFetchOptions); - } - - private function addCriteria(Criteria $criteria): void - { - if ($this->not) { - $criteria = new Not($criteria); - $this->not = false; - } - - $this->criteria[] = $criteria; - } -} \ No newline at end of file diff --git a/lib/Client/SearchCriteriaBuilder.php b/lib/Client/SearchCriteriaBuilder.php new file mode 100644 index 0000000..6aa912d --- /dev/null +++ b/lib/Client/SearchCriteriaBuilder.php @@ -0,0 +1,377 @@ + + */ + private array $criteria = []; + + public static function create(): self + { + return new self(); + } + + public function all(): self + { + return $this->pushExpression('ALL'); + } + + public function answered(): self + { + return $this->pushExpression('ANSWERED'); + } + + public function unanswered(): self + { + return $this->pushExpression('UNANSWERED'); + } + + public function deleted(): self + { + return $this->pushExpression('DELETED'); + } + + public function undeleted(): self + { + return $this->pushExpression('UNDELETED'); + } + + public function draft(): self + { + return $this->pushExpression('DRAFT'); + } + + public function undraft(): self + { + return $this->pushExpression('UNDRAFT'); + } + + public function flagged(): self + { + return $this->pushExpression('FLAGGED'); + } + + public function unflagged(): self + { + return $this->pushExpression('UNFLAGGED'); + } + + public function seen(): self + { + return $this->pushExpression('SEEN'); + } + + public function unseen(): self + { + return $this->pushExpression('UNSEEN'); + } + + public function recent(): self + { + return $this->pushExpression('RECENT'); + } + + public function old(): self + { + return $this->pushExpression('OLD'); + } + + public function new(): self + { + return $this->pushExpression('NEW'); + } + + public function from(string $value): self + { + return $this->pushKeyValue('FROM', $value); + } + + public function to(string $value): self + { + return $this->pushKeyValue('TO', $value); + } + + public function cc(string $value): self + { + return $this->pushKeyValue('CC', $value); + } + + public function bcc(string $value): self + { + return $this->pushKeyValue('BCC', $value); + } + + public function subject(string $value): self + { + return $this->pushKeyValue('SUBJECT', $value); + } + + public function body(string $value): self + { + return $this->pushKeyValue('BODY', $value); + } + + public function text(string $value): self + { + return $this->pushKeyValue('TEXT', $value); + } + + public function keyword(string $value): self + { + return $this->pushKeyValue('KEYWORD', $value, false); + } + + public function unkeyword(string $value): self + { + return $this->pushKeyValue('UNKEYWORD', $value, false); + } + + public function before(DateTimeInterface|string $value): self + { + return $this->pushDate('BEFORE', $value); + } + + public function on(DateTimeInterface|string $value): self + { + return $this->pushDate('ON', $value); + } + + public function since(DateTimeInterface|string $value): self + { + return $this->pushDate('SINCE', $value); + } + + public function sentBefore(DateTimeInterface|string $value): self + { + return $this->pushDate('SENTBEFORE', $value); + } + + public function sentOn(DateTimeInterface|string $value): self + { + return $this->pushDate('SENTON', $value); + } + + public function sentSince(DateTimeInterface|string $value): self + { + return $this->pushDate('SENTSINCE', $value); + } + + public function larger(int $value): self + { + return $this->pushNumber('LARGER', $value); + } + + public function smaller(int $value): self + { + return $this->pushNumber('SMALLER', $value); + } + + public function uid(int|string|SequenceSet $value): self + { + return $this->pushExpression('UID ' . $this->formatSequenceSet($value)); + } + + public function sequence(int|string|SequenceSet $value): self + { + return $this->pushExpression($this->formatSequenceSet($value)); + } + + public function header(string $name, string $value): self + { + return $this->pushExpression(sprintf( + 'HEADER %s %s', + $this->formatString($name), + $this->formatString($value), + )); + } + + public function group(SearchCriteriaBuilder|array|string $criteria): self + { + $expression = $this->normalizeOperand($criteria, true); + if ($expression === '') { + return $this; + } + + return $this->pushExpression($expression); + } + + public function not(SearchCriteriaBuilder|array|string $criteria): self + { + $expression = $this->normalizeOperand($criteria, true); + if ($expression === '') { + throw new InvalidArgumentException('NOT search criteria cannot be empty.'); + } + + return $this->pushExpression('NOT ' . $expression); + } + + public function or(SearchCriteriaBuilder|array|string $left, SearchCriteriaBuilder|array|string $right): self + { + $leftExpression = $this->normalizeOperand($left, true); + $rightExpression = $this->normalizeOperand($right, true); + + if ($leftExpression === '' || $rightExpression === '') { + throw new InvalidArgumentException('OR search criteria requires two non-empty operands.'); + } + + return $this->pushExpression(sprintf('OR %s %s', $leftExpression, $rightExpression)); + } + + public function raw(string ...$criteria): self + { + $normalized = []; + + foreach ($criteria as $criterion) { + $criterion = trim($criterion); + if ($criterion === '') { + continue; + } + + $normalized[] = $criterion; + } + + if ($normalized === []) { + return $this; + } + + foreach ($normalized as $expression) { + $this->pushExpression($expression); + } + + return $this; + } + + /** + * @return list + */ + public function build(): array + { + return $this->criteria === [] ? ['ALL'] : $this->criteria; + } + + /** + * @return list + */ + public function toArray(): array + { + return $this->build(); + } + + private function pushExpression(string $expression): self + { + $expression = trim($expression); + if ($expression !== '') { + $this->criteria[] = $expression; + } + + return $this; + } + + private function pushKeyValue(string $key, string $value, bool $quote = true): self + { + $value = trim($value); + if ($value === '') { + throw new InvalidArgumentException(sprintf('%s search criteria cannot be empty.', $key)); + } + + return $this->pushExpression(sprintf( + '%s %s', + $key, + $quote ? $this->formatString($value) : $value, + )); + } + + private function pushDate(string $key, DateTimeInterface|string $value): self + { + return $this->pushExpression(sprintf('%s %s', $key, $this->formatDate($value))); + } + + private function pushNumber(string $key, int $value): self + { + if ($value < 0) { + throw new InvalidArgumentException(sprintf('%s search criteria must not be negative.', $key)); + } + + return $this->pushExpression(sprintf('%s %d', $key, $value)); + } + + private function normalizeOperand(SearchCriteriaBuilder|array|string $criteria, bool $wrapComposite): string + { + if ($criteria instanceof self) { + $expressions = $criteria->toArray(); + return $this->combineExpressions($expressions, $wrapComposite); + } + + if (is_string($criteria)) { + $criteria = trim($criteria); + return $criteria; + } + + $normalized = []; + + foreach ($criteria as $criterion) { + $criterion = trim((string) $criterion); + if ($criterion === '') { + continue; + } + + $normalized[] = $criterion; + } + + return $this->combineExpressions($normalized, $wrapComposite); + } + + /** + * @param list $expressions + */ + private function combineExpressions(array $expressions, bool $wrapComposite): string + { + $expression = implode(' ', $expressions); + + if ($expression === '') { + return ''; + } + + if (!$wrapComposite || count($expressions) === 1) { + return $expression; + } + + return '(' . $expression . ')'; + } + + private function formatDate(DateTimeInterface|string $value): string + { + if ($value instanceof DateTimeInterface) { + return $value->format('d-M-Y'); + } + + $value = trim($value); + if ($value === '') { + throw new InvalidArgumentException('Search dates cannot be empty.'); + } + + return $value; + } + + private function formatSequenceSet(int|string|SequenceSet $value): string + { + return match (true) { + $value instanceof SequenceSet => $value->toCommand(), + is_int($value) => SequenceSet::single($value)->toCommand(), + default => SequenceSet::parse($value)->toCommand(), + }; + } + + private function formatString(string $value): string + { + return '"' . addcslashes($value, "\\\"") . '"'; + } +} \ No newline at end of file diff --git a/lib/Client/SequenceSet.php b/lib/Client/SequenceSet.php new file mode 100644 index 0000000..f345662 --- /dev/null +++ b/lib/Client/SequenceSet.php @@ -0,0 +1,104 @@ + self::normalizeIdentifier($identifier), + $identifiers, + ))); + } + + public static function parse(string $value): self + { + $value = trim($value); + + if ($value === '') { + throw new InvalidArgumentException('Sequence sets cannot be empty.'); + } + + $parts = preg_split('/\s*,\s*/', $value) ?: []; + if ($parts === []) { + throw new InvalidArgumentException('Sequence sets require at least one identifier.'); + } + + foreach ($parts as $part) { + if (!preg_match('/^(?:\*|[1-9]\d*)(?::(?:\*|[1-9]\d*))?$/', $part)) { + throw new InvalidArgumentException(sprintf('Invalid IMAP sequence-set segment: %s', $part)); + } + } + + return new self(implode(',', $parts)); + } + + public function toCommand(): string + { + return $this->value; + } + + public function __toString(): string + { + return $this->value; + } + + private static function normalizeIdentifier(int|string $identifier): string + { + if ($identifier === '*') { + return '*'; + } + + if (is_int($identifier)) { + if ($identifier < 1) { + throw new InvalidArgumentException('IMAP sequence identifiers must be greater than zero.'); + } + + return (string) $identifier; + } + + $identifier = trim($identifier); + if (preg_match('/^[1-9]\d*$/', $identifier) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid IMAP sequence identifier: %s', $identifier)); + } + + return $identifier; + } +} \ No newline at end of file diff --git a/lib/Client/SessionContext.php b/lib/Client/SessionContext.php new file mode 100644 index 0000000..043c50d --- /dev/null +++ b/lib/Client/SessionContext.php @@ -0,0 +1,89 @@ + */ + private array $capabilities = []; + private SessionState $state = SessionState::Disconnected; + private ?GreetingResponse $greeting = null; + private ?string $selectedMailbox = null; + + public function __construct( + private readonly ConnectionConfig $config, + private readonly ConnectionInterface $connection, + ) {} + + public function config(): ConnectionConfig + { + return $this->config; + } + + public function connection(): ConnectionInterface + { + return $this->connection; + } + + public function state(): SessionState + { + return $this->state; + } + + public function setState(SessionState $state): void + { + $this->state = $state; + } + + public function greeting(): ?GreetingResponse + { + return $this->greeting; + } + + public function setGreeting(GreetingResponse $greeting): void + { + $this->greeting = $greeting; + } + + public function selectedMailbox(): ?string + { + return $this->selectedMailbox; + } + + public function setSelectedMailbox(?string $selectedMailbox): void + { + $this->selectedMailbox = $selectedMailbox; + } + + /** + * @return list + */ + public function capabilities(): array + { + return array_keys($this->capabilities); + } + + public function replaceCapabilities(string ...$capabilities): void + { + $this->capabilities = []; + + foreach ($capabilities as $capability) { + $normalized = strtoupper(trim($capability)); + if ($normalized === '') { + continue; + } + + $this->capabilities[$normalized] = true; + } + } + + public function hasCapability(string $capability): bool + { + return isset($this->capabilities[strtoupper($capability)]); + } +} \ No newline at end of file diff --git a/lib/Client/SessionState.php b/lib/Client/SessionState.php new file mode 100644 index 0000000..4f14528 --- /dev/null +++ b/lib/Client/SessionState.php @@ -0,0 +1,14 @@ +port = $port; - $this->host = $host; - $this->transport = $transport; - $this->timeout = $timeout; - $this->verifyPeer = $verifyPeer; - $this->verifyPeerName = $verifyPeerName; - $this->allowSelfSigned = $allowSelfSigned; - } - - public function __destruct() - { - $this->close(); - } - - public function isOpen(): bool - { - return false !== $this->stream; - } - - public function open(): void - { - if ($this->isOpen()) { - return; - } - - $this->stream = @stream_socket_client( - sprintf('%s://%s:%s', $this->transport, $this->host, $this->port), - $errorCode, - $errorMessage, - $this->timeout, - context: stream_context_create([ - 'ssl' => [ - 'verify_peer' => $this->verifyPeer, - 'verify_peer_name' => $this->verifyPeerName, - 'allow_self_signed' => $this->allowSelfSigned, - ] - ]) - ); - - if (false === $this->stream) { - throw new ConnectionFailed( - sprintf('SocketConnection failed [%s]: %s', $errorCode, $errorMessage) - ); - } - } - - public function close(): void - { - if (!$this->stream) { - return; - } - - fclose($this->stream); - - $this->stream = false; - } - - /** - * Upgrade an open plain-TCP socket to TLS in-place (STARTTLS patch). - * - * Must be called after the server has responded OK to a STARTTLS command. - * - * @throws ConnectionFailed - */ - public function upgradeTls(): void - { - if (!$this->stream) { - throw new ConnectionFailed('Cannot upgrade TLS: connection is not open'); - } - - stream_context_set_option($this->stream, [ - 'ssl' => [ - 'peer_name' => $this->host, - 'verify_peer' => $this->verifyPeer, - 'verify_peer_name' => $this->verifyPeerName, - 'allow_self_signed' => $this->allowSelfSigned, - ], - ]); - - $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT - | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; - - $result = @stream_socket_enable_crypto( - $this->stream, - true, - $cryptoMethod, - ); - - if ($result !== true) { - $last = error_get_last(); - $detail = $last['message'] ?? 'unknown error'; - throw new ConnectionFailed('STARTTLS upgrade failed: ' . $detail); - } - } - - public function send(string $data): void - { - if (!$this->stream) { - throw new Exception('Unable to send data. SocketConnection is not open'); - } - - fwrite($this->stream, $data); - } - - public function receive(): ResponseStream - { - if (!$this->stream) { - throw new Exception('Unable to receive data. SocketConnection is not open'); - } - - return new SocketResponseStream($this->stream); - } -} \ No newline at end of file diff --git a/lib/Client/Transport/Socket/SocketResponseStream.php b/lib/Client/Transport/Socket/SocketResponseStream.php deleted file mode 100644 index 8e18f87..0000000 --- a/lib/Client/Transport/Socket/SocketResponseStream.php +++ /dev/null @@ -1,44 +0,0 @@ -stream, $remainingBytes); - $remainingBytes = $bytes - strlen($data); - } while ($remainingBytes > 0); - - return $data; - } - - public function readLine(): string - { - $line = ''; - - while ("\n" !== ($char = fread($this->stream, 1))) { - $line .= $char; - } - - return $line."\n"; - } -} \ No newline at end of file diff --git a/lib/Client/Transport/SocketConnection.php b/lib/Client/Transport/SocketConnection.php new file mode 100644 index 0000000..61c18df --- /dev/null +++ b/lib/Client/Transport/SocketConnection.php @@ -0,0 +1,162 @@ +isConnected()) { + return; + } + + $context = stream_context_create($config->streamContextOptions()); + $errorCode = 0; + $errorMessage = ''; + + $stream = @stream_socket_client( + $config->endpoint(), + $errorCode, + $errorMessage, + $config->timeout(), + STREAM_CLIENT_CONNECT, + $context, + ); + + if (!is_resource($stream)) { + $this->logger?->error('IMAP socket connection failed', [ + 'endpoint' => $config->endpoint(), + 'code' => $errorCode, + 'message' => $errorMessage ?: 'unknown error', + ]); + + throw new ImapException(sprintf( + 'Unable to connect to %s (%d: %s)', + $config->endpoint(), + $errorCode, + $errorMessage ?: 'unknown error', + )); + } + + stream_set_timeout($stream, (int) $config->timeout()); + $this->stream = $stream; + + $this->logger?->info('IMAP socket connected to {endpoint} (timeout={timeout})', [ + 'endpoint' => $config->endpoint(), + 'timeout' => $config->timeout(), + ]); + } + + public function disconnect(): void + { + if (!is_resource($this->stream)) { + return; + } + + fclose($this->stream); + $this->stream = null; + + $this->logger?->info('IMAP socket disconnected'); + } + + public function isConnected(): bool + { + return is_resource($this->stream); + } + + public function write(string $payload): void + { + $stream = $this->stream(); + $written = fwrite($stream, $payload); + + if ($written === false || $written !== strlen($payload)) { + $this->logger?->error('IMAP socket write failed (bytes={bytes})', [ + 'bytes' => strlen($payload), + ]); + + throw new ImapException('Failed to write complete payload to IMAP socket.'); + } + } + + public function readLine(): string + { + $stream = $this->stream(); + $line = fgets($stream); + + if ($line === false) { + $this->logger?->error('IMAP socket read failed'); + throw new ImapException('Failed to read line from IMAP socket.'); + } + + return $line; + } + + public function readBytes(int $length): string + { + if ($length < 0) { + throw new ImapException('IMAP socket cannot read a negative number of bytes.'); + } + + if ($length === 0) { + return ''; + } + + $stream = $this->stream(); + $buffer = ''; + + while (strlen($buffer) < $length) { + $chunk = fread($stream, $length - strlen($buffer)); + + if ($chunk === false || $chunk === '') { + $this->logger?->error('IMAP socket literal read failed (bytes={bytes})', [ + 'bytes' => $length, + ]); + + throw new ImapException('Failed to read literal payload from IMAP socket.'); + } + + $buffer .= $chunk; + } + + return $buffer; + } + + public function upgradeToTls(): void + { + $stream = $this->stream(); + $result = stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT); + + if ($result !== true) { + $this->logger?->error('IMAP TLS upgrade failed'); + throw new ImapException('Failed to enable TLS on IMAP socket.'); + } + + $this->logger?->info('IMAP socket upgraded to TLS'); + } + + /** + * @return resource + */ + private function stream() + { + if (!is_resource($this->stream)) { + throw new ImapException('IMAP socket is not connected.'); + } + + return $this->stream; + } +} \ No newline at end of file diff --git a/lib/Client/Transport/SocketConnectionFactory.php b/lib/Client/Transport/SocketConnectionFactory.php new file mode 100644 index 0000000..5c4284b --- /dev/null +++ b/lib/Client/Transport/SocketConnectionFactory.php @@ -0,0 +1,16 @@ +connection->isOpen(); - } - - public function open(): void - { - $this->connection->open(); - } - - public function close(): void - { - $this->connection->close(); - } - - public function send(string $data): void - { - $this->debug(addslashes($data)); - - $this->connection->send($data); - } - - public function receive(): ResponseStream - { - return new TraceableResponseStream( - $this->connection->receive(), - $this->logger, - ); - } - - private function debug(string $data): void - { - $data = str_replace("\r\n", "\\r\\n", $data); - - $this->logger->debug($data); - } -} \ No newline at end of file diff --git a/lib/Client/Transport/Traceable/TraceableResponseStream.php b/lib/Client/Transport/Traceable/TraceableResponseStream.php deleted file mode 100644 index d940cf4..0000000 --- a/lib/Client/Transport/Traceable/TraceableResponseStream.php +++ /dev/null @@ -1,43 +0,0 @@ -responseStream->read($bytes); - - $this->debug($data); - - return $data; - } - - public function readLine(): string - { - $line = $this->responseStream->readLine(); - - $this->debug($line); - - return $line; - } - - private function debug(string $data): void - { - // $data = addslashes($data); - // $data = str_replace("\r\n", "\\r\\n", $data); - - $this->logger->debug($data); - } -} \ No newline at end of file diff --git a/lib/Console/ServiceConnectCommand.php b/lib/Console/ServiceConnectCommand.php deleted file mode 100644 index a45a895..0000000 --- a/lib/Console/ServiceConnectCommand.php +++ /dev/null @@ -1,202 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImap\Console; - -use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; -use KTXM\ProviderImap\Providers\ServiceIdentityBasic; -use KTXM\ProviderImap\Providers\ServiceLocation; -use KTXC\SessionTenant; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -/** - * Manual IMAP service connection wizard. - * - * Interactively prompts for all connection details (host, port, encryption, - * username, password), runs a live connection test, then optionally persists - * the service to the store. - * - * Usage: - * bin/console provider_imap_mail:service:connect - * bin/console provider_imap_mail:service:connect --tenant=t1 --user=u1 - */ -#[AsCommand( - name: 'provider_imap_mail:service:connect', - description: 'Manually configure and connect an IMAP service', -)] -class ServiceConnectCommand extends Command -{ - public function __construct( - private readonly Provider $provider, - private readonly SessionTenant $sessionTenant, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') - ->addOption('host', null, InputOption::VALUE_REQUIRED, 'IMAP server hostname') - ->addOption('port', null, InputOption::VALUE_REQUIRED, 'IMAP port (default: 993)') - ->addOption('encryption', null, InputOption::VALUE_REQUIRED, 'Encryption: ssl | starttls | none (default: ssl)') - ->addOption('username', null, InputOption::VALUE_REQUIRED, 'IMAP username / e-mail') - ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') - ->addOption('no-save', null, InputOption::VALUE_NONE, 'Test connection only; do not persist') - ->setHelp(<<<'HELP' - The provider_imap_mail:service:connect command walks you through - manually configuring an IMAP account. All prompts can be pre-filled via - options to support non-interactive / scripted usage. - - Examples: - - Fully interactive: - bin/console provider_imap_mail:service:connect - - Pre-fill common options: - bin/console provider_imap_mail:service:connect \ - --host=mail.example.com --username=user@example.com \ - --tenant=t1 --user=u1 - HELP); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - $io->title('IMAP Service — Manual Configuration'); - - if (!$input->getOption('tenant')) { - $tenant = $io->ask('Tenant ID'); - if ($tenant) $input->setOption('tenant', $tenant); - } - - if (!$input->getOption('user')) { - $user = $io->ask('User ID'); - if ($user) $input->setOption('user', $user); - } - - if (!$input->getOption('host')) { - $host = $io->ask('IMAP server hostname'); - if ($host) $input->setOption('host', $host); - } - - if (!$input->getOption('port')) { - $port = $io->ask('Port', '993'); - if ($port) $input->setOption('port', $port); - } - - if (!$input->getOption('encryption')) { - $enc = $io->choice('Encryption', ['ssl', 'starttls', 'none'], 'ssl'); - $input->setOption('encryption', $enc); - } - - if (!$input->getOption('username')) { - $username = $io->ask('Username / e-mail address'); - if ($username) $input->setOption('username', $username); - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - $host = (string) ($input->getOption('host') ?? ''); - $port = (int) ($input->getOption('port') ?? 993); - $encryption = (string) ($input->getOption('encryption') ?? 'ssl'); - $username = (string) ($input->getOption('username') ?? ''); - $noVerify = (bool) $input->getOption('no-verify'); - $noSave = (bool) $input->getOption('no-save'); - - // ── Validate required fields ───────────────────────────────────────── - - $errors = []; - if ($host === '') $errors[] = 'Hostname is required.'; - if ($username === '') $errors[] = 'Username is required.'; - if (!$noSave) { - if ($tenantId === '') $errors[] = 'Tenant ID is required (or pass --no-save to test only).'; - if ($userId === '') $errors[] = 'User ID is required (or pass --no-save to test only).'; - } - - if (!empty($errors)) { - $io->error($errors); - return Command::FAILURE; - } - - // ── Prompt for password (always interactive — never passed via option) ─ - - $password = $io->askHidden('Password'); - - if (!$password) { - $io->error('Password is required.'); - return Command::FAILURE; - } - - // ── Build service object ──────────────────────────────────────────── - - $location = new ServiceLocation( - host: $host, - port: $port > 0 ? $port : 993, - encryption: $encryption, - verifyPeer: !$noVerify, - verifyPeerName: !$noVerify, - allowSelfSigned: $noVerify, - ); - - $identity = (new ServiceIdentityBasic())->jsonDeserialize([ - 'identity' => $username, - 'secret' => $password, - ]); - - $service = new Service(); - $service->setLocation($location); - $service->setIdentity($identity); - - // ── Test connection ────────────────────────────────────────────────── - - $io->text('Testing connection to ' . $host . ':' . $port . '…'); - - $result = $this->provider->serviceTest($service); - - if (!$result['success']) { - $io->error('Connection test failed: ' . $result['message']); - return Command::FAILURE; - } - - $io->success($result['message']); - - if ($noSave) { - $io->note('Connection test passed. Service not saved (--no-save).'); - return Command::SUCCESS; - } - - // ── Persist ────────────────────────────────────────────────────────── - - $this->sessionTenant->configureById($tenantId); - - $label = $io->ask('Service label', $username); - if ($label) { - $service->setLabel($label); - } - - $id = $this->provider->serviceCreate($tenantId, $userId, $service); - $io->success("Service saved with ID: {$id}"); - - return Command::SUCCESS; - } -} diff --git a/lib/Console/ServiceDisconnectCommand.php b/lib/Console/ServiceDisconnectCommand.php deleted file mode 100644 index 53e538a..0000000 --- a/lib/Console/ServiceDisconnectCommand.php +++ /dev/null @@ -1,177 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImap\Console; - -use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; -use KTXC\SessionTenant; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -/** - * Remove a stored IMAP service connection. - * - * Looks up the service by its ID (or prompts for one), confirms with the - * operator, then permanently deletes the service document from the store. - * - * Usage: - * bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 - */ -#[AsCommand( - name: 'provider_imap_mail:service:disconnect', - description: 'Remove a stored IMAP service connection', -)] -class ServiceDisconnectCommand extends Command -{ - public function __construct( - private readonly Provider $provider, - private readonly SessionTenant $sessionTenant, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addArgument('service-id', InputArgument::OPTIONAL, 'Service ID to remove') - ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') - ->setHelp(<<<'HELP' - The provider_imap_mail:service:disconnect command permanently removes - a stored IMAP service configuration from the store. - - Examples: - - Interactive (lists services and prompts for ID): - bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 - - Direct, with confirmation: - bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 - - Skip confirmation: - bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 --force - HELP); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - - if (!$input->getOption('tenant')) { - $tenant = $io->ask('Tenant ID'); - if ($tenant) $input->setOption('tenant', $tenant); - } - - if (!$input->getOption('user')) { - $user = $io->ask('User ID'); - if ($user) $input->setOption('user', $user); - } - - // If no service ID given, list available services and let the operator pick - if (!$input->getArgument('service-id')) { - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - - if ($tenantId !== '' && $userId !== '') { - $this->sessionTenant->configureById($tenantId); - $services = $this->provider->serviceList($tenantId, $userId); - - if (empty($services)) { - // nothing to select — let execute() handle the error - return; - } - - $choices = []; - foreach ($services as $id => $service) { - $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; - $choices[$id] = "{$label} [{$id}]"; - } - - $chosen = $io->choice('Select service to disconnect', array_values($choices)); - // resolve the chosen label back to its key - $serviceId = (string) array_search($chosen, $choices, true); - if ($serviceId !== '') { - $input->setArgument('service-id', $serviceId); - } - } - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - $serviceId = (string) ($input->getArgument('service-id') ?? ''); - $force = (bool) $input->getOption('force'); - - $errors = []; - if ($tenantId === '') $errors[] = 'Tenant ID is required (--tenant).'; - if ($userId === '') $errors[] = 'User ID is required (--user).'; - if ($serviceId === '') $errors[] = 'Service ID is required.'; - - if (!empty($errors)) { - $io->error($errors); - return Command::FAILURE; - } - - // ── Fetch service for display ──────────────────────────────────────── - $this->sessionTenant->configureById($tenantId); - $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); - - if ($service === null) { - $io->error("Service '{$serviceId}' not found."); - return Command::FAILURE; - } - - $label = $service->getLabel() ?? $serviceId; - $host = $service->getLocation()?->getHost() ?? 'unknown'; - - $io->title('Disconnect IMAP Service'); - $io->definitionList( - ['ID' => $serviceId], - ['Label' => $label], - ['Host' => $host], - ); - - // ── Confirmation ───────────────────────────────────────────────────── - - if (!$force) { - $confirm = $io->confirm( - "Permanently remove service {$label} ({$serviceId})?", - false - ); - if (!$confirm) { - $io->note('Aborted.'); - return Command::SUCCESS; - } - } - - // ── Destroy ────────────────────────────────────────────────────────── - - $deleted = $this->provider->serviceDestroy($tenantId, $userId, $service); - - if (!$deleted) { - $io->error("Failed to remove service '{$serviceId}'."); - return Command::FAILURE; - } - - $io->success("Service '{$label}' ({$serviceId}) has been removed."); - - return Command::SUCCESS; - } -} diff --git a/lib/Console/ServiceDiscoverCommand.php b/lib/Console/ServiceDiscoverCommand.php deleted file mode 100644 index 5c00bec..0000000 --- a/lib/Console/ServiceDiscoverCommand.php +++ /dev/null @@ -1,226 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImap\Console; - -use KTXM\ProviderImap\Providers\Provider; -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; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -/** - * Automated IMAP service discovery. - * - * Probes DNS SRV records and common hostnames to determine the correct IMAP - * server settings for a given e-mail address, then optionally persists the - * resulting service to the store. - * - * Usage: - * bin/console provider_imap_mail:service:discover user@example.com - * bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save - */ -#[AsCommand( - name: 'provider_imap_mail:service:discover', - description: 'Auto-discover IMAP server settings from an e-mail address', -)] -class ServiceDiscoverCommand extends Command -{ - public function __construct( - private readonly Provider $provider, - private readonly Discovery $discovery, - private readonly SessionTenant $sessionTenant, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addArgument('address', InputArgument::OPTIONAL, 'E-mail address to discover settings for') - ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID (required when --save is set)') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID (required when --save is set)') - ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Explicit hostname to probe instead of DNS lookup') - ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') - ->addOption('save', null, InputOption::VALUE_NONE, 'Persist the discovered service after a successful test') - ->setHelp(<<<'HELP' - The provider_imap_mail:service:discover command auto-discovers IMAP - server settings by probing DNS SRV records (_imaps._tcp / _imap._tcp) and - common hostname conventions (mail., imap., …). - - Examples: - - Dry-run discovery (no persistence): - bin/console provider_imap_mail:service:discover user@example.com - - Discover and save under a specific tenant/user pair: - bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save - - Probe an explicit host: - bin/console provider_imap_mail:service:discover user@example.com --host=mail.example.com - HELP); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - - if (!$input->getArgument('address')) { - $address = $io->ask('E-mail address'); - if ($address) { - $input->setArgument('address', $address); - } - } - - if ($input->getOption('save')) { - if (!$input->getOption('tenant')) { - $tenant = $io->ask('Tenant ID'); - if ($tenant) { - $input->setOption('tenant', $tenant); - } - } - if (!$input->getOption('user')) { - $user = $io->ask('User ID'); - if ($user) { - $input->setOption('user', $user); - } - } - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $address = (string) $input->getArgument('address'); - $host = $input->getOption('host'); - $noVerify = (bool) $input->getOption('no-verify'); - $save = (bool) $input->getOption('save'); - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - - if ($address === '') { - $io->error('An e-mail address is required.'); - return Command::FAILURE; - } - - if ($save && ($tenantId === '' || $userId === '')) { - $io->error('--tenant and --user are required when --save is set.'); - return Command::FAILURE; - } - - $io->title('IMAP Service Discovery'); - $io->text("Discovering settings for {$address}…"); - - // ── Discovery ──────────────────────────────────────────────────────── - - $candidates = $this->discovery->discoverAll( - identity: $address, - location: $host, - verifySSL: !$noVerify, - ); - - if ($candidates === []) { - $io->error('Discovery failed — no reachable IMAP server found.'); - return Command::FAILURE; - } - - // ── Build labelled choice list ──────────────────────────────────────── - - $encLabel = static fn (string $e): string => match ($e) { - 'ssl' => 'SSL/TLS', - 'starttls' => 'STARTTLS', - default => 'None (plain)', - }; - - /** @var array $choiceMap */ - $choiceMap = []; - foreach ($candidates as $c) { - $label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption())); - $choiceMap[$label] = $c; - } - - $io->text(sprintf('%d location(s) found:', count($candidates))); - $io->newLine(); - - if (count($candidates) === 1) { - $chosenLabel = array_key_first($choiceMap); - } else { - $chosenLabel = $io->choice('Select which server to use', array_keys($choiceMap), array_key_first($choiceMap)); - } - - $location = $choiceMap[$chosenLabel]; - - // ── Display chosen result ───────────────────────────────────────────── - - $io->success('Server selected:'); - $io->definitionList( - ['Host' => $location->getHost()], - ['Port' => (string) $location->getPort()], - ['Encryption' => $encLabel($location->getEncryption())], - ); - - if (!$save) { - $io->note('Run with --save --tenant= --user= to persist this service.'); - return Command::SUCCESS; - } - - // ── Interactive credential prompt ──────────────────────────────────── - - $username = $io->ask('Username', $address); - $password = $io->askHidden('Password'); - - if (!$username || !$password) { - $io->error('Username and password are required to save the service.'); - return Command::FAILURE; - } - - // ── Test before saving ─────────────────────────────────────────────── - - $service = new Service(); - $service->setLocation($location); - $service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([ - 'identity' => $username, - 'secret' => $password, - ])); - - $io->text('Testing connection…'); - $result = $this->provider->serviceTest($service); - - if (!$result['success']) { - $io->error('Connection test failed: ' . $result['message']); - return Command::FAILURE; - } - - $io->success($result['message']); - - // ── Persist ────────────────────────────────────────────────────────── - - $this->sessionTenant->configureById($tenantId); - - $label = $io->ask('Service label', $address); - if ($label) { - $service->setLabel($label); - } - - $id = $this->provider->serviceCreate($tenantId, $userId, $service); - $io->success("Service saved with ID: {$id}"); - - return Command::SUCCESS; - } -} diff --git a/lib/Console/ServiceTestCommand.php b/lib/Console/ServiceTestCommand.php deleted file mode 100644 index 68e9189..0000000 --- a/lib/Console/ServiceTestCommand.php +++ /dev/null @@ -1,203 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImap\Console; - -use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; -use KTXM\ProviderImap\Service\Remote\RemoteService; -use KTXC\SessionTenant; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -/** - * Live IMAP connection test. - * - * Connects to the server using a stored service's credentials, authenticates, - * and lists the root-level mailboxes with their unread message counts as a - * quick end-to-end sanity check. - * - * Usage: - * bin/console provider_imap_mail:service:test --tenant=t1 --user=u1 - */ -#[AsCommand( - name: 'provider_imap_mail:service:test', - description: 'Test an IMAP service connection and list root mailboxes', -)] -class ServiceTestCommand extends Command -{ - public function __construct( - private readonly Provider $provider, - private readonly SessionTenant $sessionTenant, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addArgument('service-id', InputArgument::OPTIONAL, 'Service ID to test') - ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') - ->addOption('all', 'a', InputOption::VALUE_NONE, 'List all mailboxes, not just the root level') - ->setHelp(<<<'HELP' - The provider_imap_mail:service:test command opens a live IMAP - connection for a stored service, authenticates, and lists the available - mailboxes together with their message counts. - - Examples: - - Test a specific service: - bin/console provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 - - Interactive (lists services, lets you choose one): - bin/console provider_imap_mail:service:test --tenant=t1 --user=u1 - - Show all mailboxes (not just top-level): - bin/console provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 --all - HELP); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $io = new SymfonyStyle($input, $output); - - if (!$input->getOption('tenant')) { - $tenant = $io->ask('Tenant ID'); - if ($tenant) $input->setOption('tenant', $tenant); - } - - if (!$input->getOption('user')) { - $user = $io->ask('User ID'); - if ($user) $input->setOption('user', $user); - } - - if (!$input->getArgument('service-id')) { - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - - if ($tenantId !== '' && $userId !== '') { - $this->sessionTenant->configureById($tenantId); - $services = $this->provider->serviceList($tenantId, $userId); - - if (!empty($services)) { - $choices = []; - foreach ($services as $id => $service) { - $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; - $choices[$id] = "{$label} [{$id}]"; - } - - $chosen = $io->choice('Select service to test', array_values($choices)); - $serviceId = (string) array_search($chosen, $choices, true); - if ($serviceId !== '') { - $input->setArgument('service-id', $serviceId); - } - } - } - } - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $tenantId = (string) ($input->getOption('tenant') ?? ''); - $userId = (string) ($input->getOption('user') ?? ''); - $serviceId = (string) ($input->getArgument('service-id') ?? ''); - $showAll = (bool) $input->getOption('all'); - - $errors = []; - if ($tenantId === '') $errors[] = 'Tenant ID is required (--tenant).'; - if ($userId === '') $errors[] = 'User ID is required (--user).'; - if ($serviceId === '') $errors[] = 'Service ID is required.'; - - if (!empty($errors)) { - $io->error($errors); - return Command::FAILURE; - } - - // ── Fetch stored service ───────────────────────────────────────────── - - $this->sessionTenant->configureById($tenantId); - - $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); - - if (!($service instanceof Service)) { - $io->error("Service '{$serviceId}' not found."); - return Command::FAILURE; - } - - $host = $service->getLocation()?->getHost() ?? 'unknown'; - $port = $service->getLocation()?->getPort() ?? 993; - $enc = $service->getLocation()?->getEncryption() ?? 'ssl'; - - $io->title('IMAP Connection Test'); - $io->definitionList( - ['Service' => $service->getLabel() ?? $serviceId], - ['Host' => $host], - ['Port' => (string) $port], - ['Encryption' => $enc], - ['Username' => $service->getIdentity()?->getIdentity() ?? '–'], - ); - - // ── Quick single-call test via Provider ────────────────────────────── - - $io->text('Authenticating…'); - $startTime = microtime(true); - $testResult = $this->provider->serviceTest($service); - $latency = (int) round((microtime(true) - $startTime) * 1000); - - if (!$testResult['success']) { - $io->error($testResult['message']); - return Command::FAILURE; - } - - $io->success($testResult['message']); - - // ── Mailbox listing ────────────────────────────────────────────────── - - $io->text('Fetching mailbox list…'); - - try { - $wrapper = RemoteService::freshClient($service); - $mailboxes = $wrapper->mailboxes(); - - $rows = []; - foreach ($mailboxes as $mailbox) { - // Filter to root-level only unless --all - if (!$showAll && substr_count($mailbox->name, $mailbox->hierarchyDelimiter ?: '/') > 0) { - continue; - } - - $selectable = $mailbox->isSelectable() ? '✓' : '–'; - $rows[] = [ - $mailbox->name, - $selectable, - ]; - } - - if (empty($rows)) { - $io->note('No mailboxes found' . ($showAll ? '.' : ' at the root level. Use --all to see all mailboxes.')); - } else { - $io->table(['Mailbox', 'Selectable'], $rows); - $noun = count($rows) === 1 ? 'mailbox' : 'mailboxes'; - $io->text(sprintf('%d %s listed. Latency: %d ms.', count($rows), $noun, $latency)); - } - } catch (\Throwable $e) { - $io->warning('Could not list mailboxes: ' . $e->getMessage()); - } - - return Command::SUCCESS; - } -} diff --git a/lib/Providers/CollectionProperties.php b/lib/Providers/CollectionProperties.php index b66ed30..fd24aed 100644 --- a/lib/Providers/CollectionProperties.php +++ b/lib/Providers/CollectionProperties.php @@ -9,7 +9,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; -use Gricob\IMAP\Mailbox; +use KTXM\ProviderImap\Client\Mailbox; use KTXF\Mail\Collection\CollectionPropertiesMutableAbstract; use KTXF\Mail\Collection\CollectionRoles; @@ -24,26 +24,28 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract // ── IMAP hydration ─────────────────────────────────────────────────────── /** - * Populate from a gricob Mailbox object. + * Populate from a standalone IMAP Mailbox value object. * - * Total / unread counts are NOT available from a LIST response alone. - * They must be set separately (after SELECT + SEARCH UNSEEN). + * Total / unread counts are available when the caller uses LIST-STATUS. */ - public function fromImap(Mailbox $mailbox): static + public function fromImap(Mailbox $mailbox, array $options = []): static { - $delimiter = $mailbox->hierarchyDelimiter; - $this->data['label'] = ($delimiter !== '' && str_contains($mailbox->name, $delimiter)) - ? substr($mailbox->name, strrpos($mailbox->name, $delimiter) + strlen($delimiter)) - : $mailbox->name; + $delimiter = $mailbox->delimiter() ?? ''; + $name = $mailbox->name(); + $attributes = $mailbox->attributes(); + + $this->data['label'] = ($delimiter !== '' && str_contains($name, $delimiter)) + ? substr($name, strrpos($name, $delimiter) + strlen($delimiter)) + : $name; $this->data['delimiter'] = $delimiter; - $this->data['attributes'] = $mailbox->nameAttributes; - $this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true); - $this->data['total'] = 0; - $this->data['unread'] = 0; + $this->data['attributes'] = $attributes; + $this->data['subscribed'] = in_array('\\SUBSCRIBED', $attributes, true) || in_array('\\Subscribed', $attributes, true); + $this->data['total'] = $mailbox->messages(); + $this->data['unread'] = $mailbox->unread(); $this->data['rank'] = 0; // Map standard IMAP role attributes - $this->data['role'] = $this->roleFromAttributes($mailbox->nameAttributes)->value; + $this->data['role'] = $this->roleFromAttributes($attributes)->value; return $this; } @@ -91,6 +93,6 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract return $role; } } - return CollectionRoles::Custom; + return CollectionRoles::None; } } diff --git a/lib/Providers/CollectionResource.php b/lib/Providers/CollectionResource.php index a863841..d2978a9 100644 --- a/lib/Providers/CollectionResource.php +++ b/lib/Providers/CollectionResource.php @@ -9,7 +9,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; -use Gricob\IMAP\Mailbox; +use KTXM\ProviderImap\Client\Mailbox; use KTXF\Mail\Collection\CollectionMutableAbstract; /** @@ -29,26 +29,27 @@ class CollectionResource extends CollectionMutableAbstract // ── IMAP hydration ─────────────────────────────────────────────────────── /** - * Populate from a gricob Mailbox object. + * Populate from a standalone IMAP Mailbox value object. * - * @param Mailbox $mailbox gricob Mailbox value object from LIST response + * @param Mailbox $mailbox mailbox value object from LIST response + * @param array $options additional options, e.g., ['delimiter' => '/'] */ - public function fromImap(Mailbox $mailbox): static + public function fromImap(Mailbox $mailbox, array $options = []): static { // The mailbox name is its unique identifier within the account - $this->data['identifier'] = $mailbox->name; + $this->data['identifier'] = $mailbox->name(); // Derive parent collection from path + delimiter - $delimiter = $mailbox->hierarchyDelimiter; - if ($delimiter && str_contains($mailbox->name, $delimiter)) { - $parts = explode($delimiter, $mailbox->name); + $delimiter = $mailbox->delimiter() ?? $options['delimiter'] ?? '/'; + if ($delimiter && str_contains($mailbox->name(), $delimiter)) { + $parts = explode($delimiter, $mailbox->name()); array_pop($parts); $this->data['collection'] = implode($delimiter, $parts); } else { - $this->data['collection'] = null; + $this->data['collection'] = null; // top-level mailbox } - $this->getProperties()->fromImap($mailbox); + $this->getProperties()->fromImap($mailbox, $options); return $this; } diff --git a/lib/Providers/EntityResource.php b/lib/Providers/EntityResource.php index ebef551..5c35874 100644 --- a/lib/Providers/EntityResource.php +++ b/lib/Providers/EntityResource.php @@ -10,8 +10,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; use DateTimeInterface; -use Gricob\IMAP\Mime\Part\Part; -use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; +use KTXM\ProviderImap\Client\Message; use KTXF\Mail\Entity\EntityMutableAbstract; /** @@ -27,25 +26,19 @@ class EntityResource extends EntityMutableAbstract { } /** - * Convert gricob FetchData to mail entity object - * - * @param FetchData $fetchData result from IMAP FETCH command - * @param string $mailbox IMAP mailbox name (used as collection) + * Convert IMAP data to a mail entity object. */ - public function fromImap(FetchData $fetchData, string $mailbox): static { - - // Collection = the IMAP mailbox name + public function fromImap(Message $message, string $mailbox): static + { $this->data['collection'] = $mailbox; - // Identifier = UID (preferred) or sequence number as fallback - $this->data['identifier'] = $fetchData->uid ?? $fetchData->id; + $this->data['identifier'] = $message->uid() ?: $message->sequence(); - // Created = INTERNALDATE (server arrival time) - if ($fetchData->internalDate !== null) { - $this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM); + if ($message->internalDate() !== null) { + $this->data['created'] = $message->internalDate(); } - $this->getProperties()->fromImap($fetchData); + $this->getProperties()->fromImap($message); return $this; } diff --git a/lib/Providers/MessageProperties.php b/lib/Providers/MessageProperties.php index 1fa1db7..35da2e1 100644 --- a/lib/Providers/MessageProperties.php +++ b/lib/Providers/MessageProperties.php @@ -11,10 +11,8 @@ namespace KTXM\ProviderImap\Providers; use DateTimeImmutable; use DateTimeInterface; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; -use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; +use KTXM\ProviderImap\Client\Message; +use KTXM\ProviderImap\Client\MessagePart as ClientMessagePart; use KTXF\Mail\Object\MessagePropertiesMutableAbstract; /** @@ -23,117 +21,111 @@ use KTXF\Mail\Object\MessagePropertiesMutableAbstract; class MessageProperties extends MessagePropertiesMutableAbstract { /** - * Convert IMAP data to mail message properties object - * - * @param FetchData $fetchData result from IMAP FETCH command + * Convert IMAP data to mail message properties object. */ - public function fromImap(FetchData $fetchData): static { + public function fromImap(Message $message): static + { + $this->data['size'] = $message->size(); - // ── Size ────────────────────────────────────────────────────── - $this->data['size'] = $fetchData->rfc822Size ?? 0; - - // ── Flags ───────────────────────────────────────────────────── $this->data['flags'] = []; - foreach ($fetchData->flags ?? [] as $flag) { + foreach ($message->flags() as $flag) { $flag = ltrim($flag, '\\'); $normalized = match (strtolower($flag)) { - 'seen' => 'read', - 'flagged' => 'flagged', + 'seen' => 'read', + 'flagged' => 'flagged', 'answered' => 'answered', - 'draft' => 'draft', - 'deleted' => 'deleted', - default => strtolower($flag), + 'draft' => 'draft', + 'deleted' => 'deleted', + default => strtolower($flag), }; $this->data['flags'][$normalized] = true; } - // ── Envelope ────────────────────────────────────────────────── - if ($fetchData->envelope !== null) { - $envelope = $fetchData->envelope; - - if ($envelope->messageId !== null) { - $this->data['urid'] = trim($envelope->messageId, '<>'); - } - - if ($envelope->subject !== null) { - // Decode MIME encoded-word in subject - $this->data['subject'] = mb_decode_mimeheader($envelope->subject); - } - - if ($envelope->date !== null) { - $date = $envelope->date instanceof DateTimeImmutable - ? $envelope->date - : new DateTimeImmutable($envelope->date); - $this->data['date'] = $date->format(DateTimeInterface::ATOM); - } - - if ($envelope->inReplyTo !== null) { - $this->data['inReplyTo'] = $envelope->inReplyTo; - } - - $addressToArray = static function ($addr): array { - $email = ''; - if ($addr->mailboxName !== null && $addr->hostName !== null) { - $email = $addr->mailboxName . '@' . $addr->hostName; - } elseif ($addr->mailboxName !== null) { - $email = $addr->mailboxName; - } - return [ - 'address' => $email, - 'label' => $addr->displayName ?? null, - ]; - }; - - if (!empty($envelope->from)) { - $this->data['from'] = $addressToArray($envelope->from[0]); - } - - if (!empty($envelope->sender)) { - $this->data['sender'] = $addressToArray($envelope->sender[0]); - } - - foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { - $envField = $field === 'replyTo' ? 'replyTo' : $field; - if (!empty($envelope->$envField)) { - $this->data[$field] = []; - foreach ($envelope->$envField as $addr) { - $this->data[$field][] = $addressToArray($addr); - } - } - } + if ($message->messageId() !== null) { + $this->data['urid'] = $message->messageId(); } - // ── Body Structure ──────────────────────────────────────────── - if ($fetchData->bodyStructure !== null) { - $bodyStructure = $fetchData->bodyStructure; - // Root multipart containers have no fetchable section ID; their - // children are numbered "1", "2", … to match IMAP section IDs. - $isRootMultipart = $bodyStructure->part instanceof MultiPart; - $rootPartId = $isRootMultipart ? '' : '1'; - $rootPart = (new MessagePart())->fromImap($bodyStructure->part, $rootPartId); + if ($message->subject() !== null) { + $this->data['subject'] = $message->subject(); + } - // ── Body Content: inject decoded content onto part nodes ────── - if (!empty($fetchData->bodySections)) { - $sectionMap = []; - foreach ($fetchData->bodySections as $bs) { - $sectionMap[$bs->section] = $bs->text; - } - $rootPart->injectSections($sectionMap); + if ($message->sentAt() !== null) { + $date = new DateTimeImmutable($message->sentAt()); + $this->data['date'] = $date->format(DateTimeInterface::ATOM); + } + + if ($message->inReplyTo() !== null) { + $this->data['inReplyTo'] = $message->inReplyTo(); + } + + if ($message->from() !== []) { + $this->data['from'] = $message->from()[0]->toArray(); + } + + if ($message->sender() !== []) { + $this->data['sender'] = $message->sender()[0]->toArray(); + } + + foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { + $addresses = $message->{$field}(); + if ($addresses === []) { + continue; } - $this->data['body'] = $rootPart->toStore(); + $this->data[$field] = array_map( + static fn ($address): array => $address->toArray(), + $addresses, + ); + } + + if ($message->bodyStructure() !== null) { + $this->data['body'] = $message->bodyStructure()->toArray(); - // Collect attachments: non-body parts with name or attachment disposition $attachments = []; - self::collectAttachments($bodyStructure->part, $rootPartId, $attachments); - if (!empty($attachments)) { + self::collectAttachments($message->bodyStructure(), $attachments); + if ($attachments !== []) { $this->data['attachments'] = $attachments; } } + if ($message->bodyStructure() !== null) { + $this->data['body'] = $message->bodyStructure()->toArray(); + // Recursively add content from bodyValues to matching parts + if (is_array($message->bodySections())) { + $addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) { + // If this part has a partId and matching bodyValue, add content + if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) { + $structure['content'] = $bodyValues[$structure['partId']] ?? null; + } + // Recursively process subParts + if (isset($structure['subParts']) && is_array($structure['subParts'])) { + foreach ($structure['subParts'] as &$subPart) { + $addContentToParts($subPart, $bodyValues); + } + } + }; + + $addContentToParts($this->data['body'], $message->bodySections()); + } + } + return $this; } + private static function normalizeFlag(string $flag): string + { + $flag = ltrim($flag, '\\'); + + return match (strtolower($flag)) { + 'seen' => 'read', + 'flagged' => 'flagged', + 'answered' => 'answered', + 'draft' => 'draft', + 'deleted' => 'deleted', + default => strtolower($flag), + }; + } + /** * Convert a string to UTF-8 from the given charset. * @@ -165,42 +157,28 @@ class MessageProperties extends MessagePropertiesMutableAbstract { /** * Recursively collect attachment parts from body structure */ - private static function collectAttachments( - Part $part, - string $partId, - array &$attachments, - ): void { - if ($part instanceof SinglePart) { - $type = strtolower($part->type ?? ''); - $subtype = strtolower($part->subtype ?? ''); - $disposition = strtolower($part->disposition?->type ?? ''); - $name = null; - if (!empty($part->attributes)) { - foreach ($part->attributes as $k => $v) { - if (strtolower($k) === 'name') { - $name = $v; - break; - } - } - } - if (!empty($part->disposition?->attributes)) { - foreach ($part->disposition->attributes as $k => $v) { - if (strtolower($k) === 'filename') { - $name = $name ?? $v; - } - } - } - $isInlineText = ($type === 'text' && ($subtype === 'plain' || $subtype === 'html') && $disposition !== 'attachment'); - if (!$isInlineText && ($disposition !== '' || $name !== null)) { - $mp = (new MessagePart())->fromImap($part, $partId); - $attachments[] = $mp->toStore(); - } - } elseif ($part instanceof MultiPart) { - foreach ($part->parts as $index => $subPart) { - $subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1); - self::collectAttachments($subPart, $subPartId, $attachments); + private static function collectAttachments(ClientMessagePart $part, array &$attachments): void + { + $children = $part->parts(); + if ($children !== []) { + foreach ($children as $childPart) { + self::collectAttachments($childPart, $attachments); } + return; } + + $mimeType = strtolower($part->mimeType()); + $disposition = strtolower($part->disposition() ?? ''); + $name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null; + $isInlineText = str_starts_with($mimeType, 'text/') + && in_array($mimeType, ['text/plain', 'text/html'], true) + && $disposition !== 'attachment'; + + if ($isInlineText || ($disposition === '' && $name === null)) { + return; + } + + $attachments[] = $part->toArray(); } /** diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index bd751f1..cde7e86 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -188,10 +188,13 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } + // augment the service with any provided test options (e.g. override location or credentials) + $service->fromStore(['sid' => 'test']); // Attempt to authenticate and list mailboxes as a connectivity check - $wrapper = RemoteService::freshClient($service); - $mailboxes = $wrapper->mailboxes(); + $client = RemoteService::freshClient($service); + $service = RemoteService::mailService($service, $client); + $mailboxes = $service->collectionList(); $latency = (int) round((microtime(true) - $startTime) * 1000); diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index fa1c7c9..8cf2993 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -35,15 +35,12 @@ use KTXM\ProviderImap\Providers\ServiceIdentityBasic; use KTXM\ProviderImap\Providers\ServiceLocation; use KTXM\ProviderImap\Service\Remote\RemoteMailService; use KTXM\ProviderImap\Service\Remote\RemoteService; +use KTXM\ProviderImap\Providers\CollectionResource; +use KTXF\Mail\Collection\CollectionRoles; +use KTXM\ProviderImap\Providers\EntityResource; /** * IMAP Mail Service - * - * Represents a single IMAP account configuration and acts as the primary - * entry-point for all mail operations (collections + entities). - * - * The RemoteMailService is initialised lazily on first use so that the object - * can be constructed cheaply for serialisation/deserialisation tasks. */ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface { @@ -64,25 +61,28 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC private array $serviceAbilities = [ self::CAPABILITY_COLLECTION_LIST => true, - self::CAPABILITY_COLLECTION_LIST_FILTER => [], + self::CAPABILITY_COLLECTION_LIST_FILTER => [ + self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256', + self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1', + self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1', + ], self::CAPABILITY_COLLECTION_LIST_SORT => [], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, + self::CAPABILITY_COLLECTION_MOVE => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ - 'seen' => 'b:0:1:1', - 'flagged' => 'b:0:1:1', - self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', - self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256', - self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256', + self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1', - self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1', - self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', - self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', + self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1', + self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', + self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', ], self::CAPABILITY_ENTITY_LIST_SORT => [], self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']], @@ -350,10 +350,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // ── Collection operations ───────────────────────────────────────────────── - public function collectionList(string|int|null $location, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array + public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); - return $this->mailService->collectionList(); + + foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) { + $resource = $this->collectionFresh(); + $resource->fromImap($mailbox); + $list[$mailbox->name()] = $resource; + } + + return $list; } public function collectionListFilter(): Filter @@ -370,7 +377,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $existing = $this->mailService->collectionList(); + $mailboxes = $this->collectionList(); $extant = []; foreach ($identifiers as $id) { $extant[(string) $id] = isset($existing[(string) $id]); @@ -381,12 +388,21 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC public function collectionFetch(string|int $identifier): ?CollectionBaseInterface { $this->initialize(); - return $this->mailService->collectionFetch((string) $identifier); + + $mailbox = $this->mailService->collectionFetch((string) $identifier); + if ($mailbox === null) { + return null; + } + + $collection = $this->collectionFresh(); + $collection->fromImap($mailbox); + + return $collection; } public function collectionFresh(): CollectionMutableInterface { - return new CollectionResource(); + return new CollectionResource($this->provider(), $this->identifier()); } public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface @@ -397,22 +413,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $label = $collection->getProperties()->getLabel() ?? ''; if ($location !== null && $location !== '') { // Determine the hierarchy delimiter from an existing mailbox, default to '/' - $existing = $this->mailService->collectionList(); - $delimiter = '/'; - foreach ($existing as $c) { - $props = $c->getProperties(); - if ($props instanceof CollectionProperties) { - $d = $props->getDelimiter(); - if ($d !== null && $d !== '') { - $delimiter = $d; - break; - } - } - } + $mailboxes = iterator_to_array($this->mailService->collectionList(null, null, null, '')); + $delimiter = $mailboxes ? reset($mailboxes)->delimiter() ?? '/' : '/'; $label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter); } - return $this->mailService->collectionCreate($label); + $mailbox = $this->mailService->collectionCreate($label); + + $collection = $this->collectionFresh(); + $collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]); + + return $collection; } public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface @@ -421,64 +432,94 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // In IMAP, "update" = rename to the new label $newName = $collection->getProperties()->getLabel() ?? (string) $identifier; - return $this->mailService->collectionRename((string) $identifier, $newName); + $mailbox = $this->mailService->collectionRename((string) $identifier, $newName); + + $collection = $this->collectionFresh(); + $collection->fromImap($mailbox); + return $collection; } - public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool - { - $this->initialize(); - return $this->mailService->collectionDestroy((string) $identifier); - } - - public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface + public function collectionDelete(string|int $identifier, bool $force = false): CollectionBaseInterface | true { $this->initialize(); - // IMAP RENAME effectively moves+renames the mailbox - $existing = $this->mailService->collectionFetch((string) $identifier); - $label = $existing?->getProperties()->getLabel() ?? basename((string) $identifier); - $newName = $targetLocation !== null ? rtrim((string) $targetLocation, '/') . '/' . $label : $label; + $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; + $deleteTarget = $this->auxiliary['deleteTarget'] ?? null; - return $this->mailService->collectionRename((string) $identifier, $newName); + if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { + throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); + } + + // Move to target collection (e.g. Trash) instead of deleting + if ($deleteMode === 'soft' && $deleteTarget !== null) { + return $this->collectionMove((string) $identifier, (string) $deleteTarget); + } + + if ($deleteMode === 'soft' && $deleteTarget === null) { + $filter = $this->collectionListFilter(); + $filter->condition('role', CollectionRoles::Trash->value); + + $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); + if (empty($mailboxes)) { + throw new \RuntimeException('No Trash collection configured or found for deletion'); + } + + $deleteTarget = key($mailboxes); + } + + // we need to determine if the folder being deleted is already in the trash + if (str_starts_with((string) $identifier, (string) $deleteTarget)) { + // if so, we should hard delete instead of moving to avoid duplicates in the trash + $deleteMode = 'hard'; + } + + $result = match ($deleteMode) { + 'soft' => $this->collectionMove((string) $identifier, (string) $deleteTarget), + 'hard' => $this->mailService->collectionDestroy((string) $identifier) + }; + return $result; + } + + public function collectionMove(string|int $identifier, string|int|null $target): CollectionBaseInterface + { + $this->initialize(); + + $sourceMailbox = $this->mailService->collectionFetch((string) $identifier); + $targetMailbox = $this->mailService->collectionFetch((string) $target); + if ($sourceMailbox === null) { + throw new \RuntimeException('Source collection not found for move operation'); + } + if ($targetMailbox === null) { + throw new \RuntimeException('Target collection not found for move operation'); + } + + $sourceDelimiter = $sourceMailbox->delimiter() ?? '/'; + $targetDelimiter = $targetMailbox->delimiter() ?? '/'; + + $targetPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $sourceMailbox->name())); + $mutatedMailbox = $this->mailService->collectionRename($sourceMailbox->name(), $targetPath); + + $collection = $this->collectionFresh(); + $collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]); + return $collection; } // ── Entity operations ───────────────────────────────────────────────────── public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { - $this->initialize(); - - // Unfiltered + unpaginated: skip the SEARCH round-trip and use FETCH 1:* - if ($filter === null && $range === null) { - return $this->mailService->entityFetchAll((string) $collection); - } - - // Filtered or paginated: SEARCH to get a UID list, then FETCH by UIDs - $uids = $this->mailService->entityList((string) $collection, $filter, $range); - if (empty($uids)) { - return []; - } - - return $this->mailService->entityFetch((string) $collection, ...$uids); + return itterator_to_array($this->entityList((string) $collection, $filter, $sort, $range), true); } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { $this->initialize(); - // Unfiltered: skip the SEARCH round-trip and stream via FETCH 1:* - if ($filter === null) { - yield from $this->mailService->entityFetchAllStream((string) $collection); - return; + foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) { + $resource = $this->entityFresh(); + $resource->fromImap($message, $collection); + yield $identifier => $resource; } - - // Filtered: SEARCH for matching UIDs then stream only those messages - $uids = $this->mailService->entityList((string) $collection, $filter, $range); - if (empty($uids)) { - return; - } - - yield from $this->mailService->entityFetchStream((string) $collection, ...$uids); } public function entityListFilter(): Filter @@ -498,6 +539,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC default => new Range(), }; } + + public function entityFetch(string|int $collection, string|int ...$identifiers): array + { + $this->initialize(); + + $uids = array_map('intval', $identifiers); + return $this->mailService->entityFetch((string) $collection, ...$uids); + } public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { @@ -517,57 +566,112 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $extant; } - public function entityFetch(string|int $collection, string|int ...$identifiers): array + public function entityFresh(): EntityResource { - $this->initialize(); - - $uids = array_map('intval', $identifiers); - return $this->mailService->entityFetch((string) $collection, ...$uids); + return new EntityResource($this->provider(), $this->identifier()); } public function entityDelete(EntityIdentifier ...$identifiers): array { // validate identifiers and group by collection - $collections = []; - foreach ($identifiers as $identifier) { - if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) { - throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier); - } - $collections[$identifier->collection()][] = (int) $identifier->entity(); - } + $identifiers = $this->groupEntitiesByCollection(...$identifiers); + // determine delete mode and target collection (e.g. Trash) if applicable + $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; + $deleteTarget = $this->auxiliary['deleteTarget'] ?? null; + + if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { + throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); + } + + // connect to remote store $this->initialize(); + + // attempt to find a target collection for soft deletion if none was specified + if ($deleteMode === 'soft' && $deleteTarget === null) { + $filter = $this->collectionListFilter(); + $filter->condition('role', CollectionRoles::Trash->value); + + $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); + if (empty($mailboxes)) { + throw new \RuntimeException('No Trash collection configured or found for deletion'); + } + + $deleteTargetNative = reset($mailboxes)->name(); + $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); + } else { + $deleteTargetNative = $deleteTarget; + $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); + } + + // entities need to be moved or deleted by collection + $list = []; + foreach ($identifiers as $sourceCollection => $sourceEntities) { + if ($deleteMode === 'soft' && $sourceCollection === $deleteTargetNative) { + continue; + } + + $uids = array_keys($sourceEntities); + + $mutations = match ($deleteMode) { + 'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), + 'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids), + }; - // delete entities per collection and build result map - $result = []; - foreach ($collections as $collection => $uids) { - $this->mailService->entityDestroy($collection, ...$uids); foreach ($uids as $uid) { - $result[(string) $uid] = true; + $mutatedUid = $mutations[$uid] ?? null; + $results[(string)$sourceEntities[$uid]] = [ + 'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted', + 'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null, + 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null, + ]; } } - return $result; + return $results; } public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array { // validate target belongs to this service - if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) { - throw new \InvalidArgumentException('Target collection does not belong to this service'); + if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) { + throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target); } - // validate identifiers and construct ID list - $ids = []; - foreach ($identifiers as $identifier) { - if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) { - throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier); - } - $ids[] = $identifier->entity(); - } + // validate identifiers and group by collection + $identifiers = $this->groupEntitiesByCollection(...$identifiers); + // move entities on remote store and construct result map $this->initialize(); + $list = []; + foreach ($identifiers as $sourceCollection => $sourceEntities) { + $uids = array_keys($sourceEntities); - return $this->mailService->entityMove($target->collection(), ...$ids); + $mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids); + + foreach ($uids as $uid) { + $mutatedUid = $mutations[$uid] ?? null; + $list[(string)$sourceEntities[$uid]] = [ + 'disposition' => 'moved', + 'destination' => $target, + 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, + ]; + } + } + + return $list; } + + private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array + { + $list = []; + foreach ($identifiers as $identifier) { + if ($identifier->provider() !== $this->provider() || $identifier->service() !== $this->identifier()) { + throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier); + } + $list[$identifier->collection()][$identifier->entity()] = $identifier; + } + return $list; + } + } diff --git a/lib/Providers/ServiceLocation.php b/lib/Providers/ServiceLocation.php index fcd3360..c8cc197 100644 --- a/lib/Providers/ServiceLocation.php +++ b/lib/Providers/ServiceLocation.php @@ -9,7 +9,8 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; -use Gricob\IMAP\Configuration; +use KTXM\ProviderImap\Client\ConnectionConfig; +use KTXM\ProviderImap\Client\ConnectionSecurity; use KTXF\Resource\Provider\ResourceServiceLocationInterface; /** @@ -101,34 +102,28 @@ class ServiceLocation implements ResourceServiceLocationInterface public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; } public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; } - // ── gricob helper ──────────────────────────────────────────────────────── + // ── Client helpers ─────────────────────────────────────────────────────── /** - * Build a Gricob IMAP Configuration from this location. - * - * gricob passes the transport directly to stream_socket_client: - * 'ssl' → ssl://host:port (implicit TLS, port 993) - * 'tcp' → tcp://host:port (plain TCP; STARTTLS negotiation is not - * supported by gricob, so starttls/none both - * use plain TCP) + * Build a standalone IMAP client ConnectionConfig from this location. */ - public function toConfiguration(): Configuration + public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig { - // Map our encryption label to a stream_socket_client transport. - // gricob has no STARTTLS negotiation, so starttls falls back to tcp. - $transport = match ($this->encryption) { - 'ssl', 'tls' => 'ssl', - default => 'tcp', // starttls, none + $security = match ($this->encryption) { + 'ssl', 'tls' => ConnectionSecurity::Tls, + 'starttls' => ConnectionSecurity::StartTls, + default => ConnectionSecurity::Plain, }; - return new Configuration( - transport: $transport, - host: $this->host, - port: $this->port, - verifyPeer: $this->verifyPeer, - verifyPeerName: $this->verifyPeerName, - allowSelfSigned: $this->allowSelfSigned, - useUid: true, + return new ConnectionConfig( + host: $this->host, + port: $this->port, + security: $security, + username: $username, + password: $password, + verifyPeer: $this->verifyPeer, + verifyPeerName: $this->verifyPeerName, + allowSelfSigned: $this->allowSelfSigned, ); } } diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 3546154..398b669 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -11,23 +11,40 @@ namespace KTXM\ProviderImap\Service\Remote; use DateTimeImmutable; use Generator; -use Gricob\IMAP\Client; -use Gricob\IMAP\Protocol\Command\Argument\Search\Before; -use Gricob\IMAP\Protocol\Command\Argument\Search\Body; -use Gricob\IMAP\Protocol\Command\Argument\Search\Flagged; -use Gricob\IMAP\Protocol\Command\Argument\Search\From; -use Gricob\IMAP\Protocol\Command\Argument\Search\Larger; -use Gricob\IMAP\Protocol\Command\Argument\Search\Seen; -use Gricob\IMAP\Protocol\Command\Argument\Search\Since; -use Gricob\IMAP\Protocol\Command\Argument\Search\Smaller; -use Gricob\IMAP\Protocol\Command\Argument\Search\Subject; -use Gricob\IMAP\Protocol\Command\Argument\Search\To; -use Gricob\IMAP\Protocol\Command\Argument\Search\Unflagged; -use Gricob\IMAP\Protocol\Command\Argument\Search\Unseen; +use KTXM\ProviderImap\Client\Client; +use KTXM\ProviderImap\Client\Command\FetchManyCommand; +use KTXM\ProviderImap\Client\Command\ExpungeCommand; +use KTXM\ProviderImap\Client\Command\ListCommand; +use KTXM\ProviderImap\Client\Command\SearchCommand; +use KTXM\ProviderImap\Client\Command\SelectCommand; +use KTXM\ProviderImap\Client\Command\SortCommand; +use KTXM\ProviderImap\Client\Command\StatusCommand; +use KTXM\ProviderImap\Client\Command\StoreCommand; +use KTXM\ProviderImap\Client\Command\CopyCommand; +use KTXM\ProviderImap\Client\Command\CreateCommand; +use KTXM\ProviderImap\Client\Command\RenameCommand; +use KTXM\ProviderImap\Client\Command\DeleteCommand; +use KTXM\ProviderImap\Client\FetchTarget; +use KTXM\ProviderImap\Client\FetchOptions; +use KTXM\ProviderImap\Client\IdentifierMode; +use KTXM\ProviderImap\Client\ImapException; +use KTXM\ProviderImap\Client\ListReturnOptions; +use KTXM\ProviderImap\Client\Mailbox; +use KTXM\ProviderImap\Client\Message; +use KTXM\ProviderImap\Client\MessageAddress; +use KTXM\ProviderImap\Client\MessagePart; +use KTXM\ProviderImap\Client\Command\MoveCommand; +use KTXM\ProviderImap\Client\SearchCriteriaBuilder; +use KTXM\ProviderImap\Client\SequenceSet; +use KTXF\Mail\Collection\CollectionRoles; use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Filter\FilterComparisonOperator; +use KTXF\Resource\Filter\FilterConjunctionOperator; use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Sort\ISort; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; @@ -36,61 +53,90 @@ use KTXM\ProviderImap\Providers\EntityResource; */ class RemoteMailService { - /** - * Default IMAP FETCH data items used for message hydration - */ - private const DEFAULT_FETCH_ITEMS = [ - 'FLAGS', - 'ENVELOPE', - 'INTERNALDATE', - 'RFC822.SIZE', - 'BODYSTRUCTURE', - 'UID', - 'BODY[TEXT]' - ]; + + private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription']; + private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']; public function __construct( private readonly Client $client, - private readonly string $provider, - private readonly string|int $service, + private readonly string $provider, + private readonly string|int $service, ) {} /** - * List all selectable mailboxes on the server. - * - * @return array keyed by mailbox name - */ - public function collectionList(): array + * list of collections in remote storage + * + * @since Release 1.0.0 + */ + public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator { - $result = []; - - foreach ($this->client->mailboxes() as $mailbox) { - if (!$mailbox->isSelectable()) { - continue; + // Prepare location filter + if (!empty($location)) { + $location = ltrim($location, '/'); + if (!str_ends_with($location, '/')) { + $location .= '/'; + } + } else { + $location = ''; + } + // construct the most efficient LIST command based on server capabilities + if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) { + $command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)); + $rfc5258 = true; + } else { + $command = new ListCommand($location, $depth); + $rfc5258 = false; + } + // retrieve list of mailboxes from remote + $mailboxes = []; + foreach ($this->client->perform($command) as $mailbox) { + // apply filter + if ($filter === null || $this->mailboxFilter($mailbox, $filter)) { + if ($rfc5258) { + yield $mailbox->name() => $mailbox; + } else { + $mailboxes[] = $mailbox; + } + } + } + // enrich with STATUS info if not already provided by LIST-STATUS response + if (!$rfc5258) { + foreach ($mailboxes as $index => $mailbox) { + if (!$mailbox->isSelectable()) { + yield $mailbox->name() => $mailbox; + continue; + } + try { + $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); + $mailbox->fromStatus($status); + } catch (ImapException) { + // do nothing + } + yield $mailbox->name() => $mailbox; } - $resource = new CollectionResource($this->provider, $this->service); - $resource->fromImap($mailbox); - $result[$resource->identifier()] = $resource; } - return $result; + return; } /** * Fetch a single mailbox by its full name. * - * Returns null when no mailbox matching $name is found. + * Returns null when no mailbox matching $identifier is found. */ - public function collectionFetch(string $name): ?CollectionResource + public function collectionFetch(string $identifier): ?Mailbox { - foreach ($this->client->mailboxes() as $mailbox) { - if ($mailbox->name === $name) { - $resource = new CollectionResource($this->provider, $this->service); - $resource->fromImap($mailbox); - return $resource; - } + // retrieve mailbox from remote + $mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)))); + if (empty($mailbox)) { + return null; } - return null; + $mailbox = reset($mailbox); + // enrich with STATUS + $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); + $mailbox->fromStatus($status); + + return $mailbox; } /** @@ -99,37 +145,41 @@ class RemoteMailService * If the server-side LIST cannot confirm the new mailbox (e.g., immediate * consistency), a lightweight stub resource is returned instead. */ - public function collectionCreate(string $name): CollectionResource + public function collectionCreate(string $name): Mailbox { - $this->client->createMailbox($name); + $result = $this->client->perform(new CreateCommand($name)); - // Attempt to refetch the new mailbox from the server - $resource = $this->collectionFetch($name); - if ($resource !== null) { - return $resource; + if (!$result->isOk()) { + throw new ImapException('Failed to create mailbox: ' . $name); } - // Fallback: return a minimal resource with just the name set - $resource = new CollectionResource($this->provider, $this->service); - $resource->fromStore(['identifier' => $name, 'collection' => null]); - return $resource; + // Attempt to refetch the new mailbox from the server + $mailbox = $this->collectionFetch($name); + if ($mailbox === null) { + throw new ImapException('Failed to create mailbox: ' . $name); + } + + return $mailbox; } /** * Rename a mailbox and return the updated resource. */ - public function collectionRename(string $oldName, string $newName): CollectionResource + public function collectionRename(string $oldName, string $newName): Mailbox { - $this->client->renameMailbox($oldName, $newName); + $result = $this->client->perform(new RenameCommand($oldName, $newName)); - $resource = $this->collectionFetch($newName); - if ($resource !== null) { - return $resource; + if (!$result->isOk()) { + throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); } - $resource = new CollectionResource($this->provider, $this->service); - $resource->fromStore(['identifier' => $newName, 'collection' => null]); - return $resource; + $mailbox = $this->collectionFetch($newName); + + if ($mailbox === null) { + throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); + } + + return $mailbox; } /** @@ -137,170 +187,96 @@ class RemoteMailService */ public function collectionDestroy(string $name): bool { - $this->client->deleteMailbox($name); + $result = $this->client->perform(new DeleteCommand($name)); + + if (!$result->isOk()) { + throw new ImapException('Failed to delete mailbox: ' . $name); + } + return true; } // ── Entity (message) operations ─────────────────────────────────────────── /** - * Return UIDs present in a mailbox, optionally filtered and paginated. - * - * UIDs are always returned descending (highest = newest first). - * When a RangeTally $range is supplied: - * - ABSOLUTE anchor: slice from position offset for tally items - * - RELATIVE anchor: find the UID whose value equals position, then - * return the next tally items (cursor-based paging) - * - * @return int[] + * Find UIDs of messages in a mailbox matching the given filter, sorted and paginated as requested. + * + * @return int[] list of UIDs matching the filter, sorted and paginated as requested */ - public function entityList(string $collection, ?IFilter $filter = null, ?IRange $range = null): array + public function entityFind(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { - $criteria = []; - if ($filter !== null) { - foreach ($filter->conditions() as $condition) { - $attribute = $condition['attribute']; - $value = $condition['value']; - $criterion = match ($attribute) { - 'seen' => $value ? new Seen() : new Unseen(), - 'flagged' => $value ? new Flagged() : new Unflagged(), - 'from' => new From($value), - 'to' => new To($value), - 'subject' => new Subject($value), - 'body' => new Body($value), - 'before' => new Before(new DateTimeImmutable($value)), - 'after' => new Since(new DateTimeImmutable($value)), - 'min' => new Larger($value), - 'max' => new Smaller($value), - default => null, - }; - if ($criterion !== null) { - $criteria[] = $criterion; - } - } + $nativeFilter = $this->buildEntitySearchCriteria($filter); + $nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : []; + + $this->client->perform(new SelectCommand($collection, true)); + $rfc5258 = $this->client->hasCapability('SORT'); + + $uids = []; + if ($nativeSort !== [] && $rfc5258) { + $uids = $this->client->perform(new SortCommand( + $nativeSort, + $nativeFilter, + IdentifierMode::Uid, + ))->matches(); + } else { + $uids = $this->client->perform(new SearchCommand( + $nativeFilter, + IdentifierMode::Uid, + ))->matches(); } - $uids = $this->client->searchMessages($collection, $criteria); + if ($uids === []) { + return []; + } + if ($sort !== null && $nativeSort === []) { + $uids = $this->entitySortClientSide($uids, $sort); + } + + return $this->entityApplyRange($uids, $range); + } + + /** + * Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested. + * + * @return Message[] list of messages matching the filter, sorted and paginated as requested + */ + public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator + { + // find all the UIDs matching the filter + $uids = $this->entityFind($collection, $filter, $sort, $range); if (empty($uids)) { return []; } - rsort($uids); + $options = FetchOptions::default()->withBodyText(); - if ($range instanceof RangeTally) { - $position = (int) $range->getPosition(); - $tally = $range->getTally(); - if ($range->getAnchor() === RangeAnchorType::RELATIVE) { - // Cursor-based: find the anchor UID then take the next slice - $index = array_search($position, $uids, true); - $start = $index !== false ? $index + 1 : 0; - } else { - // Absolute offset - $start = $position; - } - $uids = array_slice($uids, $start, $tally); - } - - return $uids; + yield from $this->entityFetch($collection, $options, ...$uids); } /** * Fetch one or more messages by UID and return EntityResource objects. * * @param int ...$uids - * @return EntityResource[] keyed by UID + * @return Message[] keyed by UID */ - public function entityFetch(string $collection, int ...$uids): array + public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator { if (empty($uids)) { return []; } - $this->client->select($collection); - $result = []; - foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { - $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection); - $result[$uid] = $resource; - } - return $result; - } + $options ??= FetchOptions::default(); + $this->client->perform(new SelectCommand($collection, true)); - public function entityFetchStream(string $collection, int ...$uids): Generator - { - if (empty($uids)) { - return; - } + $request = new FetchManyCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $options, + ); - $this->client->select($collection); - foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { - $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection); - yield $uid => $resource; - } - } - - /** - * Fetch every message in a mailbox using a single FETCH 1:* command and - * return all EntityResource objects as an array keyed by UID. - * - * Use this for unfiltered, unpaginated listing where a two-round-trip - * SEARCH-then-FETCH approach would be wasteful. - * - * @param string[] $items IMAP fetch data items - * @return EntityResource[] keyed by UID - */ - public function entityFetchAll(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): array - { - $result = []; - foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) { - $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection); - $result[$uid] = $resource; - } - return $result; - } - - /** - * Stream every message in a mailbox using FETCH 1:*, yielding - * uid => EntityResource as each FETCH response arrives off the socket. - * - * Use this for unfiltered streaming where a SEARCH ALL round-trip would be - * an unnecessary extra RTT. - * - * @param string[] $items IMAP fetch data items - * @return Generator - */ - public function entityFetchAllStream(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): Generator - { - foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) { - $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection); - yield $uid => $resource; - } - } - - /** - * Stream messages one at a time as EntityResource objects. - * - * Yields uid (int) => EntityResource. Use this for large mailbox syncs to - * avoid holding thousands of objects in memory simultaneously. - * - * Pass a custom $items array to restrict the fetched data (e.g. ['FLAGS', 'UID'] - * for a flags-only sync). Defaults to DEFAULT_FETCH_ITEMS. - * - * @param int[] $uids - * @param string[] $items IMAP fetch data items - * @return \Generator - */ - public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): Generator - { - $this->client->select($collection); - foreach ($this->client->streamByUids($uids, $items) as $uid => $fetchData) { - $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection); - yield $uid => $resource; + foreach ($this->client->perform($request) as $message) { + $uid = $message->uid() ?: $message->sequence(); + yield $uid => $message; } } @@ -327,31 +303,530 @@ class RemoteMailService return; } - $this->client->storeFlags($collection, array_values($uids), $action, $flags); + $this->client->perform(new SelectCommand($collection, false)); + $this->client->perform(new StoreCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $flags, + $action, + )); } /** * Permanently delete one or more messages by UID. */ - public function entityDestroy(string $collection, int ...$uids): void + public function entityDestroy(string $collection, int ...$uids): array { if (empty($uids)) { - return; + return []; } - $this->client->deleteMessages($collection, array_values($uids)); + $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)); + + // TODO: find a way to determine which actual UID's were deleted + return array_fill_keys($uids, true); + } + + public function entityMove(string $targetCollection, string $sourceCollection, int ...$uids): array + { + if (empty($uids)) { + return []; + } + + $rfc6851 = $this->client->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( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $targetCollection, + )); + } else { + $this->client->perform(new SelectCommand($sourceCollection, false)); + $response = $this->client->perform(new CopyCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $targetCollection, + )); + if ($response->isOk()) { + $this->client->perform(new StoreCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + ['\\Deleted'], + '+', + )); + $this->client->perform(new ExpungeCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + )); + } + } + + if (!$response->isOk()) { + throw new ImapException('Failed to move messages: ' . implode(', ', $response->responseCodes())); + } + + // construct operation result as a map of source UID to boolean or destination UID, depending on server support + $map = $response->copyUidMap(); + if ($map === []) { + $result = array_fill_keys(array_map('strval', $uids), true); + } else { + $result = array_fill_keys(array_map('strval', $uids), false); + foreach ($uids as $uid) { + $result[$uid] = $map[$uid] ?? false; + } + } + + return $result; + + } + + private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder + { + if ($filter === null || $filter->conditions() === []) { + return SearchCriteriaBuilder::create()->all(); + } + + $expression = null; + + foreach ($filter->conditions() as $condition) { + $operand = $this->entityFilterOperand($condition); + if ($operand === null) { + continue; + } + + if ($expression === null) { + $expression = $operand; + continue; + } + + $expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR) + ? SearchCriteriaBuilder::create()->or($expression, $operand) + : SearchCriteriaBuilder::create()->group($expression)->group($operand); + } + + if ($expression === null) { + return SearchCriteriaBuilder::create()->all(); + } + + return $expression instanceof SearchCriteriaBuilder + ? $expression + : SearchCriteriaBuilder::create()->group($expression); } /** - * Copy one or more messages to a destination mailbox. + * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ - public function entityCopy(string $collection, string $destination, int ...$uids): void + private function entityFilterOperand(array $condition): SearchCriteriaBuilder|array|string|null { - if (empty($uids)) { - return; - } + $attribute = $condition['attribute'] ?? ''; + $value = $condition['value'] ?? null; + $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; - $this->client->copyMessages($collection, array_values($uids), $destination); + return match ($attribute) { + '*', 'all' => $this->entityStringCriteria('text', $value, $comparator), + 'from', 'to', 'cc', 'bcc', 'subject', 'body' => $this->entityStringCriteria($attribute, $value, $comparator), + 'before' => $this->entityDateCriteria('before', $value, $comparator), + 'after' => $this->entityDateCriteria('after', $value, $comparator), + 'min' => $this->entitySizeCriteria('min', $value, $comparator), + 'max' => $this->entitySizeCriteria('max', $value, $comparator), + default => null, + }; } + private function entityStringCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null + { + $values = is_array($value) ? array_values($value) : [$value]; + $values = array_values(array_filter(array_map( + static fn (mixed $item): string => trim((string) $item), + $values, + ), static fn (string $item): bool => $item !== '')); + + if ($values === []) { + return null; + } + + $mapper = fn (string $item): SearchCriteriaBuilder => $this->entityStringCriterion($attribute, $item); + + return match ($comparator) { + FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE => count($values) === 1 + ? $mapper($values[0]) + : $this->entityOrCriteria(array_map($mapper, $values)), + FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE => count($values) === 1 + ? SearchCriteriaBuilder::create()->not($mapper($values[0])) + : SearchCriteriaBuilder::create()->group($this->entityAndCriteria(array_map( + static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)), + $values, + ))), + FilterComparisonOperator::IN => $this->entityOrCriteria(array_map($mapper, $values)), + FilterComparisonOperator::NIN => $this->entityAndCriteria(array_map( + static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)), + $values, + )), + default => null, + }; + } + + private function entityStringCriterion(string $attribute, string $value): SearchCriteriaBuilder + { + return match ($attribute) { + 'from' => SearchCriteriaBuilder::create()->from($value), + 'to' => SearchCriteriaBuilder::create()->to($value), + 'cc' => SearchCriteriaBuilder::create()->cc($value), + 'bcc' => SearchCriteriaBuilder::create()->bcc($value), + 'subject' => SearchCriteriaBuilder::create()->subject($value), + 'body' => SearchCriteriaBuilder::create()->body($value), + default => SearchCriteriaBuilder::create()->text($value), + }; + } + + private function entityDateCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null + { + if ($value === null || $value === '') { + return null; + } + + $date = $this->normalizeImapDate($value); + + return match ($attribute) { + 'before' => match ($comparator) { + FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date), + FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)), + FilterComparisonOperator::LT, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->before($date), + default => null, + }, + 'after' => match ($comparator) { + FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date), + FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)), + FilterComparisonOperator::GT, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->since($date), + default => null, + }, + default => null, + }; + } + + private function entitySizeCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null + { + if (!is_int($value) && !is_numeric($value)) { + return null; + } + + $size = max(0, (int) $value); + + return match ($attribute) { + 'min' => match ($comparator) { + FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)), + FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size), + FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size), + FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1), + FilterComparisonOperator::NEQ => $this->entityOrCriteria([ + SearchCriteriaBuilder::create()->smaller($size), + SearchCriteriaBuilder::create()->larger($size), + ]), + default => null, + }, + 'max' => match ($comparator) { + FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1), + FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size), + FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size), + FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)), + FilterComparisonOperator::NEQ => $this->entityOrCriteria([ + SearchCriteriaBuilder::create()->smaller($size), + SearchCriteriaBuilder::create()->larger($size), + ]), + default => null, + }, + default => null, + }; + } + + /** + * @return list + */ + private function entitySortCriteria(ISort $sort): array + { + $criteria = []; + + foreach ($sort->conditions() as $condition) { + $attribute = $condition['attribute'] ?? ''; + $key = match ($attribute) { + 'from' => 'FROM', + 'to' => 'TO', + 'subject' => 'SUBJECT', + 'received' => 'ARRIVAL', + 'sent' => 'DATE', + 'size' => 'SIZE', + default => null, + }; + + if ($key === null) { + return []; + } + + $criteria[] = !($condition['direction'] ?? true) ? 'REVERSE ' . $key : $key; + } + + return $criteria; + } + + /** + * @param list $uids + * @return list + */ + private function entitySortClientSide(array $uids, ISort $sort): array + { + $options = FetchOptions::summary(); + foreach ($sort->conditions() as $condition) { + if (in_array($condition['attribute'] ?? '', ['from', 'to', 'subject', 'sent'], true)) { + $options = $options->withEnvelope(); + break; + } + } + + $messages = iterator_to_array($this->client->perform(new FetchManyCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $options, + ))); + + usort($messages, function (Message $left, Message $right) use ($sort): int { + foreach ($sort->conditions() as $condition) { + $direction = ($condition['direction'] ?? true) ? 1 : -1; + $comparison = $this->entityCompareMessages($left, $right, $condition['attribute'] ?? ''); + if ($comparison !== 0) { + return $comparison * $direction; + } + } + + return $left->uid() <=> $right->uid(); + }); + + return array_values(array_map( + static fn (Message $message): int => $message->uid(), + $messages, + )); + } + + private function entityCompareMessages(Message $left, Message $right, string $attribute): int + { + return match ($attribute) { + 'from' => $this->entityPrimaryAddressValue($left->from()) <=> $this->entityPrimaryAddressValue($right->from()), + 'to' => $this->entityPrimaryAddressValue($left->to()) <=> $this->entityPrimaryAddressValue($right->to()), + 'subject' => $this->entityScalarValue($left->subject()) <=> $this->entityScalarValue($right->subject()), + 'received' => $this->entityTimestampValue($left->internalDate()) <=> $this->entityTimestampValue($right->internalDate()), + 'sent' => $this->entityTimestampValue($left->sentAt()) <=> $this->entityTimestampValue($right->sentAt()), + 'size' => $left->size() <=> $right->size(), + default => 0, + }; + } + + /** + * @param list $uids + * @return list + */ + private function entityApplyRange(array $uids, ?IRange $range): array + { + if (!$range instanceof IRangeTally) { + return array_values($uids); + } + + $tally = max(0, $range->getTally()); + if ($tally === 0) { + return []; + } + + $start = 0; + if ($range->getAnchor() === RangeAnchorType::ABSOLUTE) { + $start = max(0, (int) $range->getPosition()); + } else { + $anchor = (int) $range->getPosition(); + $index = array_search($anchor, $uids, true); + $start = $index === false ? 0 : $index; + } + + return array_values(array_slice($uids, $start, $tally)); + } + + /** + * @param list $criteria + */ + private function entityOrCriteria(array $criteria): SearchCriteriaBuilder + { + $expression = array_shift($criteria); + if ($expression === null) { + return SearchCriteriaBuilder::create()->all(); + } + + foreach ($criteria as $criterion) { + $expression = SearchCriteriaBuilder::create()->or($expression, $criterion); + } + + return $expression; + } + + /** + * @param list $criteria + */ + private function entityAndCriteria(array $criteria): SearchCriteriaBuilder + { + $expression = SearchCriteriaBuilder::create(); + + foreach ($criteria as $criterion) { + $expression->group($criterion); + } + + return $expression; + } + + private function normalizeImapDate(mixed $value): string + { + if ($value instanceof DateTimeImmutable) { + return $value->format('d-M-Y'); + } + + $stringValue = trim((string) $value); + if ($stringValue === '') { + throw new ImapException('Date filter values must not be empty.'); + } + + try { + return (new DateTimeImmutable($stringValue))->format('d-M-Y'); + } catch (\Exception) { + return $stringValue; + } + } + + /** + * @param list $addresses + */ + private function entityPrimaryAddressValue(array $addresses): string + { + $address = $addresses[0] ?? null; + if ($address === null) { + return ''; + } + + return strtolower(trim((string) ($address->email() ?? $address->name() ?? ''))); + } + + private function entityScalarValue(?string $value): string + { + return strtolower(trim((string) $value)); + } + + private function entityTimestampValue(?string $value): int + { + if ($value === null || trim($value) === '') { + return 0; + } + + try { + return (new DateTimeImmutable($value))->getTimestamp(); + } catch (\Exception) { + return 0; + } + } + + private function mailboxFilter(Mailbox $mailbox, IFilter $filter): bool + { + $result = null; + + foreach ($filter->conditions() as $condition) { + $attribute = $condition['attribute'] ?? ''; + if (!in_array($attribute, self::COLLECTION_FILTER_OPTIONS, true)) { + continue; + } + + $matches = match ($condition['attribute'] ?? '') { + 'name' => $this->mailboxFilterByName($mailbox, $condition), + 'role' => $this->mailboxFilterByRole($mailbox, $condition), + 'subscription' => $this->mailboxFilterBySubscription($mailbox, $condition), + default => false, + }; + if ($result === null) { + $result = $matches; + continue; + } + + $result = ($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR + ? ($result || $matches) + : ($result && $matches); + } + + return $result ?? true; + } + + /** + * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition + */ + private function mailboxFilterByName(Mailbox $mailbox, array $condition): bool + { + $actualValue = $mailbox->name(); + $expectedValue = $condition['value']; + $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; + + return match ($comparator) { + FilterComparisonOperator::EQ => $actualValue === $expectedValue, + FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, + FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true), + FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true), + FilterComparisonOperator::LIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) === 1, + FilterComparisonOperator::NLIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) !== 1, + default => false, + }; + } + + /** + * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition + */ + private function mailboxFilterByRole(Mailbox $mailbox, array $condition): bool + { + $actualValue = $this->mailboxRole($mailbox); + $expectedValue = $condition['value']; + $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; + + return match ($comparator) { + FilterComparisonOperator::EQ => $actualValue === $expectedValue, + FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, + FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true), + FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true), + default => false, + }; + } + + /** + * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition + */ + private function mailboxFilterBySubscription(Mailbox $mailbox, array $condition): bool + { + $actualValue = in_array('\\SUBSCRIBED', $mailbox->attributes(), true) || in_array('\\Subscribed', $mailbox->attributes(), true); + $expectedValue = $condition['value']; + $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; + + return match ($comparator) { + FilterComparisonOperator::EQ => $actualValue === $expectedValue, + FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, + default => false, + }; + } + + private function mailboxRole(Mailbox $mailbox): string + { + foreach ($mailbox->attributes() as $attribute) { + $role = match (strtolower($attribute)) { + '\\sent' => CollectionRoles::Sent, + '\\trash' => CollectionRoles::Trash, + '\\drafts' => CollectionRoles::Drafts, + '\\junk' => CollectionRoles::Junk, + '\\archive' => CollectionRoles::Archive, + default => null, + }; + + if ($role !== null) { + return $role->value; + } + } + + return CollectionRoles::None->value; + } } diff --git a/lib/Service/Remote/RemoteService.php b/lib/Service/Remote/RemoteService.php index 0cc0466..5e4011f 100644 --- a/lib/Service/Remote/RemoteService.php +++ b/lib/Service/Remote/RemoteService.php @@ -9,9 +9,9 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Service\Remote; -use Gricob\IMAP\Client; use KTXC\Server; use KTXC\Logger\PlainFileLogger; +use KTXM\ProviderImap\Client\Client; use KTXM\ProviderImap\Providers\Service; /** @@ -23,10 +23,7 @@ use KTXM\ProviderImap\Providers\Service; class RemoteService { /** - * Build a fully-configured IMAP client from a Service's location and identity. - * - * Handles STARTTLS: connects on plain TCP, sends STARTTLS, upgrades to TLS, - * then authenticates — all before returning the client. + * Build and bootstrap a fully-configured IMAP client from a Service. */ public static function freshClient(Service $service): Client { @@ -40,14 +37,13 @@ class RemoteService $logger = new PlainFileLogger($logDir . '/imap', $service->identifier()); } - $client = Client::create($location->toConfiguration(), $logger); - $client->connect(); + $config = $location->toConnectionConfig( + $identity?->getIdentity(), + $identity?->getSecret(), + ); - if ($location->getEncryption() === 'starttls') { - $client->startTls(); - } - - $client->logIn($identity->getIdentity(), $identity->getSecret()); + $client = new Client(logger: $logger); + $client->connect($config); return $client; }