From c0f51ddb184d0d92d0d11726ba6e3ddf3599b455 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 29 Aug 2026 12:31:08 +0200 Subject: [PATCH 1/4] feat: IMAP4rev2 implementation on src/ PSR-4 codebase --- README.md | 69 + doc/Horde/Imap/Client/UPGRADING.rst | 4 +- doc/POP3CAPABILITIES.md | 130 + doc/SEARCH_CACHE.md | 115 + doc/UPGRADING.md | 198 + doc/examples/imapclient.php | 186 + doc/examples/pop3client.php | 237 ++ lib/Horde/Imap/Client/Base.php | 3 +- src/AclRight.php | 7 +- src/Auth/AuthenticationChannel.php | 73 + src/Auth/ChannelEvent.php | 127 + src/Auth/ImapAuthChannel.php | 117 + src/Auth/SaslAuthenticator.php | 247 ++ src/Auth/SocketChannelBindingProvider.php | 65 + ...CapabilityInterface.php => Capability.php} | 11 +- src/CapabilityData.php | 163 + src/ConnectionConfig.php | 32 +- src/Event/AlertReceived.php | 6 +- src/Event/AuthenticationFailed.php | 6 +- src/Event/AuthenticationSucceeded.php | 6 +- src/Event/CacheDeleted.php | 6 +- src/Event/CacheRetrieved.php | 6 +- src/Event/CacheStored.php | 6 +- src/Event/CapabilityIgnored.php | 6 +- src/Event/CapabilityNegotiated.php | 6 +- src/Event/ConnectionClosed.php | 6 +- src/Event/ConnectionEstablished.php | 6 +- src/Event/DiagnosticEvent.php | 6 +- src/Event/ImapEvent.php | 6 +- src/Event/MailboxExpunged.php | 55 +- src/Event/MailboxSelected.php | 38 +- src/Event/SlowCommand.php | 6 +- src/Exception/AuthenticationException.php | 6 +- .../CapabilityNotSupportedException.php | 6 +- src/Exception/ConnectionException.php | 6 +- src/Exception/ImapProtocolException.php | 6 +- src/Exception/MailboxNotFoundException.php | 6 +- src/Exception/MailboxProtocolException.php | 6 +- src/Exception/Pop3ProtocolException.php | 6 +- src/Exception/ServerResponseException.php | 15 +- src/Exception/SyncException.php | 27 + src/Exception/WireEncodingException.php | 26 + src/FetchQueryFields.php | 161 + src/FilteredEventDispatcher.php | 7 +- src/ImapAcl.php | 154 + src/ImapAclAware.php | 15 +- src/ImapAclRights.php | 44 + src/ImapCacheStore.php | 373 ++ src/ImapCapability.php | 116 + src/ImapCapabilityNegotiator.php | 155 + src/ImapCapabilityParser.php | 61 + src/ImapClient.php | 3282 +++++++++++++++++ src/ImapCommand.php | 151 + src/ImapCommandResult.php | 35 + src/ImapCommandSegment.php | 53 + src/ImapCommandTag.php | 43 + src/ImapConnection.php | 144 + src/ImapEnvelope.php | 60 + src/ImapFetchParser.php | 599 +++ src/ImapFetchQuery.php | 307 ++ src/ImapFetchResult.php | 310 ++ src/ImapIdSet.php | 315 ++ src/ImapIdSetToken.php | 49 + src/ImapInteraction.php | 211 ++ src/ImapMailboxNameCodec.php | 42 + src/ImapMetadataAware.php | 7 +- src/ImapModifiedUtf7Codec.php | 41 + src/ImapNamespace.php | 67 + src/ImapNamespaceList.php | 101 + src/ImapPipeline.php | 68 + src/ImapProtocol.php | 9 +- src/ImapQresyncResult.php | 46 + src/ImapQuotaAware.php | 7 +- src/ImapResponse.php | 89 + src/ImapResponseCode.php | 41 + src/ImapResponseKind.php | 34 + src/ImapResponseParser.php | 143 + src/ImapResponseStatus.php | 59 + src/ImapSearchParser.php | 184 + src/ImapSearchQuery.php | 472 +++ src/ImapSearchResult.php | 63 + src/ImapSearchText.php | 51 + src/ImapStringClassification.php | 37 + src/ImapStringClassifier.php | 96 + src/ImapSyncResult.php | 39 + src/ImapThreadParser.php | 99 + src/ImapThreadResult.php | 160 + src/ImapTokenizer.php | 235 ++ src/ImapUtf8MailboxNameCodec.php | 36 + src/ImapVanishedParser.php | 73 + src/ImapWireAtom.php | 92 + src/ImapWireEncodable.php | 70 + src/ImapWireList.php | 111 + src/ImapWireMailbox.php | 75 + src/ImapWireNil.php | 50 + src/ImapWireNstring.php | 57 + src/ImapWireNumber.php | 63 + src/ImapWireString.php | 84 + src/MailboxListMode.php | 7 +- src/MailboxProtocol.php | 11 +- src/MessageContent.php | 19 +- src/MessageIdSet.php | 7 +- src/MessageMetadata.php | 9 +- src/NamespaceType.php | 34 + src/OpenMode.php | 7 +- src/ParsedAccess.php | 9 +- src/PartAccess.php | 23 +- src/PasswordInterface.php | 32 - src/Pop3AuthChannel.php | 90 + src/Pop3Capability.php | 34 + src/Pop3Client.php | 679 ++++ src/Pop3Connection.php | 137 + src/Pop3FetchQuery.php | 27 + src/Pop3IdSet.php | 95 + src/Pop3MessageData.php | 133 + src/Pop3ResponseKind.php | 34 + src/Pop3StatusLine.php | 52 + src/SearchResultType.php | 7 +- src/SecureMode.php | 9 +- src/SortCriteria.php | 7 +- src/SpecialUse.php | 7 +- src/StatusFlag.php | 40 + src/SyncCriteria.php | 30 + src/SystemFlag.php | 7 +- src/ThreadAlgorithm.php | 7 +- test/Integration/Base.php | 4 +- test/Integration/Imap.php | 8 +- test/Integration/ImapTest.php | 4 +- test/Integration/Pop3.php | 4 +- test/Integration/Pop3Test.php | 4 +- ...tyInterfaceTest.php => CapabilityTest.php} | 8 +- .../Integration/Src/ComposedInterfaceTest.php | 49 +- test/Integration/Src/ImapAclAwareTest.php | 8 +- test/Integration/Src/ImapProtocolTest.php | 10 +- test/Integration/Src/MessageContentTest.php | 25 +- test/Integration/Src/PartAccessTest.php | 23 +- .../Integration/Src/PasswordInterfaceTest.php | 38 - test/Stub/ClientSort.php | 4 +- test/Stub/DigestMD5.php | 4 +- test/Stub/Scram.php | 4 +- test/Stub/Socket.php | 4 +- test/Stub/Utf7imap.php | 4 +- test/Unit/AuthTest.php | 6 +- .../Base/BaseLogoutNullConnectionTest.php | 5 +- test/Unit/Base/DebugTest.php | 2 +- test/Unit/Base/MailboxTest.php | 6 +- test/Unit/Cache/CacheTest.php | 6 +- test/Unit/Cache/DbTest.php | 6 +- test/Unit/Cache/HashtableTest.php | 6 +- test/Unit/Cache/MongoTest.php | 6 +- test/Unit/Cache/TestBase.php | 6 +- test/Unit/Data/AclTest.php | 6 +- test/Unit/Data/Capability/ImapTest.php | 6 +- test/Unit/Data/CapabilityTest.php | 6 +- test/Unit/Data/Fetch/FetchPop3Test.php | 6 +- test/Unit/Data/Fetch/FetchTest.php | 6 +- test/Unit/Data/Fetch/TestBase.php | 6 +- .../Unit/Data/Format/Astring/NonasciiTest.php | 6 +- test/Unit/Data/Format/AstringTest.php | 6 +- test/Unit/Data/Format/AtomTest.php | 6 +- test/Unit/Data/Format/DateTest.php | 6 +- test/Unit/Data/Format/DateTimeTest.php | 6 +- test/Unit/Data/Format/ListTest.php | 6 +- .../Data/Format/Mailbox/ListMailboxTest.php | 6 +- .../Format/Mailbox/ListMailboxUtf8Test.php | 6 +- test/Unit/Data/Format/Mailbox/MailboxTest.php | 6 +- .../Data/Format/Mailbox/MailboxUtf8Test.php | 6 +- test/Unit/Data/Format/Mailbox/TestBase.php | 6 +- test/Unit/Data/Format/NilTest.php | 6 +- .../Unit/Data/Format/Nstring/NonasciiTest.php | 6 +- test/Unit/Data/Format/NstringTest.php | 6 +- test/Unit/Data/Format/NumberTest.php | 6 +- test/Unit/Data/Format/String/NonasciiTest.php | 6 +- test/Unit/Data/Format/String/TestBase.php | 6 +- test/Unit/Data/Format/StringTest.php | 6 +- test/Unit/Data/Format/TestBase.php | 6 +- test/Unit/Data/SearchCharsetTest.php | 6 +- test/Unit/Data/SearchCharsetUtf8Test.php | 6 +- test/Unit/Data/SubjectParseTest.php | 6 +- test/Unit/Data/ThreadTest.php | 6 +- test/Unit/DateTimeTest.php | 6 +- test/Unit/Fetch/Results/FetchPop3Test.php | 6 +- test/Unit/Fetch/Results/FetchTest.php | 6 +- test/Unit/Fetch/Results/TestBase.php | 6 +- test/Unit/Ids/Pop3Test.php | 6 +- test/Unit/IdsTest.php | 6 +- test/Unit/Interaction/CommandTest.php | 6 +- test/Unit/MailboxTest.php | 6 +- test/Unit/MapTest.php | 6 +- test/Unit/Namespace/DataTest.php | 6 +- test/Unit/Namespace/ListTest.php | 6 +- test/Unit/SearchTest.php | 6 +- test/Unit/Socket/ClientSortTest.php | 6 +- test/Unit/SocketTest.php | 6 +- test/Unit/SortTest.php | 6 +- test/Unit/Src/ArrayCache.php | 86 + test/Unit/Src/Auth/FakeServerChannel.php | 127 + .../Auth/InMemoryScramCredentialLookup.php | 42 + test/Unit/Src/Auth/SaslAuthenticatorTest.php | 538 +++ .../Auth/SingleShotFakeServerMechanism.php | 99 + .../Auth/SocketChannelBindingProviderTest.php | 126 + test/Unit/Src/CapabilityDataTest.php | 159 + .../Unit/Src/ConnectionConfigEdgeCaseTest.php | 41 +- test/Unit/Src/ConnectionConfigTest.php | 29 +- test/Unit/Src/EventHierarchyTest.php | 20 +- test/Unit/Src/ImapAuthChannelTest.php | 166 + test/Unit/Src/ImapCacheStoreTest.php | 190 + .../Unit/Src/ImapCapabilityNegotiatorTest.php | 178 + test/Unit/Src/ImapCapabilityParserTest.php | 69 + test/Unit/Src/ImapCapabilityTest.php | 145 + .../Src/ImapClientAppendExtensionsTest.php | 146 + test/Unit/Src/ImapClientBadCharsetTest.php | 98 + .../Src/ImapClientCacheIntegrationTest.php | 261 ++ test/Unit/Src/ImapClientDeferredGapsTest.php | 210 ++ test/Unit/Src/ImapClientExtensionsTest.php | 281 ++ .../Src/ImapClientMailboxManagementTest.php | 369 ++ test/Unit/Src/ImapClientMessageOpsTest.php | 347 ++ test/Unit/Src/ImapClientPipelineTest.php | 162 + test/Unit/Src/ImapClientSearchTest.php | 248 ++ test/Unit/Src/ImapClientSyncTest.php | 255 ++ test/Unit/Src/ImapClientTest.php | 497 +++ test/Unit/Src/ImapClientThreadSortTest.php | 228 ++ test/Unit/Src/ImapClientTokenSyncTest.php | 142 + test/Unit/Src/ImapCommandTagTest.php | 51 + test/Unit/Src/ImapCommandTest.php | 131 + test/Unit/Src/ImapConnectionTest.php | 164 + test/Unit/Src/ImapFetchParserTest.php | 274 ++ test/Unit/Src/ImapFetchQueryTest.php | 173 + test/Unit/Src/ImapFetchResultTest.php | 172 + test/Unit/Src/ImapIdSetTest.php | 245 ++ test/Unit/Src/ImapInteractionTest.php | 129 + test/Unit/Src/ImapMailboxNameCodecTest.php | 53 + test/Unit/Src/ImapPipelineTest.php | 68 + test/Unit/Src/ImapResponseParserTest.php | 172 + test/Unit/Src/ImapSearchQueryTest.php | 187 + test/Unit/Src/ImapTokenizerTest.php | 190 + test/Unit/Src/ImapWireValueTest.php | 194 + test/Unit/Src/InMemoryImapSocket.php | 114 + test/Unit/Src/InMemoryPop3Socket.php | 89 + test/Unit/Src/MailboxEventDispatchTest.php | 156 + test/Unit/Src/MailboxEventPayloadTest.php | 92 + test/Unit/Src/Pop3AuthChannelTest.php | 130 + test/Unit/Src/Pop3CapabilityTest.php | 33 + test/Unit/Src/Pop3ClientTest.php | 630 ++++ test/Unit/Src/Pop3ConnectionTest.php | 146 + test/Unit/Src/Pop3IdSetTest.php | 88 + test/Unit/Src/Pop3MessageDataTest.php | 102 + test/Unit/TokenizeTest.php | 6 +- test/Unit/Url/BaseTest.php | 6 +- test/Unit/Url/ImapDeprecatedTest.php | 6 +- test/Unit/Url/ImapRelativeTest.php | 6 +- test/Unit/Url/ImapTest.php | 6 +- test/Unit/Url/Pop3DeprecatedTest.php | 6 +- test/Unit/Url/Pop3Test.php | 6 +- test/Unit/Url/TestBase.php | 6 +- test/Unit/Utf7ConvertTest.php | 6 +- test/Unit/Xoauth2Test.php | 6 +- 257 files changed, 22222 insertions(+), 562 deletions(-) create mode 100644 README.md create mode 100644 doc/POP3CAPABILITIES.md create mode 100644 doc/SEARCH_CACHE.md create mode 100644 doc/UPGRADING.md create mode 100644 doc/examples/imapclient.php create mode 100644 doc/examples/pop3client.php create mode 100644 src/Auth/AuthenticationChannel.php create mode 100644 src/Auth/ChannelEvent.php create mode 100644 src/Auth/ImapAuthChannel.php create mode 100644 src/Auth/SaslAuthenticator.php create mode 100644 src/Auth/SocketChannelBindingProvider.php rename src/{CapabilityInterface.php => Capability.php} (74%) create mode 100644 src/CapabilityData.php create mode 100644 src/Exception/SyncException.php create mode 100644 src/Exception/WireEncodingException.php create mode 100644 src/FetchQueryFields.php create mode 100644 src/ImapAcl.php create mode 100644 src/ImapAclRights.php create mode 100644 src/ImapCacheStore.php create mode 100644 src/ImapCapability.php create mode 100644 src/ImapCapabilityNegotiator.php create mode 100644 src/ImapCapabilityParser.php create mode 100644 src/ImapClient.php create mode 100644 src/ImapCommand.php create mode 100644 src/ImapCommandResult.php create mode 100644 src/ImapCommandSegment.php create mode 100644 src/ImapCommandTag.php create mode 100644 src/ImapConnection.php create mode 100644 src/ImapEnvelope.php create mode 100644 src/ImapFetchParser.php create mode 100644 src/ImapFetchQuery.php create mode 100644 src/ImapFetchResult.php create mode 100644 src/ImapIdSet.php create mode 100644 src/ImapIdSetToken.php create mode 100644 src/ImapInteraction.php create mode 100644 src/ImapMailboxNameCodec.php create mode 100644 src/ImapModifiedUtf7Codec.php create mode 100644 src/ImapNamespace.php create mode 100644 src/ImapNamespaceList.php create mode 100644 src/ImapPipeline.php create mode 100644 src/ImapQresyncResult.php create mode 100644 src/ImapResponse.php create mode 100644 src/ImapResponseCode.php create mode 100644 src/ImapResponseKind.php create mode 100644 src/ImapResponseParser.php create mode 100644 src/ImapResponseStatus.php create mode 100644 src/ImapSearchParser.php create mode 100644 src/ImapSearchQuery.php create mode 100644 src/ImapSearchResult.php create mode 100644 src/ImapSearchText.php create mode 100644 src/ImapStringClassification.php create mode 100644 src/ImapStringClassifier.php create mode 100644 src/ImapSyncResult.php create mode 100644 src/ImapThreadParser.php create mode 100644 src/ImapThreadResult.php create mode 100644 src/ImapTokenizer.php create mode 100644 src/ImapUtf8MailboxNameCodec.php create mode 100644 src/ImapVanishedParser.php create mode 100644 src/ImapWireAtom.php create mode 100644 src/ImapWireEncodable.php create mode 100644 src/ImapWireList.php create mode 100644 src/ImapWireMailbox.php create mode 100644 src/ImapWireNil.php create mode 100644 src/ImapWireNstring.php create mode 100644 src/ImapWireNumber.php create mode 100644 src/ImapWireString.php create mode 100644 src/NamespaceType.php delete mode 100644 src/PasswordInterface.php create mode 100644 src/Pop3AuthChannel.php create mode 100644 src/Pop3Capability.php create mode 100644 src/Pop3Client.php create mode 100644 src/Pop3Connection.php create mode 100644 src/Pop3FetchQuery.php create mode 100644 src/Pop3IdSet.php create mode 100644 src/Pop3MessageData.php create mode 100644 src/Pop3ResponseKind.php create mode 100644 src/Pop3StatusLine.php create mode 100644 src/StatusFlag.php create mode 100644 src/SyncCriteria.php rename test/Integration/Src/{CapabilityInterfaceTest.php => CapabilityTest.php} (88%) delete mode 100644 test/Integration/Src/PasswordInterfaceTest.php create mode 100644 test/Unit/Src/ArrayCache.php create mode 100644 test/Unit/Src/Auth/FakeServerChannel.php create mode 100644 test/Unit/Src/Auth/InMemoryScramCredentialLookup.php create mode 100644 test/Unit/Src/Auth/SaslAuthenticatorTest.php create mode 100644 test/Unit/Src/Auth/SingleShotFakeServerMechanism.php create mode 100644 test/Unit/Src/Auth/SocketChannelBindingProviderTest.php create mode 100644 test/Unit/Src/CapabilityDataTest.php create mode 100644 test/Unit/Src/ImapAuthChannelTest.php create mode 100644 test/Unit/Src/ImapCacheStoreTest.php create mode 100644 test/Unit/Src/ImapCapabilityNegotiatorTest.php create mode 100644 test/Unit/Src/ImapCapabilityParserTest.php create mode 100644 test/Unit/Src/ImapCapabilityTest.php create mode 100644 test/Unit/Src/ImapClientAppendExtensionsTest.php create mode 100644 test/Unit/Src/ImapClientBadCharsetTest.php create mode 100644 test/Unit/Src/ImapClientCacheIntegrationTest.php create mode 100644 test/Unit/Src/ImapClientDeferredGapsTest.php create mode 100644 test/Unit/Src/ImapClientExtensionsTest.php create mode 100644 test/Unit/Src/ImapClientMailboxManagementTest.php create mode 100644 test/Unit/Src/ImapClientMessageOpsTest.php create mode 100644 test/Unit/Src/ImapClientPipelineTest.php create mode 100644 test/Unit/Src/ImapClientSearchTest.php create mode 100644 test/Unit/Src/ImapClientSyncTest.php create mode 100644 test/Unit/Src/ImapClientTest.php create mode 100644 test/Unit/Src/ImapClientThreadSortTest.php create mode 100644 test/Unit/Src/ImapClientTokenSyncTest.php create mode 100644 test/Unit/Src/ImapCommandTagTest.php create mode 100644 test/Unit/Src/ImapCommandTest.php create mode 100644 test/Unit/Src/ImapConnectionTest.php create mode 100644 test/Unit/Src/ImapFetchParserTest.php create mode 100644 test/Unit/Src/ImapFetchQueryTest.php create mode 100644 test/Unit/Src/ImapFetchResultTest.php create mode 100644 test/Unit/Src/ImapIdSetTest.php create mode 100644 test/Unit/Src/ImapInteractionTest.php create mode 100644 test/Unit/Src/ImapMailboxNameCodecTest.php create mode 100644 test/Unit/Src/ImapPipelineTest.php create mode 100644 test/Unit/Src/ImapResponseParserTest.php create mode 100644 test/Unit/Src/ImapSearchQueryTest.php create mode 100644 test/Unit/Src/ImapTokenizerTest.php create mode 100644 test/Unit/Src/ImapWireValueTest.php create mode 100644 test/Unit/Src/InMemoryImapSocket.php create mode 100644 test/Unit/Src/InMemoryPop3Socket.php create mode 100644 test/Unit/Src/MailboxEventDispatchTest.php create mode 100644 test/Unit/Src/MailboxEventPayloadTest.php create mode 100644 test/Unit/Src/Pop3AuthChannelTest.php create mode 100644 test/Unit/Src/Pop3CapabilityTest.php create mode 100644 test/Unit/Src/Pop3ClientTest.php create mode 100644 test/Unit/Src/Pop3ConnectionTest.php create mode 100644 test/Unit/Src/Pop3IdSetTest.php create mode 100644 test/Unit/Src/Pop3MessageDataTest.php diff --git a/README.md b/README.md new file mode 100644 index 00000000..7db696e2 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Horde_Imap_Client + +An IMAP4rev1 / IMAP4rev2 (RFC 3501 / RFC 9051) and POP3 (RFC 1939) client +library for PHP. + +The package ships two codebases side by side: + +- **`src/`** (namespace `Horde\Imap\Client`): the modern, PSR-4, PHP 8.2+ + rewrite. Small final classes and immutable value objects, built on + `Horde\Socket\Client` and `Horde\Sasl`. This is the recommended API for + new code. +- **`lib/`** (`Horde_Imap_Client_*`): the legacy PSR-0 engine, retained for + backward compatibility. Functional but not actively developed. + +## Modern usage + +```php +use Horde\Imap\Client\ConnectionConfig; +use Horde\Imap\Client\ImapClient; +use Horde\Imap\Client\ImapFetchQuery; +use Horde\Imap\Client\OpenMode; +use Horde\Imap\Client\SecureMode; +use Horde\Sasl\Credentials\PasswordCredentials; +use Horde\Sasl\Credentials\PlainSecret; + +$client = new ImapClient( + new ConnectionConfig(hostspec: 'imap.example.com', port: 993, secure: SecureMode::Ssl), + new PasswordCredentials('alice', new PlainSecret('secret')), +); + +$client->login(); +$client->openMailbox('INBOX', OpenMode::Readonly); + +$query = (new ImapFetchQuery())->envelope()->flags(); +foreach ($client->fetch('INBOX', $client->getIdsOb('1:*'), $query) as $uid => $msg) { + echo $uid . ': ' . $msg->getEnvelope()->subject . "\n"; +} + +$client->logout(); +``` + +`ImapClient` implements `ImapProtocol` (and `MailboxProtocol`), plus the +optional `ImapAclAware`, `ImapQuotaAware` and `ImapMetadataAware` +extension interfaces. `Pop3Client` implements `MailboxProtocol`. + +Message caching is opt-in: pass an `ImapCacheStore` (backed by any PSR-16 +cache) as the fifth `ImapClient` constructor argument and it is used +transparently across fetch, store and expunge. + +## Supported extensions + +STARTTLS, SASL (via `Horde\Sasl`), CAPABILITY/ENABLE, IMAP4rev2, +UTF8=ACCEPT, UIDPLUS, MOVE, CONDSTORE/QRESYNC, ESEARCH, SORT/ESORT, +THREAD, LIST-EXTENDED, LIST-STATUS, NAMESPACE, ACL, QUOTA, METADATA, +MULTIAPPEND, CATENATE, BINARY and SEARCH=FUZZY. + +## Documentation + +- `doc/UPGRADING.md`: migrating from the legacy `lib/` API to the modern + `src/` clients, with a class-mapping table and before/after examples. +- `doc/examples/imapclient.php`: a runnable, read-only IMAP demonstration + (capabilities, namespaces, mailbox list, status, fetch, search). +- `doc/examples/pop3client.php`: the equivalent POP3 demonstration. +- `doc/POP3CAPABILITIES.md`: what the POP3 client implements, what it + deliberately does not and a server-compatibility matrix. + +## License + +LGPL 2.1. See the enclosed `LICENSE` file. diff --git a/doc/Horde/Imap/Client/UPGRADING.rst b/doc/Horde/Imap/Client/UPGRADING.rst index e450c6d5..30f220f7 100644 --- a/doc/Horde/Imap/Client/UPGRADING.rst +++ b/doc/Horde/Imap/Client/UPGRADING.rst @@ -20,7 +20,7 @@ Upgrading to 3.0.0 Memcache 1.0 with poor multi-get; modern Redis with MGET/pipelining handles the sliced strategy of Backend_Cache equally well. Use Backend_Cache with Horde_Cache configured to wrap a HashTable storage - instead — single code path, consistent TTL/age handling. + instead. Single code path, consistent TTL/age handling. Existing IMP configurations setting ``cache.driver = 'hashtable'`` for the IMAP cache continue to work: the IMP wrapper transparently falls @@ -325,7 +325,7 @@ Upgrading to 2.9.0 - Horde_Imap_Client_Base - The 'cacheob', 'lifetime', and 'slicesize' parameters to the 'cache' + The 'cacheob', 'lifetime' and 'slicesize' parameters to the 'cache' constructor option have been deprecated. Use the 'backend' parameter instead. diff --git a/doc/POP3CAPABILITIES.md b/doc/POP3CAPABILITIES.md new file mode 100644 index 00000000..634aa289 --- /dev/null +++ b/doc/POP3CAPABILITIES.md @@ -0,0 +1,130 @@ +# POP3 Capabilities + +POP3 is a mostly frozen protocol. Few features were added after 2010. +Server support for those later features is thin. The protocol is in +maintenance mode with barely any active development. + +This document covers two things: + +1. What `Pop3Client` (the modern `src/` client) implements and what it + deliberately does not. +2. Which server products are likely to work with each capability. + +## RFC history + +- **RFC 1939** (May 1996, Standard). The core protocol: `USER`, `PASS`, + `APOP`, `STAT`, `LIST`, `RETR`, `DELE`, `NOOP`, `RSET`, `TOP`, `UIDL`, + `QUIT`. +- **RFC 2449** (November 1998, Proposed Standard). Adds `CAPA`, the + capability-discovery command. +- **RFC 2595** (June 1999, Proposed Standard). Adds `STLS`, the + STARTTLS-equivalent upgrade command. +- **RFC 5034** (July 2007, Proposed Standard). Adds the `AUTH` command + and SASL framing for POP3. +- **RFC 5721** (February 2010, Experimental). An early UTF-8 proposal. + It was later obsoleted by RFC 6856. +- **RFC 6186** (March 2011, Proposed Standard). DNS SRV records for + service discovery. This is a client/DNS concern, not a server + protocol feature. It updates RFC 1939 only in name. +- **RFC 6856** (March 2013, Proposed Standard). Supersedes RFC 5721. + Adds the `UTF8` command and a `UTF8` variant of the `USER` capability. + +## Practices that never became RFCs + +- **OAuth 2.0 / SASL XOAUTH2.** Not an RFC mechanism. Google introduced + it first; Microsoft added it to Exchange Online in 2020. Since + October 2022, Exchange Online requires it: basic auth no longer + works there. The client-credentials OAuth grant does not apply to + POP3; only delegated, user-context tokens do. +- **Microsoft NTLM over POP3** (`MS-POP3`/`MS-OXPOP3`). A proprietary + Microsoft mechanism, still usable on-premises but deprecated even + there. +- **Implicit TLS on port 995.** RFC 2595 expects `STLS` on port 110. + In practice, Google, Microsoft and Apple all standardized on + implicit TLS on 995 instead, with no plaintext fallback. +- **App-specific passwords.** A provisioning workaround from Google, + Apple and Yahoo for clients without OAuth2 support. It is not a + protocol change. It is just an ordinary password, so it needs no + special library support. +- **POP3 decline.** Gmail dropped "Check mail from other accounts" in + early 2026. Microsoft requires modern auth in Exchange Online. + Tutanota and ProtonMail never implemented POP3 natively (ProtonMail + offers a local bridge instead). +- **LIST+ draft.** An expired 2011 IETF draft + (`draft-lehmann-morg-pop3listplus`) that proposed richer `LIST` + metadata. It never became an RFC and saw no real adoption. + +## What this library implements + +| Capability | Source | Implemented | Notes | +|---|---|---|---| +| `USER`/`PASS` login | RFC 1939 | Yes | Fallback when no SASL mechanism fits. | +| `APOP` login | RFC 1939 | Yes | Used when the server greeting carries a timestamp. | +| `STAT`, `LIST`, `RETR`, `DELE`, `RSET`, `NOOP`, `QUIT` | RFC 1939 | Yes | Core mailbox operations. | +| `UIDL` | RFC 1939 | Yes | Drives UID-based fetch/store and `getIdsOb()`. | +| `TOP` | RFC 1939 | Yes | Used to fetch headers without a full `RETR`, with a `RETR` fallback. | +| `CAPA` | RFC 2449 | Yes | Queried once per connection, cached, cleared after STARTTLS. | +| `STLS` (STARTTLS) | RFC 2595 | Yes | Used when `SecureMode::Tls` is configured. | +| Implicit TLS on connect | Practice | Yes | Used when `SecureMode::Ssl` is configured (the default port is 995). | +| `AUTH` + SASL framing | RFC 5034 | Yes | Every mechanism below rides on this. | +| SASL PLAIN | RFC 4616 | Yes | | +| SASL LOGIN | De facto | Yes | Legacy fallback, still common. | +| SASL CRAM-MD5 | RFC 2195 | Yes | | +| SASL DIGEST-MD5 | RFC 2831 | Yes | Obsolete, kept for old servers. | +| SASL SCRAM-SHA-1/256/512 (+ `-PLUS`) | RFC 5802, RFC 7677, RFC 5056 | Yes | `-PLUS` variants use TLS channel binding. | +| SASL XOAUTH2 | Google practice | Yes | | +| SASL OAUTHBEARER | RFC 7628 | Yes | | +| SASL EXTERNAL | RFC 4422 §5.7 | Yes | TLS client-certificate auth. | +| SASL ANONYMOUS | RFC 4505 | Yes | | + +## What this library does not implement + +| Capability | Source | Why not | +|---|---|---| +| `UTF8` command, `UTF8 USER` | RFC 6856 | Almost no server advertises it. Add it if a target server needs it. | +| SRV-based autodiscovery | RFC 6186 | A DNS/client concern, not a wire-protocol capability. The caller resolves the host before constructing `ConnectionConfig`. | +| `LIST+` | Expired draft | Never standardized, no real server support. | +| NTLM (`MS-POP3`) | Microsoft proprietary | Proprietary and Microsoft-only. Deprecated even inside Exchange. | + +App-specific passwords need no library support. They are ordinary +passwords from the client's point of view. `PasswordCredentials` +already covers them. + +## Server compatibility matrix + +Columns show whether each server product is likely to accept the +capability. "Plugin" means the mechanism needs extra, non-default +server software. "No evidence" means public documentation does not +confirm support either way. + +| Capability | Dovecot | Cyrus IMAP | Exchange Online | Gmail | Stalwart | Courier-IMAP | +|---|---|---|---|---|---|---| +| `USER`/`PASS` | Yes | Yes | No (disabled Oct 2022) | No (disabled) | Yes | Yes | +| `APOP` | Yes | Yes | No | No | Yes | Yes | +| `CAPA` | Yes | Yes | Yes | Yes | Yes | Yes | +| `STLS` on port 110 | Yes | Yes | No (995 only) | No (995 only) | Yes | Yes | +| Implicit TLS on 995 | Yes | Yes | Yes | Yes | Yes | Yes | +| `TOP` | Yes | Yes | Yes | Yes | Yes | Yes | +| `UIDL` | Yes | Yes | Yes | Yes | Yes | Yes | +| SASL PLAIN | Yes | Yes | Yes | Yes | Yes | Yes | +| SASL LOGIN | Yes | Yes | Yes | Yes | Yes | Yes | +| SASL CRAM-MD5 | Yes | Yes | No | No | Yes | Yes | +| SASL DIGEST-MD5 | Yes | Yes | No | No | No evidence | Yes | +| SASL SCRAM-SHA-\* | Yes (2.3+) | Yes (recent) | No | No | Yes | No | +| SASL XOAUTH2 | Plugin | Plugin | Yes | Yes | Yes | No | +| SASL OAUTHBEARER | Plugin | Plugin | No evidence | No evidence | Yes | No | +| SASL EXTERNAL | Yes | Yes | No | No | Yes | No evidence | +| SASL ANONYMOUS | If configured | If configured | No | No | If configured | No | +| `UTF8` (RFC 6856) | No | No | No | No | Yes | No | + +## Bottom line + +Only one post-2010 feature has broad, real-world server support: +OAuth2/XOAUTH2. Even there, Dovecot and Cyrus need a third-party SASL +plugin; only Exchange Online, Gmail and Stalwart support it natively. +RFC 6856 (UTF-8) has barely spread past Stalwart. + +Everything this library implements maps onto RFC 1939, RFC 2449, +RFC 2595, RFC 5034, and the mechanisms in `horde/sasl`. That already +covers every server in the matrix above, at least for one working +authentication path each. diff --git a/doc/SEARCH_CACHE.md b/doc/SEARCH_CACHE.md new file mode 100644 index 00000000..54187417 --- /dev/null +++ b/doc/SEARCH_CACHE.md @@ -0,0 +1,115 @@ +# Caching search results + +The modern `Horde\Imap\Client\ImapClient` deliberately does **not** cache +`SEARCH` results. This document explains how an application can +build and correctly invalidate its own search cache using the signals the +client exposes. + +## Why the library does not cache searches + +`ImapClient` caches per-message data and per-mailbox metadata through the +optional `ImapCacheStore` for envelope, flags, structure, size and similar +immutable-per-UID fields. Those are protocol facts the client can +invalidate deterministically. When a UID is expunged it is dropped, when +`UIDVALIDITY` changes the mailbox cache is dropped. + +A `SEARCH` result set is different. It is a *derived query answer*. +Whether a cached answer is still acceptable is a policy decision that +depends on the consumer, not on the IMAP protocol. A message-list view, a +background indexer, and a filter rule each tolerate different staleness. A +library should not hardcode one invalidation policy on behalf of the consumer. + +Instead the client reports *what changed* and leaves the caching policy to the application. + +`ImapClient::search()` therefore always issues the command and returns a fresh `ImapSearchResult`. + +## What the client provides + +### Authoritative reconciliation: `sync()` + +`getSyncToken(string $mailbox): string` captures the mailbox state +(`UIDVALIDITY` + `HIGHESTMODSEQ` + `UIDNEXT`) as an opaque token. + +`sync(string $mailbox, string $token, array $criteria = []): ImapSyncResult` +diffs the current state against a stored token and returns: + +- `newMsgs`: UIDs that appeared since the token, +- `flagChanges`: UIDs whose flags changed, +- `vanished`: UIDs that were removed. + +A changed `UIDVALIDITY` (or a malformed token) raises `SyncException`, +meaning the UID space was reassigned and every cached result for that +mailbox is stale. + +This is the *authoritative* mechanism. Store the sync token beside each +cached search result. Before serving a cached result, call `sync()` and +decide, per your policy, whether the reported `vanished` / `flagChanges` +sets invalidate it. `sync()` also recovers from gaps (for example a +reconnect during which live events were missed). + +### Reactive signals: PSR-14 events + +If you pass a PSR-14 `EventDispatcherInterface` to the client constructor, +it dispatches typed domain events you can listen for to invalidate +immediately, without polling: + +- `Event\MailboxSelected`: A mailbox was opened. Fields: `mailbox`, + `uidvalidity`, `uidnext`, `highestmodseq`. A new `uidvalidity` for a + mailbox you have cached means drop everything for it. +- `Event\MailboxExpunged`: A messages were removed (a plain `EXPUNGE`, a + `UID EXPUNGE`, or the source side of a `MOVE`). Fields: `mailbox`, + `vanished` (an `ImapIdSet`), `uidvalidity`. + + Inspect `$event->vanished->isSequence()`: + - `false`: The set is UIDs (server reported `VANISHED` or you issued a + `UID EXPUNGE`). Intersect it with your cached result sets and drop only + the affected entries. + - `true`: The set is sequence numbers (a plain `EXPUNGE`). Sequence + numbers are not stable keys, so invalidate the mailbox's cached + searches conservatively. + +These events are plain `ImapEvent` subclasses (not `DiagnosticEvent`), so +the default `FilteredEventDispatcher` passes them through to your listeners. + +## Recommended pattern + +Combine both: Events for low-latency in-process invalidation and `sync()` as +the authoritative check that also covers missed events. + +```php +use Horde\Imap\Client\Event\MailboxExpunged; +use Horde\Imap\Client\Event\MailboxSelected; + +// 1. React to in-process mutations. +$listener = function (object $event) use ($searchCache): void { + if ($event instanceof MailboxExpunged) { + if ($event->vanished->isSequence()) { + $searchCache->invalidateMailbox($event->mailbox); + } else { + $searchCache->invalidateUids($event->mailbox, $event->vanished->toArray()); + } + } elseif ($event instanceof MailboxSelected) { + $searchCache->onUidValidity($event->mailbox, $event->uidvalidity); + } +}; + +// 2. Cache a search result together with a sync token. +$token = $client->getSyncToken('INBOX'); +$result = $client->search('INBOX', $query); +$searchCache->store('INBOX', $queryKey, $result->match->toArray(), $token); + +// 3. Before serving a cached result, reconcile against the server. +[$cachedUids, $token] = $searchCache->load('INBOX', $queryKey); +try { + $delta = $client->sync('INBOX', $token); + if ($delta->vanished->count() > 0 /* or your flag-change policy */) { + $searchCache->invalidate('INBOX', $queryKey); + } +} catch (\Horde\Imap\Client\Exception\SyncException $e) { + $searchCache->invalidateMailbox('INBOX'); // UIDVALIDITY changed. +} +``` + +The library owns none of `$searchCache`'s policy. It only tells you what +changed. You as the integrator decide on how aggressively you invalidate, what you key on and where you +store it. diff --git a/doc/UPGRADING.md b/doc/UPGRADING.md new file mode 100644 index 00000000..2c941e9f --- /dev/null +++ b/doc/UPGRADING.md @@ -0,0 +1,198 @@ +# Upgrading Horde_Imap_Client + +Contact: dev@lists.horde.org + +This lists the API changes between releases of the package. + +## Upgrading to 3.0.0 (src/ PSR-4 rewrite) + +Version 3.0 adds a modern PSR-4 codebase under `src/` (namespace +`Horde\Imap\Client`) alongside the legacy PSR-0 classes in `lib/`. Both +autoload roots are active at once. The `lib/` engine remains functional +and unchanged; integrators move to the new model at their own pace. + +The new model replaces the single `Horde_Imap_Client_Socket` god-class +and its `Horde_Imap_Client_Base` factory with a small set of focused, +final classes and immutable value objects. Two concrete clients are +provided: `ImapClient` (IMAP4rev1 / IMAP4rev2) and `Pop3Client` (POP3). + +### Constructing a client + +The untyped configuration array is replaced by a typed `ConnectionConfig` +value object and credentials by `Horde\Sasl` value objects. + +```php +// BEFORE (2.x) +$client = new Horde_Imap_Client_Socket([ + 'username' => 'alice', + 'password' => 'secret', + 'hostspec' => 'imap.example.com', + 'port' => 993, + 'secure' => 'ssl', +]); +$client->login(); + +// AFTER (3.x) +use Horde\Imap\Client\ConnectionConfig; +use Horde\Imap\Client\ImapClient; +use Horde\Imap\Client\SecureMode; +use Horde\Sasl\Credentials\PasswordCredentials; +use Horde\Sasl\Credentials\PlainSecret; + +$config = new ConnectionConfig( + hostspec: 'imap.example.com', + port: 993, + secure: SecureMode::Ssl, +); +$credentials = new PasswordCredentials('alice', new PlainSecret('secret')); + +$client = new ImapClient($config, $credentials); +$client->login(); +``` + +### Class mapping + +| Legacy (lib/) | Modern (src/) | +|---|---| +| `Horde_Imap_Client_Socket` | `Horde\Imap\Client\ImapClient` | +| `Horde_Imap_Client_Socket_Pop3` | `Horde\Imap\Client\Pop3Client` | +| `Horde_Imap_Client_Base` (config array) | `Horde\Imap\Client\ConnectionConfig` | +| `Horde_Imap_Client_Ids` | `Horde\Imap\Client\ImapIdSet` / `Pop3IdSet` | +| `Horde_Imap_Client_Search_Query` | `Horde\Imap\Client\ImapSearchQuery` | +| `Horde_Imap_Client_Fetch_Query` | `Horde\Imap\Client\ImapFetchQuery` / `Pop3FetchQuery` | +| `Horde_Imap_Client_Data_Fetch` | `Horde\Imap\Client\ImapFetchResult` | +| `Horde_Imap_Client_Data_Envelope` | `Horde\Imap\Client\ImapEnvelope` | +| `Horde_Imap_Client_Data_Thread` | `Horde\Imap\Client\ImapThreadResult` | +| `Horde_Imap_Client_Data_Namespace` / `_Namespace_List` | `Horde\Imap\Client\ImapNamespace` / `ImapNamespaceList` | +| `Horde_Imap_Client_Data_Acl` / `_AclRights` | `Horde\Imap\Client\ImapAcl` / `ImapAclRights` | +| `Horde_Imap_Client_Data_Capability_Imap` | `Horde\Imap\Client\ImapCapability` | +| `Horde_Imap_Client_Cache` + `Cache_Backend_*` | `Horde\Imap\Client\ImapCacheStore` (PSR-16 backed) | +| *(none)* | `Horde\Imap\Client\ImapSyncResult` / `SyncCriteria` (new) | +| *(none)* | `Horde\Imap\Client\ImapQresyncResult` (new) | + +### Enums replace integer/string constants + +The `Horde_Imap_Client::*` constant groups become backed enums, at the +same underlying values for a mechanical migration: + +| Legacy constants | Modern enum | +|---|---| +| `OPEN_READONLY` / `OPEN_READWRITE` / `OPEN_AUTO` | `OpenMode` | +| `MBOX_SUBSCRIBED` / `MBOX_ALL` / ... | `MailboxListMode` | +| `STATUS_MESSAGES` / `STATUS_UNSEEN` / ... | `StatusFlag` | +| `SORT_ARRIVAL` / `SORT_DATE` / ... | `SortCriteria` | +| `SEARCH_RESULTS_COUNT` / `_MATCH` / ... | `SearchResultType` | +| `THREAD_ORDEREDSUBJECT` / `_REFERENCES` / `_REFS` | `ThreadAlgorithm` | +| `FLAG_SEEN` / `FLAG_DELETED` / ... | `SystemFlag` | +| `SPECIALUSE_SENT` / `_DRAFTS` / ... | `SpecialUse` | +| `ACL_LOOKUP` / `_READ` / ... | `AclRight` | + +### Result objects and IDs + +Fetch results implement the `MessageMetadata`, `MessageContent`, +`PartAccess` and `ParsedAccess` interfaces and reuse the `Horde\Mime` and +`Horde\Mail` value objects (`getStructure()` returns a `Horde\Mime\Part`, +`getEnvelope()` an `ImapEnvelope`). Message ID sets are `ImapIdSet` +(range-aware) built through `getIdsOb()`, replacing `Horde_Imap_Client_Ids`. + +```php +// AFTER (3.x): fetch envelopes and flags +use Horde\Imap\Client\ImapFetchQuery; +use Horde\Imap\Client\OpenMode; + +$client->openMailbox('INBOX', OpenMode::Readonly); +$query = (new ImapFetchQuery())->envelope()->flags()->size(); + +foreach ($client->fetch('INBOX', $client->getIdsOb('1:*'), $query) as $uid => $msg) { + echo $uid . ': ' . $msg->getEnvelope()->subject . "\n"; +} +``` + +### Caching + +The `Horde_Imap_Client_Cache_Backend_*` hierarchy (Db, Hashtable, Mongo, +Cache, Null) and the `Horde_Imap_Client_Cache` orchestrator are replaced +by a single `ImapCacheStore` backed by any PSR-16 `CacheInterface`. +Caching is opt-in: pass an `ImapCacheStore` as the fifth `ImapClient` +constructor argument and it is used transparently across `fetch()`, +`store()`, `expunge()` and `deleteMailbox()`. Which storage engine backs +it (SQL, Redis, file, ...) is now the PSR-16 implementation's concern, not +this library's. If you want a SQL-indexed IMAP cache, supply a SQL-backed +`CacheInterface`. + +### Deliberately dropped + +- The `Serializable` interface throughout: value objects use + `__serialize`/`__unserialize` only. +- The `cclient` (C-library) driver: removed in 2.0 already, not carried + forward. +- The client-side `SORT`/`THREAD` fallbacks and the client-side + `ORDEREDSUBJECT` threading: a server that lacks `SORT` / `THREAD=` + now raises `CapabilityNotSupportedException` rather than sorting in PHP. +- The `ANNOTATEMORE` / `ANNOTATEMORE2` metadata fallback: superseded by + RFC 5464 `METADATA`, which every maintained server speaks. +- The Cyrus 2.4.7 (2011) broken-ESEARCH workaround. + +### Deprecation timeline + +The `lib/` classes (`Horde_Imap_Client_*`) remain functional in 3.x but +are not actively maintained. They will be removed in a future major +version. + +--- + +## Legacy release history (2.x and 1.x, lib/ API) + +The entries below are the pre-3.0 `lib/` API changes, preserved from the +former `doc/Horde/Imap/Client/UPGRADING.rst`. + +### Upgrading to 3.0.0 (lib/) + +- `Horde_Imap_Client_Cache_Backend_Hashtable`: Deprecated. The per-UID + HashTable storage strategy was justified for Memcache 1.0 with poor + multi-get; modern Redis with MGET/pipelining handles the sliced strategy + of `Backend_Cache` equally well. Use `Backend_Cache` with `Horde_Cache` + configured to wrap a HashTable storage instead. Existing IMP + configurations setting `cache.driver = 'hashtable'` continue to work + (the IMP wrapper falls through to `Backend_Cache` during the deprecation + period); update to `cache.driver = 'cache'` when convenient. + +### Upgrading to 2.29.0 + +- SCRAM-SHA-1 authentication is now supported by both the IMAP and POP3 + drivers (`Horde_Imap_Client_Auth_Scram` added). + +### Upgrading to 2.21.0 through 2.28.0 + +- Incremental additions across the 2.2x line: `capability`, + `search_charset` and `url` properties on the base object; namespace list + objects; the alerts observer; non-ASCII `Data_Format` classes; and + command `on_error`/`on_success`/`pipeline` handling. + +### Upgrading to 2.1.0 through 2.20.0 + +- XOAUTH2 token support; TLS options; cache backend refactors; and the + deprecation of `capability()`, `queryCapability()`, `statusMultiple()` + and `getCacheId()`. + +### Upgrading to 2.0.0 + +- The `cclient` drivers were removed; instantiate the socket driver + directly instead of via `factory()` (also removed). +- Exception logging was removed. +- Mailbox, source and destination parameters can no longer be + UTF7-IMAP strings across the ~30 affected `Horde_Imap_Client_Base` + methods; pass `Horde_Imap_Client_Mailbox` objects or UTF-8. +- Results are returned as objects (`Fetch_Results`, `Ids`, `Mailbox`, + `Rfc822_List`). +- The `Utils`, `Sort` and `Utils_Pop3` classes were removed. + +### Upgrading to 1.1.0 through 1.5.0 + +- 1.2.0 introduced `Horde_Imap_Client_Mailbox` objects, required UTF-8 + (deprecating auto-detection), and added `getIdsOb()` and the `Ids` / + `Mailbox` / `Ids_Pop3` objects. +- 1.1.0 added the decoded envelope properties. + +The complete, unabridged pre-3.0 history remains available in the git +history of `doc/Horde/Imap/Client/UPGRADING.rst`. diff --git a/doc/examples/imapclient.php b/doc/examples/imapclient.php new file mode 100644 index 00000000..2dab5343 --- /dev/null +++ b/doc/examples/imapclient.php @@ -0,0 +1,186 @@ +#!/usr/bin/env php + --user [--pass ] + * [--port ] [--secure ssl|tls|none] [--limit ] + * + * The password is taken from --pass, else the IMAP_PASSWORD environment + * variable, else an interactive (non-echoing) prompt. + * + * Example, Dovecot over implicit TLS: + * imapclient.php --host imap.example.com --user alice --secure ssl + * + * Example, STARTTLS on the submission port: + * imapclient.php --host imap.example.com --user alice --port 143 --secure tls + */ + +require_once __DIR__ . '/../../vendor/autoload.php'; + +use Horde\Imap\Client\ConnectionConfig; +use Horde\Imap\Client\Exception\AuthenticationException; +use Horde\Imap\Client\Exception\ConnectionException; +use Horde\Imap\Client\Exception\ImapProtocolException; +use Horde\Imap\Client\ImapClient; +use Horde\Imap\Client\ImapFetchQuery; +use Horde\Imap\Client\ImapSearchQuery; +use Horde\Imap\Client\MailboxListMode; +use Horde\Imap\Client\OpenMode; +use Horde\Imap\Client\SecureMode; +use Horde\Imap\Client\StatusFlag; +use Horde\Sasl\Credentials\PasswordCredentials; +use Horde\Sasl\Credentials\PlainSecret; +use Horde\Sasl\Negotiation\SaslPolicy; + +/** + * Print usage and exit. + */ +function usageAndExit(): never +{ + fwrite(STDERR, "Usage: imapclient.php --host --user [--pass ]\n"); + fwrite(STDERR, " [--port ] [--secure ssl|tls|none] [--limit ]\n"); + exit(1); +} + +/** + * Read a password from the terminal without echoing it. + */ +function readPasswordInteractively(): string +{ + fwrite(STDOUT, 'IMAP password: '); + shell_exec('stty -echo'); + $password = rtrim((string) fgets(STDIN), "\n"); + shell_exec('stty echo'); + fwrite(STDOUT, "\n"); + + return $password; +} + +$options = getopt('', ['host:', 'user:', 'pass::', 'port::', 'secure::', 'limit::']); + +$host = $options['host'] ?? null; +$user = $options['user'] ?? null; + +if ($host === null || $user === null) { + usageAndExit(); +} + +$password = $options['pass'] ?? getenv('IMAP_PASSWORD'); +if ($password === false || $password === '') { + $password = readPasswordInteractively(); +} + +$port = isset($options['port']) ? (int) $options['port'] : null; +$limit = isset($options['limit']) ? max(1, (int) $options['limit']) : 5; + +$secure = match ($options['secure'] ?? 'ssl') { + 'tls' => SecureMode::Tls, + 'none' => SecureMode::None, + default => SecureMode::Ssl, +}; + +$config = new ConnectionConfig( + hostspec: (string) $host, + port: $port, + secure: $secure, + // Use SaslPolicy::legacyCompatible() instead for older servers. + saslPolicy: SaslPolicy::secureDefaults(), +); + +$credentials = new PasswordCredentials((string) $user, new PlainSecret((string) $password)); + +$client = new ImapClient($config, $credentials); + +try { + $client->login(); + + // Capabilities. + $capability = $client->getCapability(); + echo 'Connected to ' . $host . "\n"; + echo 'IMAP4rev2: ' . ($capability->query('IMAP4REV2') ? 'yes' : 'no') . "\n"; + echo 'CONDSTORE: ' . ($capability->query('CONDSTORE') ? 'yes' : 'no') . "\n"; + echo 'QRESYNC: ' . ($capability->query('QRESYNC') ? 'yes' : 'no') . "\n\n"; + + // Namespaces (RFC 2342). + $namespaces = $client->getNamespaces(); + echo 'Namespaces: ' . count($namespaces) . "\n"; + foreach ($namespaces as $namespace) { + printf(" %-20s delimiter=%s\n", '"' . $namespace->name . '"', $namespace->delimiter ?? 'NIL'); + } + echo "\n"; + + // Mailbox list (top level). + $mailboxes = $client->listMailboxes('%', MailboxListMode::All); + echo 'Mailboxes (top level): ' . count($mailboxes) . "\n"; + foreach ($mailboxes as $entry) { + echo ' ' . $entry['mailbox'] . "\n"; + } + echo "\n"; + + // INBOX status. + $status = $client->status('INBOX', StatusFlag::Messages->value | StatusFlag::Unseen->value); + printf("INBOX: %d messages, %d unseen\n\n", $status->messages ?? 0, $status->unseen ?? 0); + + // Open INBOX read-only and fetch a few envelopes + flags. + $client->openMailbox('INBOX', OpenMode::Readonly); + $query = (new ImapFetchQuery())->envelope()->flags()->size(); + + printf("%-8s %-30s %-8s %s\n", 'UID', 'Subject', 'Size', 'Flags'); + echo str_repeat('-', 70) . "\n"; + + $count = 0; + foreach ($client->fetch('INBOX', $client->getIdsOb('1:' . $limit), $query) as $uid => $message) { + $subject = $message->getEnvelope()->subject; + printf( + "%-8s %-30s %-8d %s\n", + (string) $uid, + mb_strimwidth($subject === '' ? '(no subject)' : $subject, 0, 30), + $message->getSize(), + implode(' ', $message->getFlags()), + ); + $count++; + } + + if ($count === 0) { + echo "(mailbox is empty)\n"; + } + + // A simple SEARCH: unseen messages. + $search = $client->search('INBOX', (new ImapSearchQuery())->flag('\\Seen', false)); + echo "\nUnseen message UIDs: " . implode(', ', $search->match->toArray() ?: ['(none)']) . "\n"; + + $client->logout(); +} catch (ConnectionException $e) { + fwrite(STDERR, 'Connection failed: ' . $e->getMessage() . "\n"); + exit(1); +} catch (AuthenticationException $e) { + fwrite(STDERR, 'Authentication failed: ' . $e->getMessage() . "\n"); + exit(1); +} catch (ImapProtocolException $e) { + fwrite(STDERR, 'IMAP error: ' . $e->getMessage() . "\n"); + exit(1); +} diff --git a/doc/examples/pop3client.php b/doc/examples/pop3client.php new file mode 100644 index 00000000..6476612e --- /dev/null +++ b/doc/examples/pop3client.php @@ -0,0 +1,237 @@ +#!/usr/bin/env php + SecureMode::Ssl, + 'tls' => SecureMode::Tls, + 'none' => SecureMode::None, + default => usageAndExit('--secure must be one of: ssl, tls, none'), +}; + +$password = $options['pass'] ?? getenv('POP3_PASSWORD') ?: readPasswordInteractively(); + +if ($password === '') { + usageAndExit('No password provided.'); +} + +$deleteUids = isset($options['delete']) + ? array_filter(array_map('trim', explode(',', (string) $options['delete']))) + : []; +$expunge = array_key_exists('expunge', $options); + +// Most public POP3 servers today (Gmail included) only offer SASL PLAIN +// over an already-TLS-secured connection which secureDefaults() already +// allows. Switch to SaslPolicy::legacyCompatible() here if you're +// talking to an old server that only offers PLAIN/LOGIN without TLS. +$config = new ConnectionConfig( + hostspec: (string) $host, + port: $port, + secure: $secure, + saslPolicy: SaslPolicy::secureDefaults(), +); + +$credentials = new PasswordCredentials((string) $user, new PlainSecret($password)); + +$client = new Pop3Client($config, $credentials); + +try { + echo "Connecting to {$host} as {$user}...\n"; + $client->login(); + echo "Logged in.\n\n"; + + $status = $client->status( + 'INBOX', + StatusFlag::Messages->value | StatusFlag::UidNext->value, + ); + echo "Mailbox: {$status->messages} message(s), next UID {$status->uidnext}.\n\n"; + + $query = (new Pop3FetchQuery())->headerText()->uid()->size()->seq()->imapDate(); + + $listing = []; + + if ($status->messages > 0) { + printf("%-6s %-10s %-8s %-20s %s\n", 'SEQ', 'UID', 'SIZE', 'DATE', 'SUBJECT'); + printf("%-6s %-10s %-8s %-20s %s\n", '---', '---', '----', '----', '-------'); + + foreach ($client->fetch('INBOX', $client->getIdsOb(), $query) as $uid => $message) { + $listing[] = $uid; + + $subject = 'no header'; + + foreach (explode("\r\n", (string) $message->getHeaderText()) as $line) { + if (stripos($line, 'Subject:') === 0) { + $subject = trim(substr($line, strlen('Subject:'))); + + break; + } + } + + printf( + "%-6s %-10s %-8d %-20s %s\n", + (string) $message->getSeq(), + (string) $uid, + $message->getSize(), + $message->getImapDate()->format('Y-m-d H:i'), + $subject, + ); + } + } else { + echo "Mailbox is empty.\n"; + } + + echo "\n"; + + if ($deleteUids !== []) { + $unknown = array_diff($deleteUids, array_map('strval', $listing)); + + if ($unknown !== []) { + usageAndExit('Unknown UID(s) in --delete: ' . implode(', ', $unknown)); + } + + echo 'Marking for deletion: ' . implode(', ', $deleteUids) . "\n"; + + $client->store('INBOX', [ + 'ids' => $client->getIdsOb($deleteUids), + 'add' => [SystemFlag::Deleted], + ]); + + if ($expunge) { + // POP3 has no partial expunge (RFC 1939 §5). Committing the + // deletions ends the session via QUIT. + $expunged = $client->expunge('INBOX', ['list' => true]); + echo 'Expunged (via QUIT): ' . implode(', ', array_map('strval', $expunged->toArray())) . "\n"; + + exit(0); + } + + // Without --expunge, undo the marks via RSET (RFC 1939 §5) making + // this run a safe dry run. QUIT would otherwise commit the + // deletion for real and POP3 has no way to undelete just some + // of the marked messages afterwards. + echo "Not committing (pass --expunge to actually delete). Undoing the marks via RSET.\n\n"; + $client->store('INBOX', ['remove' => [SystemFlag::Deleted]]); + } + + $client->logout(); + echo "Logged out.\n"; +} catch (ConnectionException $e) { + fwrite(STDERR, 'Connection failed: ' . $e->getMessage() . "\n"); + + exit(1); +} catch (AuthenticationException $e) { + fwrite(STDERR, 'Authentication failed: ' . $e->getMessage() . "\n"); + + exit(1); +} catch (Pop3ProtocolException $e) { + fwrite(STDERR, 'POP3 protocol error: ' . $e->getMessage() . "\n"); + + exit(1); +} diff --git a/lib/Horde/Imap/Client/Base.php b/lib/Horde/Imap/Client/Base.php index 422775ac..f662456c 100644 --- a/lib/Horde/Imap/Client/Base.php +++ b/lib/Horde/Imap/Client/Base.php @@ -743,8 +743,7 @@ public function getNamespaces( array $opts = [] ) { /* Only scalar/Stringable values are valid namespace names. Nested - * arrays (e.g. from a misconfigured IMP backends.php 'namespace' - * entry) must not be passed to strval() — that triggers + * arrays must not be passed to strval() as it triggers * "Array to string conversion" on PHP 8+. */ $normalized = []; foreach ($additional as $val) { diff --git a/src/AclRight.php b/src/AclRight.php index 579c4e91..d8baefdf 100644 --- a/src/AclRight.php +++ b/src/AclRight.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,8 @@ * Deprecated RFC 2086 rights 'c' and 'd' are deliberately omitted. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum AclRight: string diff --git a/src/Auth/AuthenticationChannel.php b/src/Auth/AuthenticationChannel.php new file mode 100644 index 00000000..a084f05b --- /dev/null +++ b/src/Auth/AuthenticationChannel.php @@ -0,0 +1,73 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface AuthenticationChannel +{ + /** + * Send the `AUTHENTICATE [initial-response]` command. + * + * @param string $mechanismName The IANA mechanism name (e.g. + * `SCRAM-SHA-256`). + * @param string|null $initialResponse Raw octets to inline as the + * initial response (SASL-IR, + * RFC 4959), or null to send none + * (server-first mechanism, or a + * server that lacks SASL-IR). + */ + public function sendAuthenticate(string $mechanismName, ?string $initialResponse): void; + + /** + * Read the next event: a continuation challenge or the tagged result. + */ + public function nextEvent(): ChannelEvent; + + /** + * Send a continuation response line. + * + * @param string $response Raw octets (empty string for a zero-length + * placeholder response). + */ + public function sendResponse(string $response): void; + + /** + * Abort the exchange client-side (the `*` continuation response). + * + * Sent when the mechanism rejects a challenge or the exchange protocol + * is violated, so the server can clean up its side before the + * connection is (typically) closed. + */ + public function cancel(): void; +} diff --git a/src/Auth/ChannelEvent.php b/src/Auth/ChannelEvent.php new file mode 100644 index 00000000..044fd883 --- /dev/null +++ b/src/Auth/ChannelEvent.php @@ -0,0 +1,127 @@ +` continuation (a challenge, or the + * final additional-data round. The two are wire-indistinguishable, see + * {@see SaslAuthenticator}) or a tagged final result (`OK`/`NO`/`BAD`). + * This value object represents whichever one {@see AuthenticationChannel} + * read next, already un-base64'd and stripped of framing. All payloads are + * raw octets matching horde/Sasl's own {@see \Horde\Sasl\Data\Challenge} + * convention. + * + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ChannelEvent +{ + private function __construct( + private readonly bool $isChallenge, + private readonly string $payload, + private readonly bool $success, + private readonly ?string $responseCode, + private readonly string $text, + ) {} + + /** + * A `+ ` continuation line. + * + * @param string $payload Raw (already base64-decoded) octets. + */ + public static function challenge(string $payload): self + { + return new self(true, $payload, false, null, ''); + } + + /** + * A tagged successful result (`OK`). + * + * @param string|null $responseCode The IMAP response code, if any + * (e.g. `CAPABILITY ...`), without the + * enclosing brackets. + * @param string $text The human-readable response text. + */ + public static function success(?string $responseCode = null, string $text = ''): self + { + return new self(false, '', true, $responseCode, $text); + } + + /** + * A tagged failure result (`NO`/`BAD`). + * + * @param string|null $responseCode The IMAP response code, if any + * (e.g. `AUTHENTICATIONFAILED`). + * @param string $text The human-readable response text. + */ + public static function failure(?string $responseCode = null, string $text = ''): self + { + return new self(false, '', false, $responseCode, $text); + } + + /** + * Whether this event is a continuation challenge (as opposed to a + * tagged final result). + */ + public function isChallenge(): bool + { + return $this->isChallenge; + } + + /** + * Whether this event is a tagged final result (as opposed to a + * continuation challenge). + */ + public function isOutcome(): bool + { + return !$this->isChallenge; + } + + /** + * The challenge payload. Empty for outcome events. + */ + public function payload(): string + { + return $this->payload; + } + + /** + * Whether a tagged outcome was `OK`. Meaningless for challenge events. + */ + public function isSuccess(): bool + { + return $this->success; + } + + /** + * The IMAP response code carried by a tagged outcome, if any. + */ + public function responseCode(): ?string + { + return $this->responseCode; + } + + /** + * The human-readable text carried by a tagged outcome. + */ + public function text(): string + { + return $this->text; + } +} diff --git a/src/Auth/ImapAuthChannel.php b/src/Auth/ImapAuthChannel.php new file mode 100644 index 00000000..4b3a75c5 --- /dev/null +++ b/src/Auth/ImapAuthChannel.php @@ -0,0 +1,117 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapAuthChannel implements AuthenticationChannel +{ + private readonly string $tag; + + public function __construct( + private readonly ImapConnection $connection, + ?ImapCommandTag $tags = null, + ) { + $this->tag = ($tags ?? new ImapCommandTag())->next(); + } + + public function sendAuthenticate(string $mechanismName, ?string $initialResponse): void + { + $arguments = [$mechanismName]; + + if ($initialResponse !== null) { + $arguments[] = $initialResponse === '' ? '=' : base64_encode($initialResponse); + } + + $this->connection->sendCommand(new ImapCommand($this->tag, 'AUTHENTICATE', $arguments)); + } + + /** + * @throws ImapProtocolException If a continuation carries malformed + * base64 data. + */ + public function nextEvent(): ChannelEvent + { + while (true) { + $response = $this->connection->readResponse(); + + if ($response->isContinuation()) { + $decoded = base64_decode($response->text, true); + + if ($decoded === false) { + throw new ImapProtocolException( + 'Server sent a malformed base64 continuation.', + ); + } + + return ChannelEvent::challenge($decoded); + } + + if ($response->isUntagged()) { + // Unsolicited data mid-exchange (usually an alert). + // Nothing in this channel's contract has room to + // surface it and skipping it is harmless. + continue; + } + + // The only tagged response this exchange can produce is + // the one for its own AUTHENTICATE command. + return $response->isOk() + ? ChannelEvent::success($response->responseCode?->name, $response->text) + : ChannelEvent::failure($response->responseCode?->name, $response->text); + } + } + + /** + * A zero-length response is a bare empty base64 line, never the + * `=` shorthand. That shorthand is reserved for `AUTHENTICATE`'s + * own initial-response argument (RFC 4959 §3). + */ + public function sendResponse(string $response): void + { + $this->connection->writeLine(base64_encode($response)); + } + + public function cancel(): void + { + $this->connection->writeLine('*'); + } +} diff --git a/src/Auth/SaslAuthenticator.php b/src/Auth/SaslAuthenticator.php new file mode 100644 index 00000000..0ab1de52 --- /dev/null +++ b/src/Auth/SaslAuthenticator.php @@ -0,0 +1,247 @@ +` continuation. This is resolved with a static, + * mechanism-intrinsic fact (the SCRAM family and DIGEST-MD5 expect exactly + * one more continuation after their terminal `step()` call. Every other + * mechanism does not), kept as a small internal lookup table rather than a + * `horde/Sasl` interface addition + * + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class SaslAuthenticator +{ + /** + * Mechanisms whose exchange delivers server-verification data via + * `consumeAdditionalData()` after their one-and-only `step()` call + * (RFC 5802 §3 SCRAM server signature). + */ + private const EXPECTS_ADDITIONAL_DATA = [ + MechanismName::ScramSha1, + MechanismName::ScramSha1Plus, + MechanismName::ScramSha256, + MechanismName::ScramSha256Plus, + MechanismName::ScramSha512, + MechanismName::ScramSha512Plus, + ]; + + private ?Credentials $credentials; + + public function __construct( + private readonly ConnectionConfig $config, + ?Credentials $credentials = null, + private readonly ?ChannelBindingProvider $binding = null, + private readonly ?EventDispatcherInterface $dispatcher = null, + private readonly ClientMechanismFactory $factory = new DefaultClientMechanismFactory(), + private readonly Negotiator $negotiator = new Negotiator(), + ) { + $this->credentials = $credentials; + } + + /** + * Run one full `AUTHENTICATE` exchange to completion. + * + * @param list $offeredMechanisms The mechanism names the server + * advertised (e.g. from + * `Capability::getParams('AUTH')`). + * @param bool $tlsActive Whether the transport currently + * has an active TLS session. + * @param Credentials|null $credentials Supply (or replace) the + * credential for this call. For + * STARTTLS-deferred auth or a + * refreshed token. Falls back to + * the credential supplied at + * construction if omitted. + * + * @throws AuthenticationException If no mechanism can be negotiated, the + * the server rejects the exchange, or the + * exchange is otherwise malformed. + */ + public function authenticate( + AuthenticationChannel $channel, + array $offeredMechanisms, + bool $tlsActive, + ?Credentials $credentials = null, + ): void { + if ($credentials !== null) { + $this->credentials = $credentials; + } + + if ($this->credentials === null) { + throw new AuthenticationException( + 'No credentials supplied: pass them to the constructor or to authenticate().' + ); + } + + $policy = $this->config->saslPolicy ?? SaslPolicy::secureDefaults(); + + [$mechanismName, $mechanism] = $this->negotiate($offeredMechanisms, $this->credentials, $policy, $tlsActive); + + $initial = $mechanism->initialResponse(); + $channel->sendAuthenticate( + $mechanismName->value, + $initial->hasData() ? $initial->octets() : null, + ); + + $this->runExchange($channel, $mechanism, $mechanismName); + } + + /** + * @return array{0: MechanismName, 1: ClientMechanism} + */ + private function negotiate( + array $offeredMechanisms, + Credentials $credentials, + SaslPolicy $policy, + bool $tlsActive, + ): array { + try { + $mechanismName = $this->negotiator->select( + $offeredMechanisms, + $this->factory, + $credentials, + $policy, + $tlsActive, + // Downgrade-attack (TOFU pinning) detection deferred. No + // persistence point exists yet. + null, + ); + + $binding = $mechanismName->usesChannelBinding() ? $this->binding : null; + $mechanism = $this->factory->create($mechanismName, $credentials, $binding); + } catch (UnsupportedMechanismException | PolicyViolationException | DowngradeDetectedException $e) { + $this->dispatch(new AuthenticationFailed($e->getMessage())); + + throw new AuthenticationException($e->getMessage(), 0, $e); + } + + return [$mechanismName, $mechanism]; + } + + private function runExchange( + AuthenticationChannel $channel, + ClientMechanism $mechanism, + MechanismName $mechanismName, + ): void { + $awaitingAdditionalData = false; + + try { + while (true) { + $event = $channel->nextEvent(); + + if ($event->isOutcome()) { + if ($event->isSuccess() && $awaitingAdditionalData) { + // The server skipped the additional-data round + // (RFC 5802's server-final message) and jumped + // straight to OK. The server signature was never + // verified and must be treated as untrusted (potential MITM). + throw new AuthenticationFailedException( + 'Server reported success without sending the expected additional-data' + . ' round. Its proof could not be verified.' + ); + } + + $this->concludeExchange($event, $mechanismName); + + return; + } + + if ($awaitingAdditionalData) { + $mechanism->consumeAdditionalData(AdditionalData::bytes($event->payload())); + $channel->sendResponse(''); + $awaitingAdditionalData = false; + + continue; + } + + // Every mechanism's own step() already rejects an unexpected + // continuation with its most specific exception. A plain + // MechanismException for PLAIN/LOGIN/EXTERNAL/ANONYMOUS, but + // an AuthenticationFailedException carrying the server's JSON + // error detail for OAUTHBEARER/XOAUTH2 (RFC 7628 §3.1's + // failure challenge). A generic pre-guard here would swallow + // that detail before step() ever got to raise it. + $response = $mechanism->step(Challenge::bytes($event->payload())); + $channel->sendResponse($response->hasData() ? $response->octets() : ''); + + if (in_array($mechanismName, self::EXPECTS_ADDITIONAL_DATA, true)) { + // SCRAM's client mechanism sends exactly one step() + // response (the client-final message) and only + // reaches isComplete() after consumeAdditionalData() + // verifies the server signature. So the next + // continuation is always routed there unconditionally. + $awaitingAdditionalData = true; + } + } + } catch (MechanismException | AuthenticationFailedException $e) { + $channel->cancel(); + $this->dispatch(new AuthenticationFailed($e->getMessage())); + + throw new AuthenticationException($e->getMessage(), 0, $e); + } + } + + private function concludeExchange(ChannelEvent $event, MechanismName $mechanismName): void + { + if (!$event->isSuccess()) { + $this->dispatch(new AuthenticationFailed($event->text())); + + throw new AuthenticationException( + $event->text() !== '' ? $event->text() : 'Authentication failed.' + ); + } + + $this->dispatch(new AuthenticationSucceeded($mechanismName->value)); + } + + private function dispatch(object $event): void + { + $this->dispatcher?->dispatch($event); + } +} diff --git a/src/Auth/SocketChannelBindingProvider.php b/src/Auth/SocketChannelBindingProvider.php new file mode 100644 index 00000000..eea7a35f --- /dev/null +++ b/src/Auth/SocketChannelBindingProvider.php @@ -0,0 +1,65 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class SocketChannelBindingProvider implements ChannelBindingProvider +{ + public function __construct( + private readonly ClientInterface $client, + ) {} + + public function available(): array + { + return $this->client->supportsChannelBinding(SocketBindingType::TlsServerEndPoint) + ? [SaslBindingType::TlsServerEndPoint] + : []; + } + + public function bindingData(SaslBindingType $type): string + { + try { + return $this->client->channelBindingData(SocketBindingType::from($type->value)); + } catch (SocketChannelBindingException $e) { + // Re-thrown as horde/Sasl's own exception type so mechanisms + // (and their callers) only ever need to catch one hierarchy, + // never leak the horde/socket_client exception across the package + // boundary. + throw new SaslChannelBindingException($e->getMessage(), 0, $e); + } + } +} diff --git a/src/CapabilityInterface.php b/src/Capability.php similarity index 74% rename from src/CapabilityInterface.php rename to src/Capability.php index d0f0d711..1efb037c 100644 --- a/src/CapabilityInterface.php +++ b/src/Capability.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,14 +17,15 @@ /** * Protocol capability query interface. * - * Shared shape for IMAP and POP3 capabilities. Implementations are + * Shared interface for IMAP and POP3 capabilities. Implementations are * completely independent (ImapCapability is rich, Pop3Capability is simple). * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @author Ralf Lang + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -interface CapabilityInterface +interface Capability { /** * Query whether a capability (and optional parameter) is supported. diff --git a/src/CapabilityData.php b/src/CapabilityData.php new file mode 100644 index 00000000..97ff2ce2 --- /dev/null +++ b/src/CapabilityData.php @@ -0,0 +1,163 @@ + + * @author Ralf Lang + * @copyright 2014-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +trait CapabilityData +{ + /** + * @var array> + */ + protected array $data = []; + + /** + * Add a capability with optional parameters. + * + * Calling this again for an already-known capability merges the new + * parameters into the existing list rather than replacing it (a server + * response may list `AUTH=PLAIN` and `AUTH=LOGIN` as separate tokens + * that both belong under one `AUTH` capability). + * + * @param string|list|null $params + */ + public function add(string $capability, string|array|null $params = null): void + { + $capability = strtoupper($capability); + + if ($params === null) { + if (isset($this->data[$capability])) { + return; + } + + $this->data[$capability] = true; + + return; + } + + $params = array_map(strtoupper(...), is_array($params) ? $params : [$params]); + + $existing = $this->data[$capability] ?? null; + $this->data[$capability] = is_array($existing) ? [...$existing, ...$params] : $params; + } + + /** + * Remove a capability or just one/more of its parameters. + * + * @param string|list|null $params + */ + public function remove(string $capability, string|array|null $params = null): void + { + $capability = strtoupper($capability); + + if ($params === null) { + unset($this->data[$capability]); + + return; + } + + if (!isset($this->data[$capability])) { + return; + } + + $params = array_map(strtoupper(...), is_array($params) ? $params : [$params]); + $remaining = is_array($this->data[$capability]) + ? array_values(array_diff($this->data[$capability], $params)) + : []; + + if ($remaining === []) { + unset($this->data[$capability]); + } else { + $this->data[$capability] = $remaining; + } + } + + public function query(string $capability, ?string $parameter = null): bool + { + $capability = strtoupper($capability); + + if (!isset($this->data[$capability])) { + return false; + } + + if ($parameter === null) { + return true; + } + + $params = $this->data[$capability]; + + return is_array($params) && in_array(strtoupper($parameter), $params, true); + } + + /** + * @return list + */ + public function getParams(string $capability): array + { + $params = $this->data[strtoupper($capability)] ?? null; + + return is_array($params) ? $params : []; + } + + /** + * Raw capability data keyed by uppercased capability name. + * + * @return array> + */ + public function toArray(): array + { + return $this->data; + } + + /** + * @return array> + */ + public function __serialize(): array + { + return $this->data; + } + + /** + * @param array> $data + */ + public function __unserialize(array $data): void + { + $this->data = $data; + } +} diff --git a/src/ConnectionConfig.php b/src/ConnectionConfig.php index 3d3f9281..a6f7216b 100644 --- a/src/ConnectionConfig.php +++ b/src/ConnectionConfig.php @@ -3,36 +3,51 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ namespace Horde\Imap\Client; +use Horde\Sasl\Negotiation\SaslPolicy; + /** * Immutable connection configuration DTO. * * Replaces the associative array previously passed to - * Horde_Imap_Client_Base::__construct(). All values are stored; the - * TCP connection is deferred until the first real protocol call. + * Horde_Imap_Client_Base::__construct(). All values are stored. + * The TCP connection is deferred until the first real protocol call. * * When $port is null the implementation picks the conventional default - * (993 for SSL, 143 for plain/TLS IMAP; 995/110 for POP3). + * (993 for SSL, 143 for plain/TLS IMAP or 995/110 for POP3). + * + * Credentials are intentionally *not* part of this DTO. + * They have a different lifecycle and may need refreshing independent of connection + * parameters, e.g. OAuth token expiry or re-authentication after an + * IDLE drop. Thus they are supplied separately to the auth adapter either at + * construction or via a dedicated `authenticate()` call. See + * Horde\Sasl\Credentials. + * + * $saslPolicy governs which SASL mechanisms are acceptable for this + * connection. Defaults to SaslPolicy::secureDefaults(), which denies + * plaintext/weak mechanisms without TLS. Legacy servers that only + * offer PLAIN/LOGIN without TLS or only CRAM-MD5/DIGEST-MD5 require + * relaxing this via SaslPolicy::legacyCompatible() or a custom + * policy. The caller opts in explicitly. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ final class ConnectionConfig { public function __construct( - public readonly string $username, - public readonly string|PasswordInterface $password, public readonly string $hostspec = 'localhost', public readonly ?int $port = null, public readonly SecureMode $secure = SecureMode::None, @@ -42,5 +57,6 @@ public function __construct( public readonly array $capabilityIgnore = [], public readonly ?array $id = null, public readonly array $lang = [], + public readonly ?SaslPolicy $saslPolicy = null, ) {} } diff --git a/src/Event/AlertReceived.php b/src/Event/AlertReceived.php index ab9cf8b0..44ad23dd 100644 --- a/src/Event/AlertReceived.php +++ b/src/Event/AlertReceived.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched when the server sends an RFC 3501 section 7.1 ALERT response. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class AlertReceived extends ImapEvent {} diff --git a/src/Event/AuthenticationFailed.php b/src/Event/AuthenticationFailed.php index 3713cd76..69221129 100644 --- a/src/Event/AuthenticationFailed.php +++ b/src/Event/AuthenticationFailed.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after login failure. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class AuthenticationFailed extends ImapEvent {} diff --git a/src/Event/AuthenticationSucceeded.php b/src/Event/AuthenticationSucceeded.php index 1fd5d162..b17250d5 100644 --- a/src/Event/AuthenticationSucceeded.php +++ b/src/Event/AuthenticationSucceeded.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after successful login. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class AuthenticationSucceeded extends ImapEvent {} diff --git a/src/Event/CacheDeleted.php b/src/Event/CacheDeleted.php index 7c8f15ea..dbefb7e4 100644 --- a/src/Event/CacheDeleted.php +++ b/src/Event/CacheDeleted.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after cache entries are purged. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CacheDeleted extends DiagnosticEvent {} diff --git a/src/Event/CacheRetrieved.php b/src/Event/CacheRetrieved.php index c65d852c..35314e56 100644 --- a/src/Event/CacheRetrieved.php +++ b/src/Event/CacheRetrieved.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after data is read from cache. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CacheRetrieved extends DiagnosticEvent {} diff --git a/src/Event/CacheStored.php b/src/Event/CacheStored.php index 1f5755a3..232b9d51 100644 --- a/src/Event/CacheStored.php +++ b/src/Event/CacheStored.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after data is written to cache. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CacheStored extends DiagnosticEvent {} diff --git a/src/Event/CapabilityIgnored.php b/src/Event/CapabilityIgnored.php index c9cbb896..4fe04878 100644 --- a/src/Event/CapabilityIgnored.php +++ b/src/Event/CapabilityIgnored.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -19,7 +19,7 @@ * ConnectionConfig::$capabilityIgnore. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CapabilityIgnored extends DiagnosticEvent {} diff --git a/src/Event/CapabilityNegotiated.php b/src/Event/CapabilityNegotiated.php index 3254dc1c..e6b752a9 100644 --- a/src/Event/CapabilityNegotiated.php +++ b/src/Event/CapabilityNegotiated.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after CAPABILITY response is parsed. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CapabilityNegotiated extends ImapEvent {} diff --git a/src/Event/ConnectionClosed.php b/src/Event/ConnectionClosed.php index 65a0a5ba..33ea8cd7 100644 --- a/src/Event/ConnectionClosed.php +++ b/src/Event/ConnectionClosed.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after logout or error disconnect. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ConnectionClosed extends ImapEvent {} diff --git a/src/Event/ConnectionEstablished.php b/src/Event/ConnectionEstablished.php index 2297be8d..e06d7296 100644 --- a/src/Event/ConnectionEstablished.php +++ b/src/Event/ConnectionEstablished.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched after TCP/TLS handshake completes. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ConnectionEstablished extends ImapEvent {} diff --git a/src/Event/DiagnosticEvent.php b/src/Event/DiagnosticEvent.php index 72fdd4d4..919f5dce 100644 --- a/src/Event/DiagnosticEvent.php +++ b/src/Event/DiagnosticEvent.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * unless explicitly opted in. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class DiagnosticEvent extends ImapEvent {} diff --git a/src/Event/ImapEvent.php b/src/Event/ImapEvent.php index 3377f7ba..4d026d1e 100644 --- a/src/Event/ImapEvent.php +++ b/src/Event/ImapEvent.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * specific subclasses to handle individual signals. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class ImapEvent diff --git a/src/Event/MailboxExpunged.php b/src/Event/MailboxExpunged.php index cfa00ed5..5a07ccb6 100644 --- a/src/Event/MailboxExpunged.php +++ b/src/Event/MailboxExpunged.php @@ -3,22 +3,67 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ namespace Horde\Imap\Client\Event; +use Horde\Imap\Client\ImapIdSet; + /** - * Dispatched after EXPUNGE completes. + * Dispatched after messages are removed from a mailbox: A plain EXPUNGE, a + * UID EXPUNGE or the source-side removal of a MOVE. + * + * An external cache (for example a search-result cache the library + * deliberately does not own) can listen for this to invalidate entries + * that reference the removed messages. Whether the removed set is + * expressed as UIDs or as sequence numbers depends on what the server + * reported: VANISHED (QRESYNC) and UID EXPUNGE carry UIDs; a plain EXPUNGE + * carries only sequence numbers. Inspect {@see ImapIdSet::isSequence()} on + * {@see $vanished}: when it is UIDs a listener can intersect precisely; + * when it is sequence numbers (or empty) a listener must invalidate the + * mailbox conservatively, since sequence numbers are not stable keys. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class MailboxExpunged extends ImapEvent {} +class MailboxExpunged extends ImapEvent +{ + /** + * @param string $mailbox The mailbox messages were removed from. + * @param ImapIdSet $vanished The removed messages (UIDs or sequence + * numbers, per {@see ImapIdSet::isSequence()}; + * may be empty when the server reported + * nothing enumerable). + * @param int $uidvalidity The mailbox UIDVALIDITY in effect, or 0 + * if unknown, so a listener can scope + * cache keys to the right UID space. + */ + public function __construct( + public readonly string $mailbox, + public readonly ImapIdSet $vanished, + public readonly int $uidvalidity = 0, + ) { + parent::__construct( + sprintf( + '%d message(s) removed from %s', + $vanished->count(), + $mailbox, + ), + [ + 'mailbox' => $mailbox, + 'ids' => $vanished->toArray(), + 'sequence' => $vanished->isSequence(), + 'uidvalidity' => $uidvalidity, + ], + ); + } +} diff --git a/src/Event/MailboxSelected.php b/src/Event/MailboxSelected.php index 8a849259..9319c2de 100644 --- a/src/Event/MailboxSelected.php +++ b/src/Event/MailboxSelected.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,8 +17,38 @@ /** * Dispatched when a mailbox is opened (SELECT/EXAMINE). * + * Carries the sync-relevant state the open reply advertised so an external + * cache can detect a UID-space change (a new UIDVALIDITY means every cached + * result for the mailbox is stale) or gauge freshness against + * HIGHESTMODSEQ. A value of 0 means the server did not report that code. + * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class MailboxSelected extends ImapEvent {} +class MailboxSelected extends ImapEvent +{ + /** + * @param string $mailbox The opened mailbox. + * @param int $uidvalidity UIDVALIDITY (RFC 3501 §2.3.1.1), or 0. + * @param int $uidnext UIDNEXT (RFC 3501 §2.3.1.1), or 0. + * @param int $highestmodseq HIGHESTMODSEQ (RFC 7162), or 0. + */ + public function __construct( + public readonly string $mailbox, + public readonly int $uidvalidity = 0, + public readonly int $uidnext = 0, + public readonly int $highestmodseq = 0, + ) { + parent::__construct( + sprintf('Mailbox %s opened', $mailbox), + [ + 'mailbox' => $mailbox, + 'uidvalidity' => $uidvalidity, + 'uidnext' => $uidnext, + 'highestmodseq' => $highestmodseq, + ], + ); + } +} diff --git a/src/Event/SlowCommand.php b/src/Event/SlowCommand.php index 2a75e0ae..1b98d1b2 100644 --- a/src/Event/SlowCommand.php +++ b/src/Event/SlowCommand.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Dispatched when a command exceeds the configured latency threshold. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class SlowCommand extends ImapEvent {} diff --git a/src/Exception/AuthenticationException.php b/src/Exception/AuthenticationException.php index a1dd3f12..acd89a05 100644 --- a/src/Exception/AuthenticationException.php +++ b/src/Exception/AuthenticationException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Login/authentication failure. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class AuthenticationException extends MailboxProtocolException {} diff --git a/src/Exception/CapabilityNotSupportedException.php b/src/Exception/CapabilityNotSupportedException.php index f6d71daf..1d22a0ad 100644 --- a/src/Exception/CapabilityNotSupportedException.php +++ b/src/Exception/CapabilityNotSupportedException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2012-2026 Horde LLC (http://www.horde.org/) + * Copyright 2012-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * The server does not advertise a required IMAP extension. * * @author Michael Slusarz - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class CapabilityNotSupportedException extends ImapProtocolException {} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php index cb7de7b9..83cb6776 100644 --- a/src/Exception/ConnectionException.php +++ b/src/Exception/ConnectionException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * Network or transport failure (DNS, TCP, TLS handshake, timeout). * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ConnectionException extends MailboxProtocolException {} diff --git a/src/Exception/ImapProtocolException.php b/src/Exception/ImapProtocolException.php index 90f9212a..8b9c27fc 100644 --- a/src/Exception/ImapProtocolException.php +++ b/src/Exception/ImapProtocolException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * IMAP-specific protocol error. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ImapProtocolException extends MailboxProtocolException {} diff --git a/src/Exception/MailboxNotFoundException.php b/src/Exception/MailboxNotFoundException.php index ad0321b5..72f98ba7 100644 --- a/src/Exception/MailboxNotFoundException.php +++ b/src/Exception/MailboxNotFoundException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2012-2026 Horde LLC (http://www.horde.org/) + * Copyright 2012-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * The requested mailbox does not exist on the server. * * @author Michael Slusarz - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class MailboxNotFoundException extends ImapProtocolException {} diff --git a/src/Exception/MailboxProtocolException.php b/src/Exception/MailboxProtocolException.php index 6726e2ba..8f2c8f68 100644 --- a/src/Exception/MailboxProtocolException.php +++ b/src/Exception/MailboxProtocolException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Catch this to handle any IMAP or POP3 error uniformly. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class MailboxProtocolException extends RuntimeException {} diff --git a/src/Exception/Pop3ProtocolException.php b/src/Exception/Pop3ProtocolException.php index d10497bc..34c733ad 100644 --- a/src/Exception/Pop3ProtocolException.php +++ b/src/Exception/Pop3ProtocolException.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,7 @@ * POP3-specific protocol error. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Pop3ProtocolException extends MailboxProtocolException {} diff --git a/src/Exception/ServerResponseException.php b/src/Exception/ServerResponseException.php index fed126fb..2b49185b 100644 --- a/src/Exception/ServerResponseException.php +++ b/src/Exception/ServerResponseException.php @@ -3,27 +3,31 @@ declare(strict_types=1); /** - * Copyright 2012-2026 Horde LLC (http://www.horde.org/) + * Copyright 2012-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ namespace Horde\Imap\Client\Exception; +use Horde\Imap\Client\ImapResponseCode; use Throwable; /** * Carries the server's tagged response data. * - * Provides access to the IMAP command tag, status code, and response text - * so callers can inspect the server's exact reply. + * Provides access to the IMAP command tag, status code, response text and + * (when present) the bracketed response code so callers can inspect the + * server's exact reply. The response code lets a caller react to + * machine-readable failure hints such as `[TRYCREATE]` (RFC 3501 §6.4.7) + * or `[MODIFIED ...]` without re-parsing the human-readable text. * * @author Michael Slusarz - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ServerResponseException extends MailboxProtocolException @@ -35,6 +39,7 @@ public function __construct( public readonly ?string $command = null, public readonly ?string $status = null, public readonly ?string $responseText = null, + public readonly ?ImapResponseCode $responseCode = null, ) { parent::__construct($message, $code, $previous); } diff --git a/src/Exception/SyncException.php b/src/Exception/SyncException.php new file mode 100644 index 00000000..ad1d3698 --- /dev/null +++ b/src/Exception/SyncException.php @@ -0,0 +1,27 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SyncException extends ImapProtocolException {} diff --git a/src/Exception/WireEncodingException.php b/src/Exception/WireEncodingException.php new file mode 100644 index 00000000..69b8a24f --- /dev/null +++ b/src/Exception/WireEncodingException.php @@ -0,0 +1,26 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class WireEncodingException extends MailboxProtocolException {} diff --git a/src/FetchQueryFields.php b/src/FetchQueryFields.php new file mode 100644 index 00000000..71b74a77 --- /dev/null +++ b/src/FetchQueryFields.php @@ -0,0 +1,161 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +trait FetchQueryFields +{ + private bool $wantFullMsg = false; + + private ?int $fullMsgStart = null; + + private ?int $fullMsgLength = null; + + /** @var array */ + private array $headerTextIds = []; + + /** @var array */ + private array $bodyTextIds = []; + + private bool $wantUid = false; + + private bool $wantSize = false; + + private bool $wantSeq = false; + + private bool $wantImapDate = false; + + /** + * Request the full raw message or optionally a byte range of it. + */ + public function fullMsg(?int $start = null, ?int $length = null): static + { + $this->wantFullMsg = true; + $this->fullMsgStart = $start; + $this->fullMsgLength = $length; + + return $this; + } + + public function headerText(int|string $id = 0): static + { + $this->headerTextIds[$id] = true; + + return $this; + } + + public function bodyText(int|string $id = 0): static + { + $this->bodyTextIds[$id] = true; + + return $this; + } + + public function uid(): static + { + $this->wantUid = true; + + return $this; + } + + public function size(): static + { + $this->wantSize = true; + + return $this; + } + + public function seq(): static + { + $this->wantSeq = true; + + return $this; + } + + public function imapDate(): static + { + $this->wantImapDate = true; + + return $this; + } + + public function wantsFullMsg(): bool + { + return $this->wantFullMsg; + } + + /** + * @return array{start: ?int, length: ?int} + */ + public function fullMsgRange(): array + { + return ['start' => $this->fullMsgStart, 'length' => $this->fullMsgLength]; + } + + /** + * @return list + */ + public function headerTextIds(): array + { + return array_keys($this->headerTextIds); + } + + /** + * @return list + */ + public function bodyTextIds(): array + { + return array_keys($this->bodyTextIds); + } + + public function wantsUid(): bool + { + return $this->wantUid; + } + + public function wantsSize(): bool + { + return $this->wantSize; + } + + public function wantsSeq(): bool + { + return $this->wantSeq; + } + + public function wantsImapDate(): bool + { + return $this->wantImapDate; + } +} diff --git a/src/FilteredEventDispatcher.php b/src/FilteredEventDispatcher.php index bb840676..b155b34e 100644 --- a/src/FilteredEventDispatcher.php +++ b/src/FilteredEventDispatcher.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,8 @@ * Pass an empty $suppress array to let everything through. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ final class FilteredEventDispatcher implements EventDispatcherInterface diff --git a/src/ImapAcl.php b/src/ImapAcl.php new file mode 100644 index 00000000..95e24ae2 --- /dev/null +++ b/src/ImapAcl.php @@ -0,0 +1,154 @@ + + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapAcl implements Stringable +{ + /** + * RFC 2086 virtual rights and their RFC 4314 component letters + * (RFC 4314 §2.1.1). + */ + private const VIRTUAL = [ + 'c' => ['k', 'x'], + 'd' => ['t', 'x', 'e'], + ]; + + /** @var list The normalized RFC 4314 right letters. */ + private readonly array $rights; + + public function __construct(string $rights = '') + { + $this->rights = self::normalize(str_split($rights)); + } + + /** + * Whether this ACL grants a right (an {@see AclRight} case or its + * single-letter string). + */ + public function has(AclRight|string $right): bool + { + $letter = $right instanceof AclRight ? $right->value : $right; + + return in_array($letter, $this->rights, true); + } + + /** + * The RFC 4314 right letters, in canonical (sorted) order. + * + * @return list + */ + public function rights(): array + { + return $this->rights; + } + + /** + * The rights that would be added and removed to reach `$other` + * (virtual rights ignored). + * + * @return array{added: string, removed: string} + */ + public function diff(string $other): array + { + $target = array_diff(str_split($other), array_keys(self::VIRTUAL)); + + return [ + 'added' => implode('', array_diff($target, $this->rights)), + 'removed' => implode('', array_diff($this->rights, $target)), + ]; + } + + /** + * The wire string for IMAP calls. RFC 4314 form by default; with + * `$rfc2086` the RFC 4314 component rights are collapsed back into the + * virtual `c`/`d` letters for a legacy server. + */ + public function getString(bool $rfc2086 = false): string + { + $acl = (string) $this; + + if (!$rfc2086) { + return $acl; + } + + foreach (self::VIRTUAL as $virtual => $components) { + $acl = str_replace($components, '', $acl, $count); + + if ($count) { + $acl .= $virtual; + } + } + + return $acl; + } + + public function __toString(): string + { + return implode('', $this->rights); + } + + /** + * Expand any RFC 2086 virtual rights into their RFC 4314 components + * and drop the virtual letters (RFC 4314 §2.1.1). + * + * @param list $rights + * + * @return list + */ + private static function normalize(array $rights): array + { + $expanded = []; + + foreach ($rights as $right) { + if (isset(self::VIRTUAL[$right])) { + foreach (self::VIRTUAL[$right] as $component) { + $expanded[$component] = true; + } + + continue; + } + + $expanded[$right] = true; + } + + // Drop the virtual letters themselves; keep only real rights. + unset($expanded['c'], $expanded['d']); + + $letters = array_keys($expanded); + sort($letters); + + return $letters; + } +} diff --git a/src/ImapAclAware.php b/src/ImapAclAware.php index 92058c39..bf2a1150 100644 --- a/src/ImapAclAware.php +++ b/src/ImapAclAware.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,16 +20,21 @@ * Separated because ACL is an optional server capability. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface ImapAclAware { /** - * @return object Acl value object + * @return array ACL rights keyed by identifier (each + * value an Acl value object). */ - public function getACL(string $mailbox): object; + public function getACL(string $mailbox): array; + /** + * @param array{rights?: string} $options + */ public function setACL(string $mailbox, string $identifier, array $options): void; public function deleteACL(string $mailbox, string $identifier): void; diff --git a/src/ImapAclRights.php b/src/ImapAclRights.php new file mode 100644 index 00000000..64f4e628 --- /dev/null +++ b/src/ImapAclRights.php @@ -0,0 +1,44 @@ + + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapAclRights +{ + /** + * @param list $required Rights the identifier always has. + * @param list $optional Rights that may be granted (each entry + * is one grantable unit, possibly a + * multi-letter group). + */ + public function __construct( + public array $required = [], + public array $optional = [], + ) {} +} diff --git a/src/ImapCacheStore.php b/src/ImapCacheStore.php new file mode 100644 index 00000000..eebb2a8a --- /dev/null +++ b/src/ImapCacheStore.php @@ -0,0 +1,373 @@ + + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCacheStore +{ + /** + * Per-mailbox index buffer, keyed by mailbox name. Each entry is + * `{uidvalidity: int, uids: array, meta: array}`. + * Loaded lazily, mutated by set/delete, and written back on flush. + * + * @var array, meta: array}> + */ + private array $index = []; + + /** + * Buffered per-UID field writes, keyed by PSR-16 key. Flushed via + * `setMultiple()`. + * + * @var array> + */ + private array $pending = []; + + /** + * Mailbox index keys touched since the last flush. + * + * @var array + */ + private array $dirtyIndex = []; + + private readonly string $scope; + + public function __construct( + private readonly CacheInterface $cache, + string $hostspec, + int $port, + string $username, + ) { + // One namespace per account so two accounts on the same PSR-16 + // store never collide. + $this->scope = substr(hash('sha256', $hostspec . ':' . $port . ':' . $username), 0, 16); + } + + /** + * Retrieve cached field data for a set of UIDs. + * + * @param list $uids The UIDs wanted. + * @param list $fields The fields wanted; empty for all cached + * fields of each UID. + * + * @return array> Field data keyed by UID. + * A UID absent from the cache is omitted. + */ + public function get(string $mailbox, array $uids, array $fields, int $uidvalidity): array + { + if (!$this->indexValid($mailbox, $uidvalidity) || $uids === []) { + return []; + } + + $keys = []; + foreach ($uids as $uid) { + $keys[$this->uidKey($mailbox, $uidvalidity, $uid)] = $uid; + } + + $out = []; + + foreach ($this->cache->getMultiple(array_keys($keys)) as $key => $value) { + if (!is_array($value)) { + continue; + } + + // Merge any not-yet-flushed writes for this UID on top. + if (isset($this->pending[$key])) { + $value = array_merge($value, $this->pending[$key]); + } + + $uid = $keys[$key]; + $out[$uid] = $fields === [] ? $value : array_intersect_key($value, array_flip($fields)); + } + + // Include UIDs that exist only in the write buffer. + foreach ($keys as $key => $uid) { + if (!isset($out[$uid]) && isset($this->pending[$key])) { + $value = $this->pending[$key]; + $out[$uid] = $fields === [] ? $value : array_intersect_key($value, array_flip($fields)); + } + } + + return $out; + } + + /** + * The list of UIDs currently cached for a mailbox (unsorted). + * + * @return list + */ + public function getCachedUids(string $mailbox, int $uidvalidity): array + { + if (!$this->indexValid($mailbox, $uidvalidity)) { + return []; + } + + return array_map('intval', array_keys($this->index[$mailbox]['uids'])); + } + + /** + * Buffer field data for a set of UIDs. Values are merged with any + * already-cached fields for the same UID. Nothing is written to the + * PSR-16 store until {@see flush()}. + * + * @param array> $data Field data keyed by UID. + */ + public function set(string $mailbox, array $data, int $uidvalidity): void + { + if ($data === []) { + return; + } + + $this->loadIndex($mailbox); + + // A changed UIDVALIDITY invalidates every earlier entry (RFC 3501 + // §2.3.1.1); start the mailbox's cache over at the new value. + if ($this->index[$mailbox]['uidvalidity'] !== $uidvalidity) { + $this->dropMailbox($mailbox); + $this->index[$mailbox]['uidvalidity'] = $uidvalidity; + } + + foreach ($data as $uid => $fields) { + $uid = (int) $uid; + $key = $this->uidKey($mailbox, $uidvalidity, $uid); + $existing = $this->pending[$key] ?? []; + $this->pending[$key] = array_merge($existing, $fields); + $this->index[$mailbox]['uids'][$uid] = true; + } + + $this->dirtyIndex[$mailbox] = true; + } + + /** + * Mailbox-level metadata (for example the last-seen HIGHESTMODSEQ). + * `uidvalid` is always present, from the index. + * + * @param list $entries Entry names wanted; empty for all. + * + * @return array + */ + public function getMetadata(string $mailbox, int $uidvalidity, array $entries): array + { + $this->loadIndex($mailbox); + + if ($this->index[$mailbox]['uidvalidity'] !== $uidvalidity) { + return ['uidvalid' => $uidvalidity]; + } + + $meta = $this->index[$mailbox]['meta']; + $meta['uidvalid'] = $this->index[$mailbox]['uidvalidity']; + + return $entries === [] ? $meta : array_intersect_key($meta, array_flip([...$entries, 'uidvalid'])); + } + + /** + * Store mailbox-level metadata. A `uidvalid` key updates the stored + * UIDVALIDITY (and drops the mailbox cache if it changed). + * + * @param array $data + */ + public function setMetadata(string $mailbox, array $data): void + { + $this->loadIndex($mailbox); + + if (isset($data['uidvalid'])) { + $uidvalidity = (int) $data['uidvalid']; + unset($data['uidvalid']); + + if ($this->index[$mailbox]['uidvalidity'] !== $uidvalidity) { + $this->dropMailbox($mailbox); + $this->index[$mailbox]['uidvalidity'] = $uidvalidity; + } + } + + if ($data !== []) { + $this->index[$mailbox]['meta'] = array_merge($this->index[$mailbox]['meta'], $data); + } + + $this->dirtyIndex[$mailbox] = true; + } + + /** + * Remove cached data for a set of UIDs. + * + * @param list $uids + */ + public function deleteMsgs(string $mailbox, array $uids): void + { + if ($uids === []) { + return; + } + + $this->loadIndex($mailbox); + $uidvalidity = $this->index[$mailbox]['uidvalidity']; + $keys = []; + + foreach ($uids as $uid) { + $uid = (int) $uid; + $key = $this->uidKey($mailbox, $uidvalidity, $uid); + $keys[] = $key; + unset($this->pending[$key], $this->index[$mailbox]['uids'][$uid]); + } + + $this->cache->deleteMultiple($keys); + $this->dirtyIndex[$mailbox] = true; + } + + /** + * Drop a mailbox entirely from the cache. + */ + public function deleteMailbox(string $mailbox): void + { + $this->loadIndex($mailbox); + $this->dropMailbox($mailbox); + $this->cache->delete($this->indexKey($mailbox)); + + unset($this->index[$mailbox], $this->dirtyIndex[$mailbox]); + } + + /** + * Write all buffered data to the PSR-16 store. Called automatically + * on destruction, but a caller may flush explicitly (for example + * before a long-running operation). + */ + public function flush(): void + { + if ($this->pending !== []) { + $this->cache->setMultiple($this->pending); + $this->pending = []; + } + + foreach (array_keys($this->dirtyIndex) as $mailbox) { + $this->cache->set($this->indexKey($mailbox), $this->index[$mailbox]); + } + + $this->dirtyIndex = []; + } + + public function __destruct() + { + $this->flush(); + } + + /** + * Load a mailbox's index entry from the store into memory, if not + * already loaded, initializing an empty one otherwise. + */ + private function loadIndex(string $mailbox): void + { + if (isset($this->index[$mailbox])) { + return; + } + + $stored = $this->cache->get($this->indexKey($mailbox)); + + $this->index[$mailbox] = (is_array($stored) && isset($stored['uidvalidity'])) + ? [ + 'uidvalidity' => (int) $stored['uidvalidity'], + 'uids' => is_array($stored['uids'] ?? null) ? $stored['uids'] : [], + 'meta' => is_array($stored['meta'] ?? null) ? $stored['meta'] : [], + ] + : ['uidvalidity' => 0, 'uids' => [], 'meta' => []]; + } + + /** + * Whether the mailbox is cached at the given UIDVALIDITY. A mismatch + * drops the stale cache. + */ + private function indexValid(string $mailbox, int $uidvalidity): bool + { + $this->loadIndex($mailbox); + + if ($this->index[$mailbox]['uidvalidity'] === 0) { + return false; + } + + if ($this->index[$mailbox]['uidvalidity'] !== $uidvalidity) { + $this->dropMailbox($mailbox); + $this->index[$mailbox]['uidvalidity'] = $uidvalidity; + $this->dirtyIndex[$mailbox] = true; + + return false; + } + + return true; + } + + /** + * Delete every cached UID key for a mailbox and reset its in-memory + * index (keeping the entry so a fresh UIDVALIDITY can be set). + */ + private function dropMailbox(string $mailbox): void + { + $uidvalidity = $this->index[$mailbox]['uidvalidity']; + $keys = []; + + foreach (array_keys($this->index[$mailbox]['uids']) as $uid) { + $key = $this->uidKey($mailbox, $uidvalidity, (int) $uid); + $keys[] = $key; + unset($this->pending[$key]); + } + + if ($keys !== []) { + $this->cache->deleteMultiple($keys); + } + + $this->index[$mailbox]['uids'] = []; + $this->index[$mailbox]['meta'] = []; + } + + private function indexKey(string $mailbox): string + { + return 'imap:' . $this->scope . ':idx:' . $this->hashPart($mailbox); + } + + private function uidKey(string $mailbox, int $uidvalidity, int $uid): string + { + return 'imap:' . $this->scope . ':' . $this->hashPart($mailbox) . ':' . $uidvalidity . ':' . $uid; + } + + /** + * Hash a mailbox name into a PSR-16-safe key segment (RFC-reserved + * PSR-16 characters `{}()/\@:` cannot appear in a key literally, and a + * mailbox name may contain any of them). + */ + private function hashPart(string $value): string + { + return substr(hash('sha256', $value), 0, 24); + } +} diff --git a/src/ImapCapability.php b/src/ImapCapability.php new file mode 100644 index 00000000..3276abab --- /dev/null +++ b/src/ImapCapability.php @@ -0,0 +1,116 @@ + + * @author Ralf Lang + * @copyright 2014-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCapability implements Capability +{ + use CapabilityData { + query as private baseQuery; + } + + /** + * @var list + */ + private array $enabled = []; + + public function query(string $capability, ?string $parameter = null): bool + { + if ($this->baseQuery($capability, $parameter)) { + return true; + } + + return match (strtoupper($capability)) { + // RFC 7162 §3.2.3: QRESYNC implies CONDSTORE and ENABLE. + 'CONDSTORE', 'ENABLE' => $parameter === null && $this->query('QRESYNC'), + // RFC 6855 §3: UTF8=ONLY implies UTF8=ACCEPT. + 'UTF8' => $parameter !== null + && strtoupper($parameter) === 'ACCEPT' + && $this->query('UTF8', 'ONLY'), + default => false, + }; + } + + /** + * The extensions currently enabled via `ENABLE` (RFC 5161). + * + * @return list + */ + public function enabled(): array + { + return $this->enabled; + } + + public function isEnabled(string $capability): bool + { + return in_array(strtoupper($capability), $this->enabled, true); + } + + /** + * Record an extension as enabled (or disabled) for this connection. + * + * This does not itself send `ENABLE` to the server. It records the + * outcome of an exchange the protocol driver already carried out. + */ + public function enable(string $capability, bool $enable = true): void + { + $capability = strtoupper($capability); + $isEnabled = $this->isEnabled($capability); + + if ($enable && !$isEnabled) { + if ($capability === 'QRESYNC') { + // RFC 7162 §3.2.3: enabling QRESYNC also enables CONDSTORE. + $this->enable('CONDSTORE'); + } + + $this->enabled[] = $capability; + } elseif (!$enable && $isEnabled) { + $this->enabled = array_values(array_diff($this->enabled, [$capability])); + } + } + + /** + * The command-line length (in octets) it's safe to assume the server + * accepts. + * + * RFC 2683 §3.2.1.5 originally recommended limiting lines to + * "approximately 1000 octets" while requiring servers to accept at + * least 8000. RFC 7162 §4 raised the recommendation to 8192. As a + * compromise, assume 2000 octets for a plain server, and 8000 once + * CONDSTORE/QRESYNC support is advertised (their mere presence is + * signal enough. No need to check they're actually in use). + */ + public function cmdLength(): int + { + return ($this->query('CONDSTORE') || $this->query('QRESYNC')) ? 8000 : 2000; + } +} diff --git a/src/ImapCapabilityNegotiator.php b/src/ImapCapabilityNegotiator.php new file mode 100644 index 00000000..5286f9a6 --- /dev/null +++ b/src/ImapCapabilityNegotiator.php @@ -0,0 +1,155 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCapabilityNegotiator +{ + public function __construct( + private readonly ImapInteraction $interaction, + ) {} + + /** + * Send `CAPABILITY` and build a fresh {@see ImapCapability} from + * every response that carries capability data. The untagged + * `* CAPABILITY ...` data response and defensively a + * `[CAPABILITY ...]` response code on the tagged completion, in + * case a server piggybacks it there instead (RFC 3501 §7.1). + */ + public function fetch(): ImapCapability + { + $capability = new ImapCapability(); + $result = $this->interaction->send('CAPABILITY'); + + foreach ($result->untagged as $response) { + self::mergeFromResponse($response, $capability); + } + + self::mergeFromResponse($result->tagged, $capability); + + return $capability; + } + + /** + * Send `ENABLE ` and record whichever ones the + * server actually acknowledges via its untagged `ENABLED` response + * (RFC 5161 §3.1: The server may enable fewer than requested but + * always confirms the ones it did). + * + * @param list $extensions + * + * @return list The extensions the server confirmed enabled. + */ + public function enable(ImapCapability $capability, array $extensions): array + { + $result = $this->interaction->send('ENABLE', $extensions); + $enabled = []; + + foreach ($result->untagged as $response) { + if (!$response->isUntagged() || $response->data === [] || !is_string($response->data[0])) { + continue; + } + + if (strtoupper($response->data[0]) !== 'ENABLED') { + continue; + } + + foreach (array_slice($response->data, 1) as $token) { + if (is_string($token)) { + $capability->enable($token); + $enabled[] = strtoupper($token); + } + } + } + + return $enabled; + } + + /** + * Switch the connection into IMAP4rev2 behavior if the server + * supports it. RFC 9051 §3.2: A server advertising both + * `IMAP4rev1` and `IMAP4rev2` behaves as rev1 until the client asks + * otherwise. + * Without it a rev2-capable server is driven with rev1 semantics all connection long. + * + * A rev1-only server that separately advertises `UTF8=ACCEPT` (RFC + * 6855) still benefits from UTF-8 mailbox names and `literal8`, + * even without full rev2 command/response semantics. + * + * @return string|null The capability actually enabled + * (`IMAP4REV2` or `UTF8=ACCEPT`) or null if + * the server offers neither. + */ + public function negotiateRev2(ImapCapability $capability): ?string + { + if ($capability->query('IMAP4REV2')) { + $this->enable($capability, ['IMAP4rev2']); + + return 'IMAP4REV2'; + } + + if ($capability->query('UTF8', 'ACCEPT')) { + $this->enable($capability, ['UTF8=ACCEPT']); + + return 'UTF8=ACCEPT'; + } + + return null; + } + + /** + * Merge capability data out of one response if it carries any. + * Either an untagged `* CAPABILITY ...` data response or a + * `[CAPABILITY ...]` response code on any response: A greeting or + * a tagged completion. Returns whether it found anything. + */ + public static function mergeFromResponse(ImapResponse $response, ImapCapability $capability): bool + { + if ($response->responseCode !== null && strtoupper($response->responseCode->name) === 'CAPABILITY') { + ImapCapabilityParser::parse($response->responseCode->data, $capability); + + return true; + } + + if ( + $response->isUntagged() + && $response->data !== [] + && is_string($response->data[0]) + && strtoupper($response->data[0]) === 'CAPABILITY' + ) { + ImapCapabilityParser::parse(array_slice($response->data, 1), $capability); + + return true; + } + + return false; + } +} diff --git a/src/ImapCapabilityParser.php b/src/ImapCapabilityParser.php new file mode 100644 index 00000000..c1ac1515 --- /dev/null +++ b/src/ImapCapabilityParser.php @@ -0,0 +1,61 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCapabilityParser +{ + private function __construct() {} + + /** + * @param list $tokens The capability names/pairs, with any + * leading `CAPABILITY` token already + * stripped. + */ + public static function parse(array $tokens, ?ImapCapability $capability = null): ImapCapability + { + $capability ??= new ImapCapability(); + + foreach ($tokens as $token) { + if (!is_string($token)) { + // A nested list never appears in a CAPABILITY response; + // ignore defensively rather than fail on it. + continue; + } + + $equals = strpos($token, '='); + + if ($equals === false) { + $capability->add($token); + } else { + $capability->add(substr($token, 0, $equals), substr($token, $equals + 1)); + } + } + + return $capability; + } +} diff --git a/src/ImapClient.php b/src/ImapClient.php new file mode 100644 index 00000000..78c3eb69 --- /dev/null +++ b/src/ImapClient.php @@ -0,0 +1,3282 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapClient implements ImapProtocol, ImapAclAware, ImapQuotaAware, ImapMetadataAware +{ + private ?ClientInterface $client; + + private ?ImapConnection $connection = null; + + private ?ImapCommandTag $tags = null; + + private ?ImapInteraction $interaction = null; + + private ?ImapCapabilityNegotiator $negotiator = null; + + private ?ImapCapability $capability = null; + + private bool $capabilityFetched = false; + + private ?SaslAuthenticator $authenticator = null; + + private bool $loggedIn = false; + + private ?string $selectedMailbox = null; + + private bool $selectedReadWrite = false; + + /** + * UIDVALIDITY of the selected mailbox (RFC 3501 §2.3.1.1), captured + * from its SELECT/EXAMINE reply. 0 when unknown/none selected. Used + * as the cache-keying dimension. + */ + private int $selectedUidValidity = 0; + + /** + * HIGHESTMODSEQ of the selected mailbox (RFC 7162), captured from its + * SELECT/EXAMINE reply. 0 when the server lacks CONDSTORE. Used to + * gate the freshness of cached flags. + */ + private int $selectedHighestModSeq = 0; + + public function __construct( + private readonly ConnectionConfig $config, + private ?Credentials $credentials = null, + private readonly ?EventDispatcherInterface $dispatcher = null, + ?ClientInterface $client = null, + private readonly ?ImapCacheStore $cache = null, + ) { + $this->client = $client; + } + + /** + * Dispatch a domain event to the configured PSR-14 dispatcher, if any. + * + * A no-op when no dispatcher was injected, so mutation paths can signal + * external listeners (for example a search-result cache the library + * does not own) without every call site null-checking. + */ + private function dispatch(object $event): void + { + $this->dispatcher?->dispatch($event); + } + + public function getCapability(): ImapCapability + { + $this->connect(); + + if (!$this->capabilityFetched) { + $this->capability = $this->negotiator->fetch(); + $this->capabilityFetched = true; + } + + return $this->capability; + } + + /** + * The currently selected/examined mailbox or null if none is + * (RFC 3501 §3.2, "selected state"). + */ + public function selectedMailbox(): ?string + { + return $this->selectedMailbox; + } + + /** + * Whether the selected mailbox was opened read-write. Meaningless + * (returns false) when no mailbox is currently selected. + */ + public function isReadWrite(): bool + { + return $this->selectedReadWrite; + } + + public function login(): void + { + if ($this->loggedIn) { + return; + } + + $this->connect(); + + if ($this->loggedIn) { + // A PREAUTH greeting (RFC 3501 §7.1.4) already authenticated + // this connection out-of-band (e.g. by TLS client certificate, + // or IP allowlisting). There is nothing left for login() to do. + return; + } + + if ($this->credentials === null) { + throw new AuthenticationException( + 'No credentials supplied: pass them to the constructor.' + ); + } + + $this->maybeUpgradeTls(); + + $capability = $this->getCapability(); + $authMechanisms = $capability->getParams('AUTH'); + + if ($authMechanisms !== []) { + try { + $this->loginSasl($authMechanisms); + $this->finishLogin(); + + return; + } catch (AuthenticationException $e) { + if (!$this->credentials instanceof PasswordCredentials) { + throw $e; + } + // Fall through to the native LOGIN fallback below. + } + } + + if (!$this->credentials instanceof PasswordCredentials) { + throw new AuthenticationException( + 'The server offered no usable SASL mechanism, and no password' + . ' credential was supplied for the LOGIN fallback.' + ); + } + + if ($capability->query('LOGINDISABLED')) { + throw new AuthenticationException( + 'The server has disabled the LOGIN command (LOGINDISABLED) and' + . ' offered no usable SASL mechanism.' + ); + } + + $this->loginNative($this->credentials); + $this->finishLogin(); + } + + public function logout(): void + { + if ($this->connection === null) { + return; + } + + try { + $this->interaction->send('LOGOUT'); + } catch (ImapProtocolException | ServerResponseException) { + // The server is going away regardless; nothing more to do. + } finally { + $this->cache?->flush(); + $this->client?->close(); + $this->connection = null; + $this->tags = null; + $this->interaction = null; + $this->negotiator = null; + $this->capability = null; + $this->capabilityFetched = false; + $this->loggedIn = false; + $this->selectedMailbox = null; + $this->selectedReadWrite = false; + $this->selectedUidValidity = 0; + $this->selectedHighestModSeq = 0; + } + } + + public function noop(): void + { + $this->connect(); + $this->interaction->send('NOOP'); + } + + /** + * Send `SELECT` (read-write) or `EXAMINE` (read-only) (RFC 3501 + * §6.3.1-6.3.2). `OpenMode::Auto` is treated the same as + * `ReadWrite`. Always asks for the stronger mode and lets the server downgrade via its tagged + * `[READ-ONLY]` response code if it must. + * + * @throws MailboxNotFoundException If the server rejects the command + * almost always caused by the + * mailbox not existing or being + * not selectable. + */ + public function openMailbox(string $mailbox, OpenMode $mode): void + { + $this->connect(); + $this->sendSelect($mailbox, $mode, null); + } + + /** + * Open a mailbox with a QRESYNC parameter (RFC 7162 §3.2.5), + * fast-forwarding the client's view from a known point. + * + * Passes the last-seen `$uidValidity` and `$modseq` (and optionally + * the UIDs the client already knows) so the server replies with the + * messages expunged since (`VANISHED (EARLIER)`) and a flag-change + * FETCH for each changed message, all bundled into the returned + * {@see ImapQresyncResult}. Requires QRESYNC to be enabled first (via + * {@see enableQresync()}). + * + * @param ?ImapIdSet $knownUids The UIDs the client already has (the + * QRESYNC "known-uids" set), or null to + * omit it. + * + * @throws CapabilityNotSupportedException If QRESYNC is not enabled. + * @throws MailboxNotFoundException If the server rejects the open. + */ + public function openMailboxQresync( + string $mailbox, + OpenMode $mode, + int $uidValidity, + int $modseq, + ?ImapIdSet $knownUids = null, + ): ImapQresyncResult { + $this->connect(); + + if (!$this->getCapability()->isEnabled('QRESYNC')) { + throw new CapabilityNotSupportedException( + 'A QRESYNC SELECT requires QRESYNC to be enabled first (RFC 7162); call enableQresync().' + ); + } + + $params = new ImapWireList([ + new ImapWireNumber($uidValidity), + new ImapWireNumber($modseq), + ]); + + if ($knownUids !== null && !$knownUids->isEmpty()) { + $params->add(new ImapWireAtom((string) $knownUids)); + } + + $qresync = new ImapWireList([new ImapWireAtom('QRESYNC'), $params]); + $result = $this->sendSelect($mailbox, $mode, $qresync); + + return new ImapQresyncResult( + ImapVanishedParser::parse($result->untagged), + $this->collectQresyncChanges($result->untagged), + ); + } + + /** + * Send the actual SELECT/EXAMINE, optionally with a trailing + * parameter list (used for QRESYNC), and update the selected-mailbox + * state. Shared by {@see openMailbox()} and + * {@see openMailboxQresync()}. + * + * @throws MailboxNotFoundException If the server rejects the command. + */ + private function sendSelect(string $mailbox, OpenMode $mode, ?ImapWireList $extra): ImapCommandResult + { + $command = $mode === OpenMode::Readonly ? 'EXAMINE' : 'SELECT'; + $arguments = [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]; + + if ($extra !== null) { + $arguments[] = $extra; + } + + try { + $result = $this->interaction->send($command, $arguments); + } catch (ServerResponseException $e) { + throw new MailboxNotFoundException( + "Could not open mailbox '{$mailbox}': {$e->getMessage()}", + 0, + $e, + ); + } + + $this->selectedMailbox = $mailbox; + $this->selectedReadWrite = $command === 'SELECT' + && !( + $result->tagged->responseCode !== null + && strtoupper($result->tagged->responseCode->name) === 'READ-ONLY' + ); + + // Capture UIDVALIDITY / HIGHESTMODSEQ from the untagged + // `* OK [UIDVALIDITY n]` / `* OK [HIGHESTMODSEQ n]` codes the open + // reply carries (RFC 3501 §7.1, RFC 7162 §3.1.2.1); the cache + // layer keys on the former and gates flag freshness on the latter. + $this->selectedUidValidity = 0; + $this->selectedHighestModSeq = 0; + $uidnext = 0; + + foreach ($result->untagged as $response) { + $code = $response->responseCode; + + if ($code === null || $code->data === [] || !is_string($code->data[0]) || !ctype_digit($code->data[0])) { + continue; + } + + match (strtoupper($code->name)) { + 'UIDVALIDITY' => $this->selectedUidValidity = (int) $code->data[0], + 'HIGHESTMODSEQ' => $this->selectedHighestModSeq = (int) $code->data[0], + 'UIDNEXT' => $uidnext = (int) $code->data[0], + default => null, + }; + } + + $this->dispatch(new MailboxSelected( + $mailbox, + $this->selectedUidValidity, + $uidnext, + $this->selectedHighestModSeq, + )); + + return $result; + } + + /** + * @return object MailboxStatus value object. + */ + public function status(string $mailbox, int $flags): object + { + $this->connect(); + + $items = $this->statusItems($flags); + $mailboxArg = new ImapWireMailbox($mailbox, $this->mailboxNameCodec()); + + try { + $result = $this->interaction->send('STATUS', [$mailboxArg, new ImapWireList($items)]); + } catch (ServerResponseException $e) { + throw new MailboxNotFoundException( + "Could not read status of mailbox '{$mailbox}': {$e->getMessage()}", + 0, + $e, + ); + } + + return (object) $this->parseStatusResponse($result->untagged); + } + + /** + * Read the status of several mailboxes in one pipelined burst + * (RFC 3501 §5.5), returning one status object per mailbox keyed by + * name. This is the batch form of {@see status()}; issuing the STATUS + * commands together saves a round trip each versus calling `status()` + * in a loop. + * + * A mailbox the server rejects is simply absent from the result + * (unlike `status()`, a single bad mailbox does not abort the batch). + * + * @param list $mailboxes + * + * @return array MailboxStatus value objects by mailbox. + */ + public function statusMultiple(array $mailboxes, int $flags): array + { + $this->connect(); + + if ($mailboxes === []) { + return []; + } + + $items = new ImapWireList($this->statusItems($flags)); + $commands = []; + $tagToMailbox = []; + + foreach ($mailboxes as $mailbox) { + $command = new ImapCommand( + $this->interaction->newTag(), + 'STATUS', + [new ImapWireMailbox($mailbox, $this->mailboxNameCodec()), $items], + ); + $commands[] = $command; + $tagToMailbox[$command->tag] = $mailbox; + } + + $results = $this->interaction->sendPipeline($commands); + $out = []; + + foreach ($results as $tag => $result) { + // Skip a mailbox the server rejected (NO/BAD tagged response). + if (!$result->tagged->isOk()) { + continue; + } + + $out[$tagToMailbox[$tag]] = (object) $this->parseStatusResponse($result->untagged); + } + + return $out; + } + + public function close(array $options = []): void + { + $this->connect(); + $this->interaction->send('CLOSE'); + $this->selectedMailbox = null; + $this->selectedReadWrite = false; + $this->selectedUidValidity = 0; + $this->selectedHighestModSeq = 0; + } + + /** + * @throws CapabilityNotSupportedException If the server is not IMAP4rev2 and does not + * advertise `UNSELECT` (RFC 3691). + */ + public function unselect(): void + { + $this->connect(); + $capability = $this->getCapability(); + + if (!$capability->isEnabled('IMAP4REV2') && !$capability->query('UNSELECT')) { + throw new CapabilityNotSupportedException( + 'The server does not advertise UNSELECT (RFC 3691). Only IMAP4rev2 servers support it unconditionally.' + ); + } + + $this->interaction->send('UNSELECT'); + $this->selectedMailbox = null; + $this->selectedReadWrite = false; + $this->selectedUidValidity = 0; + $this->selectedHighestModSeq = 0; + } + + /** + * Create a mailbox (RFC 3501 §6.3.3). + * + * When `$specialUse` is given, the server is asked to attach those + * RFC 6154 special-use attributes to the new mailbox with the + * `CREATE ... (USE (...))` form (RFC 6154 §3). A server without + * `CREATE-SPECIAL-USE` may reject the attributes; this method does + * not pre-check the capability, leaving the server to accept or + * refuse. + * + * @param list $specialUse Special-use attributes to request. + * + * @throws ServerResponseException If the server rejects CREATE. + */ + public function createMailbox(string $mailbox, array $specialUse = []): void + { + $this->connect(); + + $arguments = [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]; + + if ($specialUse !== []) { + $uses = new ImapWireList(); + + foreach ($specialUse as $use) { + $uses->add(new ImapWireAtom($use->value)); + } + + // RFC 6154 §3: CREATE mailbox (USE (\Attr ...)). + $arguments[] = new ImapWireList([new ImapWireAtom('USE'), $uses]); + } + + // CREATE returns no untagged data (RFC 3501 §6.3.3). + $this->interaction->send('CREATE', $arguments); + } + + /** + * Delete a mailbox (RFC 3501 §6.3.4). + * + * A mailbox that is currently selected is closed first, since some + * servers refuse to delete an open mailbox. Some servers also refuse + * to delete a mailbox that still holds messages; on a rejection the + * mailbox is emptied (every message flagged `\Deleted` and expunged) + * and the DELETE retried once. + * + * @throws ServerResponseException If the server rejects DELETE even + * after the mailbox was emptied. + */ + public function deleteMailbox(string $mailbox): void + { + $this->connect(); + + if ($this->selectedMailbox === $mailbox) { + $this->close(); + } + + $arg = [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]; + + try { + // DELETE returns no untagged data (RFC 3501 §6.3.4). + $this->interaction->send('DELETE', $arg); + } catch (ServerResponseException $e) { + // Some servers won't delete a non-empty mailbox. Empty it and + // retry once (the legacy driver's same fallback). + $this->emptyMailbox($mailbox); + $this->interaction->send('DELETE', $arg); + } + + $this->cache?->deleteMailbox($mailbox); + } + + /** + * Rename a mailbox (RFC 3501 §6.3.5). + * + * The source mailbox is closed first when it is the selected one, + * since some servers refuse to rename an open mailbox. + * + * @throws ServerResponseException If the server rejects RENAME. + */ + public function renameMailbox(string $old, string $new): void + { + $this->connect(); + + if ($this->selectedMailbox === $old) { + $this->close(); + } + + $codec = $this->mailboxNameCodec(); + + // RENAME returns no untagged data (RFC 3501 §6.3.5). + $this->interaction->send('RENAME', [ + new ImapWireMailbox($old, $codec), + new ImapWireMailbox($new, $codec), + ]); + } + + /** + * Subscribe to or unsubscribe from a mailbox (RFC 3501 §6.3.6-6.3.7). + * + * @throws ServerResponseException If the server rejects the command. + */ + public function subscribeMailbox(string $mailbox, bool $subscribe = true): void + { + $this->connect(); + + // SUBSCRIBE/UNSUBSCRIBE return no untagged data (RFC 3501 + // §6.3.6-6.3.7). + $this->interaction->send( + $subscribe ? 'SUBSCRIBE' : 'UNSUBSCRIBE', + [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())], + ); + } + + /** + * List mailboxes matching a pattern (RFC 3501 §6.3.8, RFC 5258). + * + * Uses the `LIST-EXTENDED` (RFC 5258) form when the server advertises + * it, with `LIST (SUBSCRIBED)`/`RETURN (SUBSCRIBED)` selecting or + * annotating subscription state. Otherwise it falls back to the base + * RFC 3501 commands: `LSUB` for the subscribed-only modes, `LIST` + * otherwise. `LIST (SUBSCRIBED)` is additive on top of ordinary + * `LIST` parsing, not a separate response format. + * + * The result mirrors the legacy shape so existing consumers keep + * working. With the `flat` option it is a `list` of matching + * mailbox names (UTF-8). Otherwise it is an + * `array, status?: array}>` + * keyed by mailbox name, with `attributes` present when the caller + * asked for them (the `attributes` option) or the server volunteered + * any, and `status` present when the `status` option was requested and + * the server supports `LIST-STATUS` (RFC 5819). + * + * `LIST-EXTENDED` return/select options are driven by the options: + * `children` (`RETURN (CHILDREN)`), `special_use` + * (`RETURN (SPECIAL-USE)`), `remote` (select `REMOTE`), + * `recursivematch` (select `RECURSIVEMATCH`), and `status` (a + * {@see StatusFlag} bitmask requesting `RETURN (STATUS (...))`). All + * are ignored gracefully when the server lacks the relevant capability. + * + * @param array{ + * flat?: bool, + * attributes?: bool, + * children?: bool, + * special_use?: bool, + * remote?: bool, + * recursivematch?: bool, + * status?: int + * } $options + * + * @return array + * + * @throws ServerResponseException If the server rejects the command. + */ + public function listMailboxes(string $pattern, MailboxListMode $mode, array $options = []): array + { + $this->connect(); + + $flat = !empty($options['flat']); + $wantAttributes = $flat ? false : !empty($options['attributes']); + $useExtended = $this->getCapability()->query('LIST-EXTENDED'); + + $command = $useExtended + ? $this->buildExtendedListCommand($pattern, $mode, $options) + : $this->buildBaseListCommand($pattern, $mode); + + $result = $this->interaction->send($command['name'], $command['arguments']); + + $list = $this->parseListResponses($result->untagged, $mode, $flat, $wantAttributes, $useExtended); + + // LIST-STATUS (RFC 5819) interleaves * STATUS lines; fold them + // into each entry when the caller asked for status. + if (!$flat && !empty($options['status']) && $this->getCapability()->query('LIST-STATUS')) { + $this->attachListStatus($list, $result->untagged); + } + + return $list; + } + + /** + * List mailboxes matching several patterns in one pipelined burst + * (RFC 3501 §5.5), the batch form of {@see listMailboxes()}. Each + * pattern's `LIST` command is issued together and the matches are + * merged into one result keyed by mailbox name. + * + * Only used when the server supports `LIST-EXTENDED` (so each LIST + * carries no continuation and can be pipelined) and more than one + * pattern is given; otherwise it falls back to issuing + * {@see listMailboxes()} per pattern and merging. + * + * @param list $patterns + * @param array{ + * flat?: bool, attributes?: bool, children?: bool, + * special_use?: bool, remote?: bool, recursivematch?: bool, status?: int + * } $options + * + * @return array + */ + public function listMailboxesMulti(array $patterns, MailboxListMode $mode, array $options = []): array + { + $this->connect(); + + if (count($patterns) <= 1) { + return $patterns === [] ? [] : $this->listMailboxes($patterns[0], $mode, $options); + } + + $flat = !empty($options['flat']); + + if (!$this->getCapability()->query('LIST-EXTENDED')) { + // Base LIST/LSUB carries no RETURN options; merge per-pattern + // results (still correct, just one round trip per pattern). + $merged = []; + + foreach ($patterns as $pattern) { + foreach ($this->listMailboxes($pattern, $mode, $options) as $key => $entry) { + if ($flat) { + $merged[] = $entry; + } else { + $merged[$key] = $entry; + } + } + } + + return $merged; + } + + $wantAttributes = $flat ? false : !empty($options['attributes']); + $commands = []; + + foreach ($patterns as $pattern) { + $built = $this->buildExtendedListCommand($pattern, $mode, $options); + $commands[] = new ImapCommand($this->interaction->newTag(), $built['name'], $built['arguments']); + } + + $results = $this->interaction->sendPipeline($commands); + + // Merge every command's untagged responses, then parse once. + $untagged = []; + foreach ($results as $result) { + array_push($untagged, ...$result->untagged); + } + + $list = $this->parseListResponses($untagged, $mode, $flat, $wantAttributes, true); + + if (!$flat && !empty($options['status']) && $this->getCapability()->query('LIST-STATUS')) { + $this->attachListStatus($list, $untagged); + } + + return $list; + } + + /** + * Query the server's namespaces (RFC 2342). + * + * Returns an {@see ImapNamespaceList}. When the server does not + * advertise the `NAMESPACE` capability the list is empty, matching + * the legacy behaviour of returning an empty namespace list rather + * than raising. + */ + public function getNamespaces(): ImapNamespaceList + { + $this->connect(); + + if (!$this->getCapability()->query('NAMESPACE')) { + return new ImapNamespaceList(); + } + + $result = $this->interaction->send('NAMESPACE'); + + foreach ($result->untagged as $response) { + if ( + $response->isUntagged() + && $response->data !== [] + && is_string($response->data[0]) + && strtoupper($response->data[0]) === 'NAMESPACE' + ) { + return $this->parseNamespaceResponse($response->data); + } + } + + return new ImapNamespaceList(); + } + + /** + * Search the mailbox (RFC 3501 §6.4.4). + * + * Uses the ESEARCH form (`SEARCH RETURN (...)`, RFC 4731) when the + * server advertises `ESEARCH`, requesting the return items that + * cover the caller's `results`; otherwise it sends a classic + * `SEARCH` and {@see ImapSearchParser} derives count/min/max from the + * matching set. The `$results` option is a list of + * {@see SearchResultType} cases (defaulting to just the match set). + * + * The caller must have opened `$mailbox` first (via + * {@see openMailbox()}); `search()` does not implicitly `SELECT`. The + * `$mailbox` argument is accepted for interface parity and to make the + * intent explicit at the call site. + * + * When the `sort` option (a list of {@see SortCriteria}) is given, a + * server-side `SORT`/`UID SORT` (RFC 5256) is sent instead, using + * ESORT (RFC 5267) when advertised; the result's `match` set + * preserves the server's ordering. Client-side sorting is not + * implemented, so a server without `SORT` raises rather than falling + * back. + * + * @param ImapSearchQuery|object $query An {@see ImapSearchQuery}. + * @param array{ + * sequence?: bool, + * results?: list, + * sort?: list + * } $options + * + * @throws ImapProtocolException If `$query` is not an {@see ImapSearchQuery}. + * @throws CapabilityNotSupportedException If `sort` is requested but the server lacks SORT. + * @throws ServerResponseException If the server rejects the SEARCH/SORT. + */ + public function search(string $mailbox, object $query, array $options = []): ImapSearchResult + { + $this->connect(); + + if (!$query instanceof ImapSearchQuery) { + throw new ImapProtocolException('search() requires an ImapSearchQuery.'); + } + + $sequence = !empty($options['sequence']); + $results = $options['results'] ?? [SearchResultType::Match]; + $built = $query->build(); + + if (!empty($options['sort'])) { + return $this->sortSearch($query, $built, $options['sort'], $results, $sequence); + } + + $command = $sequence ? 'SEARCH' : 'UID SEARCH'; + + try { + $result = $this->interaction->send($command, $this->searchArguments($built, $results, $sequence)); + } catch (ServerResponseException $e) { + // RFC 3501 §6.4.4: a rejected non-ASCII CHARSET yields + // NO [BADCHARSET (charset ...)]. Re-encode the search text + // into a charset the server accepts and retry. A UTF-8 client + // never carries a CHARSET, so it cannot reach this. + $result = $this->retryBadCharset($e, $query, $built, $results, $sequence, $command); + } + + return ImapSearchParser::parse($result->untagged, $sequence); + } + + /** + * Retry a search after a `NO [BADCHARSET (...)]`, re-encoding its text + * into the first server-offered charset that can represent it without + * loss (a lossy charset such as US-ASCII would silently corrupt the + * search terms, so it is skipped). Re-throws the original error when + * the failure was not a BADCHARSET or no usable charset was offered. + * + * @param array{charset: ?string, criteria: list} $built + * @param list $results + * + * @throws ServerResponseException + */ + private function retryBadCharset( + ServerResponseException $e, + ImapSearchQuery $query, + array $built, + array $results, + bool $sequence, + string $command, + ): ImapCommandResult { + $code = $e->responseCode; + + if ($built['charset'] === null || $code === null || strtoupper($code->name) !== 'BADCHARSET') { + throw $e; + } + + foreach ($this->badCharsetOffered($code) as $charset) { + if (strtoupper($charset) === strtoupper($built['charset']) || !$query->canEncodeIn($charset)) { + continue; + } + + $retryBuilt = $query->build($charset); + + return $this->interaction->send($command, $this->searchArguments($retryBuilt, $results, $sequence)); + } + + throw $e; + } + + /** + * The charsets a `[BADCHARSET (...)]` response code offered, in order. + * The list is normally parenthesized; bare trailing tokens are also + * accepted defensively. + * + * @return list + */ + private function badCharsetOffered(ImapResponseCode $code): array + { + $offered = []; + + foreach ($code->data as $token) { + if (is_array($token)) { + foreach ($token as $charset) { + if (is_string($charset)) { + $offered[] = $charset; + } + } + } elseif (is_string($token)) { + $offered[] = $token; + } + } + + return $offered; + } + + /** + * Assemble the argument list for a `[UID] SEARCH` command from a built + * query: optional ESEARCH `RETURN (...)`, an optional `CHARSET`, and + * the criteria (or `ALL`). + * + * @param array{charset: ?string, criteria: list} $built + * @param list $results + * + * @return list + */ + private function searchArguments(array $built, array $results, bool $sequence): array + { + $arguments = []; + + if ($this->getCapability()->query('ESEARCH')) { + $arguments[] = new ImapWireAtom('RETURN'); + $arguments[] = new ImapWireList($this->searchReturnOptions($results)); + } + + // RFC 3501 §6.4.4: CHARSET is optional and only carried when the + // query holds text. RFC 6855 §3: once the connection is in UTF-8 + // mode (UTF8=ACCEPT, or IMAP4rev2 which implies it) the client + // MUST NOT send a CHARSET, since UTF-8 is implied. + if ($built['charset'] !== null && !$this->utf8Enabled()) { + $arguments[] = new ImapWireAtom('CHARSET'); + $arguments[] = new ImapWireAtom($built['charset']); + } + + if ($built['criteria'] === []) { + $arguments[] = new ImapWireAtom('ALL'); + } else { + array_push($arguments, ...$built['criteria']); + } + + return $arguments; + } + + /** + * Update message flags (RFC 3501 §6.4.6). + * + * Pass `add`/`remove` flag lists (`+FLAGS`/`-FLAGS`) or a `replace` + * list (`FLAGS`); flags are {@see SystemFlag} cases or plain keyword + * strings. `.SILENT` is used by default so the server does not echo a + * FLAGS FETCH per message; pass `silent: false` in `$options` to see + * them. `unchangedsince` gates the store on CONDSTORE (RFC 7162): + * messages whose MODSEQ changed meanwhile are skipped and returned in + * the result set (from the tagged `[MODIFIED ...]` code). + * + * @param array{ + * ids?: MessageIdSet, + * add?: list, + * remove?: list, + * replace?: list, + * sequence?: bool, + * silent?: bool, + * unchangedsince?: int + * } $options + * + * @return MessageIdSet The messages NOT updated because their MODSEQ + * changed (CONDSTORE); empty on an ordinary store. + * + * @throws ServerResponseException If the server rejects the STORE. + */ + public function store(string $mailbox, array $options): MessageIdSet + { + $this->connect(); + + $ids = $options['ids'] ?? null; + // The sequence/UID mode follows the id set when one is given + // (mirroring fetch()), falling back to the explicit option. + $sequence = $ids instanceof ImapIdSet ? $ids->isSequence() : !empty($options['sequence']); + $ids ??= new ImapIdSet([], $sequence); + $silent = $options['silent'] ?? true; + + $items = $this->storeItems($options, $silent); + + if ($items === [] || $ids->isEmpty()) { + return new ImapIdSet([], $sequence); + } + + $command = $sequence ? 'STORE' : 'UID STORE'; + $modified = new ImapIdSet([], $sequence); + + foreach ($items as [$key, $flags]) { + $arguments = [new ImapWireAtom((string) $ids)]; + + if (isset($options['unchangedsince'])) { + $arguments[] = new ImapWireList([ + new ImapWireAtom('UNCHANGEDSINCE'), + new ImapWireNumber((int) $options['unchangedsince']), + ]); + } + + $arguments[] = new ImapWireAtom($key); + $arguments[] = $flags; + + $result = $this->interaction->send($command, $arguments); + $modified = $this->mergeModified($modified, $result->tagged, $sequence); + } + + // A flag change makes any cached flags for these messages stale. + // Drop their cache entries (UID mode only, the cache keys on UID); + // the next fetch re-warms them. Over-invalidating the immutable + // fields is cheap and keeps this unambiguously correct. + if (!$sequence && $this->cache !== null && $ids instanceof ImapIdSet && !$ids->isSpecial()) { + $this->cache->deleteMsgs($this->selectedMailbox ?? '', $ids->toArray()); + } + + return $modified; + } + + /** + * Permanently remove messages flagged `\Deleted` (RFC 3501 §6.4.3), + * or a UID subset of them with `UID EXPUNGE` (UIDPLUS, RFC 4315). + * + * With `delete: true` the requested `ids` are flagged `\Deleted` + * first. With `list: true` the expunged messages are collected and + * returned: sequence numbers from `* n EXPUNGE` on a plain + * connection, or UIDs from `* VANISHED` once QRESYNC is enabled + * (RFC 7162 §3.2.10). Cache-driven expunge bookkeeping is a + * separate concern (see {@see ImapCacheStore}). + * + * @param array{ids?: MessageIdSet, delete?: bool, list?: bool, sequence?: bool} $options + * + * @return MessageIdSet The expunged messages when `list` was set + * (UIDs under QRESYNC, else sequence numbers), + * otherwise empty. + * + * @throws ServerResponseException If the server rejects the command. + */ + public function expunge(string $mailbox, array $options = []): MessageIdSet + { + $this->connect(); + + $ids = $options['ids'] ?? null; + $uidExpunge = $this->getCapability()->query('UIDPLUS') + && $ids instanceof ImapIdSet + && !$ids->isEmpty() + && !$ids->isSequence(); + + if (!empty($options['delete']) && $ids instanceof ImapIdSet && !$ids->isEmpty()) { + $this->store($mailbox, ['ids' => $ids, 'add' => [SystemFlag::Deleted]]); + } + + if ($uidExpunge) { + // RFC 4315 §2.1: UID EXPUNGE only touches the given UIDs. + $result = $this->interaction->send('UID EXPUNGE', [new ImapWireAtom((string) $ids)]); + } else { + $result = $this->interaction->send('EXPUNGE'); + } + + // Drop expunged messages from the cache. VANISHED (QRESYNC) always + // gives UIDs; a UID EXPUNGE targeted a known UID set; a plain + // EXPUNGE reports sequence numbers, which are not cache keys, so a + // full-mailbox EXPUNGE conservatively drops the mailbox cache. + $this->invalidateExpunged($mailbox, $ids, $uidExpunge, $result->untagged); + + // Signal external listeners such as an application-level + // search-result cache with the removed set, independent of the + // caller's `list` option. The set is UIDs when the server reported + // them (VANISHED / UID EXPUNGE) and sequence numbers otherwise. The + // event exposes which via ImapIdSet::isSequence(). + $removed = $this->collectExpunged($result->untagged, true); + $this->dispatch(new MailboxExpunged($mailbox, $removed, $this->selectedUidValidity)); + + return $this->collectExpunged($result->untagged, !empty($options['list'])); + } + + /** + * Copy messages to another mailbox (RFC 3501 §6.4.7). + * + * @param array{ids?: MessageIdSet, create?: bool} $options + * + * @return MessageIdSet The destination UIDs from a `[COPYUID ...]` + * response (UIDPLUS, RFC 4315), or empty when the + * server does not report them. + * + * @throws ServerResponseException If the server rejects the COPY. + */ + public function copy(string $source, string $dest, array $options = []): MessageIdSet + { + return $this->copyOrMove($dest, $options, move: false); + } + + /** + * Move messages to another mailbox. + * + * Uses the `MOVE` command (RFC 6851) when the server advertises it, + * otherwise falls back to `COPY` followed by expunging the source + * messages (flagging them `\Deleted` and running `UID EXPUNGE`). + * + * @param array{ids?: MessageIdSet, create?: bool} $options + * + * @return MessageIdSet The destination UIDs from a `[COPYUID ...]` + * response, or empty when unreported. + * + * @throws ServerResponseException If the server rejects the command. + */ + public function move(string $source, string $dest, array $options = []): MessageIdSet + { + return $this->copyOrMove($dest, $options, move: true); + } + + /** + * Append one or more messages to a mailbox (RFC 3501 §6.3.11). + * + * Each `$data` entry is + * `['data' => string, 'flags' => list, 'internaldate' => DateTimeInterface]` + * (only `data` is required). Alternatively `data` may be a CATENATE + * (RFC 4469) parts list: `['catenate' => [['text' => '...'], + * ['url' => 'imap://...'], ...]]`, assembled server-side when the + * server advertises `CATENATE`. + * + * Extensions used when advertised: `MULTIAPPEND` (RFC 3502, all + * messages in one command), `literal8`/`~{n}` (RFC 3516, automatic for + * 8-bit bodies), and the `UTF8 (...)` wrapper (RFC 6855) once + * UTF8=ACCEPT is enabled. `create: true` creates the mailbox and + * retries on `[TRYCREATE]`. + * + * @param list, flags?: list, internaldate?: DateTimeInterface}> $data + * @param array{create?: bool} $options + * + * @return MessageIdSet The new UIDs from `[APPENDUID ...]` responses + * (UIDPLUS, RFC 4315), or empty when unreported. + * + * @throws CapabilityNotSupportedException If a `url` CATENATE part is + * used without server CATENATE. + * @throws ServerResponseException If the server rejects the APPEND. + */ + public function append(string $mailbox, array $data, array $options = []): MessageIdSet + { + $this->connect(); + + $create = !empty($options['create']); + + // MULTIAPPEND (RFC 3502): one command carrying every message. + if (count($data) > 1 && $this->getCapability()->query('MULTIAPPEND')) { + return $this->sendAppend($mailbox, $data, $create); + } + + $uids = []; + + foreach ($data as $message) { + foreach ($this->sendAppend($mailbox, [$message], $create)->toArray() as $uid) { + $uids[] = $uid; + } + } + + return new ImapIdSet($uids, false); + } + + /** + * Send one APPEND command carrying one or more messages (the latter + * only under MULTIAPPEND), handling the `[TRYCREATE]` retry. + * + * @param list> $messages + */ + private function sendAppend(string $mailbox, array $messages, bool $create): MessageIdSet + { + $arguments = [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]; + + foreach ($messages as $message) { + array_push($arguments, ...$this->appendMessageArguments($message)); + } + + try { + $result = $this->interaction->send('APPEND', $arguments); + } catch (ServerResponseException $e) { + if ($create && $this->hasTryCreate($e)) { + $this->createMailbox($mailbox); + + return $this->sendAppend($mailbox, $messages, false); + } + + throw $e; + } + + // MULTIAPPEND returns one [APPENDUID validity uid1,uid2,...] code. + return $this->parseUidPlus($result->tagged, 'APPENDUID', 1); + } + + /** + * The wire arguments for one appended message: optional flags, + * optional internaldate, then the message content (a plain literal, a + * `UTF8 (...)` wrapper under UTF8=ACCEPT, or a `CATENATE (...)` list). + * + * @param array $message + * + * @return list + */ + private function appendMessageArguments(array $message): array + { + $arguments = []; + + if (!empty($message['flags']) && is_array($message['flags'])) { + $arguments[] = $this->flagsList($message['flags']); + } + + if (isset($message['internaldate']) && $message['internaldate'] instanceof DateTimeInterface) { + // RFC 3501 date-time: 1-Feb-1994 12:00:00 +0000, quoted. + $arguments[] = new ImapWireString($message['internaldate']->format('j-M-Y H:i:s O')); + } + + if (isset($message['catenate']) && is_array($message['catenate'])) { + $arguments[] = new ImapWireAtom('CATENATE'); + $arguments[] = $this->catenateList($message['catenate']); + + return $arguments; + } + + $content = new ImapWireString(is_string($message['data'] ?? null) ? $message['data'] : ''); + + // RFC 6855 §4: under UTF8=ACCEPT, an appended message is wrapped as + // UTF8 (...). literal8 (~{n}) is chosen automatically by the wire + // layer for 8-bit content (RFC 3516). + $arguments[] = $this->utf8Enabled() + ? new ImapWireList([new ImapWireAtom('UTF8'), new ImapWireList([$content])]) + : $content; + + return $arguments; + } + + /** + * Build a CATENATE parts list (RFC 4469): `(TEXT {n}... URL url ...)`. + * A `url` part requires the server to support CATENATE (it resolves + * the URL server-side); this library does not fetch-and-inline URLs + * client-side. + * + * @param list $parts + * + * @throws CapabilityNotSupportedException If a URL part is used but the + * server lacks CATENATE. + */ + private function catenateList(array $parts): ImapWireList + { + $hasUrl = false; + + foreach ($parts as $part) { + if (isset($part['url'])) { + $hasUrl = true; + } + } + + if (!$this->getCapability()->query('CATENATE') && $hasUrl) { + throw new CapabilityNotSupportedException( + 'A URL CATENATE part requires the server CATENATE extension (RFC 4469).' + ); + } + + $list = new ImapWireList(); + + foreach ($parts as $part) { + if (isset($part['url'])) { + $list->add(new ImapWireAtom('URL')); + $list->add(new ImapWireString($part['url'], isAstring: true)); + } elseif (isset($part['text'])) { + $list->add(new ImapWireAtom('TEXT')); + $list->add(new ImapWireString($part['text'])); + } + } + + return $list; + } + + /** + * Thread the mailbox (RFC 5256). + * + * Sends `[UID] THREAD `. The + * `criteria` option chooses the algorithm as a {@see ThreadAlgorithm} + * case or a bare string (`ORDEREDSUBJECT`, `REFERENCES`, `REFS`), + * defaulting to ORDEREDSUBJECT. The `search` option narrows the + * threaded set with an {@see ImapSearchQuery}; without it, `ALL` is + * threaded. `sequence` selects sequence-number vs UID ids. + * + * The server must advertise the chosen algorithm via `THREAD=` + * (RFC 5256 §3); this does not implement the client-side + * ORDEREDSUBJECT fallback the legacy driver offered. + * + * @param array{criteria?: ThreadAlgorithm|string, search?: ImapSearchQuery, sequence?: bool} $options + * + * @throws CapabilityNotSupportedException If the server does not advertise the algorithm. + * @throws ServerResponseException If the server rejects the THREAD. + */ + public function thread(string $mailbox, array $options = []): ImapThreadResult + { + $this->connect(); + + $algorithm = $this->threadAlgorithm($options['criteria'] ?? ThreadAlgorithm::OrderedSubject); + + if (!$this->getCapability()->query('THREAD', $algorithm)) { + throw new CapabilityNotSupportedException( + "The server does not advertise the '{$algorithm}' THREAD algorithm (RFC 5256)." + ); + } + + $sequence = !empty($options['sequence']); + $command = $sequence ? 'THREAD' : 'UID THREAD'; + + $arguments = [new ImapWireAtom($algorithm)]; + $search = $options['search'] ?? null; + + // Charset is mandatory for THREAD (RFC 5256 §3). Under UTF-8 mode + // the client MUST send UTF-8 (RFC 6855 §3); otherwise a query's + // own charset, falling back to US-ASCII. + $built = $search instanceof ImapSearchQuery ? $search->build() : ['charset' => null, 'criteria' => []]; + $arguments[] = new ImapWireAtom($this->utf8Enabled() ? 'UTF-8' : ($built['charset'] ?? 'US-ASCII')); + + if ($built['criteria'] === []) { + $arguments[] = new ImapWireAtom('ALL'); + } else { + array_push($arguments, ...$built['criteria']); + } + + $result = $this->interaction->send($command, $arguments); + + return ImapThreadParser::parse($result->untagged, $sequence); + } + + /** + * Enable the QRESYNC extension (RFC 7162 §3.1) on this connection. + * + * QRESYNC requires CONDSTORE and enabling it implicitly enables + * CONDSTORE too. Must be called after login (ENABLE is only valid in + * the authenticated state). Returns true when the server acknowledged + * it. + * + * @throws CapabilityNotSupportedException If the server does not advertise QRESYNC. + */ + public function enableQresync(): bool + { + $this->connect(); + $capability = $this->getCapability(); + + if ($capability->isEnabled('QRESYNC')) { + return true; + } + + if (!$capability->query('QRESYNC')) { + throw new CapabilityNotSupportedException( + 'The server does not advertise QRESYNC (RFC 7162).' + ); + } + + return in_array('QRESYNC', $this->negotiator->enable($capability, ['QRESYNC']), true); + } + + /** + * Report which of a set of UIDs have been expunged since a given + * modification sequence, using QRESYNC's `VANISHED` FETCH modifier + * (RFC 7162 §3.2.5). + * + * Sends `UID FETCH (UID) (VANISHED CHANGEDSINCE )` and + * collects the resulting `* VANISHED (EARLIER) ...` UIDs. Defaults to + * checking every UID (`1:*`). Requires QRESYNC to be enabled first + * (via {@see enableQresync()}), and the caller to have the mailbox + * open. + * + * @param array{ids?: ImapIdSet} $options + * + * @throws CapabilityNotSupportedException If QRESYNC is not enabled. + * @throws ServerResponseException If the server rejects the FETCH. + */ + public function vanished(string $mailbox, int $modseq, array $options = []): ImapIdSet + { + $this->connect(); + + if (!$this->getCapability()->isEnabled('QRESYNC')) { + throw new CapabilityNotSupportedException( + 'VANISHED requires QRESYNC to be enabled first (RFC 7162); call enableQresync().' + ); + } + + $ids = $options['ids'] ?? new ImapIdSet(ImapIdSetToken::All, false); + + $arguments = [ + new ImapWireAtom((string) $ids), + new ImapWireAtom('UID'), + new ImapWireList([ + new ImapWireAtom('VANISHED'), + new ImapWireAtom('CHANGEDSINCE'), + new ImapWireNumber($modseq), + ]), + ]; + + $result = $this->interaction->send('UID FETCH', $arguments); + + return ImapVanishedParser::parse($result->untagged); + } + + /** + * Take an opaque sync token capturing a mailbox's current state, for + * a later {@see sync()} to diff against. + * + * The token records the mailbox's UIDVALIDITY, HIGHESTMODSEQ (0 when + * the server lacks CONDSTORE) and UIDNEXT. It is a base64 string with + * no guaranteed internal format; treat it as opaque. + * + * @throws ServerResponseException If the STATUS call fails. + */ + public function getSyncToken(string $mailbox): string + { + $this->connect(); + + $status = $this->status( + $mailbox, + StatusFlag::UidValidity->value | StatusFlag::UidNext->value | StatusFlag::HighestModSeq->value, + ); + + $parts = [ + 'V' . (int) ($status->uidvalidity ?? 0), + 'H' . (int) ($status->highestmodseq ?? 0), + 'U' . (int) ($status->uidnext ?? 0), + ]; + + return base64_encode(implode(',', $parts)); + } + + /** + * Report what changed in a mailbox since a {@see getSyncToken()} was + * taken (the modern, cache-free equivalent of the legacy + * `Horde_Imap_Client_Base::sync()`). + * + * Built entirely on the existing wire primitives: + * `STATUS` for the current state, `SEARCH` for new messages + * (`UID :*`) and flag changes (`MODSEQ `, + * CONDSTORE), and `vanished()` for expunged UIDs (QRESYNC). It holds + * no cached state of its own; the caller supplies the token. + * + * @param list $criteria Which change classes to report + * (default: all three). + * + * @throws SyncException If the token is malformed or the + * mailbox's UIDVALIDITY changed + * (a full resync is then required). + * @throws ServerResponseException If a server command fails. + */ + public function sync(string $mailbox, string $token, array $criteria = []): ImapSyncResult + { + $this->connect(); + + $parsed = $this->decodeSyncToken($token); + $criteria = $criteria === [] + ? [SyncCriteria::NewMessages, SyncCriteria::FlagChanges, SyncCriteria::Vanished] + : $criteria; + + $status = $this->status( + $mailbox, + StatusFlag::UidValidity->value | StatusFlag::UidNext->value | StatusFlag::HighestModSeq->value, + ); + $currentUidValidity = (int) ($status->uidvalidity ?? 0); + + if ($parsed['V'] === 0 || $currentUidValidity !== $parsed['V']) { + throw new SyncException( + 'The mailbox UIDVALIDITY has changed; a full resynchronization is required (RFC 3501 §2.3.1.1).' + ); + } + + $empty = new ImapIdSet([], false); + $newMsgs = $flagChanges = $vanished = $empty; + + if (in_array(SyncCriteria::NewMessages, $criteria, true) && $parsed['U'] > 0) { + $query = (new ImapSearchQuery())->uidFrom($parsed['U']); + $newMsgs = $this->search($mailbox, $query)->match; + } + + // Flag changes and vanished both hinge on CONDSTORE/MODSEQ; with + // no server modseq there is nothing reliable to diff. + if ($parsed['H'] > 0) { + if (in_array(SyncCriteria::FlagChanges, $criteria, true)) { + $query = (new ImapSearchQuery())->modseq($parsed['H'] + 1); + $flagChanges = $this->search($mailbox, $query)->match; + } + + if (in_array(SyncCriteria::Vanished, $criteria, true) && $this->getCapability()->isEnabled('QRESYNC')) { + $vanished = $this->vanished($mailbox, $parsed['H']); + } + } + + return new ImapSyncResult($newMsgs, $flagChanges, $vanished); + } + + /** + * Get the access control list of a mailbox (RFC 4314 §3.3, `GETACL`). + * + * @return array Rights keyed by identifier. A + * negative right entry keeps its + * leading `-` in the key. + * + * @throws CapabilityNotSupportedException If the server lacks ACL. + * @throws ServerResponseException If the server rejects the command. + */ + public function getACL(string $mailbox): array + { + $this->requireCapability('ACL'); + $result = $this->interaction->send('GETACL', [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]); + + foreach ($result->untagged as $response) { + if ($this->isUntaggedNamed($response, 'ACL')) { + return $this->parseAclResponse($response->data); + } + } + + return []; + } + + /** + * Set the rights of an identifier on a mailbox (RFC 4314 §3.1, + * `SETACL`). + * + * The `rights` option is the right string; prefix a right modifier + * (`+`/`-`) as RFC 4314 §3.1 allows to add or remove rather than + * replace. + * + * @param array{rights?: string} $options + * + * @throws CapabilityNotSupportedException If the server lacks ACL. + */ + public function setACL(string $mailbox, string $identifier, array $options): void + { + $this->requireCapability('ACL'); + + // SETACL returns no untagged data (RFC 4314 §3.1). + $this->interaction->send('SETACL', [ + new ImapWireMailbox($mailbox, $this->mailboxNameCodec()), + new ImapWireString($identifier, isAstring: true), + new ImapWireString($options['rights'] ?? '', isAstring: true), + ]); + } + + /** + * Remove an identifier's rights from a mailbox (RFC 4314 §3.2, + * `DELETEACL`). + * + * @throws CapabilityNotSupportedException If the server lacks ACL. + */ + public function deleteACL(string $mailbox, string $identifier): void + { + $this->requireCapability('ACL'); + + // DELETEACL returns no untagged data (RFC 4314 §3.2). + $this->interaction->send('DELETEACL', [ + new ImapWireMailbox($mailbox, $this->mailboxNameCodec()), + new ImapWireString($identifier, isAstring: true), + ]); + } + + /** + * List the rights that can be granted to an identifier on a mailbox + * (RFC 4314 §3.7, `LISTRIGHTS`). + * + * @throws CapabilityNotSupportedException If the server lacks ACL. + */ + public function listACLRights(string $mailbox, string $identifier): ImapAclRights + { + $this->requireCapability('ACL'); + $result = $this->interaction->send('LISTRIGHTS', [ + new ImapWireMailbox($mailbox, $this->mailboxNameCodec()), + new ImapWireString($identifier, isAstring: true), + ]); + + foreach ($result->untagged as $response) { + if ($this->isUntaggedNamed($response, 'LISTRIGHTS')) { + // data: LISTRIGHTS mailbox identifier required optional... + $rights = array_values(array_filter( + array_slice($response->data, 3), + static fn ($value): bool => is_string($value), + )); + + $required = $rights === [] ? '' : array_shift($rights); + + return new ImapAclRights(str_split($required), $rights); + } + } + + return new ImapAclRights(); + } + + /** + * The current user's rights on a mailbox (RFC 4314 §3.8, `MYRIGHTS`). + * + * @throws CapabilityNotSupportedException If the server lacks ACL. + */ + public function getMyACLRights(string $mailbox): ImapAcl + { + $this->requireCapability('ACL'); + $result = $this->interaction->send('MYRIGHTS', [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]); + + foreach ($result->untagged as $response) { + if ($this->isUntaggedNamed($response, 'MYRIGHTS') && isset($response->data[2]) && is_string($response->data[2])) { + return new ImapAcl($response->data[2]); + } + } + + return new ImapAcl(); + } + + /** + * Set resource limits on a quota root (RFC 9208 §5.1, `SETQUOTA`). + * + * @param array $resources Resource name => limit. + * + * @throws CapabilityNotSupportedException If the server lacks QUOTA. + */ + public function setQuota(string $root, array $resources): void + { + $this->requireCapability('QUOTA'); + + $limits = new ImapWireList(); + + foreach ($resources as $name => $limit) { + // RFC 9208 §5.1: a flat (resource limit resource limit ...) + // list, not nested per-resource groups. + $limits->add(new ImapWireAtom(strtoupper($name))); + $limits->add(new ImapWireNumber($limit)); + } + + $this->interaction->send('SETQUOTA', [ + new ImapWireMailbox($root, $this->mailboxNameCodec()), + $limits, + ]); + } + + /** + * Get the resource usage and limits of a quota root (RFC 9208 §5.2, + * `GETQUOTA`). + * + * @return array> + * Keyed by quota root, then resource name. + * + * @throws CapabilityNotSupportedException If the server lacks QUOTA. + */ + public function getQuota(string $root): array + { + $this->requireCapability('QUOTA'); + $result = $this->interaction->send('GETQUOTA', [new ImapWireMailbox($root, $this->mailboxNameCodec())]); + + return $this->parseQuotaResponses($result->untagged); + } + + /** + * Get the quota roots that apply to a mailbox and their resource + * usage (RFC 9208 §5.3, `GETQUOTAROOT`). + * + * @return array> + * + * @throws CapabilityNotSupportedException If the server lacks QUOTA. + */ + public function getQuotaRoot(string $mailbox): array + { + $this->requireCapability('QUOTA'); + $result = $this->interaction->send('GETQUOTAROOT', [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]); + + return $this->parseQuotaResponses($result->untagged); + } + + /** + * Get metadata entries for a mailbox (RFC 5464 §4.2, `GETMETADATA`). + * An empty `$mailbox` reads server metadata (`METADATA-SERVER`). + * + * @param list $entries Entry names. + * @param array{maxsize?: int, depth?: int|string} $options + * + * @return array> Values keyed by + * mailbox, then entry name. + * + * @throws CapabilityNotSupportedException If the server lacks METADATA. + */ + public function getMetadata(string $mailbox, array $entries, array $options = []): array + { + $this->requireMetadataCapability($mailbox); + + $arguments = [new ImapWireMailbox($mailbox, $this->mailboxNameCodec())]; + $cmdOptions = new ImapWireList(); + + if (!empty($options['maxsize'])) { + $cmdOptions->add(new ImapWireAtom('MAXSIZE')); + $cmdOptions->add(new ImapWireNumber((int) $options['maxsize'])); + } + + if (!empty($options['depth'])) { + $cmdOptions->add(new ImapWireAtom('DEPTH')); + $cmdOptions->add(new ImapWireAtom((string) $options['depth'])); + } + + if (count($cmdOptions) > 0) { + $arguments[] = $cmdOptions; + } + + $entryList = new ImapWireList(); + foreach ($entries as $entry) { + $entryList->add(new ImapWireString($entry, isAstring: true)); + } + $arguments[] = $entryList; + + $result = $this->interaction->send('GETMETADATA', $arguments); + + return $this->parseMetadataResponses($result->untagged); + } + + /** + * Set metadata entries on a mailbox (RFC 5464 §4.3, `SETMETADATA`). + * A null value removes an entry. + * + * @param array $data Entry name => value. + * + * @throws CapabilityNotSupportedException If the server lacks METADATA. + */ + public function setMetadata(string $mailbox, array $data): void + { + $this->requireMetadataCapability($mailbox); + + $entries = new ImapWireList(); + + foreach ($data as $entry => $value) { + $entries->add(new ImapWireString((string) $entry, isAstring: true)); + $entries->add($value === null ? new ImapWireNil() : new ImapWireNstring($value)); + } + + $this->interaction->send('SETMETADATA', [ + new ImapWireMailbox($mailbox, $this->mailboxNameCodec()), + $entries, + ]); + } + + /** + * Build an {@see ImapIdSet} from a caller-supplied ID argument. + * + * Mirrors {@see Pop3Client::getIdsOb()} + * `null` yields an empty set. + * An existing {@see MessageIdSet} is copied by value. + * A string is parsed as an IMAP sequence string (`1:5,7`, including the special `1:*`/`*`/`$` + * tokens). + * An array or scalar becomes an explicit list. + * + * @param iterable|string|int|ImapIdSetToken|MessageIdSet|null $ids + */ + public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSet + { + if ($ids === null) { + return new ImapIdSet([], $sequence); + } + + if ($ids instanceof ImapIdSet) { + return $sequence === $ids->isSequence() + ? $ids + : new ImapIdSet($ids->isSpecial() ? $ids->token() : $ids->toArray(), $sequence); + } + + if ($ids instanceof MessageIdSet) { + return new ImapIdSet($ids->toArray(), $sequence); + } + + if ($ids instanceof ImapIdSetToken) { + return new ImapIdSet($ids, $sequence); + } + + if (is_string($ids)) { + return ImapIdSet::fromSequenceString($ids, $sequence); + } + + return new ImapIdSet(is_iterable($ids) ? $ids : [$ids], $sequence); + } + + /** + * Fetch message data for a set of ids. + * + * Mirrors {@see Pop3Client::fetch()}: + * A generator yielding one result per message as the server sends it + * keyed by sequence number (when `$ids` is a sequence set) or by UID (otherwise). + * The `$query` must be an {@see ImapFetchQuery}. + * + * The caller is responsible for having opened `$mailbox` first (via + * {@see openMailbox()}) + * `fetch()` does not implicitly `SELECT`. + * An empty `$ids` set yields nothing and sends no command. + * + * When an {@see ImapCacheStore} was supplied, the cacheable fields + * (envelope, flags, header-field groups, internaldate, size, + * bodystructure: the same set the legacy driver cached) are written + * through on every fetch, and served from cache when the query asks + * only for those fields in UID mode. Cached flags are only trusted + * while the mailbox's HIGHESTMODSEQ is unchanged (CONDSTORE, RFC 7162); + * otherwise they are re-fetched. Body/text stream content (full + * message, body sections, MIME headers, raw parts) is never cached. + * + * @param ImapFetchQuery|object $query An {@see ImapFetchQuery}. + * + * @return Generator + * + * @throws ImapProtocolException If `$query` is not an {@see ImapFetchQuery}. + * @throws ServerResponseException If the server rejects the FETCH. + */ + public function fetch(string $mailbox, MessageIdSet $ids, object $query): Generator + { + if (!$query instanceof ImapFetchQuery) { + throw new ImapProtocolException('fetch() requires an ImapFetchQuery.'); + } + + $this->connect(); + + if ($ids->isEmpty()) { + return; + } + + $sequenceMode = $ids instanceof ImapIdSet && $ids->isSequence(); + + // Cache read-through is only possible in UID mode (the cache keys + // on UID) and only when every requested field is cacheable (a + // query wanting body/text content must hit the wire regardless). + $wantUids = !$sequenceMode && $this->cache !== null && $this->cacheServiceable($query) + ? $ids->toArray() + : []; + $servedFromCache = []; + + if ($wantUids !== []) { + foreach ($this->readFetchCache($wantUids, $query) as $uid => $result) { + $servedFromCache[$uid] = true; + yield $uid => $result; + } + } + + // Whatever the cache could not serve (or, in sequence mode, + // everything) is fetched from the wire. + $wireIds = $servedFromCache === [] + ? $ids + : new ImapIdSet(array_values(array_diff($wantUids, array_keys($servedFromCache))), false); + + if ($servedFromCache !== [] && $wireIds->isEmpty()) { + return; + } + + $items = $query->wireItems(); + + // A UID FETCH returns the UID implicitly (RFC 3501 §6.4.8) so it + // is only worth requesting explicitly in sequence mode and only + // if the caller asked for it. In UID mode the parser already + // has the UID from the response. + if ($sequenceMode && $query->wantsUid()) { + $items[] = 'UID'; + } + + // size() and imapDate() live in the shared FetchQueryFields trait + // and only set a flag; translate them to their IMAP wire items + // here (the trait has no concept of the wire form). + if ($query->wantsSize()) { + $items[] = 'RFC822.SIZE'; + } + + if ($query->wantsImapDate()) { + $items[] = 'INTERNALDATE'; + } + + if ($items === []) { + // A FETCH with an empty item list is invalid. Fall back to UID, + // the cheapest always-valid item. + $items[] = 'UID'; + } + + $command = $sequenceMode ? 'FETCH' : 'UID FETCH'; + $arguments = [ + new ImapWireAtom((string) $wireIds), + new ImapWireList($items), + ]; + + $result = $this->interaction->send($command, $arguments); + + foreach ($result->untagged as $response) { + $parsed = ImapFetchParser::parse($response, $query); + + if ($parsed === null) { + continue; + } + + // Write cacheable fields through (UID mode + cache present). + if (!$sequenceMode && $this->cache !== null) { + $this->writeFetchCache($parsed['result'], $query); + } + + yield ($sequenceMode ? $parsed['seq'] : $parsed['result']->getUid()) => $parsed['result']; + } + } + + /** + * Whether a query can be fully served from the metadata cache: it + * wants at least one cacheable field and no stream content, and a + * mailbox is open with a known UIDVALIDITY. + */ + private function cacheServiceable(ImapFetchQuery $query): bool + { + if ($this->cache === null || $this->selectedUidValidity === 0 || $query->wantsStreamContent()) { + return false; + } + + return $query->wantsEnvelope() + || $query->wantsFlags() + || $query->wantsStructure() + || $query->wantsModSeq() + || $query->wantsSize() + || $query->wantsImapDate() + || $query->headerLabels() !== []; + } + + /** + * Serve the requested UIDs from the cache, yielding one + * {@see ImapFetchResult} per fully-satisfiable UID. A UID missing any + * requested field (or whose cached flags are stale under CONDSTORE) is + * skipped so the caller fetches it from the wire. + * + * @param list $uids + * + * @return Generator + */ + private function readFetchCache(array $uids, ImapFetchQuery $query): Generator + { + $cached = $this->cache->get($this->selectedMailbox ?? '', $uids, [], $this->selectedUidValidity); + + foreach ($uids as $uid) { + $fields = $cached[$uid] ?? null; + + if ($fields === null) { + continue; + } + + $result = $this->fetchResultFromCache($uid, $fields, $query); + + if ($result !== null) { + yield $uid => $result; + } + } + } + + /** + * Rebuild an {@see ImapFetchResult} from cached fields, or null when a + * requested field is absent (or flags are stale). The freshness of + * cached flags is gated on the mailbox's current HIGHESTMODSEQ: if it + * advanced past the modseq the flags were cached at, another session + * may have changed them, so they are treated as missing (RFC 7162). + * + * @param array $fields + */ + private function fetchResultFromCache(int $uid, array $fields, ImapFetchQuery $query): ?ImapFetchResult + { + $result = new ImapFetchResult(0); + $result->setUid($uid); + + if ($query->wantsEnvelope()) { + if (!($fields['envelope'] ?? null) instanceof ImapEnvelope) { + return null; + } + $result->setEnvelope($fields['envelope']); + } + + if ($query->wantsStructure()) { + if (!($fields['structure'] ?? null) instanceof Part) { + return null; + } + $result->setStructure($fields['structure']); + } + + if ($query->wantsSize()) { + if (!isset($fields['size'])) { + return null; + } + $result->setSize((int) $fields['size']); + } + + if ($query->wantsImapDate()) { + if (!($fields['imapdate'] ?? null) instanceof DateTimeImmutable) { + return null; + } + $result->setImapDate($fields['imapdate']); + } + + if ($query->wantsModSeq()) { + if (!isset($fields['modseq'])) { + return null; + } + $result->setModSeq((int) $fields['modseq']); + } + + if ($query->wantsFlags()) { + $flagModseq = isset($fields['flags_modseq']) ? (int) $fields['flags_modseq'] : 0; + + // Trust cached flags only if the mailbox HIGHESTMODSEQ has not + // advanced since they were stored (CONDSTORE). Without a server + // modseq at all, flags cannot be validated and are refetched. + if ( + !isset($fields['flags']) + || !is_array($fields['flags']) + || $this->selectedHighestModSeq === 0 + || $flagModseq === 0 + || $flagModseq < $this->selectedHighestModSeq + ) { + return null; + } + + $result->setFlags(array_values(array_filter($fields['flags'], 'is_string'))); + } + + // Restore each requested header-field group's raw text. + foreach ($query->headerLabels() as $label) { + $stored = $fields['headers'][$label] ?? null; + + if (!is_string($stored)) { + return null; + } + + $result->setHeaders($label, $stored); + } + + return $result; + } + + /** + * Write a fetched result's cacheable fields through to the cache. + * Only fields actually requested (and present) are stored; stream + * content is never cached. + */ + private function writeFetchCache(ImapFetchResult $result, ImapFetchQuery $query): void + { + $uid = $result->getUid(); + + if ($this->selectedUidValidity === 0 || !is_int($uid) || $uid <= 0) { + return; + } + + $fields = []; + + if ($query->wantsEnvelope()) { + $fields['envelope'] = $result->getEnvelope(); + } + + if ($query->wantsStructure()) { + $fields['structure'] = $result->getStructure(); + } + + if ($query->wantsSize()) { + $fields['size'] = $result->getSize(); + } + + if ($query->wantsImapDate()) { + $fields['imapdate'] = $result->getImapDate(); + } + + $modseq = $result->getModSeq(); + + if ($query->wantsModSeq() && $modseq !== null) { + $fields['modseq'] = $modseq; + } + + if ($query->wantsFlags()) { + $fields['flags'] = $result->getFlags(); + // Stamp the modseq the flags are valid as of, for the freshness + // gate on read. Prefer the message's own MODSEQ, else the + // mailbox HIGHESTMODSEQ captured at SELECT. + $fields['flags_modseq'] = $modseq ?? $this->selectedHighestModSeq; + } + + // Header-field groups: store the raw text keyed by label. The + // result reconstructs the HeaderCollection from it on read. + foreach ($query->headerLabels() as $label) { + $raw = $result->getRawHeaders($label); + + if ($raw !== null) { + $fields['headers'][$label] = $raw; + } + } + + if ($fields !== []) { + $this->cache->set($this->selectedMailbox ?? '', [$uid => $fields], $this->selectedUidValidity); + } + } + + private function finishLogin(): void + { + $this->loggedIn = true; + + // RFC 3501 §6.3.10: The capability list MAY change once + // authenticated (e.g. LOGINDISABLED disappearing or new + // extensions becoming visible). Don't trust data from pre-auth. + // + $this->capability = null; + $this->capabilityFetched = false; + + $capability = $this->getCapability(); + // ENABLE is only valid once authenticated (RFC 5161 §3.1) so + // rev2/UTF8=ACCEPT negotiation has to happen here. + $this->negotiator->negotiateRev2($capability); + } + + /** + * @param list $authMechanisms + */ + private function loginSasl(array $authMechanisms): void + { + // Shares $this->tags with $this->interaction so AUTHENTICATE's + // tag and any ordinary command's tag never collide on the same + // connection. + $channel = new ImapAuthChannel($this->connection, $this->tags); + + $this->authenticator ??= new SaslAuthenticator( + $this->config, + $this->credentials, + new SocketChannelBindingProvider($this->client), + $this->dispatcher, + ); + + $this->authenticator->authenticate( + $channel, + $authMechanisms, + $this->client->isSecure(), + $this->credentials, + ); + } + + private function loginNative(PasswordCredentials $credentials): void + { + $password = $credentials->password()->reveal(); + + if ($password === '') { + throw new AuthenticationException('No password provided.'); + } + + try { + $this->interaction->send('LOGIN', [ + new ImapWireString($credentials->authcid(), isAstring: true), + new ImapWireString($password, isAstring: true), + ]); + } catch (ServerResponseException $e) { + throw new AuthenticationException($e->getMessage(), 0, $e); + } + } + + private function connect(): void + { + if ($this->connection !== null) { + return; + } + + if ($this->client === null) { + try { + $this->client = new SocketClient( + new SocketConnectionConfig( + host: $this->config->hostspec, + port: $this->config->port ?? $this->defaultPort(), + secure: SocketSecureMode::from($this->config->secure->value), + connectTimeout: $this->config->timeout, + readTimeout: $this->config->readTimeout, + context: $this->config->context ?? [], + ), + $this->dispatcher, + ); + } catch (SocketConnectionException $e) { + throw new ConnectionException('Error connecting to mail server.', 0, $e); + } + } + + $connection = new ImapConnection($this->client); + $greeting = $connection->readResponse(); + + if ($greeting->isBye() || !$greeting->isUntagged() || $greeting->status === null) { + throw new ConnectionException( + 'Unexpected server greeting: ' . ($greeting->text !== '' ? $greeting->text : '(none)') + ); + } + + $this->connection = $connection; + $this->tags = new ImapCommandTag(); + $this->interaction = new ImapInteraction($connection, $this->tags); + $this->negotiator = new ImapCapabilityNegotiator($this->interaction); + + // RFC 3501 §7.1: A greeting MAY piggyback its CAPABILITY data + // sparing an explicit round trip. + $capability = new ImapCapability(); + $this->capabilityFetched = ImapCapabilityNegotiator::mergeFromResponse($greeting, $capability); + $this->capability = $capability; + + if ($greeting->isPreAuth()) { + $this->loggedIn = true; + } + } + + private function defaultPort(): int + { + return $this->config->secure === SecureMode::Ssl ? 993 : 143; + } + + private function maybeUpgradeTls(): void + { + if ($this->config->secure !== SecureMode::Tls || $this->client->isSecure()) { + return; + } + + if (!$this->getCapability()->query('STARTTLS')) { + throw new ConnectionException( + 'Could not open a secure connection: the server does not advertise STARTTLS.' + ); + } + + $this->interaction->send('STARTTLS'); + + if (!$this->client->startTls()) { + throw new ConnectionException('Could not open secure connection to the IMAP server.'); + } + + // RFC 3501 §6.2.1: Capabilities MUST be re-checked after + // STARTTLS since a pre-TLS CAPABILITY response is unauthenticated + // and could have been forged by an attacker. + $this->capability = null; + $this->capabilityFetched = false; + } + + private function mailboxNameCodec(): ImapMailboxNameCodec + { + return $this->utf8Enabled() + ? new ImapUtf8MailboxNameCodec() + : new ImapModifiedUtf7Codec(); + } + + /** + * Whether the connection is in UTF-8 mode: either `UTF8=ACCEPT` is + * enabled (RFC 6855) or `IMAP4REV2`, which implies it (RFC 9051 §5.1). + * Drives both mailbox-name encoding and SEARCH CHARSET suppression. + */ + private function utf8Enabled(): bool + { + $capability = $this->capability ?? new ImapCapability(); + + return $capability->isEnabled('IMAP4REV2') || $capability->isEnabled('UTF8=ACCEPT'); + } + + /** + * @return list + */ + private function statusItems(int $flags): array + { + $all = ($flags & StatusFlag::All->value) !== 0; + $items = []; + + if ($all || ($flags & StatusFlag::Messages->value) !== 0) { + $items[] = 'MESSAGES'; + } + + if ($all || ($flags & StatusFlag::Recent->value) !== 0) { + $items[] = 'RECENT'; + } + + if ($all || ($flags & StatusFlag::UidNext->value) !== 0) { + $items[] = 'UIDNEXT'; + } + + if ($all || ($flags & StatusFlag::UidValidity->value) !== 0) { + $items[] = 'UIDVALIDITY'; + } + + if ($all || ($flags & StatusFlag::Unseen->value) !== 0) { + $items[] = 'UNSEEN'; + } + + // HIGHESTMODSEQ needs CONDSTORE (RFC 7162 §3.6). Request it when + // asked explicitly, or under All only if the already-known + // capability advertises it. `status()` must not trigger an extra + // CAPABILITY round trip just to expand All, so this reads the + // cached capability rather than fetching one. + $wantModSeq = ($flags & StatusFlag::HighestModSeq->value) !== 0 + || ($all && $this->capability?->query('CONDSTORE') === true); + + if ($wantModSeq) { + $items[] = 'HIGHESTMODSEQ'; + } + + return $items; + } + + /** + * @param list $untagged + * + * @return array + */ + private function parseStatusResponse(array $untagged): array + { + $result = []; + + foreach ($untagged as $response) { + if ( + !$response->isUntagged() + || count($response->data) < 3 + || !is_string($response->data[0]) + || strtoupper($response->data[0]) !== 'STATUS' + || !is_array($response->data[2]) + ) { + continue; + } + + $result = array_merge($result, $this->parseStatusPairs($response->data[2])); + } + + return $result; + } + + /** + * Build a `LIST-EXTENDED` command (RFC 5258 §3): optional select + * options, the empty reference name, the pattern, and an optional + * `RETURN` list. Subscription mode drives `SUBSCRIBED`; the extended + * options add `CHILDREN`/`SPECIAL-USE` returns, `REMOTE`/ + * `RECURSIVEMATCH` selects, and a `STATUS` return (LIST-STATUS, + * RFC 5819) gated on capability. + * + * @param array $options + * + * @return array{name: string, arguments: list} + */ + private function buildExtendedListCommand(string $pattern, MailboxListMode $mode, array $options = []): array + { + $selectOptions = new ImapWireList(); + $returnOptions = new ImapWireList(); + + switch ($mode) { + case MailboxListMode::Subscribed: + case MailboxListMode::SubscribedExists: + $selectOptions->add(new ImapWireAtom('SUBSCRIBED')); + $returnOptions->add(new ImapWireAtom('SUBSCRIBED')); + break; + + case MailboxListMode::AllSubscribed: + case MailboxListMode::Unsubscribed: + $returnOptions->add(new ImapWireAtom('SUBSCRIBED')); + break; + + case MailboxListMode::All: + break; + } + + if (!empty($options['remote'])) { + $selectOptions->add(new ImapWireAtom('REMOTE')); + } + + if (!empty($options['recursivematch'])) { + $selectOptions->add(new ImapWireAtom('RECURSIVEMATCH')); + } + + if (!empty($options['children'])) { + $returnOptions->add(new ImapWireAtom('CHILDREN')); + } + + if (!empty($options['special_use'])) { + $returnOptions->add(new ImapWireAtom('SPECIAL-USE')); + } + + // LIST-STATUS (RFC 5819): RETURN (STATUS (item ...)). Independent + // of LIST-EXTENDED, but only meaningful with the RETURN syntax. + if (!empty($options['status']) && $this->getCapability()->query('LIST-STATUS')) { + $statusItems = $this->statusItems((int) $options['status']); + + if ($statusItems !== []) { + $returnOptions->add(new ImapWireAtom('STATUS')); + $returnOptions->add(new ImapWireList(array_map( + static fn (string $item): ImapWireEncodable => new ImapWireAtom($item), + $statusItems, + ))); + } + } + + $arguments = []; + + if (count($selectOptions) > 0) { + $arguments[] = $selectOptions; + } + + // The reference name (RFC 3501 §6.3.8) is left empty so the + // pattern is interpreted against the root. + $arguments[] = new ImapWireString('', isAstring: true); + $arguments[] = new ImapWireMailbox($pattern, $this->mailboxNameCodec(), allowWildcards: true); + + if (count($returnOptions) > 0) { + $arguments[] = new ImapWireAtom('RETURN'); + $arguments[] = $returnOptions; + } + + return ['name' => 'LIST', 'arguments' => $arguments]; + } + + /** + * Build a base RFC 3501 `LIST`/`LSUB` command. `LSUB` (RFC 3501 + * §6.3.9) serves the subscribed-only modes when the server lacks + * `LIST-EXTENDED`. + * + * @return array{name: string, arguments: list} + */ + private function buildBaseListCommand(string $pattern, MailboxListMode $mode): array + { + $subscribedOnly = $mode === MailboxListMode::Subscribed + || $mode === MailboxListMode::SubscribedExists; + + return [ + 'name' => $subscribedOnly ? 'LSUB' : 'LIST', + 'arguments' => [ + new ImapWireString('', isAstring: true), + new ImapWireMailbox($pattern, $this->mailboxNameCodec(), allowWildcards: true), + ], + ]; + } + + /** + * Parse the untagged `LIST`/`LSUB` responses into the legacy result + * shape. + * + * @param list $untagged + * + * @return array + */ + private function parseListResponses( + array $untagged, + MailboxListMode $mode, + bool $flat, + bool $wantAttributes, + bool $extended, + ): array { + $result = []; + + foreach ($untagged as $response) { + $entry = $this->parseListResponse($response, $mode, $flat, $wantAttributes, $extended); + + if ($entry === null) { + continue; + } + + if ($flat) { + $result[] = $entry['mailbox']; + } else { + $result[$entry['mailbox']] = $entry; + } + } + + return $result; + } + + /** + * Parse one untagged `LIST`/`LSUB` line (RFC 3501 §7.2.2-7.2.3): + * `LIST (attributes) delimiter mailbox`. Returns null for a line that + * is not a LIST/LSUB response or that the requested `$mode` filters + * out (an unsubscribed entry in a subscribed-only listing, and so on). + * + * @return array{mailbox: string, delimiter: ?string, attributes?: list}|null + */ + private function parseListResponse( + ImapResponse $response, + MailboxListMode $mode, + bool $flat, + bool $wantAttributes, + bool $extended, + ): ?array { + if ( + !$response->isUntagged() + || count($response->data) < 4 + || !is_string($response->data[0]) + ) { + return null; + } + + $type = strtoupper($response->data[0]); + + if ($type !== 'LIST' && $type !== 'LSUB') { + return null; + } + + if (!is_array($response->data[1])) { + return null; + } + + $rawAttributes = array_values(array_filter( + $response->data[1], + static fn ($value): bool => is_string($value), + )); + $delimiter = is_string($response->data[2]) ? $response->data[2] : null; + + if (!is_string($response->data[3])) { + return null; + } + + $mailbox = $this->mailboxNameCodec()->decode($response->data[3]); + + /** @var array $attributes */ + $attributes = []; + foreach ($rawAttributes as $attribute) { + $attributes[strtolower($attribute)] = true; + } + + // RFC 5258 §3.4: with LIST-EXTENDED, some attributes imply others. + if ($extended) { + if (isset($attributes['\\noinferiors'])) { + $attributes['\\hasnochildren'] = true; + } + + if (isset($attributes['\\nonexistent'])) { + $attributes['\\noselect'] = true; + } + } + + if ($this->listEntryFilteredOut($mode, $attributes, $mailbox)) { + return null; + } + + $entry = ['mailbox' => $mailbox, 'delimiter' => $delimiter]; + + if (!$flat && ($wantAttributes || $rawAttributes !== [])) { + $entry['attributes'] = array_keys($attributes); + } + + return $entry; + } + + /** + * Apply the RFC 5258 subscription/existence filtering the modes that + * only make sense with LIST-EXTENDED impose. + * + * @param array $attributes + */ + private function listEntryFilteredOut(MailboxListMode $mode, array $attributes, string $mailbox): bool + { + // INBOX is always considered subscribed (RFC 3501 §5.1). + $subscribed = isset($attributes['\\subscribed']) || strcasecmp($mailbox, 'INBOX') === 0; + + return match ($mode) { + MailboxListMode::SubscribedExists => + isset($attributes['\\nonexistent']) || !$subscribed, + MailboxListMode::Unsubscribed => $subscribed, + default => false, + }; + } + + /** + * Fold the interleaved `* STATUS mailbox (...)` responses a + * LIST-STATUS (RFC 5819) reply carries into the matching list + * entries, under a `status` key. + * + * @param array $list Parsed LIST result, by ref. + * @param list $untagged + */ + private function attachListStatus(array &$list, array $untagged): void + { + $codec = $this->mailboxNameCodec(); + + foreach ($untagged as $response) { + if ( + !$response->isUntagged() + || count($response->data) < 3 + || !is_string($response->data[0]) + || strtoupper($response->data[0]) !== 'STATUS' + || !is_string($response->data[1]) + || !is_array($response->data[2]) + ) { + continue; + } + + $mailbox = $codec->decode($response->data[1]); + + if (!isset($list[$mailbox]) || !is_array($list[$mailbox])) { + continue; + } + + $list[$mailbox]['status'] = $this->parseStatusPairs($response->data[2]); + } + } + + /** + * Turn a flat `(KEY value KEY value ...)` STATUS attribute list into a + * lowercase-keyed map, digits coerced to int. + * + * @param list $pairs + * + * @return array + */ + private function parseStatusPairs(array $pairs): array + { + $out = []; + $total = count($pairs); + + for ($i = 0; $i + 1 < $total; $i += 2) { + if (!is_string($pairs[$i])) { + continue; + } + + $value = $pairs[$i + 1]; + $out[strtolower($pairs[$i])] = is_string($value) && ctype_digit($value) ? (int) $value : $value; + } + + return $out; + } + + /** + * Parse a `NAMESPACE` response (RFC 2342 §5, RFC 5255 §3.4) into an + * {@see ImapNamespaceList}. + * + * The response after the `NAMESPACE` word is a fixed triple of + * parenthesized lists (personal, other, shared), each either `NIL` + * or a list of `(prefix delimiter [extensions...])` entries. + * + * @param list $data The whole untagged response tokens, + * starting with the `NAMESPACE` word. + */ + private function parseNamespaceResponse(array $data): ImapNamespaceList + { + $codec = $this->mailboxNameCodec(); + $namespaces = []; + $types = [NamespaceType::Personal, NamespaceType::Other, NamespaceType::Shared]; + + foreach ($types as $index => $type) { + $group = $data[$index + 1] ?? null; + + if (!is_array($group)) { + continue; + } + + foreach ($group as $entry) { + if (!is_array($entry) || $entry === [] || !is_string($entry[0])) { + continue; + } + + $delimiter = (isset($entry[1]) && is_string($entry[1])) ? $entry[1] : null; + + $namespaces[] = new ImapNamespace( + name: $codec->decode($entry[0]), + type: $type, + delimiter: $delimiter, + translation: $this->namespaceTranslation($entry), + ); + } + } + + return new ImapNamespaceList($namespaces); + } + + /** + * Extract the RFC 5255 §3.4 TRANSLATION extension value from one + * namespace entry's trailing `name value` extension pairs, if present. + * + * @param list $entry + */ + private function namespaceTranslation(array $entry): string + { + // Extensions begin after the prefix and delimiter (RFC 4466). + for ($i = 2; $i + 1 < count($entry); $i += 2) { + if (!is_string($entry[$i]) || strtoupper($entry[$i]) !== 'TRANSLATION') { + continue; + } + + $value = $entry[$i + 1]; + + // The value is itself a parenthesized list of strings; take + // the first (RFC 5255 §3.4). + if (is_array($value) && isset($value[0]) && is_string($value[0])) { + return $value[0]; + } + + if (is_string($value)) { + return $value; + } + } + + return ''; + } + + /** + * Map the requested {@see SearchResultType} set onto the ESEARCH + * `RETURN` option atoms (RFC 4731 §3.1). `Match` maps to `ALL`; + * `Save` maps to `SAVE` (RFC 5182). An empty or match-only request + * still asks for `ALL` so the matching set always comes back. + * + * @param list $results + * + * @return list + */ + private function searchReturnOptions(array $results): array + { + $atoms = []; + + foreach ($results as $type) { + $atom = match ($type) { + SearchResultType::Count => 'COUNT', + SearchResultType::Match => 'ALL', + SearchResultType::Max => 'MAX', + SearchResultType::Min => 'MIN', + SearchResultType::Save => 'SAVE', + SearchResultType::Relevancy => 'RELEVANCY', + }; + + $atoms[$atom] = true; + } + + if ($atoms === []) { + $atoms['ALL'] = true; + } + + return array_map( + static fn (string $atom): ImapWireEncodable => new ImapWireAtom($atom), + array_keys($atoms), + ); + } + + /** + * Send a server-side `SORT`/`UID SORT` (RFC 5256), using the ESORT + * `RETURN (...)` extension (RFC 5267) when advertised. Unlike SEARCH, + * SORT's charset is mandatory and follows the sort-criteria list. + * + * @param array{charset: ?string, criteria: list} $built + * @param list $sort + * @param list $results + * + * @throws CapabilityNotSupportedException If the server does not advertise SORT. + */ + private function sortSearch(ImapSearchQuery $query, array $built, array $sort, array $results, bool $sequence): ImapSearchResult + { + if (!$this->getCapability()->query('SORT')) { + throw new CapabilityNotSupportedException( + 'The server does not advertise SORT (RFC 5256).' + ); + } + + $command = $sequence ? 'SORT' : 'UID SORT'; + $arguments = []; + + // ESORT (RFC 5267 §3.4) returns a compact result the same way + // ESEARCH does, when advertised. + if ($this->getCapability()->query('ESORT')) { + $arguments[] = new ImapWireAtom('RETURN'); + $arguments[] = new ImapWireList($this->searchReturnOptions($results)); + } + + $arguments[] = new ImapWireList($this->sortCriteriaList($sort)); + + // Charset is mandatory for SORT (RFC 5256 §3); UTF-8 mode forces + // UTF-8 (RFC 6855 §3), otherwise the query charset or US-ASCII. + $arguments[] = new ImapWireAtom($this->utf8Enabled() ? 'UTF-8' : ($built['charset'] ?? 'US-ASCII')); + + if ($built['criteria'] === []) { + $arguments[] = new ImapWireAtom('ALL'); + } else { + array_push($arguments, ...$built['criteria']); + } + + $result = $this->interaction->send($command, $arguments); + + // A `* SORT ...` reply preserves the server's ordering, which + // ImapIdSet keeps (it dedups in first-seen order, never sorts). + return ImapSearchParser::parse($result->untagged, $sequence); + } + + /** + * Map the requested {@see SortCriteria} cases onto the SORT criteria + * atoms (RFC 5256 §3, RFC 5957 DISPLAYFROM/DISPLAYTO). `REVERSE` + * inverts the ordering of the criterion that follows it. Client-only + * cases (`Sequence`, the DISPLAY fallbacks) are skipped since there is + * no server-side atom for them. + * + * @param list $sort + * + * @return list + */ + private function sortCriteriaList(array $sort): array + { + $atoms = []; + + foreach ($sort as $criterion) { + $atom = match ($criterion) { + SortCriteria::Arrival => 'ARRIVAL', + SortCriteria::Cc => 'CC', + SortCriteria::Date => 'DATE', + SortCriteria::From => 'FROM', + SortCriteria::Reverse => 'REVERSE', + SortCriteria::Size => 'SIZE', + SortCriteria::Subject => 'SUBJECT', + SortCriteria::To => 'TO', + SortCriteria::DisplayFrom => 'DISPLAYFROM', + SortCriteria::DisplayTo => 'DISPLAYTO', + SortCriteria::Relevancy => 'RELEVANCY', + default => null, + }; + + if ($atom !== null) { + $atoms[] = new ImapWireAtom($atom); + } + } + + // A SORT with no criteria is invalid; fall back to ARRIVAL. + return $atoms === [] ? [new ImapWireAtom('ARRIVAL')] : $atoms; + } + + /** + * Resolve a thread algorithm option to its wire atom. + */ + private function threadAlgorithm(ThreadAlgorithm|string $criteria): string + { + if (is_string($criteria)) { + return strtoupper($criteria); + } + + return match ($criteria) { + ThreadAlgorithm::OrderedSubject => 'ORDEREDSUBJECT', + ThreadAlgorithm::References => 'REFERENCES', + ThreadAlgorithm::Refs => 'REFS', + }; + } + + /** + * Decode a {@see getSyncToken()} string into its + * UIDVALIDITY/HIGHESTMODSEQ/UIDNEXT parts. + * + * @return array{V: int, H: int, U: int} + * + * @throws SyncException If the token is not valid base64 or is + * missing the UIDVALIDITY part. + */ + private function decodeSyncToken(string $token): array + { + $decoded = base64_decode($token, true); + + if ($decoded === false) { + throw new SyncException('Malformed sync token.'); + } + + $parsed = ['V' => 0, 'H' => 0, 'U' => 0]; + + foreach (explode(',', $decoded) as $part) { + if ($part === '') { + continue; + } + + $letter = $part[0]; + $value = substr($part, 1); + + if (isset($parsed[$letter]) && ctype_digit($value)) { + $parsed[$letter] = (int) $value; + } + } + + if ($parsed['V'] === 0) { + throw new SyncException('Malformed sync token: missing UIDVALIDITY.'); + } + + return $parsed; + } + + /** + * Throw unless the server advertises `$capability`. Used by the + * optional ACL/QUOTA extension methods so each guards on its own + * capability, per the independently-optional `*Aware` design. + * + * @throws CapabilityNotSupportedException + */ + private function requireCapability(string $capability): void + { + $this->connect(); + + if (!$this->getCapability()->query($capability)) { + throw new CapabilityNotSupportedException( + "The server does not advertise the {$capability} extension." + ); + } + } + + /** + * METADATA (RFC 5464) has two capability tokens: `METADATA` for + * mailbox annotations and `METADATA-SERVER` for server-wide ones (an + * empty mailbox name). Accept either as appropriate. + * + * @throws CapabilityNotSupportedException + */ + private function requireMetadataCapability(string $mailbox): void + { + $this->connect(); + $capability = $this->getCapability(); + + if ($capability->query('METADATA')) { + return; + } + + if ($mailbox === '' && $capability->query('METADATA-SERVER')) { + return; + } + + throw new CapabilityNotSupportedException( + 'The server does not advertise the METADATA extension (RFC 5464).' + ); + } + + private function isUntaggedNamed(ImapResponse $response, string $name): bool + { + return $response->isUntagged() + && $response->data !== [] + && is_string($response->data[0]) + && strtoupper($response->data[0]) === $name; + } + + /** + * Parse a `* ACL mailbox (identifier rights)*` response (RFC 4314 + * §3.6). A negative-rights identifier keeps its leading `-`. + * + * @param list $data + * + * @return array + */ + private function parseAclResponse(array $data): array + { + $acl = []; + // data: ACL mailbox [identifier rights]... + $pairs = array_slice($data, 2); + + for ($i = 0; $i + 1 < count($pairs); $i += 2) { + if (!is_string($pairs[$i]) || !is_string($pairs[$i + 1])) { + continue; + } + + $acl[$pairs[$i]] = new ImapAcl($pairs[$i + 1]); + } + + return $acl; + } + + /** + * Parse `* QUOTA root (resource usage limit ...)` responses (RFC 9208 + * §5.1), ignoring the `* QUOTAROOT` lines a GETQUOTAROOT interleaves. + * + * @param list $untagged + * + * @return array> + */ + private function parseQuotaResponses(array $untagged): array + { + $out = []; + + foreach ($untagged as $response) { + if (!$this->isUntaggedNamed($response, 'QUOTA')) { + continue; + } + + $root = is_string($response->data[1] ?? null) ? $response->data[1] : ''; + $resources = $response->data[2] ?? null; + $out[$root] = []; + + if (!is_array($resources)) { + continue; + } + + $total = count($resources); + + for ($i = 0; $i + 2 < $total; $i += 3) { + if (!is_string($resources[$i])) { + continue; + } + + $out[$root][strtolower($resources[$i])] = [ + 'usage' => (int) (is_string($resources[$i + 1]) ? $resources[$i + 1] : 0), + 'limit' => (int) (is_string($resources[$i + 2]) ? $resources[$i + 2] : 0), + ]; + } + } + + return $out; + } + + /** + * Parse `* METADATA mailbox (entry value ...)` responses (RFC 5464 + * §4.4). + * + * @param list $untagged + * + * @return array> + */ + private function parseMetadataResponses(array $untagged): array + { + $codec = $this->mailboxNameCodec(); + $out = []; + + foreach ($untagged as $response) { + if (!$this->isUntaggedNamed($response, 'METADATA') || !isset($response->data[1]) || !is_string($response->data[1])) { + continue; + } + + $mailbox = $codec->decode($response->data[1]); + $pairs = $response->data[2] ?? null; + + if (!is_array($pairs)) { + continue; + } + + $out[$mailbox] ??= []; + + for ($i = 0; $i + 1 < count($pairs); $i += 2) { + if (!is_string($pairs[$i])) { + continue; + } + + $value = $pairs[$i + 1]; + $out[$mailbox][$pairs[$i]] = is_string($value) ? $value : null; + } + } + + return $out; + } + + /** + * Turn the store options into the ordered `(key, flagsList)` pairs a + * STORE command needs: a single `FLAGS[.SILENT]` for a replace, or up + * to one each of `+FLAGS[.SILENT]`/`-FLAGS[.SILENT]` for add/remove + * (RFC 3501 §6.4.6). + * + * @param array $options + * + * @return list + */ + private function storeItems(array $options, bool $silent): array + { + $suffix = $silent ? '.SILENT' : ''; + $items = []; + + if (!empty($options['replace'])) { + $items[] = ['FLAGS' . $suffix, $this->flagsList($options['replace'])]; + + return $items; + } + + if (!empty($options['add'])) { + $items[] = ['+FLAGS' . $suffix, $this->flagsList($options['add'])]; + } + + if (!empty($options['remove'])) { + $items[] = ['-FLAGS' . $suffix, $this->flagsList($options['remove'])]; + } + + return $items; + } + + /** + * Build a parenthesized flag list from {@see SystemFlag} cases or + * plain keyword strings. `\Recent` is dropped: it is not a settable + * flag (RFC 3501 §2.3.2). + * + * @param list $flags + */ + private function flagsList(array $flags): ImapWireList + { + $list = new ImapWireList(); + + foreach ($flags as $flag) { + $name = $flag instanceof SystemFlag ? $flag->value : $flag; + + if (strcasecmp($name, '\\Recent') === 0) { + continue; + } + + $list->add(new ImapWireAtom($name)); + } + + return $list; + } + + /** + * Fold a tagged STORE completion's `[MODIFIED ]` code (RFC 7162 + * §3.1.3) into the running set of not-updated messages. + */ + private function mergeModified(ImapIdSet $modified, ImapResponse $tagged, bool $sequence): ImapIdSet + { + $code = $tagged->responseCode; + + if ($code === null || strtoupper($code->name) !== 'MODIFIED' || !isset($code->data[0]) || !is_string($code->data[0])) { + return $modified; + } + + $extra = ImapIdSet::fromSequenceString($code->data[0], $sequence)->toArray(); + + return new ImapIdSet([...$modified->toArray(), ...$extra], $sequence); + } + + /** + * Collect the messages reported expunged by an EXPUNGE/UID EXPUNGE + * command, when the caller asked for the list. + * + * A plain connection reports `* n EXPUNGE` sequence numbers (RFC 3501 + * §7.4.1). Once QRESYNC is enabled the server reports `* VANISHED` + * UIDs instead (RFC 7162 §3.2.10); the two forms never appear + * together for one command. The returned set therefore holds sequence + * numbers or UIDs depending on which the server sent, flagged + * accordingly. + * + * @param list $untagged + */ + private function collectExpunged(array $untagged, bool $wantList): ImapIdSet + { + if (!$wantList) { + return new ImapIdSet([], true); + } + + // Prefer VANISHED (QRESYNC) when present: it carries UIDs, which + // are more useful than the sequence numbers a plain EXPUNGE gives. + $vanished = ImapVanishedParser::parse($untagged); + + if (!$vanished->isEmpty()) { + return $vanished; + } + + $expunged = []; + + foreach ($untagged as $response) { + if ( + $response->isUntagged() + && count($response->data) >= 2 + && is_string($response->data[0]) + && ctype_digit($response->data[0]) + && is_string($response->data[1]) + && strtoupper($response->data[1]) === 'EXPUNGE' + ) { + $expunged[] = (int) $response->data[0]; + } + } + + // EXPUNGE responses report sequence numbers (RFC 3501 §7.4.1). + return new ImapIdSet($expunged, true); + } + + /** + * Drop expunged messages from the cache after an EXPUNGE/UID EXPUNGE. + * + * @param list $untagged + */ + private function invalidateExpunged(string $mailbox, ?MessageIdSet $ids, bool $uidExpunge, array $untagged): void + { + if ($this->cache === null) { + return; + } + + // VANISHED (QRESYNC) carries UIDs even for a plain EXPUNGE. + $vanished = ImapVanishedParser::parse($untagged); + + if (!$vanished->isEmpty()) { + $this->cache->deleteMsgs($mailbox, $vanished->toArray()); + + return; + } + + // A targeted UID EXPUNGE removed exactly the requested UID set. + if ($uidExpunge && $ids instanceof ImapIdSet && !$ids->isSpecial()) { + $this->cache->deleteMsgs($mailbox, $ids->toArray()); + + return; + } + + // A plain EXPUNGE reports only sequence numbers, which are not + // cache keys. Conservatively drop the whole mailbox cache. + $this->cache->deleteMailbox($mailbox); + } + + /** + * Empty a mailbox: open it read-write, flag every message `\Deleted`, + * expunge, and close. Used by {@see deleteMailbox()} to satisfy + * servers that refuse to delete a non-empty mailbox. + */ + private function emptyMailbox(string $mailbox): void + { + $this->openMailbox($mailbox, OpenMode::ReadWrite); + $allUids = new ImapIdSet(ImapIdSetToken::All, false); + $this->expunge($mailbox, ['ids' => $allUids, 'delete' => true]); + $this->close(); + } + + /** + * Parse the flag-change `* n FETCH (...)` responses a QRESYNC + * SELECT/EXAMINE volunteers (RFC 7162 §3.2.5.2), keyed by UID. A + * QRESYNC FETCH always carries the UID, so any response missing one + * is skipped. + * + * @param list $untagged + * + * @return array + */ + private function collectQresyncChanges(array $untagged): array + { + // The server chooses what to send; ask the parser for flags and + // modseq, the QRESYNC flag-change payload (RFC 7162 §3.2.5.2). + $query = (new ImapFetchQuery())->flags()->modseq(); + $changed = []; + + foreach ($untagged as $response) { + $parsed = ImapFetchParser::parse($response, $query); + + if ($parsed === null) { + continue; + } + + $uid = $parsed['result']->getUid(); + + if (is_int($uid) && $uid > 0) { + $changed[$uid] = $parsed['result']; + } + } + + return $changed; + } + + /** + * Shared COPY/MOVE implementation. When moving without server-side + * `MOVE` (RFC 6851), falls back to COPY plus expunging the source. + * + * @param array{ids?: MessageIdSet, create?: bool} $options + */ + private function copyOrMove(string $dest, array $options, bool $move): MessageIdSet + { + $this->connect(); + + $ids = $options['ids'] ?? null; + + if (!$ids instanceof ImapIdSet || $ids->isEmpty()) { + return new ImapIdSet([], false); + } + + $sequence = $ids->isSequence(); + $serverMove = $move && $this->getCapability()->query('MOVE'); + $verb = $serverMove ? 'MOVE' : 'COPY'; + $command = $sequence ? $verb : 'UID ' . $verb; + + $arguments = [ + new ImapWireAtom((string) $ids), + new ImapWireMailbox($dest, $this->mailboxNameCodec()), + ]; + + try { + $result = $this->interaction->send($command, $arguments); + } catch (ServerResponseException $e) { + // RFC 3502 / RFC 4315: a [TRYCREATE] code means the + // destination is missing. Create it once and retry. + if (!empty($options['create']) && $this->hasTryCreate($e)) { + $this->createMailbox($dest); + unset($options['create']); + + return $this->copyOrMove($dest, $options, $move); + } + + throw $e; + } + + $copyUid = $this->parseUidPlus($result->tagged, 'COPYUID', 2); + + // A client-side move (no server MOVE) has to delete the source + // messages itself once the copy succeeded. That path runs through + // expunge(), which invalidates the cache and emits MailboxExpunged. + if ($move && !$serverMove) { + $this->expunge($this->selectedMailbox ?? '', ['ids' => $ids, 'delete' => true]); + } elseif ($serverMove) { + // A server-side MOVE (RFC 6851) removes the source messages + // atomically and reports them via untagged VANISHED/EXPUNGE on + // the MOVE reply. Invalidate the source cache and signal the + // same removal event the expunge path would so a MOVE that + // does not advance HIGHESTMODSEQ cannot leave stale entries in + // an external cache. + $source = $this->selectedMailbox ?? ''; + $this->invalidateExpunged($source, $ids, !$sequence, $result->untagged); + $removed = $this->collectExpunged($result->untagged, true); + $this->dispatch(new MailboxExpunged($source, $removed, $this->selectedUidValidity)); + } + + return $copyUid; + } + + /** + * Pull the UID set out of a UIDPLUS response code (RFC 4315): + * `[COPYUID validity srcset destset]` (the dest set is at index 2) or + * `[APPENDUID validity uidset]` (the set is at index 1). + */ + private function parseUidPlus(ImapResponse $tagged, string $name, int $setIndex): MessageIdSet + { + $code = $tagged->responseCode; + + if ( + $code === null + || strtoupper($code->name) !== $name + || !isset($code->data[$setIndex]) + || !is_string($code->data[$setIndex]) + ) { + return new ImapIdSet([], false); + } + + return ImapIdSet::fromSequenceString($code->data[$setIndex], false); + } + + /** + * Whether a rejected command carried a `[TRYCREATE]` response code + * (RFC 3501 §6.4.7 / §6.3.11). + */ + private function hasTryCreate(ServerResponseException $e): bool + { + return $e->responseCode !== null && strtoupper($e->responseCode->name) === 'TRYCREATE'; + } +} + + + diff --git a/src/ImapCommand.php b/src/ImapCommand.php new file mode 100644 index 00000000..491e7c62 --- /dev/null +++ b/src/ImapCommand.php @@ -0,0 +1,151 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCommand +{ + /** @var list */ + private readonly array $arguments; + + /** + * @param iterable $arguments A plain string + * is treated as + * an atom. + */ + public function __construct( + public readonly string $tag, + public readonly string $name, + iterable $arguments = [], + ) { + $normalized = []; + + foreach ($arguments as $argument) { + $normalized[] = is_string($argument) ? new ImapWireAtom($argument) : $argument; + } + + $this->arguments = $normalized; + } + + /** + * Does any argument (recursively, through nested lists) require a + * literal? Pipelining more than one such command at a time is not + * safe since only one continuation exchange can be outstanding on + * a connection. + */ + public function needsContinuation(): bool + { + foreach ($this->arguments as $argument) { + if ($this->containsLiteral($argument)) { + return true; + } + } + + return false; + } + + private function containsLiteral(ImapWireEncodable $value): bool + { + if ($value->isLiteral()) { + return true; + } + + if (!$value instanceof ImapWireList) { + return false; + } + + foreach ($value as $member) { + if ($this->containsLiteral($member)) { + return true; + } + } + + return false; + } + + /** + * @return list + */ + public function segments(): array + { + $segments = [ImapCommandSegment::text($this->tag . ' ' . $this->name)]; + + foreach ($this->arguments as $argument) { + $segments[] = ImapCommandSegment::text(' '); + array_push($segments, ...$this->flatten($argument)); + } + + return $segments; + } + + /** + * @return list + */ + private function flatten(ImapWireEncodable $value): array + { + if ($value->isLiteral()) { + return [ImapCommandSegment::literal($value->isBinary(), $value->rawBytes())]; + } + + try { + $escaped = $value->escape(); + + // ImapWireList::escape() only joins its members. + // A list used directly as a command argument is its own top-level "parent" here. + // Otherwise the parent is responsible for parantheses. + return [ImapCommandSegment::text($value instanceof ImapWireList ? "({$escaped})" : $escaped)]; + } catch (WireEncodingException $e) { + if (!$value instanceof ImapWireList) { + // Only a list can fail escape() without being a literal + throw $e; + } + } + + $segments = [ImapCommandSegment::text('(')]; + $first = true; + + foreach ($value as $member) { + if (!$first) { + $segments[] = ImapCommandSegment::text(' '); + } + + $first = false; + array_push($segments, ...$this->flatten($member)); + } + + $segments[] = ImapCommandSegment::text(')'); + + return $segments; + } +} diff --git a/src/ImapCommandResult.php b/src/ImapCommandResult.php new file mode 100644 index 00000000..3276244b --- /dev/null +++ b/src/ImapCommandResult.php @@ -0,0 +1,35 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCommandResult +{ + /** + * @param list $untagged + */ + public function __construct( + public readonly ImapResponse $tagged, + public readonly array $untagged, + ) {} +} diff --git a/src/ImapCommandSegment.php b/src/ImapCommandSegment.php new file mode 100644 index 00000000..a22245d9 --- /dev/null +++ b/src/ImapCommandSegment.php @@ -0,0 +1,53 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCommandSegment +{ + private function __construct( + public readonly bool $isLiteral, + public readonly string $text, + public readonly bool $isBinary, + public readonly string $bytes, + ) {} + + public static function text(string $text): self + { + return new self(false, $text, false, ''); + } + + public static function literal(bool $binary, string $bytes): self + { + return new self(true, '', $binary, $bytes); + } + + public function length(): int + { + return strlen($this->bytes); + } +} diff --git a/src/ImapCommandTag.php b/src/ImapCommandTag.php new file mode 100644 index 00000000..add10a93 --- /dev/null +++ b/src/ImapCommandTag.php @@ -0,0 +1,43 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapCommandTag +{ + private int $counter = 0; + + public function __construct( + private readonly string $prefix = 'A', + ) {} + + public function next(): string + { + ++$this->counter; + + return $this->prefix . $this->counter; + } +} diff --git a/src/ImapConnection.php b/src/ImapConnection.php new file mode 100644 index 00000000..1e1ef29a --- /dev/null +++ b/src/ImapConnection.php @@ -0,0 +1,144 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapConnection +{ + private readonly ImapTokenizer $tokenizer; + + public function __construct( + private readonly ClientInterface $client, + ) { + $this->tokenizer = new ImapTokenizer($client); + } + + /** + * Write a command's segments, handles any literal arguments' + * `{n}`/`+`/raw-bytes exchange as they come up. + * + * @param bool $nonSynchronizingLiterals Send literals as `{n+}` (RFC + * 7888) instead of waiting for a `+` continuation. + * Only safe once the server's `LITERAL+`/`LITERAL-` + * support is confirmed. + * + * @return list Untagged responses seen while awaiting + * a continuation. A server may + * send one. Rare occurrence e.g. an alert. + * + * @throws ServerResponseException If the server responds to a + * literal announcement with + * anything but a continuation + * (a tagged rejection of the + * command, most commonly). + */ + public function sendCommand(ImapCommand $command, bool $nonSynchronizingLiterals = false): array + { + $untagged = []; + $buffer = ''; + + foreach ($command->segments() as $segment) { + if (!$segment->isLiteral) { + $buffer .= $segment->text; + continue; + } + + $marker = ($segment->isBinary ? '~' : '') + . '{' . $segment->length() . ($nonSynchronizingLiterals ? '+' : '') . "}\r\n"; + $this->client->write($buffer . $marker); + $buffer = ''; + + if (!$nonSynchronizingLiterals) { + array_push($untagged, ...$this->awaitContinuation($command)); + } + + $this->client->write($segment->bytes); + } + + $this->client->write($buffer . "\r\n"); + + return $untagged; + } + + /** + * @return list + */ + private function awaitContinuation(ImapCommand $command): array + { + $untagged = []; + + while (true) { + $response = $this->readResponse(); + + if ($response->isContinuation()) { + return $untagged; + } + + if ($response->isUntagged()) { + $untagged[] = $response; + continue; + } + + throw new ServerResponseException( + "Server rejected command '{$command->name}' before sending a literal continuation.", + 0, + null, + $command->name, + null, + $response->text, + ); + } + } + + /** + * Write a bare line (no tag, no command name) such as a SASL + * continuation reply or its `*` cancellation (RFC 3501 §6.2.2). + */ + public function writeLine(string $line): void + { + $this->client->write($line . "\r\n"); + } + + /** + * Read and parse the next response line. + */ + public function readResponse(): ImapResponse + { + return ImapResponseParser::parse($this->tokenizer->readLine()); + } +} diff --git a/src/ImapEnvelope.php b/src/ImapEnvelope.php new file mode 100644 index 00000000..5b4cf58d --- /dev/null +++ b/src/ImapEnvelope.php @@ -0,0 +1,60 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapEnvelope +{ + public function __construct( + public string $date = '', + public string $subject = '', + public AddressList $from = new AddressList(), + public AddressList $sender = new AddressList(), + public AddressList $replyTo = new AddressList(), + public AddressList $to = new AddressList(), + public AddressList $cc = new AddressList(), + public AddressList $bcc = new AddressList(), + public string $inReplyTo = '', + public string $messageId = '', + ) {} +} diff --git a/src/ImapFetchParser.php b/src/ImapFetchParser.php new file mode 100644 index 00000000..0c9051e6 --- /dev/null +++ b/src/ImapFetchParser.php @@ -0,0 +1,599 @@ +', ...]`), + * with complex structures (ENVELOPE, BODYSTRUCTURE, FLAGS) as + * nested arrays and `NIL` as `null`. Parsing is therefore index walking, + * not cursor advancing. + * + * Stateless implementation. Every method is static. + * The {@see ImapFetchQuery} is passed in only to map an incoming `BODY[HEADER.FIELDS (...)]` key back to the + * caller label it was requested under. + * + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapFetchParser +{ + private function __construct() {} + + /** + * @return array{seq: int, result: ImapFetchResult}|null Null when the + * response is not an `* n FETCH (...)` line (the caller skips + * it. A mailbox may interleave EXPUNGE/EXISTS/etc. among the + * FETCH replies). + * + * @throws ImapProtocolException On a malformed FETCH structure. + */ + public static function parse(ImapResponse $response, ImapFetchQuery $query): ?array + { + $data = $response->data; + + if ( + !$response->isUntagged() + || count($data) < 3 + || !is_string($data[0]) + || !ctype_digit($data[0]) + || !is_string($data[1]) + || strtoupper($data[1]) !== 'FETCH' + || !is_array($data[2]) + ) { + return null; + } + + $seq = (int) $data[0]; + $result = new ImapFetchResult($seq); + self::walkItems($data[2], $result, $query); + + return ['seq' => $seq, 'result' => $result]; + } + + /** + * @param list $items Flat name/value list from the FETCH data + * list. + */ + private static function walkItems(array $items, ImapFetchResult $result, ImapFetchQuery $query): void + { + $count = count($items); + $i = 0; + + while ($i < $count) { + $name = $items[$i]; + + if (!is_string($name)) { + throw new ImapProtocolException('Malformed FETCH item name.'); + } + + // A `BODY[HEADER.FIELDS (...)]` item is split by the tokenizer + // on the space before the parenthesized field list, arriving + // as `BODY[HEADER.FIELDS`, `(FROM TO)`, `]` then the value. + // Reassemble the full section key before pairing it with a + // value. Every other section (`BODY[HEADER]`, `BODY[1.TEXT]`, + // `BODY[]<0>`, ...) is a single atom and needs no reassembly. + if (self::isUnterminatedSection($name)) { + [$name, $i] = self::reassembleSectionKey($items, $i, $count); + } + + $value = $items[$i + 1] ?? null; + self::dispatch(strtoupper($name), $name, $value, $result, $query); + $i += 2; + } + } + + /** + * A `BODY[`/`BINARY[` token whose closing `]` was split off by the + * tokenizer (an embedded parenthesized field list forced a break). + */ + private static function isUnterminatedSection(string $name): bool + { + return (str_starts_with($name, 'BODY[') || str_starts_with($name, 'BINARY[')) + && !str_contains($name, ']'); + } + + /** + * Rebuild a section key split across tokens, folding the field-list + * members back inside their brackets. Returns the reconstructed key + * and the index of its last consumed token (the closing `]`). + * + * @param list $items + * + * @return array{0: string, 1: int} + */ + private static function reassembleSectionKey(array $items, int $start, int $count): array + { + $key = (string) $items[$start]; + $j = $start + 1; + + for (; $j < $count; ++$j) { + $token = $items[$j]; + + if (is_array($token)) { + // The parenthesized field list, rendered back verbatim. + $key .= ' (' . implode(' ', array_map(self::asString(...), $token)) . ')'; + + continue; + } + + if (is_string($token)) { + // The token that carries the closing bracket ends the key. + // It may also carry a trailing `` suffix. + $key .= $token; + + if (str_contains($token, ']')) { + break; + } + } + } + + return [$key, $j]; + } + + private static function dispatch( + string $upper, + string $original, + mixed $value, + ImapFetchResult $result, + ImapFetchQuery $query, + ): void { + // RFC822 aliases resolve to their BODY[...] equivalents before + // dispatch. RFC 3501 §7.4.2 keeps them for backward compatibility. + $upper = match ($upper) { + 'RFC822' => 'BODY[]', + 'RFC822.HEADER' => 'BODY[HEADER]', + 'RFC822.TEXT' => 'BODY[TEXT]', + default => $upper, + }; + + switch ($upper) { + case 'FLAGS': + $result->setFlags(self::stringList($value)); + + return; + + case 'ENVELOPE': + $result->setEnvelope(self::parseEnvelope(self::asList($value))); + + return; + + case 'INTERNALDATE': + $result->setImapDate(self::parseInternalDate(self::asString($value))); + + return; + + case 'RFC822.SIZE': + $result->setSize((int) self::asString($value)); + + return; + + case 'UID': + $uid = self::asString($value); + $result->setUid(ctype_digit($uid) ? (int) $uid : $uid); + + return; + + case 'MODSEQ': + // Sent as a one-element parenthesized list: `MODSEQ (n)`. + $modseq = self::asList($value)[0] ?? null; + + if (is_string($modseq) && $modseq !== '') { + $result->setModSeq((int) $modseq); + } + + return; + + case 'BODYSTRUCTURE': + case 'BODY': + // A bare `BODY` (no `[...]`) carries a non-extension + // bodystructure. Treat it the same as BODYSTRUCTURE. + $result->setStructure(self::parseBodyStructure(self::asList($value))); + + return; + } + + if (str_starts_with($upper, 'BODY[') || str_starts_with($upper, 'BINARY[')) { + self::dispatchSection($upper, $value, $result, $query); + } + + // Anything else is ignored (not implemented or unknown). + } + + private static function dispatchSection( + string $upper, + mixed $value, + ImapFetchResult $result, + ImapFetchQuery $query, + ): void { + // HEADER.FIELDS keys carry their field list inside the brackets. + // Rebuild the section signature and map it back to the caller + // label (H5's fetch_lookup round-trip). + if (str_contains($upper, 'HEADER.FIELDS')) { + $signature = self::sectionOf($upper); + $label = $query->headerLabelFor($signature); + + if ($label !== null) { + $result->setHeaders($label, self::asString($value)); + } + + return; + } + + // Strip everything from the last ']' onward. This drops the + // closing bracket and any `` octet-offset suffix so `BODY[1.TEXT]<0>` reduces to the section `1.TEXT`. + $section = self::sectionOf($upper); + + if (str_starts_with($upper, 'BINARY[')) { + // No BINARY decoding yet. Store the raw + // part body under its id, same slot a BODY[id] would use. + $result->setBodyPart($section, self::asString($value)); + + return; + } + + if ($section === '') { + $result->setFullMsg(self::asString($value)); + + return; + } + + // A section ending in a bare number (`2`, `1.3`) is a raw body + // part. A section with a trailing keyword is header/text/mime. + $lastDot = strrpos($section, '.'); + $tail = $lastDot === false ? $section : substr($section, $lastDot + 1); + $tailUpper = strtoupper($tail); + + [$id, $keyword] = match ($tailUpper) { + 'HEADER', 'TEXT', 'MIME' => [ + $lastDot === false ? 0 : substr($section, 0, $lastDot), + $tailUpper, + ], + default => [$section, null], + }; + + match ($keyword) { + 'HEADER' => $result->setHeaderText($id, self::asString($value)), + 'TEXT' => $result->setBodyText($id, self::asString($value)), + 'MIME' => $result->setMimeHeader($id, self::asString($value)), + default => $result->setBodyPart($section, self::asString($value)), + }; + } + + /** + * The section text inside `BODY[...]`/`BINARY[...]`, i.e. everything + * between the first `[` and the last `]`, dropping any trailing + * `` suffix. + */ + private static function sectionOf(string $item): string + { + $open = strpos($item, '['); + $close = strrpos($item, ']'); + + if ($open === false || $close === false || $close < $open) { + return ''; + } + + return substr($item, $open + 1, $close - $open - 1); + } + + /** + * Parse an ENVELOPE list (RFC 3501 §7.4.2). Ten positional fields. + * `sender`/`reply-to` fall back to `from` when NIL. + * + * @param list $tokens + */ + private static function parseEnvelope(array $tokens): ImapEnvelope + { + $date = self::nstring($tokens[0] ?? null); + $subject = self::nstring($tokens[1] ?? null); + $from = self::parseAddressList($tokens[2] ?? null); + $to = self::parseAddressList($tokens[5] ?? null); + $cc = self::parseAddressList($tokens[6] ?? null); + $bcc = self::parseAddressList($tokens[7] ?? null); + $inReplyTo = self::nstring($tokens[8] ?? null); + $messageId = self::nstring($tokens[9] ?? null); + + // RFC 3501 §7.4.2: A NIL sender/reply-to means "same as from field". + $sender = ($tokens[3] ?? null) === null + ? $from + : self::parseAddressList($tokens[3]); + $replyTo = ($tokens[4] ?? null) === null + ? $from + : self::parseAddressList($tokens[4]); + + return new ImapEnvelope( + date: $date, + subject: $subject, + from: $from, + sender: $sender, + replyTo: $replyTo, + to: $to, + cc: $cc, + bcc: $bcc, + inReplyTo: $inReplyTo, + messageId: $messageId, + ); + } + + /** + * Parse an address-list token: A list of 4-element `(name adl mailbox + * host)` sub-lists (RFC 3501 §7.4.2), honoring the group start/end + * markers (host NIL + mailbox non-NIL starts a group. Both NIL ends + * it). + */ + private static function parseAddressList(mixed $token): AddressList + { + $list = new AddressList(); + + if (!is_array($token)) { + return $list; + } + + $group = null; + $groupItems = null; + + foreach ($token as $address) { + if (!is_array($address)) { + continue; + } + + $personal = self::nstringOrNull($address[0] ?? null); + $mailbox = self::nstringOrNull($address[2] ?? null); + $host = self::nstringOrNull($address[3] ?? null); + + if ($host === null && $mailbox !== null) { + // Group start: flush any prior group, open a new one. + if ($group !== null && $groupItems !== null) { + $list->add(new Group($group, $groupItems)); + } + + $group = $mailbox; + $groupItems = new AddressList(groupsAllowed: false); + + continue; + } + + if ($host === null && $mailbox === null) { + // Group end. + if ($group !== null && $groupItems !== null) { + $list->add(new Group($group, $groupItems)); + } + + $group = null; + $groupItems = null; + + continue; + } + + $addr = new Address( + mailbox: $mailbox ?? '', + host: $host, + personal: $personal, + ); + + if ($groupItems !== null) { + $groupItems->add($addr); + } else { + $list->add($addr); + } + } + + // A group left open by a missing end marker still gets flushed. + if ($group !== null && $groupItems !== null) { + $list->add(new Group($group, $groupItems)); + } + + return $list; + } + + /** + * Recursively parse a BODYSTRUCTURE list into a {@see Part} tree + * (RFC 3501 §7.4.2). Multipart is distinguished by its first token + * being a nested list (a child part) rather than a type string. + * + * MIME-id numbering is deliberately left to the {@see Part} tree + * itself: iterating the built tree ({@see ImapFetchResult::getParts()} + * uses `iterate(false)`) yields the canonical RFC 3501 §6.4.5 section + * ids (`1`, `1.1`, `2`, ...) as iterator keys, so the parser does not + * stamp them a second time. Doing so would duplicate the tree's own + * numbering and, for a top-level multipart, disagree with it. + * + * @param list $tokens + */ + private static function parseBodyStructure(array $tokens): Part + { + if (isset($tokens[0]) && is_array($tokens[0])) { + return self::parseMultipart($tokens); + } + + return self::parseSinglePart($tokens); + } + + /** + * @param list $tokens + */ + private static function parseMultipart(array $tokens): Part + { + $children = []; + $i = 0; + + while (isset($tokens[$i]) && is_array($tokens[$i])) { + $children[] = self::parseBodyStructure($tokens[$i]); + ++$i; + } + + $subtype = self::nstring($tokens[$i] ?? null); + $rawHeaders = 'Content-Type: multipart/' . ($subtype !== '' ? $subtype : 'mixed') . "\r\n"; + + return new Part( + headers: HeaderCollection::parse($rawHeaders), + children: $children, + ); + } + + /** + * @param list $tokens + */ + private static function parseSinglePart(array $tokens): Part + { + $type = self::nstring($tokens[0] ?? null); + $subtype = self::nstring($tokens[1] ?? null); + $params = self::parseStructureParams($tokens[2] ?? null); + $contentId = self::nstringOrNull($tokens[3] ?? null); + $description = self::nstringOrNull($tokens[4] ?? null); + $encoding = self::nstringOrNull($tokens[5] ?? null); + $bytes = self::nstringOrNull($tokens[6] ?? null); + + $fullType = ($type !== '' ? $type : 'application') . '/' . ($subtype !== '' ? $subtype : 'octet-stream'); + + $headerLines = 'Content-Type: ' . $fullType . self::formatParams($params) . "\r\n"; + + if ($encoding !== null && $encoding !== '') { + $headerLines .= 'Content-Transfer-Encoding: ' . $encoding . "\r\n"; + } + + if ($contentId !== null && $contentId !== '') { + $headerLines .= 'Content-ID: ' . $contentId . "\r\n"; + } + + if ($description !== null && $description !== '') { + $headerLines .= 'Content-Description: ' . $description . "\r\n"; + } + + $sizeHint = ($bytes !== null && ctype_digit($bytes)) ? (int) $bytes : null; + + return new Part( + headers: HeaderCollection::parse($headerLines), + sizeHint: $sizeHint, + ); + } + + /** + * Parse a list (`("charset" "utf-8" ...)`) into a name => value map, lower-casing names. + * RFC 2045 params are case-insensitive. + * + * @return array + */ + private static function parseStructureParams(mixed $token): array + { + if (!is_array($token)) { + return []; + } + + $params = []; + $count = count($token); + + for ($i = 0; $i + 1 < $count; $i += 2) { + $name = $token[$i]; + $value = $token[$i + 1]; + + if (is_string($name) && is_string($value)) { + $params[strtolower($name)] = $value; + } + } + + return $params; + } + + /** + * @param array $params + */ + private static function formatParams(array $params): string + { + $out = ''; + + foreach ($params as $name => $value) { + $out .= '; ' . $name . '="' . $value . '"'; + } + + return $out; + } + + private static function parseInternalDate(string $value): ?DateTimeImmutable + { + if ($value === '') { + return null; + } + + try { + // IMAP INTERNALDATE: "dd-Mon-yyyy HH:MM:SS +ZZZZ". + return new DateTimeImmutable($value); + } catch (Exception) { + return null; + } + } + + /** + * @return list + */ + private static function stringList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $out = []; + + foreach ($value as $item) { + if (is_string($item)) { + $out[] = $item; + } + } + + return $out; + } + + /** + * @return list + */ + private static function asList(mixed $value): array + { + return is_array($value) ? $value : []; + } + + private static function asString(mixed $value): string + { + return is_string($value) ? $value : ''; + } + + /** + * An RFC 3501 §4.5 `nstring`: a string or NIL rendered as an empty string. + */ + private static function nstring(mixed $value): string + { + return is_string($value) ? $value : ''; + } + + private static function nstringOrNull(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/ImapFetchQuery.php b/src/ImapFetchQuery.php new file mode 100644 index 00000000..8bf06b45 --- /dev/null +++ b/src/ImapFetchQuery.php @@ -0,0 +1,307 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapFetchQuery +{ + use FetchQueryFields; + + /** + * Wire data items, in first-requested order, deduplicated. These are + * emitted verbatim as bare atoms in the `FETCH (...)` list. + * + * @var array + */ + private array $items = []; + + private bool $wantStructure = false; + + private bool $wantEnvelope = false; + + private bool $wantFlags = false; + + private bool $wantModSeq = false; + + /** + * Requested `HEADER.FIELDS` groups, keyed by caller label. Each maps + * to the reconstructed section signature (`HEADER.FIELDS (FROM TO)`) + * that the response key will carry, so {@see ImapFetchParser} can map + * an incoming `BODY[HEADER.FIELDS (...)]` key back to its label. + * This is the same round-trip legacy did through its `fetch_lookup` map. + * + * @var array + */ + private array $headerFieldLabels = []; + + public function structure(): self + { + $this->wantStructure = true; + $this->addItem('BODYSTRUCTURE'); + + return $this; + } + + public function envelope(): self + { + $this->wantEnvelope = true; + $this->addItem('ENVELOPE'); + + return $this; + } + + public function flags(): self + { + $this->wantFlags = true; + $this->addItem('FLAGS'); + + return $this; + } + + public function modseq(): self + { + $this->wantModSeq = true; + $this->addItem('MODSEQ'); + + return $this; + } + + /** + * Request a named subset of RFC 822 headers. + * + * @param string $label A caller-chosen key the matching + * headers are stored under, retrievable + * via {@see ImapFetchResult::getHeaders()}. + * @param list $fields The header field names (case-insensitive + * on the wire; upper-cased here). + * @param bool $not Fetch every header *except* `$fields` + * (`HEADER.FIELDS.NOT`, RFC 3501 §6.4.5). + * @param bool $peek Use `BODY.PEEK` (default) to avoid the + * `\Seen` side effect. + */ + public function headers(string $label, array $fields, bool $not = false, bool $peek = true): self + { + $upper = array_map('strtoupper', $fields); + $keyword = $not ? 'HEADER.FIELDS.NOT' : 'HEADER.FIELDS'; + $signature = $keyword . ' (' . implode(' ', $upper) . ')'; + + $this->headerFieldLabels[$label] = $signature; + $this->addItem(($peek ? 'BODY.PEEK[' : 'BODY[') . $signature . ']'); + + return $this; + } + + /** + * Request the RFC 822 header text of a MIME part (`BODY[id.HEADER]`), + * or the whole message header when `$id` is `0`/`''` (`BODY[HEADER]`). + */ + public function headerText(int|string $id = 0, bool $peek = true): self + { + $this->addItem($this->sectionItem($id, 'HEADER', $peek)); + + return $this; + } + + /** + * Request the body text of a MIME part (`BODY[id.TEXT]`) or the whole + * message body when `$id` is `0`/`''` (`BODY[TEXT]`). + */ + public function bodyText(int|string $id = 0, bool $peek = true): self + { + $this->addItem($this->sectionItem($id, 'TEXT', $peek)); + + return $this; + } + + /** + * Request the MIME header of a specific part (`BODY[id.MIME]`, RFC + * 3501 §6.4.5). Only meaningful for a non-zero part id. + */ + public function mimeHeader(int|string $id, bool $peek = true): self + { + $prefix = $peek ? 'BODY.PEEK[' : 'BODY['; + $this->addItem($prefix . $id . '.MIME]'); + + return $this; + } + + /** + * Request a specific MIME part's raw body content (`BODY[id]`), + * optionally a byte range of it (`BODY[id]`). + * + */ + public function bodyPart(int|string $id, ?int $start = null, ?int $length = null, bool $peek = true): self + { + $prefix = $peek ? 'BODY.PEEK[' : 'BODY['; + $this->addItem($prefix . $id . ']' . $this->partialSuffix($start, $length)); + + return $this; + } + + /** + * Request the full raw message (`BODY[]`), optionally a byte range. + * Overrides the trait's storage-only `fullMsg()` so the wire item is + * recorded too. + */ + public function fullMsg(?int $start = null, ?int $length = null, bool $peek = true): static + { + $this->wantFullMsg = true; + $this->fullMsgStart = $start; + $this->fullMsgLength = $length; + + $prefix = $peek ? 'BODY.PEEK[]' : 'BODY[]'; + $this->addItem($prefix . $this->partialSuffix($start, $length)); + + return $this; + } + + public function wantsStructure(): bool + { + return $this->wantStructure; + } + + public function wantsEnvelope(): bool + { + return $this->wantEnvelope; + } + + public function wantsFlags(): bool + { + return $this->wantFlags; + } + + public function wantsModSeq(): bool + { + return $this->wantModSeq; + } + + /** + * Whether the query asks for any stream-backed content that cannot be + * served from the metadata cache: the full message, a body/text + * section, a MIME header, or a raw body part. Header-field groups + * (`BODY[HEADER.FIELDS (...)]`) are cacheable and do not count. When + * false, every requested item (envelope, flags, structure, modseq, + * size, internaldate, header-field groups) can be served from cache. + */ + public function wantsStreamContent(): bool + { + if ($this->wantsFullMsg()) { + return true; + } + + foreach ($this->items as $item => $_) { + // A header-field group is cacheable; other BODY[...]/BINARY[...] + // sections carry stream content that is not. + if (str_contains($item, 'HEADER.FIELDS')) { + continue; + } + + if ( + str_starts_with($item, 'BODY.PEEK[') + || str_starts_with($item, 'BODY[') + || str_starts_with($item, 'BINARY[') + ) { + return true; + } + } + + return false; + } + + /** + * The caller labels of every requested `HEADER.FIELDS` group, in + * request order (RFC 3501 §6.4.5). These map to cacheable header text. + * + * @return list + */ + public function headerLabels(): array + { + return array_keys($this->headerFieldLabels); + } + + /** + * The wire data items to place inside the `FETCH (...)` list in + * first-requested order. `UID` is added by {@see ImapClient::fetch()} + * itself when needed (a `UID FETCH` returns it implicitly). It is + * never recorded here. + * + * @return list + */ + public function wireItems(): array + { + return array_keys($this->items); + } + + /** + * Map a reconstructed `HEADER.FIELDS (...)` section signature back to + * the caller label it was requested under or null if none matches. + */ + public function headerLabelFor(string $signature): ?string + { + $match = array_search($signature, $this->headerFieldLabels, true); + + return $match === false ? null : $match; + } + + private function addItem(string $item): void + { + $this->items[$item] = true; + } + + private function sectionItem(int|string $id, string $keyword, bool $peek): string + { + $prefix = $peek ? 'BODY.PEEK[' : 'BODY['; + $whole = $id === 0 || $id === '' || $id === '0'; + $section = $whole ? $keyword : $id . '.' . $keyword; + + return $prefix . $section . ']'; + } + + private function partialSuffix(?int $start, ?int $length): string + { + if ($length !== null) { + return '<' . ($start ?? 0) . '.' . $length . '>'; + } + + return $start !== null ? '<' . $start . '>' : ''; + } +} diff --git a/src/ImapFetchResult.php b/src/ImapFetchResult.php new file mode 100644 index 00000000..d950641e --- /dev/null +++ b/src/ImapFetchResult.php @@ -0,0 +1,310 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapFetchResult implements MessageMetadata, MessageContent, PartAccess, ParsedAccess +{ + private int|string|null $uid = null; + + /** @var list */ + private array $flags = []; + + private int $size = 0; + + private ?DateTimeImmutable $imapDate = null; + + private ?int $modSeq = null; + + private ?StreamInterface $fullMsg = null; + + /** @var array */ + private array $headerText = []; + + /** @var array */ + private array $bodyText = []; + + /** @var array */ + private array $mimeHeader = []; + + /** @var array */ + private array $bodyPart = []; + + /** @var array */ + private array $bodyPartSize = []; + + /** @var array Selective header groups by label. */ + private array $headers = []; + + private ?ImapEnvelope $envelope = null; + + private ?Part $structure = null; + + public function __construct( + private readonly int $seq, + ) {} + + // -- Setters (driven by ImapFetchParser) -- + + public function setUid(int|string $uid): void + { + $this->uid = $uid; + } + + /** + * @param list $flags + */ + public function setFlags(array $flags): void + { + $this->flags = $flags; + } + + public function setSize(int $size): void + { + $this->size = $size; + } + + public function setImapDate(?DateTimeImmutable $date): void + { + $this->imapDate = $date; + } + + public function setModSeq(?int $modSeq): void + { + $this->modSeq = $modSeq; + } + + public function setFullMsg(string $data): void + { + $this->fullMsg = $this->toStream($data); + } + + public function setHeaderText(int|string $id, string $data): void + { + $this->headerText[$id] = $this->toStream($data); + } + + public function setBodyText(int|string $id, string $data): void + { + $this->bodyText[$id] = $this->toStream($data); + } + + public function setMimeHeader(int|string $id, string $data): void + { + $this->mimeHeader[$id] = $this->toStream($data); + } + + public function setBodyPart(int|string $id, string $data): void + { + $this->bodyPart[$id] = $this->toStream($data); + } + + public function setBodyPartSize(int|string $id, int $size): void + { + $this->bodyPartSize[$id] = $size; + } + + public function setHeaders(string $label, string $data): void + { + $this->headers[$label] = $this->toStream($data); + } + + public function setEnvelope(ImapEnvelope $envelope): void + { + $this->envelope = $envelope; + } + + public function setStructure(Part $structure): void + { + $this->structure = $structure; + } + + // -- MessageMetadata -- + + public function getUid(): int|string + { + return $this->uid ?? $this->seq; + } + + /** + * @return list + */ + public function getFlags(): array + { + return $this->flags; + } + + public function getSize(): int + { + return $this->size; + } + + public function getImapDate(): DateTimeImmutable + { + return $this->imapDate ?? new DateTimeImmutable('@0'); + } + + public function getSeq(): int + { + return $this->seq; + } + + public function getModSeq(): ?int + { + return $this->modSeq; + } + + // -- MessageContent -- + + public function getFullMsg(): StreamInterface + { + return $this->fullMsg ?? $this->toStream(''); + } + + public function getHeaderText(string|int $id = 0): StreamInterface + { + return $this->headerText[$id] ?? $this->toStream(''); + } + + public function getBodyText(string|int $id = 0): StreamInterface + { + return $this->bodyText[$id] ?? $this->toStream(''); + } + + // -- PartAccess -- + + public function getBodyPart(string $id): StreamInterface + { + return $this->bodyPart[$id] ?? $this->toStream(''); + } + + public function getMimeHeader(string $id): StreamInterface + { + return $this->mimeHeader[$id] ?? $this->toStream(''); + } + + /** + * The reported octet size of a body part (`BINARY.SIZE`/`BODY[id]` + * length) or null if it was not fetched. + */ + public function getBodyPartSize(string $id): ?int + { + return $this->bodyPartSize[$id] ?? null; + } + + /** + * Yields the MIME parts of the structure lazily (RFC 3501 §7.4.2 + * `BODYSTRUCTURE`), skipping the top-level message part itself. Empty + * if no structure was fetched. + * + * @return Generator + */ + public function getParts(): Generator + { + if ($this->structure === null) { + return; + } + + yield from $this->structure->iterate(false); + } + + // -- ParsedAccess -- + + public function getEnvelope(): ImapEnvelope + { + return $this->envelope ??= new ImapEnvelope(); + } + + /** + * The named header group requested via {@see ImapFetchQuery::headers()}, + * parsed into a {@see HeaderCollection}. An unknown label yields an + * empty collection. + */ + public function getHeaders(string $label): HeaderCollection + { + $stream = $this->headers[$label] ?? null; + + return HeaderCollection::parse($stream !== null ? (string) $stream : ''); + } + + /** + * @return Generator<\Horde\Mime\Headers\HeaderElement> + */ + public function getHeadersIterator(string $label): Generator + { + yield from $this->getHeaders($label)->all(); + } + + /** + * The raw (unparsed) header text stored for a label, or null if the + * label was not fetched. Used by the cache layer, which stores the + * raw text and reconstructs the {@see HeaderCollection} on read via + * {@see getHeaders()}. + */ + public function getRawHeaders(string $label): ?string + { + return isset($this->headers[$label]) ? (string) $this->headers[$label] : null; + } + + /** + * The header-group labels present on this result. + * + * @return list + */ + public function headerLabels(): array + { + return array_keys($this->headers); + } + + public function getStructure(): Part + { + return $this->structure ??= new Part(); + } + + private function toStream(string $data): StreamInterface + { + $stream = new Temp(); + $stream->add($data, true); + + return $stream; + } +} diff --git a/src/ImapIdSet.php b/src/ImapIdSet.php new file mode 100644 index 00000000..08525fbd --- /dev/null +++ b/src/ImapIdSet.php @@ -0,0 +1,315 @@ + + * @author Ralf Lang + * @copyright 2011-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapIdSet implements MessageIdSet +{ + /** + * Explicit IDs in first-seen order. Empty when this is a special set. + * + * @var list + */ + private readonly array $ids; + + /** + * The special token this set represents or null for an explicit list. + */ + private readonly ?ImapIdSetToken $token; + + /** + * @param iterable|string|int|ImapIdSetToken $ids + * Explicit IDs (array/iterable of ints or numeric strings), an + * IMAP sequence string (`1:5,7`), a single ID or a special + * {@see ImapIdSetToken}. An empty argument yields an empty set. + * @param bool $sequence True if these are sequence numbers, not UIDs. + */ + public function __construct( + iterable|string|int|ImapIdSetToken $ids = [], + private readonly bool $sequence = false, + ) { + if ($ids instanceof ImapIdSetToken) { + $this->token = $ids; + $this->ids = []; + + return; + } + + $this->token = null; + $this->ids = self::normalize(self::resolve($ids)); + } + + /** + * Build a set from an IMAP message sequence string (`1:5,7,9:*`). + * + * A lone special token (`1:*`, `*`, `$`) is recognized and preserved as + * such rather than being expanded into an integer list. + */ + public static function fromSequenceString(string $str, bool $sequence = false): self + { + $trimmed = trim($str); + + $token = ImapIdSetToken::tryFrom($trimmed); + if ($token !== null) { + return new self($token, $sequence); + } + + return new self($trimmed, $sequence); + } + + /** + * Is this set one of the special forms (`1:*`, `*`, `$`)? + */ + public function isSpecial(): bool + { + return $this->token !== null; + } + + /** + * The special token this set represents or null for an explicit list. + */ + public function token(): ?ImapIdSetToken + { + return $this->token; + } + + public function isSequence(): bool + { + return $this->sequence; + } + + public function isEmpty(): bool + { + return $this->token === null && $this->ids === []; + } + + /** + * The smallest ID, or null for an empty or special set. + */ + public function min(): ?int + { + return $this->ids === [] ? null : min($this->ids); + } + + /** + * The largest ID, or null for an empty or special set. + */ + public function max(): ?int + { + return $this->ids === [] ? null : max($this->ids); + } + + /** + * Return a new set with $ids added (union). Adding to a special set + * replaces the token with the resulting explicit list. + * + * @param iterable|string|int $ids + */ + public function add(iterable|string|int $ids): self + { + return new self([...$this->ids, ...self::resolve($ids)], $this->sequence); + } + + /** + * Return a new set with $ids removed. Removing from a special set is a + * no-op (there is no concrete list to subtract from). + * + * @param iterable|string|int $ids + */ + public function remove(iterable|string|int $ids): self + { + if ($this->token !== null) { + return $this; + } + + $drop = self::normalize(self::resolve($ids)); + + return new self(array_values(array_diff($this->ids, $drop)), $this->sequence); + } + + /** + * @return list + */ + public function toArray(): array + { + return $this->ids; + } + + public function count(): int + { + return count($this->ids); + } + + /** + * @return Iterator + */ + public function getIterator(): Iterator + { + return new ArrayIterator($this->ids); + } + + /** + * The IMAP sequence string, IDs sorted ascending and ranges compressed. + * + * A special set renders as its wire token (`1:*`, `*`, `$`). An empty + * set renders as the empty string. + */ + public function __toString(): string + { + if ($this->token !== null) { + return $this->token->toWire(); + } + + return self::toSequenceString($this->ids); + } + + /** + * Resolve a constructor/add/remove argument into a flat list of ints. + * + * @param iterable|string|int $ids + * + * @return list + */ + private static function resolve(iterable|string|int $ids): array + { + if (is_int($ids)) { + return [$ids]; + } + + if (is_string($ids)) { + return self::fromWireList($ids); + } + + $out = []; + foreach ($ids as $id) { + $out[] = (int) $id; + } + + return $out; + } + + /** + * Deduplicate while preserving first-seen order. + * + * @param list $ids + * + * @return list + */ + private static function normalize(array $ids): array + { + return array_keys(array_flip($ids)); + } + + /** + * Parse an IMAP sequence string into an int list with ranges expanded. + * + * @return list + */ + private static function fromWireList(string $str): array + { + $str = trim($str); + if ($str === '') { + return []; + } + + $ids = []; + + foreach (explode(',', $str) as $part) { + $range = explode(':', $part, 2); + if (isset($range[1])) { + $start = (int) $range[0]; + $end = (int) $range[1]; + if ($start > $end) { + [$start, $end] = [$end, $start]; + } + for ($i = $start; $i <= $end; ++$i) { + $ids[] = $i; + } + } else { + $ids[] = (int) $part; + } + } + + return $ids; + } + + /** + * Compress a sorted, contiguity-collapsed IMAP sequence string. + * + * @param list $ids + */ + private static function toSequenceString(array $ids): string + { + if ($ids === []) { + return ''; + } + + sort($ids, SORT_NUMERIC); + + $out = []; + $start = $prev = $ids[0]; + + foreach (array_slice($ids, 1) as $id) { + if ($id === $prev + 1) { + $prev = $id; + continue; + } + + $out[] = self::rangePart($start, $prev); + $start = $prev = $id; + } + + $out[] = self::rangePart($start, $prev); + + return implode(',', $out); + } + + /** + * Render a single range or singleton such as `5` vs `5:9`. + */ + private static function rangePart(int $start, int $end): string + { + return $start === $end ? (string) $start : $start . ':' . $end; + } +} diff --git a/src/ImapIdSetToken.php b/src/ImapIdSetToken.php new file mode 100644 index 00000000..f648b950 --- /dev/null +++ b/src/ImapIdSetToken.php @@ -0,0 +1,49 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum ImapIdSetToken: string +{ + case All = '1:*'; + case Largest = '*'; + case SearchRes = '$'; + + /** + * The IMAP wire representation of this special set. + */ + public function toWire(): string + { + return $this->value; + } +} diff --git a/src/ImapInteraction.php b/src/ImapInteraction.php new file mode 100644 index 00000000..22684bd2 --- /dev/null +++ b/src/ImapInteraction.php @@ -0,0 +1,211 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapInteraction +{ + private readonly ImapCommandTag $tags; + + private readonly ImapPipeline $pipeline; + + public function __construct( + private readonly ImapConnection $connection, + ?ImapCommandTag $tags = null, + ?ImapPipeline $pipeline = null, + ) { + $this->tags = $tags ?? new ImapCommandTag(); + $this->pipeline = $pipeline ?? new ImapPipeline(); + } + + public function newTag(): string + { + return $this->tags->next(); + } + + /** + * Send a command built from `$name`/`$arguments` under a fresh tag + * and collect its response cycle. + * + * @param iterable $arguments + * + * @throws ServerResponseException If the command's own tagged + * response is `NO` or `BAD`. + * @throws ImapProtocolException If the wire produces a response + * this exchange cannot make sense + * of (an unexpected continuation, + * or a tagged response for a + * command genuinely still pending + * elsewhere in the pipeline). + */ + public function send(string $name, iterable $arguments = [], bool $nonSynchronizingLiterals = false): ImapCommandResult + { + $command = new ImapCommand($this->newTag(), $name, $arguments); + + return $this->run($command, $nonSynchronizingLiterals); + } + + /** + * Send an already-built command and collect its response cycle. + */ + public function run(ImapCommand $command, bool $nonSynchronizingLiterals = false): ImapCommandResult + { + $this->pipeline->enqueue($command); + $untagged = $this->connection->sendCommand($command, $nonSynchronizingLiterals); + + while (true) { + $response = $this->connection->readResponse(); + + if ($response->isContinuation()) { + throw new ImapProtocolException( + 'Unexpected continuation response outside of a literal exchange.', + ); + } + + if ($response->isUntagged()) { + $untagged[] = $response; + continue; + } + + if ($response->tag !== $command->tag) { + if ($this->pipeline->complete($response->tag) === null) { + // A dangling tagged response - left over from an + // aborted earlier exchange, or spurious server + // output. Ignore it and keep waiting for our own. + continue; + } + + throw new ImapProtocolException( + "Received a tagged response for '{$response->tag}' while awaiting" + . " '{$command->tag}'; concurrent multi-command pipelining is" + . ' not supported by this call.', + ); + } + + $this->pipeline->complete($command->tag); + + if ($response->isNo() || $response->isBad()) { + throw new ServerResponseException( + $response->text === '' ? 'IMAP error reported by server.' : $response->text, + 0, + null, + $command->name, + $response->status?->label(), + $response->text, + $response->responseCode, + ); + } + + return new ImapCommandResult($response, $untagged); + } + } + + /** + * Send several commands as one pipelined burst (RFC 3501 §5.5) and + * collect each one's result, keyed by tag. + * + * All commands are written first, then their tagged completions are + * read as they arrive. Untagged responses are attributed to the next + * command to complete (servers process a pipeline in order, so a + * command's untagged data precedes its tagged completion). Unlike + * {@see run()}, a `NO`/`BAD` completion does not throw: it is returned + * as that command's {@see ImapCommandResult} so one failing command + * does not abort the batch. The caller inspects each result's tagged + * status. + * + * A command that needs a synchronizing-literal continuation cannot be + * pipelined (only one continuation may be outstanding, RFC 3501 §5.5); + * passing one is rejected. + * + * @param list $commands + * + * @return array Keyed by command tag. + * + * @throws ImapProtocolException If a command needs a continuation, or + * the wire desyncs (an unexpected + * continuation, or a completion for an + * unknown tag). + */ + public function sendPipeline(array $commands): array + { + if ($commands === []) { + return []; + } + + foreach ($commands as $command) { + if ($command->needsContinuation()) { + throw new ImapProtocolException( + "Command '{$command->name}' needs a literal continuation and cannot be pipelined (RFC 3501 §5.5)." + ); + } + } + + // Write every command's segments back to back. sendCommand() + // returns no untagged here because none carries a continuation. + foreach ($commands as $command) { + $this->pipeline->enqueue($command); + $this->connection->sendCommand($command); + } + + $expected = count($commands); + $results = []; + $pendingUntagged = []; + + while (count($results) < $expected) { + $response = $this->connection->readResponse(); + + if ($response->isContinuation()) { + throw new ImapProtocolException( + 'Unexpected continuation response in a pipelined command burst.', + ); + } + + if ($response->isUntagged()) { + $pendingUntagged[] = $response; + continue; + } + + $command = $this->pipeline->complete($response->tag); + + if ($command === null) { + // Dangling/stale tagged response; ignore and keep reading. + continue; + } + + $results[$response->tag] = new ImapCommandResult($response, $pendingUntagged); + $pendingUntagged = []; + } + + return $results; + } +} diff --git a/src/ImapMailboxNameCodec.php b/src/ImapMailboxNameCodec.php new file mode 100644 index 00000000..f1c6ccd5 --- /dev/null +++ b/src/ImapMailboxNameCodec.php @@ -0,0 +1,42 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface ImapMailboxNameCodec +{ + /** + * Encode a UTF-8 mailbox name for the wire. + */ + public function encode(string $utf8Name): string; + + /** + * Decode a wire mailbox name back to UTF-8. + */ + public function decode(string $wireName): string; +} diff --git a/src/ImapMetadataAware.php b/src/ImapMetadataAware.php index c9c138ef..54c1b47a 100644 --- a/src/ImapMetadataAware.php +++ b/src/ImapMetadataAware.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,8 @@ * Separated because METADATA is an optional server capability. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface ImapMetadataAware diff --git a/src/ImapModifiedUtf7Codec.php b/src/ImapModifiedUtf7Codec.php new file mode 100644 index 00000000..5eb41e12 --- /dev/null +++ b/src/ImapModifiedUtf7Codec.php @@ -0,0 +1,41 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapModifiedUtf7Codec implements ImapMailboxNameCodec +{ + public function encode(string $utf8Name): string + { + return mb_convert_encoding($utf8Name, 'UTF7-IMAP', 'UTF-8'); + } + + public function decode(string $wireName): string + { + return mb_convert_encoding($wireName, 'UTF-8', 'UTF7-IMAP'); + } +} diff --git a/src/ImapNamespace.php b/src/ImapNamespace.php new file mode 100644 index 00000000..4687f4cb --- /dev/null +++ b/src/ImapNamespace.php @@ -0,0 +1,67 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapNamespace +{ + public function __construct( + public string $name, + public NamespaceType $type, + public ?string $delimiter = null, + public bool $hidden = false, + public string $translation = '', + ) {} + + /** + * The namespace base: the name with any single trailing delimiter + * removed (UTF-8). Mirrors the legacy `base` property. + */ + public function base(): string + { + if ($this->delimiter === null || $this->delimiter === '' || $this->name === '') { + return $this->name; + } + + return str_ends_with($this->name, $this->delimiter) + ? substr($this->name, 0, -strlen($this->delimiter)) + : $this->name; + } + + /** + * Strip this namespace's prefix from a mailbox name, if present. + * Returns the name unchanged when it does not start with the prefix. + */ + public function stripNamespace(string $mailbox): string + { + return ($this->name !== '' && str_starts_with($mailbox, $this->name)) + ? substr($mailbox, strlen($this->name)) + : $mailbox; + } +} diff --git a/src/ImapNamespaceList.php b/src/ImapNamespaceList.php new file mode 100644 index 00000000..c045cf4a --- /dev/null +++ b/src/ImapNamespaceList.php @@ -0,0 +1,101 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * + * @implements IteratorAggregate + */ +final class ImapNamespaceList implements Countable, IteratorAggregate +{ + /** @var array */ + private array $namespaces = []; + + /** + * @param iterable $namespaces + */ + public function __construct(iterable $namespaces = []) + { + foreach ($namespaces as $namespace) { + $this->namespaces[$namespace->name] = $namespace; + } + } + + /** + * The namespace registered under an exact name, or null if none. + */ + public function get(string $name): ?ImapNamespace + { + return $this->namespaces[$name] ?? null; + } + + /** + * The namespace a mailbox path belongs to, matching the longest + * prefix first and falling back to the empty ("") namespace. + * + * @param bool $personalOnly When true, the empty-namespace fallback + * applies only if that namespace is + * personal (RFC 2342 personal namespace). + */ + public function getForMailbox(string $mailbox, bool $personalOnly = false): ?ImapNamespace + { + if (isset($this->namespaces[$mailbox])) { + return $this->namespaces[$mailbox]; + } + + foreach ($this->namespaces as $namespace) { + if ($namespace->name !== '' && str_starts_with($mailbox . ($namespace->delimiter ?? ''), $namespace->name)) { + return $namespace; + } + } + + $empty = $this->namespaces[''] ?? null; + + if ($empty === null) { + return null; + } + + return (!$personalOnly || $empty->type === NamespaceType::Personal) ? $empty : null; + } + + public function count(): int + { + return count($this->namespaces); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->namespaces); + } +} diff --git a/src/ImapPipeline.php b/src/ImapPipeline.php new file mode 100644 index 00000000..065df5d2 --- /dev/null +++ b/src/ImapPipeline.php @@ -0,0 +1,68 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapPipeline +{ + /** @var array */ + private array $commands = []; + + public function enqueue(ImapCommand $command): void + { + $this->commands[$command->tag] = $command; + } + + public function isPending(string $tag): bool + { + return isset($this->commands[$tag]); + } + + /** + * Remove and return the command for a tag, or null if that tag is + * not (or is no longer) outstanding. A dangling tagged response i.e. + * one left over from an aborted earlier exchange. + */ + public function complete(string $tag): ?ImapCommand + { + $command = $this->commands[$tag] ?? null; + unset($this->commands[$tag]); + + return $command; + } + + public function count(): int + { + return count($this->commands); + } + + public function isEmpty(): bool + { + return $this->commands === []; + } +} diff --git a/src/ImapProtocol.php b/src/ImapProtocol.php index f4dccb8d..274f6dc8 100644 --- a/src/ImapProtocol.php +++ b/src/ImapProtocol.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,12 +22,13 @@ * is internal to the implementation. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface ImapProtocol extends MailboxProtocol { - public function getCapability(): CapabilityInterface; + public function getCapability(): Capability; public function openMailbox(string $mailbox, OpenMode $mode): void; diff --git a/src/ImapQresyncResult.php b/src/ImapQresyncResult.php new file mode 100644 index 00000000..a866e1da --- /dev/null +++ b/src/ImapQresyncResult.php @@ -0,0 +1,46 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapQresyncResult +{ + /** + * @param ImapIdSet $vanished UIDs expunged since the + * client's last sync. + * @param array $changed Flag-change fetches, + * keyed by UID. + */ + public function __construct( + public ImapIdSet $vanished, + public array $changed = [], + ) {} +} diff --git a/src/ImapQuotaAware.php b/src/ImapQuotaAware.php index fe69fd66..576206e0 100644 --- a/src/ImapQuotaAware.php +++ b/src/ImapQuotaAware.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,8 @@ * Separated because QUOTA is an optional server capability. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface ImapQuotaAware diff --git a/src/ImapResponse.php b/src/ImapResponse.php new file mode 100644 index 00000000..574ac29b --- /dev/null +++ b/src/ImapResponse.php @@ -0,0 +1,89 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapResponse +{ + /** + * @param list $data Unconsumed tokens (see class docblock). + */ + public function __construct( + public readonly ImapResponseKind $kind, + public readonly ?string $tag = null, + public readonly ?ImapResponseStatus $status = null, + public readonly ?ImapResponseCode $responseCode = null, + public readonly array $data = [], + public readonly string $text = '', + ) {} + + public function isTagged(): bool + { + return $this->kind === ImapResponseKind::Tagged; + } + + public function isUntagged(): bool + { + return $this->kind === ImapResponseKind::Untagged; + } + + public function isContinuation(): bool + { + return $this->kind === ImapResponseKind::Continuation; + } + + public function isOk(): bool + { + return $this->status === ImapResponseStatus::Ok; + } + + public function isNo(): bool + { + return $this->status === ImapResponseStatus::No; + } + + public function isBad(): bool + { + return $this->status === ImapResponseStatus::Bad; + } + + public function isBye(): bool + { + return $this->status === ImapResponseStatus::Bye; + } + + public function isPreAuth(): bool + { + return $this->status === ImapResponseStatus::PreAuth; + } +} diff --git a/src/ImapResponseCode.php b/src/ImapResponseCode.php new file mode 100644 index 00000000..c1564927 --- /dev/null +++ b/src/ImapResponseCode.php @@ -0,0 +1,41 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapResponseCode +{ + /** + * @param list $data Tokens that followed the code name inside + * the brackets (strings, or nested arrays + * for a parenthesized list such as + * `PERMANENTFLAGS`). + */ + public function __construct( + public readonly string $name, + public readonly array $data = [], + ) {} +} diff --git a/src/ImapResponseKind.php b/src/ImapResponseKind.php new file mode 100644 index 00000000..82da0d59 --- /dev/null +++ b/src/ImapResponseKind.php @@ -0,0 +1,34 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum ImapResponseKind +{ + /** ` ...` completes exactly one outstanding command. */ + case Tagged; + + /** `* ...` data or a status update not tied to any one command. */ + case Untagged; + + /** `+ ...` a request for more data (a literal or a SASL challenge). */ + case Continuation; +} diff --git a/src/ImapResponseParser.php b/src/ImapResponseParser.php new file mode 100644 index 00000000..d73464df --- /dev/null +++ b/src/ImapResponseParser.php @@ -0,0 +1,143 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapResponseParser +{ + private function __construct() {} + + /** + * @param list $tokens One {@see ImapTokenizer::readLine()} result. + * + * @throws ImapProtocolException On an empty line, or a tagged line + * with no status word. + */ + public static function parse(array $tokens): ImapResponse + { + if ($tokens === []) { + throw new ImapProtocolException('Empty server response.'); + } + + $first = $tokens[0]; + $rest = array_slice($tokens, 1); + + if ($first === '+') { + return new ImapResponse(ImapResponseKind::Continuation, data: $rest, text: self::joinText($rest)); + } + + $kind = $first === '*' ? ImapResponseKind::Untagged : ImapResponseKind::Tagged; + $tag = $kind === ImapResponseKind::Tagged ? $first : null; + $status = ($rest !== [] && is_string($rest[0])) ? self::statusFor($rest[0]) : null; + + if ($status === null) { + if ($kind === ImapResponseKind::Tagged) { + throw new ImapProtocolException("Malformed tagged response for tag '{$tag}'."); + } + + // Untagged data (e.g. "1 EXISTS", "CAPABILITY ...") carries + // no status word and no response code. + return new ImapResponse(ImapResponseKind::Untagged, data: $rest, text: self::joinText($rest)); + } + + $remaining = array_slice($rest, 1); + $responseCode = self::extractResponseCode($remaining); + + return new ImapResponse($kind, $tag, $status, $responseCode, $remaining, self::joinText($remaining)); + } + + private static function statusFor(string $word): ?ImapResponseStatus + { + return match (strtoupper($word)) { + 'OK' => ImapResponseStatus::Ok, + 'NO' => ImapResponseStatus::No, + 'BAD' => ImapResponseStatus::Bad, + 'BYE' => ImapResponseStatus::Bye, + 'PREAUTH' => ImapResponseStatus::PreAuth, + default => null, + }; + } + + /** + * Pull a leading `[CODE ...]` off `$tokens`, if present, consuming + * the tokens it spans. + * + * @param list $tokens + */ + private static function extractResponseCode(array &$tokens): ?ImapResponseCode + { + if ($tokens === [] || !is_string($tokens[0]) || !str_starts_with($tokens[0], '[')) { + return null; + } + + $first = array_shift($tokens); + + if (str_ends_with($first, ']')) { + return new ImapResponseCode(substr($first, 1, -1)); + } + + $name = substr($first, 1); + $data = []; + + while ($tokens !== []) { + $token = array_shift($tokens); + + if (is_string($token) && str_ends_with($token, ']')) { + $trimmed = substr($token, 0, -1); + + if ($trimmed !== '') { + $data[] = $trimmed; + } + + break; + } + + $data[] = $token; + } + + return new ImapResponseCode($name, $data); + } + + /** + * @param list $tokens + */ + private static function joinText(array $tokens): string + { + $parts = []; + + foreach ($tokens as $token) { + $parts[] = match (true) { + is_array($token) => '(' . self::joinText($token) . ')', + $token === null => 'NIL', + default => $token, + }; + } + + return implode(' ', $parts); + } +} diff --git a/src/ImapResponseStatus.php b/src/ImapResponseStatus.php new file mode 100644 index 00000000..e291dbb4 --- /dev/null +++ b/src/ImapResponseStatus.php @@ -0,0 +1,59 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum ImapResponseStatus +{ + /** The preceding command (or the connection as a whole) succeeded. */ + case Ok; + + /** A warning or a failure not directly tied to any one command. */ + case No; + + /** The tagged command was rejected, or the server reports a fatal error. */ + case Bad; + + /** The server is closing the connection. */ + case Bye; + + /** The connection arrived already authenticated (pre-authenticated). */ + case PreAuth; + + /** + * The wire word this status was parsed from (`OK`, `NO`, ...). + */ + public function label(): string + { + return match ($this) { + self::Ok => 'OK', + self::No => 'NO', + self::Bad => 'BAD', + self::Bye => 'BYE', + self::PreAuth => 'PREAUTH', + }; + } +} diff --git a/src/ImapSearchParser.php b/src/ImapSearchParser.php new file mode 100644 index 00000000..d78f62b3 --- /dev/null +++ b/src/ImapSearchParser.php @@ -0,0 +1,184 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapSearchParser +{ + private function __construct() {} + + /** + * @param list $untagged + */ + public static function parse(array $untagged, bool $sequence): ImapSearchResult + { + /** @var list $matches */ + $matches = []; + $count = null; + $min = null; + $max = null; + $modseq = null; + $relevancy = []; + $usedEsearch = false; + + foreach ($untagged as $response) { + if (!$response->isUntagged() || $response->data === [] || !is_string($response->data[0])) { + continue; + } + + $keyword = strtoupper($response->data[0]); + + // A SORT reply (RFC 5256) shares the classic SEARCH shape: a + // space-separated id list, but in the server's sort order. + if ($keyword === 'SEARCH' || $keyword === 'SORT') { + foreach (array_slice($response->data, 1) as $token) { + if (is_string($token) && ctype_digit($token)) { + $matches[] = (int) $token; + } + } + + continue; + } + + if ($keyword === 'ESEARCH') { + $usedEsearch = true; + self::parseEsearch($response->data, $sequence, $matches, $count, $min, $max, $modseq, $relevancy); + } + } + + $match = new ImapIdSet($matches, $sequence); + + // Classic SEARCH does not report COUNT/MIN/MAX. Derive them from + // the matching set so the caller sees a uniform result regardless + // of which wire form the server used. + if (!$usedEsearch) { + $ids = $match->toArray(); + $count = count($ids); + $min = $ids === [] ? null : min($ids); + $max = $ids === [] ? null : max($ids); + } + + return new ImapSearchResult( + match: $match, + count: $count, + min: $min, + max: $max, + relevancy: $relevancy, + modseq: $modseq, + ); + } + + /** + * @param list $data The whole ESEARCH response tokens, + * starting with the `ESEARCH` word. + * @param list $matches + * @param list $relevancy + * + * @param-out list $matches + * @param-out list $relevancy + */ + private static function parseEsearch( + array $data, + bool $sequence, + array &$matches, + ?int &$count, + ?int &$min, + ?int &$max, + ?int &$modseq, + array &$relevancy, + ): void { + $index = 1; + + // Skip the optional search-correlator "(TAG "A1")". + if (isset($data[$index]) && is_array($data[$index])) { + $index++; + } + + // Skip the optional "UID" marker (present for a UID SEARCH). + if (isset($data[$index]) && is_string($data[$index]) && strtoupper($data[$index]) === 'UID') { + $index++; + } + + $total = count($data); + + for (; $index < $total; $index += 2) { + $name = $data[$index]; + $value = $data[$index + 1] ?? null; + + if (!is_string($name)) { + continue; + } + + switch (strtoupper($name)) { + case 'ALL': + if (is_string($value)) { + foreach (ImapIdSet::fromSequenceString($value, $sequence)->toArray() as $id) { + $matches[] = $id; + } + } + break; + + case 'COUNT': + $count = self::intOrNull($value); + break; + + case 'MIN': + $min = self::intOrNull($value); + break; + + case 'MAX': + $max = self::intOrNull($value); + break; + + case 'MODSEQ': + $modseq = self::intOrNull($value); + break; + + case 'RELEVANCY': + if (is_array($value)) { + foreach ($value as $score) { + if (is_string($score) && ctype_digit($score)) { + $relevancy[] = (int) $score; + } + } + } + break; + } + } + } + + private static function intOrNull(mixed $value): ?int + { + return (is_string($value) && ctype_digit($value)) ? (int) $value : null; + } +} diff --git a/src/ImapSearchQuery.php b/src/ImapSearchQuery.php new file mode 100644 index 00000000..f000443b --- /dev/null +++ b/src/ImapSearchQuery.php @@ -0,0 +1,472 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapSearchQuery +{ + /** + * The RFC 3501 §2.3.2 system flags, which search as a single token + * (`SEEN`) or its negation (`UNSEEN`), unlike keywords which take a + * `KEYWORD ` pair. + */ + private const SYSTEM_FLAGS = ['ANSWERED', 'DELETED', 'DRAFT', 'FLAGGED', 'RECENT', 'SEEN']; + + /** + * The RFC 3501 §6.4.4 header search keys that are a single token + * rather than the generic `HEADER ` two-token form. + */ + private const HEADER_KEYS = ['BCC', 'CC', 'FROM', 'SUBJECT', 'TO']; + + /** + * Accumulated criteria nodes, resolved to wire form at {@see build()}. + * A node is an {@see ImapWireEncodable} (structural token, ready as-is), + * an {@see ImapSearchText} (deferred text, encoded at build time), or a + * group `array{op: 'NOT'|'OR', nodes: list<...>}` for a nested + * sub-query (also resolved at build time so its text picks up the + * chosen charset). + * + * @var list}> + */ + private array $criteria = []; + + private ?string $charset = null; + + /** + * Match a system flag or keyword. A system flag (`\Seen`, `\Flagged`, + * ...) searches as `SEEN`/`UNSEEN`; any other name searches as + * `KEYWORD `/`UNKEYWORD ` (RFC 3501 §6.4.4). + */ + public function flag(string $name, bool $set = true): self + { + $name = strtoupper(ltrim($name, '\\')); + + if (in_array($name, self::SYSTEM_FLAGS, true)) { + $this->add(new ImapWireAtom(($set ? '' : 'UN') . $name)); + + return $this; + } + + $this->add(new ImapWireAtom($set ? 'KEYWORD' : 'UNKEYWORD')); + $this->add(new ImapWireAtom($name)); + + return $this; + } + + /** + * Mark the next criterion as fuzzy (`SEARCH=FUZZY`, RFC 6203): the + * server may return relevance-ranked, approximate matches rather than + * exact ones. Emits a `FUZZY` token before the criterion that follows, + * e.g. `->fuzzy()->text('mispeld')` produces `FUZZY BODY mispeld`. + * + * The caller is responsible for confirming the server advertises + * `SEARCH=FUZZY` (Dovecot and Cyrus do so when a full-text index is + * configured). + */ + public function fuzzy(): self + { + $this->add(new ImapWireAtom('FUZZY')); + + return $this; + } + + /** + * Match every message (`ALL`, RFC 3501 §6.4.4). This is the implicit + * default when no criteria are added, but is offered explicitly so a + * caller can be unambiguous. + */ + public function all(): self + { + $this->add(new ImapWireAtom('ALL')); + + return $this; + } + + /** + * Match a specific header field (RFC 3501 §6.4.4). The well-known + * keys (`FROM`, `TO`, `CC`, `BCC`, `SUBJECT`) use their single-token + * form; any other field name uses the generic `HEADER `. + */ + public function header(string $field, string $value, bool $not = false): self + { + $field = strtoupper($field); + + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + if (in_array($field, self::HEADER_KEYS, true)) { + $this->add(new ImapWireAtom($field)); + } else { + $this->add(new ImapWireAtom('HEADER')); + $this->add(new ImapWireString($field, isAstring: true)); + } + + $this->addText($value); + + return $this; + } + + /** + * Match message body text (`BODY`) or the full message text including + * headers (`TEXT`) (RFC 3501 §6.4.4). + */ + public function text(string $value, bool $bodyOnly = true, bool $not = false): self + { + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + $this->add(new ImapWireAtom($bodyOnly ? 'BODY' : 'TEXT')); + $this->addText($value); + + return $this; + } + + /** + * Match on message size in bytes: `LARGER` or `SMALLER` + * (RFC 3501 §6.4.4). + */ + public function size(int $bytes, bool $larger = true): self + { + $this->add(new ImapWireAtom($larger ? 'LARGER' : 'SMALLER')); + $this->add(new ImapWireNumber($bytes)); + + return $this; + } + + /** + * Match a set of message ids. When `$ids` is a UID set the `UID` + * keyword is emitted first; a sequence set is added bare + * (RFC 3501 §6.4.4). + */ + public function ids(ImapIdSet $ids, bool $not = false): self + { + if ($ids->isEmpty()) { + return $this; + } + + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + if (!$ids->isSequence()) { + $this->add(new ImapWireAtom('UID')); + } + + $this->add(new ImapWireAtom((string) $ids)); + + return $this; + } + + /** + * Match every UID at or above `$from` (`UID :*`, RFC 3501 + * §6.4.4). The open-ended `:*` range cannot be expressed through an + * {@see ImapIdSet} (which only models the lone `*` special), so this + * is a dedicated helper, used chiefly by {@see ImapClient::sync()} to + * find messages arrived since a known UIDNEXT. + */ + public function uidFrom(int $from): self + { + $this->add(new ImapWireAtom('UID')); + $this->add(new ImapWireAtom($from . ':*')); + + return $this; + } + + /** + * Match on a date, comparing either the message's internal date or + * (when `$sent` is true) its `Date:` header (RFC 3501 §6.4.4). + * `$range` is one of `BEFORE`, `ON`, `SINCE`. + */ + public function date(DateTimeInterface $date, string $range = 'SINCE', bool $sent = false, bool $not = false): self + { + $range = strtoupper($range); + $keyword = $sent ? 'SENT' . $range : $range; + + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + $this->add(new ImapWireAtom($keyword)); + // RFC 3501 date format: 1-Feb-1994 (day is not zero padded). + $this->add(new ImapWireAtom($date->format('j-M-Y'))); + + return $this; + } + + /** + * Match on relative age using the WITHIN extension (RFC 5032): + * `YOUNGER ` or `OLDER `. The caller is responsible + * for confirming the server advertises `WITHIN`. + */ + public function within(int $seconds, bool $younger = true, bool $not = false): self + { + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + $this->add(new ImapWireAtom($younger ? 'YOUNGER' : 'OLDER')); + $this->add(new ImapWireNumber($seconds)); + + return $this; + } + + /** + * Match on modification sequence (CONDSTORE, RFC 7162 §3.1.5). The + * optional `$entryName`/`$entryType` pair narrows the match to a + * metadata item; `$entryType` is one of `all`, `priv`, `shared`. The + * caller is responsible for confirming the server advertises + * `CONDSTORE`. + */ + public function modseq(int $value, ?string $entryName = null, string $entryType = 'all'): self + { + $this->add(new ImapWireAtom('MODSEQ')); + + if ($entryName !== null) { + $this->add(new ImapWireString($entryName)); + $this->add(new ImapWireAtom(strtolower($entryType))); + } + + $this->add(new ImapWireNumber($value)); + + return $this; + } + + /** + * Match the result of the previous SEARCH on this connection (the `$` + * marker, SEARCHRES / RFC 5182). The caller is responsible for + * confirming the server advertises `SEARCHRES`. + */ + public function previousSearch(bool $not = false): self + { + if ($not) { + $this->add(new ImapWireAtom('NOT')); + } + + $this->add(new ImapWireAtom('$')); + + return $this; + } + + /** + * Negate a whole sub-query: `NOT (...)` (RFC 3501 §6.4.4). The + * sub-query's criteria are wrapped in a parenthesized group, resolved + * (including any text charset) when this query is built. + */ + public function notMatching(self $query): self + { + $this->mergeCharset($query->charset); + $this->criteria[] = ['op' => 'NOT', 'nodes' => $query->criteria]; + + return $this; + } + + /** + * Match either this query's accumulated criteria or the alternative: + * `OR (this-so-far) (other)` (RFC 3501 §6.4.4). Each side is wrapped + * in its own parenthesized group so the operator binds the two whole + * sub-queries, not just their first tokens. + */ + public function orWith(self $query): self + { + $this->mergeCharset($query->charset); + $this->criteria = [['op' => 'OR', 'nodes' => $this->criteria, 'right' => $query->criteria]]; + + return $this; + } + + /** + * Force the search charset. Normally the charset is inferred (it + * becomes `UTF-8` the first time a non-ASCII text value is added); + * setting it explicitly is only needed for a legacy charset a caller + * has already encoded its values in. + */ + public function charset(string $charset): self + { + $this->charset = strtoupper($charset); + + return $this; + } + + /** + * The built search command. + * + * `criteria` is the ordered token list to splice into the `SEARCH` + * command as individual arguments. When empty, the client sends `ALL`. + * `charset` is the charset the text values need, or null when the + * query holds no text (so no `CHARSET` argument is required). + * + * `$charsetOverride` re-encodes every text value into a specific + * charset instead of the query's own (used by {@see ImapClient::search()} + * to retry after a `NO [BADCHARSET ...]` rejection); the returned + * `charset` then reflects the override. + * + * @return array{charset: ?string, criteria: list} + */ + public function build(?string $charsetOverride = null): array + { + $charset = $charsetOverride !== null ? strtoupper($charsetOverride) : $this->charset; + + return ['charset' => $charset, 'criteria' => $this->resolveNodes($this->criteria, $charset)]; + } + + /** + * Resolve a node list to wire encodables in `$charset`: structural + * tokens pass through, deferred text is encoded, and NOT/OR groups + * recurse into parenthesized lists. + * + * @param array $nodes + * + * @return list + */ + private function resolveNodes(array $nodes, ?string $charset): array + { + $out = []; + + foreach ($nodes as $node) { + if ($node instanceof ImapSearchText) { + $out[] = $node->encode($charset); + } elseif ($node instanceof ImapWireEncodable) { + $out[] = $node; + } elseif (is_array($node) && ($node['op'] ?? null) === 'NOT') { + $out[] = new ImapWireAtom('NOT'); + $out[] = new ImapWireList($this->resolveNodes($node['nodes'], $charset)); + } elseif (is_array($node) && ($node['op'] ?? null) === 'OR') { + $out[] = new ImapWireAtom('OR'); + $out[] = new ImapWireList($this->resolveNodes($node['nodes'], $charset)); + $out[] = new ImapWireList($this->resolveNodes($node['right'], $charset)); + } + } + + return $out; + } + + private function add(ImapWireEncodable $token): void + { + $this->criteria[] = $token; + } + + /** + * Add a text value, promoting the search charset to UTF-8 when the + * value is not pure ASCII (RFC 3501 §6.4.4 requires a CHARSET for + * non-ASCII search text). The raw UTF-8 is held (as an + * {@see ImapSearchText}) and only encoded at {@see build()} time, so a + * BADCHARSET retry can re-encode it. + */ + private function addText(string $value): void + { + if ($this->charset === null && !$this->isAscii($value)) { + $this->charset = 'UTF-8'; + } + + $this->criteria[] = new ImapSearchText($value); + } + + private function mergeCharset(?string $charset): void + { + if ($charset !== null) { + $this->charset = $charset; + } + } + + private function isAscii(string $value): bool + { + return $value === '' || !preg_match('/[^\x00-\x7F]/', $value); + } + + /** + * Whether every text value in this query (including nested OR/NOT + * sub-queries) can be represented in `$charset` without loss, i.e. it + * survives a UTF-8 -> charset -> UTF-8 round trip unchanged. Used to + * reject a lossy BADCHARSET retry target (e.g. US-ASCII for accented + * text). + */ + public function canEncodeIn(string $charset): bool + { + if (strtoupper($charset) === 'UTF-8') { + return true; + } + + foreach ($this->collectText($this->criteria) as $value) { + if ($value === '') { + continue; + } + + $roundTrip = mb_convert_encoding(mb_convert_encoding($value, $charset, 'UTF-8'), 'UTF-8', $charset); + + if ($roundTrip !== $value) { + return false; + } + } + + return true; + } + + /** + * Gather the raw UTF-8 text values from a node list, descending into + * NOT/OR groups. + * + * @param array $nodes + * + * @return list + */ + private function collectText(array $nodes): array + { + $out = []; + + foreach ($nodes as $node) { + if ($node instanceof ImapSearchText) { + $out[] = $node->utf8Value; + } elseif (is_array($node)) { + $out = [...$out, ...$this->collectText($node['nodes'])]; + + if (isset($node['right'])) { + $out = [...$out, ...$this->collectText($node['right'])]; + } + } + } + + return $out; + } +} diff --git a/src/ImapSearchResult.php b/src/ImapSearchResult.php new file mode 100644 index 00000000..2850a2a4 --- /dev/null +++ b/src/ImapSearchResult.php @@ -0,0 +1,63 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapSearchResult +{ + /** + * @param ImapIdSet $match The matching messages (UIDs or + * sequence numbers, per the search + * mode). Empty when nothing matched. + * @param ?int $count Number of matches (ESEARCH `COUNT`), + * or derived from `$match`. + * @param ?int $min Lowest matching id (ESEARCH `MIN`). + * @param ?int $max Highest matching id (ESEARCH `MAX`). + * @param ?bool $saved Whether the server saved the result + * to the search-result variable (`$`, + * RFC 5182). Null when not requested. + * @param list $relevancy Per-match relevancy scores + * (RFC 6203), empty when not reported. + * @param ?int $modseq Highest MODSEQ among matches when the + * server reported one (RFC 7162). + */ + public function __construct( + public ImapIdSet $match, + public ?int $count = null, + public ?int $min = null, + public ?int $max = null, + public ?bool $saved = null, + public array $relevancy = [], + public ?int $modseq = null, + ) {} +} diff --git a/src/ImapSearchText.php b/src/ImapSearchText.php new file mode 100644 index 00000000..34ad5a3e --- /dev/null +++ b/src/ImapSearchText.php @@ -0,0 +1,51 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapSearchText +{ + public function __construct(public string $utf8Value) {} + + /** + * Materialize as an {@see ImapWireString} in `$charset`. UTF-8 (and + * the null "no charset chosen" case, which only happens for pure-ASCII + * text) passes through unconverted; any other charset re-encodes from + * UTF-8 via ext-mbstring (already a hard dependency of this library). + */ + public function encode(?string $charset): ImapWireString + { + $value = $this->utf8Value; + + if ($charset !== null && strtoupper($charset) !== 'UTF-8' && $value !== '') { + $value = mb_convert_encoding($value, $charset, 'UTF-8'); + } + + return new ImapWireString($value, isAstring: true); + } +} diff --git a/src/ImapStringClassification.php b/src/ImapStringClassification.php new file mode 100644 index 00000000..55c4246c --- /dev/null +++ b/src/ImapStringClassification.php @@ -0,0 +1,37 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapStringClassification +{ + public function __construct( + /** Must be quoted (contains a space, paren, or other atom-special). */ + public readonly bool $quoted, + /** Must be sent as a literal (contains CR, LF, or a null byte). */ + public readonly bool $literal, + /** Contains a null byte, so a literal8 (RFC 3516) is required. */ + public readonly bool $binary, + /** Contains an octet outside printable US-ASCII. */ + public readonly bool $nonAscii, + ) {} +} diff --git a/src/ImapStringClassifier.php b/src/ImapStringClassifier.php new file mode 100644 index 00000000..670987e1 --- /dev/null +++ b/src/ImapStringClassifier.php @@ -0,0 +1,96 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapStringClassifier +{ + /** + * Atom-special characters (RFC 3501 §9) that force quoting when + * they appear inside an otherwise plain string. `%` and `*` are + * handled separately below as they are only specials outside a + * LIST/LSUB pattern argument. + */ + private const ATOM_SPECIALS = " \"(){}\\\x7f"; + + /** + * @param bool $allowWildcards If true, `%` and `*` do not force + * quoting. Set this for a LIST/LSUB + * mailbox-pattern argument, where those + * characters are wildcards rather than + * literal text (RFC 3501 §6.3.8). + */ + public static function classify(string $data, bool $allowWildcards = false): ImapStringClassification + { + $quoted = false; + $literal = false; + $nonAscii = false; + + $len = strlen($data); + + for ($i = 0; $i < $len; ++$i) { + $byte = $data[$i]; + $ord = ord($byte); + + if ($ord === 0) { + // A null byte can only travel in a literal8 (RFC 3516). + // Nothing else in the string changes that, so stop early. + return new ImapStringClassification( + quoted: false, + literal: true, + binary: true, + nonAscii: $nonAscii, + ); + } + + if ($ord === 10 || $ord === 13) { + // Embedded CR/LF: Only a literal can carry this safely. + $literal = true; + } elseif ($ord < 32) { + // Other control characters must, at minimum, be quoted. + $quoted = true; + } elseif ($ord > 127) { + $nonAscii = true; + // 8-bit octets must travel in a literal. + $literal = true; + } elseif (($byte === '%' || $byte === '*')) { + if (!$allowWildcards) { + $quoted = true; + } + } elseif (str_contains(self::ATOM_SPECIALS, $byte)) { + $quoted = true; + } + } + + return new ImapStringClassification( + quoted: $quoted && !$literal, + literal: $literal, + binary: false, + nonAscii: $nonAscii, + ); + } +} diff --git a/src/ImapSyncResult.php b/src/ImapSyncResult.php new file mode 100644 index 00000000..07529142 --- /dev/null +++ b/src/ImapSyncResult.php @@ -0,0 +1,39 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final readonly class ImapSyncResult +{ + public function __construct( + public ImapIdSet $newMsgs, + public ImapIdSet $flagChanges, + public ImapIdSet $vanished, + ) {} +} diff --git a/src/ImapThreadParser.php b/src/ImapThreadParser.php new file mode 100644 index 00000000..13ee668c --- /dev/null +++ b/src/ImapThreadParser.php @@ -0,0 +1,99 @@ + level + * map the way the legacy `_parseThreadLevel` walker did off its streaming + * cursor: scalar ids at a given depth take increasing levels, and a + * nested list continues from the current level rather than resetting it. + * + * Stateless: every method is static. `$sequence` records the id kind. + * + * @author Michael Slusarz + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapThreadParser +{ + private function __construct() {} + + /** + * @param list $untagged + */ + public static function parse(array $untagged, bool $sequence): ImapThreadResult + { + $threads = []; + + foreach ($untagged as $response) { + if (!$response->isUntagged() || $response->data === [] || !is_string($response->data[0])) { + continue; + } + + if (strtoupper($response->data[0]) !== 'THREAD') { + continue; + } + + foreach (array_slice($response->data, 1) as $thread) { + if (!is_array($thread)) { + continue; + } + + $flat = []; + self::flatten($thread, $flat, 0); + + if ($flat !== []) { + $threads[] = $flat; + } + } + } + + return new ImapThreadResult($threads, $sequence); + } + + /** + * Flatten one thread's nested id lists into an ordered id => level + * map. Within a branch, scalar ids take increasing levels; a nested + * sub-thread recurses starting at the branch's current level, and + * sibling sub-threads at the same position therefore share that level + * (RFC 5256 §4). This mirrors the legacy `_parseThreadLevel` walker, + * which passed its level by value on recursion so a child's advance + * never leaked back to its parent or its siblings. + * + * @param list $nodes + * @param array $flat + */ + private static function flatten(array $nodes, array &$flat, int $level): void + { + foreach ($nodes as $node) { + if (is_array($node)) { + self::flatten($node, $flat, $level); + + continue; + } + + if (is_string($node) && ctype_digit($node)) { + $flat[(int) $node] = $level++; + } + } + } +} diff --git a/src/ImapThreadResult.php b/src/ImapThreadResult.php new file mode 100644 index 00000000..da9229aa --- /dev/null +++ b/src/ImapThreadResult.php @@ -0,0 +1,160 @@ + 0, childId => 1, ...]`), the + * shape the THREAD response's nested parenthesized lists flatten into. + * `$sequence` records whether the ids are message sequence numbers or + * UIDs, matching the mode the command was sent in. + * + * The legacy `Serializable` surface is dropped; a caller that needs to + * persist a thread result serializes the value object directly. + * + * @author Michael Slusarz + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapThreadResult implements Countable +{ + /** + * @param list> $threads One entry per thread, each an + * ordered id => level map. + */ + public function __construct( + private readonly array $threads, + private readonly bool $sequence = false, + ) {} + + /** + * Whether the ids are message sequence numbers (true) or UIDs (false). + */ + public function isSequence(): bool + { + return $this->sequence; + } + + /** + * Every message id across all threads, in thread order, as an + * {@see ImapIdSet}. + */ + public function messageList(): ImapIdSet + { + return new ImapIdSet($this->allIds(), $this->sequence); + } + + /** + * The thread containing `$index`, as an ordered map of id to a small + * object describing its place in the tree, or an empty array if the + * id is not part of any thread. + * + * Each value object carries: + * - `base` (?int) The thread's base id, or null for a lone message. + * - `level` (int) The message's nesting level. + * - `last` (bool) Whether it is the last id at its level. + * + * @return array + */ + public function getThread(int $index): array + { + foreach ($this->threads as $thread) { + if (isset($thread[$index])) { + return $this->describeThread($thread); + } + } + + return []; + } + + /** + * Every thread, each described the same way {@see getThread()} + * describes one. + * + * @return list> + */ + public function getThreads(): array + { + return array_map($this->describeThread(...), $this->threads); + } + + public function count(): int + { + return count($this->allIds()); + } + + /** + * @return list + */ + private function allIds(): array + { + $ids = []; + + foreach ($this->threads as $thread) { + foreach (array_keys($thread) as $id) { + $ids[] = $id; + } + } + + return $ids; + } + + /** + * Turn one id => level map into the id => stdClass description + * {@see getThread()}/{@see getThreads()} return, computing the `base` + * and per-level `last` flags. Ported verbatim from the legacy + * `Data_Thread::getThread()` traversal. + * + * @param array $thread + * + * @return array + */ + private function describeThread(array $thread): array + { + $base = count($thread) > 1 ? array_key_first($thread) : null; + + /** @var array $levels Last id seen at each level. */ + $levels = []; + $out = []; + $last = 0; + + foreach ($thread as $id => $level) { + $ob = new stdClass(); + $ob->base = $base; + $ob->level = $level; + $ob->last = false; + $out[$id] = $ob; + + if ($last < $level && isset($levels[$level])) { + $out[$levels[$level]]->last = true; + } + + $levels[$level] = $id; + $last = $level; + } + + foreach ($levels as $id) { + $out[$id]->last = true; + } + + return $out; + } +} diff --git a/src/ImapTokenizer.php b/src/ImapTokenizer.php new file mode 100644 index 00000000..a7c673cb --- /dev/null +++ b/src/ImapTokenizer.php @@ -0,0 +1,235 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapTokenizer +{ + /** + * Generous upper bound for a single physical line read. IMAP + * command/response lines are usually short; literals carry the + * actual bulk data and are read separately, by exact byte count. + */ + private const LINE_BUFFER = 65536; + + private string $buffer = ''; + + private int $pos = 0; + + public function __construct( + private readonly ClientInterface $client, + ) {} + + /** + * Read and parse one full logical response line. + * + * @return list Top-level tokens: strings, null (for `NIL`), + * and nested arrays for parenthesized lists. + */ + public function readLine(): array + { + $this->buffer = ''; + $this->pos = 0; + $this->fill(); + + return $this->parseTokens(0); + } + + /** + * @return list + */ + private function parseTokens(int $depth): array + { + $tokens = []; + + while (true) { + $this->skipSpaces(); + + if ($this->pos >= strlen($this->buffer)) { + // A line ends here unless we are inside an unclosed + // list, in which case the server has more of this + // response coming on the next physical line. + if ($depth === 0) { + return $tokens; + } + + $this->fill(); + continue; + } + + $char = $this->buffer[$this->pos]; + + if ($char === ')') { + ++$this->pos; + + return $tokens; + } + + if ($char === '(') { + ++$this->pos; + $tokens[] = $this->parseTokens($depth + 1); + continue; + } + + $tokens[] = $this->parseToken(); + } + } + + private function parseToken(): ?string + { + if ($this->buffer[$this->pos] === '"') { + return $this->parseQuotedString(); + } + + return $this->parseAtomOrLiteral(); + } + + private function parseQuotedString(): string + { + ++$this->pos; + $result = ''; + + while (true) { + if ($this->pos >= strlen($this->buffer)) { + // A quoted string should never span physical lines but + // stay lenient and just ask for more rather than fail. + $this->fill(); + continue; + } + + $char = $this->buffer[$this->pos]; + + if ($char === '\\') { + $next = $this->buffer[$this->pos + 1] ?? null; + + if ($next === null) { + $this->fill(); + continue; + } + + $result .= $next; + $this->pos += 2; + continue; + } + + if ($char === '"') { + ++$this->pos; + + return $result; + } + + $result .= $char; + ++$this->pos; + } + } + + private function parseAtomOrLiteral(): ?string + { + $text = ''; + + while (true) { + if ($this->pos >= strlen($this->buffer)) { + $literal = $this->resolvePendingLiteral($text); + + if ($literal !== null) { + return $literal; + } + + // End of the physical line, and not a literal + // announcement: The token is simply complete. Whether + // the overall response needs another physical line + // (because we're inside an unclosed list) is decided + // by the caller. + break; + } + + $char = $this->buffer[$this->pos]; + + if ($char === ' ' || $char === '(' || $char === ')') { + break; + } + + $text .= $char; + ++$this->pos; + } + + return $this->interpretAtom($text); + } + + private function interpretAtom(string $text): ?string + { + return strcasecmp($text, 'NIL') === 0 ? null : $text; + } + + /** + * If the token accumulated so far is a complete literal + * announcement (`{n}` or `~{n}`), read its payload and return it. + * Otherwise, return null. The caller should fetch more data and + * keep accumulating. + */ + private function resolvePendingLiteral(string $text): ?string + { + if (preg_match('/^(~?)\{(\d+)\+?\}$/', $text, $matches) !== 1) { + return null; + } + + $length = (int) $matches[2]; + + return $this->client->read($length); + } + + private function skipSpaces(): void + { + while (($this->buffer[$this->pos] ?? '') === ' ') { + ++$this->pos; + } + } + + private function fill(): void + { + $line = $this->client->gets(self::LINE_BUFFER); + + if ($line === '') { + throw new ImapProtocolException('Connection closed while reading an IMAP response.'); + } + + $this->buffer .= rtrim($line, "\r\n"); + } +} diff --git a/src/ImapUtf8MailboxNameCodec.php b/src/ImapUtf8MailboxNameCodec.php new file mode 100644 index 00000000..5da36ab1 --- /dev/null +++ b/src/ImapUtf8MailboxNameCodec.php @@ -0,0 +1,36 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapUtf8MailboxNameCodec implements ImapMailboxNameCodec +{ + public function encode(string $utf8Name): string + { + return $utf8Name; + } + + public function decode(string $wireName): string + { + return $wireName; + } +} diff --git a/src/ImapVanishedParser.php b/src/ImapVanishedParser.php new file mode 100644 index 00000000..1101335a --- /dev/null +++ b/src/ImapVanishedParser.php @@ -0,0 +1,73 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapVanishedParser +{ + private function __construct() {} + + /** + * @param list $untagged + */ + public static function parse(array $untagged): ImapIdSet + { + $uids = []; + + foreach ($untagged as $response) { + if (!$response->isUntagged() || $response->data === [] || !is_string($response->data[0])) { + continue; + } + + if (strtoupper($response->data[0]) !== 'VANISHED') { + continue; + } + + // The sequence set is the last string token; the optional + // (EARLIER) marker is a nested array before it. + $set = null; + + foreach (array_slice($response->data, 1) as $token) { + if (is_string($token)) { + $set = $token; + } + } + + if ($set !== null) { + foreach (ImapIdSet::fromSequenceString($set, false)->toArray() as $uid) { + $uids[] = $uid; + } + } + } + + return new ImapIdSet($uids, false); + } +} diff --git a/src/ImapWireAtom.php b/src/ImapWireAtom.php new file mode 100644 index 00000000..26fbd732 --- /dev/null +++ b/src/ImapWireAtom.php @@ -0,0 +1,92 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapWireAtom implements ImapWireEncodable +{ + private const INVALID_ATOM_CHARACTERS = ['(', ')', '{', ' ', '%', '*', '"', '\\', ']']; + + public function __construct( + private readonly string $data, + ) {} + + public function isLiteral(): bool + { + return false; + } + + public function isBinary(): bool + { + return false; + } + + public function length(): int + { + return strlen($this->data); + } + + public function escape(): string + { + // An empty atom is ambiguous on the wire so send it quoted. + return $this->data === '' ? '""' : $this->data; + } + + public function rawBytes(): string + { + return $this->data; + } + + /** + * Confirm this value contains nothing but strict, plain-atom + * characters (no atom-specials at all, not even a leading + * backslash). Not called automatically by {@see escape()}, since + * flags are also represented as `ImapWireAtom` and are not strict atoms. + * + * @throws WireEncodingException If an atom-special character is + * present. + */ + public function validate(): void + { + if ($this->data !== $this->withoutInvalidAtomCharacters()) { + throw new WireEncodingException( + "Illegal character in IMAP atom: '{$this->data}'.", + ); + } + } + + private function withoutInvalidAtomCharacters(): string + { + $printable = preg_replace('/[^\x20-\x7e]/', '', $this->data); + + return str_replace(self::INVALID_ATOM_CHARACTERS, '', $printable); + } +} diff --git a/src/ImapWireEncodable.php b/src/ImapWireEncodable.php new file mode 100644 index 00000000..a58f9b50 --- /dev/null +++ b/src/ImapWireEncodable.php @@ -0,0 +1,70 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface ImapWireEncodable +{ + /** + * Must this value be sent as a literal instead of inline? + */ + public function isLiteral(): bool; + + /** + * If `isLiteral()`, must it be announced as a literal8 (`~{n}`, + * RFC 3516) rather than an ordinary literal (`{n}`)? + */ + public function isBinary(): bool; + + /** + * Byte length of the raw value, for the `{n}` announcement. + */ + public function length(): int; + + /** + * The inline wire representation. + * + * @throws WireEncodingException If this value (or, for a list, one + * of its members) requires a literal. + */ + public function escape(): string; + + /** + * The raw bytes to send after a literal's `+` continuation. + * + * Meaningless (and not guaranteed to mean anything) unless + * `isLiteral()` is true. + */ + public function rawBytes(): string; +} diff --git a/src/ImapWireList.php b/src/ImapWireList.php new file mode 100644 index 00000000..7d4fc0b6 --- /dev/null +++ b/src/ImapWireList.php @@ -0,0 +1,111 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * + * @implements IteratorAggregate + */ +final class ImapWireList implements ImapWireEncodable, Countable, IteratorAggregate +{ + /** @var list */ + private array $items = []; + + /** + * @param iterable $items + */ + public function __construct(iterable $items = []) + { + foreach ($items as $item) { + $this->add($item); + } + } + + /** + * Add a member. A plain string is treated as an atom, matching how + * bare flag/keyword names are usually written. + */ + public function add(ImapWireEncodable|string $item): self + { + $this->items[] = is_string($item) ? new ImapWireAtom($item) : $item; + + return $this; + } + + public function isLiteral(): bool + { + // A parenthesized list is never itself a literal; a literal + // member inside it surfaces as an escape() failure instead. + return false; + } + + public function isBinary(): bool + { + return false; + } + + public function length(): int + { + return strlen($this->escape()); + } + + public function escape(): string + { + $parts = []; + + foreach ($this->items as $item) { + $parts[] = $item instanceof self + ? '(' . $item->escape() . ')' + : $item->escape(); + } + + return implode(' ', $parts); + } + + public function rawBytes(): string + { + throw new WireEncodingException('A parenthesized list cannot be sent as a literal.'); + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} diff --git a/src/ImapWireMailbox.php b/src/ImapWireMailbox.php new file mode 100644 index 00000000..4a457aa0 --- /dev/null +++ b/src/ImapWireMailbox.php @@ -0,0 +1,75 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapWireMailbox implements ImapWireEncodable +{ + private readonly ImapWireString $inner; + + public function __construct( + private readonly string $utf8Name, + ImapMailboxNameCodec $codec, + bool $allowWildcards = false, + ) { + $this->inner = new ImapWireString($codec->encode($utf8Name), isAstring: true, allowWildcards: $allowWildcards); + + if ($this->inner->isBinary()) { + throw new WireEncodingException( + "Mailbox name '{$this->utf8Name}' contains a null byte and cannot be sent to an IMAP server.", + ); + } + } + + public function isLiteral(): bool + { + return $this->inner->isLiteral(); + } + + public function isBinary(): bool + { + // Mailbox names are never sent as an RFC 3516 binary literal; + // the constructor already rejected the one input that would + // otherwise require it. + return false; + } + + public function length(): int + { + return $this->inner->length(); + } + + public function escape(): string + { + return $this->inner->escape(); + } + + public function rawBytes(): string + { + return $this->inner->rawBytes(); + } +} diff --git a/src/ImapWireNil.php b/src/ImapWireNil.php new file mode 100644 index 00000000..24cc255a --- /dev/null +++ b/src/ImapWireNil.php @@ -0,0 +1,50 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapWireNil implements ImapWireEncodable +{ + public function isLiteral(): bool + { + return false; + } + + public function isBinary(): bool + { + return false; + } + + public function length(): int + { + return 0; + } + + public function escape(): string + { + return 'NIL'; + } + + public function rawBytes(): string + { + return ''; + } +} diff --git a/src/ImapWireNstring.php b/src/ImapWireNstring.php new file mode 100644 index 00000000..f760bccf --- /dev/null +++ b/src/ImapWireNstring.php @@ -0,0 +1,57 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapWireNstring implements ImapWireEncodable +{ + private readonly ImapWireEncodable $inner; + + public function __construct(?string $data, bool $isAstring = false) + { + $this->inner = $data === null ? new ImapWireNil() : new ImapWireString($data, $isAstring); + } + + public function isLiteral(): bool + { + return $this->inner->isLiteral(); + } + + public function isBinary(): bool + { + return $this->inner->isBinary(); + } + + public function length(): int + { + return $this->inner->length(); + } + + public function escape(): string + { + return $this->inner->escape(); + } + + public function rawBytes(): string + { + return $this->inner->rawBytes(); + } +} diff --git a/src/ImapWireNumber.php b/src/ImapWireNumber.php new file mode 100644 index 00000000..0b275b84 --- /dev/null +++ b/src/ImapWireNumber.php @@ -0,0 +1,63 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class ImapWireNumber implements ImapWireEncodable +{ + public function __construct( + private readonly int $value, + ) { + if ($value < 0) { + throw new WireEncodingException( + "IMAP number cannot be negative: {$value}.", + ); + } + } + + public function isLiteral(): bool + { + return false; + } + + public function isBinary(): bool + { + return false; + } + + public function length(): int + { + return strlen((string) $this->value); + } + + public function escape(): string + { + return (string) $this->value; + } + + public function rawBytes(): string + { + return (string) $this->value; + } +} diff --git a/src/ImapWireString.php b/src/ImapWireString.php new file mode 100644 index 00000000..4c74c434 --- /dev/null +++ b/src/ImapWireString.php @@ -0,0 +1,84 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ImapWireString implements ImapWireEncodable +{ + private readonly ImapStringClassification $classification; + + /** + * @param string $data The raw (unescaped) value. + * @param bool $isAstring An astring (RFC 3501 §9) must be + * quoted even when empty, since a bare + * empty atom is ambiguous. A plain + * string does not have this rule. + * @param bool $allowWildcards If true, `%` and `*` are treated as + * ordinary characters rather than + * atom-specials. Set this for a + * LIST/LSUB mailbox pattern. + */ + public function __construct( + private readonly string $data, + private readonly bool $isAstring = false, + bool $allowWildcards = false, + ) { + $this->classification = ImapStringClassifier::classify($data, $allowWildcards); + } + + public function isLiteral(): bool + { + return $this->classification->literal; + } + + public function isBinary(): bool + { + return $this->classification->binary; + } + + public function length(): int + { + return strlen($this->data); + } + + public function escape(): string + { + if ($this->isLiteral()) { + throw new WireEncodingException( + 'This string contains a byte that requires literal output.', + ); + } + + if ($this->classification->quoted || ($this->isAstring && $this->data === '')) { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $this->data) . '"'; + } + + return $this->data; + } + + public function rawBytes(): string + { + return $this->data; + } +} diff --git a/src/MailboxListMode.php b/src/MailboxListMode.php index aa167cc9..e904d955 100644 --- a/src/MailboxListMode.php +++ b/src/MailboxListMode.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * Mailbox list mode for listMailboxes(). * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum MailboxListMode: int diff --git a/src/MailboxProtocol.php b/src/MailboxProtocol.php index 802cb07a..e5d7dccc 100644 --- a/src/MailboxProtocol.php +++ b/src/MailboxProtocol.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,13 +17,14 @@ use Generator; /** - * Honest common ground between IMAP and POP3. + * Common ground between IMAP and POP3. * - * ~8 methods that both protocols genuinely support. Everything else + * Methods that both protocols genuinely support. Everything else * belongs on ImapProtocol or extension interfaces. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface MailboxProtocol diff --git a/src/MessageContent.php b/src/MessageContent.php index d5c86a61..f9917e36 100644 --- a/src/MessageContent.php +++ b/src/MessageContent.php @@ -3,33 +3,34 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ namespace Horde\Imap\Client; -use Horde_Stream; +use Horde\Stream\StreamInterface; /** - * Layer 1: Message content access via Horde_Stream. + * Layer 1: Message content access via StreamInterface. * - * All content methods return Horde_Stream. Use (string) cast for string access. + * All content methods return StreamInterface. Use (string) cast for string access. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @author Ralf Lang + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface MessageContent { - public function getFullMsg(): Horde_Stream; + public function getFullMsg(): StreamInterface; - public function getHeaderText(string|int $id = 0): Horde_Stream; + public function getHeaderText(string|int $id = 0): StreamInterface; - public function getBodyText(string|int $id = 0): Horde_Stream; + public function getBodyText(string|int $id = 0): StreamInterface; } diff --git a/src/MessageIdSet.php b/src/MessageIdSet.php index e0fcd834..92a93873 100644 --- a/src/MessageIdSet.php +++ b/src/MessageIdSet.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -26,7 +26,8 @@ * @extends IteratorAggregate * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @author Ralf Lang + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface MessageIdSet extends Countable, IteratorAggregate diff --git a/src/MessageMetadata.php b/src/MessageMetadata.php index 0a3be042..8ca226b2 100644 --- a/src/MessageMetadata.php +++ b/src/MessageMetadata.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,10 +17,11 @@ use DateTimeImmutable; /** - * Layer 0: Scalar message metadata — always available, always lightweight. + * Scalar message metadata always available, always lightweight. * + * @author Ralf Lang * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface MessageMetadata diff --git a/src/NamespaceType.php b/src/NamespaceType.php new file mode 100644 index 00000000..f661b78d --- /dev/null +++ b/src/NamespaceType.php @@ -0,0 +1,34 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum NamespaceType: int +{ + case Personal = 1; + case Other = 2; + case Shared = 3; +} diff --git a/src/OpenMode.php b/src/OpenMode.php index 5f94909b..ddc3620e 100644 --- a/src/OpenMode.php +++ b/src/OpenMode.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * Mailbox open mode. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum OpenMode: int diff --git a/src/ParsedAccess.php b/src/ParsedAccess.php index 95ed8426..35e7c8a1 100644 --- a/src/ParsedAccess.php +++ b/src/ParsedAccess.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,13 +17,14 @@ use Generator; /** - * Layer 3: OO representation on top of the stream layer. + * OO representation on top of the stream layer. * * Return types use object until Envelope, Headers, and BodyStructure value * objects are implemented. Concrete classes narrow via covariant return types. * + * @author Ralf Lang * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface ParsedAccess diff --git a/src/PartAccess.php b/src/PartAccess.php index eea20291..17c79df4 100644 --- a/src/PartAccess.php +++ b/src/PartAccess.php @@ -3,39 +3,34 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ namespace Horde\Imap\Client; use Generator; -use Horde_Stream; +use Horde\Stream\StreamInterface; /** - * Layer 2: MIME part access (IMAP-only). + * MIME part access for IMAP. * - * POP3 does not implement this — MIME part addressing is not a POP3 feature. + * POP3 does not implement this. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @author Ralf Lang + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ interface PartAccess { - public function getBodyPart(string $id): Horde_Stream; + public function getBodyPart(string $id): StreamInterface; - public function getMimeHeader(string $id): Horde_Stream; + public function getMimeHeader(string $id): StreamInterface; - /** - * Yields parts lazily from BODYSTRUCTURE. - * - * @return Generator - */ - public function getParts(): Generator; } diff --git a/src/PasswordInterface.php b/src/PasswordInterface.php deleted file mode 100644 index a3a065b1..00000000 --- a/src/PasswordInterface.php +++ /dev/null @@ -1,32 +0,0 @@ - - * @copyright 2013-2026 Horde LLC - * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 - */ -interface PasswordInterface -{ - /** - * Return the password for the server connection. - */ - public function getPassword(): string; -} diff --git a/src/Pop3AuthChannel.php b/src/Pop3AuthChannel.php new file mode 100644 index 00000000..7d47055f --- /dev/null +++ b/src/Pop3AuthChannel.php @@ -0,0 +1,90 @@ + + * [initial-response]`, then `+ ` continuations, then a + * final `+OK`/`-ERR`. This is a real consumer of + * {@see \Horde\Imap\Client\Auth\SaslAuthenticator} as opposed to the IMAP-shaped + * fake used in tests), proving the channel abstraction isn't IMAP-specific. + * + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3AuthChannel implements AuthenticationChannel +{ + public function __construct( + private readonly Pop3Connection $connection, + ) {} + + public function sendAuthenticate(string $mechanismName, ?string $initialResponse): void + { + $command = 'AUTH ' . $mechanismName; + + if ($initialResponse !== null) { + $command .= ' ' . ($initialResponse === '' ? '=' : base64_encode($initialResponse)); + } + + $this->connection->sendLine($command); + } + + public function nextEvent(): ChannelEvent + { + $line = $this->connection->readStatusLine(); + + if ($line->isContinuation()) { + $decoded = base64_decode($line->text, true); + + if ($decoded === false) { + throw new Pop3ProtocolException( + 'Server sent a malformed base64 continuation.', + ); + } + + return ChannelEvent::challenge($decoded); + } + + if ($line->isError()) { + return ChannelEvent::failure(null, $line->text); + } + + return ChannelEvent::success(null, $line->text); + } + + /** + * A zero-length response is a bare empty base64 line, not the `=` + * shorthand. RFC 5034 §4 reserves `=` for the initial-response + * argument of the `AUTH` command only; ordinary continuation + * responses are always "a line containing a string encoded as + * Base64", which for zero-length data is simply an empty line. + */ + public function sendResponse(string $response): void + { + $this->connection->sendLine(base64_encode($response)); + } + + public function cancel(): void + { + $this->connection->sendLine('*'); + } +} diff --git a/src/Pop3Capability.php b/src/Pop3Capability.php new file mode 100644 index 00000000..654f259b --- /dev/null +++ b/src/Pop3Capability.php @@ -0,0 +1,34 @@ + + * @author Ralf Lang + * @copyright 2014-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3Capability implements Capability +{ + use CapabilityData; +} diff --git a/src/Pop3Client.php b/src/Pop3Client.php new file mode 100644 index 00000000..b480ce0e --- /dev/null +++ b/src/Pop3Client.php @@ -0,0 +1,679 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3Client implements MailboxProtocol +{ + private ?ClientInterface $client; + + private ?Pop3Connection $connection = null; + + private ?SaslAuthenticator $authenticator = null; + + private ?Pop3Capability $capability = null; + + private bool $loggedIn = false; + + /** The `<...@...>` timestamp from the greeting banner, if present (APOP). */ + private ?string $apopTimestamp = null; + + /** @var list Sequence numbers marked `\Deleted` this session (RFC 1939 §5). */ + private array $deletedSeqIds = []; + + public function __construct( + private readonly ConnectionConfig $config, + private ?Credentials $credentials = null, + private readonly ?EventDispatcherInterface $dispatcher = null, + ?ClientInterface $client = null, + ) { + $this->client = $client; + } + + public function getCapability(): Pop3Capability + { + $this->connect(); + + return $this->capability ??= $this->fetchCapability(); + } + + public function login(): void + { + if ($this->loggedIn) { + return; + } + + $this->connect(); + + if ($this->credentials === null) { + throw new AuthenticationException( + 'No credentials supplied: pass them to the constructor.' + ); + } + + $this->maybeUpgradeTls(); + + $saslMechanisms = $this->getCapability()->getParams('SASL'); + + if ($saslMechanisms !== []) { + try { + $this->loginSasl($saslMechanisms); + $this->loggedIn = true; + // RFC 2449 §6.3: The capability list MAY change after + // authentication (e.g. UIDL/TOP becoming visible). + $this->capability = null; + + return; + } catch (AuthenticationException $e) { + if (!$this->credentials instanceof PasswordCredentials) { + throw $e; + } + // Fall through to the APOP/USER-PASS native fallback below. + } + } + + if (!$this->credentials instanceof PasswordCredentials) { + throw new AuthenticationException( + 'The server offered no usable SASL mechanism, and no password' + . ' credential was supplied for the USER/PASS or APOP fallback.' + ); + } + + $this->loginNative($this->credentials); + $this->loggedIn = true; + $this->capability = null; + } + + public function logout(): void + { + if ($this->connection === null) { + return; + } + + try { + $this->connection->sendLine('QUIT'); + $this->connection->expectOk(); + } catch (Pop3ProtocolException) { + // The server is going away regardless. Nothing more to do. + } finally { + $this->client?->close(); + $this->connection = null; + $this->capability = null; + $this->loggedIn = false; + $this->deletedSeqIds = []; + } + } + + public function noop(): void + { + $this->connect(); + $this->connection->sendLine('NOOP'); + $this->connection->expectOk(); + } + + /** + * @return object MailboxStatus value object + */ + public function status(string $mailbox, int $flags): object + { + $this->requireInbox($mailbox); + + $this->connect(); + + $result = []; + + if (($flags & (StatusFlag::Messages->value | StatusFlag::Recent->value)) !== 0) { + $stat = $this->stat(); + + if (($flags & StatusFlag::Messages->value) !== 0) { + $result['messages'] = $stat['messages']; + } + + if (($flags & StatusFlag::Recent->value) !== 0) { + $result['recent'] = $stat['messages']; + } + } + + if (($flags & StatusFlag::UidNext->value) !== 0) { + $result['uidnext'] = $this->uidNext(); + } + + if (($flags & StatusFlag::UidValidity->value) !== 0) { + $result['uidvalidity'] = $this->getCapability()->query('UIDL') ? 1 : microtime(true); + } + + if (($flags & StatusFlag::Unseen->value) !== 0) { + $result['unseen'] = 0; + } + + return (object) $result; + } + + public function fetch(string $mailbox, MessageIdSet $ids, object $query): Generator + { + if (!$query instanceof Pop3FetchQuery) { + throw new Pop3ProtocolException('Pop3Client::fetch() requires a Pop3FetchQuery.'); + } + + $this->requireInbox($mailbox); + $this->connect(); + + $sequenceMode = $ids instanceof Pop3IdSet && $ids->isSequence(); + $uidl = (!$sequenceMode || $query->wantsUid()) ? $this->uidlBySeq() : []; + $seqIds = $this->resolveSeqIds($ids, $sequenceMode, $uidl); + + if ($seqIds === []) { + return; + } + + $sizes = $query->wantsSize() ? $this->listSizes() : []; + + $needsBody = $query->wantsFullMsg() || $query->bodyTextIds() !== []; + $needsHeader = $query->headerTextIds() !== [] || $query->wantsImapDate(); + + foreach ($seqIds as $seq) { + $uid = $uidl[$seq] ?? (string) $seq; + $data = new Pop3MessageData($uid, $seq); + + $raw = null; + $header = null; + $body = null; + + if ($needsBody) { + $raw = $this->retr($seq); + [$header, $body] = $this->splitMessage($raw); + } elseif ($needsHeader) { + $header = $this->top($seq); + + if ($header === null) { + $raw = $this->retr($seq); + [$header, $body] = $this->splitMessage($raw); + } + } + + if ($query->wantsFullMsg()) { + $range = $query->fullMsgRange(); + $data->setFullMsg($this->applyRange($raw ?? '', $range['start'], $range['length'])); + } + + foreach ($query->headerTextIds() as $id) { + $data->setHeaderText($id, $header ?? ''); + } + + foreach ($query->bodyTextIds() as $id) { + $data->setBodyText($id, $body ?? ''); + } + + if ($query->wantsSize()) { + $data->setSize($sizes[$seq] ?? strlen($raw ?? '')); + } + + if ($query->wantsImapDate()) { + $data->setImapDate($this->parseDateHeader($header ?? '')); + } + + yield ($sequenceMode ? $seq : $uid) => $data; + } + } + + /** + * @param array{ids?: MessageIdSet, add?: list, + * remove?: list, replace?: list} $options + * POP3 only understands the `\Deleted` flag: `add`/`replace` + * containing it marks the given `ids` for deletion (`DELE`); + * `remove` containing it, or a `replace` without it, undeletes. + * But RFC 1939's `RSET` always restores *every* deletion mark + * in the session so there is no way to selectively undelete a + * subset. Any other flag is silently ignored (POP3 has no + * concept of `\Seen`/`\Flagged`/etc.). + */ + public function store(string $mailbox, array $options): MessageIdSet + { + $this->requireInbox($mailbox); + $this->connect(); + + $flagValue = static fn (SystemFlag|string $flag): string => $flag instanceof SystemFlag + ? $flag->value + : $flag; + + $add = array_map($flagValue, $options['add'] ?? []); + $remove = array_map($flagValue, $options['remove'] ?? []); + $replace = array_key_exists('replace', $options) + ? array_map($flagValue, $options['replace']) + : null; + + $deleted = SystemFlag::Deleted->value; + + $wantsDelete = in_array($deleted, $add, true) + || ($replace !== null && in_array($deleted, $replace, true)); + $wantsUndelete = in_array($deleted, $remove, true) + || ($replace !== null && !in_array($deleted, $replace, true)); + + if ($wantsUndelete) { + $this->connection->expectOkFor('RSET'); + $this->deletedSeqIds = []; + + return new Pop3IdSet([], true); + } + + if (!$wantsDelete) { + return new Pop3IdSet([], true); + } + + $idsToStore = $options['ids'] ?? $this->getIdsOb(); + $storeSequenceMode = $idsToStore instanceof Pop3IdSet && $idsToStore->isSequence(); + $storeUidl = (!$storeSequenceMode && !$idsToStore->isEmpty()) ? $this->uidlBySeq() : []; + $seqIds = $this->resolveSeqIds($idsToStore, $storeSequenceMode, $storeUidl); + $deletedNow = []; + + foreach ($seqIds as $seq) { + try { + $this->connection->expectOkFor('DELE ' . $seq); + $deletedNow[] = $seq; + } catch (Pop3ProtocolException) { + // Server refused this one (e.g. already deleted); skip it. + } + } + + $this->deletedSeqIds = array_values(array_unique([...$this->deletedSeqIds, ...$deletedNow])); + + return new Pop3IdSet($deletedNow, true); + } + + /** + * RFC 1939 has no partial expunge: Deletions only take effect when the + * session ends with `QUIT` (`RSET` discards them instead). This calls + * {@see logout()} to commit them, so the connection is closed + * afterwards. + */ + public function expunge(string $mailbox, array $options): MessageIdSet + { + $this->requireInbox($mailbox); + $this->connect(); + + $expunged = new Pop3IdSet($this->deletedSeqIds, true); + + $this->logout(); + + return empty($options['list']) ? new Pop3IdSet([], true) : $expunged; + } + + public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSet + { + if ($ids === null) { + return new Pop3IdSet([], $sequence); + } + + if ($ids instanceof MessageIdSet) { + return new Pop3IdSet($ids->toArray(), $sequence); + } + + if (is_string($ids)) { + return Pop3IdSet::fromSequenceString($ids, $sequence); + } + + return new Pop3IdSet(is_array($ids) ? $ids : [$ids], $sequence); + } + + private function connect(): void + { + if ($this->connection !== null) { + return; + } + + if ($this->client === null) { + try { + $this->client = new SocketClient( + new SocketConnectionConfig( + host: $this->config->hostspec, + port: $this->config->port ?? $this->defaultPort(), + secure: SocketSecureMode::from($this->config->secure->value), + connectTimeout: $this->config->timeout, + readTimeout: $this->config->readTimeout, + context: $this->config->context ?? [], + ), + $this->dispatcher, + ); + } catch (SocketConnectionException $e) { + throw new ConnectionException('Error connecting to mail server.', 0, $e); + } + } + + $connection = new Pop3Connection($this->client); + $greeting = $connection->expectOk(); + + if (preg_match('/<.+@.+>/U', $greeting->text, $matches) === 1) { + $this->apopTimestamp = $matches[0]; + } + + $this->connection = $connection; + } + + private function defaultPort(): int + { + return $this->config->secure === SecureMode::Ssl ? 995 : 110; + } + + private function fetchCapability(): Pop3Capability + { + $capability = new Pop3Capability(); + + try { + $this->connection->sendLine('CAPA'); + $this->connection->expectOk(); + + foreach ($this->connection->readMultiline() as $line) { + $parts = explode(' ', $line); + $capability->add($parts[0], array_slice($parts, 1)); + } + } catch (Pop3ProtocolException) { + // No CAPA support: Assume the bare minimum (RFC 1939 §4). + $capability->add('USER'); + } + + return $capability; + } + + private function maybeUpgradeTls(): void + { + if ($this->config->secure !== SecureMode::Tls || $this->client->isSecure()) { + return; + } + + if (!$this->getCapability()->query('STLS')) { + throw new ConnectionException( + 'Could not open a secure connection: the server does not advertise STLS.' + ); + } + + $this->connection->sendLine('STLS'); + $this->connection->expectOk(); + + if (!$this->client->startTls()) { + throw new ConnectionException('Could not open secure connection to the POP3 server.'); + } + + // Capabilities may legitimately change after the TLS upgrade + // (RFC 2595). Discard the pre-STLS snapshot. + $this->capability = null; + } + + /** + * @param list $saslMechanisms + */ + private function loginSasl(array $saslMechanisms): void + { + $channel = new Pop3AuthChannel($this->connection); + + $this->authenticator ??= new SaslAuthenticator( + $this->config, + $this->credentials, + new SocketChannelBindingProvider($this->client), + $this->dispatcher, + ); + + $this->authenticator->authenticate( + $channel, + $saslMechanisms, + $this->client->isSecure(), + $this->credentials, + ); + } + + private function loginNative(PasswordCredentials $credentials): void + { + $password = $credentials->password()->reveal(); + + if ($password === '') { + throw new AuthenticationException('No password provided.'); + } + + if ($this->apopTimestamp !== null) { + try { + $digest = hash('md5', $this->apopTimestamp . $password); + $this->connection->sendLine('APOP ' . $credentials->authcid() . ' ' . $digest); + $this->connection->expectOk(); + + return; + } catch (Pop3ProtocolException) { + // Fall through to USER/PASS. + } + } + + $this->connection->sendLine('USER ' . $credentials->authcid()); + $this->connection->expectOk(); + $this->connection->sendLine('PASS ' . $password); + + try { + $this->connection->expectOk(); + } catch (Pop3ProtocolException $e) { + throw new AuthenticationException($e->getMessage(), 0, $e); + } + } + + /** + * @return array{messages: int, size: int} + */ + private function stat(): array + { + $status = $this->connection->expectOkFor('STAT'); + [$messages, $size] = explode(' ', $status->text, 2) + ['0', '0']; + + return ['messages' => (int) $messages, 'size' => (int) $size]; + } + + private function uidNext(): int|string + { + if (!$this->getCapability()->query('UIDL')) { + return $this->stat()['messages'] + 1; + } + + $ctx = hash_init('md5'); + + foreach ($this->uidlBySeq() as $seq => $uid) { + hash_update($ctx, '|' . $seq . '|' . $uid); + } + + return hash_final($ctx); + } + + private function requireInbox(string $mailbox): void + { + if (strcasecmp($mailbox, 'INBOX') !== 0) { + throw new Pop3ProtocolException('POP3 only supports the INBOX mailbox.'); + } + } + + /** + * Resolve a `MessageIdSet` into POP3 sequence numbers. + * + * An empty set means "every message" (matching legacy `_getSeqIds()`). + * A `Pop3IdSet` built in sequence mode is used as-is. Anything else is + * treated as a set of UIDLs and mapped back to sequence numbers via + * the caller-supplied `$uidl` map (silently dropping any UID the + * server no longer has. It was presumably expunged by a concurrent + * session). Callers must supply `$uidl` whenever `$sequenceMode` is + * false and `$ids` is non-empty. + * + * @param array $uidl + * @return list + */ + private function resolveSeqIds(MessageIdSet $ids, bool $sequenceMode, array $uidl = []): array + { + if ($ids->isEmpty()) { + return range(1, $this->stat()['messages']); + } + + if ($sequenceMode) { + return array_map(intval(...), $ids->toArray()); + } + + $seqByUid = array_flip($uidl); + + $result = []; + + foreach ($ids->toArray() as $uid) { + if (isset($seqByUid[$uid])) { + $result[] = $seqByUid[$uid]; + } + } + + return $result; + } + + /** + * @return array Sequence number => UIDL string. + */ + private function uidlBySeq(): array + { + $this->connection->expectOkFor('UIDL'); + + $map = []; + + foreach ($this->connection->readMultiline() as $line) { + $parts = explode(' ', $line, 2); + $map[(int) $parts[0]] = $parts[1] ?? ''; + } + + return $map; + } + + /** + * @return array Sequence number => octet size. + */ + private function listSizes(): array + { + $this->connection->expectOkFor('LIST'); + + $map = []; + + foreach ($this->connection->readMultiline() as $line) { + $parts = explode(' ', $line, 2); + $map[(int) $parts[0]] = (int) ($parts[1] ?? 0); + } + + return $map; + } + + /** + * Fetch the full raw message via `RETR`. + */ + private function retr(int $seq): string + { + $this->connection->expectOkFor('RETR ' . $seq); + + return implode("\r\n", iterator_to_array($this->connection->readMultiline())); + } + + /** + * Fetch just the header via `TOP 0`, or null if the server + * doesn't support `TOP` (RFC 1939 §7) or refuses this particular + * message. The caller falls back to a full `RETR` in that case. + */ + private function top(int $seq): ?string + { + if (!$this->getCapability()->query('TOP')) { + return null; + } + + try { + $this->connection->expectOkFor('TOP ' . $seq . ' 0'); + + return implode("\r\n", iterator_to_array($this->connection->readMultiline())); + } catch (Pop3ProtocolException) { + return null; + } + } + + /** + * Split a raw RFC 822 message into header/body at the first blank + * line. There is no MIME-part addressing here (see + * {@see Pop3FetchQuery}). This is always the whole message's header + * and the whole message's body. + * + * @return array{0: string, 1: string} + */ + private function splitMessage(string $raw): array + { + $pos = strpos($raw, "\r\n\r\n"); + + return $pos === false ? [$raw, ''] : [substr($raw, 0, $pos), substr($raw, $pos + 4)]; + } + + private function applyRange(string $data, ?int $start, ?int $length): string + { + if ($length !== null) { + return substr($data, $start ?? 0, $length); + } + + return $start !== null ? substr($data, $start) : $data; + } + + /** + * Parse the message's `Date:` header. Deliberately simple (no folded + * header support). Good enough for the common case. Returns null if + * absent or unparseable, and {@see Pop3MessageData::getImapDate()} + * falls back to the epoch. + */ + private function parseDateHeader(string $header): ?DateTimeImmutable + { + foreach (explode("\r\n", $header) as $line) { + if (stripos($line, 'Date:') === 0) { + try { + return new DateTimeImmutable(trim(substr($line, 5))); + } catch (\Exception) { + return null; + } + } + } + + return null; + } +} + diff --git a/src/Pop3Connection.php b/src/Pop3Connection.php new file mode 100644 index 00000000..dbd1a1d4 --- /dev/null +++ b/src/Pop3Connection.php @@ -0,0 +1,137 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3Connection +{ + /** + * Generous upper bound for a single line read. POP3 command/status + * lines are capped at 255 octets (RFC 1939 §3), but multiline data + * (RETR/TOP message content) has no such limit in practice. + */ + private const LINE_BUFFER = 65536; + + public function __construct( + private readonly ClientInterface $client, + ) {} + + /** + * Send a command and append CRLF. + * + * TODO: Should we check if CRLF is already present and avoid double-CRLF? + */ + public function sendLine(string $command): void + { + $this->client->write($command . "\r\n"); + } + + /** + * Read and parse the next status line, without raising an exception + * for `-ERR`. The caller decides what a failure means to it. An + * ordinary command throws via {@see expectOk()}; a SASL exchange + * routes it into a {@see Auth\ChannelEvent} failure instead. + * + * @throws Pop3ProtocolException On a line that is none of `+OK`, + * `-ERR`, or `+`. + */ + public function readStatusLine(): Pop3StatusLine + { + $raw = rtrim($this->client->gets(self::LINE_BUFFER), "\r\n"); + $parts = explode(' ', $raw, 2); + $indicator = $parts[0]; + $text = $parts[1] ?? ''; + + $kind = match ($indicator) { + '+OK' => Pop3ResponseKind::Ok, + '-ERR' => Pop3ResponseKind::Error, + '+' => Pop3ResponseKind::Continuation, + default => throw new Pop3ProtocolException( + 'Error when communicating with the mail server.', + ), + }; + + return new Pop3StatusLine($kind, $text); + } + + /** + * Read the next status line and require it to be `+OK`. + * + * @throws Pop3ProtocolException On `-ERR` (the response text becomes + * the exception message). + */ + public function expectOk(): Pop3StatusLine + { + $line = $this->readStatusLine(); + + if ($line->isError()) { + throw new Pop3ProtocolException( + $line->text === '' ? 'POP3 error reported by server.' : $line->text, + ); + } + + return $line; + } + + /** + * Send a command and require the response to be `+OK`. + * + * @throws Pop3ProtocolException On `-ERR`. + */ + public function expectOkFor(string $command): Pop3StatusLine + { + $this->sendLine($command); + + return $this->expectOk(); + } + + /** + * Read a `.`-terminated multiline block, yielding one un-byte-stuffed + * line at a time (the terminating `.` line itself is consumed but not + * yielded). + * + * @return Generator + */ + public function readMultiline(): Generator + { + while (true) { + $raw = rtrim($this->client->gets(self::LINE_BUFFER), "\r\n"); + + if ($raw === '.') { + return; + } + + yield str_starts_with($raw, '..') ? substr($raw, 1) : $raw; + } + } +} diff --git a/src/Pop3FetchQuery.php b/src/Pop3FetchQuery.php new file mode 100644 index 00000000..491d4d2d --- /dev/null +++ b/src/Pop3FetchQuery.php @@ -0,0 +1,27 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3FetchQuery +{ + use FetchQueryFields; +} diff --git a/src/Pop3IdSet.php b/src/Pop3IdSet.php new file mode 100644 index 00000000..2c8de745 --- /dev/null +++ b/src/Pop3IdSet.php @@ -0,0 +1,95 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3IdSet implements MessageIdSet +{ + /** @var list */ + private readonly array $ids; + + /** + * @param array $ids + */ + public function __construct(array $ids = [], private readonly bool $sequence = false) + { + // Preserve first-seen order while deduplicating, matching the + // legacy `array_keys(array_flip($this->_ids))` behavior. + $this->ids = array_keys(array_flip($ids)); + } + + /** + * Parse a POP3 message sequence string. Space-delimited. The only + * printable ASCII character RFC 1939 §7 disallows in a UID. + */ + public static function fromSequenceString(string $str, bool $sequence = false): self + { + $trimmed = trim($str); + + return new self($trimmed === '' ? [] : explode(' ', $trimmed), $sequence); + } + + public function isSequence(): bool + { + return $this->sequence; + } + + public function isEmpty(): bool + { + return $this->ids === []; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->ids; + } + + public function count(): int + { + return count($this->ids); + } + + /** + * @return Iterator + */ + public function getIterator(): Iterator + { + return new ArrayIterator($this->ids); + } + + public function __toString(): string + { + return implode(' ', $this->ids); + } +} diff --git a/src/Pop3MessageData.php b/src/Pop3MessageData.php new file mode 100644 index 00000000..84e36878 --- /dev/null +++ b/src/Pop3MessageData.php @@ -0,0 +1,133 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3MessageData implements MessageMetadata, MessageContent +{ + private ?StreamInterface $fullMsg = null; + + /** @var array */ + private array $headerText = []; + + /** @var array */ + private array $bodyText = []; + + private int $size = 0; + + private ?DateTimeImmutable $imapDate = null; + + public function __construct( + private readonly int|string $uid, + private readonly ?int $seq = null, + ) {} + + public function setFullMsg(string $data): void + { + $this->fullMsg = $this->toStream($data); + } + + public function setHeaderText(string|int $id, string $data): void + { + $this->headerText[$id] = $this->toStream($data); + } + + public function setBodyText(string|int $id, string $data): void + { + $this->bodyText[$id] = $this->toStream($data); + } + + public function setSize(int $size): void + { + $this->size = $size; + } + + public function setImapDate(?DateTimeImmutable $date): void + { + $this->imapDate = $date; + } + + public function getUid(): int|string + { + return $this->uid; + } + + public function getFlags(): array + { + return []; + } + + public function getSize(): int + { + return $this->size; + } + + public function getImapDate(): DateTimeImmutable + { + return $this->imapDate ?? new DateTimeImmutable('@0'); + } + + public function getSeq(): ?int + { + return $this->seq; + } + + public function getModSeq(): ?int + { + return null; + } + + public function getFullMsg(): StreamInterface + { + return $this->fullMsg ?? $this->toStream(''); + } + + public function getHeaderText(string|int $id = 0): StreamInterface + { + return $this->headerText[$id] ?? $this->toStream(''); + } + + public function getBodyText(string|int $id = 0): StreamInterface + { + return $this->bodyText[$id] ?? $this->toStream(''); + } + + private function toStream(string $data): StreamInterface + { + $stream = new Temp(); + $stream->add($data, true); + + return $stream; + } +} diff --git a/src/Pop3ResponseKind.php b/src/Pop3ResponseKind.php new file mode 100644 index 00000000..262c3a8e --- /dev/null +++ b/src/Pop3ResponseKind.php @@ -0,0 +1,34 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum Pop3ResponseKind +{ + /** `+OK `: a successful, final result. */ + case Ok; + + /** `-ERR `: a failed, final result. */ + case Error; + + /** `+ `: a SASL continuation challenge (RFC 5034). */ + case Continuation; +} diff --git a/src/Pop3StatusLine.php b/src/Pop3StatusLine.php new file mode 100644 index 00000000..56072ec8 --- /dev/null +++ b/src/Pop3StatusLine.php @@ -0,0 +1,52 @@ +`, `-ERR `, or `+ ` + * (RFC 1939 §3, RFC 5034 continuation). + * + * Non-throwing by design. {@see Pop3Connection::readStatusLine()} + * hands this back raw so callers with different needs (an ordinary command + * that always wants an exception on `-ERR`, versus a SASL exchange that + * needs to route `-ERR` into a {@see Auth\ChannelEvent} failure) can each + * decide what a `-ERR` means to them. + * + * @author Ralf Lang + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +final class Pop3StatusLine +{ + public function __construct( + public readonly Pop3ResponseKind $kind, + public readonly string $text, + ) {} + + public function isOk(): bool + { + return $this->kind === Pop3ResponseKind::Ok; + } + + public function isError(): bool + { + return $this->kind === Pop3ResponseKind::Error; + } + + public function isContinuation(): bool + { + return $this->kind === Pop3ResponseKind::Continuation; + } +} diff --git a/src/SearchResultType.php b/src/SearchResultType.php index b76c85ae..55fb8bd5 100644 --- a/src/SearchResultType.php +++ b/src/SearchResultType.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * Search result return type. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum SearchResultType: int diff --git a/src/SecureMode.php b/src/SecureMode.php index 313a979b..0021b02a 100644 --- a/src/SecureMode.php +++ b/src/SecureMode.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -17,10 +17,11 @@ /** * Transport security mode for IMAP/POP3 connections. * - * sslv2/sslv3 deliberately omitted — insecure protocols. + * sslv2/sslv3 deliberately omitted: insecure protocols. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum SecureMode: string diff --git a/src/SortCriteria.php b/src/SortCriteria.php index 82b80bfb..e310c02a 100644 --- a/src/SortCriteria.php +++ b/src/SortCriteria.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,8 @@ * Values match Horde_Imap_Client::SORT_* constants for migration. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum SortCriteria: int diff --git a/src/SpecialUse.php b/src/SpecialUse.php index 9318481e..8cb7e14c 100644 --- a/src/SpecialUse.php +++ b/src/SpecialUse.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * Special-use mailbox attributes (RFC 6154 section 2). * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum SpecialUse: string diff --git a/src/StatusFlag.php b/src/StatusFlag.php new file mode 100644 index 00000000..6efee965 --- /dev/null +++ b/src/StatusFlag.php @@ -0,0 +1,40 @@ + + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum StatusFlag: int +{ + case Messages = 1; + case Recent = 2; + case UidNext = 4; + case UidValidity = 8; + case Unseen = 16; + case All = 32; + /** RFC 7162 (CONDSTORE). Requested only when the server advertises it. */ + case HighestModSeq = 512; +} diff --git a/src/SyncCriteria.php b/src/SyncCriteria.php new file mode 100644 index 00000000..fb6e44a8 --- /dev/null +++ b/src/SyncCriteria.php @@ -0,0 +1,30 @@ + + * @copyright 2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum SyncCriteria +{ + case NewMessages; + case FlagChanges; + case Vanished; +} diff --git a/src/SystemFlag.php b/src/SystemFlag.php index 652f9267..f4f68ca1 100644 --- a/src/SystemFlag.php +++ b/src/SystemFlag.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * IMAP system flags and well-known keywords. * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum SystemFlag: string diff --git a/src/ThreadAlgorithm.php b/src/ThreadAlgorithm.php index 0cb43da6..6d637674 100644 --- a/src/ThreadAlgorithm.php +++ b/src/ThreadAlgorithm.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2008-2026 Horde LLC (http://www.horde.org/) + * Copyright 2008-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2008-2026 Horde LLC + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -18,7 +18,8 @@ * Threading algorithm for thread(). * * @author Michael Slusarz - * @copyright 2008-2026 Horde LLC + * @author Ralf Lang + * @copyright 2008-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ enum ThreadAlgorithm: int diff --git a/test/Integration/Base.php b/test/Integration/Base.php index d66d701b..eb519952 100644 --- a/test/Integration/Base.php +++ b/test/Integration/Base.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -22,7 +22,7 @@ * Base class for live server testing. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Integration/Imap.php b/test/Integration/Imap.php index c7b19b70..b7649089 100644 --- a/test/Integration/Imap.php +++ b/test/Integration/Imap.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -34,7 +34,7 @@ * Package testing on a real (live) IMAP server. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Imap extends Base @@ -324,7 +324,7 @@ public function testStatus() ); $this->assertIsArray($s); - // Only FIRSTUNSEEN, FLAGS, PERMFLAGS, HIGHESTMODSEQ, and UIDNOTSTICKY + // Only FIRSTUNSEEN, FLAGS, PERMFLAGS, HIGHESTMODSEQ and UIDNOTSTICKY // status information for test mailbox. $s = self::$live->status( self::$test_mbox, @@ -349,7 +349,7 @@ public function testAppendMessagesToMailbox() $fixturesDir = dirname(__DIR__) . '/fixtures'; // Appending test e-mail 1 (with Flagged), 2 via a stream (with Seen), - // 3 via a stream (with internaldate), and 4 via a string: + // 3 via a stream (with internaldate) and 4 via a string: $handle = fopen($fixturesDir . '/remote2.txt', 'r'); $handle2 = fopen($fixturesDir . '/remote3.txt', 'r'); $uid = self::$live->append(self::$test_mbox, [ diff --git a/test/Integration/ImapTest.php b/test/Integration/ImapTest.php index 332cf29a..b687b508 100644 --- a/test/Integration/ImapTest.php +++ b/test/Integration/ImapTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -24,7 +24,7 @@ * is available. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Integration/Pop3.php b/test/Integration/Pop3.php index f6cea91e..304a1709 100644 --- a/test/Integration/Pop3.php +++ b/test/Integration/Pop3.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -26,7 +26,7 @@ * Package testing on a (live) POP3 server. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Pop3 extends Base diff --git a/test/Integration/Pop3Test.php b/test/Integration/Pop3Test.php index a5edf684..060cae0a 100644 --- a/test/Integration/Pop3Test.php +++ b/test/Integration/Pop3Test.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -24,7 +24,7 @@ * configuration is available. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Integration/Src/CapabilityInterfaceTest.php b/test/Integration/Src/CapabilityTest.php similarity index 88% rename from test/Integration/Src/CapabilityInterfaceTest.php rename to test/Integration/Src/CapabilityTest.php index af81a385..bc06a196 100644 --- a/test/Integration/Src/CapabilityInterfaceTest.php +++ b/test/Integration/Src/CapabilityTest.php @@ -4,16 +4,16 @@ namespace Horde\Imap\Client\Test\Integration\Src; -use Horde\Imap\Client\CapabilityInterface; +use Horde\Imap\Client\Capability; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] -class CapabilityInterfaceTest extends TestCase +class CapabilityTest extends TestCase { - private function createImplementation(): CapabilityInterface + private function createImplementation(): Capability { - return new class implements CapabilityInterface { + return new class implements Capability { public function query(string $capability, ?string $parameter = null): bool { if ($capability === 'IMAP4rev1' && $parameter === null) { diff --git a/test/Integration/Src/ComposedInterfaceTest.php b/test/Integration/Src/ComposedInterfaceTest.php index e43f7678..c8d9a06a 100644 --- a/test/Integration/Src/ComposedInterfaceTest.php +++ b/test/Integration/Src/ComposedInterfaceTest.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use Generator; -use Horde\Imap\Client\CapabilityInterface; +use Horde\Imap\Client\Capability; use Horde\Imap\Client\ImapAclAware; use Horde\Imap\Client\ImapMetadataAware; use Horde\Imap\Client\ImapProtocol; @@ -19,7 +19,8 @@ use Horde\Imap\Client\ParsedAccess; use Horde\Imap\Client\PartAccess; use Horde\Imap\Client\Test\Stub\StubMessageIdSet; -use Horde_Stream; +use Horde\Stream\StreamInterface; +use Horde\Stream\Temp; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use stdClass; @@ -55,9 +56,9 @@ public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSe return new StubMessageIdSet(); } // ImapProtocol - public function getCapability(): CapabilityInterface + public function getCapability(): Capability { - return new class implements CapabilityInterface { + return new class implements Capability { public function query(string $capability, ?string $parameter = null): bool { return false; @@ -114,9 +115,9 @@ public function getQuotaRoot(string $mailbox): array return []; } // ImapAclAware - public function getACL(string $mailbox): object + public function getACL(string $mailbox): array { - return new stdClass(); + return []; } public function setACL(string $mailbox, string $identifier, array $options): void {} public function deleteACL(string $mailbox, string $identifier): void {} @@ -177,24 +178,24 @@ public function getModSeq(): ?int { return null; } - public function getFullMsg(): Horde_Stream + public function getFullMsg(): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getHeaderText(string|int $id = 0): Horde_Stream + public function getHeaderText(string|int $id = 0): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getBodyText(string|int $id = 0): Horde_Stream + public function getBodyText(string|int $id = 0): StreamInterface { - return new Horde_Stream(); + return new Temp(); } }; $this->assertInstanceOf(MessageMetadata::class, $stub); $this->assertInstanceOf(MessageContent::class, $stub); $this->assertSame(1, $stub->getUid()); - $this->assertInstanceOf(Horde_Stream::class, $stub->getFullMsg()); + $this->assertInstanceOf(StreamInterface::class, $stub->getFullMsg()); } public function testMessageCanImplementAllFourLayers(): void @@ -224,25 +225,25 @@ public function getModSeq(): ?int { return null; } - public function getFullMsg(): Horde_Stream + public function getFullMsg(): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getHeaderText(string|int $id = 0): Horde_Stream + public function getHeaderText(string|int $id = 0): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getBodyText(string|int $id = 0): Horde_Stream + public function getBodyText(string|int $id = 0): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getBodyPart(string $id): Horde_Stream + public function getBodyPart(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getMimeHeader(string $id): Horde_Stream + public function getMimeHeader(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } public function getParts(): Generator { @@ -272,7 +273,7 @@ public function getStructure(): object $this->assertInstanceOf(ParsedAccess::class, $stub); $this->assertSame(42, $stub->getUid()); - $this->assertInstanceOf(Horde_Stream::class, $stub->getBodyPart('1')); + $this->assertInstanceOf(StreamInterface::class, $stub->getBodyPart('1')); $this->assertIsObject($stub->getEnvelope()); } } diff --git a/test/Integration/Src/ImapAclAwareTest.php b/test/Integration/Src/ImapAclAwareTest.php index cc075d39..5bb08d05 100644 --- a/test/Integration/Src/ImapAclAwareTest.php +++ b/test/Integration/Src/ImapAclAwareTest.php @@ -15,9 +15,9 @@ class ImapAclAwareTest extends TestCase private function createImplementation(): ImapAclAware { return new class implements ImapAclAware { - public function getACL(string $mailbox): object + public function getACL(string $mailbox): array { - return new stdClass(); + return []; } public function setACL(string $mailbox, string $identifier, array $options): void {} public function deleteACL(string $mailbox, string $identifier): void {} @@ -32,9 +32,9 @@ public function getMyACLRights(string $mailbox): object }; } - public function testGetACLReturnsObject(): void + public function testGetACLReturnsArray(): void { - $this->assertIsObject($this->createImplementation()->getACL('INBOX')); + $this->assertIsArray($this->createImplementation()->getACL('INBOX')); } public function testSetACLReturnsVoid(): void diff --git a/test/Integration/Src/ImapProtocolTest.php b/test/Integration/Src/ImapProtocolTest.php index 5ab0c6d7..8d661dc8 100644 --- a/test/Integration/Src/ImapProtocolTest.php +++ b/test/Integration/Src/ImapProtocolTest.php @@ -5,7 +5,7 @@ namespace Horde\Imap\Client\Test\Integration\Src; use Generator; -use Horde\Imap\Client\CapabilityInterface; +use Horde\Imap\Client\Capability; use Horde\Imap\Client\ImapProtocol; use Horde\Imap\Client\MailboxListMode; use Horde\Imap\Client\MailboxProtocol; @@ -48,9 +48,9 @@ public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSe } // ImapProtocol methods - public function getCapability(): CapabilityInterface + public function getCapability(): Capability { - return new class implements CapabilityInterface { + return new class implements Capability { public function query(string $capability, ?string $parameter = null): bool { return false; @@ -109,9 +109,9 @@ public function testImplementsImapProtocol(): void $this->assertInstanceOf(ImapProtocol::class, $this->createImplementation()); } - public function testGetCapabilityReturnsCapabilityInterface(): void + public function testGetCapabilityReturnsCapability(): void { - $this->assertInstanceOf(CapabilityInterface::class, $this->createImplementation()->getCapability()); + $this->assertInstanceOf(Capability::class, $this->createImplementation()->getCapability()); } public function testOpenMailboxAcceptsOpenModeEnum(): void diff --git a/test/Integration/Src/MessageContentTest.php b/test/Integration/Src/MessageContentTest.php index 33778695..12f7e3fd 100644 --- a/test/Integration/Src/MessageContentTest.php +++ b/test/Integration/Src/MessageContentTest.php @@ -5,7 +5,8 @@ namespace Horde\Imap\Client\Test\Integration\Src; use Horde\Imap\Client\MessageContent; -use Horde_Stream; +use Horde\Stream\StreamInterface; +use Horde\Stream\Temp; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; @@ -15,48 +16,48 @@ class MessageContentTest extends TestCase private function createImplementation(): MessageContent { return new class implements MessageContent { - public function getFullMsg(): Horde_Stream + public function getFullMsg(): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getHeaderText(string|int $id = 0): Horde_Stream + public function getHeaderText(string|int $id = 0): StreamInterface { - $s = new Horde_Stream(); + $s = new Temp(); $s->add("Subject: Test $id"); return $s; } - public function getBodyText(string|int $id = 0): Horde_Stream + public function getBodyText(string|int $id = 0): StreamInterface { - return new Horde_Stream(); + return new Temp(); } }; } public function testGetFullMsgReturnsHordeStream(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getFullMsg()); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getFullMsg()); } public function testGetHeaderTextWithDefaultId(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText()); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getHeaderText()); } public function testGetHeaderTextWithIntId(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText(1)); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getHeaderText(1)); } public function testGetHeaderTextWithStringId(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText('1.2')); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getHeaderText('1.2')); } public function testGetBodyTextReturnsHordeStream(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getBodyText()); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getBodyText()); } public function testStreamContainsContent(): void diff --git a/test/Integration/Src/PartAccessTest.php b/test/Integration/Src/PartAccessTest.php index 34787b48..333c1a1d 100644 --- a/test/Integration/Src/PartAccessTest.php +++ b/test/Integration/Src/PartAccessTest.php @@ -6,7 +6,8 @@ use Generator; use Horde\Imap\Client\PartAccess; -use Horde_Stream; +use Horde\Stream\StreamInterface; +use Horde\Stream\Temp; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use stdClass; @@ -17,14 +18,14 @@ class PartAccessTest extends TestCase private function createImplementation(): PartAccess { return new class implements PartAccess { - public function getBodyPart(string $id): Horde_Stream + public function getBodyPart(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getMimeHeader(string $id): Horde_Stream + public function getMimeHeader(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } public function getParts(): Generator @@ -37,12 +38,12 @@ public function getParts(): Generator public function testGetBodyPartReturnsHordeStream(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getBodyPart('1.1')); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getBodyPart('1.1')); } public function testGetMimeHeaderReturnsHordeStream(): void { - $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getMimeHeader('1')); + $this->assertInstanceOf(StreamInterface::class, $this->createImplementation()->getMimeHeader('1')); } public function testGetPartsReturnsGenerator(): void @@ -55,14 +56,14 @@ public function testGetPartsReturnsGenerator(): void public function testGetPartsEmptyGenerator(): void { $stub = new class implements PartAccess { - public function getBodyPart(string $id): Horde_Stream + public function getBodyPart(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } - public function getMimeHeader(string $id): Horde_Stream + public function getMimeHeader(string $id): StreamInterface { - return new Horde_Stream(); + return new Temp(); } public function getParts(): Generator diff --git a/test/Integration/Src/PasswordInterfaceTest.php b/test/Integration/Src/PasswordInterfaceTest.php deleted file mode 100644 index 1abf562d..00000000 --- a/test/Integration/Src/PasswordInterfaceTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertInstanceOf(PasswordInterface::class, $pw); - $this->assertSame('s3cret', $pw->getPassword()); - } - - public function testEmptyPasswordReturn(): void - { - $pw = new class implements PasswordInterface { - public function getPassword(): string - { - return ''; - } - }; - - $this->assertSame('', $pw->getPassword()); - } -} diff --git a/test/Stub/ClientSort.php b/test/Stub/ClientSort.php index a9209aff..385514b3 100644 --- a/test/Stub/ClientSort.php +++ b/test/Stub/ClientSort.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -23,7 +23,7 @@ * consistency. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class ClientSort extends Horde_Imap_Client_Socket_ClientSort diff --git a/test/Stub/DigestMD5.php b/test/Stub/DigestMD5.php index fe8b9ce7..7c8f6782 100644 --- a/test/Stub/DigestMD5.php +++ b/test/Stub/DigestMD5.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -20,7 +20,7 @@ * Needed because we need to overwrite a protected method. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class DigestMD5 extends Horde_Imap_Client_Auth_DigestMD5 diff --git a/test/Stub/Scram.php b/test/Stub/Scram.php index bae63dda..85fc3b79 100644 --- a/test/Stub/Scram.php +++ b/test/Stub/Scram.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -20,7 +20,7 @@ * Needed because we need to overwrite a protected property. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Scram extends Horde_Imap_Client_Auth_Scram diff --git a/test/Stub/Socket.php b/test/Stub/Socket.php index 704dc0e0..5f2eff64 100644 --- a/test/Stub/Socket.php +++ b/test/Stub/Socket.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -26,7 +26,7 @@ * Needed because we need to access protected methods. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Socket extends Horde_Imap_Client_Socket diff --git a/test/Stub/Utf7imap.php b/test/Stub/Utf7imap.php index cc123be8..6de8d462 100644 --- a/test/Stub/Utf7imap.php +++ b/test/Stub/Utf7imap.php @@ -3,7 +3,7 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. @@ -20,7 +20,7 @@ * Needed to change protected static member variables. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class Utf7imap extends Horde_Imap_Client_Utf7imap diff --git a/test/Unit/AuthTest.php b/test/Unit/AuthTest.php index ef21f127..6b5529bc 100644 --- a/test/Unit/AuthTest.php +++ b/test/Unit/AuthTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the Imap Client ACL Auth features. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Base/BaseLogoutNullConnectionTest.php b/test/Unit/Base/BaseLogoutNullConnectionTest.php index a7b1ab62..d42e19ef 100644 --- a/test/Unit/Base/BaseLogoutNullConnectionTest.php +++ b/test/Unit/Base/BaseLogoutNullConnectionTest.php @@ -29,7 +29,7 @@ * still flagged true), PHP 8+ raised * "Attempt to read property 'connected' on null" from shutdown(). * PHPUnit's `failOnWarning="true"` in phpunit.xml.dist turns the - * warning into a test failure — the assertion is implicit. + * warning into a test failure. */ #[CoversNothing] class BaseLogoutNullConnectionTest extends TestCase @@ -49,8 +49,7 @@ public function testLogoutWithAuthenticatedFlagAndNullConnectionDoesNotWarn(): v // Force the exact state the CI stack trace observed: // _isAuthenticated = true, _connection = null. Reflection // is needed because both properties are protected and no - // setter exists (nor should one — this state is only - // reachable through internal lifecycle races). + // setter exists. $reflection = new ReflectionClass($ob); $authProperty = $reflection->getProperty('_isAuthenticated'); diff --git a/test/Unit/Base/DebugTest.php b/test/Unit/Base/DebugTest.php index 2b4218e5..91041cb3 100644 --- a/test/Unit/Base/DebugTest.php +++ b/test/Unit/Base/DebugTest.php @@ -122,7 +122,7 @@ public function testShutdownClosesStream(): void // After shutdown, writing should produce no output $debug->client('after shutdown'); - // Stream is closed, so we can't read it — just verify no error + // Stream is closed, so we can't read it. Just verify no error $this->assertFalse(is_resource($stream)); } } diff --git a/test/Unit/Base/MailboxTest.php b/test/Unit/Base/MailboxTest.php index 4f27a3ea..5a11e983 100644 --- a/test/Unit/Base/MailboxTest.php +++ b/test/Unit/Base/MailboxTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the Horde_Imap_Client_Base_Mailbox object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Cache/CacheTest.php b/test/Unit/Cache/CacheTest.php index b8554299..47267212 100644 --- a/test/Unit/Cache/CacheTest.php +++ b/test/Unit/Cache/CacheTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Horde_Cache cache driver. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Cache/DbTest.php b/test/Unit/Cache/DbTest.php index b03a4ec1..943ce75b 100644 --- a/test/Unit/Cache/DbTest.php +++ b/test/Unit/Cache/DbTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the Db cache driver. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Cache/HashtableTest.php b/test/Unit/Cache/HashtableTest.php index 256e584e..cd3665b2 100644 --- a/test/Unit/Cache/HashtableTest.php +++ b/test/Unit/Cache/HashtableTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Horde_HashTable cache driver. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Cache/MongoTest.php b/test/Unit/Cache/MongoTest.php index 8c53a18f..42144595 100644 --- a/test/Unit/Cache/MongoTest.php +++ b/test/Unit/Cache/MongoTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Mongo cache driver. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Cache/TestBase.php b/test/Unit/Cache/TestBase.php index c67bea79..947f395f 100644 --- a/test/Unit/Cache/TestBase.php +++ b/test/Unit/Cache/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Horde_Cache cache driver. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Data/AclTest.php b/test/Unit/Data/AclTest.php index 1b748f34..04592016 100644 --- a/test/Unit/Data/AclTest.php +++ b/test/Unit/Data/AclTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Imap Client ACL data object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Capability/ImapTest.php b/test/Unit/Data/Capability/ImapTest.php index 7d941799..e7c037f2 100644 --- a/test/Unit/Data/Capability/ImapTest.php +++ b/test/Unit/Data/Capability/ImapTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the IMAP-specific capability object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/CapabilityTest.php b/test/Unit/Data/CapabilityTest.php index 56c67acf..5365ff49 100644 --- a/test/Unit/Data/CapabilityTest.php +++ b/test/Unit/Data/CapabilityTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Capability object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Fetch/FetchPop3Test.php b/test/Unit/Data/Fetch/FetchPop3Test.php index dc516322..54680ba7 100644 --- a/test/Unit/Data/Fetch/FetchPop3Test.php +++ b/test/Unit/Data/Fetch/FetchPop3Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the Horde_Imap_Client_Data_Fetch_Pop3 object. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Fetch/FetchTest.php b/test/Unit/Data/Fetch/FetchTest.php index 456f9166..b19ee718 100644 --- a/test/Unit/Data/Fetch/FetchTest.php +++ b/test/Unit/Data/Fetch/FetchTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the Horde_Imap_Client_Data_Fetch object. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Fetch/TestBase.php b/test/Unit/Data/Fetch/TestBase.php index 76bb217c..06c49563 100644 --- a/test/Unit/Data/Fetch/TestBase.php +++ b/test/Unit/Data/Fetch/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -29,7 +29,7 @@ * Base class for testing the Horde_Imap_Client_Data_Fetch object. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Data/Format/Astring/NonasciiTest.php b/test/Unit/Data/Format/Astring/NonasciiTest.php index 1b05550d..330da7e9 100644 --- a/test/Unit/Data/Format/Astring/NonasciiTest.php +++ b/test/Unit/Data/Format/Astring/NonasciiTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the Astring/Nonascii data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/AstringTest.php b/test/Unit/Data/Format/AstringTest.php index beff80f4..4291c79b 100644 --- a/test/Unit/Data/Format/AstringTest.php +++ b/test/Unit/Data/Format/AstringTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the Astring data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/AtomTest.php b/test/Unit/Data/Format/AtomTest.php index 361652f3..2d5d842a 100644 --- a/test/Unit/Data/Format/AtomTest.php +++ b/test/Unit/Data/Format/AtomTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Atom data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/DateTest.php b/test/Unit/Data/Format/DateTest.php index a47973b6..fa63ca44 100644 --- a/test/Unit/Data/Format/DateTest.php +++ b/test/Unit/Data/Format/DateTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Date data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/DateTimeTest.php b/test/Unit/Data/Format/DateTimeTest.php index 9cdb5442..a1d553f0 100644 --- a/test/Unit/Data/Format/DateTimeTest.php +++ b/test/Unit/Data/Format/DateTimeTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the DateTime data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/ListTest.php b/test/Unit/Data/Format/ListTest.php index c8379340..923efe66 100644 --- a/test/Unit/Data/Format/ListTest.php +++ b/test/Unit/Data/Format/ListTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the List data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Mailbox/ListMailboxTest.php b/test/Unit/Data/Format/Mailbox/ListMailboxTest.php index fd5f137d..d5e7fb4c 100644 --- a/test/Unit/Data/Format/Mailbox/ListMailboxTest.php +++ b/test/Unit/Data/Format/Mailbox/ListMailboxTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the ListMailbox data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Mailbox/ListMailboxUtf8Test.php b/test/Unit/Data/Format/Mailbox/ListMailboxUtf8Test.php index fde43639..cb708533 100644 --- a/test/Unit/Data/Format/Mailbox/ListMailboxUtf8Test.php +++ b/test/Unit/Data/Format/Mailbox/ListMailboxUtf8Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the ListMailbox data format object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Mailbox/MailboxTest.php b/test/Unit/Data/Format/Mailbox/MailboxTest.php index c6c64f89..59fe502d 100644 --- a/test/Unit/Data/Format/Mailbox/MailboxTest.php +++ b/test/Unit/Data/Format/Mailbox/MailboxTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Mailbox data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Mailbox/MailboxUtf8Test.php b/test/Unit/Data/Format/Mailbox/MailboxUtf8Test.php index 89d561f4..17e5b7d1 100644 --- a/test/Unit/Data/Format/Mailbox/MailboxUtf8Test.php +++ b/test/Unit/Data/Format/Mailbox/MailboxUtf8Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the UTF-8 Mailbox data format object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Mailbox/TestBase.php b/test/Unit/Data/Format/Mailbox/TestBase.php index 1f112f61..e4f22551 100644 --- a/test/Unit/Data/Format/Mailbox/TestBase.php +++ b/test/Unit/Data/Format/Mailbox/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the Mailbox data format object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Data/Format/NilTest.php b/test/Unit/Data/Format/NilTest.php index a31e918a..2b8dd825 100644 --- a/test/Unit/Data/Format/NilTest.php +++ b/test/Unit/Data/Format/NilTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Nil data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/Nstring/NonasciiTest.php b/test/Unit/Data/Format/Nstring/NonasciiTest.php index a2b346a5..11720fe9 100644 --- a/test/Unit/Data/Format/Nstring/NonasciiTest.php +++ b/test/Unit/Data/Format/Nstring/NonasciiTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the Nstring/Nonascii data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/NstringTest.php b/test/Unit/Data/Format/NstringTest.php index f8661689..f054eab7 100644 --- a/test/Unit/Data/Format/NstringTest.php +++ b/test/Unit/Data/Format/NstringTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the Nstring data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/NumberTest.php b/test/Unit/Data/Format/NumberTest.php index 889b70e9..b556afb7 100644 --- a/test/Unit/Data/Format/NumberTest.php +++ b/test/Unit/Data/Format/NumberTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the Number data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/String/NonasciiTest.php b/test/Unit/Data/Format/String/NonasciiTest.php index dcc246f2..5ed5bc3f 100644 --- a/test/Unit/Data/Format/String/NonasciiTest.php +++ b/test/Unit/Data/Format/String/NonasciiTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the String/Nonascii data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/String/TestBase.php b/test/Unit/Data/Format/String/TestBase.php index 1c438b2a..2f356f4f 100644 --- a/test/Unit/Data/Format/String/TestBase.php +++ b/test/Unit/Data/Format/String/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Base class for tests of the Horde_Imap_Client_Data_Format_String object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends ExtTestBase diff --git a/test/Unit/Data/Format/StringTest.php b/test/Unit/Data/Format/StringTest.php index df6e1d36..92528673 100644 --- a/test/Unit/Data/Format/StringTest.php +++ b/test/Unit/Data/Format/StringTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for the String data format object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/Format/TestBase.php b/test/Unit/Data/Format/TestBase.php index 5b7ea984..7c582832 100644 --- a/test/Unit/Data/Format/TestBase.php +++ b/test/Unit/Data/Format/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Base test provider for data format objects. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Data/SearchCharsetTest.php b/test/Unit/Data/SearchCharsetTest.php index e0dcd249..d92277a7 100644 --- a/test/Unit/Data/SearchCharsetTest.php +++ b/test/Unit/Data/SearchCharsetTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the SearchCharset object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/SearchCharsetUtf8Test.php b/test/Unit/Data/SearchCharsetUtf8Test.php index f75f9b99..858095a7 100644 --- a/test/Unit/Data/SearchCharsetUtf8Test.php +++ b/test/Unit/Data/SearchCharsetUtf8Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the SearchCharset_Utf8 object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/SubjectParseTest.php b/test/Unit/Data/SubjectParseTest.php index b761c393..dbdc87a7 100644 --- a/test/Unit/Data/SubjectParseTest.php +++ b/test/Unit/Data/SubjectParseTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for Subject parsing. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Data/ThreadTest.php b/test/Unit/Data/ThreadTest.php index ac768a75..c773d0b5 100644 --- a/test/Unit/Data/ThreadTest.php +++ b/test/Unit/Data/ThreadTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the thread data object. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/DateTimeTest.php b/test/Unit/DateTimeTest.php index 35a27111..d5d97ce3 100644 --- a/test/Unit/DateTimeTest.php +++ b/test/Unit/DateTimeTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Imap Client DateTime object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Fetch/Results/FetchPop3Test.php b/test/Unit/Fetch/Results/FetchPop3Test.php index 11dc2b4e..ef43e06f 100644 --- a/test/Unit/Fetch/Results/FetchPop3Test.php +++ b/test/Unit/Fetch/Results/FetchPop3Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Horde_Imap_Client_Data_Fetch_Pop3 object for data storage. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Fetch/Results/FetchTest.php b/test/Unit/Fetch/Results/FetchTest.php index 50466ac1..f8a35fd3 100644 --- a/test/Unit/Fetch/Results/FetchTest.php +++ b/test/Unit/Fetch/Results/FetchTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Horde_Imap_Client_Data_Fetch object for data storage. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Fetch/Results/TestBase.php b/test/Unit/Fetch/Results/TestBase.php index 1d4f4d59..9d3749c6 100644 --- a/test/Unit/Fetch/Results/TestBase.php +++ b/test/Unit/Fetch/Results/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2015-2026 Horde LLC (http://www.horde.org/) + * Copyright 2015-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Horde_Imap_Client_Fetch_Results object. * * @author Michael Slusarz - * @copyright 2015-2026 Horde LLC + * @copyright 2015-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Ids/Pop3Test.php b/test/Unit/Ids/Pop3Test.php index a5e2839e..8187d837 100644 --- a/test/Unit/Ids/Pop3Test.php +++ b/test/Unit/Ids/Pop3Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * POP3 specific tests for the Ids object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/IdsTest.php b/test/Unit/IdsTest.php index 5b64aa57..19e6cd28 100644 --- a/test/Unit/IdsTest.php +++ b/test/Unit/IdsTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the Ids object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Interaction/CommandTest.php b/test/Unit/Interaction/CommandTest.php index 459175bc..2725d78f 100644 --- a/test/Unit/Interaction/CommandTest.php +++ b/test/Unit/Interaction/CommandTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -26,7 +26,7 @@ * Tests for the Interaction Command object * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/MailboxTest.php b/test/Unit/MailboxTest.php index 8175785c..d72ba1c3 100644 --- a/test/Unit/MailboxTest.php +++ b/test/Unit/MailboxTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -25,7 +25,7 @@ * Tests for the mailbox object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/MapTest.php b/test/Unit/MapTest.php index 86cd6716..0bfc9cc3 100644 --- a/test/Unit/MapTest.php +++ b/test/Unit/MapTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -25,7 +25,7 @@ * Tests for the UID -> Sequence Number mapping object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Namespace/DataTest.php b/test/Unit/Namespace/DataTest.php index ecd8716f..d41afc7c 100644 --- a/test/Unit/Namespace/DataTest.php +++ b/test/Unit/Namespace/DataTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for the Namespace data object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Namespace/ListTest.php b/test/Unit/Namespace/ListTest.php index b22ad824..b013ef9d 100644 --- a/test/Unit/Namespace/ListTest.php +++ b/test/Unit/Namespace/ListTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -24,7 +24,7 @@ * Tests for the Namespace list object. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/SearchTest.php b/test/Unit/SearchTest.php index c3be04ef..069fb647 100644 --- a/test/Unit/SearchTest.php +++ b/test/Unit/SearchTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -26,7 +26,7 @@ * Tests for the Search Query object. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Socket/ClientSortTest.php b/test/Unit/Socket/ClientSortTest.php index da635482..913fa8e6 100644 --- a/test/Unit/Socket/ClientSortTest.php +++ b/test/Unit/Socket/ClientSortTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -30,7 +30,7 @@ * Tests for the IMAP Socket driver. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/SocketTest.php b/test/Unit/SocketTest.php index b43c71a9..ff4cc3ae 100644 --- a/test/Unit/SocketTest.php +++ b/test/Unit/SocketTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -25,7 +25,7 @@ * Tests for the IMAP Socket driver. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/SortTest.php b/test/Unit/SortTest.php index 40714f97..9f98fd6b 100644 --- a/test/Unit/SortTest.php +++ b/test/Unit/SortTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2012-2026 Horde LLC (http://www.horde.org/) + * Copyright 2012-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for IMAP mailbox sorting. * * @author Michael Slusarz - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Src/ArrayCache.php b/test/Unit/Src/ArrayCache.php new file mode 100644 index 00000000..e9c1a88e --- /dev/null +++ b/test/Unit/Src/ArrayCache.php @@ -0,0 +1,86 @@ + */ + public array $data = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $this->data[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->data[$key]); + + return true; + } + + public function clear(): bool + { + $this->data = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $out = []; + foreach ($keys as $key) { + $out[$key] = $this->data[$key] ?? $default; + } + + return $out; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->data[$key] = $value; + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + unset($this->data[$key]); + } + + return true; + } + + public function has(string $key): bool + { + return isset($this->data[$key]); + } +} diff --git a/test/Unit/Src/Auth/FakeServerChannel.php b/test/Unit/Src/Auth/FakeServerChannel.php new file mode 100644 index 00000000..86a21158 --- /dev/null +++ b/test/Unit/Src/Auth/FakeServerChannel.php @@ -0,0 +1,127 @@ + */ + public array $sentResponses = []; + + public bool $cancelled = false; + + /** Set once the additional-data continuation has been sent to the client. */ + private bool $additionalDataSent = false; + + private ChannelEvent $pending; + + public function __construct( + private readonly ServerMechanism $server, + ) {} + + public function sendAuthenticate(string $mechanismName, ?string $initialResponse): void + { + $this->sentMechanismName = $mechanismName; + $this->sentInitialResponse = $initialResponse; + + if ($initialResponse !== null) { + $this->pending = $this->advance($initialResponse); + + return; + } + + // Server-first mechanism (e.g. CRAM-MD5): the server issues its + // initial challenge before the client has sent anything. + $challenge = $this->server->initialChallenge(); + $this->pending = ChannelEvent::challenge($challenge->hasData() ? $challenge->octets() : ''); + } + + public function nextEvent(): ChannelEvent + { + return $this->pending; + } + + public function sendResponse(string $response): void + { + $this->sentResponses[] = $response; + + if ($this->additionalDataSent) { + // The client just acknowledged the additional-data + // continuation with an empty response; the exchange is done. + $this->pending = ChannelEvent::success(); + + return; + } + + $this->pending = $this->advance($response); + } + + public function cancel(): void + { + $this->cancelled = true; + } + + /** + * Feed one client message into the server mechanism and compute the + * next event: another challenge, the additional-data continuation, the + * final success outcome, or a tagged failure. + * + * A real server never throws a PHP exception at the wire. A rejected + * exchange (bad credentials, malformed message) becomes a tagged `NO`. + * This mirrors that by catching the server mechanism's own exceptions + * and turning them into a failure event, exactly like a real server + * would. + */ + private function advance(string $clientMessage): ChannelEvent + { + try { + $challenge = $this->server->step(Response::bytes($clientMessage)); + } catch (AuthenticationFailedException | MechanismException $e) { + return ChannelEvent::failure(text: $e->getMessage()); + } + + if ($challenge->hasData()) { + return ChannelEvent::challenge($challenge->octets()); + } + + if (!$this->server->isComplete()) { + return ChannelEvent::challenge(''); + } + + $additionalData = $this->server->additionalData(); + if ($additionalData->hasData()) { + $this->additionalDataSent = true; + + return ChannelEvent::challenge($additionalData->octets()); + } + + return ChannelEvent::success(); + } +} diff --git a/test/Unit/Src/Auth/InMemoryScramCredentialLookup.php b/test/Unit/Src/Auth/InMemoryScramCredentialLookup.php new file mode 100644 index 00000000..439ca8d7 --- /dev/null +++ b/test/Unit/Src/Auth/InMemoryScramCredentialLookup.php @@ -0,0 +1,42 @@ + $users + */ + public function __construct( + private readonly array $users, + ) {} + + public function lookup(string $authcid, string $hashAlgo): ScramCredential + { + if (!isset($this->users[$authcid])) { + throw new AuthenticationFailedException('Unknown user.'); + } + + $user = $this->users[$authcid]; + + return ScramCredential::fromPassword( + $user['password'], + $user['salt'], + $user['iterations'], + $hashAlgo, + ); + } +} diff --git a/test/Unit/Src/Auth/SaslAuthenticatorTest.php b/test/Unit/Src/Auth/SaslAuthenticatorTest.php new file mode 100644 index 00000000..f8da4572 --- /dev/null +++ b/test/Unit/Src/Auth/SaslAuthenticatorTest.php @@ -0,0 +1,538 @@ +dispatcherRecording($events); + + $auth = new SaslAuthenticator($this->config(), $this->credentials(), null, $dispatcher); + $auth->authenticate($channel, [MechanismName::Plain->value], tlsActive: false); + + $this->assertSame(MechanismName::Plain->value, $channel->sentMechanismName); + $this->assertFalse($channel->cancelled); + $this->assertCount(1, $events); + $this->assertInstanceOf(AuthenticationSucceeded::class, $events[0]); + } + + public function testPlainAuthenticationRejectedByServer(): void + { + $lookup = new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + throw new AuthenticationFailedException('Unknown user.'); + } + }; + $server = new PlainServerMechanism($lookup); + $channel = new FakeServerChannel($server); + + $events = []; + $dispatcher = $this->dispatcherRecording($events); + + $auth = new SaslAuthenticator($this->config(), $this->credentials(), null, $dispatcher); + + $this->expectException(AuthenticationException::class); + + try { + $auth->authenticate($channel, [MechanismName::Plain->value], tlsActive: false); + } finally { + // A tagged NO is the server's own final answer. Nothing to do + // for a client-side cancel(). + $this->assertFalse($channel->cancelled); + $this->assertCount(1, $events); + $this->assertInstanceOf(AuthenticationFailed::class, $events[0]); + } + } + + public function testScramSha256RoundTripSucceedsAndVerifiesServerSignature(): void + { + $lookup = new InMemoryScramCredentialLookup([ + 'alice' => ['password' => 'password123', 'salt' => random_bytes(16), 'iterations' => 4096], + ]); + $server = new ScramServerMechanism(MechanismName::ScramSha256, $lookup); + $channel = new FakeServerChannel($server); + + $events = []; + $dispatcher = $this->dispatcherRecording($events); + + $auth = new SaslAuthenticator($this->config(), $this->credentials(), null, $dispatcher); + $auth->authenticate($channel, [MechanismName::ScramSha256->value], tlsActive: true); + + $this->assertSame(MechanismName::ScramSha256->value, $channel->sentMechanismName); + // client-first (inlined), client-final, and the empty ack of the + // server's additional-data continuation. + $this->assertCount(2, $channel->sentResponses); + $this->assertSame('', $channel->sentResponses[1]); + $this->assertFalse($channel->cancelled); + $this->assertInstanceOf(AuthenticationSucceeded::class, $events[0]); + } + + public function testScramWrongPasswordIsRejectedByServerAsTaggedFailure(): void + { + $lookup = new InMemoryScramCredentialLookup([ + 'alice' => ['password' => 'correct-password', 'salt' => random_bytes(16), 'iterations' => 4096], + ]); + $server = new ScramServerMechanism(MechanismName::ScramSha256, $lookup); + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(), $this->credentials('alice', 'wrong-password')); + + $this->expectException(AuthenticationException::class); + + try { + $auth->authenticate($channel, [MechanismName::ScramSha256->value], tlsActive: true); + } finally { + // The server rejects the bad proof as a tagged NO. A normal + // credentials failure, not a client-side abort. + $this->assertFalse($channel->cancelled); + } + } + + public function testForgedServerSignatureTriggersClientSideCancel(): void + { + // A server that completes successfully but never sends a genuine + // additional-data verifier. Simulating a MITM that cannot produce + // a valid SCRAM server signature. The client mechanism's own + // consumeAdditionalData() must reject it and the authenticator + // must cancel() client-side rather than trust a bare OK. + $server = new class implements \Horde\Sasl\ServerMechanism { + private bool $firstStepDone = false; + + private bool $complete = false; + + public function name(): MechanismName + { + return MechanismName::ScramSha256; + } + + public function role(): \Horde\Sasl\Role + { + return \Horde\Sasl\Role::Server; + } + + public function isComplete(): bool + { + return $this->complete; + } + + public function usesChannelBinding(): bool + { + return false; + } + + public function initialChallenge(): \Horde\Sasl\Data\Challenge + { + return \Horde\Sasl\Data\Challenge::none(); + } + + public function step(\Horde\Sasl\Data\Response $response): \Horde\Sasl\Data\Challenge + { + if (!$this->firstStepDone) { + $this->firstStepDone = true; + + return \Horde\Sasl\Data\Challenge::bytes('r=fakenonce,s=' . base64_encode('salt') . ',i=4096'); + } + + $this->complete = true; + + return \Horde\Sasl\Data\Challenge::none(); + } + + public function additionalData(): \Horde\Sasl\Data\AdditionalData + { + // A forged, garbage server signature. The client mechanism + // must reject this itself. + return \Horde\Sasl\Data\AdditionalData::bytes('v=' . base64_encode('not-the-real-signature')); + } + + public function authorizationId(): string + { + return 'alice'; + } + }; + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(), $this->credentials()); + + $this->expectException(AuthenticationException::class); + + try { + $auth->authenticate($channel, [MechanismName::ScramSha256->value], tlsActive: true); + } finally { + $this->assertTrue($channel->cancelled); + } + } + + public function testCramMd5ServerFirstMechanism(): void + { + $lookup = new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('password123'); + } + }; + $server = new CramMd5ServerMechanism($lookup, 'fixed-test-challenge'); + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(), $this->credentials()); + $auth->authenticate($channel, [MechanismName::CramMd5->value], tlsActive: false); + + // Server-first: no initial response is inlined. + $this->assertNull($channel->sentInitialResponse); + $this->assertFalse($channel->cancelled); + } + + public function testMissingCredentialsThrows(): void + { + $auth = new SaslAuthenticator($this->config()); + $server = new PlainServerMechanism(new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('x'); + } + }); + $channel = new FakeServerChannel($server); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No credentials supplied'); + + $auth->authenticate($channel, [MechanismName::Plain->value], tlsActive: false); + } + + public function testDeferredCredentialsSuppliedAtAuthenticateCall(): void + { + $lookup = new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('password123'); + } + }; + $server = new PlainServerMechanism($lookup); + $channel = new FakeServerChannel($server); + + // No credential at construction (e.g. STARTTLS deferred). + $auth = new SaslAuthenticator($this->config()); + $auth->authenticate( + $channel, + [MechanismName::Plain->value], + tlsActive: true, + credentials: $this->credentials(), + ); + + $this->assertFalse($channel->cancelled); + } + + public function testUnsupportedMechanismListThrowsAuthenticationException(): void + { + $auth = new SaslAuthenticator($this->config(), $this->credentials()); + $server = new PlainServerMechanism(new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('x'); + } + }); + $channel = new FakeServerChannel($server); + + $this->expectException(AuthenticationException::class); + + // Server only offers a mechanism this build/credential combination + // cannot use. + $auth->authenticate($channel, ['GSSAPI'], tlsActive: true); + } + + public function testSecureDefaultsPolicyRejectsPlainEvenWithTls(): void + { + // secureDefaults() sets a minimum mechanism strength of Token, + // which excludes Plaintext-strength PLAIN/LOGIN unconditionally. + // Not merely "without TLS". Confirms the policy is actually wired + // through, not just passed and ignored. + $auth = new SaslAuthenticator($this->config(SaslPolicy::secureDefaults()), $this->credentials()); + $server = new PlainServerMechanism(new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('x'); + } + }); + $channel = new FakeServerChannel($server); + + $this->expectException(AuthenticationException::class); + + $auth->authenticate($channel, [MechanismName::Plain->value], tlsActive: true); + } + + public function testSecureDefaultsPolicyAllowsScram(): void + { + $lookup = new InMemoryScramCredentialLookup([ + 'alice' => ['password' => 'password123', 'salt' => random_bytes(16), 'iterations' => 4096], + ]); + $server = new ScramServerMechanism(MechanismName::ScramSha256, $lookup); + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(SaslPolicy::secureDefaults()), $this->credentials()); + $auth->authenticate($channel, [MechanismName::ScramSha256->value], tlsActive: true); + + $this->assertFalse($channel->cancelled); + } + + public function testOAuthBearerAuthenticationSucceeds(): void + { + $server = new SingleShotFakeServerMechanism(MechanismName::OAuthBearer); + $channel = new FakeServerChannel($server); + $credentials = new TokenCredentials('alice', new PlainSecret('access-token')); + + $auth = new SaslAuthenticator($this->config(), $credentials); + $auth->authenticate($channel, [MechanismName::OAuthBearer->value], tlsActive: true); + + $this->assertNotNull($channel->sentInitialResponse); + $this->assertFalse($channel->cancelled); + } + + public function testOAuthBearerFailureChallengeSurfacesServerDetailAndIsNotSwallowed(): void + { + // RFC 7628 §3.1: on failure the server does not send a plain + // rejection but a JSON error object as one more `+` continuation. + // The client mechanism's own step() converts that into an + // AuthenticationFailedException carrying the detail. Confirms that the + // adapter's loop calls step() here. + $server = new SingleShotFakeServerMechanism( + MechanismName::OAuthBearer, + shouldFail: true, + failureAsChallenge: true, + failureDetail: '{"status":"invalid_token"}', + ); + $channel = new FakeServerChannel($server); + $credentials = new TokenCredentials('alice', new PlainSecret('expired-token')); + + $auth = new SaslAuthenticator($this->config(), $credentials); + + try { + $auth->authenticate($channel, [MechanismName::OAuthBearer->value], tlsActive: true); + $this->fail('Expected an AuthenticationException.'); + } catch (AuthenticationException $e) { + $this->assertStringContainsString('invalid_token', $e->getMessage()); + $this->assertTrue($channel->cancelled); + } + } + + public function testXoauth2AuthenticationSucceeds(): void + { + $server = new SingleShotFakeServerMechanism(MechanismName::Xoauth2); + $channel = new FakeServerChannel($server); + $credentials = new TokenCredentials('alice', new PlainSecret('access-token')); + + $auth = new SaslAuthenticator($this->config(), $credentials); + $auth->authenticate($channel, [MechanismName::Xoauth2->value], tlsActive: true); + + $this->assertFalse($channel->cancelled); + } + + public function testXoauth2FailureChallengeSurfacesServerDetail(): void + { + $server = new SingleShotFakeServerMechanism( + MechanismName::Xoauth2, + shouldFail: true, + failureAsChallenge: true, + failureDetail: '{"status":"invalid_token"}', + ); + $channel = new FakeServerChannel($server); + $credentials = new TokenCredentials('alice', new PlainSecret('expired-token')); + + $auth = new SaslAuthenticator($this->config(), $credentials); + + try { + $auth->authenticate($channel, [MechanismName::Xoauth2->value], tlsActive: true); + $this->fail('Expected an AuthenticationException.'); + } catch (AuthenticationException $e) { + $this->assertStringContainsString('invalid_token', $e->getMessage()); + $this->assertTrue($channel->cancelled); + } + } + + public function testExternalAuthenticationSucceeds(): void + { + // EXTERNAL derives identity purely from the channel (e.g. a client + // TLS certificate); no secret is exchanged at all. + $server = new SingleShotFakeServerMechanism(MechanismName::External); + $channel = new FakeServerChannel($server); + $credentials = new ExternalCredentials('alice'); + + $auth = new SaslAuthenticator($this->config(), $credentials); + $auth->authenticate($channel, [MechanismName::External->value], tlsActive: true); + + $this->assertFalse($channel->cancelled); + } + + public function testExternalAuthenticationRejectedByServer(): void + { + $server = new SingleShotFakeServerMechanism( + MechanismName::External, + shouldFail: true, + failureDetail: 'no certificate presented', + ); + $channel = new FakeServerChannel($server); + $credentials = new ExternalCredentials('alice'); + + $auth = new SaslAuthenticator($this->config(), $credentials); + + $this->expectException(AuthenticationException::class); + + $auth->authenticate($channel, [MechanismName::External->value], tlsActive: true); + } + + public function testAnonymousAuthenticationSucceeds(): void + { + // ANONYMOUS's strength (-1) is below even legacyCompatible()'s + // minimum (Plaintext, 0). It is deliberately excluded by every + // named policy factory. Allowing ANONYMOUS needs an explicit + // opt-in policy. + $server = new SingleShotFakeServerMechanism(MechanismName::Anonymous); + $channel = new FakeServerChannel($server); + $credentials = new AnonymousCredentials('guest@example.com'); + $policy = new SaslPolicy( + minimumStrength: \Horde\Sasl\MechanismStrength::Anonymous, + requireTlsForPlaintext: false, + requireChannelBinding: false, + denied: [], + ); + + $auth = new SaslAuthenticator($this->config($policy), $credentials); + $auth->authenticate($channel, [MechanismName::Anonymous->value], tlsActive: false); + + $this->assertFalse($channel->cancelled); + } + + public function testAnonymousRejectedByLegacyCompatiblePolicy(): void + { + // Confirms ANONYMOUS is excluded even by the permissive named + // policy, not just by secureDefaults(). It needs the bespoke + // opt-in policy from testAnonymousAuthenticationSucceeds(). + $server = new SingleShotFakeServerMechanism(MechanismName::Anonymous); + $channel = new FakeServerChannel($server); + $credentials = new AnonymousCredentials(); + + $auth = new SaslAuthenticator($this->config(SaslPolicy::legacyCompatible()), $credentials); + + $this->expectException(AuthenticationException::class); + + $auth->authenticate($channel, [MechanismName::Anonymous->value], tlsActive: true); + } + + public function testAnonymousRejectedBySecureDefaultsPolicy(): void + { + $server = new SingleShotFakeServerMechanism(MechanismName::Anonymous); + $channel = new FakeServerChannel($server); + $credentials = new AnonymousCredentials(); + + $auth = new SaslAuthenticator($this->config(SaslPolicy::secureDefaults()), $credentials); + + $this->expectException(AuthenticationException::class); + + $auth->authenticate($channel, [MechanismName::Anonymous->value], tlsActive: true); + } + + public function testNegotiatorChoosesStrongestOfMultipleOfferedMechanisms(): void + { + // The server offers PLAIN, CRAM-MD5, and SCRAM-SHA-256; under + // legacyCompatible() all three are permitted, so the Negotiator + // must select the strongest (SCRAM), not just the first offered. + $lookup = new InMemoryScramCredentialLookup([ + 'alice' => ['password' => 'password123', 'salt' => random_bytes(16), 'iterations' => 4096], + ]); + $server = new ScramServerMechanism(MechanismName::ScramSha256, $lookup); + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(), $this->credentials()); + $auth->authenticate( + $channel, + [MechanismName::Plain->value, MechanismName::CramMd5->value, MechanismName::ScramSha256->value], + tlsActive: true, + ); + + $this->assertSame(MechanismName::ScramSha256->value, $channel->sentMechanismName); + $this->assertFalse($channel->cancelled); + } + + public function testDowngradeDetectionStaysDisabledWithoutPinnedMechanisms(): void + { + // No persistence point for trust-on-first-use pinning exists yet + // Confirms omitting it never throws + // DowngradeDetectedException, even when a weaker mechanism than + // what could theoretically be offered is chosen. + $server = new PlainServerMechanism(new class implements \Horde\Sasl\Credentials\PasswordLookup { + public function password(string $authcid): \Horde\Sasl\Credentials\Secret + { + return new PlainSecret('password123'); + } + }); + $channel = new FakeServerChannel($server); + + $auth = new SaslAuthenticator($this->config(SaslPolicy::legacyCompatible()), $this->credentials()); + $auth->authenticate($channel, [MechanismName::Plain->value], tlsActive: false); + + $this->assertFalse($channel->cancelled); + } + + /** + * @param list $events + */ + private function dispatcherRecording(array &$events): EventDispatcherInterface + { + return new class ($events) implements EventDispatcherInterface { + public function __construct(private array &$events) {} + + public function dispatch(object $event): object + { + $this->events[] = $event; + + return $event; + } + }; + } +} diff --git a/test/Unit/Src/Auth/SingleShotFakeServerMechanism.php b/test/Unit/Src/Auth/SingleShotFakeServerMechanism.php new file mode 100644 index 00000000..843e9dd3 --- /dev/null +++ b/test/Unit/Src/Auth/SingleShotFakeServerMechanism.php @@ -0,0 +1,99 @@ +mechanismName; + } + + public function role(): Role + { + return Role::Server; + } + + public function isComplete(): bool + { + return $this->complete; + } + + public function usesChannelBinding(): bool + { + return false; + } + + public function initialChallenge(): Challenge + { + // Client-first: the server waits for the client's first message. + return Challenge::none(); + } + + public function step(Response $response): Challenge + { + if ($this->shouldFail) { + if ($this->failureAsChallenge) { + return Challenge::bytes($this->failureDetail); + } + + throw new \Horde\Sasl\Exception\AuthenticationFailedException($this->failureDetail); + } + + $this->complete = true; + + return Challenge::none(); + } + + public function additionalData(): AdditionalData + { + return AdditionalData::none(); + } + + public function authorizationId(): string + { + return 'alice'; + } +} diff --git a/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php b/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php new file mode 100644 index 00000000..9d085b88 --- /dev/null +++ b/test/Unit/Src/Auth/SocketChannelBindingProviderTest.php @@ -0,0 +1,126 @@ +supportsTlsServerEndPoint; + } + + public function channelBindingData(SocketBindingType $type): string + { + if ($this->bindingException !== null) { + throw $this->bindingException; + } + + return $this->bindingData ?? ''; + } + + public function startTls(): bool + { + return true; + } + + public function close(): void {} + + public function getStatus(): StreamStatus + { + return new StreamStatus(timedOut: false, blocked: false, eof: false, unreadBytes: 0); + } + + public function gets(int $size): string + { + return ''; + } + + public function read(int $size): string + { + return ''; + } + + public function write(string $data): void {} + }; + } + + public function testAvailableReportsTlsServerEndPointWhenSupported(): void + { + $provider = new SocketChannelBindingProvider($this->client(supportsTlsServerEndPoint: true)); + + $this->assertSame([SaslBindingType::TlsServerEndPoint], $provider->available()); + } + + public function testAvailableIsEmptyWhenNotSupported(): void + { + $provider = new SocketChannelBindingProvider($this->client(supportsTlsServerEndPoint: false)); + + $this->assertSame([], $provider->available()); + } + + public function testBindingDataDelegatesToSocketClientAndTranslatesEnum(): void + { + $provider = new SocketChannelBindingProvider($this->client(bindingData: 'fake-cert-hash')); + + $this->assertSame('fake-cert-hash', $provider->bindingData(SaslBindingType::TlsServerEndPoint)); + } + + public function testBindingDataWrapsSocketClientExceptionIntoSaslException(): void + { + $original = new SocketChannelBindingException('no TLS session bound to this socket'); + $provider = new SocketChannelBindingProvider($this->client(bindingException: $original)); + + try { + $provider->bindingData(SaslBindingType::TlsServerEndPoint); + $this->fail('Expected a SaslChannelBindingException to be thrown.'); + } catch (SaslChannelBindingException $e) { + $this->assertSame('no TLS session bound to this socket', $e->getMessage()); + $this->assertSame($original, $e->getPrevious()); + } + } +} diff --git a/test/Unit/Src/CapabilityDataTest.php b/test/Unit/Src/CapabilityDataTest.php new file mode 100644 index 00000000..a3991b93 --- /dev/null +++ b/test/Unit/Src/CapabilityDataTest.php @@ -0,0 +1,159 @@ +newCapability(); + $cap->add('IMAP4rev1'); + + $this->assertTrue($cap->query('IMAP4rev1')); + $this->assertFalse($cap->query('CONDSTORE')); + } + + public function testQueryAndAddAreCaseInsensitive(): void + { + $cap = $this->newCapability(); + $cap->add('imap4rev1'); + + $this->assertTrue($cap->query('IMAP4REV1')); + $this->assertTrue($cap->query('ImAp4reV1')); + } + + public function testAddWithSingleParameter(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', 'PLAIN'); + + $this->assertTrue($cap->query('AUTH')); + $this->assertTrue($cap->query('AUTH', 'PLAIN')); + $this->assertFalse($cap->query('AUTH', 'LOGIN')); + $this->assertSame(['PLAIN'], $cap->getParams('AUTH')); + } + + public function testAddWithParameterListInOneCall(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', ['PLAIN', 'LOGIN']); + + $this->assertSame(['PLAIN', 'LOGIN'], $cap->getParams('AUTH')); + } + + public function testRepeatedAddMergesParametersRatherThanReplacing(): void + { + // A real CAPABILITY response lists AUTH=PLAIN and AUTH=LOGIN as + // separate tokens under the same capability name. + $cap = $this->newCapability(); + $cap->add('AUTH', 'PLAIN'); + $cap->add('AUTH', 'LOGIN'); + + $this->assertSame(['PLAIN', 'LOGIN'], $cap->getParams('AUTH')); + } + + public function testAddParametersAreUppercased(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', 'plain'); + + $this->assertTrue($cap->query('AUTH', 'PLAIN')); + $this->assertSame(['PLAIN'], $cap->getParams('AUTH')); + } + + public function testAddWithoutParamsIsIdempotentAndDoesNotClobberExistingParams(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', 'PLAIN'); + $cap->add('AUTH'); + + $this->assertSame(['PLAIN'], $cap->getParams('AUTH')); + } + + public function testGetParamsIsEmptyForUnknownOrParameterlessCapability(): void + { + $cap = $this->newCapability(); + $cap->add('IMAP4rev1'); + + $this->assertSame([], $cap->getParams('IMAP4rev1')); + $this->assertSame([], $cap->getParams('UNKNOWN')); + } + + public function testRemoveWholeCapability(): void + { + $cap = $this->newCapability(); + $cap->add('IMAP4rev1'); + $cap->remove('IMAP4rev1'); + + $this->assertFalse($cap->query('IMAP4rev1')); + } + + public function testRemoveNonExistentCapabilityIsANoop(): void + { + $cap = $this->newCapability(); + $cap->remove('IMAP4rev1'); + + $this->assertFalse($cap->query('IMAP4rev1')); + } + + public function testRemoveOneParameterLeavesOthersIntact(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', ['PLAIN', 'LOGIN']); + $cap->remove('AUTH', 'PLAIN'); + + $this->assertTrue($cap->query('AUTH')); + $this->assertFalse($cap->query('AUTH', 'PLAIN')); + $this->assertTrue($cap->query('AUTH', 'LOGIN')); + $this->assertSame(['LOGIN'], $cap->getParams('AUTH')); + } + + public function testRemoveLastParameterDropsTheWholeCapability(): void + { + $cap = $this->newCapability(); + $cap->add('AUTH', 'PLAIN'); + $cap->remove('AUTH', 'PLAIN'); + + $this->assertFalse($cap->query('AUTH')); + } + + public function testToArrayReturnsRawUppercasedData(): void + { + $cap = $this->newCapability(); + $cap->add('imap4rev1'); + $cap->add('AUTH', 'plain'); + + $this->assertSame( + ['IMAP4REV1' => true, 'AUTH' => ['PLAIN']], + $cap->toArray(), + ); + } + + public function testSerializeRoundTrip(): void + { + $cap = $this->newCapability(); + $cap->add('IMAP4rev1'); + $cap->add('AUTH', ['PLAIN', 'LOGIN']); + + $restored = $this->newCapability(); + $restored->__unserialize($cap->__serialize()); + + $this->assertSame($cap->toArray(), $restored->toArray()); + $this->assertTrue($restored->query('AUTH', 'LOGIN')); + } +} diff --git a/test/Unit/Src/ConnectionConfigEdgeCaseTest.php b/test/Unit/Src/ConnectionConfigEdgeCaseTest.php index 080c52d9..147e4d4c 100644 --- a/test/Unit/Src/ConnectionConfigEdgeCaseTest.php +++ b/test/Unit/Src/ConnectionConfigEdgeCaseTest.php @@ -14,75 +14,62 @@ #[CoversClass(ConnectionConfig::class)] class ConnectionConfigEdgeCaseTest extends TestCase { - public function testReadonlyUsernameThrowsError(): void + public function testReadonlyHostspecThrowsError(): void { - $cfg = new ConnectionConfig('user', 'pass'); + $cfg = new ConnectionConfig('mail.example.com'); $this->expectException(Error::class); - $cfg->username = 'other'; - } - - public function testReadonlyPasswordThrowsError(): void - { - $cfg = new ConnectionConfig('user', 'pass'); - $this->expectException(Error::class); - $cfg->password = 'other'; + $cfg->hostspec = 'other.example.com'; } public function testReadonlyPortThrowsError(): void { - $cfg = new ConnectionConfig('user', 'pass', port: 993); + $cfg = new ConnectionConfig(port: 993); $this->expectException(Error::class); $cfg->port = 143; } - public function testEmptyUsername(): void - { - $cfg = new ConnectionConfig('', 'pass'); - $this->assertSame('', $cfg->username); - } - - public function testEmptyPassword(): void + public function testEmptyHostspec(): void { - $cfg = new ConnectionConfig('user', ''); - $this->assertSame('', $cfg->password); + $cfg = new ConnectionConfig(''); + $this->assertSame('', $cfg->hostspec); } public function testPortZero(): void { - $cfg = new ConnectionConfig('user', 'pass', port: 0); + $cfg = new ConnectionConfig(port: 0); $this->assertSame(0, $cfg->port); } public function testNegativeTimeout(): void { - $cfg = new ConnectionConfig('user', 'pass', timeout: -1); + $cfg = new ConnectionConfig(timeout: -1); $this->assertSame(-1, $cfg->timeout); } public function testContextWithNestedArrays(): void { $context = ['ssl' => ['verify_peer' => false, 'cafile' => '/etc/ssl/certs/ca.pem']]; - $cfg = new ConnectionConfig('user', 'pass', context: $context); + $cfg = new ConnectionConfig(context: $context); $this->assertSame($context, $cfg->context); } public function testCapabilityIgnorePreservesOrder(): void { $ignore = ['CONDSTORE', 'QRESYNC', 'IDLE']; - $cfg = new ConnectionConfig('user', 'pass', capabilityIgnore: $ignore); + $cfg = new ConnectionConfig(capabilityIgnore: $ignore); $this->assertSame($ignore, $cfg->capabilityIgnore); } public function testIdArrayPreservesKeys(): void { $id = ['name' => 'Horde', 'version' => '6.0']; - $cfg = new ConnectionConfig('user', 'pass', id: $id); + $cfg = new ConnectionConfig(id: $id); $this->assertSame($id, $cfg->id); } public function testLangMultipleEntries(): void { - $cfg = new ConnectionConfig('user', 'pass', lang: ['en', 'de', 'fr']); + $cfg = new ConnectionConfig(lang: ['en', 'de', 'fr']); $this->assertCount(3, $cfg->lang); $this->assertSame(['en', 'de', 'fr'], $cfg->lang); } @@ -98,7 +85,7 @@ public static function secureModeProvider(): array #[DataProvider('secureModeProvider')] public function testAllSecureModes(SecureMode $mode): void { - $cfg = new ConnectionConfig('user', 'pass', secure: $mode); + $cfg = new ConnectionConfig(secure: $mode); $this->assertSame($mode, $cfg->secure); } } diff --git a/test/Unit/Src/ConnectionConfigTest.php b/test/Unit/Src/ConnectionConfigTest.php index cf56a11b..c4c073da 100644 --- a/test/Unit/Src/ConnectionConfigTest.php +++ b/test/Unit/Src/ConnectionConfigTest.php @@ -5,8 +5,8 @@ namespace Horde\Imap\Client\Test\Unit\Src; use Horde\Imap\Client\ConnectionConfig; -use Horde\Imap\Client\PasswordInterface; use Horde\Imap\Client\SecureMode; +use Horde\Sasl\Negotiation\SaslPolicy; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -15,10 +15,8 @@ class ConnectionConfigTest extends TestCase { public function testMinimalConstruction(): void { - $cfg = new ConnectionConfig('user', 'pass'); + $cfg = new ConnectionConfig(); - $this->assertSame('user', $cfg->username); - $this->assertSame('pass', $cfg->password); $this->assertSame('localhost', $cfg->hostspec); $this->assertNull($cfg->port); $this->assertSame(SecureMode::None, $cfg->secure); @@ -28,13 +26,14 @@ public function testMinimalConstruction(): void $this->assertSame([], $cfg->capabilityIgnore); $this->assertNull($cfg->id); $this->assertSame([], $cfg->lang); + $this->assertNull($cfg->saslPolicy); } public function testFullConstruction(): void { + $policy = SaslPolicy::legacyCompatible(); + $cfg = new ConnectionConfig( - username: 'admin', - password: 'secret', hostspec: 'mail.example.com', port: 993, secure: SecureMode::Ssl, @@ -44,28 +43,22 @@ public function testFullConstruction(): void capabilityIgnore: ['CONDSTORE'], id: ['name' => 'TestClient'], lang: ['en'], + saslPolicy: $policy, ); - $this->assertSame('admin', $cfg->username); + $this->assertSame('mail.example.com', $cfg->hostspec); $this->assertSame(993, $cfg->port); $this->assertSame(SecureMode::Ssl, $cfg->secure); $this->assertSame(60, $cfg->timeout); $this->assertSame(['CONDSTORE'], $cfg->capabilityIgnore); $this->assertSame(['en'], $cfg->lang); + $this->assertSame($policy, $cfg->saslPolicy); } - public function testPasswordInterface(): void + public function testSaslPolicyDefaultsToNull(): void { - $pw = new class implements PasswordInterface { - public function getPassword(): string - { - return 'dynamic-pass'; - } - }; - - $cfg = new ConnectionConfig('user', $pw); + $cfg = new ConnectionConfig(hostspec: 'mail.example.com'); - $this->assertInstanceOf(PasswordInterface::class, $cfg->password); - $this->assertSame('dynamic-pass', $cfg->password->getPassword()); + $this->assertNull($cfg->saslPolicy); } } diff --git a/test/Unit/Src/EventHierarchyTest.php b/test/Unit/Src/EventHierarchyTest.php index a003f0fe..356a224b 100644 --- a/test/Unit/Src/EventHierarchyTest.php +++ b/test/Unit/Src/EventHierarchyTest.php @@ -19,6 +19,7 @@ use Horde\Imap\Client\Event\MailboxExpunged; use Horde\Imap\Client\Event\MailboxSelected; use Horde\Imap\Client\Event\SlowCommand; +use Horde\Imap\Client\ImapIdSet; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -62,8 +63,6 @@ public static function lifecycleEventProvider(): array [AuthenticationSucceeded::class], [AuthenticationFailed::class], [CapabilityNegotiated::class], - [MailboxSelected::class], - [MailboxExpunged::class], [AlertReceived::class], [SlowCommand::class], ]; @@ -77,6 +76,23 @@ public function testLifecycleEventsExtendImapEvent(string $class): void $this->assertNotInstanceOf(DiagnosticEvent::class, $event); } + /** + * MailboxSelected and MailboxExpunged carry typed payloads so they are + * asserted separately from the zero-argument lifecycle events. They are + * still plain ImapEvent (non-diagnostic) subclasses so the default + * FilteredEventDispatcher lets them through to listeners. + */ + public function testTypedDomainEventsExtendImapEvent(): void + { + $selected = new MailboxSelected('INBOX', 42, 100, 715); + $this->assertInstanceOf(ImapEvent::class, $selected); + $this->assertNotInstanceOf(DiagnosticEvent::class, $selected); + + $expunged = new MailboxExpunged('INBOX', new ImapIdSet([5], false), 42); + $this->assertInstanceOf(ImapEvent::class, $expunged); + $this->assertNotInstanceOf(DiagnosticEvent::class, $expunged); + } + public static function diagnosticEventProvider(): array { return [ diff --git a/test/Unit/Src/ImapAuthChannelTest.php b/test/Unit/Src/ImapAuthChannelTest.php new file mode 100644 index 00000000..3aa57cb4 --- /dev/null +++ b/test/Unit/Src/ImapAuthChannelTest.php @@ -0,0 +1,166 @@ +sendAuthenticate('CRAM-MD5', null); + + self::assertSame(['A1 AUTHENTICATE CRAM-MD5'], $socket->written); + } + + public function testSendAuthenticateWithInitialResponseBase64Encodes(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $channel->sendAuthenticate('PLAIN', "\0user\0pass"); + + self::assertSame(['A1 AUTHENTICATE PLAIN ' . base64_encode("\0user\0pass")], $socket->written); + } + + public function testSendAuthenticateWithEmptyInitialResponseUsesEqualsShorthand(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $channel->sendAuthenticate('EXTERNAL', ''); + + self::assertSame(['A1 AUTHENTICATE EXTERNAL ='], $socket->written); + } + + public function testNextEventDecodesContinuationChallenge(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('+ ' . base64_encode('challenge-bytes')), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isChallenge()); + self::assertSame('challenge-bytes', $event->payload()); + } + + public function testNextEventReportsSuccess(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 OK AUTHENTICATE completed.'), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isOutcome()); + self::assertTrue($event->isSuccess()); + self::assertSame('AUTHENTICATE completed.', $event->text()); + } + + public function testNextEventReportsFailureWithoutThrowing(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 NO [AUTHENTICATIONFAILED] Authentication failed.'), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isOutcome()); + self::assertFalse($event->isSuccess()); + self::assertSame('AUTHENTICATIONFAILED', $event->responseCode()); + self::assertSame('Authentication failed.', $event->text()); + } + + public function testNextEventReportsBadWithoutThrowing(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 BAD Invalid SASL response.'), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isOutcome()); + self::assertFalse($event->isSuccess()); + } + + public function testNextEventSkipsUnsolicitedUntaggedResponses(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [ALERT] System going down soon.'), + InMemoryImapSocket::line('+ ' . base64_encode('challenge-bytes')), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isChallenge()); + self::assertSame('challenge-bytes', $event->payload()); + } + + public function testNextEventThrowsOnMalformedBase64(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('+ not-valid-base64!!!'), + ); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $this->expectException(ImapProtocolException::class); + + $channel->nextEvent(); + } + + public function testSendResponseBase64EncodesPayload(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $channel->sendResponse('response-bytes'); + + self::assertSame([base64_encode('response-bytes')], $socket->written); + } + + public function testSendResponseWithEmptyPayloadSendsBlankLine(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $channel->sendResponse(''); + + self::assertSame([''], $socket->written); + } + + public function testCancelSendsAsterisk(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $channel = new ImapAuthChannel(new ImapConnection($socket)); + + $channel->cancel(); + + self::assertSame(['*'], $socket->written); + } +} diff --git a/test/Unit/Src/ImapCacheStoreTest.php b/test/Unit/Src/ImapCacheStoreTest.php new file mode 100644 index 00000000..e2b10947 --- /dev/null +++ b/test/Unit/Src/ImapCacheStoreTest.php @@ -0,0 +1,190 @@ +store($cache); + + $store->set('INBOX', [ + 5 => ['flags' => ['\\Seen'], 'size' => 100], + 7 => ['flags' => [], 'size' => 200], + ], 42); + $store->flush(); + + $got = $store->get('INBOX', [5, 7], [], 42); + + self::assertSame(['\\Seen'], $got[5]['flags']); + self::assertSame(200, $got[7]['size']); + } + + public function testGetReadsFromWriteBufferBeforeFlush(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 100]], 42); + + // No flush yet: the value is still only in the buffer. + $got = $store->get('INBOX', [5], [], 42); + self::assertSame(100, $got[5]['size']); + } + + public function testSetMergesFieldsForSameUid(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 100]], 42); + $store->set('INBOX', [5 => ['flags' => ['\\Seen']]], 42); + $store->flush(); + + $got = $store->get('INBOX', [5], [], 42); + self::assertSame(100, $got[5]['size']); + self::assertSame(['\\Seen'], $got[5]['flags']); + } + + public function testGetWithFieldFilter(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['flags' => [], 'size' => 100, 'envelope' => 'x']], 42); + $store->flush(); + + $got = $store->get('INBOX', [5], ['size'], 42); + self::assertSame(['size' => 100], $got[5]); + } + + public function testGetCachedUids(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 1], 7 => ['size' => 2], 9 => ['size' => 3]], 42); + $store->flush(); + + $uids = $store->getCachedUids('INBOX', 42); + sort($uids); + self::assertSame([5, 7, 9], $uids); + } + + public function testUidValidityChangeInvalidatesCache(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 100]], 42); + $store->flush(); + + // A different UIDVALIDITY means the UID space was reassigned. + self::assertSame([], $store->get('INBOX', [5], [], 99)); + self::assertSame([], $store->getCachedUids('INBOX', 99)); + } + + public function testDeleteMsgs(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 1], 7 => ['size' => 2]], 42); + $store->flush(); + + $store->deleteMsgs('INBOX', [5]); + $store->flush(); + + self::assertSame([7], $store->getCachedUids('INBOX', 42)); + self::assertArrayNotHasKey(5, $store->get('INBOX', [5, 7], [], 42)); + } + + public function testDeleteMailbox(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 1]], 42); + $store->flush(); + + $store->deleteMailbox('INBOX'); + + self::assertSame([], $store->getCachedUids('INBOX', 42)); + } + + public function testMetadataRoundTrip(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->setMetadata('INBOX', ['uidvalid' => 42, 'highestmodseq' => 715]); + $store->flush(); + + $meta = $store->getMetadata('INBOX', 42, []); + self::assertSame(42, $meta['uidvalid']); + self::assertSame(715, $meta['highestmodseq']); + } + + public function testMetadataUidValidityMismatchReturnsOnlyUidvalid(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->setMetadata('INBOX', ['uidvalid' => 42, 'highestmodseq' => 715]); + $store->flush(); + + $meta = $store->getMetadata('INBOX', 99, []); + self::assertSame(['uidvalid' => 99], $meta); + } + + public function testDestructFlushesPendingWrites(): void + { + $cache = new ArrayCache(); + $store = $this->store($cache); + + $store->set('INBOX', [5 => ['size' => 100]], 42); + // Trigger the destructor flush by dropping the only reference. + unset($store); + + self::assertNotSame([], $cache->data); + } + + public function testSeparateAccountsDoNotCollide(): void + { + $cache = new ArrayCache(); + $alice = new ImapCacheStore($cache, 'imap.example.test', 993, 'alice'); + $bob = new ImapCacheStore($cache, 'imap.example.test', 993, 'bob'); + + $alice->set('INBOX', [5 => ['size' => 1]], 42); + $alice->flush(); + + // Bob sees nothing of Alice's cache. + self::assertSame([], $bob->getCachedUids('INBOX', 42)); + } +} diff --git a/test/Unit/Src/ImapCapabilityNegotiatorTest.php b/test/Unit/Src/ImapCapabilityNegotiatorTest.php new file mode 100644 index 00000000..bc550ed8 --- /dev/null +++ b/test/Unit/Src/ImapCapabilityNegotiatorTest.php @@ -0,0 +1,178 @@ +negotiator( + '* CAPABILITY IMAP4rev2 UIDPLUS AUTH=PLAIN', + 'A1 OK CAPABILITY completed.', + ); + + $capability = $negotiator->fetch(); + + self::assertTrue($capability->query('IMAP4REV2')); + self::assertTrue($capability->query('UIDPLUS')); + self::assertTrue($capability->query('AUTH', 'PLAIN')); + } + + public function testFetchSendsTheCapabilityCommand(): void + { + [$negotiator, $socket] = $this->negotiator( + '* CAPABILITY IMAP4rev1', + 'A1 OK CAPABILITY completed.', + ); + + $negotiator->fetch(); + + self::assertSame(['A1 CAPABILITY'], $socket->written); + } + + public function testFetchAlsoReadsCapabilityResponseCodeOnTaggedCompletion(): void + { + [$negotiator] = $this->negotiator( + 'A1 OK [CAPABILITY IMAP4rev1 IDLE] CAPABILITY completed.', + ); + + $capability = $negotiator->fetch(); + + self::assertTrue($capability->query('IMAP4REV1')); + self::assertTrue($capability->query('IDLE')); + } + + public function testEnableRecordsAcknowledgedExtensions(): void + { + [$negotiator] = $this->negotiator( + '* ENABLED IMAP4rev2', + 'A1 OK ENABLE completed.', + ); + $capability = new ImapCapability(); + + $enabled = $negotiator->enable($capability, ['IMAP4rev2']); + + self::assertSame(['IMAP4REV2'], $enabled); + self::assertTrue($capability->isEnabled('IMAP4REV2')); + } + + public function testEnableSendsRequestedExtensionsAsArguments(): void + { + [$negotiator, $socket] = $this->negotiator( + '* ENABLED CONDSTORE QRESYNC', + 'A1 OK ENABLE completed.', + ); + + $negotiator->enable(new ImapCapability(), ['CONDSTORE', 'QRESYNC']); + + self::assertSame(['A1 ENABLE CONDSTORE QRESYNC'], $socket->written); + } + + public function testEnableWithNoAcknowledgedExtensionsReturnsEmptyList(): void + { + [$negotiator] = $this->negotiator( + '* ENABLED', + 'A1 OK ENABLE completed.', + ); + + $enabled = $negotiator->enable(new ImapCapability(), ['UNSUPPORTED']); + + self::assertSame([], $enabled); + } + + public function testNegotiateRev2EnablesImap4Rev2WhenOffered(): void + { + [$negotiator] = $this->negotiator( + '* ENABLED IMAP4rev2', + 'A1 OK ENABLE completed.', + ); + $capability = new ImapCapability(); + $capability->add('IMAP4rev2'); + $capability->add('UTF8', 'ACCEPT'); + + $result = $negotiator->negotiateRev2($capability); + + self::assertSame('IMAP4REV2', $result); + self::assertTrue($capability->isEnabled('IMAP4REV2')); + } + + public function testNegotiateRev2FallsBackToUtf8AcceptForRev1Server(): void + { + [$negotiator, $socket] = $this->negotiator( + '* ENABLED UTF8=ACCEPT', + 'A1 OK ENABLE completed.', + ); + $capability = new ImapCapability(); + $capability->add('IMAP4rev1'); + $capability->add('UTF8', 'ACCEPT'); + + $result = $negotiator->negotiateRev2($capability); + + self::assertSame('UTF8=ACCEPT', $result); + self::assertSame(['A1 ENABLE UTF8=ACCEPT'], $socket->written); + } + + public function testNegotiateRev2DoesNothingWithoutRev2OrUtf8Accept(): void + { + [$negotiator, $socket] = $this->negotiator(''); + $capability = new ImapCapability(); + $capability->add('IMAP4rev1'); + + $result = $negotiator->negotiateRev2($capability); + + self::assertNull($result); + self::assertSame([], $socket->written); + } + + public function testMergeFromResponseReturnsFalseWhenNoCapabilityData(): void + { + $response = ImapResponseParser::parse(['*', '1', 'EXISTS']); + $capability = new ImapCapability(); + + $merged = ImapCapabilityNegotiator::mergeFromResponse($response, $capability); + + self::assertFalse($merged); + } + + public function testMergeFromResponseParsesGreetingCapabilityCode(): void + { + $response = ImapResponseParser::parse(['*', 'OK', '[CAPABILITY', 'IMAP4rev1]', 'Server', 'ready.']); + $capability = new ImapCapability(); + + $merged = ImapCapabilityNegotiator::mergeFromResponse($response, $capability); + + self::assertTrue($merged); + self::assertTrue($capability->query('IMAP4REV1')); + } +} diff --git a/test/Unit/Src/ImapCapabilityParserTest.php b/test/Unit/Src/ImapCapabilityParserTest.php new file mode 100644 index 00000000..e125a715 --- /dev/null +++ b/test/Unit/Src/ImapCapabilityParserTest.php @@ -0,0 +1,69 @@ +query('IMAP4REV2')); + self::assertTrue($capability->query('UIDPLUS')); + self::assertTrue($capability->query('IDLE')); + self::assertFalse($capability->query('QRESYNC')); + } + + public function testParsesParameterizedCapabilities(): void + { + $capability = ImapCapabilityParser::parse(['AUTH=PLAIN', 'AUTH=SCRAM-SHA-256', 'UTF8=ACCEPT']); + + self::assertTrue($capability->query('AUTH', 'PLAIN')); + self::assertTrue($capability->query('AUTH', 'SCRAM-SHA-256')); + self::assertTrue($capability->query('UTF8', 'ACCEPT')); + self::assertFalse($capability->query('AUTH', 'LOGIN')); + } + + public function testIgnoresNestedListTokens(): void + { + $capability = ImapCapabilityParser::parse(['IMAP4rev1', ['nested', 'list']]); + + self::assertTrue($capability->query('IMAP4REV1')); + } + + public function testMergesIntoAnExistingCapabilityInstance(): void + { + $capability = new ImapCapability(); + $capability->add('IDLE'); + + ImapCapabilityParser::parse(['UIDPLUS'], $capability); + + self::assertTrue($capability->query('IDLE')); + self::assertTrue($capability->query('UIDPLUS')); + } + + public function testEmptyTokenListReturnsEmptyCapability(): void + { + $capability = ImapCapabilityParser::parse([]); + + self::assertFalse($capability->query('IMAP4REV1')); + } +} diff --git a/test/Unit/Src/ImapCapabilityTest.php b/test/Unit/Src/ImapCapabilityTest.php new file mode 100644 index 00000000..3373cfd5 --- /dev/null +++ b/test/Unit/Src/ImapCapabilityTest.php @@ -0,0 +1,145 @@ +assertInstanceOf(Capability::class, new ImapCapability()); + } + + public function testPlainQueryBehavesLikeTheGenericSet(): void + { + $cap = new ImapCapability(); + $cap->add('IMAP4rev1'); + $cap->add('AUTH', ['PLAIN', 'LOGIN']); + + $this->assertTrue($cap->query('IMAP4rev1')); + $this->assertTrue($cap->query('AUTH', 'PLAIN')); + $this->assertFalse($cap->query('AUTH', 'CRAM-MD5')); + } + + public function testQresyncImpliesCondstore(): void + { + $cap = new ImapCapability(); + $cap->add('QRESYNC'); + + // CONDSTORE is never added as raw data / it is only implied via query(). + $this->assertArrayNotHasKey('CONDSTORE', $cap->toArray()); + $this->assertTrue($cap->query('CONDSTORE')); + } + + public function testQresyncImpliesEnableCapabilityButOnlyWithoutAParameter(): void + { + $cap = new ImapCapability(); + $cap->add('QRESYNC'); + + $this->assertTrue($cap->query('ENABLE')); + } + + public function testCondstoreAloneDoesNotImplyQresync(): void + { + $cap = new ImapCapability(); + $cap->add('CONDSTORE'); + + $this->assertTrue($cap->query('CONDSTORE')); + $this->assertFalse($cap->query('QRESYNC')); + $this->assertFalse($cap->query('ENABLE')); + } + + public function testUtf8OnlyImpliesUtf8Accept(): void + { + $cap = new ImapCapability(); + $cap->add('UTF8', 'ONLY'); + + $this->assertTrue($cap->query('UTF8', 'ONLY')); + $this->assertTrue($cap->query('UTF8', 'ACCEPT')); + } + + public function testUtf8AcceptAloneDoesNotImplyOnly(): void + { + $cap = new ImapCapability(); + $cap->add('UTF8', 'ACCEPT'); + + $this->assertTrue($cap->query('UTF8', 'ACCEPT')); + $this->assertFalse($cap->query('UTF8', 'ONLY')); + } + + public function testEnableRecordsAndQueriesEnabledState(): void + { + $cap = new ImapCapability(); + $cap->add('CONDSTORE'); + + $this->assertFalse($cap->isEnabled('CONDSTORE')); + + $cap->enable('CONDSTORE'); + + $this->assertTrue($cap->isEnabled('CONDSTORE')); + $this->assertSame(['CONDSTORE'], $cap->enabled()); + } + + public function testEnablingQresyncAlsoEnablesCondstore(): void + { + $cap = new ImapCapability(); + $cap->add('QRESYNC'); + + $cap->enable('QRESYNC'); + + $this->assertTrue($cap->isEnabled('QRESYNC')); + $this->assertTrue($cap->isEnabled('CONDSTORE')); + } + + public function testDisablingRemovesFromEnabledState(): void + { + $cap = new ImapCapability(); + $cap->add('CONDSTORE'); + $cap->enable('CONDSTORE'); + $cap->enable('CONDSTORE', false); + + $this->assertFalse($cap->isEnabled('CONDSTORE')); + $this->assertSame([], $cap->enabled()); + } + + public function testEnableIsIdempotent(): void + { + $cap = new ImapCapability(); + $cap->add('CONDSTORE'); + $cap->enable('CONDSTORE'); + $cap->enable('CONDSTORE'); + + $this->assertSame(['CONDSTORE'], $cap->enabled()); + } + + public function testCmdLengthDefaultsToTwoThousand(): void + { + $cap = new ImapCapability(); + $cap->add('IMAP4rev1'); + + $this->assertSame(2000, $cap->cmdLength()); + } + + public function testCmdLengthIsEightThousandWithCondstore(): void + { + $cap = new ImapCapability(); + $cap->add('CONDSTORE'); + + $this->assertSame(8000, $cap->cmdLength()); + } + + public function testCmdLengthIsEightThousandWithQresync(): void + { + $cap = new ImapCapability(); + $cap->add('QRESYNC'); + + $this->assertSame(8000, $cap->cmdLength()); + } +} diff --git a/test/Unit/Src/ImapClientAppendExtensionsTest.php b/test/Unit/Src/ImapClientAppendExtensionsTest.php new file mode 100644 index 00000000..fd8cdab1 --- /dev/null +++ b/test/Unit/Src/ImapClientAppendExtensionsTest.php @@ -0,0 +1,146 @@ +socket( + '* OK [CAPABILITY IMAP4rev1 MULTIAPPEND UIDPLUS] Server ready.', + 'A1 OK [APPENDUID 38505 3955,3956] APPEND completed.', + ); + $client = $this->client($socket); + + $result = $client->append('Drafts', [ + ['data' => 'one', 'flags' => [SystemFlag::Seen]], + ['data' => 'two'], + ]); + + // Both messages in a single APPEND command. + self::assertSame(['A1 APPEND Drafts (\\seen) one two'], $socket->written); + self::assertSame([3955, 3956], $result->toArray()); + } + + public function testWithoutMultiAppendLoopsPerMessage(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 OK [APPENDUID 38505 3955] APPEND completed.', + 'A2 OK [APPENDUID 38505 3956] APPEND completed.', + ); + $client = $this->client($socket); + + $result = $client->append('Drafts', [['data' => 'one'], ['data' => 'two']]); + + self::assertSame(['A1 APPEND Drafts one', 'A2 APPEND Drafts two'], $socket->written); + self::assertSame([3955, 3956], $result->toArray()); + } + + public function testBinaryBodyUsesLiteral8(): void + { + $body = "a\x00b"; // a null byte forces literal8 (~{n}) + + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1 BINARY UIDPLUS] Server ready.'), + InMemoryImapSocket::line('+ go'), + InMemoryImapSocket::line('A1 OK [APPENDUID 1 5] APPEND completed.'), + ); + $client = $this->client($socket); + + $client->append('Drafts', [['data' => $body]]); + + // literal8 marker: ~{3}. (The fake socket rtrims the raw bytes on + // capture, but the announcement carries the ~ and true length.) + self::assertSame('A1 APPEND Drafts ~{3}', $socket->written[0]); + } + + public function testUtf8AcceptWrapsMessage(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev2] Server ready.', + 'A1 OK [APPENDUID 1 5] APPEND completed.', + ); + $client = $this->client($socket); + // Prime the enabled state the way login()'s negotiateRev2 would. + $client->getCapability()->enable('IMAP4REV2'); + + $client->append('Drafts', [['data' => 'hi']]); + + // RFC 6855 §4: UTF8 () wrapper. + self::assertSame('A1 APPEND Drafts (UTF8 (hi))', $socket->written[0]); + } + + public function testCatenateTextAndUrl(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CATENATE UIDPLUS] Server ready.', + 'A1 OK [APPENDUID 1 5] APPEND completed.', + ); + $client = $this->client($socket); + + $client->append('Drafts', [[ + 'catenate' => [ + ['text' => 'Header: v'], + ['url' => '/Sent;UID=20/;section=1.2'], + ], + ]]); + + self::assertSame( + ['A1 APPEND Drafts CATENATE (TEXT "Header: v" URL /Sent;UID=20/;section=1.2)'], + $socket->written, + ); + } + + public function testCatenateUrlWithoutCapabilityThrows(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->append('Drafts', [['catenate' => [['url' => '/Sent;UID=20']]]]); + } +} diff --git a/test/Unit/Src/ImapClientBadCharsetTest.php b/test/Unit/Src/ImapClientBadCharsetTest.php new file mode 100644 index 00000000..ffce6315 --- /dev/null +++ b/test/Unit/Src/ImapClientBadCharsetTest.php @@ -0,0 +1,98 @@ +client($socket); + + $result = $client->search('INBOX', (new ImapSearchQuery())->text($utf8)); + + // First send announced a 6-byte UTF-8 literal. + self::assertSame('A1 UID SEARCH CHARSET UTF-8 BODY {' . strlen($utf8) . '}', $socket->written[0]); + // Retry announced a 5-byte ISO-8859-1 literal with the re-encoded value. + self::assertSame('A2 UID SEARCH CHARSET ISO-8859-1 BODY {' . strlen($iso) . '}', $socket->written[3]); + self::assertSame($iso, $socket->written[4]); + self::assertSame([4, 8], $result->match->toArray()); + } + + public function testSearchDoesNotRetryOnNonBadcharsetRejection(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1] Server ready.'), + InMemoryImapSocket::line('+ send literal'), + InMemoryImapSocket::line('A1 NO Some other error.'), + ); + $client = $this->client($socket); + + $this->expectException(ServerResponseException::class); + + $client->search('INBOX', (new ImapSearchQuery())->text('naïve')); + } + + public function testSearchDoesNotRetryWhenNoAlternativeCharsetOffered(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1] Server ready.'), + InMemoryImapSocket::line('+ send literal'), + // BADCHARSET whose only offered charset is the one we already sent. + InMemoryImapSocket::line('A1 NO [BADCHARSET (UTF-8)] Unsupported.'), + ); + $client = $this->client($socket); + + $this->expectException(ServerResponseException::class); + + $client->search('INBOX', (new ImapSearchQuery())->text('naïve')); + } +} diff --git a/test/Unit/Src/ImapClientCacheIntegrationTest.php b/test/Unit/Src/ImapClientCacheIntegrationTest.php new file mode 100644 index 00000000..e3e653a4 --- /dev/null +++ b/test/Unit/Src/ImapClientCacheIntegrationTest.php @@ -0,0 +1,261 @@ +config(), null, null, $socket, $cache); + } + + private function cache(ArrayCache $backend): ImapCacheStore + { + return new ImapCacheStore($backend, 'imap.example.test', 993, 'alice'); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testFetchWritesThroughAndSecondFetchServesFromCache(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + // First fetch of UIDs 5,7 hits the wire. + '* 1 FETCH (UID 5 RFC822.SIZE 100 ENVELOPE (NIL "hi" NIL NIL NIL NIL NIL NIL NIL NIL))', + '* 2 FETCH (UID 7 RFC822.SIZE 200 ENVELOPE (NIL "yo" NIL NIL NIL NIL NIL NIL NIL NIL))', + 'A2 OK FETCH completed.', + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + + $query = (new ImapFetchQuery())->envelope()->size(); + $first = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5, 7], false), $query)); + + self::assertCount(2, $first); + self::assertSame(100, $first[5]->getSize()); + self::assertSame(200, $first[7]->getSize()); + + // Second identical fetch: no new wire command (served from cache). + $writtenAfterFirst = $socket->written; + $second = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5, 7], false), $query)); + + self::assertSame($writtenAfterFirst, $socket->written, 'no extra FETCH command was sent'); + self::assertCount(2, $second); + self::assertSame(100, $second[5]->getSize()); + self::assertSame(200, $second[7]->getSize()); + } + + public function testPartialCacheHitFetchesOnlyMissingUids(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 RFC822.SIZE 100)', + 'A2 OK FETCH completed.', + // Second fetch: UID 5 cached, UID 9 fetched. + '* 2 FETCH (UID 9 RFC822.SIZE 300)', + 'A3 OK FETCH completed.', + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $query = (new ImapFetchQuery())->size(); + + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + $result = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5, 9], false), $query)); + + // Only the missing UID 9 was fetched the second time. + self::assertSame('A3 UID FETCH 9 (RFC822.SIZE)', $socket->written[2]); + self::assertSame(100, $result[5]->getSize()); + self::assertSame(300, $result[9]->getSize()); + } + + public function testStaleFlagsRefetchedWhenModseqAdvances(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* OK [UIDVALIDITY 42] .', + '* OK [HIGHESTMODSEQ 100] .', + 'A1 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 FLAGS (\\Seen) MODSEQ (100))', + 'A2 OK FETCH completed.', + // Reopen with a higher HIGHESTMODSEQ: cached flags are stale. + '* OK [UIDVALIDITY 42] .', + '* OK [HIGHESTMODSEQ 105] .', + 'A3 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 FLAGS (\\Seen \\Answered) MODSEQ (104))', + 'A4 OK FETCH completed.', + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $query = (new ImapFetchQuery())->flags()->modseq(); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $result = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + // written: [0]=A1 SELECT, [1]=A2 FETCH, [2]=A3 SELECT, [3]=A4 FETCH. + // A second FETCH was sent (A4), because the cached flags were stale. + self::assertSame('A4 UID FETCH 5 (FLAGS MODSEQ)', $socket->written[3]); + self::assertContains('\\Answered', $result[5]->getFlags()); + } + + public function testStoreInvalidatesCachedEntry(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 RFC822.SIZE 100)', + 'A2 OK FETCH completed.', + 'A3 OK STORE completed.', + // After the store, the cache entry was dropped, so refetch. + '* 1 FETCH (UID 5 RFC822.SIZE 100)', + 'A4 OK FETCH completed.', + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $query = (new ImapFetchQuery())->size(); + + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + $client->store('INBOX', ['ids' => new ImapIdSet([5], false), 'add' => [SystemFlag::Seen]]); + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + // The post-store fetch went to the wire (A4), not the cache. + self::assertSame('A4 UID FETCH 5 (RFC822.SIZE)', $socket->written[3]); + } + + public function testNoCacheBehavesAsBefore(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 RFC822.SIZE 100)', + 'A2 OK FETCH completed.', + '* 1 FETCH (UID 5 RFC822.SIZE 100)', + 'A3 OK FETCH completed.', + ); + $client = $this->client($socket, null); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $query = (new ImapFetchQuery())->size(); + + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + // Without a cache, both fetches hit the wire. + self::assertSame('A2 UID FETCH 5 (RFC822.SIZE)', $socket->written[1]); + self::assertSame('A3 UID FETCH 5 (RFC822.SIZE)', $socket->written[2]); + } + + public function testStreamContentQueryBypassesCache(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + '* 1 FETCH (UID 5 BODY[] {5}', + "hello)", + 'A2 OK FETCH completed.', + '* 1 FETCH (UID 5 BODY[] {5}', + "hello)", + 'A3 OK FETCH completed.', + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $query = (new ImapFetchQuery())->fullMsg(); + + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + // Body content is never cached: both fetches hit the wire. + self::assertSame('A3 UID FETCH 5 (BODY.PEEK[])', $socket->written[2]); + } + + public function testHeaderFieldGroupsAreCached(): void + { + $headerText = "From: a@b\r\nSubject: hi\r\n"; + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1] Server ready.'), + InMemoryImapSocket::line('* OK [UIDVALIDITY 42] .'), + InMemoryImapSocket::line('A1 OK [READ-WRITE] SELECT completed.'), + InMemoryImapSocket::line('* 1 FETCH (UID 5 BODY[HEADER.FIELDS (FROM SUBJECT)] {' . strlen($headerText) . '}'), + $headerText . ")\r\n", + InMemoryImapSocket::line('A2 OK FETCH completed.'), + ); + $cache = $this->cache(new ArrayCache()); + $client = $this->client($socket, $cache); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $query = (new ImapFetchQuery())->headers('std', ['From', 'Subject']); + + // First fetch populates the cache. + $first = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + self::assertSame('a@b', $first[5]->getHeaders('std')->get('From')->value()); + + $writtenAfterFirst = $socket->written; + + // Second fetch is served from cache: no new FETCH command and the + // reconstructed header group parses back to the same values. + $second = iterator_to_array($client->fetch('INBOX', new ImapIdSet([5], false), $query)); + + self::assertSame($writtenAfterFirst, $socket->written, 'no extra FETCH command was sent'); + self::assertSame('a@b', $second[5]->getHeaders('std')->get('From')->value()); + self::assertSame('hi', $second[5]->getHeaders('std')->get('Subject')->value()); + } +} diff --git a/test/Unit/Src/ImapClientDeferredGapsTest.php b/test/Unit/Src/ImapClientDeferredGapsTest.php new file mode 100644 index 00000000..e7ef1fb4 --- /dev/null +++ b/test/Unit/Src/ImapClientDeferredGapsTest.php @@ -0,0 +1,210 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testExpungeReportsVanishedUidsUnderQresync(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC UIDPLUS] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + // A QRESYNC connection answers EXPUNGE with VANISHED (UIDs). + '* VANISHED 405,410:412', + 'A2 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $result = $client->expunge('INBOX', ['list' => true]); + + // UIDs, not sequence numbers. + self::assertSame([405, 410, 411, 412], $result->toArray()); + self::assertFalse($result->isSequence()); + } + + public function testExpungeStillReadsPlainExpungeWhenNoVanished(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* 3 EXPUNGE', + '* 4 EXPUNGE', + 'A1 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $result = $client->expunge('INBOX', ['list' => true]); + + self::assertSame([3, 4], $result->toArray()); + self::assertTrue($result->isSequence()); + } + + public function testDeleteMailboxEmptiesAndRetriesOnRejection(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 NO Mailbox has messages.', // first DELETE rejected + 'A2 OK [READ-WRITE] SELECT completed.', // openMailbox + 'A3 OK STORE completed.', // flag \Deleted + 'A4 OK EXPUNGE completed.', // UID EXPUNGE 1:* + 'A5 OK CLOSE completed.', // close + 'A6 OK DELETE completed.', // retried DELETE + ); + $client = $this->client($socket); + + $client->deleteMailbox('Old/Stuff'); + + self::assertSame( + [ + 'A1 DELETE Old/Stuff', + 'A2 SELECT Old/Stuff', + 'A3 UID STORE 1:* +FLAGS.SILENT (\\deleted)', + 'A4 UID EXPUNGE 1:*', + 'A5 CLOSE', + 'A6 DELETE Old/Stuff', + ], + $socket->written, + ); + } + + public function testDeleteMailboxSucceedsWithoutRetryNormally(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK DELETE completed.', + ); + $client = $this->client($socket); + + $client->deleteMailbox('Old/Stuff'); + + self::assertSame(['A1 DELETE Old/Stuff'], $socket->written); + } + + public function testListMailboxesChildrenAndSpecialUseReturnOptions(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\HasChildren \\Sent) "/" "Sent"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All, [ + 'children' => true, + 'special_use' => true, + ]); + + self::assertSame( + ['A1 LIST "" % RETURN (CHILDREN SPECIAL-USE)'], + $socket->written, + ); + self::assertContains('\\sent', $result['Sent']['attributes']); + } + + public function testListMailboxesRemoteAndRecursiveMatchSelectOptions(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\Subscribed) "/" "INBOX"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $client->listMailboxes('%', MailboxListMode::Subscribed, [ + 'remote' => true, + 'recursivematch' => true, + ]); + + self::assertSame( + ['A1 LIST (SUBSCRIBED REMOTE RECURSIVEMATCH) "" % RETURN (SUBSCRIBED)'], + $socket->written, + ); + } + + public function testListMailboxesWithStatusReturnAndParsing(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED LIST-STATUS] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + '* STATUS "INBOX" (MESSAGES 42 UNSEEN 3)', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All, [ + 'status' => StatusFlag::Messages->value | StatusFlag::Unseen->value, + ]); + + self::assertSame( + ['A1 LIST "" % RETURN (STATUS (MESSAGES UNSEEN))'], + $socket->written, + ); + self::assertSame(42, $result['INBOX']['status']['messages']); + self::assertSame(3, $result['INBOX']['status']['unseen']); + } + + public function testListStatusOptionIgnoredWithoutCapability(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All, [ + 'status' => StatusFlag::Messages->value, + ]); + + // No RETURN (STATUS ...) since the server lacks LIST-STATUS. + self::assertSame(['A1 LIST "" %'], $socket->written); + self::assertArrayNotHasKey('status', $result['INBOX']); + } +} diff --git a/test/Unit/Src/ImapClientExtensionsTest.php b/test/Unit/Src/ImapClientExtensionsTest.php new file mode 100644 index 00000000..40982420 --- /dev/null +++ b/test/Unit/Src/ImapClientExtensionsTest.php @@ -0,0 +1,281 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testAclNormalizesVirtualRights(): void + { + // 'c' expands to k,x and drops the virtual letter. + $acl = new ImapAcl('lrswipc'); + + self::assertTrue($acl->has(AclRight::CreateMbox)); + self::assertTrue($acl->has(AclRight::DeleteMbox)); + self::assertFalse($acl->has('c')); + self::assertStringNotContainsString('c', (string) $acl); + } + + public function testAclHasWithEnumAndString(): void + { + $acl = new ImapAcl('lrs'); + + self::assertTrue($acl->has(AclRight::Lookup)); + self::assertTrue($acl->has('r')); + self::assertFalse($acl->has(AclRight::Administer)); + } + + public function testAclGetStringRfc2086CollapsesRights(): void + { + $acl = new ImapAcl('lrsw'); + $acl2 = new ImapAcl('kxte'); + + self::assertSame('lrsw', $acl->getString()); + // k+x collapse to c, t+e (with x already consumed) to d. + self::assertStringContainsString('c', $acl2->getString(rfc2086: true)); + } + + public function testAclDiff(): void + { + $acl = new ImapAcl('lrs'); + $diff = $acl->diff('lrw'); + + self::assertSame('w', $diff['added']); + self::assertSame('s', $diff['removed']); + } + + public function testGetAclParsesResponse(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ACL] Server ready.', + '* ACL INBOX fred rwipslxeta -anyone rwip', + 'A1 OK GETACL completed.', + ); + $client = $this->client($socket); + + $acl = $client->getACL('INBOX'); + + self::assertSame(['A1 GETACL INBOX'], $socket->written); + self::assertArrayHasKey('fred', $acl); + self::assertArrayHasKey('-anyone', $acl); + self::assertTrue($acl['fred']->has(AclRight::Administer)); + } + + public function testSetAcl(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ACL] Server ready.', + 'A1 OK SETACL completed.', + ); + $client = $this->client($socket); + + $client->setACL('INBOX', 'fred', ['rights' => 'lrsw']); + + self::assertSame(['A1 SETACL INBOX fred lrsw'], $socket->written); + } + + public function testDeleteAcl(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ACL] Server ready.', + 'A1 OK DELETEACL completed.', + ); + $client = $this->client($socket); + + $client->deleteACL('INBOX', 'fred'); + + self::assertSame(['A1 DELETEACL INBOX fred'], $socket->written); + } + + public function testListAclRights(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ACL] Server ready.', + '* LISTRIGHTS INBOX fred la r w i p', + 'A1 OK LISTRIGHTS completed.', + ); + $client = $this->client($socket); + + $rights = $client->listACLRights('INBOX', 'fred'); + + self::assertSame(['A1 LISTRIGHTS INBOX fred'], $socket->written); + self::assertSame(['l', 'a'], $rights->required); + self::assertSame(['r', 'w', 'i', 'p'], $rights->optional); + } + + public function testGetMyAclRights(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ACL] Server ready.', + '* MYRIGHTS INBOX lrswipkxtea', + 'A1 OK MYRIGHTS completed.', + ); + $client = $this->client($socket); + + $acl = $client->getMyACLRights('INBOX'); + + self::assertSame(['A1 MYRIGHTS INBOX'], $socket->written); + self::assertTrue($acl->has(AclRight::Administer)); + } + + public function testAclRequiresCapability(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->getACL('INBOX'); + } + + public function testGetQuotaParsesResponse(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 QUOTA] Server ready.', + '* QUOTA "" (STORAGE 10 512 MESSAGE 3 100)', + 'A1 OK GETQUOTA completed.', + ); + $client = $this->client($socket); + + $quota = $client->getQuota(''); + + self::assertSame(['A1 GETQUOTA ""'], $socket->written); + self::assertSame(10, $quota['']['storage']['usage']); + self::assertSame(512, $quota['']['storage']['limit']); + self::assertSame(3, $quota['']['message']['usage']); + } + + public function testSetQuota(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 QUOTA] Server ready.', + 'A1 OK SETQUOTA completed.', + ); + $client = $this->client($socket); + + $client->setQuota('', ['storage' => 512]); + + self::assertSame(['A1 SETQUOTA "" (STORAGE 512)'], $socket->written); + } + + public function testGetQuotaRoot(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 QUOTA] Server ready.', + '* QUOTAROOT INBOX ""', + '* QUOTA "" (STORAGE 10 512)', + 'A1 OK GETQUOTAROOT completed.', + ); + $client = $this->client($socket); + + $quota = $client->getQuotaRoot('INBOX'); + + self::assertSame(['A1 GETQUOTAROOT INBOX'], $socket->written); + self::assertSame(512, $quota['']['storage']['limit']); + } + + public function testGetMetadataWithOptions(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 METADATA] Server ready.', + '* METADATA "INBOX" ("/shared/comment" "My comment")', + 'A1 OK GETMETADATA completed.', + ); + $client = $this->client($socket); + + $meta = $client->getMetadata('INBOX', ['/shared/comment'], ['maxsize' => 1024]); + + self::assertSame( + ['A1 GETMETADATA INBOX (MAXSIZE 1024) (/shared/comment)'], + $socket->written, + ); + self::assertSame('My comment', $meta['INBOX']['/shared/comment']); + } + + public function testSetMetadata(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 METADATA] Server ready.', + 'A1 OK SETMETADATA completed.', + ); + $client = $this->client($socket); + + $client->setMetadata('INBOX', ['/shared/comment' => 'hi', '/shared/old' => null]); + + self::assertSame( + ['A1 SETMETADATA INBOX (/shared/comment hi /shared/old NIL)'], + $socket->written, + ); + } + + public function testMetadataServerScopeRequiresMetadataServerCapability(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 METADATA-SERVER] Server ready.', + '* METADATA "" ("/shared/vendor" "acme")', + 'A1 OK GETMETADATA completed.', + ); + $client = $this->client($socket); + + $meta = $client->getMetadata('', ['/shared/vendor']); + + self::assertSame('acme', $meta['']['/shared/vendor']); + } + + public function testMetadataRequiresCapability(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->getMetadata('INBOX', ['/shared/comment']); + } +} diff --git a/test/Unit/Src/ImapClientMailboxManagementTest.php b/test/Unit/Src/ImapClientMailboxManagementTest.php new file mode 100644 index 00000000..b508d22b --- /dev/null +++ b/test/Unit/Src/ImapClientMailboxManagementTest.php @@ -0,0 +1,369 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testCreateMailboxSendsCreate(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK CREATE completed.', + ); + $client = $this->client($socket); + + $client->createMailbox('Work/Reports'); + + self::assertSame(['A1 CREATE Work/Reports'], $socket->written); + } + + public function testCreateMailboxWithSpecialUse(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK CREATE completed.', + ); + $client = $this->client($socket); + + $client->createMailbox('Archive', [SpecialUse::Archive]); + + self::assertSame(['A1 CREATE Archive (USE (\\Archive))'], $socket->written); + } + + public function testCreateMailboxWrapsRejection(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 NO Mailbox already exists.', + ); + $client = $this->client($socket); + + $this->expectException(ServerResponseException::class); + + $client->createMailbox('INBOX'); + } + + public function testDeleteMailboxSendsDelete(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK DELETE completed.', + ); + $client = $this->client($socket); + + $client->deleteMailbox('Old/Stuff'); + + self::assertSame(['A1 DELETE Old/Stuff'], $socket->written); + } + + public function testDeleteMailboxClosesSelectedMailboxFirst(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK [READ-WRITE] SELECT completed.', + 'A2 OK CLOSE completed.', + 'A3 OK DELETE completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('Trash', OpenMode::ReadWrite); + $client->deleteMailbox('Trash'); + + self::assertSame(['A1 SELECT Trash', 'A2 CLOSE', 'A3 DELETE Trash'], $socket->written); + self::assertNull($client->selectedMailbox()); + } + + public function testRenameMailboxSendsRename(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK RENAME completed.', + ); + $client = $this->client($socket); + + $client->renameMailbox('Old', 'New'); + + self::assertSame(['A1 RENAME Old New'], $socket->written); + } + + public function testRenameMailboxClosesSelectedSourceFirst(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK [READ-WRITE] SELECT completed.', + 'A2 OK CLOSE completed.', + 'A3 OK RENAME completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('Drafts', OpenMode::ReadWrite); + $client->renameMailbox('Drafts', 'Old Drafts'); + + self::assertSame( + ['A1 SELECT Drafts', 'A2 CLOSE', 'A3 RENAME Drafts "Old Drafts"'], + $socket->written, + ); + } + + public function testSubscribeMailboxSendsSubscribe(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK SUBSCRIBE completed.', + ); + $client = $this->client($socket); + + $client->subscribeMailbox('Lists/Horde'); + + self::assertSame(['A1 SUBSCRIBE Lists/Horde'], $socket->written); + } + + public function testSubscribeMailboxSendsUnsubscribe(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK UNSUBSCRIBE completed.', + ); + $client = $this->client($socket); + + $client->subscribeMailbox('Lists/Horde', false); + + self::assertSame(['A1 UNSUBSCRIBE Lists/Horde'], $socket->written); + } + + public function testListMailboxesBaseListParsesEntries(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + '* LIST (\\HasChildren) "/" "Work"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All); + + self::assertSame(['A1 LIST "" %'], $socket->written); + self::assertArrayHasKey('INBOX', $result); + self::assertArrayHasKey('Work', $result); + self::assertSame('/', $result['INBOX']['delimiter']); + self::assertSame('INBOX', $result['INBOX']['mailbox']); + self::assertSame(['\\hasnochildren'], $result['INBOX']['attributes']); + } + + public function testListMailboxesFlatReturnsNames(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + '* LIST (\\HasChildren) "/" "Work"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All, ['flat' => true]); + + self::assertSame(['INBOX', 'Work'], $result); + } + + public function testListMailboxesSubscribedUsesLsubWithoutListExtended(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* LSUB () "/" "INBOX"', + '* LSUB () "/" "Work"', + 'A1 OK LSUB completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::Subscribed); + + self::assertSame(['A1 LSUB "" %'], $socket->written); + self::assertArrayHasKey('Work', $result); + } + + public function testListMailboxesSubscribedUsesListExtendedWhenAvailable(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\Subscribed) "/" "INBOX"', + '* LIST (\\Subscribed) "/" "Work"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::Subscribed); + + self::assertSame(['A1 LIST (SUBSCRIBED) "" % RETURN (SUBSCRIBED)'], $socket->written); + self::assertArrayHasKey('Work', $result); + } + + public function testListMailboxesUnsubscribedFiltersSubscribedEntries(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\Subscribed) "/" "INBOX"', + '* LIST () "/" "Junk"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::Unsubscribed); + + self::assertSame(['A1 LIST "" % RETURN (SUBSCRIBED)'], $socket->written); + self::assertArrayNotHasKey('INBOX', $result); + self::assertArrayHasKey('Junk', $result); + } + + public function testListMailboxesInfersAttributesUnderListExtended(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\NoInferiors) "/" "Feeds"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxes('%', MailboxListMode::All, ['attributes' => true]); + + self::assertContains('\\hasnochildren', $result['Feeds']['attributes']); + self::assertContains('\\noinferiors', $result['Feeds']['attributes']); + } + + public function testGetNamespacesParsesPersonalOtherAndShared(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 NAMESPACE] Server ready.', + '* NAMESPACE (("" "/")) (("Other Users/" "/")) (("Public Folders/" "/"))', + 'A1 OK NAMESPACE completed.', + ); + $client = $this->client($socket); + + $namespaces = $client->getNamespaces(); + + self::assertSame(['A1 NAMESPACE'], $socket->written); + self::assertCount(3, $namespaces); + + $personal = $namespaces->get(''); + self::assertNotNull($personal); + self::assertSame(NamespaceType::Personal, $personal->type); + self::assertSame('/', $personal->delimiter); + + $other = $namespaces->get('Other Users/'); + self::assertNotNull($other); + self::assertSame(NamespaceType::Other, $other->type); + + $shared = $namespaces->get('Public Folders/'); + self::assertNotNull($shared); + self::assertSame(NamespaceType::Shared, $shared->type); + } + + public function testGetNamespacesHandlesNilGroups(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 NAMESPACE] Server ready.', + '* NAMESPACE (("" "/")) NIL NIL', + 'A1 OK NAMESPACE completed.', + ); + $client = $this->client($socket); + + $namespaces = $client->getNamespaces(); + + self::assertCount(1, $namespaces); + self::assertNotNull($namespaces->get('')); + } + + public function testGetNamespacesReturnsEmptyListWithoutCapability(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $namespaces = $client->getNamespaces(); + + self::assertCount(0, $namespaces); + self::assertSame([], $socket->written); + } + + public function testNamespaceBaseStripsTrailingDelimiter(): void + { + $ns = new ImapNamespace('Other Users/', NamespaceType::Other, '/'); + + self::assertSame('Other Users', $ns->base()); + } + + public function testNamespaceStripNamespaceRemovesPrefix(): void + { + $ns = new ImapNamespace('Other Users/', NamespaceType::Other, '/'); + + self::assertSame('bob/INBOX', $ns->stripNamespace('Other Users/bob/INBOX')); + self::assertSame('INBOX', $ns->stripNamespace('INBOX')); + } + + public function testNamespaceListGetForMailboxMatchesPrefix(): void + { + $list = new ImapNamespaceList([ + new ImapNamespace('', NamespaceType::Personal, '/'), + new ImapNamespace('Other Users/', NamespaceType::Other, '/'), + ]); + + $match = $list->getForMailbox('Other Users/bob/INBOX'); + self::assertNotNull($match); + self::assertSame('Other Users/', $match->name); + + $fallback = $list->getForMailbox('INBOX'); + self::assertNotNull($fallback); + self::assertSame('', $fallback->name); + } +} diff --git a/test/Unit/Src/ImapClientMessageOpsTest.php b/test/Unit/Src/ImapClientMessageOpsTest.php new file mode 100644 index 00000000..d30cf8bc --- /dev/null +++ b/test/Unit/Src/ImapClientMessageOpsTest.php @@ -0,0 +1,347 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testStoreAddFlagsSilentByDefault(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK STORE completed.', + ); + $client = $this->client($socket); + + $result = $client->store('INBOX', [ + 'ids' => new ImapIdSet([1, 2, 3], false), + 'add' => [SystemFlag::Seen], + ]); + + self::assertSame(['A1 UID STORE 1:3 +FLAGS.SILENT (\\seen)'], $socket->written); + self::assertTrue($result->isEmpty()); + } + + public function testStoreNonSilentAndSequenceMode(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK STORE completed.', + ); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new ImapIdSet([5], true), + 'remove' => ['\\Flagged'], + 'silent' => false, + ]); + + self::assertSame(['A1 STORE 5 -FLAGS (\\Flagged)'], $socket->written); + } + + public function testStoreReplaceFlags(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK STORE completed.', + ); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new ImapIdSet([1], false), + 'replace' => [SystemFlag::Seen, SystemFlag::Flagged], + ]); + + self::assertSame(['A1 UID STORE 1 FLAGS.SILENT (\\seen \\flagged)'], $socket->written); + } + + public function testStoreUnchangedSinceReturnsModified(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + 'A1 OK [MODIFIED 7,9] Conditional STORE failed for some.', + ); + $client = $this->client($socket); + + $result = $client->store('INBOX', [ + 'ids' => new ImapIdSet([5, 7, 9], false), + 'add' => [SystemFlag::Seen], + 'unchangedsince' => 320, + ]); + + self::assertSame( + ['A1 UID STORE 5,7,9 (UNCHANGEDSINCE 320) +FLAGS.SILENT (\\seen)'], + $socket->written, + ); + self::assertSame([7, 9], $result->toArray()); + } + + public function testStoreRecentFlagIsDropped(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK STORE completed.', + ); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new ImapIdSet([1], false), + 'add' => [SystemFlag::Seen, SystemFlag::Recent], + ]); + + self::assertSame(['A1 UID STORE 1 +FLAGS.SILENT (\\seen)'], $socket->written); + } + + public function testExpungePlainWithoutUidplus(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* 3 EXPUNGE', + '* 4 EXPUNGE', + 'A1 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $result = $client->expunge('INBOX', ['list' => true]); + + self::assertSame(['A1 EXPUNGE'], $socket->written); + self::assertSame([3, 4], $result->toArray()); + } + + public function testExpungeUidExpungeWithUidplus(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + '* 1 EXPUNGE', + 'A1 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $client->expunge('INBOX', ['ids' => new ImapIdSet([100, 101], false), 'list' => true]); + + self::assertSame(['A1 UID EXPUNGE 100:101'], $socket->written); + } + + public function testExpungeDeleteFlagsFirst(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 OK STORE completed.', + 'A2 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $client->expunge('INBOX', ['ids' => new ImapIdSet([5], false), 'delete' => true]); + + self::assertSame( + ['A1 UID STORE 5 +FLAGS.SILENT (\\deleted)', 'A2 UID EXPUNGE 5'], + $socket->written, + ); + } + + public function testExpungeWithoutListReturnsEmpty(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* 2 EXPUNGE', + 'A1 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $result = $client->expunge('INBOX'); + + self::assertTrue($result->isEmpty()); + } + + public function testCopyReturnsCopyUidDestinationSet(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 OK [COPYUID 38505 304,319 3956,3957] COPY completed.', + ); + $client = $this->client($socket); + + $result = $client->copy('Archive', 'Archive', [ + 'ids' => new ImapIdSet([304, 319], false), + ]); + + self::assertSame(['A1 UID COPY 304,319 Archive'], $socket->written); + self::assertSame([3956, 3957], $result->toArray()); + } + + public function testMoveUsesServerMoveWhenAvailable(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 MOVE UIDPLUS] Server ready.', + 'A1 OK [COPYUID 1 7 42] MOVE completed.', + ); + $client = $this->client($socket); + + $result = $client->move('Trash', 'Trash', [ + 'ids' => new ImapIdSet([7], false), + ]); + + self::assertSame(['A1 UID MOVE 7 Trash'], $socket->written); + self::assertSame([42], $result->toArray()); + } + + public function testMoveFallsBackToCopyPlusExpunge(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 OK [COPYUID 1 7 42] COPY completed.', + 'A2 OK STORE completed.', + 'A3 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + // Establish a selected mailbox so the client-side expunge targets it. + // (openMailbox not needed; copyOrMove reads selectedMailbox, which + // is null here and expunge does not use the name on the wire.) + + $client->move('Trash', 'Trash', [ + 'ids' => new ImapIdSet([7], false), + ]); + + self::assertSame( + [ + 'A1 UID COPY 7 Trash', + 'A2 UID STORE 7 +FLAGS.SILENT (\\deleted)', + 'A3 UID EXPUNGE 7', + ], + $socket->written, + ); + } + + public function testCopyEmptyIdsSendsNothing(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $result = $client->copy('Archive', 'Archive', ['ids' => new ImapIdSet([], false)]); + + self::assertSame([], $socket->written); + self::assertTrue($result->isEmpty()); + } + + public function testCopyCreatesDestinationOnTryCreate(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 NO [TRYCREATE] Mailbox does not exist.', + 'A2 OK CREATE completed.', + 'A3 OK [COPYUID 1 7 42] COPY completed.', + ); + $client = $this->client($socket); + + $result = $client->copy('Archive', 'Archive', [ + 'ids' => new ImapIdSet([7], false), + 'create' => true, + ]); + + self::assertSame( + ['A1 UID COPY 7 Archive', 'A2 CREATE Archive', 'A3 UID COPY 7 Archive'], + $socket->written, + ); + self::assertSame([42], $result->toArray()); + } + + public function testAppendInlineBodyReturnsAppendUid(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.', + 'A1 OK [APPENDUID 38505 3955] APPEND completed.', + ); + $client = $this->client($socket); + + // A short body with no CRLF encodes as a bare atom inline. + $result = $client->append('Drafts', [['data' => 'hello']]); + + self::assertSame(['A1 APPEND Drafts hello'], $socket->written); + self::assertSame([3955], $result->toArray()); + } + + public function testAppendWithFlagsAndInternalDateLiteralBody(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [CAPABILITY IMAP4rev1 UIDPLUS] Server ready.'), + InMemoryImapSocket::line('+ Ready for literal data.'), + InMemoryImapSocket::line('A1 OK [APPENDUID 38505 3955] APPEND completed.'), + ); + $client = $this->client($socket); + + $body = "Subject: hi\r\n\r\nbody line\r\n"; + $result = $client->append('Drafts', [[ + 'data' => $body, + 'flags' => [SystemFlag::Seen], + 'internaldate' => new DateTimeImmutable('2024-02-01T12:00:00+00:00'), + ]]); + + self::assertSame( + 'A1 APPEND Drafts (\\seen) "1-Feb-2024 12:00:00 +0000" {' . strlen($body) . '}', + $socket->written[0], + ); + // The fake socket rtrims trailing CRLF when capturing a write; the + // literal-size announcement above still reflects the true length. + self::assertSame(rtrim($body, "\r\n"), $socket->written[1]); + self::assertSame([3955], $result->toArray()); + } + + public function testAppendWrapsServerRejection(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 NO Over quota.', + ); + $client = $this->client($socket); + + $this->expectException(ServerResponseException::class); + + $client->append('Drafts', [['data' => 'hello']]); + } +} diff --git a/test/Unit/Src/ImapClientPipelineTest.php b/test/Unit/Src/ImapClientPipelineTest.php new file mode 100644 index 00000000..c5fa2ab1 --- /dev/null +++ b/test/Unit/Src/ImapClientPipelineTest.php @@ -0,0 +1,162 @@ +sendPipeline([$c1, $c2]); + + // Both commands written before any response was read. + self::assertSame(['A1 STATUS INBOX (MESSAGES)', 'A2 STATUS Sent (MESSAGES)'], $socket->written); + self::assertArrayHasKey('A1', $results); + self::assertArrayHasKey('A2', $results); + // Untagged routed to the right command by completion order. + self::assertCount(1, $results['A1']->untagged); + self::assertCount(1, $results['A2']->untagged); + } + + public function testSendPipelineRefusesLiteralCommand(): void + { + $interaction = new ImapInteraction(new ImapConnection($this->socket())); + + // A CRLF body forces a synchronizing literal, which cannot pipeline. + $command = new ImapCommand('A1', 'APPEND', [new ImapWireString("a\r\nb")]); + + $this->expectException(ImapProtocolException::class); + + $interaction->sendPipeline([$command]); + } + + public function testStatusMultiplePipelinesAllMailboxes(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* STATUS INBOX (MESSAGES 10 UNSEEN 2)', + 'A1 OK STATUS completed.', + '* STATUS Sent (MESSAGES 5 UNSEEN 0)', + 'A2 OK STATUS completed.', + ); + $client = $this->client($socket); + + $result = $client->statusMultiple(['INBOX', 'Sent'], StatusFlag::Messages->value | StatusFlag::Unseen->value); + + self::assertSame( + ['A1 STATUS INBOX (MESSAGES UNSEEN)', 'A2 STATUS Sent (MESSAGES UNSEEN)'], + $socket->written, + ); + self::assertSame(10, $result['INBOX']->messages); + self::assertSame(5, $result['Sent']->messages); + } + + public function testStatusMultipleSkipsRejectedMailbox(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* STATUS INBOX (MESSAGES 10)', + 'A1 OK STATUS completed.', + 'A2 NO Mailbox does not exist.', + ); + $client = $this->client($socket); + + $result = $client->statusMultiple(['INBOX', 'Bogus'], StatusFlag::Messages->value); + + self::assertArrayHasKey('INBOX', $result); + self::assertArrayNotHasKey('Bogus', $result); + } + + public function testListMailboxesMultiPipelinesPatterns(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + 'A1 OK LIST completed.', + '* LIST (\\HasChildren) "/" "Work"', + 'A2 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxesMulti(['INBOX', 'Work%'], MailboxListMode::All); + + self::assertSame( + ['A1 LIST "" INBOX', 'A2 LIST "" Work%'], + $socket->written, + ); + self::assertArrayHasKey('INBOX', $result); + self::assertArrayHasKey('Work', $result); + } + + public function testListMailboxesMultiSinglePatternDelegates(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 LIST-EXTENDED] Server ready.', + '* LIST (\\HasNoChildren) "/" "INBOX"', + 'A1 OK LIST completed.', + ); + $client = $this->client($socket); + + $result = $client->listMailboxesMulti(['INBOX'], MailboxListMode::All); + + self::assertSame(['A1 LIST "" INBOX'], $socket->written); + self::assertArrayHasKey('INBOX', $result); + } +} diff --git a/test/Unit/Src/ImapClientSearchTest.php b/test/Unit/Src/ImapClientSearchTest.php new file mode 100644 index 00000000..7e4ec83f --- /dev/null +++ b/test/Unit/Src/ImapClientSearchTest.php @@ -0,0 +1,248 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + /** + * Tokenize/parse a single wire line into an ImapResponse for the + * parser-only tests. + */ + private function response(string $line): \Horde\Imap\Client\ImapResponse + { + $tokens = (new ImapTokenizer($this->socket($line)))->readLine(); + + return ImapResponseParser::parse($tokens); + } + + public function testParseClassicSearchDerivesCountMinMax(): void + { + $result = ImapSearchParser::parse([$this->response('* SEARCH 3 7 11')], false); + + self::assertSame([3, 7, 11], $result->match->toArray()); + self::assertSame(3, $result->count); + self::assertSame(3, $result->min); + self::assertSame(11, $result->max); + } + + public function testParseClassicSearchEmpty(): void + { + $result = ImapSearchParser::parse([$this->response('* SEARCH')], false); + + self::assertSame([], $result->match->toArray()); + self::assertSame(0, $result->count); + self::assertNull($result->min); + self::assertNull($result->max); + } + + public function testParseClassicSearchAcrossMultipleLines(): void + { + $result = ImapSearchParser::parse( + [$this->response('* SEARCH 1 2'), $this->response('* SEARCH 5 6')], + false, + ); + + self::assertSame([1, 2, 5, 6], $result->match->toArray()); + } + + public function testParseEsearchWithCountMinMaxAll(): void + { + $result = ImapSearchParser::parse( + [$this->response('* ESEARCH (TAG "A1") UID COUNT 4 MIN 2 MAX 28 ALL 2,4:6,28')], + false, + ); + + self::assertSame([2, 4, 5, 6, 28], $result->match->toArray()); + self::assertSame(4, $result->count); + self::assertSame(2, $result->min); + self::assertSame(28, $result->max); + } + + public function testParseEsearchCountOnly(): void + { + $result = ImapSearchParser::parse( + [$this->response('* ESEARCH (TAG "A1") UID COUNT 9')], + false, + ); + + self::assertSame(9, $result->count); + self::assertSame([], $result->match->toArray()); + self::assertNull($result->min); + } + + public function testSearchUsesUidSearchByDefault(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* SEARCH 1 2 3', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + + $result = $client->search('INBOX', (new ImapSearchQuery())->flag('\\Seen')); + + self::assertSame(['A1 UID SEARCH SEEN'], $socket->written); + self::assertSame([1, 2, 3], $result->match->toArray()); + } + + public function testSearchSequenceMode(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* SEARCH 4 5', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + + $result = $client->search('INBOX', (new ImapSearchQuery())->flag('\\Seen'), ['sequence' => true]); + + self::assertSame(['A1 SEARCH SEEN'], $socket->written); + self::assertTrue($result->match->isSequence()); + } + + public function testSearchEmptyQuerySendsAll(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* SEARCH 1', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + + $client->search('INBOX', new ImapSearchQuery()); + + self::assertSame(['A1 UID SEARCH ALL'], $socket->written); + } + + public function testSearchUsesEsearchReturnWhenAvailable(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 ESEARCH] Server ready.', + '* ESEARCH (TAG "A1") UID COUNT 2 ALL 3,7', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + + $result = $client->search( + 'INBOX', + (new ImapSearchQuery())->flag('\\Seen'), + ['results' => [SearchResultType::Count, SearchResultType::Match]], + ); + + self::assertSame(['A1 UID SEARCH RETURN (COUNT ALL) SEEN'], $socket->written); + self::assertSame(2, $result->count); + self::assertSame([3, 7], $result->match->toArray()); + } + + public function testSearchEmitsCharsetWhenQueryDeclaresOne(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* SEARCH', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + + // An explicit charset with an ASCII value keeps the value inline + // (no literal / continuation), so the whole command is one line. + $query = (new ImapSearchQuery())->charset('UTF-8')->text('plain'); + $client->search('INBOX', $query); + + self::assertSame(['A1 UID SEARCH CHARSET UTF-8 BODY plain'], $socket->written); + } + + public function testSearchOmitsCharsetOnceUtf8AcceptEnabled(): void + { + // Server negotiates IMAP4rev2 (UTF-8 mailbox/search implied), so + // RFC 6855 §3 forbids sending a CHARSET argument. + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev2] Server ready.', + '* ENABLED IMAP4rev2', + '* SEARCH', + 'A1 OK SEARCH completed.', + ); + $client = $this->client($socket); + // Prime the enabled state the way login() would. + $client->getCapability()->enable('IMAP4REV2'); + + $query = (new ImapSearchQuery())->charset('UTF-8')->text('plain'); + $client->search('INBOX', $query); + + self::assertSame(['A1 UID SEARCH BODY plain'], $socket->written); + } + + public function testSearchRejectsNonSearchQuery(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(ImapProtocolException::class); + + $client->search('INBOX', new \stdClass()); + } + + public function testSearchWrapsServerRejection(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 NO Invalid search criteria.', + ); + $client = $this->client($socket); + + $this->expectException(ServerResponseException::class); + + $client->search('INBOX', (new ImapSearchQuery())->flag('\\Seen')); + } +} diff --git a/test/Unit/Src/ImapClientSyncTest.php b/test/Unit/Src/ImapClientSyncTest.php new file mode 100644 index 00000000..399c028c --- /dev/null +++ b/test/Unit/Src/ImapClientSyncTest.php @@ -0,0 +1,255 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + private function response(string $line): \Horde\Imap\Client\ImapResponse + { + $tokens = (new ImapTokenizer($this->socket($line)))->readLine(); + + return ImapResponseParser::parse($tokens); + } + + public function testStatusRequestsHighestModSeqWhenAsked(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* STATUS INBOX (HIGHESTMODSEQ 715194045007)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $status = $client->status('INBOX', StatusFlag::HighestModSeq->value); + + self::assertSame(['A1 STATUS INBOX (HIGHESTMODSEQ)'], $socket->written); + self::assertSame(715194045007, $status->highestmodseq); + } + + public function testStatusAllIncludesHighestModSeqOnlyWithCondstore(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* STATUS INBOX (MESSAGES 3 HIGHESTMODSEQ 42)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $client->status('INBOX', StatusFlag::All->value); + + self::assertSame( + ['A1 STATUS INBOX (MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)'], + $socket->written, + ); + } + + public function testStatusAllOmitsHighestModSeqWithoutCondstore(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* STATUS INBOX (MESSAGES 3)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $client->status('INBOX', StatusFlag::All->value); + + self::assertSame( + ['A1 STATUS INBOX (MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)'], + $socket->written, + ); + } + + public function testVanishedParserEarlierForm(): void + { + $result = ImapVanishedParser::parse([$this->response('* VANISHED (EARLIER) 41,43:45')]); + + self::assertSame([41, 43, 44, 45], $result->toArray()); + self::assertFalse($result->isSequence()); + } + + public function testVanishedParserPlainForm(): void + { + $result = ImapVanishedParser::parse([$this->response('* VANISHED 3,5')]); + + self::assertSame([3, 5], $result->toArray()); + } + + public function testEnableQresync(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + ); + $client = $this->client($socket); + + self::assertTrue($client->enableQresync()); + self::assertSame(['A1 ENABLE QRESYNC'], $socket->written); + } + + public function testEnableQresyncThrowsWhenUnsupported(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->enableQresync(); + } + + public function testVanishedSendsFetchWithChangedSince(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + '* VANISHED (EARLIER) 300:310,405', + 'A2 OK FETCH completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $result = $client->vanished('INBOX', 41010); + + self::assertSame('A2 UID FETCH 1:* UID (VANISHED CHANGEDSINCE 41010)', $socket->written[1]); + self::assertContains(405, $result->toArray()); + self::assertContains(300, $result->toArray()); + } + + public function testVanishedWithExplicitIds(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + 'A2 OK FETCH completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $client->vanished('INBOX', 100, ['ids' => new ImapIdSet([300, 301, 302], false)]); + + self::assertSame('A2 UID FETCH 300:302 UID (VANISHED CHANGEDSINCE 100)', $socket->written[1]); + } + + public function testVanishedThrowsWhenQresyncNotEnabled(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->vanished('INBOX', 100); + } + + public function testOpenMailboxQresyncBundlesVanishedAndChanges(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + '* 100 EXISTS', + '* VANISHED (EARLIER) 41,43:45', + '* 49 FETCH (UID 117 FLAGS (\\Seen \\Deleted) MODSEQ (12345))', + 'A2 OK [READ-WRITE] SELECT completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $result = $client->openMailboxQresync('INBOX', OpenMode::ReadWrite, 67890, 90060115194045); + + self::assertSame( + 'A2 SELECT INBOX (QRESYNC (67890 90060115194045))', + $socket->written[1], + ); + self::assertSame([41, 43, 44, 45], $result->vanished->toArray()); + self::assertArrayHasKey(117, $result->changed); + self::assertSame('INBOX', $client->selectedMailbox()); + } + + public function testOpenMailboxQresyncWithKnownUids(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + 'A2 OK [READ-WRITE] SELECT completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $client->openMailboxQresync( + 'INBOX', + OpenMode::ReadWrite, + 67890, + 90060, + new ImapIdSet([1, 2, 3], false), + ); + + self::assertSame( + 'A2 SELECT INBOX (QRESYNC (67890 90060 1:3))', + $socket->written[1], + ); + } + + public function testOpenMailboxQresyncThrowsWhenNotEnabled(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->openMailboxQresync('INBOX', OpenMode::ReadWrite, 1, 2); + } +} diff --git a/test/Unit/Src/ImapClientTest.php b/test/Unit/Src/ImapClientTest.php new file mode 100644 index 00000000..b3a9000b --- /dev/null +++ b/test/Unit/Src/ImapClientTest.php @@ -0,0 +1,497 @@ +config($policy), $credentials, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testGetCapabilityReadsGreetingPiggybackWithoutARoundTrip(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] Server ready.'); + $client = $this->client($socket); + + $capability = $client->getCapability(); + + self::assertTrue($capability->query('IMAP4REV1')); + self::assertTrue($capability->query('AUTH', 'PLAIN')); + self::assertSame([], $socket->written); + } + + public function testGetCapabilitySendsCapabilityWhenGreetingCarriesNone(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* CAPABILITY IMAP4rev1 IDLE', + 'A1 OK CAPABILITY completed.', + ); + $client = $this->client($socket); + + $capability = $client->getCapability(); + + self::assertTrue($capability->query('IDLE')); + self::assertSame(['A1 CAPABILITY'], $socket->written); + } + + public function testGetCapabilityIsCachedAfterFirstCall(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* CAPABILITY IMAP4rev1', + 'A1 OK CAPABILITY completed.', + ); + $client = $this->client($socket); + + $client->getCapability(); + $client->getCapability(); + + self::assertSame(['A1 CAPABILITY'], $socket->written); + } + + public function testConnectThrowsOnByeGreeting(): void + { + $socket = $this->socket('* BYE Too many connections.'); + $client = $this->client($socket); + + $this->expectException(ConnectionException::class); + + $client->getCapability(); + } + + public function testLoginViaSaslPlain(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] Server ready.', + 'A1 OK AUTHENTICATE completed.', + '* CAPABILITY IMAP4rev1', + 'A2 OK CAPABILITY completed.', + ); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + self::assertSame([ + 'A1 AUTHENTICATE PLAIN ' . base64_encode("\0alice\0hunter2"), + 'A2 CAPABILITY', + ], $socket->written); + } + + public function testLoginFallsBackToNativeLoginWhenNoSaslOffered(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + 'A1 OK Logged in.', + '* CAPABILITY IMAP4rev1', + 'A2 OK CAPABILITY completed.', + ); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + self::assertSame([ + 'A1 LOGIN alice hunter2', + 'A2 CAPABILITY', + ], $socket->written); + } + + public function testLoginThrowsWhenLoginDisabledAndNoSaslOffered(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 LOGINDISABLED] Server ready.'); + $client = $this->client($socket, $this->credentials()); + + $this->expectException(AuthenticationException::class); + + $client->login(); + } + + public function testLoginThrowsWithoutCredentials(): void + { + $socket = $this->socket('* OK Server ready.'); + $client = $this->client($socket, null); + + $this->expectException(AuthenticationException::class); + + $client->login(); + } + + public function testLoginIsIdempotent(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] Server ready.', + 'A1 OK AUTHENTICATE completed.', + '* CAPABILITY IMAP4rev1', + 'A2 OK CAPABILITY completed.', + ); + $client = $this->client($socket, $this->credentials()); + + $client->login(); + $client->login(); + + self::assertSame(2, count($socket->written)); + } + + public function testLoginNegotiatesRev2WhenOffered(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 AUTH=PLAIN] Server ready.', + 'A1 OK AUTHENTICATE completed.', + '* CAPABILITY IMAP4rev1 IMAP4rev2', + 'A2 OK CAPABILITY completed.', + '* ENABLED IMAP4rev2', + 'A3 OK ENABLE completed.', + ); + $client = $this->client($socket, $this->credentials()); + + $client->login(); + + self::assertSame([ + 'A1 AUTHENTICATE PLAIN ' . base64_encode("\0alice\0hunter2"), + 'A2 CAPABILITY', + 'A3 ENABLE IMAP4rev2', + ], $socket->written); + } + + public function testAlreadyPreAuthenticatedGreetingSkipsLogin(): void + { + $socket = $this->socket('* PREAUTH Already authenticated as alice.'); + $client = $this->client($socket, $this->credentials()); + + $client->login(); + + self::assertSame([], $socket->written); + } + + public function testLogoutSendsLogoutAndClosesConnection(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK NOOP completed.', + '* BYE Logging out.', + 'A2 OK LOGOUT completed.', + ); + $client = $this->client($socket); + + $client->noop(); + $client->logout(); + + self::assertSame(['A1 NOOP', 'A2 LOGOUT'], $socket->written); + } + + public function testLogoutWithoutPriorConnectionIsANoop(): void + { + $socket = $this->socket(); + $client = $this->client($socket); + + $client->logout(); + + self::assertSame([], $socket->written); + } + + public function testNoopSendsNoopCommand(): void + { + $socket = $this->socket('* OK Server ready.', 'A1 OK NOOP completed.'); + $client = $this->client($socket); + + $client->noop(); + + self::assertSame(['A1 NOOP'], $socket->written); + } + + public function testOpenMailboxSendsSelectForReadWrite(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* 10 EXISTS', + '* 2 RECENT', + 'A1 OK [READ-WRITE] SELECT completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + + self::assertSame(['A1 SELECT INBOX'], $socket->written); + self::assertSame('INBOX', $client->selectedMailbox()); + self::assertTrue($client->isReadWrite()); + } + + public function testOpenMailboxSendsExamineForReadonly(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK [READ-ONLY] EXAMINE completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::Readonly); + + self::assertSame(['A1 EXAMINE INBOX'], $socket->written); + self::assertFalse($client->isReadWrite()); + } + + public function testOpenMailboxWrapsRejectionAsMailboxNotFound(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 NO Mailbox does not exist.', + ); + $client = $this->client($socket); + + $this->expectException(MailboxNotFoundException::class); + + $client->openMailbox('NoSuchBox', OpenMode::ReadWrite); + } + + public function testStatusParsesUntaggedStatusResponse(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* STATUS INBOX (MESSAGES 10 UIDNEXT 100 UNSEEN 3)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $status = $client->status('INBOX', StatusFlag::All->value); + + self::assertSame(10, $status->messages); + self::assertSame(100, $status->uidnext); + self::assertSame(3, $status->unseen); + self::assertSame(['A1 STATUS INBOX (MESSAGES RECENT UIDNEXT UIDVALIDITY UNSEEN)'], $socket->written); + } + + public function testStatusRequestsOnlyRequestedItems(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* STATUS INBOX (MESSAGES 10)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $client->status('INBOX', StatusFlag::Messages->value); + + self::assertSame(['A1 STATUS INBOX (MESSAGES)'], $socket->written); + } + + public function testCloseSendsCloseCommand(): void + { + $socket = $this->socket( + '* OK Server ready.', + 'A1 OK [READ-WRITE] SELECT completed.', + 'A2 OK CLOSE completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $client->close(); + + self::assertSame(['A1 SELECT INBOX', 'A2 CLOSE'], $socket->written); + self::assertNull($client->selectedMailbox()); + } + + public function testUnselectSendsUnselectWhenSupported(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 UNSELECT] Server ready.', + 'A1 OK [READ-WRITE] SELECT completed.', + 'A2 OK UNSELECT completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $client->unselect(); + + self::assertSame(['A1 SELECT INBOX', 'A2 UNSELECT'], $socket->written); + } + + public function testUnselectThrowsWhenNotSupported(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->unselect(); + } + + public function testGetIdsObReturnsEmptySetForNull(): void + { + $ids = $this->client($this->socket())->getIdsOb(); + + self::assertInstanceOf(ImapIdSet::class, $ids); + self::assertTrue($ids->isEmpty()); + } + + public function testGetIdsObParsesSequenceString(): void + { + $ids = $this->client($this->socket())->getIdsOb('1:3,7'); + + self::assertSame([1, 2, 3, 7], $ids->toArray()); + self::assertFalse($ids->isSequence()); + } + + public function testGetIdsObBuildsFromArrayWithSequenceFlag(): void + { + $ids = $this->client($this->socket())->getIdsOb([4, 2, 4], true); + + self::assertSame([4, 2], $ids->toArray()); + self::assertTrue($ids->isSequence()); + } + + public function testGetIdsObCopiesExistingSetChangingSequenceFlag(): void + { + $client = $this->client($this->socket()); + $original = $client->getIdsOb([1, 2, 3], false); + $copy = $client->getIdsOb($original, true); + + self::assertNotSame($original, $copy); + self::assertSame([1, 2, 3], $copy->toArray()); + self::assertTrue($copy->isSequence()); + } + + public function testFetchEmptyIdSetYieldsNothingAndSendsNoCommand(): void + { + $socket = $this->socket('* OK Server ready.'); + $client = $this->client($socket); + $query = (new ImapFetchQuery())->flags(); + + $results = iterator_to_array($client->fetch('INBOX', $client->getIdsOb(), $query)); + + self::assertSame([], $results); + self::assertSame([], $socket->written); + } + + public function testFetchUsesUidFetchAndKeysResultsByUid(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* 1 FETCH (UID 100 FLAGS (\Seen))', + '* 2 FETCH (UID 101 FLAGS (\Answered))', + 'A1 OK FETCH completed.', + ); + $client = $this->client($socket); + $query = (new ImapFetchQuery())->flags(); + + $results = iterator_to_array($client->fetch('INBOX', $client->getIdsOb('100,101'), $query)); + + self::assertSame(['A1 UID FETCH 100:101 (FLAGS)'], $socket->written); + self::assertSame([100, 101], array_keys($results)); + self::assertSame(['\Seen'], $results[100]->getFlags()); + self::assertSame(['\Answered'], $results[101]->getFlags()); + } + + public function testFetchInSequenceModeKeysResultsBySequenceNumber(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* 1 FETCH (FLAGS (\Seen))', + '* 2 FETCH (FLAGS (\Draft))', + 'A1 OK FETCH completed.', + ); + $client = $this->client($socket); + $query = (new ImapFetchQuery())->flags(); + + $results = iterator_to_array($client->fetch('INBOX', $client->getIdsOb('1:2', true), $query)); + + self::assertSame(['A1 FETCH 1:2 (FLAGS)'], $socket->written); + self::assertSame([1, 2], array_keys($results)); + } + + public function testFetchInSequenceModeAppendsUidWhenRequested(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* 1 FETCH (FLAGS (\Seen) UID 100)', + 'A1 OK FETCH completed.', + ); + $client = $this->client($socket); + $query = (new ImapFetchQuery())->flags()->uid(); + + $results = iterator_to_array($client->fetch('INBOX', $client->getIdsOb('1', true), $query)); + + self::assertSame(['A1 FETCH 1 (FLAGS UID)'], $socket->written); + self::assertSame([1], array_keys($results)); + self::assertSame(100, $results[1]->getUid()); + } + + public function testFetchSkipsInterleavedNonFetchResponses(): void + { + $socket = $this->socket( + '* OK Server ready.', + '* 5 EXPUNGE', + '* 1 FETCH (UID 100 FLAGS (\Seen))', + 'A1 OK FETCH completed.', + ); + $client = $this->client($socket); + $query = (new ImapFetchQuery())->flags(); + + $results = iterator_to_array($client->fetch('INBOX', $client->getIdsOb('100'), $query)); + + self::assertSame([100], array_keys($results)); + } + + public function testImplementsProtocolInterfaces(): void + { + $client = $this->client($this->socket()); + + self::assertInstanceOf(\Horde\Imap\Client\ImapProtocol::class, $client); + self::assertInstanceOf(\Horde\Imap\Client\MailboxProtocol::class, $client); + self::assertInstanceOf(\Horde\Imap\Client\ImapAclAware::class, $client); + self::assertInstanceOf(\Horde\Imap\Client\ImapQuotaAware::class, $client); + self::assertInstanceOf(\Horde\Imap\Client\ImapMetadataAware::class, $client); + } +} diff --git a/test/Unit/Src/ImapClientThreadSortTest.php b/test/Unit/Src/ImapClientThreadSortTest.php new file mode 100644 index 00000000..90bb15d4 --- /dev/null +++ b/test/Unit/Src/ImapClientThreadSortTest.php @@ -0,0 +1,228 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + private function threadResponse(string $line): \Horde\Imap\Client\ImapResponse + { + $tokens = (new ImapTokenizer($this->socket($line)))->readLine(); + + return ImapResponseParser::parse($tokens); + } + + public function testParseFlatThreads(): void + { + $result = ImapThreadParser::parse([$this->threadResponse('* THREAD (2)(3)(6)')], false); + + self::assertCount(3, $result); + self::assertSame([2, 3, 6], $result->messageList()->toArray()); + } + + public function testParseNestedThreadLevels(): void + { + // (3 6 (4 23)(44 7 96)): 3->0 6->1, then siblings share level 2. + $result = ImapThreadParser::parse( + [$this->threadResponse('* THREAD (3 6 (4 23)(44 7 96))')], + false, + ); + + self::assertCount(1, $result->getThreads()); + + $thread = $result->getThread(3); + self::assertSame(3, $thread[3]->base); + self::assertSame(0, $thread[3]->level); + self::assertSame(1, $thread[6]->level); + self::assertSame(2, $thread[4]->level); + self::assertSame(3, $thread[23]->level); + // The second sub-thread starts back at level 2, not continuing. + self::assertSame(2, $thread[44]->level); + self::assertSame(3, $thread[7]->level); + self::assertSame(4, $thread[96]->level); + } + + public function testLoneMessageThreadHasNullBase(): void + { + $result = ImapThreadParser::parse([$this->threadResponse('* THREAD (5)')], false); + + $thread = $result->getThread(5); + self::assertNull($thread[5]->base); + self::assertTrue($thread[5]->last); + } + + public function testGetThreadUnknownIndexIsEmpty(): void + { + $result = ImapThreadParser::parse([$this->threadResponse('* THREAD (1)(2)')], false); + + self::assertSame([], $result->getThread(99)); + } + + public function testGetThreadsReturnsAll(): void + { + $result = ImapThreadParser::parse( + [$this->threadResponse('* THREAD (1 2)(3)')], + false, + ); + + $threads = $result->getThreads(); + self::assertCount(2, $threads); + self::assertSame(1, $threads[0][1]->base); + self::assertNull($threads[1][3]->base); + } + + public function testThreadSendsUidThreadOrderedSubjectAll(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 THREAD=ORDEREDSUBJECT] Server ready.', + '* THREAD (1)(2 3)', + 'A1 OK THREAD completed.', + ); + $client = $this->client($socket); + + $result = $client->thread('INBOX'); + + self::assertSame(['A1 UID THREAD ORDEREDSUBJECT US-ASCII ALL'], $socket->written); + self::assertCount(3, $result); + self::assertFalse($result->isSequence()); + } + + public function testThreadReferencesWithSearchCriteria(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 THREAD=REFERENCES] Server ready.', + '* THREAD (1)', + 'A1 OK THREAD completed.', + ); + $client = $this->client($socket); + + $client->thread('INBOX', [ + 'criteria' => ThreadAlgorithm::References, + 'search' => (new ImapSearchQuery())->flag('\\Seen'), + 'sequence' => true, + ]); + + self::assertSame(['A1 THREAD REFERENCES US-ASCII SEEN'], $socket->written); + } + + public function testThreadThrowsWhenAlgorithmUnsupported(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1 THREAD=ORDEREDSUBJECT] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->thread('INBOX', ['criteria' => ThreadAlgorithm::References]); + } + + public function testSearchSortSendsUidSortAndPreservesOrder(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 SORT] Server ready.', + '* SORT 5 3 4 1 2', + 'A1 OK SORT completed.', + ); + $client = $this->client($socket); + + $result = $client->search('INBOX', (new ImapSearchQuery())->flag('\\Seen'), [ + 'sort' => [SortCriteria::Date], + ]); + + self::assertSame(['A1 UID SORT (DATE) US-ASCII SEEN'], $socket->written); + self::assertSame([5, 3, 4, 1, 2], $result->match->toArray()); + } + + public function testSearchSortReverseAndMultipleCriteria(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 SORT] Server ready.', + '* SORT 1', + 'A1 OK SORT completed.', + ); + $client = $this->client($socket); + + $client->search('INBOX', new ImapSearchQuery(), [ + 'sort' => [SortCriteria::Reverse, SortCriteria::Date, SortCriteria::Subject], + ]); + + self::assertSame(['A1 UID SORT (REVERSE DATE SUBJECT) US-ASCII ALL'], $socket->written); + } + + public function testSearchSortUsesEsortReturnWhenAvailable(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 SORT ESORT] Server ready.', + '* ESEARCH (TAG "A1") UID COUNT 2 ALL 3,7', + 'A1 OK SORT completed.', + ); + $client = $this->client($socket); + + $result = $client->search('INBOX', new ImapSearchQuery(), [ + 'sort' => [SortCriteria::Arrival], + ]); + + self::assertSame(['A1 UID SORT RETURN (ALL) (ARRIVAL) US-ASCII ALL'], $socket->written); + self::assertSame([3, 7], $result->match->toArray()); + } + + public function testSearchSortThrowsWhenSortUnsupported(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(CapabilityNotSupportedException::class); + + $client->search('INBOX', new ImapSearchQuery(), ['sort' => [SortCriteria::Date]]); + } +} diff --git a/test/Unit/Src/ImapClientTokenSyncTest.php b/test/Unit/Src/ImapClientTokenSyncTest.php new file mode 100644 index 00000000..c1020875 --- /dev/null +++ b/test/Unit/Src/ImapClientTokenSyncTest.php @@ -0,0 +1,142 @@ +config(), null, null, $socket); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + public function testGetSyncTokenEncodesState(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* STATUS INBOX (UIDVALIDITY 42 UIDNEXT 100 HIGHESTMODSEQ 715)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $token = $client->getSyncToken('INBOX'); + + self::assertSame('V42,H715,U100', base64_decode($token, true)); + } + + public function testSyncDetectsNewFlagAndVanished(): void + { + $token = base64_encode('V42,H715,U100'); + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE QRESYNC] Server ready.', + '* ENABLED QRESYNC', + 'A1 OK ENABLE completed.', + // sync(): STATUS then new-msgs SEARCH, flag SEARCH, vanished FETCH. + '* STATUS INBOX (UIDVALIDITY 42 UIDNEXT 130 HIGHESTMODSEQ 900)', + 'A2 OK STATUS completed.', + '* SEARCH 100 101 105', + 'A3 OK SEARCH completed.', + '* SEARCH 5 9', + 'A4 OK SEARCH completed.', + '* VANISHED (EARLIER) 1:3', + 'A5 OK FETCH completed.', + ); + $client = $this->client($socket); + $client->enableQresync(); + + $result = $client->sync('INBOX', $token); + + // written[0]=ENABLE, [1]=STATUS, [2]=new-msgs SEARCH, + // [3]=flag SEARCH, [4]=vanished FETCH. + self::assertSame('A3 UID SEARCH UID 100:*', $socket->written[2]); + self::assertSame('A4 UID SEARCH MODSEQ 716', $socket->written[3]); + self::assertSame('A5 UID FETCH 1:* UID (VANISHED CHANGEDSINCE 715)', $socket->written[4]); + + self::assertSame([100, 101, 105], $result->newMsgs->toArray()); + self::assertSame([5, 9], $result->flagChanges->toArray()); + self::assertSame([1, 2, 3], $result->vanished->toArray()); + } + + public function testSyncOnlyRequestedCriteria(): void + { + $token = base64_encode('V42,H715,U100'); + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* STATUS INBOX (UIDVALIDITY 42 UIDNEXT 130 HIGHESTMODSEQ 900)', + 'A1 OK STATUS completed.', + '* SEARCH 100 101', + 'A2 OK SEARCH completed.', + ); + $client = $this->client($socket); + + $result = $client->sync('INBOX', $token, [SyncCriteria::NewMessages]); + + self::assertSame(['A1 STATUS INBOX (UIDNEXT UIDVALIDITY HIGHESTMODSEQ)', 'A2 UID SEARCH UID 100:*'], $socket->written); + self::assertSame([100, 101], $result->newMsgs->toArray()); + self::assertTrue($result->flagChanges->isEmpty()); + self::assertTrue($result->vanished->isEmpty()); + } + + public function testSyncThrowsOnUidValidityChange(): void + { + $token = base64_encode('V42,H715,U100'); + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 CONDSTORE] Server ready.', + '* STATUS INBOX (UIDVALIDITY 99 UIDNEXT 130 HIGHESTMODSEQ 900)', + 'A1 OK STATUS completed.', + ); + $client = $this->client($socket); + + $this->expectException(SyncException::class); + + $client->sync('INBOX', $token); + } + + public function testSyncThrowsOnMalformedToken(): void + { + $socket = $this->socket('* OK [CAPABILITY IMAP4rev1] Server ready.'); + $client = $this->client($socket); + + $this->expectException(SyncException::class); + + $client->sync('INBOX', '!!!not base64!!!'); + } +} diff --git a/test/Unit/Src/ImapCommandTagTest.php b/test/Unit/Src/ImapCommandTagTest.php new file mode 100644 index 00000000..704263de --- /dev/null +++ b/test/Unit/Src/ImapCommandTagTest.php @@ -0,0 +1,51 @@ +next()); + self::assertSame('A2', $tags->next()); + self::assertSame('A3', $tags->next()); + } + + public function testUsesCustomPrefix(): void + { + $tags = new ImapCommandTag('X'); + + self::assertSame('X1', $tags->next()); + self::assertSame('X2', $tags->next()); + } + + public function testTwoGeneratorsAreIndependent(): void + { + $first = new ImapCommandTag(); + $second = new ImapCommandTag(); + + $first->next(); + $first->next(); + + self::assertSame('A1', $second->next()); + } +} diff --git a/test/Unit/Src/ImapCommandTest.php b/test/Unit/Src/ImapCommandTest.php new file mode 100644 index 00000000..c3ff7828 --- /dev/null +++ b/test/Unit/Src/ImapCommandTest.php @@ -0,0 +1,131 @@ +segments() as $segment) { + $rendered .= $segment->isLiteral ? $segment->bytes : $segment->text; + } + + return $rendered; + } + + public function testSimpleCommandWithNoArguments(): void + { + $command = new ImapCommand('A1', 'NOOP'); + + self::assertSame('A1 NOOP', $this->render($command)); + self::assertFalse($command->needsContinuation()); + } + + public function testCommandWithBareStringArguments(): void + { + $command = new ImapCommand('A2', 'SELECT', ['INBOX']); + + self::assertSame('A2 SELECT INBOX', $this->render($command)); + } + + public function testCommandWithWireValueArgument(): void + { + $command = new ImapCommand('A3', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString('sw0rdfish'), + ]); + + self::assertSame('A3 LOGIN admin sw0rdfish', $this->render($command)); + self::assertFalse($command->needsContinuation()); + } + + public function testCommandWithParenthesizedListArgument(): void + { + $command = new ImapCommand('A4', 'STORE', [ + '1:5', + '+FLAGS', + new ImapWireList(['\\Seen', '\\Deleted']), + ]); + + self::assertSame('A4 STORE 1:5 +FLAGS (\\Seen \\Deleted)', $this->render($command)); + } + + public function testCommandNeedsContinuationForLiteralArgument(): void + { + $command = new ImapCommand('A5', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + + self::assertTrue($command->needsContinuation()); + } + + public function testLiteralArgumentProducesLiteralSegment(): void + { + $command = new ImapCommand('A6', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + + $segments = $command->segments(); + $literalSegments = array_values(array_filter($segments, fn($s) => $s->isLiteral)); + + self::assertCount(1, $literalSegments); + self::assertSame("multi\r\nline", $literalSegments[0]->bytes); + self::assertSame(11, $literalSegments[0]->length()); + self::assertFalse($literalSegments[0]->isBinary); + } + + public function testNestedListWithLiteralMemberNeedsContinuation(): void + { + $command = new ImapCommand('A7', 'APPEND', [ + 'INBOX', + new ImapWireList(['\\Seen', new ImapWireString("bin\x00ary")]), + ]); + + self::assertTrue($command->needsContinuation()); + + $segments = $command->segments(); + $rendered = ''; + $sawLiteral = false; + + foreach ($segments as $segment) { + if ($segment->isLiteral) { + $sawLiteral = true; + self::assertTrue($segment->isBinary); + $rendered .= $segment->bytes; + continue; + } + + $rendered .= $segment->text; + } + + self::assertTrue($sawLiteral); + self::assertSame("A7 APPEND INBOX (\\Seen bin\x00ary)", $rendered); + } +} diff --git a/test/Unit/Src/ImapConnectionTest.php b/test/Unit/Src/ImapConnectionTest.php new file mode 100644 index 00000000..2cf45209 --- /dev/null +++ b/test/Unit/Src/ImapConnectionTest.php @@ -0,0 +1,164 @@ +sendCommand(new ImapCommand('A1', 'SELECT', ['INBOX'])); + + self::assertSame([], $untagged); + self::assertSame(["A1 SELECT INBOX"], $socket->written); + } + + public function testSendCommandWithLiteralWaitsForContinuation(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('+ Ready for literal data'), + ); + $connection = new ImapConnection($socket); + + $command = new ImapCommand('A2', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + $untagged = $connection->sendCommand($command); + + self::assertSame([], $untagged); + self::assertSame( + ["A2 LOGIN admin {11}", "multi\r\nline", ''], + $socket->written, + ); + } + + public function testSendCommandCollectsUntaggedResponsesWhileAwaitingContinuation(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [ALERT] System going down soon.'), + InMemoryImapSocket::line('+ Ready'), + ); + $connection = new ImapConnection($socket); + + $command = new ImapCommand('A3', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + $untagged = $connection->sendCommand($command); + + self::assertCount(1, $untagged); + self::assertTrue($untagged[0]->isUntagged()); + self::assertSame('ALERT', $untagged[0]->responseCode->name); + } + + public function testSendCommandThrowsWhenLiteralIsRejected(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A4 NO literal too large'), + ); + $connection = new ImapConnection($socket); + + $command = new ImapCommand('A4', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + + $this->expectException(ServerResponseException::class); + + $connection->sendCommand($command); + } + + public function testSendCommandSendsNonSynchronizingLiteralWithoutWaiting(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $connection = new ImapConnection($socket); + + $command = new ImapCommand('A5', 'LOGIN', [ + new ImapWireString('admin'), + new ImapWireString("multi\r\nline"), + ]); + $untagged = $connection->sendCommand($command, nonSynchronizingLiterals: true); + + self::assertSame([], $untagged); + self::assertSame( + ["A5 LOGIN admin {11+}", "multi\r\nline", ''], + $socket->written, + ); + } + + public function testSendCommandAnnouncesBinaryLiteralWithTilde(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('+ Ready'), + ); + $connection = new ImapConnection($socket); + + $command = new ImapCommand('A6', 'APPEND', [ + 'INBOX', + new ImapWireString("bin\x00ary"), + ]); + $connection->sendCommand($command); + + self::assertSame( + ["A6 APPEND INBOX ~{7}", "bin\x00ary", ''], + $socket->written, + ); + } + + public function testReadResponseParsesTaggedLine(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A7 OK LOGIN completed.'), + ); + $connection = new ImapConnection($socket); + + $response = $connection->readResponse(); + + self::assertTrue($response->isTagged()); + self::assertSame('A7', $response->tag); + self::assertTrue($response->isOk()); + } + + public function testWriteLineWritesBareLineWithoutTagOrCommand(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $connection = new ImapConnection($socket); + + $connection->writeLine(base64_encode('response-bytes')); + + self::assertSame([base64_encode('response-bytes')], $socket->written); + } + + public function testWriteLineAcceptsEmptyLine(): void + { + $socket = InMemoryImapSocket::fromParts(''); + $connection = new ImapConnection($socket); + + $connection->writeLine(''); + + self::assertSame([''], $socket->written); + } +} diff --git a/test/Unit/Src/ImapFetchParserTest.php b/test/Unit/Src/ImapFetchParserTest.php new file mode 100644 index 00000000..46a54a54 --- /dev/null +++ b/test/Unit/Src/ImapFetchParserTest.php @@ -0,0 +1,274 @@ +readLine()); + } + + private function parse(string $line, ?ImapFetchQuery $query = null): ImapFetchResult + { + $parsed = ImapFetchParser::parse($this->response($line), $query ?? new ImapFetchQuery()); + self::assertNotNull($parsed); + + return $parsed['result']; + } + + public function testNonFetchResponseReturnsNull(): void + { + $parsed = ImapFetchParser::parse($this->response('* 1 EXISTS'), new ImapFetchQuery()); + + self::assertNull($parsed); + } + + public function testTaggedResponseReturnsNull(): void + { + $parsed = ImapFetchParser::parse($this->response('A1 OK FETCH completed.'), new ImapFetchQuery()); + + self::assertNull($parsed); + } + + public function testSequenceNumberAndUid(): void + { + $parsed = ImapFetchParser::parse($this->response('* 5 FETCH (UID 4211)'), new ImapFetchQuery()); + + self::assertNotNull($parsed); + self::assertSame(5, $parsed['seq']); + self::assertSame(4211, $parsed['result']->getUid()); + } + + public function testFlags(): void + { + $result = $this->parse('* 1 FETCH (FLAGS (\Seen \Answered $label1))'); + + self::assertSame(['\Seen', '\Answered', '$label1'], $result->getFlags()); + } + + public function testRfc822Size(): void + { + $result = $this->parse('* 1 FETCH (RFC822.SIZE 8642)'); + + self::assertSame(8642, $result->getSize()); + } + + public function testInternalDate(): void + { + $result = $this->parse('* 1 FETCH (INTERNALDATE "17-Jul-1996 02:44:25 -0700")'); + + self::assertSame('1996-07-17 02:44:25', $result->getImapDate()->format('Y-m-d H:i:s')); + } + + public function testMalformedInternalDateFallsBackToEpoch(): void + { + $result = $this->parse('* 1 FETCH (INTERNALDATE "not-a-date")'); + + self::assertSame(0, $result->getImapDate()->getTimestamp()); + } + + public function testModSeq(): void + { + $result = $this->parse('* 1 FETCH (MODSEQ (624140003))'); + + self::assertSame(624140003, $result->getModSeq()); + } + + public function testEnvelopeFieldsAndAddresses(): void + { + $env = '("Wed, 17 Jul 1996 02:23:25 -0700" "Test subject" ' + . '(("Alice" NIL "alice" "a.com")) ' // from + . 'NIL ' // sender (falls back to from) + . 'NIL ' // reply-to (falls back to from) + . '(("Bob" NIL "bob" "b.com")) ' // to + . 'NIL NIL ' // cc, bcc + . '"" "")'; // in-reply-to, message-id + $result = $this->parse('* 1 FETCH (ENVELOPE ' . $env . ')'); + + $envelope = $result->getEnvelope(); + self::assertSame('Wed, 17 Jul 1996 02:23:25 -0700', $envelope->date); + self::assertSame('Test subject', $envelope->subject); + self::assertSame('alice@a.com', $envelope->from->addresses()[0]->bareAddress()); + self::assertSame('bob@b.com', $envelope->to->addresses()[0]->bareAddress()); + self::assertSame('', $envelope->inReplyTo); + self::assertSame('', $envelope->messageId); + } + + public function testEnvelopeNilSenderAndReplyToFallBackToFrom(): void + { + $env = '(NIL NIL (("Alice" NIL "alice" "a.com")) NIL NIL NIL NIL NIL NIL NIL)'; + $result = $this->parse('* 1 FETCH (ENVELOPE ' . $env . ')'); + + $envelope = $result->getEnvelope(); + self::assertSame('alice@a.com', $envelope->sender->addresses()[0]->bareAddress()); + self::assertSame('alice@a.com', $envelope->replyTo->addresses()[0]->bareAddress()); + } + + public function testEnvelopeAddressGroup(): void + { + // A group: start marker (host NIL, mailbox non-NIL), one member, + // end marker (host NIL, mailbox NIL). RFC 3501 §7.4.2. + $to = '((NIL NIL "friends" NIL) ("Bob" NIL "bob" "b.com") (NIL NIL NIL NIL))'; + $env = '(NIL NIL NIL NIL NIL ' . $to . ' NIL NIL NIL NIL)'; + $result = $this->parse('* 1 FETCH (ENVELOPE ' . $env . ')'); + + // The group is preserved as a Group in the list. addresses() + // flattens groups, so iterate the list to see the group itself. + $items = iterator_to_array($result->getEnvelope()->to); + self::assertCount(1, $items); + self::assertInstanceOf(\Horde\Mail\Rfc822\Group::class, $items[0]); + self::assertSame('friends', $items[0]->groupname); + self::assertSame('bob@b.com', $items[0]->addresses->addresses()[0]->bareAddress()); + } + + public function testBodyStructureSinglePart(): void + { + $bs = '("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 128 4)'; + $result = $this->parse('* 1 FETCH (BODYSTRUCTURE ' . $bs . ')', (new ImapFetchQuery())->structure()); + + $structure = $result->getStructure(); + self::assertSame('text/plain', $structure->fullType()); + self::assertSame('utf-8', $structure->charset()); + } + + public function testBodyStructureMultipartYieldsCanonicalIds(): void + { + $bs = '(("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 100 5)' + . '("text" "html" NIL NIL NIL "base64" 200 8) "alternative")'; + $result = $this->parse('* 2 FETCH (BODYSTRUCTURE ' . $bs . ')', (new ImapFetchQuery())->structure()); + + self::assertSame('multipart/alternative', $result->getStructure()->fullType()); + + $parts = []; + + foreach ($result->getParts() as $id => $part) { + $parts[$id] = $part->fullType(); + } + + self::assertSame(['1' => 'text/plain', '2' => 'text/html'], $parts); + } + + public function testBareBodyIsTreatedAsBodyStructure(): void + { + $result = $this->parse( + '* 1 FETCH (BODY ("text" "plain" NIL NIL NIL "7bit" 10 1))', + (new ImapFetchQuery())->structure(), + ); + + self::assertSame('text/plain', $result->getStructure()->fullType()); + } + + public function testWholeMessageBodySection(): void + { + $body = "Subject: hi\r\n\r\nHello"; + $line = '* 1 FETCH (BODY[] {' . strlen($body) . "}\r\n" . $body . ')'; + $result = $this->parse($line); + + self::assertSame($body, (string) $result->getFullMsg()); + } + + public function testHeaderTextSection(): void + { + $header = "Subject: hi\r\n"; + $line = '* 1 FETCH (BODY[HEADER] {' . strlen($header) . "}\r\n" . $header . ')'; + $result = $this->parse($line); + + self::assertSame($header, (string) $result->getHeaderText()); + } + + public function testPartTextAndMimeSections(): void + { + $line = '* 1 FETCH (BODY[1.TEXT] "text body" BODY[1.MIME] "Content-Type: text/plain")'; + $result = $this->parse($line); + + self::assertSame('text body', (string) $result->getBodyText('1')); + self::assertSame('Content-Type: text/plain', (string) $result->getMimeHeader('1')); + } + + public function testRawBodyPartSection(): void + { + $result = $this->parse('* 1 FETCH (BODY[2] "raw part")'); + + self::assertSame('raw part', (string) $result->getBodyPart('2')); + } + + public function testPartialOffsetSuffixIsStrippedFromSection(): void + { + $result = $this->parse('* 1 FETCH (BODY[1]<0> "partial")'); + + self::assertSame('partial', (string) $result->getBodyPart('1')); + } + + public function testHeaderFieldsMapBackToCallerLabel(): void + { + $query = (new ImapFetchQuery())->headers('std', ['From', 'To']); + $headerText = "From: a@b\r\nTo: c@d\r\n"; + $line = '* 1 FETCH (BODY[HEADER.FIELDS (FROM TO)] {' + . strlen($headerText) . "}\r\n" . $headerText . ')'; + $result = $this->parse($line, $query); + + $headers = $result->getHeaders('std'); + self::assertSame('a@b', $headers->get('From')->value()); + self::assertSame('c@d', $headers->get('To')->value()); + } + + public function testRfc822AliasesResolveToBodyEquivalents(): void + { + $line = '* 1 FETCH (RFC822.HEADER "Subject: x" RFC822.TEXT "body" RFC822 "whole")'; + $result = $this->parse($line); + + self::assertSame('Subject: x', (string) $result->getHeaderText()); + self::assertSame('body', (string) $result->getBodyText()); + self::assertSame('whole', (string) $result->getFullMsg()); + } + + public function testUnknownItemsAreIgnored(): void + { + $result = $this->parse('* 1 FETCH (UID 9 XSOMETHING "ignored" FLAGS (\Seen))'); + + self::assertSame(9, $result->getUid()); + self::assertSame(['\Seen'], $result->getFlags()); + } + + public function testMultipleItemsInOneResponse(): void + { + $line = '* 3 FETCH (UID 100 FLAGS (\Seen) RFC822.SIZE 42 INTERNALDATE "17-Jul-1996 02:44:25 -0700")'; + $result = $this->parse($line); + + self::assertSame(100, $result->getUid()); + self::assertSame(['\Seen'], $result->getFlags()); + self::assertSame(42, $result->getSize()); + self::assertSame('1996-07-17', $result->getImapDate()->format('Y-m-d')); + } +} diff --git a/test/Unit/Src/ImapFetchQueryTest.php b/test/Unit/Src/ImapFetchQueryTest.php new file mode 100644 index 00000000..d21b4b79 --- /dev/null +++ b/test/Unit/Src/ImapFetchQueryTest.php @@ -0,0 +1,173 @@ +structure() + ->envelope() + ->flags() + ->modseq(); + + self::assertSame( + ['BODYSTRUCTURE', 'ENVELOPE', 'FLAGS', 'MODSEQ'], + $query->wireItems(), + ); + self::assertTrue($query->wantsStructure()); + self::assertTrue($query->wantsEnvelope()); + self::assertTrue($query->wantsFlags()); + self::assertTrue($query->wantsModSeq()); + } + + public function testWireItemsPreserveFirstRequestedOrder(): void + { + $query = (new ImapFetchQuery()) + ->flags() + ->envelope() + ->structure(); + + self::assertSame(['FLAGS', 'ENVELOPE', 'BODYSTRUCTURE'], $query->wireItems()); + } + + public function testDuplicateItemsAreDeduplicated(): void + { + $query = (new ImapFetchQuery()) + ->flags() + ->flags() + ->structure(); + + self::assertSame(['FLAGS', 'BODYSTRUCTURE'], $query->wireItems()); + } + + public function testHeadersEmitsPeekFieldListAndUppercasesNames(): void + { + $query = (new ImapFetchQuery())->headers('std', ['From', 'to', 'Subject']); + + self::assertSame( + ['BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT)]'], + $query->wireItems(), + ); + } + + public function testHeadersNotUsesHeaderFieldsNot(): void + { + $query = (new ImapFetchQuery())->headers('rest', ['Received'], not: true); + + self::assertSame( + ['BODY.PEEK[HEADER.FIELDS.NOT (RECEIVED)]'], + $query->wireItems(), + ); + } + + public function testHeaderLabelRoundTrips(): void + { + $query = (new ImapFetchQuery())->headers('std', ['From', 'To']); + + self::assertSame('std', $query->headerLabelFor('HEADER.FIELDS (FROM TO)')); + self::assertNull($query->headerLabelFor('HEADER.FIELDS (FROM CC)')); + } + + public function testHeaderTextWholeMessageVsPart(): void + { + $query = (new ImapFetchQuery()) + ->headerText() + ->headerText(2); + + self::assertSame( + ['BODY.PEEK[HEADER]', 'BODY.PEEK[2.HEADER]'], + $query->wireItems(), + ); + } + + public function testBodyTextWholeMessageVsPart(): void + { + $query = (new ImapFetchQuery()) + ->bodyText() + ->bodyText('1.2'); + + self::assertSame( + ['BODY.PEEK[TEXT]', 'BODY.PEEK[1.2.TEXT]'], + $query->wireItems(), + ); + } + + public function testMimeHeader(): void + { + $query = (new ImapFetchQuery())->mimeHeader(2); + + self::assertSame(['BODY.PEEK[2.MIME]'], $query->wireItems()); + } + + public function testBodyPartWithPartialRange(): void + { + $query = (new ImapFetchQuery()) + ->bodyPart('1') + ->bodyPart('2', 0, 1024) + ->bodyPart('3', 100); + + self::assertSame( + [ + 'BODY.PEEK[1]', + 'BODY.PEEK[2]<0.1024>', + 'BODY.PEEK[3]<100>', + ], + $query->wireItems(), + ); + } + + public function testFullMsgWithAndWithoutRange(): void + { + $query = (new ImapFetchQuery())->fullMsg(); + + self::assertSame(['BODY.PEEK[]'], $query->wireItems()); + self::assertTrue($query->wantsFullMsg()); + + $ranged = (new ImapFetchQuery())->fullMsg(0, 512); + + self::assertSame(['BODY.PEEK[]<0.512>'], $ranged->wireItems()); + } + + public function testPeekFalseEmitsNonPeekingBodyItem(): void + { + $query = (new ImapFetchQuery())->bodyText(0, peek: false); + + self::assertSame(['BODY[TEXT]'], $query->wireItems()); + } + + public function testInheritedScalarFieldsDoNotEmitWireItems(): void + { + // uid/size/seq/imapDate are request flags the client turns into + // their own wire atoms; they are not BODY[...] items recorded here. + $query = (new ImapFetchQuery()) + ->uid() + ->size() + ->seq() + ->imapDate(); + + self::assertSame([], $query->wireItems()); + self::assertTrue($query->wantsUid()); + self::assertTrue($query->wantsSize()); + self::assertTrue($query->wantsSeq()); + self::assertTrue($query->wantsImapDate()); + } +} diff --git a/test/Unit/Src/ImapFetchResultTest.php b/test/Unit/Src/ImapFetchResultTest.php new file mode 100644 index 00000000..a49bd4af --- /dev/null +++ b/test/Unit/Src/ImapFetchResultTest.php @@ -0,0 +1,172 @@ +getUid()); + self::assertSame(7, $result->getSeq()); + + $result->setUid(4211); + + self::assertSame(4211, $result->getUid()); + } + + public function testScalarMetadataRoundTrips(): void + { + $result = new ImapFetchResult(1); + $result->setFlags(['\Seen', '\Answered']); + $result->setSize(2048); + $result->setModSeq(12345); + $date = new DateTimeImmutable('2026-01-02 03:04:05'); + $result->setImapDate($date); + + self::assertSame(['\Seen', '\Answered'], $result->getFlags()); + self::assertSame(2048, $result->getSize()); + self::assertSame(12345, $result->getModSeq()); + self::assertSame($date->getTimestamp(), $result->getImapDate()->getTimestamp()); + } + + public function testDefaultsForUnsetMetadata(): void + { + $result = new ImapFetchResult(1); + + self::assertSame([], $result->getFlags()); + self::assertSame(0, $result->getSize()); + self::assertNull($result->getModSeq()); + // Unset INTERNALDATE reads as the epoch. + self::assertSame(0, $result->getImapDate()->getTimestamp()); + } + + public function testContentStreamsRoundTripAsStrings(): void + { + $result = new ImapFetchResult(1); + $result->setFullMsg("Subject: x\r\n\r\nbody"); + $result->setHeaderText(0, 'Subject: x'); + $result->setBodyText(0, 'body'); + $result->setHeaderText('1', 'Content-Type: text/plain'); + $result->setBodyText('1', 'part body'); + + self::assertSame("Subject: x\r\n\r\nbody", (string) $result->getFullMsg()); + self::assertSame('Subject: x', (string) $result->getHeaderText()); + self::assertSame('body', (string) $result->getBodyText()); + self::assertSame('Content-Type: text/plain', (string) $result->getHeaderText('1')); + self::assertSame('part body', (string) $result->getBodyText('1')); + } + + public function testUnsetContentDefaultsToEmptyStream(): void + { + $result = new ImapFetchResult(1); + + self::assertSame('', (string) $result->getFullMsg()); + self::assertSame('', (string) $result->getHeaderText()); + self::assertSame('', (string) $result->getBodyText(3)); + } + + public function testPartAccessStoresBodyPartsAndMimeHeadersById(): void + { + $result = new ImapFetchResult(1); + $result->setBodyPart('1', 'first'); + $result->setBodyPart('2', 'second'); + $result->setMimeHeader('2', 'Content-Type: image/png'); + $result->setBodyPartSize('1', 5); + + self::assertSame('first', (string) $result->getBodyPart('1')); + self::assertSame('second', (string) $result->getBodyPart('2')); + self::assertSame('Content-Type: image/png', (string) $result->getMimeHeader('2')); + self::assertSame(5, $result->getBodyPartSize('1')); + self::assertNull($result->getBodyPartSize('2')); + } + + public function testEnvelopeDefaultsToEmptyWhenUnset(): void + { + $result = new ImapFetchResult(1); + + $envelope = $result->getEnvelope(); + + self::assertSame('', $envelope->subject); + self::assertSame('', $envelope->date); + } + + public function testEnvelopeRoundTrips(): void + { + $result = new ImapFetchResult(1); + $result->setEnvelope(new ImapEnvelope(subject: 'Hello', messageId: '')); + + self::assertSame('Hello', $result->getEnvelope()->subject); + self::assertSame('', $result->getEnvelope()->messageId); + } + + public function testHeadersParseIntoAHeaderCollectionByLabel(): void + { + $result = new ImapFetchResult(1); + $result->setHeaders('std', "From: alice@a.com\r\nSubject: Hi\r\n"); + + $headers = $result->getHeaders('std'); + + self::assertInstanceOf(HeaderCollection::class, $headers); + self::assertSame('alice@a.com', $headers->get('From')->value()); + self::assertSame('Hi', $headers->get('Subject')->value()); + } + + public function testUnknownHeaderLabelYieldsEmptyCollection(): void + { + $result = new ImapFetchResult(1); + + self::assertCount(0, $result->getHeaders('missing')->all()); + } + + public function testGetPartsIsEmptyWithoutStructure(): void + { + $result = new ImapFetchResult(1); + + self::assertSame([], iterator_to_array($result->getParts())); + } + + public function testGetPartsYieldsCanonicalMimeIdKeyedChildren(): void + { + $result = new ImapFetchResult(1); + $child1 = new Part(headers: HeaderCollection::parse("Content-Type: text/plain\r\n")); + $child2 = new Part(headers: HeaderCollection::parse("Content-Type: text/html\r\n")); + $result->setStructure(new Part( + headers: HeaderCollection::parse("Content-Type: multipart/alternative\r\n"), + children: [$child1, $child2], + )); + + $parts = []; + + foreach ($result->getParts() as $id => $part) { + $parts[$id] = $part->fullType(); + } + + self::assertSame( + ['1' => 'text/plain', '2' => 'text/html'], + $parts, + ); + } +} diff --git a/test/Unit/Src/ImapIdSetTest.php b/test/Unit/Src/ImapIdSetTest.php new file mode 100644 index 00000000..d406b8a7 --- /dev/null +++ b/test/Unit/Src/ImapIdSetTest.php @@ -0,0 +1,245 @@ +isEmpty()); + self::assertFalse($ids->isSpecial()); + self::assertSame(0, $ids->count()); + self::assertSame([], $ids->toArray()); + self::assertSame('', (string) $ids); + self::assertNull($ids->min()); + self::assertNull($ids->max()); + self::assertNull($ids->token()); + } + + public function testExplicitListDeduplicatesPreservingFirstSeenOrder(): void + { + $ids = new ImapIdSet([3, 1, 3, 2, 1]); + + self::assertSame([3, 1, 2], $ids->toArray()); + self::assertSame(3, $ids->count()); + self::assertFalse($ids->isEmpty()); + } + + public function testNumericStringsAreCastToInt(): void + { + $ids = new ImapIdSet(['5', '7', 9]); + + self::assertSame([5, 7, 9], $ids->toArray()); + } + + public function testIterationYieldsIds(): void + { + $ids = new ImapIdSet([1, 2, 3]); + + self::assertSame([1, 2, 3], iterator_to_array($ids->getIterator())); + } + + public function testMinMax(): void + { + $ids = new ImapIdSet([7, 2, 9, 4]); + + self::assertSame(2, $ids->min()); + self::assertSame(9, $ids->max()); + } + + /** + * @param list $input + */ + #[DataProvider('sequenceStringProvider')] + public function testToStringCompressesRanges(array $input, string $expected): void + { + self::assertSame($expected, (string) new ImapIdSet($input)); + } + + /** + * @return iterable, string}> + */ + public static function sequenceStringProvider(): iterable + { + yield 'single' => [[5], '5']; + yield 'contiguous run' => [[1, 2, 3, 4, 5], '1:5']; + yield 'run then gap' => [[1, 2, 3, 7], '1:3,7']; + yield 'mixed ranges and singletons' => [[1, 2, 3, 5, 7, 8, 9], '1:3,5,7:9']; + yield 'unsorted input is sorted' => [[9, 1, 3, 2], '1:3,9']; + yield 'two element range' => [[4, 5], '4:5']; + yield 'non adjacent singletons' => [[2, 4, 6], '2,4,6']; + } + + /** + * @param list $expected + */ + #[DataProvider('parseProvider')] + public function testFromSequenceStringParsesRanges(string $input, array $expected): void + { + $ids = ImapIdSet::fromSequenceString($input); + + self::assertSame($expected, $ids->toArray()); + self::assertFalse($ids->isSpecial()); + } + + /** + * @return iterable}> + */ + public static function parseProvider(): iterable + { + yield 'empty' => ['', []]; + yield 'whitespace only' => [' ', []]; + yield 'single' => ['5', [5]]; + yield 'range' => ['1:5', [1, 2, 3, 4, 5]]; + yield 'mixed' => ['1:3,7,9:11', [1, 2, 3, 7, 9, 10, 11]]; + yield 'reversed range is normalized' => ['5:1', [1, 2, 3, 4, 5]]; + yield 'surrounding whitespace trimmed' => [' 1:3 ', [1, 2, 3]]; + } + + public function testRoundTripThroughSequenceString(): void + { + $original = '1:3,7,9:11'; + $ids = ImapIdSet::fromSequenceString($original); + + self::assertSame($original, (string) $ids); + } + + public function testConstructFromSequenceString(): void + { + $ids = new ImapIdSet('2,4:6'); + + self::assertSame([2, 4, 5, 6], $ids->toArray()); + self::assertSame('2,4:6', (string) $ids); + } + + public function testConstructFromSingleInt(): void + { + $ids = new ImapIdSet(42); + + self::assertSame([42], $ids->toArray()); + self::assertSame('42', (string) $ids); + } + + /** + * @param non-empty-string $wire + */ + #[DataProvider('specialTokenProvider')] + public function testSpecialTokens(ImapIdSetToken $token, string $wire): void + { + $ids = new ImapIdSet($token); + + self::assertTrue($ids->isSpecial()); + self::assertSame($token, $ids->token()); + self::assertSame($wire, (string) $ids); + // A special set has no concrete list to enumerate. + self::assertSame([], $ids->toArray()); + self::assertSame(0, $ids->count()); + // isEmpty() is false: it represents messages, just not by list. + self::assertFalse($ids->isEmpty()); + } + + /** + * @return iterable + */ + public static function specialTokenProvider(): iterable + { + yield 'all' => [ImapIdSetToken::All, '1:*']; + yield 'largest' => [ImapIdSetToken::Largest, '*']; + yield 'search result' => [ImapIdSetToken::SearchRes, '$']; + } + + /** + * @param non-empty-string $wire + */ + #[DataProvider('specialTokenProvider')] + public function testFromSequenceStringRecognizesSpecialTokens(ImapIdSetToken $token, string $wire): void + { + $ids = ImapIdSet::fromSequenceString($wire); + + self::assertTrue($ids->isSpecial()); + self::assertSame($token, $ids->token()); + self::assertSame($wire, (string) $ids); + } + + public function testSequenceFlag(): void + { + $seq = new ImapIdSet([1, 2, 3], true); + $uid = new ImapIdSet([1, 2, 3]); + + self::assertTrue($seq->isSequence()); + self::assertFalse($uid->isSequence()); + } + + public function testAddReturnsNewSetAndIsImmutable(): void + { + $ids = new ImapIdSet([1, 2, 3]); + $more = $ids->add([3, 4, 5]); + + self::assertSame([1, 2, 3], $ids->toArray()); + self::assertSame([1, 2, 3, 4, 5], $more->toArray()); + self::assertNotSame($ids, $more); + } + + public function testAddAcceptsSequenceString(): void + { + $ids = (new ImapIdSet([1]))->add('3:5'); + + self::assertSame([1, 3, 4, 5], $ids->toArray()); + } + + public function testRemoveReturnsNewSet(): void + { + $ids = new ImapIdSet([1, 2, 3, 4, 5]); + $fewer = $ids->remove([2, 4]); + + self::assertSame([1, 2, 3, 4, 5], $ids->toArray()); + self::assertSame([1, 3, 5], $fewer->toArray()); + } + + public function testRemoveFromSpecialSetIsNoOp(): void + { + $ids = new ImapIdSet(ImapIdSetToken::All); + $result = $ids->remove([1, 2]); + + self::assertTrue($result->isSpecial()); + self::assertSame(ImapIdSetToken::All, $result->token()); + } + + public function testAddToSpecialSetProducesExplicitList(): void + { + $ids = new ImapIdSet(ImapIdSetToken::All); + $result = $ids->add([1, 2]); + + self::assertFalse($result->isSpecial()); + self::assertSame([1, 2], $result->toArray()); + } + + public function testSequenceFlagPreservedThroughAdd(): void + { + $ids = (new ImapIdSet([1], true))->add([2]); + + self::assertTrue($ids->isSequence()); + } +} diff --git a/test/Unit/Src/ImapInteractionTest.php b/test/Unit/Src/ImapInteractionTest.php new file mode 100644 index 00000000..92f1951a --- /dev/null +++ b/test/Unit/Src/ImapInteractionTest.php @@ -0,0 +1,129 @@ +send('LOGIN', ['admin', 'sw0rdfish']); + + self::assertTrue($result->tagged->isOk()); + self::assertSame([], $result->untagged); + } + + public function testSendCollectsUntaggedResponsesBeforeTagged(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 1 EXISTS'), + InMemoryImapSocket::line('* 0 RECENT'), + InMemoryImapSocket::line('A1 OK SELECT completed.'), + ); + $interaction = new ImapInteraction(new ImapConnection($socket)); + + $result = $interaction->send('SELECT', ['INBOX']); + + self::assertCount(2, $result->untagged); + self::assertSame(['1', 'EXISTS'], $result->untagged[0]->data); + self::assertSame(['0', 'RECENT'], $result->untagged[1]->data); + self::assertTrue($result->tagged->isOk()); + } + + public function testSendThrowsServerResponseExceptionOnNo(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 NO Mailbox does not exist.'), + ); + $interaction = new ImapInteraction(new ImapConnection($socket)); + + try { + $interaction->send('SELECT', ['Nonexistent']); + self::fail('Expected a ServerResponseException.'); + } catch (ServerResponseException $e) { + self::assertSame('Mailbox does not exist.', $e->getMessage()); + self::assertSame('SELECT', $e->command); + self::assertSame('NO', $e->status); + } + } + + public function testSendThrowsServerResponseExceptionOnBad(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 BAD Command unrecognized.'), + ); + $interaction = new ImapInteraction(new ImapConnection($socket)); + + $this->expectException(ServerResponseException::class); + + $interaction->send('BOGUS'); + } + + public function testEachSendUsesANewTag(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A1 OK NOOP completed.'), + InMemoryImapSocket::line('A2 OK NOOP completed.'), + ); + $interaction = new ImapInteraction(new ImapConnection($socket)); + + $interaction->send('NOOP'); + $interaction->send('NOOP'); + + self::assertSame(['A1 NOOP', 'A2 NOOP'], $socket->written); + } + + public function testIgnoresDanglingTaggedResponse(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('STALE OK Leftover from an aborted exchange.'), + InMemoryImapSocket::line('A1 OK NOOP completed.'), + ); + $interaction = new ImapInteraction(new ImapConnection($socket)); + + $result = $interaction->send('NOOP'); + + self::assertTrue($result->tagged->isOk()); + self::assertSame('A1', $result->tagged->tag); + } + + public function testThrowsOnTaggedResponseForAStillPendingCommand(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A0 OK Some other in-flight command.'), + ); + $pipeline = new ImapPipeline(); + $pipeline->enqueue(new ImapCommand('A0', 'OLDCMD')); + $interaction = new ImapInteraction(new ImapConnection($socket), pipeline: $pipeline); + + $this->expectException(ImapProtocolException::class); + + $interaction->send('NOOP'); + } +} diff --git a/test/Unit/Src/ImapMailboxNameCodecTest.php b/test/Unit/Src/ImapMailboxNameCodecTest.php new file mode 100644 index 00000000..7d15faae --- /dev/null +++ b/test/Unit/Src/ImapMailboxNameCodecTest.php @@ -0,0 +1,53 @@ +encode('INBOX.Sent')); + self::assertSame('INBOX.Sent', $codec->decode('INBOX.Sent')); + } + + public function testNonAsciiNameEncodesToModifiedUtf7(): void + { + $codec = new ImapModifiedUtf7Codec(); + + // "Postfach" (German for "mailbox") with an umlaut, matching a + // well-known modified UTF-7 test vector. + $encoded = $codec->encode('Präsentation'); + + self::assertSame('Pr&AOQ-sentation', $encoded); + self::assertSame('Präsentation', $codec->decode($encoded)); + } + + public function testUtf8CodecIsANoOp(): void + { + $codec = new ImapUtf8MailboxNameCodec(); + + self::assertSame('Präsentation', $codec->encode('Präsentation')); + self::assertSame('Präsentation', $codec->decode('Präsentation')); + } +} diff --git a/test/Unit/Src/ImapPipelineTest.php b/test/Unit/Src/ImapPipelineTest.php new file mode 100644 index 00000000..471b5448 --- /dev/null +++ b/test/Unit/Src/ImapPipelineTest.php @@ -0,0 +1,68 @@ +enqueue(new ImapCommand('A1', 'NOOP')); + + self::assertTrue($pipeline->isPending('A1')); + self::assertSame(1, $pipeline->count()); + self::assertFalse($pipeline->isEmpty()); + } + + public function testCompleteRemovesAndReturnsTheCommand(): void + { + $pipeline = new ImapPipeline(); + $command = new ImapCommand('A1', 'NOOP'); + $pipeline->enqueue($command); + + $completed = $pipeline->complete('A1'); + + self::assertSame($command, $completed); + self::assertFalse($pipeline->isPending('A1')); + self::assertTrue($pipeline->isEmpty()); + } + + public function testCompletingAnUnknownTagReturnsNull(): void + { + $pipeline = new ImapPipeline(); + + self::assertNull($pipeline->complete('A9')); + } + + public function testTracksMultipleOutstandingCommands(): void + { + $pipeline = new ImapPipeline(); + $pipeline->enqueue(new ImapCommand('A1', 'NOOP')); + $pipeline->enqueue(new ImapCommand('A2', 'NOOP')); + + self::assertSame(2, $pipeline->count()); + + $pipeline->complete('A1'); + + self::assertSame(1, $pipeline->count()); + self::assertTrue($pipeline->isPending('A2')); + } +} diff --git a/test/Unit/Src/ImapResponseParserTest.php b/test/Unit/Src/ImapResponseParserTest.php new file mode 100644 index 00000000..62f5ab5f --- /dev/null +++ b/test/Unit/Src/ImapResponseParserTest.php @@ -0,0 +1,172 @@ +isTagged()); + self::assertSame('A1', $response->tag); + self::assertTrue($response->isOk()); + self::assertSame('LOGIN completed.', $response->text); + self::assertNull($response->responseCode); + } + + public function testParsesTaggedNo(): void + { + $response = ImapResponseParser::parse(['A2', 'NO', 'Mailbox', 'does', 'not', 'exist.']); + + self::assertTrue($response->isNo()); + self::assertFalse($response->isOk()); + self::assertSame('Mailbox does not exist.', $response->text); + } + + public function testParsesTaggedBad(): void + { + $response = ImapResponseParser::parse(['A3', 'BAD', 'Command', 'unrecognized.']); + + self::assertTrue($response->isBad()); + } + + public function testTaggedWithoutStatusThrows(): void + { + $this->expectException(ImapProtocolException::class); + + ImapResponseParser::parse(['A4', 'garbage']); + } + + public function testEmptyResponseThrows(): void + { + $this->expectException(ImapProtocolException::class); + + ImapResponseParser::parse([]); + } + + public function testParsesContinuation(): void + { + $response = ImapResponseParser::parse(['+', 'PLBASE64CHALLENGE==']); + + self::assertTrue($response->isContinuation()); + self::assertSame('PLBASE64CHALLENGE==', $response->text); + } + + public function testParsesBareContinuation(): void + { + $response = ImapResponseParser::parse(['+']); + + self::assertTrue($response->isContinuation()); + self::assertSame('', $response->text); + } + + public function testParsesUntaggedStatus(): void + { + $response = ImapResponseParser::parse(['*', 'OK', 'IMAP4rev2', 'Server', 'ready.']); + + self::assertTrue($response->isUntagged()); + self::assertTrue($response->isOk()); + self::assertSame('IMAP4rev2 Server ready.', $response->text); + } + + public function testParsesUntaggedBye(): void + { + $response = ImapResponseParser::parse(['*', 'BYE', 'Logging', 'out.']); + + self::assertTrue($response->isBye()); + } + + public function testParsesUntaggedPreAuth(): void + { + $response = ImapResponseParser::parse(['*', 'PREAUTH', 'Already', 'authenticated.']); + + self::assertSame(ImapResponseStatus::PreAuth, $response->status); + } + + public function testParsesUntaggedDataWithoutStatus(): void + { + $response = ImapResponseParser::parse(['*', '1', 'EXISTS']); + + self::assertTrue($response->isUntagged()); + self::assertNull($response->status); + self::assertSame(['1', 'EXISTS'], $response->data); + self::assertSame('1 EXISTS', $response->text); + } + + public function testParsesUntaggedDataWithNestedList(): void + { + $response = ImapResponseParser::parse(['*', 'FLAGS', ['\\Seen', '\\Deleted']]); + + self::assertSame(['FLAGS', ['\\Seen', '\\Deleted']], $response->data); + self::assertSame('FLAGS (\\Seen \\Deleted)', $response->text); + } + + public function testParsesBracketedResponseCodeInOneToken(): void + { + $response = ImapResponseParser::parse(['A5', 'OK', '[READ-WRITE]', 'SELECT', 'completed.']); + + self::assertNotNull($response->responseCode); + self::assertSame('READ-WRITE', $response->responseCode->name); + self::assertSame([], $response->responseCode->data); + self::assertSame('SELECT completed.', $response->text); + } + + public function testParsesBracketedResponseCodeSpanningTokens(): void + { + $response = ImapResponseParser::parse(['A6', 'OK', '[UIDVALIDITY', '3857529045]', 'UIDs', 'valid.']); + + self::assertSame('UIDVALIDITY', $response->responseCode->name); + self::assertSame(['3857529045'], $response->responseCode->data); + self::assertSame('UIDs valid.', $response->text); + } + + public function testParsesBracketedResponseCodeWithNestedList(): void + { + $response = ImapResponseParser::parse([ + 'A7', + 'OK', + '[PERMANENTFLAGS', + ['\\Deleted', '\\Seen', '\\*'], + ']', + ]); + + self::assertSame('PERMANENTFLAGS', $response->responseCode->name); + self::assertSame([['\\Deleted', '\\Seen', '\\*']], $response->responseCode->data); + self::assertSame([], $response->data); + } + + public function testUntaggedStatusWithoutResponseCode(): void + { + $response = ImapResponseParser::parse(['*', 'NO', 'Disk', 'quota', 'exceeded.']); + + self::assertNull($response->responseCode); + self::assertSame('Disk quota exceeded.', $response->text); + } + + public function testResponseKindEnumValues(): void + { + self::assertNotSame(ImapResponseKind::Tagged, ImapResponseKind::Untagged); + self::assertNotSame(ImapResponseKind::Untagged, ImapResponseKind::Continuation); + } +} diff --git a/test/Unit/Src/ImapSearchQueryTest.php b/test/Unit/Src/ImapSearchQueryTest.php new file mode 100644 index 00000000..0edaeb7d --- /dev/null +++ b/test/Unit/Src/ImapSearchQueryTest.php @@ -0,0 +1,187 @@ +build(); + $parts = []; + + foreach ($built['criteria'] as $token) { + $escaped = $token->escape(); + $parts[] = $token instanceof \Horde\Imap\Client\ImapWireList ? "({$escaped})" : $escaped; + } + + return implode(' ', $parts); + } + + public function testSystemFlagRendersAsSingleToken(): void + { + self::assertSame('SEEN', $this->render((new ImapSearchQuery())->flag('\\Seen'))); + self::assertSame('UNSEEN', $this->render((new ImapSearchQuery())->flag('Seen', false))); + self::assertSame('FLAGGED', $this->render((new ImapSearchQuery())->flag('\\Flagged'))); + } + + public function testKeywordRendersAsPair(): void + { + self::assertSame('KEYWORD $IMPORTANT', $this->render((new ImapSearchQuery())->flag('$Important'))); + self::assertSame('UNKEYWORD NOSPAM', $this->render((new ImapSearchQuery())->flag('NoSpam', false))); + } + + public function testHeaderWellKnownAndGeneric(): void + { + self::assertSame('SUBJECT hello', $this->render((new ImapSearchQuery())->header('Subject', 'hello'))); + self::assertSame( + 'HEADER X-SPAM YES', + $this->render((new ImapSearchQuery())->header('X-Spam', 'YES')), + ); + } + + public function testHeaderNotNegates(): void + { + self::assertSame('NOT FROM bob', $this->render((new ImapSearchQuery())->header('From', 'bob', not: true))); + } + + public function testTextBodyAndFullText(): void + { + self::assertSame('BODY needle', $this->render((new ImapSearchQuery())->text('needle'))); + self::assertSame('TEXT needle', $this->render((new ImapSearchQuery())->text('needle', bodyOnly: false))); + } + + public function testTextWithSpacesQuotes(): void + { + self::assertSame('BODY "two words"', $this->render((new ImapSearchQuery())->text('two words'))); + } + + public function testSize(): void + { + self::assertSame('LARGER 1024', $this->render((new ImapSearchQuery())->size(1024))); + self::assertSame('SMALLER 512', $this->render((new ImapSearchQuery())->size(512, larger: false))); + } + + public function testIdsUidAndSequence(): void + { + $uid = new ImapIdSet([4, 5, 6], false); + self::assertSame('UID 4:6', $this->render((new ImapSearchQuery())->ids($uid))); + + $seq = new ImapIdSet([1, 2, 3], true); + self::assertSame('1:3', $this->render((new ImapSearchQuery())->ids($seq))); + } + + public function testDateVariants(): void + { + $date = new DateTimeImmutable('2024-02-01T00:00:00Z'); + + self::assertSame('SINCE 1-Feb-2024', $this->render((new ImapSearchQuery())->date($date))); + self::assertSame( + 'SENTBEFORE 1-Feb-2024', + $this->render((new ImapSearchQuery())->date($date, 'BEFORE', sent: true)), + ); + } + + public function testWithin(): void + { + self::assertSame('YOUNGER 3600', $this->render((new ImapSearchQuery())->within(3600))); + self::assertSame('OLDER 86400', $this->render((new ImapSearchQuery())->within(86400, younger: false))); + } + + public function testModseq(): void + { + self::assertSame('MODSEQ 720', $this->render((new ImapSearchQuery())->modseq(720))); + self::assertSame( + 'MODSEQ "/flags/\\\\draft" all 720', + $this->render((new ImapSearchQuery())->modseq(720, '/flags/\\draft', 'all')), + ); + } + + public function testPreviousSearch(): void + { + self::assertSame('$', $this->render((new ImapSearchQuery())->previousSearch())); + self::assertSame('NOT $', $this->render((new ImapSearchQuery())->previousSearch(not: true))); + } + + public function testImplicitAndConcatenates(): void + { + $query = (new ImapSearchQuery())->flag('\\Seen')->size(1024)->header('Subject', 'x'); + + self::assertSame('SEEN LARGER 1024 SUBJECT x', $this->render($query)); + } + + public function testNotMatchingWrapsGroup(): void + { + $inner = (new ImapSearchQuery())->flag('\\Deleted'); + $query = (new ImapSearchQuery())->flag('\\Seen')->notMatching($inner); + + self::assertSame('SEEN NOT (DELETED)', $this->render($query)); + } + + public function testOrWithWrapsBothSides(): void + { + $query = (new ImapSearchQuery())->flag('\\Seen'); + $query->orWith((new ImapSearchQuery())->flag('\\Flagged')); + + self::assertSame('OR (SEEN) (FLAGGED)', $this->render($query)); + } + + public function testNonAsciiTextSetsUtf8Charset(): void + { + $query = (new ImapSearchQuery())->text('naïve'); + $built = $query->build(); + + self::assertSame('UTF-8', $built['charset']); + } + + public function testAsciiOnlyQueryHasNullCharset(): void + { + $built = (new ImapSearchQuery())->flag('\\Seen')->text('plain')->build(); + + self::assertNull($built['charset']); + } + + public function testExplicitCharsetOverrides(): void + { + $built = (new ImapSearchQuery())->charset('iso-8859-1')->text('plain')->build(); + + self::assertSame('ISO-8859-1', $built['charset']); + } + + public function testEmptyQueryHasNoCriteria(): void + { + self::assertSame([], (new ImapSearchQuery())->build()['criteria']); + } + + public function testFuzzyPrefixesFollowingCriterion(): void + { + self::assertSame('FUZZY BODY mispeld', $this->render((new ImapSearchQuery())->fuzzy()->text('mispeld'))); + } +} diff --git a/test/Unit/Src/ImapTokenizerTest.php b/test/Unit/Src/ImapTokenizerTest.php new file mode 100644 index 00000000..ba19baee --- /dev/null +++ b/test/Unit/Src/ImapTokenizerTest.php @@ -0,0 +1,190 @@ +readLine(); + + self::assertSame( + ['*', 'CAPABILITY', 'IMAP4rev1', 'SASL-IR', 'AUTH=PLAIN'], + $tokens, + ); + } + + public function testTaggedOkCompletion(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('A001 OK LOGIN completed'), + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame(['A001', 'OK', 'LOGIN', 'completed'], $tokens); + } + + public function testParenthesizedList(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)'), + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', 'FLAGS', ['\\Answered', '\\Flagged', '\\Deleted', '\\Seen', '\\Draft']], + $tokens, + ); + } + + public function testNestedParenthesizedLists(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* SEARCH'), + ); + $tokenizer = new ImapTokenizer($socket); + self::assertSame(['*', 'SEARCH'], $tokenizer->readLine()); + + $socket2 = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* LIST (\\HasNoChildren (nested)) "/" "INBOX"'), + ); + $tokens = (new ImapTokenizer($socket2))->readLine(); + + self::assertSame( + ['*', 'LIST', ['\\HasNoChildren', ['nested']], '/', 'INBOX'], + $tokens, + ); + } + + public function testQuotedStringWithEscapes(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* OK [ALERT] "System going down for \\"maintenance\\" soon"'), + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', 'OK', '[ALERT]', 'System going down for "maintenance" soon'], + $tokens, + ); + } + + public function testNilBecomesNull(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 12 FETCH (INTERNALDATE NIL)'), + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame(['*', '12', 'FETCH', ['INTERNALDATE', null]], $tokens); + } + + public function testLiteralPayloadResolvesAndContinuesOnNextLine(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 1 FETCH (BODY[] {15}'), + "Hello, world!\r\n)\r\n", + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', '1', 'FETCH', ['BODY[]', "Hello, world!\r\n"]], + $tokens, + ); + } + + public function testLiteralFollowedByMoreTokensOnSameLogicalResponse(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 12 FETCH (BODY[] {5}'), + "hello FLAGS (\\Seen))\r\n", + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', '12', 'FETCH', ['BODY[]', 'hello', 'FLAGS', ['\\Seen']]], + $tokens, + ); + } + + public function testBinaryLiteral8IsResolvedLikeAnOrdinaryLiteral(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 1 FETCH (BINARY[] ~{4}'), + "\x00\x01\x02\x03)", + "\r\n", + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', '1', 'FETCH', ['BINARY[]', "\x00\x01\x02\x03"]], + $tokens, + ); + } + + public function testEmptyLiteral(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 1 FETCH (BODY[] {0}'), + ")\r\n", + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame(['*', '1', 'FETCH', ['BODY[]', '']], $tokens); + } + + public function testConnectionClosedMidResponseThrows(): void + { + $socket = InMemoryImapSocket::fromParts(''); + + $this->expectException(ImapProtocolException::class); + + (new ImapTokenizer($socket))->readLine(); + } + + public function testAtomWithBracketedSectionSpecifier(): void + { + $socket = InMemoryImapSocket::fromParts( + InMemoryImapSocket::line('* 3 FETCH (BODY[HEADER.FIELDS (SUBJECT)] {2}'), + "Hi)", + "\r\n", + ); + + $tokens = (new ImapTokenizer($socket))->readLine(); + + self::assertSame( + ['*', '3', 'FETCH', ['BODY[HEADER.FIELDS', ['SUBJECT'], ']', 'Hi']], + $tokens, + ); + } +} diff --git a/test/Unit/Src/ImapWireValueTest.php b/test/Unit/Src/ImapWireValueTest.php new file mode 100644 index 00000000..f7e580bc --- /dev/null +++ b/test/Unit/Src/ImapWireValueTest.php @@ -0,0 +1,194 @@ +escape()); + } + + public function testEmptyAtomEscapesAsEmptyQuotedString(): void + { + self::assertSame('""', (new ImapWireAtom(''))->escape()); + } + + public function testAtomRejectsIllegalCharacters(): void + { + $this->expectException(WireEncodingException::class); + + (new ImapWireAtom('has space'))->validate(); + } + + public function testAtomEscapeAllowsLeadingBackslashForFlags(): void + { + self::assertSame('\\Seen', (new ImapWireAtom('\\Seen'))->escape()); + } + + public function testNumberEscapesAsDecimal(): void + { + self::assertSame('42', (new ImapWireNumber(42))->escape()); + } + + public function testNumberRejectsNegativeValues(): void + { + $this->expectException(WireEncodingException::class); + + new ImapWireNumber(-1); + } + + public function testNilEscapesToNilKeyword(): void + { + self::assertSame('NIL', (new ImapWireNil())->escape()); + } + + public function testPlainStringEscapesUnquotedWhenSafe(): void + { + self::assertSame('hello', (new ImapWireString('hello'))->escape()); + } + + public function testStringWithSpaceIsQuoted(): void + { + self::assertSame('"hello world"', (new ImapWireString('hello world'))->escape()); + } + + public function testStringQuotingEscapesBackslashAndQuote(): void + { + self::assertSame('"say \\"hi\\" \\\\ok"', (new ImapWireString('say "hi" \\ok'))->escape()); + } + + public function testAstringQuotesEmptyStringButPlainStringDoesNot(): void + { + self::assertSame('""', (new ImapWireString('', isAstring: true))->escape()); + self::assertSame('', (new ImapWireString('', isAstring: false))->escape()); + } + + public function testStringWithEmbeddedNewlineRequiresLiteral(): void + { + $string = new ImapWireString("line1\nline2"); + + self::assertTrue($string->isLiteral()); + self::assertFalse($string->isBinary()); + self::assertSame("line1\nline2", $string->rawBytes()); + } + + public function testEscapeOnLiteralRequiredStringThrows(): void + { + $string = new ImapWireString("line1\nline2"); + + $this->expectException(WireEncodingException::class); + + $string->escape(); + } + + public function testStringWithNullByteIsBinaryLiteral(): void + { + $string = new ImapWireString("has\x00null"); + + self::assertTrue($string->isLiteral()); + self::assertTrue($string->isBinary()); + } + + public function testWildcardsAreQuotedByDefaultButNotForListPatterns(): void + { + self::assertSame('"INBOX.*"', (new ImapWireString('INBOX.*'))->escape()); + self::assertSame('INBOX.*', (new ImapWireString('INBOX.*', allowWildcards: true))->escape()); + } + + public function testNstringEscapesNullAsNil(): void + { + self::assertSame('NIL', (new ImapWireNstring(null))->escape()); + } + + public function testNstringEscapesStringNormally(): void + { + self::assertSame('"hi there"', (new ImapWireNstring('hi there'))->escape()); + } + + public function testMailboxEncodesThroughCodec(): void + { + $mailbox = new ImapWireMailbox('Sent', new ImapUtf8MailboxNameCodec()); + + self::assertSame('Sent', $mailbox->escape()); + } + + public function testMailboxWithSpaceIsQuoted(): void + { + $mailbox = new ImapWireMailbox('My Folder', new ImapUtf8MailboxNameCodec()); + + self::assertSame('"My Folder"', $mailbox->escape()); + } + + public function testMailboxRejectsNullByte(): void + { + $this->expectException(WireEncodingException::class); + + new ImapWireMailbox("has\x00null", new ImapUtf8MailboxNameCodec()); + } + + public function testListEscapesAtomMembers(): void + { + $list = new ImapWireList(['\\Deleted', '\\Seen']); + + self::assertSame('\\Deleted \\Seen', $list->escape()); + self::assertCount(2, $list); + } + + public function testNestedListEscapesWithParens(): void + { + $list = new ImapWireList([ + new ImapWireAtom('UID'), + new ImapWireList([new ImapWireAtom('FLAGS'), new ImapWireAtom('\\Seen')]), + ]); + + self::assertSame('UID (FLAGS \\Seen)', $list->escape()); + } + + public function testListEscapeThrowsWhenAMemberRequiresLiteral(): void + { + $list = new ImapWireList([new ImapWireString("has\nnewline")]); + + $this->expectException(WireEncodingException::class); + + $list->escape(); + } + + public function testListRawBytesAlwaysThrows(): void + { + $this->expectException(WireEncodingException::class); + + (new ImapWireList())->rawBytes(); + } +} diff --git a/test/Unit/Src/InMemoryImapSocket.php b/test/Unit/Src/InMemoryImapSocket.php new file mode 100644 index 00000000..03849110 --- /dev/null +++ b/test/Unit/Src/InMemoryImapSocket.php @@ -0,0 +1,114 @@ + */ + public array $written = []; + + public function __construct( + private readonly string $buffer, + ) {} + + /** + * Build the byte script from response lines (each without its own + * CRLF, which is appended automatically) and raw literal payloads + * (passed as-is, with no CRLF appended). + */ + public static function fromParts(string ...$parts): self + { + return new self(implode('', $parts)); + } + + public static function line(string $line): string + { + return $line . "\r\n"; + } + + public function isConnected(): bool + { + return true; + } + + public function isSecure(): bool + { + return false; + } + + public function supportsChannelBinding(ChannelBindingType $type): bool + { + return false; + } + + public function channelBindingData(ChannelBindingType $type): string + { + return ''; + } + + public function startTls(): bool + { + return true; + } + + public function close(): void + { + } + + public function getStatus(): StreamStatus + { + return new StreamStatus(false, false, $this->pos >= strlen($this->buffer), 0); + } + + public function gets(int $size): string + { + if ($this->pos >= strlen($this->buffer)) { + // Real end of stream: fgets() returns nothing more to read. + return ''; + } + + $newline = strpos($this->buffer, "\n", $this->pos); + + if ($newline === false) { + $chunk = substr($this->buffer, $this->pos); + $this->pos = strlen($this->buffer); + + return $chunk; + } + + $chunk = substr($this->buffer, $this->pos, $newline - $this->pos + 1); + $this->pos = $newline + 1; + + return $chunk; + } + + public function read(int $size): string + { + $chunk = substr($this->buffer, $this->pos, $size); + $this->pos += strlen($chunk); + + return $chunk; + } + + public function write(string $data): void + { + $this->written[] = rtrim($data, "\r\n"); + } +} diff --git a/test/Unit/Src/InMemoryPop3Socket.php b/test/Unit/Src/InMemoryPop3Socket.php new file mode 100644 index 00000000..3bef32a4 --- /dev/null +++ b/test/Unit/Src/InMemoryPop3Socket.php @@ -0,0 +1,89 @@ + */ + private array $queue; + + /** @var list */ + public array $written = []; + + /** + * @param list $lines Lines to hand back from `gets()`, in + * order, each without a trailing CRLF (it + * is appended automatically). + */ + public function __construct(array $lines = []) + { + $this->queue = $lines; + } + + public function isConnected(): bool + { + return true; + } + + public function isSecure(): bool + { + return false; + } + + public function supportsChannelBinding(ChannelBindingType $type): bool + { + return false; + } + + public function channelBindingData(ChannelBindingType $type): string + { + return ''; + } + + public function startTls(): bool + { + return true; + } + + public function close(): void + { + } + + public function getStatus(): StreamStatus + { + return new StreamStatus(false, false, $this->queue === [], 0); + } + + public function gets(int $size): string + { + if ($this->queue === []) { + throw new \RuntimeException('InMemoryPop3Socket: read past the end of the scripted queue.'); + } + + return array_shift($this->queue) . "\r\n"; + } + + public function read(int $size): string + { + return $this->gets($size); + } + + public function write(string $data): void + { + $this->written[] = rtrim($data, "\r\n"); + } +} diff --git a/test/Unit/Src/MailboxEventDispatchTest.php b/test/Unit/Src/MailboxEventDispatchTest.php new file mode 100644 index 00000000..b71d0153 --- /dev/null +++ b/test/Unit/Src/MailboxEventDispatchTest.php @@ -0,0 +1,156 @@ + */ + private array $events = []; + + private function dispatcher(): EventDispatcherInterface + { + $sink = &$this->events; + + return new class ($sink) implements EventDispatcherInterface { + /** @param list $sink */ + public function __construct(private array &$sink) {} + + public function dispatch(object $event): object + { + $this->sink[] = $event; + + return $event; + } + }; + } + + private function config(): ConnectionConfig + { + return new ConnectionConfig( + hostspec: 'imap.example.test', + saslPolicy: SaslPolicy::legacyCompatible(), + ); + } + + private function client(InMemoryImapSocket $socket): ImapClient + { + return new ImapClient($this->config(), null, $this->dispatcher(), $socket, null); + } + + private function socket(string ...$lines): InMemoryImapSocket + { + return InMemoryImapSocket::fromParts( + ...array_map(InMemoryImapSocket::line(...), $lines), + ); + } + + /** + * @template T of object + * @param class-string $class + * @return list + */ + private function eventsOf(string $class): array + { + return array_values(array_filter( + $this->events, + static fn(object $e): bool => $e instanceof $class, + )); + } + + public function testOpenMailboxDispatchesMailboxSelectedWithSyncState(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1] Server ready.', + '* OK [UIDVALIDITY 42] .', + '* OK [UIDNEXT 100] .', + '* OK [HIGHESTMODSEQ 715] .', + 'A1 OK [READ-WRITE] SELECT completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + + $selected = $this->eventsOf(MailboxSelected::class); + self::assertCount(1, $selected); + self::assertSame('INBOX', $selected[0]->mailbox); + self::assertSame(42, $selected[0]->uidvalidity); + self::assertSame(100, $selected[0]->uidnext); + self::assertSame(715, $selected[0]->highestmodseq); + } + + public function testExpungeDispatchesMailboxExpungedWithVanishedUids(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 QRESYNC] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + // A QRESYNC connection answers EXPUNGE with VANISHED (UIDs). + '* VANISHED 405,410:412', + 'A2 OK EXPUNGE completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $client->expunge('INBOX'); + + $expunged = $this->eventsOf(MailboxExpunged::class); + self::assertCount(1, $expunged); + self::assertSame('INBOX', $expunged[0]->mailbox); + self::assertFalse($expunged[0]->vanished->isSequence()); + self::assertSame([405, 410, 411, 412], $expunged[0]->vanished->toArray()); + self::assertSame(42, $expunged[0]->uidvalidity); + } + + public function testServerMoveDispatchesMailboxExpungedForSource(): void + { + $socket = $this->socket( + '* OK [CAPABILITY IMAP4rev1 MOVE UIDPLUS QRESYNC] Server ready.', + '* OK [UIDVALIDITY 42] .', + 'A1 OK [READ-WRITE] SELECT completed.', + // A server-side MOVE removes the source and reports it via + // VANISHED on the MOVE reply. + '* VANISHED 7', + 'A2 OK [COPYUID 1 7 88] MOVE completed.', + ); + $client = $this->client($socket); + + $client->openMailbox('INBOX', OpenMode::ReadWrite); + $client->move('INBOX', 'Archive', ['ids' => new ImapIdSet([7], false)]); + + $expunged = $this->eventsOf(MailboxExpunged::class); + self::assertCount(1, $expunged); + self::assertSame('INBOX', $expunged[0]->mailbox); + self::assertSame([7], $expunged[0]->vanished->toArray()); + } +} diff --git a/test/Unit/Src/MailboxEventPayloadTest.php b/test/Unit/Src/MailboxEventPayloadTest.php new file mode 100644 index 00000000..52fa8b08 --- /dev/null +++ b/test/Unit/Src/MailboxEventPayloadTest.php @@ -0,0 +1,92 @@ +mailbox); + self::assertSame($ids, $event->vanished); + self::assertSame(42, $event->uidvalidity); + self::assertFalse($event->vanished->isSequence()); + } + + public function testMailboxExpungedPreservesBaseContract(): void + { + $event = new MailboxExpunged('INBOX', new ImapIdSet([5, 7], false), 42); + + // A generic ImapEvent listener still gets a message and context. + self::assertInstanceOf(ImapEvent::class, $event); + self::assertSame('2 message(s) removed from INBOX', $event->getMessage()); + self::assertSame( + [ + 'mailbox' => 'INBOX', + 'ids' => [5, 7], + 'sequence' => false, + 'uidvalidity' => 42, + ], + $event->getContext(), + ); + } + + public function testMailboxExpungedDistinguishesSequenceNumbers(): void + { + // A plain EXPUNGE reports sequence numbers, not UIDs. The event must + // flag that so a listener invalidates conservatively. + $event = new MailboxExpunged('INBOX', new ImapIdSet([1, 2], true), 42); + + self::assertTrue($event->vanished->isSequence()); + self::assertTrue($event->getContext()['sequence']); + } + + public function testMailboxSelectedExposesSyncState(): void + { + $event = new MailboxSelected('INBOX', 42, 100, 715); + + self::assertSame('INBOX', $event->mailbox); + self::assertSame(42, $event->uidvalidity); + self::assertSame(100, $event->uidnext); + self::assertSame(715, $event->highestmodseq); + self::assertSame( + [ + 'mailbox' => 'INBOX', + 'uidvalidity' => 42, + 'uidnext' => 100, + 'highestmodseq' => 715, + ], + $event->getContext(), + ); + } +} diff --git a/test/Unit/Src/Pop3AuthChannelTest.php b/test/Unit/Src/Pop3AuthChannelTest.php new file mode 100644 index 00000000..25127468 --- /dev/null +++ b/test/Unit/Src/Pop3AuthChannelTest.php @@ -0,0 +1,130 @@ +sendAuthenticate('CRAM-MD5', null); + + self::assertSame(['AUTH CRAM-MD5'], $socket->written); + } + + public function testSendAuthenticateWithInitialResponseBase64Encodes(): void + { + $socket = new InMemoryPop3Socket(); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $channel->sendAuthenticate('PLAIN', "\0user\0pass"); + + self::assertSame(['AUTH PLAIN ' . base64_encode("\0user\0pass")], $socket->written); + } + + public function testSendAuthenticateWithEmptyInitialResponseUsesEqualsShorthand(): void + { + $socket = new InMemoryPop3Socket(); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $channel->sendAuthenticate('EXTERNAL', ''); + + self::assertSame(['AUTH EXTERNAL ='], $socket->written); + } + + public function testNextEventDecodesContinuationChallenge(): void + { + $socket = new InMemoryPop3Socket(['+ ' . base64_encode('challenge-bytes')]); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isChallenge()); + self::assertSame('challenge-bytes', $event->payload()); + } + + public function testNextEventReportsSuccess(): void + { + $socket = new InMemoryPop3Socket(['+OK Maildrop locked and ready']); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isOutcome()); + self::assertTrue($event->isSuccess()); + self::assertSame('Maildrop locked and ready', $event->text()); + } + + public function testNextEventReportsFailureWithoutThrowing(): void + { + $socket = new InMemoryPop3Socket(['-ERR authentication failed']); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $event = $channel->nextEvent(); + + self::assertTrue($event->isOutcome()); + self::assertFalse($event->isSuccess()); + self::assertSame('authentication failed', $event->text()); + } + + public function testNextEventThrowsOnMalformedBase64(): void + { + $socket = new InMemoryPop3Socket(['+ not-valid-base64!!!']); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $this->expectException(Pop3ProtocolException::class); + + $channel->nextEvent(); + } + + public function testSendResponseBase64EncodesPayload(): void + { + $socket = new InMemoryPop3Socket(); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $channel->sendResponse('response-bytes'); + + self::assertSame([base64_encode('response-bytes')], $socket->written); + } + + public function testSendResponseWithEmptyPayloadSendsBlankLine(): void + { + $socket = new InMemoryPop3Socket(); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $channel->sendResponse(''); + + self::assertSame([''], $socket->written); + } + + public function testCancelSendsAsterisk(): void + { + $socket = new InMemoryPop3Socket(); + $channel = new Pop3AuthChannel(new Pop3Connection($socket)); + + $channel->cancel(); + + self::assertSame(['*'], $socket->written); + } +} diff --git a/test/Unit/Src/Pop3CapabilityTest.php b/test/Unit/Src/Pop3CapabilityTest.php new file mode 100644 index 00000000..245efc85 --- /dev/null +++ b/test/Unit/Src/Pop3CapabilityTest.php @@ -0,0 +1,33 @@ +assertInstanceOf(Capability::class, new Pop3Capability()); + } + + public function testBehavesLikeTheGenericCapabilityData(): void + { + // POP3 (RFC 2449) has no implication rules or ENABLE state. The generic engine is exposed as the Pop3 type. + $cap = new Pop3Capability(); + $cap->add('SASL', ['PLAIN', 'CRAM-MD5']); + $cap->add('PIPELINING'); + $cap->add('UIDL'); + + $this->assertTrue($cap->query('SASL', 'PLAIN')); + $this->assertFalse($cap->query('SASL', 'LOGIN')); + $this->assertTrue($cap->query('PIPELINING')); + $this->assertSame(['PLAIN', 'CRAM-MD5'], $cap->getParams('SASL')); + } +} diff --git a/test/Unit/Src/Pop3ClientTest.php b/test/Unit/Src/Pop3ClientTest.php new file mode 100644 index 00000000..d7ea5113 --- /dev/null +++ b/test/Unit/Src/Pop3ClientTest.php @@ -0,0 +1,630 @@ +config($policy), $credentials, null, $socket); + } + + public function testGetCapabilityParsesCapaResponse(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK Capability list follows', + 'SASL PLAIN CRAM-MD5', + 'UIDL', + 'TOP', + '.', + ]); + $client = $this->client($socket); + + $capability = $client->getCapability(); + + self::assertTrue($capability->query('UIDL')); + self::assertTrue($capability->query('TOP')); + self::assertSame(['PLAIN', 'CRAM-MD5'], $capability->getParams('SASL')); + self::assertSame(['CAPA'], array_slice($socket->written, 0, 1)); + } + + public function testGetCapabilityFallsBackWhenCapaUnsupported(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '-ERR unknown command', + ]); + $client = $this->client($socket); + + $capability = $client->getCapability(); + + self::assertTrue($capability->query('USER')); + self::assertFalse($capability->query('UIDL')); + } + + public function testGetCapabilityIsCachedAfterFirstCall(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'UIDL', + '.', + ]); + $client = $this->client($socket); + + $client->getCapability(); + $client->getCapability(); + + self::assertSame(1, count(array_filter($socket->written, static fn (string $line): bool => $line === 'CAPA'))); + } + + public function testLoginViaSaslPlain(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'SASL PLAIN', + '.', + '+OK Logged in', + ]); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + self::assertSame([ + 'CAPA', + 'AUTH PLAIN ' . base64_encode("\0alice\0hunter2"), + ], $socket->written); + } + + public function testLoginFallsBackToUserPassWhenNoSaslOffered(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'UIDL', + '.', + '+OK', + '+OK Logged in', + ]); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + self::assertSame([ + 'CAPA', + 'USER alice', + 'PASS hunter2', + ], $socket->written); + } + + public function testLoginUsesApopWhenTimestampPresentAndNoSaslOffered(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready <1234.abc@pop3.example.test>', + '+OK', + 'UIDL', + '.', + '+OK Logged in', + ]); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + $expectedDigest = hash('md5', '<1234.abc@pop3.example.test>hunter2'); + + self::assertSame([ + 'CAPA', + 'APOP alice ' . $expectedDigest, + ], $socket->written); + } + + public function testLoginFallsBackToUserPassWhenApopRejected(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready <1234.abc@pop3.example.test>', + '+OK', + 'UIDL', + '.', + '-ERR APOP not supported', + '+OK', + '+OK Logged in', + ]); + $client = $this->client($socket, $this->credentials('alice', 'hunter2')); + + $client->login(); + + self::assertSame([ + 'CAPA', + 'APOP alice ' . hash('md5', '<1234.abc@pop3.example.test>hunter2'), + 'USER alice', + 'PASS hunter2', + ], $socket->written); + } + + public function testLoginThrowsWithoutCredentials(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready']); + $client = $this->client($socket, null); + + $this->expectException(AuthenticationException::class); + + $client->login(); + } + + public function testLoginThrowsOnEmptyPassword(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'UIDL', + '.', + ]); + $client = $this->client($socket, $this->credentials('alice', '')); + + $this->expectException(AuthenticationException::class); + + $client->login(); + } + + public function testLoginIsIdempotent(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'SASL PLAIN', + '.', + '+OK Logged in', + ]); + $client = $this->client($socket, $this->credentials()); + + $client->login(); + $client->login(); + + self::assertSame(2, count($socket->written)); + } + + public function testLogoutSendsQuitAndClosesConnection(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready', '+OK', '+OK Bye']); + $client = $this->client($socket); + + $client->noop(); + $client->logout(); + + self::assertContains('QUIT', $socket->written); + } + + public function testLogoutWithoutPriorConnectionIsANoop(): void + { + $socket = new InMemoryPop3Socket(); + $client = $this->client($socket); + + $client->logout(); + + self::assertSame([], $socket->written); + } + + public function testNoopSendsNoopCommand(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready', '+OK']); + $client = $this->client($socket); + + $client->noop(); + + self::assertSame(['NOOP'], $socket->written); + } + + public function testStatusRejectsMailboxesOtherThanInbox(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready']); + $client = $this->client($socket); + + $this->expectException(Pop3ProtocolException::class); + + $client->status('Sent', StatusFlag::Messages->value); + } + + public function testStatusReturnsMessagesAndRecent(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready', '+OK 4 1200']); + $client = $this->client($socket); + + $status = $client->status('INBOX', StatusFlag::Messages->value | StatusFlag::Recent->value); + + self::assertSame(4, $status->messages); + self::assertSame(4, $status->recent); + self::assertSame(['STAT'], $socket->written); + } + + public function testStatusUidNextFallsBackToStatWhenNoUidl(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '-ERR no capa', + '+OK 4 1200', + ]); + $client = $this->client($socket); + + $status = $client->status('INBOX', StatusFlag::UidNext->value); + + self::assertSame(5, $status->uidnext); + } + + public function testStatusUidNextUsesUidlHashWhenSupported(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + 'UIDL', + '.', + '+OK', + '1 abc', + '2 def', + '.', + ]); + $client = $this->client($socket); + + $status = $client->status('INBOX', StatusFlag::UidNext->value); + + self::assertIsString($status->uidnext); + self::assertNotSame('', $status->uidnext); + } + + public function testGetIdsObWithNullReturnsEmptySet(): void + { + $client = $this->client(new InMemoryPop3Socket()); + + $ids = $client->getIdsOb(); + + self::assertTrue($ids->isEmpty()); + } + + public function testGetIdsObWithSequenceString(): void + { + $client = $this->client(new InMemoryPop3Socket()); + + $ids = $client->getIdsOb('uidl-a uidl-b'); + + self::assertSame(['uidl-a', 'uidl-b'], $ids->toArray()); + } + + public function testGetIdsObWithArray(): void + { + $client = $this->client(new InMemoryPop3Socket()); + + $ids = $client->getIdsOb(['uidl-a', 'uidl-b'], false); + + self::assertInstanceOf(Pop3IdSet::class, $ids); + self::assertSame(['uidl-a', 'uidl-b'], $ids->toArray()); + } + + public function testGetIdsObWithExistingMessageIdSet(): void + { + $client = $this->client(new InMemoryPop3Socket()); + $existing = new Pop3IdSet(['uidl-a'], true); + + $ids = $client->getIdsOb($existing); + + self::assertSame(['uidl-a'], $ids->toArray()); + } + + public function testFetchRejectsNonPop3FetchQuery(): void + { + $client = $this->client(new InMemoryPop3Socket()); + + $this->expectException(Pop3ProtocolException::class); + + iterator_to_array($client->fetch('INBOX', new Pop3IdSet([1], true), new \stdClass())); + } + + public function testFetchRejectsMailboxesOtherThanInbox(): void + { + $client = $this->client(new InMemoryPop3Socket()); + + $this->expectException(Pop3ProtocolException::class); + + iterator_to_array($client->fetch('Sent', new Pop3IdSet([1], true), new Pop3FetchQuery())); + } + + public function testFetchFullMessageBySequence(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK message follows', + 'Subject: test', + '', + 'Body text', + '.', + ]); + $client = $this->client($socket); + + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet([1], true), (new Pop3FetchQuery())->fullMsg()) + ); + + self::assertSame(["RETR 1"], $socket->written); + self::assertArrayHasKey(1, $results); + self::assertSame("Subject: test\r\n\r\nBody text", (string) $results[1]->getFullMsg()); + self::assertSame(1, $results[1]->getSeq()); + self::assertSame('1', $results[1]->getUid()); + } + + public function testFetchWithEmptyIdsFetchesEveryMessage(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK 2 500', + '+OK', + 'Subject: 1', + '', + 'a', + '.', + '+OK', + 'Subject: 2', + '', + 'b', + '.', + ]); + $client = $this->client($socket); + + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet([], true), (new Pop3FetchQuery())->bodyText()) + ); + + self::assertSame(['STAT', 'RETR 1', 'RETR 2'], $socket->written); + self::assertCount(2, $results); + self::assertSame('a', (string) $results[1]->getBodyText()); + self::assertSame('b', (string) $results[2]->getBodyText()); + } + + public function testFetchHeaderUsesTopWhenSupported(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK Capability list follows', + 'TOP', + '.', + '+OK', + 'Subject: hi', + '.', + ]); + $client = $this->client($socket); + + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet([1], true), (new Pop3FetchQuery())->headerText()) + ); + + self::assertSame(['CAPA', 'TOP 1 0'], $socket->written); + self::assertSame('Subject: hi', (string) $results[1]->getHeaderText()); + } + + public function testFetchHeaderFallsBackToRetrWhenTopUnsupported(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '-ERR unknown command', + '+OK message follows', + 'Subject: hi', + '', + 'Body', + '.', + ]); + $client = $this->client($socket); + + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet([1], true), (new Pop3FetchQuery())->headerText()) + ); + + self::assertSame(['CAPA', 'RETR 1'], $socket->written); + self::assertSame('Subject: hi', (string) $results[1]->getHeaderText()); + } + + public function testFetchUidSizeAndSeqByUid(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + '1 uidl-a', + '2 uidl-b', + '.', + '+OK', + '1 100', + '2 200', + '.', + ]); + $client = $this->client($socket); + + $query = (new Pop3FetchQuery())->uid()->size()->seq(); + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet(['uidl-b']), $query) + ); + + self::assertSame(['UIDL', 'LIST'], $socket->written); + self::assertArrayHasKey('uidl-b', $results); + self::assertSame('uidl-b', $results['uidl-b']->getUid()); + self::assertSame(200, $results['uidl-b']->getSize()); + self::assertSame(2, $results['uidl-b']->getSeq()); + } + + public function testFetchImapDateParsesDateHeaderViaTop(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK Capability list follows', + 'TOP', + '.', + '+OK', + 'Date: Mon, 02 Jan 2006 15:04:05 +0000', + '.', + ]); + $client = $this->client($socket); + + $results = iterator_to_array( + $client->fetch('INBOX', new Pop3IdSet([1], true), (new Pop3FetchQuery())->imapDate()) + ); + + self::assertSame('2006-01-02', $results[1]->getImapDate()->format('Y-m-d')); + } + + public function testStoreAddDeletedSendsDeleForEachId(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK deleted', + '+OK deleted', + ]); + $client = $this->client($socket); + + $result = $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1, 2], true), + 'add' => [SystemFlag::Deleted], + ]); + + self::assertSame(['DELE 1', 'DELE 2'], $socket->written); + self::assertSame([1, 2], $result->toArray()); + } + + public function testStoreRemoveDeletedSendsRset(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready', '+OK']); + $client = $this->client($socket); + + $result = $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1], true), + 'remove' => [SystemFlag::Deleted], + ]); + + self::assertSame(['RSET'], $socket->written); + self::assertTrue($result->isEmpty()); + } + + public function testStoreReplaceWithoutDeletedSendsRset(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready', '+OK']); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1], true), + 'replace' => [SystemFlag::Seen], + ]); + + self::assertSame(['RSET'], $socket->written); + } + + public function testStoreWithNoDeletedFlagIsNoop(): void + { + $socket = new InMemoryPop3Socket(['+OK POP3 ready']); + $client = $this->client($socket); + + $result = $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1], true), + 'add' => [SystemFlag::Seen], + ]); + + self::assertSame([], $socket->written); + self::assertTrue($result->isEmpty()); + } + + public function testStoreResolvesUidsToSequenceNumbersForDele(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK', + '1 uidl-a', + '2 uidl-b', + '.', + '+OK deleted', + ]); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new Pop3IdSet(['uidl-b']), + 'add' => [SystemFlag::Deleted], + ]); + + self::assertSame(['UIDL', 'DELE 2'], $socket->written); + } + + public function testExpungeCommitsViaLogoutAndReturnsDeletedIds(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK deleted', + '+OK bye', + ]); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1], true), + 'add' => [SystemFlag::Deleted], + ]); + + $expunged = $client->expunge('INBOX', ['list' => true]); + + self::assertSame(['DELE 1', 'QUIT'], $socket->written); + self::assertSame([1], $expunged->toArray()); + } + + public function testExpungeWithoutListOptionReturnsEmptySet(): void + { + $socket = new InMemoryPop3Socket([ + '+OK POP3 ready', + '+OK deleted', + '+OK bye', + ]); + $client = $this->client($socket); + + $client->store('INBOX', [ + 'ids' => new Pop3IdSet([1], true), + 'add' => [SystemFlag::Deleted], + ]); + + $expunged = $client->expunge('INBOX', []); + + self::assertTrue($expunged->isEmpty()); + } +} diff --git a/test/Unit/Src/Pop3ConnectionTest.php b/test/Unit/Src/Pop3ConnectionTest.php new file mode 100644 index 00000000..33aaa34e --- /dev/null +++ b/test/Unit/Src/Pop3ConnectionTest.php @@ -0,0 +1,146 @@ +sendLine('NOOP'); + + self::assertSame(['NOOP'], $socket->written); + } + + public function testReadStatusLineParsesOk(): void + { + $socket = new InMemoryPop3Socket(['+OK 2 messages']); + $connection = new Pop3Connection($socket); + + $line = $connection->readStatusLine(); + + self::assertSame(Pop3ResponseKind::Ok, $line->kind); + self::assertTrue($line->isOk()); + self::assertSame('2 messages', $line->text); + } + + public function testReadStatusLineParsesOkWithNoText(): void + { + $socket = new InMemoryPop3Socket(['+OK']); + $connection = new Pop3Connection($socket); + + $line = $connection->readStatusLine(); + + self::assertTrue($line->isOk()); + self::assertSame('', $line->text); + } + + public function testReadStatusLineParsesErrorWithoutThrowing(): void + { + $socket = new InMemoryPop3Socket(['-ERR invalid command']); + $connection = new Pop3Connection($socket); + + $line = $connection->readStatusLine(); + + self::assertTrue($line->isError()); + self::assertSame('invalid command', $line->text); + } + + public function testReadStatusLineParsesContinuation(): void + { + $socket = new InMemoryPop3Socket(['+ PLBASE64CHALLENGE==']); + $connection = new Pop3Connection($socket); + + $line = $connection->readStatusLine(); + + self::assertTrue($line->isContinuation()); + self::assertSame('PLBASE64CHALLENGE==', $line->text); + } + + public function testReadStatusLineRejectsUnknownIndicator(): void + { + $socket = new InMemoryPop3Socket(['GARBAGE']); + $connection = new Pop3Connection($socket); + + $this->expectException(Pop3ProtocolException::class); + + $connection->readStatusLine(); + } + + public function testExpectOkReturnsOkLine(): void + { + $socket = new InMemoryPop3Socket(['+OK done']); + $connection = new Pop3Connection($socket); + + $line = $connection->expectOk(); + + self::assertSame('done', $line->text); + } + + public function testExpectOkThrowsOnError(): void + { + $socket = new InMemoryPop3Socket(['-ERR permission denied']); + $connection = new Pop3Connection($socket); + + $this->expectException(Pop3ProtocolException::class); + $this->expectExceptionMessage('permission denied'); + + $connection->expectOk(); + } + + public function testExpectOkThrowsWithDefaultMessageWhenNoText(): void + { + $socket = new InMemoryPop3Socket(['-ERR']); + $connection = new Pop3Connection($socket); + + $this->expectException(Pop3ProtocolException::class); + $this->expectExceptionMessage('POP3 error reported by server.'); + + $connection->expectOk(); + } + + public function testReadMultilineYieldsLinesUntilTerminator(): void + { + $socket = new InMemoryPop3Socket(['first', 'second', '.']); + $connection = new Pop3Connection($socket); + + self::assertSame(['first', 'second'], iterator_to_array($connection->readMultiline())); + } + + public function testReadMultilineUnstuffsLeadingDot(): void + { + $socket = new InMemoryPop3Socket(['..leading dot line', 'plain', '.']); + $connection = new Pop3Connection($socket); + + self::assertSame(['.leading dot line', 'plain'], iterator_to_array($connection->readMultiline())); + } + + public function testReadMultilineHandlesEmptyBlock(): void + { + $socket = new InMemoryPop3Socket(['.']); + $connection = new Pop3Connection($socket); + + self::assertSame([], iterator_to_array($connection->readMultiline())); + } +} diff --git a/test/Unit/Src/Pop3IdSetTest.php b/test/Unit/Src/Pop3IdSetTest.php new file mode 100644 index 00000000..c87c0a9d --- /dev/null +++ b/test/Unit/Src/Pop3IdSetTest.php @@ -0,0 +1,88 @@ +isEmpty()); + self::assertSame(0, $ids->count()); + self::assertSame([], $ids->toArray()); + self::assertSame('', (string) $ids); + } + + public function testDeduplicatesWhilePreservingFirstSeenOrder(): void + { + $ids = new Pop3IdSet(['uidl-b', 'uidl-a', 'uidl-b', 'uidl-c']); + + self::assertSame(['uidl-b', 'uidl-a', 'uidl-c'], $ids->toArray()); + self::assertSame(3, $ids->count()); + } + + public function testToStringJoinsWithSpace(): void + { + $ids = new Pop3IdSet(['uidl-a', 'uidl-b']); + + self::assertSame('uidl-a uidl-b', (string) $ids); + } + + public function testIsSequenceFlag(): void + { + $sequence = new Pop3IdSet([1, 2, 3], true); + $uids = new Pop3IdSet(['uidl-a'], false); + + self::assertTrue($sequence->isSequence()); + self::assertFalse($uids->isSequence()); + } + + public function testIterationYieldsIds(): void + { + $ids = new Pop3IdSet(['uidl-a', 'uidl-b']); + + self::assertSame(['uidl-a', 'uidl-b'], iterator_to_array($ids->getIterator())); + } + + public function testFromSequenceStringParsesSpaceDelimited(): void + { + $ids = Pop3IdSet::fromSequenceString('uidl-a uidl-b uidl-c'); + + self::assertSame(['uidl-a', 'uidl-b', 'uidl-c'], $ids->toArray()); + } + + public function testFromSequenceStringHandlesEmptyString(): void + { + $ids = Pop3IdSet::fromSequenceString(''); + + self::assertTrue($ids->isEmpty()); + } + + public function testFromSequenceStringTrimsSurroundingWhitespace(): void + { + $ids = Pop3IdSet::fromSequenceString(' 1 2 3 '); + + // Numeric-looking UIDs are int-cast by PHP's array-key coercion + // (array_flip/array_keys), exactly like the legacy Ids_Pop3 class. + // Harmless since MessageIdSet's type is int|string. + self::assertSame([1, 2, 3], $ids->toArray()); + } +} diff --git a/test/Unit/Src/Pop3MessageDataTest.php b/test/Unit/Src/Pop3MessageDataTest.php new file mode 100644 index 00000000..b3307c61 --- /dev/null +++ b/test/Unit/Src/Pop3MessageDataTest.php @@ -0,0 +1,102 @@ +getUid()); + self::assertSame(3, $data->getSeq()); + } + + public function testFlagsAreAlwaysEmpty(): void + { + $data = new Pop3MessageData('uidl-1'); + + self::assertSame([], $data->getFlags()); + } + + public function testModSeqIsAlwaysNull(): void + { + $data = new Pop3MessageData('uidl-1'); + + self::assertNull($data->getModSeq()); + } + + public function testSizeDefaultsToZeroAndIsSettable(): void + { + $data = new Pop3MessageData('uidl-1'); + + self::assertSame(0, $data->getSize()); + + $data->setSize(1234); + + self::assertSame(1234, $data->getSize()); + } + + public function testImapDateDefaultsToEpochAndIsSettable(): void + { + $data = new Pop3MessageData('uidl-1'); + + self::assertSame(0, $data->getImapDate()->getTimestamp()); + + $date = new DateTimeImmutable('2026-01-15T10:00:00+00:00'); + $data->setImapDate($date); + + self::assertSame($date, $data->getImapDate()); + } + + public function testFullMsgRoundTrips(): void + { + $data = new Pop3MessageData('uidl-1'); + $data->setFullMsg("Subject: test\r\n\r\nBody text"); + + self::assertSame("Subject: test\r\n\r\nBody text", (string) $data->getFullMsg()); + } + + public function testFullMsgDefaultsToEmptyStream(): void + { + $data = new Pop3MessageData('uidl-1'); + + self::assertSame('', (string) $data->getFullMsg()); + } + + public function testHeaderTextIsKeyedById(): void + { + $data = new Pop3MessageData('uidl-1'); + $data->setHeaderText(0, 'Subject: test'); + + self::assertSame('Subject: test', (string) $data->getHeaderText()); + self::assertSame('', (string) $data->getHeaderText(1)); + } + + public function testBodyTextIsKeyedById(): void + { + $data = new Pop3MessageData('uidl-1'); + $data->setBodyText(0, 'Body text'); + + self::assertSame('Body text', (string) $data->getBodyText()); + self::assertSame('', (string) $data->getBodyText(1)); + } +} diff --git a/test/Unit/TokenizeTest.php b/test/Unit/TokenizeTest.php index 802d5720..af7f9476 100644 --- a/test/Unit/TokenizeTest.php +++ b/test/Unit/TokenizeTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2012-2026 Horde LLC (http://www.horde.org/) + * Copyright 2012-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the IMAP string tokenizer. * * @author Michael Slusarz - * @copyright 2012-2026 Horde LLC + * @copyright 2012-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/BaseTest.php b/test/Unit/Url/BaseTest.php index d030e885..6299fe7e 100644 --- a/test/Unit/Url/BaseTest.php +++ b/test/Unit/Url/BaseTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for base URL parsing. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/ImapDeprecatedTest.php b/test/Unit/Url/ImapDeprecatedTest.php index 9e993400..720bf830 100644 --- a/test/Unit/Url/ImapDeprecatedTest.php +++ b/test/Unit/Url/ImapDeprecatedTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the deprecated Horde_Imap_Client_Url IMAP URL parsing. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/ImapRelativeTest.php b/test/Unit/Url/ImapRelativeTest.php index b3932bce..d262f6c6 100644 --- a/test/Unit/Url/ImapRelativeTest.php +++ b/test/Unit/Url/ImapRelativeTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for relative IMAP URL parsing. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/ImapTest.php b/test/Unit/Url/ImapTest.php index 6423041c..1e6083f6 100644 --- a/test/Unit/Url/ImapTest.php +++ b/test/Unit/Url/ImapTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Tests for IMAP URL parsing. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/Pop3DeprecatedTest.php b/test/Unit/Url/Pop3DeprecatedTest.php index c37b510f..04e82d12 100644 --- a/test/Unit/Url/Pop3DeprecatedTest.php +++ b/test/Unit/Url/Pop3DeprecatedTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for the deprecated Horde_Imap_Client_Url POP3 URL parsing. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/Pop3Test.php b/test/Unit/Url/Pop3Test.php index f1f79235..f38f7402 100644 --- a/test/Unit/Url/Pop3Test.php +++ b/test/Unit/Url/Pop3Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -20,7 +20,7 @@ * Tests for Horde_Imap_Client_Url_Pop3 POP3 URL parsing. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Url/TestBase.php b/test/Unit/Url/TestBase.php index 90082561..c9ad6f7a 100644 --- a/test/Unit/Url/TestBase.php +++ b/test/Unit/Url/TestBase.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2014-2026 Horde LLC (http://www.horde.org/) + * Copyright 2014-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -21,7 +21,7 @@ * Base class for URL tests. * * @author Michael Slusarz - * @copyright 2014-2026 Horde LLC + * @copyright 2014-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ abstract class TestBase extends TestCase diff --git a/test/Unit/Utf7ConvertTest.php b/test/Unit/Utf7ConvertTest.php index e70ee8d3..07244d08 100644 --- a/test/Unit/Utf7ConvertTest.php +++ b/test/Unit/Utf7ConvertTest.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2011-2026 Horde LLC (http://www.horde.org/) + * Copyright 2011-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -23,7 +23,7 @@ * Tests for UTF7-IMAP <-> UTF-8 conversions. * * @author Michael Slusarz - * @copyright 2011-2026 Horde LLC + * @copyright 2011-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] diff --git a/test/Unit/Xoauth2Test.php b/test/Unit/Xoauth2Test.php index d2750517..964eff8f 100644 --- a/test/Unit/Xoauth2Test.php +++ b/test/Unit/Xoauth2Test.php @@ -3,12 +3,12 @@ declare(strict_types=1); /** - * Copyright 2013-2026 Horde LLC (http://www.horde.org/) + * Copyright 2013-2026 The Horde Project (http://www.horde.org/) * * See the enclosed file LICENSE for license information (LGPL). If you * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ @@ -22,7 +22,7 @@ * Tests for the mailbox object. * * @author Michael Slusarz - * @copyright 2013-2026 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ #[CoversNothing] From 37b07793d7c9f0a53c80c2214797a7c6addbd15e Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 29 Aug 2026 15:28:33 +0200 Subject: [PATCH 2/4] style: php-cs-fixer --- lib/Horde/Imap/Client.php | 2 +- lib/Horde/Imap/Client/Auth/DigestMD5.php | 2 +- lib/Horde/Imap/Client/Auth/Scram.php | 2 +- lib/Horde/Imap/Client/Base.php | 4 ++-- lib/Horde/Imap/Client/Base/Alerts.php | 2 +- lib/Horde/Imap/Client/Base/Debug.php | 2 +- lib/Horde/Imap/Client/Base/Deprecated.php | 2 +- lib/Horde/Imap/Client/Base/Mailbox.php | 2 +- lib/Horde/Imap/Client/Base/Password.php | 2 +- lib/Horde/Imap/Client/Cache.php | 2 +- lib/Horde/Imap/Client/Cache/Backend.php | 2 +- lib/Horde/Imap/Client/Cache/Backend/Cache.php | 2 +- lib/Horde/Imap/Client/Cache/Backend/Db.php | 2 +- lib/Horde/Imap/Client/Cache/Backend/Hashtable.php | 2 +- lib/Horde/Imap/Client/Cache/Backend/Mongo.php | 2 +- lib/Horde/Imap/Client/Cache/Backend/Null.php | 2 +- lib/Horde/Imap/Client/Data/Acl.php | 2 +- lib/Horde/Imap/Client/Data/AclCommon.php | 2 +- lib/Horde/Imap/Client/Data/AclNegative.php | 2 +- lib/Horde/Imap/Client/Data/AclRights.php | 2 +- lib/Horde/Imap/Client/Data/BaseSubject.php | 2 +- lib/Horde/Imap/Client/Data/Capability.php | 2 +- lib/Horde/Imap/Client/Data/Capability/Imap.php | 2 +- lib/Horde/Imap/Client/Data/Envelope.php | 2 +- lib/Horde/Imap/Client/Data/Fetch.php | 2 +- lib/Horde/Imap/Client/Data/Fetch/Pop3.php | 2 +- lib/Horde/Imap/Client/Data/Format.php | 2 +- lib/Horde/Imap/Client/Data/Format/Astring.php | 2 +- lib/Horde/Imap/Client/Data/Format/Astring/Nonascii.php | 2 +- lib/Horde/Imap/Client/Data/Format/Atom.php | 2 +- lib/Horde/Imap/Client/Data/Format/Date.php | 2 +- lib/Horde/Imap/Client/Data/Format/DateTime.php | 2 +- lib/Horde/Imap/Client/Data/Format/Exception.php | 2 +- lib/Horde/Imap/Client/Data/Format/Filter/Quote.php | 2 +- lib/Horde/Imap/Client/Data/Format/Filter/String.php | 2 +- lib/Horde/Imap/Client/Data/Format/List.php | 2 +- lib/Horde/Imap/Client/Data/Format/ListMailbox.php | 2 +- lib/Horde/Imap/Client/Data/Format/ListMailbox/Utf8.php | 2 +- lib/Horde/Imap/Client/Data/Format/Mailbox.php | 2 +- lib/Horde/Imap/Client/Data/Format/Mailbox/Utf8.php | 2 +- lib/Horde/Imap/Client/Data/Format/Nil.php | 2 +- lib/Horde/Imap/Client/Data/Format/Nstring.php | 2 +- lib/Horde/Imap/Client/Data/Format/Nstring/Nonascii.php | 2 +- lib/Horde/Imap/Client/Data/Format/Number.php | 2 +- lib/Horde/Imap/Client/Data/Format/String.php | 2 +- lib/Horde/Imap/Client/Data/Format/String/Nonascii.php | 2 +- lib/Horde/Imap/Client/Data/Format/String/Support/Nonascii.php | 2 +- lib/Horde/Imap/Client/Data/Namespace.php | 2 +- lib/Horde/Imap/Client/Data/SearchCharset.php | 2 +- lib/Horde/Imap/Client/Data/SearchCharset/Utf8.php | 2 +- lib/Horde/Imap/Client/Data/Sync.php | 2 +- lib/Horde/Imap/Client/Data/Thread.php | 2 +- lib/Horde/Imap/Client/DateTime.php | 2 +- lib/Horde/Imap/Client/Exception.php | 2 +- lib/Horde/Imap/Client/Exception/NoSupportExtension.php | 2 +- lib/Horde/Imap/Client/Exception/NoSupportPop3.php | 2 +- lib/Horde/Imap/Client/Exception/SearchCharset.php | 2 +- lib/Horde/Imap/Client/Exception/ServerResponse.php | 2 +- lib/Horde/Imap/Client/Exception/Sync.php | 2 +- lib/Horde/Imap/Client/Fetch/Query.php | 2 +- lib/Horde/Imap/Client/Fetch/Results.php | 2 +- lib/Horde/Imap/Client/Ids.php | 2 +- lib/Horde/Imap/Client/Ids/Map.php | 2 +- lib/Horde/Imap/Client/Ids/Pop3.php | 2 +- lib/Horde/Imap/Client/Interaction/Client.php | 2 +- lib/Horde/Imap/Client/Interaction/Command.php | 2 +- lib/Horde/Imap/Client/Interaction/Command/Continuation.php | 2 +- lib/Horde/Imap/Client/Interaction/Pipeline.php | 2 +- lib/Horde/Imap/Client/Interaction/Server.php | 2 +- lib/Horde/Imap/Client/Interaction/Server/Continuation.php | 2 +- lib/Horde/Imap/Client/Interaction/Server/Tagged.php | 2 +- lib/Horde/Imap/Client/Interaction/Server/Untagged.php | 2 +- lib/Horde/Imap/Client/Mailbox.php | 2 +- lib/Horde/Imap/Client/Mailbox/List.php | 2 +- lib/Horde/Imap/Client/Namespace/List.php | 2 +- lib/Horde/Imap/Client/Password/Xoauth2.php | 2 +- lib/Horde/Imap/Client/Search/Query.php | 2 +- lib/Horde/Imap/Client/Socket.php | 2 +- lib/Horde/Imap/Client/Socket/Catenate.php | 2 +- lib/Horde/Imap/Client/Socket/ClientSort.php | 2 +- lib/Horde/Imap/Client/Socket/Connection/Base.php | 2 +- lib/Horde/Imap/Client/Socket/Connection/Pop3.php | 2 +- lib/Horde/Imap/Client/Socket/Connection/Socket.php | 2 +- lib/Horde/Imap/Client/Socket/Pop3.php | 2 +- lib/Horde/Imap/Client/Tokenize.php | 2 +- lib/Horde/Imap/Client/Translation.php | 2 +- lib/Horde/Imap/Client/Url.php | 2 +- lib/Horde/Imap/Client/Url/Base.php | 2 +- lib/Horde/Imap/Client/Url/Imap.php | 2 +- lib/Horde/Imap/Client/Url/Imap/Relative.php | 2 +- lib/Horde/Imap/Client/Url/Pop3.php | 2 +- lib/Horde/Imap/Client/Utf7imap.php | 2 +- .../Horde/Imap/Client/1_horde_imap_client_base_tables.php | 2 +- .../Imap/Client/2_horde_imap_client_change_column_name.php | 2 +- 94 files changed, 95 insertions(+), 95 deletions(-) diff --git a/lib/Horde/Imap/Client.php b/lib/Horde/Imap/Client.php index bcd40393..aa1c7e97 100644 --- a/lib/Horde/Imap/Client.php +++ b/lib/Horde/Imap/Client.php @@ -1,7 +1,7 @@ Date: Sat, 29 Aug 2026 15:31:09 +0200 Subject: [PATCH 3/4] chore(metadata) add dependency on horde/sasl and bump horde/socket_client dependency to 3.1 --- .horde.yml | 3 ++- composer.json | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.horde.yml b/.horde.yml index 147e1a60..86d04d71 100644 --- a/.horde.yml +++ b/.horde.yml @@ -31,7 +31,8 @@ dependencies: horde/exception: ^3 horde/mail: ^3 horde/mime: ^3 - horde/socket_client: ^3 + horde/sasl: ^1 + horde/socket_client: ^3.1 horde/stream: ^2 horde/secret: ^3 horde/stream_filter: ^3 diff --git a/composer.json b/composer.json index 8e536b20..e5ae6059 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,15 @@ "mailbox" ], "time": "2026-07-21", - "repositories": [], + "repositories": [ + { + "type": "path", + "url": "../Sasl", + "options": { + "symlink": true + } + } + ], "require": { "horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2", "php": "^8.1", @@ -23,7 +31,8 @@ "horde/exception": "^3 || dev-FRAMEWORK_6_0", "horde/mail": "^3 || dev-FRAMEWORK_6_0", "horde/mime": "^3 || dev-FRAMEWORK_6_0", - "horde/socket_client": "^3 || dev-FRAMEWORK_6_0", + "horde/sasl": "^1 || dev-FRAMEWORK_6_0", + "horde/socket_client": "^3.1 || dev-FRAMEWORK_6_0", "horde/stream": "^2 || dev-FRAMEWORK_6_0", "horde/secret": "^3 || dev-FRAMEWORK_6_0", "horde/stream_filter": "^3 || dev-FRAMEWORK_6_0", From 2d99d6a8dcea3a50eac6f17dc2d4bcb405dfc8a4 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 29 Aug 2026 16:26:41 +0200 Subject: [PATCH 4/4] test: Add test coverage for #52 search cache invalidation bug --- test/Stub/Base.php | 11 ++ test/Stub/CacheBackend.php | 110 ++++++++++++++++++ test/Unit/Base/DeleteMsgsSearchCacheTest.php | 97 +++++++++++++++ .../Src/ImapCacheStoreSearchInvariantTest.php | 74 ++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 test/Stub/CacheBackend.php create mode 100644 test/Unit/Base/DeleteMsgsSearchCacheTest.php create mode 100644 test/Unit/Src/ImapCacheStoreSearchInvariantTest.php diff --git a/test/Stub/Base.php b/test/Stub/Base.php index 1b0fb4ed..3cfb0d8f 100644 --- a/test/Stub/Base.php +++ b/test/Stub/Base.php @@ -41,6 +41,17 @@ public function initCache($current = false): bool return $this->_initCache($current); } + /** + * Expose the protected message deletion cache handler for testing. + */ + public function deleteMsgs( + Horde_Imap_Client_Mailbox $mailbox, + Horde_Imap_Client_Ids $ids, + array $opts = [] + ): Horde_Imap_Client_Ids { + return $this->_deleteMsgs($mailbox, $ids, $opts); + } + protected function _initCapability() {} protected function _noop() {} diff --git a/test/Stub/CacheBackend.php b/test/Stub/CacheBackend.php new file mode 100644 index 00000000..00dce3fc --- /dev/null +++ b/test/Stub/CacheBackend.php @@ -0,0 +1,110 @@ +data[$mailbox][$uid])) { + $out[$uid] = $this->data[$mailbox][$uid]; + } + } + + return $out; + } + + public function getCachedUids($mailbox, $uidvalid) + { + return isset($this->data[$mailbox]) + ? array_keys($this->data[$mailbox]) + : []; + } + + public function set($mailbox, $data, $uidvalid) + { + foreach ($data as $uid => $fields) { + $this->data[$mailbox][$uid] = isset($this->data[$mailbox][$uid]) + ? array_merge($this->data[$mailbox][$uid], $fields) + : $fields; + } + } + + public function getMetaData($mailbox, $uidvalid, $entries) + { + $md = $this->metadata[$mailbox] ?? []; + $md['uidvalid'] = $uidvalid; + + return empty($entries) + ? $md + : array_intersect_key($md, array_flip(array_merge($entries, ['uidvalid']))); + } + + public function setMetaData($mailbox, $data) + { + unset($data['uidvalid']); + $this->metadata[$mailbox] = array_merge( + $this->metadata[$mailbox] ?? [], + $data + ); + } + + public function deleteMsgs($mailbox, $uids) + { + foreach ($uids as $uid) { + unset($this->data[$mailbox][$uid]); + } + } + + public function deleteMailbox($mailbox) + { + unset($this->data[$mailbox], $this->metadata[$mailbox]); + } + + public function clear($lifetime) + { + $this->data = []; + $this->metadata = []; + } +} diff --git a/test/Unit/Base/DeleteMsgsSearchCacheTest.php b/test/Unit/Base/DeleteMsgsSearchCacheTest.php new file mode 100644 index 00000000..17712474 --- /dev/null +++ b/test/Unit/Base/DeleteMsgsSearchCacheTest.php @@ -0,0 +1,97 @@ +backend = new CacheBackend(); + $this->ob = new Base([ + 'username' => 'user', + 'password' => 'pass', + 'cache' => [ + 'fields' => [Horde_Imap_Client::FETCH_ENVELOPE], + 'backend' => $this->backend, + ], + ]); + } + + public function testDeleteMsgsClearsCachedSearchResults(): void + { + /* A prior SEARCH cached its result set for this mailbox. */ + $this->backend->metadata['INBOX'] = [ + Horde_Imap_Client_Base::CACHE_SEARCH => [ + 'somehash' => [1, 2, 3], + ], + Horde_Imap_Client_Base::CACHE_SEARCHID => 'cache-token-1', + ]; + + $this->ob->deleteMsgs( + new Horde_Imap_Client_Mailbox('INBOX'), + new Horde_Imap_Client_Ids([2], false) + ); + + $md = $this->backend->metadata['INBOX']; + $this->assertSame([], $md[Horde_Imap_Client_Base::CACHE_SEARCH]); + $this->assertNull($md[Horde_Imap_Client_Base::CACHE_SEARCHID]); + } + + public function testDeleteMsgsLeavesCacheUntouchedWhenNoSearchCached(): void + { + /* No SEARCH was ever cached. Nothing to invalidate. */ + $this->ob->deleteMsgs( + new Horde_Imap_Client_Mailbox('INBOX'), + new Horde_Imap_Client_Ids([2], false) + ); + + $md = $this->backend->metadata['INBOX'] ?? []; + $this->assertArrayNotHasKey( + Horde_Imap_Client_Base::CACHE_SEARCH, + $md + ); + $this->assertArrayNotHasKey( + Horde_Imap_Client_Base::CACHE_SEARCHID, + $md + ); + } +} diff --git a/test/Unit/Src/ImapCacheStoreSearchInvariantTest.php b/test/Unit/Src/ImapCacheStoreSearchInvariantTest.php new file mode 100644 index 00000000..7a768e70 --- /dev/null +++ b/test/Unit/Src/ImapCacheStoreSearchInvariantTest.php @@ -0,0 +1,74 @@ +store($cache); + + $store->set('INBOX', [5 => ['size' => 1], 7 => ['size' => 2]], 42); + $store->setMetadata('INBOX', ['uidvalid' => 42, 'highestmodseq' => 715]); + $store->flush(); + + $store->deleteMsgs('INBOX', [5]); + $store->flush(); + + /* Only the deleted UID is gone; unrelated metadata survives and no + * search-keyed entry was ever present to become stale. */ + self::assertSame([7], $store->getCachedUids('INBOX', 42)); + + $meta = $store->getMetadata('INBOX', 42, []); + self::assertSame(715, $meta['highestmodseq']); + + foreach ($cache->data as $key => $value) { + self::assertStringNotContainsStringIgnoringCase( + 'search', + (string) $key, + 'No cache entry should be keyed to SEARCH results.' + ); + } + } +}