diff --git a/.horde.yml b/.horde.yml
index 563ffaf4..d4e88859 100644
--- a/.horde.yml
+++ b/.horde.yml
@@ -28,6 +28,7 @@ dependencies:
required:
php: ^8.1
composer:
+ horde/eventdispatcher: ^1
horde/exception: ^3
horde/mail: ^3
horde/mime: ^3
@@ -51,6 +52,8 @@ dependencies:
horde/pack: ^2
horde/stringprep: ^2
horde/support: ^3
+ psr/event-dispatcher: ^1
+ psr/simple-cache: ^3
optional:
composer:
horde/cache: ^3
diff --git a/composer.json b/composer.json
index d2e6b0dc..61bf55a9 100644
--- a/composer.json
+++ b/composer.json
@@ -17,47 +17,53 @@
"time": "2026-04-12",
"repositories": [],
"require": {
- "horde/horde-installer-plugin": "dev-FRAMEWORK_6_0 || ^3 || ^2",
+ "horde/horde-installer-plugin": "^3 || ^2",
"php": "^8.1",
- "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/stream": "^2 || dev-FRAMEWORK_6_0",
- "horde/secret": "^3 || dev-FRAMEWORK_6_0",
- "horde/stream_filter": "^3 || dev-FRAMEWORK_6_0",
- "horde/translation": "^3 || dev-FRAMEWORK_6_0",
- "horde/util": "^3 || dev-FRAMEWORK_6_0",
+ "horde/eventdispatcher": "^1",
+ "horde/exception": "^3",
+ "horde/mail": "^3",
+ "horde/mime": "^3",
+ "horde/socket_client": "^3",
+ "horde/stream": "^2",
+ "horde/secret": "^3",
+ "horde/stream_filter": "^3",
+ "horde/translation": "^3",
+ "horde/util": "^3",
"ext-hash": "*",
"ext-json": "*"
},
"require-dev": {
- "horde/cache": "^3 || dev-FRAMEWORK_6_0",
- "horde/compress_fast": "^2 || dev-FRAMEWORK_6_0",
- "horde/crypt_blowfish": "^2 || dev-FRAMEWORK_6_0",
- "horde/db": "^3 || dev-FRAMEWORK_6_0",
- "horde/hashtable": "^2 || dev-FRAMEWORK_6_0",
- "horde/mongo": "^2 || dev-FRAMEWORK_6_0",
- "horde/pack": "^2 || dev-FRAMEWORK_6_0",
- "horde/stringprep": "^2 || dev-FRAMEWORK_6_0",
- "horde/support": "^3 || dev-FRAMEWORK_6_0"
+ "horde/cache": "^3",
+ "horde/compress_fast": "^2",
+ "horde/crypt_blowfish": "^2",
+ "horde/db": "^3",
+ "horde/hashtable": "^2",
+ "horde/mongo": "^2",
+ "horde/pack": "^2",
+ "horde/stringprep": "^2",
+ "horde/support": "^3",
+ "psr/event-dispatcher": "^1",
+ "psr/simple-cache": "^3"
},
"suggest": {
- "horde/cache": "^3 || dev-FRAMEWORK_6_0",
- "horde/compress_fast": "^2 || dev-FRAMEWORK_6_0",
- "horde/crypt_blowfish": "^2 || dev-FRAMEWORK_6_0",
- "horde/db": "^3 || dev-FRAMEWORK_6_0",
- "horde/hashtable": "^2 || dev-FRAMEWORK_6_0",
- "horde/mongo": "^2 || dev-FRAMEWORK_6_0",
- "horde/pack": "^2 || dev-FRAMEWORK_6_0",
- "horde/stringprep": "^2 || dev-FRAMEWORK_6_0",
- "horde/support": "^3 || dev-FRAMEWORK_6_0",
+ "horde/cache": "^3",
+ "horde/compress_fast": "^2",
+ "horde/crypt_blowfish": "^2",
+ "horde/db": "^3",
+ "horde/hashtable": "^2",
+ "horde/mongo": "^2",
+ "horde/pack": "^2",
+ "horde/stringprep": "^2",
+ "horde/support": "^3",
"ext-intl": "*",
"ext-mbstring": "*"
},
"autoload": {
"psr-0": {
"Horde_Imap_Client": "lib/"
+ },
+ "psr-4": {
+ "Horde\\Imap\\Client\\": "src/"
}
},
"autoload-dev": {
@@ -71,9 +77,5 @@
"horde/horde-installer-plugin": true
}
},
- "extra": {
- "branch-alias": {
- "dev-FRAMEWORK_6_0": "3.x-dev"
- }
- }
-}
\ No newline at end of file
+ "minimum-stability": "dev"
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a3e582fb..04b955ad 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -20,11 +20,16 @@
test/Integration
+ test/Integration/Connection
+
+
+ test/Integration/Connection
lib
+ src
diff --git a/src/AclRight.php b/src/AclRight.php
new file mode 100644
index 00000000..579c4e91
--- /dev/null
+++ b/src/AclRight.php
@@ -0,0 +1,39 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum AclRight: string
+{
+ case Lookup = 'l';
+ case Read = 'r';
+ case Seen = 's';
+ case Write = 'w';
+ case Insert = 'i';
+ case Post = 'p';
+ case CreateMbox = 'k';
+ case DeleteMbox = 'x';
+ case DeleteMsgs = 't';
+ case Expunge = 'e';
+ case Administer = 'a';
+}
diff --git a/src/CapabilityInterface.php b/src/CapabilityInterface.php
new file mode 100644
index 00000000..d0f0d711
--- /dev/null
+++ b/src/CapabilityInterface.php
@@ -0,0 +1,40 @@
+
+ * @copyright 2014-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface CapabilityInterface
+{
+ /**
+ * Query whether a capability (and optional parameter) is supported.
+ */
+ public function query(string $capability, ?string $parameter = null): bool;
+
+ /**
+ * Get the parameters for a capability.
+ *
+ * @return string[]
+ */
+ public function getParams(string $capability): array;
+}
diff --git a/src/ConnectionConfig.php b/src/ConnectionConfig.php
new file mode 100644
index 00000000..3d3f9281
--- /dev/null
+++ b/src/ConnectionConfig.php
@@ -0,0 +1,46 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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,
+ public readonly int $timeout = 30,
+ public readonly int $readTimeout = 120,
+ public readonly ?array $context = null,
+ public readonly array $capabilityIgnore = [],
+ public readonly ?array $id = null,
+ public readonly array $lang = [],
+ ) {}
+}
diff --git a/src/Event/AlertReceived.php b/src/Event/AlertReceived.php
new file mode 100644
index 00000000..ab9cf8b0
--- /dev/null
+++ b/src/Event/AlertReceived.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..3713cd76
--- /dev/null
+++ b/src/Event/AuthenticationFailed.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..1fd5d162
--- /dev/null
+++ b/src/Event/AuthenticationSucceeded.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..7c8f15ea
--- /dev/null
+++ b/src/Event/CacheDeleted.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..c65d852c
--- /dev/null
+++ b/src/Event/CacheRetrieved.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..1f5755a3
--- /dev/null
+++ b/src/Event/CacheStored.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..c9cbb896
--- /dev/null
+++ b/src/Event/CapabilityIgnored.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..3254dc1c
--- /dev/null
+++ b/src/Event/CapabilityNegotiated.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..65a0a5ba
--- /dev/null
+++ b/src/Event/ConnectionClosed.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..2297be8d
--- /dev/null
+++ b/src/Event/ConnectionEstablished.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..72fdd4d4
--- /dev/null
+++ b/src/Event/DiagnosticEvent.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..3377f7ba
--- /dev/null
+++ b/src/Event/ImapEvent.php
@@ -0,0 +1,46 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+abstract class ImapEvent
+{
+ public function __construct(
+ private readonly string $message = '',
+ private readonly array $context = [],
+ ) {}
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ /**
+ * @return array
+ */
+ public function getContext(): array
+ {
+ return $this->context;
+ }
+}
diff --git a/src/Event/MailboxExpunged.php b/src/Event/MailboxExpunged.php
new file mode 100644
index 00000000..cfa00ed5
--- /dev/null
+++ b/src/Event/MailboxExpunged.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+class MailboxExpunged extends ImapEvent {}
diff --git a/src/Event/MailboxSelected.php b/src/Event/MailboxSelected.php
new file mode 100644
index 00000000..8a849259
--- /dev/null
+++ b/src/Event/MailboxSelected.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+class MailboxSelected extends ImapEvent {}
diff --git a/src/Event/SlowCommand.php b/src/Event/SlowCommand.php
new file mode 100644
index 00000000..2a75e0ae
--- /dev/null
+++ b/src/Event/SlowCommand.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..a1dd3f12
--- /dev/null
+++ b/src/Exception/AuthenticationException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..f6d71daf
--- /dev/null
+++ b/src/Exception/CapabilityNotSupportedException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2012-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..cb7de7b9
--- /dev/null
+++ b/src/Exception/ConnectionException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..90f9212a
--- /dev/null
+++ b/src/Exception/ImapProtocolException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..ad0321b5
--- /dev/null
+++ b/src/Exception/MailboxNotFoundException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2012-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..6726e2ba
--- /dev/null
+++ b/src/Exception/MailboxProtocolException.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..d10497bc
--- /dev/null
+++ b/src/Exception/Pop3ProtocolException.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @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
new file mode 100644
index 00000000..fed126fb
--- /dev/null
+++ b/src/Exception/ServerResponseException.php
@@ -0,0 +1,41 @@
+
+ * @copyright 2012-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+class ServerResponseException extends MailboxProtocolException
+{
+ public function __construct(
+ string $message = '',
+ int $code = 0,
+ ?Throwable $previous = null,
+ public readonly ?string $command = null,
+ public readonly ?string $status = null,
+ public readonly ?string $responseText = null,
+ ) {
+ parent::__construct($message, $code, $previous);
+ }
+}
diff --git a/src/FilteredEventDispatcher.php b/src/FilteredEventDispatcher.php
new file mode 100644
index 00000000..bb840676
--- /dev/null
+++ b/src/FilteredEventDispatcher.php
@@ -0,0 +1,50 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+final class FilteredEventDispatcher implements EventDispatcherInterface
+{
+ /**
+ * @param class-string[] $suppress Event classes to swallow silently.
+ */
+ public function __construct(
+ private readonly EventDispatcherInterface $inner,
+ private readonly array $suppress = [DiagnosticEvent::class],
+ ) {}
+
+ public function dispatch(object $event): object
+ {
+ foreach ($this->suppress as $class) {
+ if ($event instanceof $class) {
+ return $event;
+ }
+ }
+
+ return $this->inner->dispatch($event);
+ }
+}
diff --git a/src/ImapAclAware.php b/src/ImapAclAware.php
new file mode 100644
index 00000000..92058c39
--- /dev/null
+++ b/src/ImapAclAware.php
@@ -0,0 +1,46 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface ImapAclAware
+{
+ /**
+ * @return object Acl value object
+ */
+ public function getACL(string $mailbox): object;
+
+ public function setACL(string $mailbox, string $identifier, array $options): void;
+
+ public function deleteACL(string $mailbox, string $identifier): void;
+
+ /**
+ * @return object AclRights value object
+ */
+ public function listACLRights(string $mailbox, string $identifier): object;
+
+ /**
+ * @return object AclRights value object
+ */
+ public function getMyACLRights(string $mailbox): object;
+}
diff --git a/src/ImapMetadataAware.php b/src/ImapMetadataAware.php
new file mode 100644
index 00000000..c9c138ef
--- /dev/null
+++ b/src/ImapMetadataAware.php
@@ -0,0 +1,31 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface ImapMetadataAware
+{
+ public function getMetadata(string $mailbox, array $entries, array $options = []): array;
+
+ public function setMetadata(string $mailbox, array $data): void;
+}
diff --git a/src/ImapProtocol.php b/src/ImapProtocol.php
new file mode 100644
index 00000000..f4dccb8d
--- /dev/null
+++ b/src/ImapProtocol.php
@@ -0,0 +1,69 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface ImapProtocol extends MailboxProtocol
+{
+ public function getCapability(): CapabilityInterface;
+
+ public function openMailbox(string $mailbox, OpenMode $mode): void;
+
+ public function createMailbox(string $mailbox): void;
+
+ public function deleteMailbox(string $mailbox): void;
+
+ public function renameMailbox(string $old, string $new): void;
+
+ public function subscribeMailbox(string $mailbox, bool $subscribe = true): void;
+
+ public function listMailboxes(string $pattern, MailboxListMode $mode, array $options = []): array;
+
+ public function close(array $options = []): void;
+
+ /**
+ * @param object $query SearchQuery
+ * @return object SearchResult
+ */
+ public function search(string $mailbox, object $query, array $options = []): object;
+
+ /**
+ * @return object ThreadResult
+ */
+ public function thread(string $mailbox, array $options = []): object;
+
+ public function copy(string $source, string $dest, array $options = []): MessageIdSet;
+
+ public function move(string $source, string $dest, array $options = []): MessageIdSet;
+
+ public function append(string $mailbox, array $data, array $options = []): MessageIdSet;
+
+ /**
+ * @return object NamespaceList
+ */
+ public function getNamespaces(): object;
+
+ public function unselect(): void;
+}
diff --git a/src/ImapQuotaAware.php b/src/ImapQuotaAware.php
new file mode 100644
index 00000000..fe69fd66
--- /dev/null
+++ b/src/ImapQuotaAware.php
@@ -0,0 +1,33 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface ImapQuotaAware
+{
+ public function setQuota(string $root, array $resources): void;
+
+ public function getQuota(string $root): array;
+
+ public function getQuotaRoot(string $mailbox): array;
+}
diff --git a/src/MailboxListMode.php b/src/MailboxListMode.php
new file mode 100644
index 00000000..aa167cc9
--- /dev/null
+++ b/src/MailboxListMode.php
@@ -0,0 +1,31 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum MailboxListMode: int
+{
+ case Subscribed = 1;
+ case SubscribedExists = 2;
+ case Unsubscribed = 3;
+ case All = 4;
+ case AllSubscribed = 5;
+}
diff --git a/src/MailboxProtocol.php b/src/MailboxProtocol.php
new file mode 100644
index 00000000..802cb07a
--- /dev/null
+++ b/src/MailboxProtocol.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface MailboxProtocol
+{
+ public function login(): void;
+
+ public function logout(): void;
+
+ public function noop(): void;
+
+ /**
+ * @return object MailboxStatus value object
+ */
+ public function status(string $mailbox, int $flags): object;
+
+ /**
+ * @param object $query FetchQuery
+ * @return Generator
+ */
+ public function fetch(string $mailbox, MessageIdSet $ids, object $query): Generator;
+
+ public function store(string $mailbox, array $options): MessageIdSet;
+
+ public function expunge(string $mailbox, array $options): MessageIdSet;
+
+ public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSet;
+}
diff --git a/src/MessageContent.php b/src/MessageContent.php
new file mode 100644
index 00000000..d5c86a61
--- /dev/null
+++ b/src/MessageContent.php
@@ -0,0 +1,35 @@
+
+ * @copyright 2011-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface MessageContent
+{
+ public function getFullMsg(): Horde_Stream;
+
+ public function getHeaderText(string|int $id = 0): Horde_Stream;
+
+ public function getBodyText(string|int $id = 0): Horde_Stream;
+}
diff --git a/src/MessageIdSet.php b/src/MessageIdSet.php
new file mode 100644
index 00000000..e0fcd834
--- /dev/null
+++ b/src/MessageIdSet.php
@@ -0,0 +1,42 @@
+
+ *
+ * @author Michael Slusarz
+ * @copyright 2011-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface MessageIdSet extends Countable, IteratorAggregate
+{
+ public function isEmpty(): bool;
+
+ /**
+ * @return array
+ */
+ public function toArray(): array;
+
+ public function __toString(): string;
+}
diff --git a/src/MessageMetadata.php b/src/MessageMetadata.php
new file mode 100644
index 00000000..0a3be042
--- /dev/null
+++ b/src/MessageMetadata.php
@@ -0,0 +1,42 @@
+
+ * @copyright 2011-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface MessageMetadata
+{
+ public function getUid(): int|string;
+
+ /**
+ * @return string[]
+ */
+ public function getFlags(): array;
+
+ public function getSize(): int;
+
+ public function getImapDate(): DateTimeImmutable;
+
+ public function getSeq(): ?int;
+
+ public function getModSeq(): ?int;
+}
diff --git a/src/OpenMode.php b/src/OpenMode.php
new file mode 100644
index 00000000..5f94909b
--- /dev/null
+++ b/src/OpenMode.php
@@ -0,0 +1,29 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum OpenMode: int
+{
+ case Readonly = 1;
+ case ReadWrite = 2;
+ case Auto = 3;
+}
diff --git a/src/ParsedAccess.php b/src/ParsedAccess.php
new file mode 100644
index 00000000..95ed8426
--- /dev/null
+++ b/src/ParsedAccess.php
@@ -0,0 +1,52 @@
+
+ * @copyright 2011-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface ParsedAccess
+{
+ /**
+ * @return object Envelope value object
+ */
+ public function getEnvelope(): object;
+
+ /**
+ * @return object Headers value object
+ */
+ public function getHeaders(string $label): object;
+
+ /**
+ * Yields headers one at a time from the stream.
+ *
+ * @return Generator
+ */
+ public function getHeadersIterator(string $label): Generator;
+
+ /**
+ * @return object BodyStructure value object
+ */
+ public function getStructure(): object;
+}
diff --git a/src/PartAccess.php b/src/PartAccess.php
new file mode 100644
index 00000000..eea20291
--- /dev/null
+++ b/src/PartAccess.php
@@ -0,0 +1,41 @@
+
+ * @copyright 2011-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+interface PartAccess
+{
+ public function getBodyPart(string $id): Horde_Stream;
+
+ public function getMimeHeader(string $id): Horde_Stream;
+
+ /**
+ * Yields parts lazily from BODYSTRUCTURE.
+ *
+ * @return Generator
+ */
+ public function getParts(): Generator;
+}
diff --git a/src/PasswordInterface.php b/src/PasswordInterface.php
new file mode 100644
index 00000000..a3a065b1
--- /dev/null
+++ b/src/PasswordInterface.php
@@ -0,0 +1,32 @@
+
+ * @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/SearchResultType.php b/src/SearchResultType.php
new file mode 100644
index 00000000..b76c85ae
--- /dev/null
+++ b/src/SearchResultType.php
@@ -0,0 +1,33 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum SearchResultType: int
+{
+ case Count = 1;
+ case Match = 2;
+ case Max = 3;
+ case Min = 4;
+ case Save = 5;
+ /** RFC 6203 */
+ case Relevancy = 6;
+}
diff --git a/src/SecureMode.php b/src/SecureMode.php
new file mode 100644
index 00000000..313a979b
--- /dev/null
+++ b/src/SecureMode.php
@@ -0,0 +1,32 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum SecureMode: string
+{
+ case None = '';
+ case Ssl = 'ssl';
+ case Tls = 'tls';
+ case Tlsv1 = 'tlsv1';
+}
diff --git a/src/SortCriteria.php b/src/SortCriteria.php
new file mode 100644
index 00000000..82b80bfb
--- /dev/null
+++ b/src/SortCriteria.php
@@ -0,0 +1,46 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum SortCriteria: int
+{
+ case Arrival = 1;
+ case Cc = 2;
+ case Date = 3;
+ case From = 4;
+ case Reverse = 5;
+ case Size = 6;
+ case Subject = 7;
+ case To = 8;
+ case Thread = 9;
+ /** RFC 5957 */
+ case DisplayFrom = 10;
+ /** RFC 5957 */
+ case DisplayTo = 11;
+ case Sequence = 12;
+ /** RFC 6203 */
+ case Relevancy = 13;
+ case DisplayFromFallback = 14;
+ case DisplayToFallback = 15;
+}
diff --git a/src/SpecialUse.php b/src/SpecialUse.php
new file mode 100644
index 00000000..9318481e
--- /dev/null
+++ b/src/SpecialUse.php
@@ -0,0 +1,33 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum SpecialUse: string
+{
+ case All = '\\All';
+ case Archive = '\\Archive';
+ case Drafts = '\\Drafts';
+ case Flagged = '\\Flagged';
+ case Junk = '\\Junk';
+ case Sent = '\\Sent';
+ case Trash = '\\Trash';
+}
diff --git a/src/SystemFlag.php b/src/SystemFlag.php
new file mode 100644
index 00000000..652f9267
--- /dev/null
+++ b/src/SystemFlag.php
@@ -0,0 +1,43 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum SystemFlag: string
+{
+ /** RFC 3501 section 2.3.2 */
+ case Answered = '\\answered';
+ case Deleted = '\\deleted';
+ case Draft = '\\draft';
+ case Flagged = '\\flagged';
+ case Recent = '\\recent';
+ case Seen = '\\seen';
+
+ /** RFC 3503 section 3.3 */
+ case MdnSent = '$mdnsent';
+
+ /** RFC 5550 section 2.8 */
+ case Forwarded = '$forwarded';
+
+ /** RFC 5788 registered keywords */
+ case Junk = '$junk';
+ case NotJunk = '$notjunk';
+}
diff --git a/src/ThreadAlgorithm.php b/src/ThreadAlgorithm.php
new file mode 100644
index 00000000..0cb43da6
--- /dev/null
+++ b/src/ThreadAlgorithm.php
@@ -0,0 +1,29 @@
+
+ * @copyright 2008-2026 Horde LLC
+ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
+ */
+enum ThreadAlgorithm: int
+{
+ case OrderedSubject = 1;
+ case References = 2;
+ case Refs = 3;
+}
diff --git a/test/Integration/Connection/ImapConnectionTest.php b/test/Integration/Connection/ImapConnectionTest.php
new file mode 100644
index 00000000..c354f155
--- /dev/null
+++ b/test/Integration/Connection/ImapConnectionTest.php
@@ -0,0 +1,143 @@
+logout();
+ } catch (Horde_Imap_Client_Exception $e) {
+ }
+ }
+ self::$live = null;
+ self::$clientConfig = null;
+ }
+
+ public function testConnectReturnsCapability(): void
+ {
+ $this->assertInstanceOf(
+ Horde_Imap_Client_Data_Capability::class,
+ self::$live->capability
+ );
+ }
+
+ #[Depends('testConnectReturnsCapability')]
+ public function testLogin(): void
+ {
+ self::$live->login();
+ $this->assertTrue(true);
+ }
+
+ #[Depends('testLogin')]
+ public function testCapabilityAfterLoginIncludesImap4Rev1(): void
+ {
+ $this->assertTrue(
+ self::$live->capability->query('IMAP4REV1')
+ );
+ }
+
+ #[Depends('testLogin')]
+ public function testNoop(): void
+ {
+ self::$live->noop();
+ $this->assertTrue(true);
+ }
+
+ #[Depends('testLogin')]
+ public function testUrlPropertyMatchesConfig(): void
+ {
+ $url = self::$live->url;
+ $this->assertEquals(
+ self::$clientConfig['hostspec'],
+ $url->hostspec
+ );
+ }
+
+ #[Depends('testLogin')]
+ public function testLogout(): void
+ {
+ self::$live->logout();
+ $this->assertTrue(true);
+ }
+
+ #[Depends('testLogout')]
+ public function testReconnectAfterLogout(): void
+ {
+ self::$live->login();
+ $this->assertTrue(true);
+ }
+
+ public function testInvalidCredentialsFails(): void
+ {
+ if (empty(self::$clientConfig)) {
+ $this->markTestSkipped('No IMAP configuration available.');
+ }
+
+ $config = self::$clientConfig;
+ $config['password'] = 'intentionally-wrong-password-' . mt_rand();
+
+ $bad = new Horde_Imap_Client_Socket($config);
+
+ $this->expectException(Horde_Imap_Client_Exception::class);
+ $bad->login();
+ }
+}
diff --git a/test/Integration/Connection/Pop3ConnectionTest.php b/test/Integration/Connection/Pop3ConnectionTest.php
new file mode 100644
index 00000000..8404048d
--- /dev/null
+++ b/test/Integration/Connection/Pop3ConnectionTest.php
@@ -0,0 +1,123 @@
+logout();
+ } catch (Horde_Imap_Client_Exception $e) {
+ }
+ }
+ self::$live = null;
+ self::$clientConfig = null;
+ }
+
+ public function testConnect(): void
+ {
+ $this->assertNotNull(self::$live->capability);
+ }
+
+ #[Depends('testConnect')]
+ public function testLogin(): void
+ {
+ self::$live->login();
+ $this->assertTrue(true);
+ }
+
+ #[Depends('testLogin')]
+ public function testLogout(): void
+ {
+ self::$live->logout();
+ $this->assertTrue(true);
+ }
+
+ public function testInvalidCredentialsFails(): void
+ {
+ if (empty(self::$clientConfig)) {
+ $this->markTestSkipped('No POP3 configuration available.');
+ }
+
+ $config = self::$clientConfig;
+ $config['password'] = 'intentionally-wrong-password-' . mt_rand();
+
+ $bad = new Horde_Imap_Client_Socket_Pop3($config);
+
+ $this->expectException(Horde_Imap_Client_Exception::class);
+ $bad->login();
+ }
+
+ #[Depends('testLogin')]
+ public function testStatusAfterLogin(): void
+ {
+ // Re-login after logout test may have run
+ self::$live = new Horde_Imap_Client_Socket_Pop3(self::$clientConfig);
+ self::$live->login();
+
+ $status = self::$live->status(
+ 'INBOX',
+ Horde_Imap_Client::STATUS_MESSAGES
+ );
+
+ $this->assertArrayHasKey('messages', $status);
+ }
+}
diff --git a/test/Integration/Src/CapabilityInterfaceTest.php b/test/Integration/Src/CapabilityInterfaceTest.php
new file mode 100644
index 00000000..af81a385
--- /dev/null
+++ b/test/Integration/Src/CapabilityInterfaceTest.php
@@ -0,0 +1,63 @@
+createImplementation();
+ $this->assertTrue($cap->query('IMAP4rev1'));
+ $this->assertFalse($cap->query('CONDSTORE'));
+ }
+
+ public function testQueryWithParameter(): void
+ {
+ $cap = $this->createImplementation();
+ $this->assertTrue($cap->query('AUTH', 'PLAIN'));
+ $this->assertFalse($cap->query('AUTH', 'CRAM-MD5'));
+ }
+
+ public function testGetParamsReturnsStringArray(): void
+ {
+ $cap = $this->createImplementation();
+ $this->assertSame(['PLAIN', 'LOGIN'], $cap->getParams('AUTH'));
+ }
+
+ public function testGetParamsEmptyArray(): void
+ {
+ $cap = $this->createImplementation();
+ $this->assertSame([], $cap->getParams('UNKNOWN'));
+ }
+}
diff --git a/test/Integration/Src/ComposedInterfaceTest.php b/test/Integration/Src/ComposedInterfaceTest.php
new file mode 100644
index 00000000..e43f7678
--- /dev/null
+++ b/test/Integration/Src/ComposedInterfaceTest.php
@@ -0,0 +1,278 @@
+assertInstanceOf(ImapProtocol::class, $stub);
+ $this->assertInstanceOf(ImapQuotaAware::class, $stub);
+ $this->assertInstanceOf(ImapAclAware::class, $stub);
+ $this->assertInstanceOf(ImapMetadataAware::class, $stub);
+
+ // Exercise one method from each interface
+ $stub->login();
+ $stub->openMailbox('INBOX', OpenMode::ReadWrite);
+ $stub->setQuota('', []);
+ $stub->deleteACL('INBOX', 'user');
+ $stub->setMetadata('INBOX', []);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testMessageCanImplementMetadataAndContent(): void
+ {
+ $stub = new class implements MessageMetadata, MessageContent {
+ public function getUid(): int|string
+ {
+ return 1;
+ }
+ public function getFlags(): array
+ {
+ return [];
+ }
+ public function getSize(): int
+ {
+ return 0;
+ }
+ public function getImapDate(): DateTimeImmutable
+ {
+ return new DateTimeImmutable();
+ }
+ public function getSeq(): ?int
+ {
+ return null;
+ }
+ public function getModSeq(): ?int
+ {
+ return null;
+ }
+ public function getFullMsg(): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getHeaderText(string|int $id = 0): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getBodyText(string|int $id = 0): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ };
+
+ $this->assertInstanceOf(MessageMetadata::class, $stub);
+ $this->assertInstanceOf(MessageContent::class, $stub);
+ $this->assertSame(1, $stub->getUid());
+ $this->assertInstanceOf(Horde_Stream::class, $stub->getFullMsg());
+ }
+
+ public function testMessageCanImplementAllFourLayers(): void
+ {
+ $stub = new class implements MessageMetadata, MessageContent, PartAccess, ParsedAccess {
+ public function getUid(): int|string
+ {
+ return 42;
+ }
+ public function getFlags(): array
+ {
+ return ['\\Seen'];
+ }
+ public function getSize(): int
+ {
+ return 2048;
+ }
+ public function getImapDate(): DateTimeImmutable
+ {
+ return new DateTimeImmutable();
+ }
+ public function getSeq(): ?int
+ {
+ return 1;
+ }
+ public function getModSeq(): ?int
+ {
+ return null;
+ }
+ public function getFullMsg(): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getHeaderText(string|int $id = 0): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getBodyText(string|int $id = 0): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getBodyPart(string $id): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getMimeHeader(string $id): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ public function getParts(): Generator
+ {
+ yield '1' => new stdClass();
+ }
+ public function getEnvelope(): object
+ {
+ return new stdClass();
+ }
+ public function getHeaders(string $label): object
+ {
+ return new stdClass();
+ }
+ public function getHeadersIterator(string $label): Generator
+ {
+ yield new stdClass();
+ }
+ public function getStructure(): object
+ {
+ return new stdClass();
+ }
+ };
+
+ $this->assertInstanceOf(MessageMetadata::class, $stub);
+ $this->assertInstanceOf(MessageContent::class, $stub);
+ $this->assertInstanceOf(PartAccess::class, $stub);
+ $this->assertInstanceOf(ParsedAccess::class, $stub);
+
+ $this->assertSame(42, $stub->getUid());
+ $this->assertInstanceOf(Horde_Stream::class, $stub->getBodyPart('1'));
+ $this->assertIsObject($stub->getEnvelope());
+ }
+}
diff --git a/test/Integration/Src/ImapAclAwareTest.php b/test/Integration/Src/ImapAclAwareTest.php
new file mode 100644
index 00000000..cc075d39
--- /dev/null
+++ b/test/Integration/Src/ImapAclAwareTest.php
@@ -0,0 +1,66 @@
+assertIsObject($this->createImplementation()->getACL('INBOX'));
+ }
+
+ public function testSetACLReturnsVoid(): void
+ {
+ $this->createImplementation()->setACL('INBOX', 'user', ['rights' => 'lrs']);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testDeleteACLReturnsVoid(): void
+ {
+ $this->createImplementation()->deleteACL('INBOX', 'user');
+ $this->addToAssertionCount(1);
+ }
+
+ public function testListACLRightsReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->listACLRights('INBOX', 'user'));
+ }
+
+ public function testGetMyACLRightsReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->getMyACLRights('INBOX'));
+ }
+
+ public function testInstanceOfImapAclAware(): void
+ {
+ $this->assertInstanceOf(ImapAclAware::class, $this->createImplementation());
+ }
+}
diff --git a/test/Integration/Src/ImapMetadataAwareTest.php b/test/Integration/Src/ImapMetadataAwareTest.php
new file mode 100644
index 00000000..ce2ad660
--- /dev/null
+++ b/test/Integration/Src/ImapMetadataAwareTest.php
@@ -0,0 +1,47 @@
+ 'test'];
+ }
+
+ public function setMetadata(string $mailbox, array $data): void {}
+ };
+ }
+
+ public function testGetMetadataReturnsArray(): void
+ {
+ $this->assertIsArray($this->createImplementation()->getMetadata('INBOX', ['/shared/comment']));
+ }
+
+ public function testGetMetadataWithOptions(): void
+ {
+ $result = $this->createImplementation()->getMetadata('INBOX', ['/shared/comment'], ['DEPTH' => 0]);
+ $this->assertIsArray($result);
+ }
+
+ public function testSetMetadataReturnsVoid(): void
+ {
+ $this->createImplementation()->setMetadata('INBOX', ['/shared/comment' => 'test']);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testInstanceOfImapMetadataAware(): void
+ {
+ $this->assertInstanceOf(ImapMetadataAware::class, $this->createImplementation());
+ }
+}
diff --git a/test/Integration/Src/ImapProtocolTest.php b/test/Integration/Src/ImapProtocolTest.php
new file mode 100644
index 00000000..5ab0c6d7
--- /dev/null
+++ b/test/Integration/Src/ImapProtocolTest.php
@@ -0,0 +1,183 @@
+ new stdClass();
+ }
+ public function store(string $mailbox, array $options): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+ public function expunge(string $mailbox, array $options): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+ public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+
+ // ImapProtocol methods
+ public function getCapability(): CapabilityInterface
+ {
+ return new class implements CapabilityInterface {
+ public function query(string $capability, ?string $parameter = null): bool
+ {
+ return false;
+ }
+ public function getParams(string $capability): array
+ {
+ return [];
+ }
+ };
+ }
+ public function openMailbox(string $mailbox, OpenMode $mode): void {}
+ public function createMailbox(string $mailbox): void {}
+ public function deleteMailbox(string $mailbox): void {}
+ public function renameMailbox(string $old, string $new): void {}
+ public function subscribeMailbox(string $mailbox, bool $subscribe = true): void {}
+ public function listMailboxes(string $pattern, MailboxListMode $mode, array $options = []): array
+ {
+ return [];
+ }
+ public function close(array $options = []): void {}
+ public function search(string $mailbox, object $query, array $options = []): object
+ {
+ return new stdClass();
+ }
+ public function thread(string $mailbox, array $options = []): object
+ {
+ return new stdClass();
+ }
+ public function copy(string $source, string $dest, array $options = []): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+ public function move(string $source, string $dest, array $options = []): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+ public function append(string $mailbox, array $data, array $options = []): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+ public function getNamespaces(): object
+ {
+ return new stdClass();
+ }
+ public function unselect(): void {}
+ };
+ }
+
+ public function testImplementsMailboxProtocol(): void
+ {
+ $this->assertInstanceOf(MailboxProtocol::class, $this->createImplementation());
+ }
+
+ public function testImplementsImapProtocol(): void
+ {
+ $this->assertInstanceOf(ImapProtocol::class, $this->createImplementation());
+ }
+
+ public function testGetCapabilityReturnsCapabilityInterface(): void
+ {
+ $this->assertInstanceOf(CapabilityInterface::class, $this->createImplementation()->getCapability());
+ }
+
+ public function testOpenMailboxAcceptsOpenModeEnum(): void
+ {
+ $this->createImplementation()->openMailbox('INBOX', OpenMode::ReadWrite);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testCreateDeleteRenameMailbox(): void
+ {
+ $stub = $this->createImplementation();
+ $stub->createMailbox('Test');
+ $stub->renameMailbox('Test', 'Archive');
+ $stub->deleteMailbox('Archive');
+ $this->addToAssertionCount(1);
+ }
+
+ public function testSubscribeMailbox(): void
+ {
+ $stub = $this->createImplementation();
+ $stub->subscribeMailbox('INBOX', true);
+ $stub->subscribeMailbox('INBOX');
+ $this->addToAssertionCount(1);
+ }
+
+ public function testListMailboxesAcceptsMailboxListModeEnum(): void
+ {
+ $result = $this->createImplementation()->listMailboxes('*', MailboxListMode::All);
+ $this->assertIsArray($result);
+ }
+
+ public function testSearchReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->search('INBOX', new stdClass()));
+ }
+
+ public function testThreadReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->thread('INBOX'));
+ }
+
+ public function testCopyReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->copy('INBOX', 'Archive'));
+ }
+
+ public function testMoveReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->move('INBOX', 'Archive'));
+ }
+
+ public function testAppendReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->append('INBOX', []));
+ }
+
+ public function testGetNamespacesReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->getNamespaces());
+ }
+
+ public function testCloseAndUnselect(): void
+ {
+ $stub = $this->createImplementation();
+ $stub->close();
+ $stub->unselect();
+ $this->addToAssertionCount(1);
+ }
+}
diff --git a/test/Integration/Src/ImapQuotaAwareTest.php b/test/Integration/Src/ImapQuotaAwareTest.php
new file mode 100644
index 00000000..a0b23ad7
--- /dev/null
+++ b/test/Integration/Src/ImapQuotaAwareTest.php
@@ -0,0 +1,49 @@
+ [1024, 10240]];
+ }
+ public function getQuotaRoot(string $mailbox): array
+ {
+ return ['INBOX' => ''];
+ }
+ };
+ }
+
+ public function testSetQuotaReturnsVoid(): void
+ {
+ $this->createImplementation()->setQuota('', ['STORAGE' => 10240]);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testGetQuotaReturnsArray(): void
+ {
+ $this->assertIsArray($this->createImplementation()->getQuota(''));
+ }
+
+ public function testGetQuotaRootReturnsArray(): void
+ {
+ $this->assertIsArray($this->createImplementation()->getQuotaRoot('INBOX'));
+ }
+
+ public function testInstanceOfImapQuotaAware(): void
+ {
+ $this->assertInstanceOf(ImapQuotaAware::class, $this->createImplementation());
+ }
+}
diff --git a/test/Integration/Src/MailboxProtocolTest.php b/test/Integration/Src/MailboxProtocolTest.php
new file mode 100644
index 00000000..56657bba
--- /dev/null
+++ b/test/Integration/Src/MailboxProtocolTest.php
@@ -0,0 +1,113 @@
+ new stdClass();
+ }
+
+ public function store(string $mailbox, array $options): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+
+ public function expunge(string $mailbox, array $options): MessageIdSet
+ {
+ return new StubMessageIdSet();
+ }
+
+ public function getIdsOb(mixed $ids = null, bool $sequence = false): MessageIdSet
+ {
+ return new StubMessageIdSet(is_array($ids) ? $ids : []);
+ }
+ };
+ }
+
+ public function testLoginReturnsVoid(): void
+ {
+ $this->createImplementation()->login();
+ $this->addToAssertionCount(1);
+ }
+
+ public function testLogoutReturnsVoid(): void
+ {
+ $this->createImplementation()->logout();
+ $this->addToAssertionCount(1);
+ }
+
+ public function testNoopReturnsVoid(): void
+ {
+ $this->createImplementation()->noop();
+ $this->addToAssertionCount(1);
+ }
+
+ public function testStatusReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->status('INBOX', 0));
+ }
+
+ public function testFetchReturnsGenerator(): void
+ {
+ $gen = $this->createImplementation()->fetch('INBOX', new StubMessageIdSet([1]), new stdClass());
+ $this->assertInstanceOf(Generator::class, $gen);
+ }
+
+ public function testFetchYieldsResults(): void
+ {
+ $gen = $this->createImplementation()->fetch('INBOX', new StubMessageIdSet([1]), new stdClass());
+ $items = iterator_to_array($gen);
+ $this->assertCount(1, $items);
+ }
+
+ public function testStoreReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->store('INBOX', []));
+ }
+
+ public function testExpungeReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->expunge('INBOX', []));
+ }
+
+ public function testGetIdsObReturnsMessageIdSet(): void
+ {
+ $this->assertInstanceOf(MessageIdSet::class, $this->createImplementation()->getIdsOb());
+ }
+
+ public function testGetIdsObWithParameters(): void
+ {
+ $ids = $this->createImplementation()->getIdsOb([1, 2, 3], true);
+ $this->assertInstanceOf(MessageIdSet::class, $ids);
+ }
+
+ public function testStubInstanceOfMailboxProtocol(): void
+ {
+ $this->assertInstanceOf(MailboxProtocol::class, $this->createImplementation());
+ }
+}
diff --git a/test/Integration/Src/MessageContentTest.php b/test/Integration/Src/MessageContentTest.php
new file mode 100644
index 00000000..33778695
--- /dev/null
+++ b/test/Integration/Src/MessageContentTest.php
@@ -0,0 +1,68 @@
+add("Subject: Test $id");
+ return $s;
+ }
+
+ public function getBodyText(string|int $id = 0): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+ };
+ }
+
+ public function testGetFullMsgReturnsHordeStream(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getFullMsg());
+ }
+
+ public function testGetHeaderTextWithDefaultId(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText());
+ }
+
+ public function testGetHeaderTextWithIntId(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText(1));
+ }
+
+ public function testGetHeaderTextWithStringId(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getHeaderText('1.2'));
+ }
+
+ public function testGetBodyTextReturnsHordeStream(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getBodyText());
+ }
+
+ public function testStreamContainsContent(): void
+ {
+ $stub = $this->createImplementation();
+ $stream = $stub->getHeaderText(5);
+ $this->assertStringContainsString('Subject: Test 5', (string) $stream);
+ }
+}
diff --git a/test/Integration/Src/MessageIdSetTest.php b/test/Integration/Src/MessageIdSetTest.php
new file mode 100644
index 00000000..709ef85d
--- /dev/null
+++ b/test/Integration/Src/MessageIdSetTest.php
@@ -0,0 +1,75 @@
+assertCount(3, $set);
+ }
+
+ public function testIteratorAggregateContract(): void
+ {
+ $set = new StubMessageIdSet([1, 2, 3]);
+ $collected = [];
+ foreach ($set as $id) {
+ $collected[] = $id;
+ }
+ $this->assertSame([1, 2, 3], $collected);
+ }
+
+ public function testIsEmptyWhenEmpty(): void
+ {
+ $set = new StubMessageIdSet();
+ $this->assertTrue($set->isEmpty());
+ }
+
+ public function testIsEmptyWhenNotEmpty(): void
+ {
+ $set = new StubMessageIdSet([42]);
+ $this->assertFalse($set->isEmpty());
+ }
+
+ public function testToArrayReturnsArray(): void
+ {
+ $set = new StubMessageIdSet([1, 2, 3]);
+ $this->assertSame([1, 2, 3], $set->toArray());
+ }
+
+ public function testToStringReturnsString(): void
+ {
+ $set = new StubMessageIdSet([1, 2, 3]);
+ $this->assertSame('1,2,3', (string) $set);
+ }
+
+ public function testStringIdSet(): void
+ {
+ $set = new StubMessageIdSet(['abc', 'def']);
+ $this->assertCount(2, $set);
+ $this->assertSame(['abc', 'def'], $set->toArray());
+ $this->assertSame('abc,def', (string) $set);
+ $this->assertFalse($set->isEmpty());
+ }
+
+ public function testCountZeroWhenEmpty(): void
+ {
+ $set = new StubMessageIdSet();
+ $this->assertSame(0, count($set));
+ }
+
+ public function testInstanceOfMessageIdSet(): void
+ {
+ $set = new StubMessageIdSet();
+ $this->assertInstanceOf(MessageIdSet::class, $set);
+ }
+}
diff --git a/test/Integration/Src/MessageMetadataTest.php b/test/Integration/Src/MessageMetadataTest.php
new file mode 100644
index 00000000..0186407e
--- /dev/null
+++ b/test/Integration/Src/MessageMetadataTest.php
@@ -0,0 +1,103 @@
+uid;
+ }
+ public function getFlags(): array
+ {
+ return $this->flags;
+ }
+ public function getSize(): int
+ {
+ return $this->size;
+ }
+ public function getImapDate(): DateTimeImmutable
+ {
+ return $this->date;
+ }
+ public function getSeq(): ?int
+ {
+ return $this->seq;
+ }
+ public function getModSeq(): ?int
+ {
+ return $this->modSeq;
+ }
+ };
+ }
+
+ public function testAllMethodReturnTypes(): void
+ {
+ $stub = $this->createImplementation();
+
+ $this->assertSame(42, $stub->getUid());
+ $this->assertSame(['\\Seen'], $stub->getFlags());
+ $this->assertSame(1024, $stub->getSize());
+ $this->assertSame('2026-01-15 10:30:00', $stub->getImapDate()->format('Y-m-d H:i:s'));
+ $this->assertSame(7, $stub->getSeq());
+ $this->assertSame(12345, $stub->getModSeq());
+ }
+
+ public function testStringUid(): void
+ {
+ $stub = $this->createImplementation(uid: 'msg-abc');
+ $this->assertIsString($stub->getUid());
+ $this->assertSame('msg-abc', $stub->getUid());
+ }
+
+ public function testNullableSeq(): void
+ {
+ $stub = $this->createImplementation(seq: null);
+ $this->assertNull($stub->getSeq());
+ }
+
+ public function testNullableModSeq(): void
+ {
+ $stub = $this->createImplementation(modSeq: null);
+ $this->assertNull($stub->getModSeq());
+ }
+
+ public function testEmptyFlags(): void
+ {
+ $stub = $this->createImplementation(flags: []);
+ $this->assertSame([], $stub->getFlags());
+ }
+
+ public function testDateTimeImmutableType(): void
+ {
+ $stub = $this->createImplementation();
+ $this->assertInstanceOf(DateTimeImmutable::class, $stub->getImapDate());
+ }
+}
diff --git a/test/Integration/Src/ParsedAccessTest.php b/test/Integration/Src/ParsedAccessTest.php
new file mode 100644
index 00000000..2e3edee5
--- /dev/null
+++ b/test/Integration/Src/ParsedAccessTest.php
@@ -0,0 +1,69 @@
+assertIsObject($this->createImplementation()->getEnvelope());
+ }
+
+ public function testGetHeadersReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->getHeaders('from'));
+ }
+
+ public function testGetHeadersIteratorReturnsGenerator(): void
+ {
+ $gen = $this->createImplementation()->getHeadersIterator('to');
+ $this->assertInstanceOf(Generator::class, $gen);
+ }
+
+ public function testGetStructureReturnsObject(): void
+ {
+ $this->assertIsObject($this->createImplementation()->getStructure());
+ }
+
+ public function testGetHeadersIteratorYieldsMultipleHeaders(): void
+ {
+ $items = iterator_to_array($this->createImplementation()->getHeadersIterator('cc'));
+ $this->assertCount(3, $items);
+ }
+}
diff --git a/test/Integration/Src/PartAccessTest.php b/test/Integration/Src/PartAccessTest.php
new file mode 100644
index 00000000..34787b48
--- /dev/null
+++ b/test/Integration/Src/PartAccessTest.php
@@ -0,0 +1,76 @@
+ new stdClass();
+ yield '1.1' => new stdClass();
+ }
+ };
+ }
+
+ public function testGetBodyPartReturnsHordeStream(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getBodyPart('1.1'));
+ }
+
+ public function testGetMimeHeaderReturnsHordeStream(): void
+ {
+ $this->assertInstanceOf(Horde_Stream::class, $this->createImplementation()->getMimeHeader('1'));
+ }
+
+ public function testGetPartsReturnsGenerator(): void
+ {
+ $gen = $this->createImplementation()->getParts();
+ $this->assertInstanceOf(Generator::class, $gen);
+ $this->assertCount(2, iterator_to_array($gen));
+ }
+
+ public function testGetPartsEmptyGenerator(): void
+ {
+ $stub = new class implements PartAccess {
+ public function getBodyPart(string $id): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+
+ public function getMimeHeader(string $id): Horde_Stream
+ {
+ return new Horde_Stream();
+ }
+
+ public function getParts(): Generator
+ {
+ yield from [];
+ }
+ };
+
+ $this->assertSame([], iterator_to_array($stub->getParts()));
+ }
+}
diff --git a/test/Integration/Src/PasswordInterfaceTest.php b/test/Integration/Src/PasswordInterfaceTest.php
new file mode 100644
index 00000000..1abf562d
--- /dev/null
+++ b/test/Integration/Src/PasswordInterfaceTest.php
@@ -0,0 +1,38 @@
+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/Base.php b/test/Stub/Base.php
new file mode 100644
index 00000000..1b0fb4ed
--- /dev/null
+++ b/test/Stub/Base.php
@@ -0,0 +1,211 @@
+_setInit($key, $val);
+ }
+
+ public function initCache($current = false): bool
+ {
+ return $this->_initCache($current);
+ }
+
+ protected function _initCapability() {}
+
+ protected function _noop() {}
+
+ protected function _getNamespaces()
+ {
+ return [];
+ }
+
+ protected function _connect() {}
+
+ protected function _login()
+ {
+ return true;
+ }
+
+ protected function _logout() {}
+
+ protected function _sendID($info) {}
+
+ protected function _getID()
+ {
+ return [];
+ }
+
+ protected function _setLanguage($langs)
+ {
+ return [];
+ }
+
+ protected function _getLanguage($list)
+ {
+ return [];
+ }
+
+ protected function _openMailbox(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $mode
+ ) {}
+
+ protected function _createMailbox(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $opts
+ ) {}
+
+ protected function _deleteMailbox(Horde_Imap_Client_Mailbox $mailbox) {}
+
+ protected function _renameMailbox(
+ Horde_Imap_Client_Mailbox $old,
+ Horde_Imap_Client_Mailbox $new
+ ) {}
+
+ protected function _subscribeMailbox(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $subscribe
+ ) {}
+
+ protected function _listMailboxes($pattern, $mode, $options)
+ {
+ return [];
+ }
+
+ protected function _status($mboxes, $flags)
+ {
+ return [];
+ }
+
+ protected function _append(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $data,
+ $options
+ ) {
+ return new Horde_Imap_Client_Ids();
+ }
+
+ protected function _check() {}
+
+ protected function _close($options) {}
+
+ protected function _expunge($options) {}
+
+ protected function _search($query, $options)
+ {
+ return [];
+ }
+
+ protected function _setComparator($comparator) {}
+
+ protected function _getComparator()
+ {
+ return [];
+ }
+
+ protected function _thread($options) {}
+
+ protected function _fetch(
+ Horde_Imap_Client_Fetch_Results $results,
+ $queries
+ ) {}
+
+ protected function _vanished(
+ $modseq,
+ Horde_Imap_Client_Ids $ids
+ ) {}
+
+ protected function _store($options)
+ {
+ return [];
+ }
+
+ protected function _copy(
+ Horde_Imap_Client_Mailbox $dest,
+ $options
+ ) {
+ return new Horde_Imap_Client_Ids();
+ }
+
+ protected function _setQuota(
+ Horde_Imap_Client_Mailbox $root,
+ $resources
+ ) {}
+
+ protected function _getQuota(Horde_Imap_Client_Mailbox $root)
+ {
+ return [];
+ }
+
+ protected function _getQuotaRoot(Horde_Imap_Client_Mailbox $mailbox)
+ {
+ return [];
+ }
+
+ protected function _getACL(Horde_Imap_Client_Mailbox $mailbox)
+ {
+ return [];
+ }
+
+ protected function _setACL(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $identifier,
+ $options
+ ) {}
+
+ protected function _deleteACL(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $identifier
+ ) {}
+
+ protected function _listACLRights(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $identifier
+ ) {}
+
+ protected function _getMyACLRights(Horde_Imap_Client_Mailbox $mailbox) {}
+
+ protected function _getMetadata(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $entries,
+ $options
+ ) {
+ return [];
+ }
+
+ protected function _setMetadata(
+ Horde_Imap_Client_Mailbox $mailbox,
+ $data
+ ) {}
+}
diff --git a/test/Stub/StubMessageIdSet.php b/test/Stub/StubMessageIdSet.php
new file mode 100644
index 00000000..a8844f16
--- /dev/null
+++ b/test/Stub/StubMessageIdSet.php
@@ -0,0 +1,45 @@
+ $ids */
+ public function __construct(
+ private readonly array $ids = [],
+ ) {}
+
+ public function isEmpty(): bool
+ {
+ return $this->ids === [];
+ }
+
+ public function toArray(): array
+ {
+ return $this->ids;
+ }
+
+ public function __toString(): string
+ {
+ return implode(',', array_map('strval', $this->ids));
+ }
+
+ public function count(): int
+ {
+ return count($this->ids);
+ }
+
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->ids);
+ }
+}
diff --git a/test/Unit/Base/AlertsTest.php b/test/Unit/Base/AlertsTest.php
new file mode 100644
index 00000000..bfb5eea6
--- /dev/null
+++ b/test/Unit/Base/AlertsTest.php
@@ -0,0 +1,115 @@
+alerts = new Horde_Imap_Client_Base_Alerts();
+ }
+
+ public function testGetLastReturnsNullInitially(): void
+ {
+ $this->assertNull($this->alerts->getLast());
+ }
+
+ public function testAddSetsAlertData(): void
+ {
+ $this->alerts->add('Test alert');
+
+ $last = $this->alerts->getLast();
+ $this->assertEquals('Test alert', $last->alert);
+ $this->assertFalse(isset($last->type));
+ }
+
+ public function testAddWithType(): void
+ {
+ $this->alerts->add('Alert msg', 'WARNING');
+
+ $last = $this->alerts->getLast();
+ $this->assertEquals('Alert msg', $last->alert);
+ $this->assertEquals('WARNING', $last->type);
+ }
+
+ public function testAddOverwritesPrevious(): void
+ {
+ $this->alerts->add('First');
+ $this->alerts->add('Second');
+
+ $this->assertEquals('Second', $this->alerts->getLast()->alert);
+ }
+
+ public function testAttachAndNotify(): void
+ {
+ $observer = $this->createMock(SplObserver::class);
+ $observer->expects($this->once())
+ ->method('update')
+ ->with($this->alerts);
+
+ $this->alerts->attach($observer);
+ $this->alerts->add('Test');
+ }
+
+ public function testDetach(): void
+ {
+ $observer = $this->createMock(SplObserver::class);
+ $observer->expects($this->never())
+ ->method('update');
+
+ $this->alerts->attach($observer);
+ $this->alerts->detach($observer);
+ $this->alerts->add('Test');
+ }
+
+ public function testAttachDeduplicates(): void
+ {
+ $observer = $this->createMock(SplObserver::class);
+ $observer->expects($this->once())
+ ->method('update');
+
+ $this->alerts->attach($observer);
+ $this->alerts->attach($observer);
+ $this->alerts->add('Test');
+ }
+
+ public function testMultipleObservers(): void
+ {
+ $obs1 = $this->createMock(SplObserver::class);
+ $obs1->expects($this->once())->method('update');
+
+ $obs2 = $this->createMock(SplObserver::class);
+ $obs2->expects($this->once())->method('update');
+
+ $this->alerts->attach($obs1);
+ $this->alerts->attach($obs2);
+ $this->alerts->add('Test');
+ }
+}
diff --git a/test/Unit/Base/BaseConstructorTest.php b/test/Unit/Base/BaseConstructorTest.php
new file mode 100644
index 00000000..a2b668dd
--- /dev/null
+++ b/test/Unit/Base/BaseConstructorTest.php
@@ -0,0 +1,137 @@
+ 'user',
+ 'password' => 'pass',
+ ], $params));
+ }
+
+ public function testRequiresUsername(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new Base([]);
+ }
+
+ public function testDefaultHostspec(): void
+ {
+ $ob = $this->create();
+ $this->assertEquals('localhost', $ob->getParam('hostspec'));
+ }
+
+ public function testDefaultTimeout(): void
+ {
+ $ob = $this->create();
+ $this->assertEquals(30, $ob->getParam('timeout'));
+ }
+
+ public function testDefaultReadTimeout(): void
+ {
+ $ob = $this->create();
+ $this->assertEquals(120, $ob->getParam('read_timeout'));
+ }
+
+ public function testDefaultSecure(): void
+ {
+ $ob = $this->create();
+ $this->assertFalse($ob->getParam('secure'));
+ }
+
+ public function testPortDefaultsTo143WhenNoSsl(): void
+ {
+ $ob = $this->create(['secure' => false]);
+ $this->assertEquals(143, $ob->getParam('port'));
+ }
+
+ public function testPortDefaultsTo993WithSsl(): void
+ {
+ $ob = $this->create(['secure' => 'ssl']);
+ $this->assertEquals(993, $ob->getParam('port'));
+ }
+
+ public function testPortDefaultsTo993WithSslv2(): void
+ {
+ $ob = $this->create(['secure' => 'sslv2']);
+ $this->assertEquals(993, $ob->getParam('port'));
+ }
+
+ public function testPortDefaultsTo993WithSslv3(): void
+ {
+ $ob = $this->create(['secure' => 'sslv3']);
+ $this->assertEquals(993, $ob->getParam('port'));
+ }
+
+ public function testPortDefaultsTo143WithTls(): void
+ {
+ $ob = $this->create(['secure' => 'tls']);
+ $this->assertEquals(143, $ob->getParam('port'));
+ }
+
+ public function testPortDefaultsTo143WithTrue(): void
+ {
+ $ob = $this->create(['secure' => true]);
+ $this->assertEquals(143, $ob->getParam('port'));
+ }
+
+ public function testExplicitPortOverridesDefault(): void
+ {
+ $ob = $this->create(['port' => 999]);
+ $this->assertEquals(999, $ob->getParam('port'));
+ }
+
+ public function testCacheFieldsEmptyWhenNoCacheSet(): void
+ {
+ $ob = $this->create();
+ $cache = $ob->getParam('cache');
+ $this->assertEmpty($cache['fields']);
+ }
+
+ public function testCacheFieldsDefaultWhenBackendProvided(): void
+ {
+ $backend = $this->createMock(Horde_Imap_Client_Cache_Backend::class);
+ $ob = $this->create([
+ 'cache' => ['backend' => $backend],
+ ]);
+
+ $cache = $ob->getParam('cache');
+ $this->assertNotEmpty($cache['fields']);
+ $this->assertArrayHasKey(Horde_Imap_Client::FETCH_ENVELOPE, $cache['fields']);
+ }
+
+ public function testChangedFlagSetAfterConstruction(): void
+ {
+ $ob = $this->create();
+ $this->assertTrue($ob->changed);
+ }
+}
diff --git a/test/Unit/Base/BaseInitCacheTest.php b/test/Unit/Base/BaseInitCacheTest.php
new file mode 100644
index 00000000..b1d811bc
--- /dev/null
+++ b/test/Unit/Base/BaseInitCacheTest.php
@@ -0,0 +1,82 @@
+ 'user',
+ 'password' => 'pass',
+ ]);
+
+ $this->assertFalse($ob->initCache());
+ }
+
+ public function testReturnsFalseWhenNoBackend(): void
+ {
+ $ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ 'cache' => ['fields' => [Horde_Imap_Client::FETCH_ENVELOPE]],
+ ]);
+
+ $this->assertFalse($ob->initCache());
+ }
+
+ public function testReturnsTrueWhenBackendProvided(): void
+ {
+ $backend = $this->createMock(Horde_Imap_Client_Cache_Backend::class);
+ $ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ 'cache' => ['backend' => $backend],
+ ]);
+
+ $this->assertTrue($ob->initCache());
+ }
+
+ public function testCreatesCacheObject(): void
+ {
+ $backend = $this->createMock(Horde_Imap_Client_Cache_Backend::class);
+ $ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ 'cache' => ['backend' => $backend],
+ ]);
+
+ $ob->initCache();
+
+ $this->assertInstanceOf(
+ Horde_Imap_Client_Cache::class,
+ $ob->getCache()
+ );
+ }
+}
diff --git a/test/Unit/Base/BaseObserverTest.php b/test/Unit/Base/BaseObserverTest.php
new file mode 100644
index 00000000..8a68ceb4
--- /dev/null
+++ b/test/Unit/Base/BaseObserverTest.php
@@ -0,0 +1,77 @@
+ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ ]);
+ }
+
+ public function testUpdateSetsChangedOnCapabilitySubject(): void
+ {
+ $cap = new Horde_Imap_Client_Data_Capability();
+ $this->ob->changed = false;
+
+ $this->ob->update($cap);
+
+ $this->assertTrue($this->ob->changed);
+ }
+
+ public function testUpdateSetsChangedOnSearchCharsetSubject(): void
+ {
+ $sc = new Horde_Imap_Client_Data_SearchCharset();
+ $this->ob->changed = false;
+
+ $this->ob->update($sc);
+
+ $this->assertTrue($this->ob->changed);
+ }
+
+ public function testUpdateCollectsAlertFromAlertsSubject(): void
+ {
+ $this->ob->alerts_ob->add('Test Alert');
+
+ $alerts = $this->ob->alerts();
+ $this->assertContains('Test Alert', $alerts);
+ }
+
+ public function testAlertsAreClearedAfterRetrieval(): void
+ {
+ $this->ob->alerts_ob->add('Alert');
+ $this->ob->alerts();
+
+ $this->assertEmpty($this->ob->alerts());
+ }
+}
diff --git a/test/Unit/Base/BaseParamsTest.php b/test/Unit/Base/BaseParamsTest.php
new file mode 100644
index 00000000..37ebe25c
--- /dev/null
+++ b/test/Unit/Base/BaseParamsTest.php
@@ -0,0 +1,64 @@
+ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ ]);
+ }
+
+ public function testGetParamReturnsNullForMissing(): void
+ {
+ $this->assertNull($this->ob->getParam('nonexistent'));
+ }
+
+ public function testSetAndGetParam(): void
+ {
+ $this->ob->setParam('foo', 'bar');
+ $this->assertEquals('bar', $this->ob->getParam('foo'));
+ }
+
+ public function testPasswordObjectSupport(): void
+ {
+ $pwObj = $this->createMock(Horde_Imap_Client_Base_Password::class);
+ $pwObj->method('getPassword')->willReturn('secret123');
+
+ $ob = new Base([
+ 'username' => 'user',
+ 'password' => $pwObj,
+ ]);
+
+ $this->assertEquals('secret123', $ob->getParam('password'));
+ }
+}
diff --git a/test/Unit/Base/BasePropertyTest.php b/test/Unit/Base/BasePropertyTest.php
new file mode 100644
index 00000000..63959ac1
--- /dev/null
+++ b/test/Unit/Base/BasePropertyTest.php
@@ -0,0 +1,87 @@
+ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ ]);
+ }
+
+ public function testAlertsObProperty(): void
+ {
+ $this->assertInstanceOf(
+ Horde_Imap_Client_Base_Alerts::class,
+ $this->ob->alerts_ob
+ );
+ }
+
+ public function testSearchCharsetCreatedOnDemand(): void
+ {
+ $this->assertInstanceOf(
+ Horde_Imap_Client_Data_SearchCharset::class,
+ $this->ob->search_charset
+ );
+ }
+
+ public function testSearchCharsetReturnsSameInstance(): void
+ {
+ $first = $this->ob->search_charset;
+ $second = $this->ob->search_charset;
+ $this->assertSame($first, $second);
+ }
+
+ public function testUrlProperty(): void
+ {
+ $url = $this->ob->url;
+ $this->assertEquals('localhost', $url->hostspec);
+ $this->assertEquals(143, $url->port);
+ }
+
+ public function testUrlReflectsCustomParams(): void
+ {
+ $ob = new Base([
+ 'username' => 'user',
+ 'password' => 'pass',
+ 'hostspec' => 'mail.example.com',
+ 'port' => 993,
+ 'secure' => 'ssl',
+ ]);
+
+ $url = $ob->url;
+ $this->assertEquals('mail.example.com', $url->hostspec);
+ $this->assertEquals(993, $url->port);
+ }
+}
diff --git a/test/Unit/Base/BaseSerializationTest.php b/test/Unit/Base/BaseSerializationTest.php
new file mode 100644
index 00000000..11b07696
--- /dev/null
+++ b/test/Unit/Base/BaseSerializationTest.php
@@ -0,0 +1,73 @@
+ 'user',
+ 'password' => 'pass',
+ ], $params));
+ }
+
+ public function testSerializeContainsVersionAndParams(): void
+ {
+ $ob = $this->create();
+ $data = $ob->__serialize();
+
+ $this->assertArrayHasKey('v', $data);
+ $this->assertArrayHasKey('p', $data);
+ $this->assertArrayHasKey('i', $data);
+ $this->assertEquals(Horde_Imap_Client_Base::VERSION, $data['v']);
+ }
+
+ public function testUnserializeThrowsOnVersionMismatch(): void
+ {
+ $ob = $this->create();
+
+ $this->expectException(Exception::class);
+ $ob->__unserialize(['v' => 0, 'i' => [], 'p' => ['username' => 'u']]);
+ }
+
+ public function testRoundTripSerialization(): void
+ {
+ $ob = $this->create([
+ 'hostspec' => 'mail.example.com',
+ 'port' => 993,
+ ]);
+
+ $serialized = serialize($ob);
+ $restored = unserialize($serialized);
+
+ $this->assertEquals('mail.example.com', $restored->getParam('hostspec'));
+ $this->assertEquals(993, $restored->getParam('port'));
+ $this->assertEquals('user', $restored->getParam('username'));
+ }
+}
diff --git a/test/Unit/Base/BaseSetInitTest.php b/test/Unit/Base/BaseSetInitTest.php
new file mode 100644
index 00000000..9c8a18c9
--- /dev/null
+++ b/test/Unit/Base/BaseSetInitTest.php
@@ -0,0 +1,127 @@
+ 'user',
+ 'password' => 'pass',
+ ], $params));
+ }
+
+ public function testSetsChangedFlag(): void
+ {
+ $ob = $this->create();
+ $ob->changed = false;
+
+ $ob->setInit('testkey', 'testval');
+
+ $this->assertTrue($ob->changed);
+ }
+
+ public function testNullKeyResetsAll(): void
+ {
+ $ob = $this->create();
+ $ob->setInit('key1', 'val1');
+ $ob->setInit('key2', 'val2');
+
+ $ob->setInit(null);
+
+ $data = $ob->__serialize();
+ $this->assertEmpty($data['i']);
+ }
+
+ public function testNullValueRemovesKey(): void
+ {
+ $ob = $this->create();
+ $ob->setInit('key', 'val');
+ $ob->setInit('key', null);
+
+ $data = $ob->__serialize();
+ $this->assertArrayNotHasKey('key', $data['i']);
+ }
+
+ public function testCapabilityFilterRemovesIgnored(): void
+ {
+ $ob = $this->create([
+ 'capability_ignore' => ['IDLE'],
+ ]);
+
+ $cap = new Horde_Imap_Client_Data_Capability();
+ $cap->add('IDLE');
+ $cap->add('SORT');
+
+ $ob->setInit('capability', $cap);
+
+ $this->assertFalse($ob->capability->query('IDLE'));
+ $this->assertTrue($ob->capability->query('SORT'));
+ }
+
+ public function testCapabilityFilterWithParam(): void
+ {
+ $ob = $this->create([
+ 'capability_ignore' => ['AUTH=PLAIN'],
+ ]);
+
+ $cap = new Horde_Imap_Client_Data_Capability();
+ $cap->add('AUTH', 'PLAIN');
+ $cap->add('AUTH', 'LOGIN');
+
+ $ob->setInit('capability', $cap);
+
+ $this->assertFalse($ob->capability->query('AUTH', 'PLAIN'));
+ $this->assertTrue($ob->capability->query('AUTH', 'LOGIN'));
+ }
+
+ public function testCapabilityAttachesObserver(): void
+ {
+ $ob = $this->create();
+ $cap = new Horde_Imap_Client_Data_Capability();
+
+ $ob->setInit('capability', $cap);
+ $ob->changed = false;
+
+ // Modifying the capability object should trigger the observer
+ $cap->add('NEWCAP');
+
+ $this->assertTrue($ob->changed);
+ }
+
+ public function testNoChangeIfValueIdentical(): void
+ {
+ $ob = $this->create();
+ $ob->setInit('key', 'val');
+ $ob->changed = false;
+
+ $ob->setInit('key', 'val');
+
+ $this->assertFalse($ob->changed);
+ }
+}
diff --git a/test/Unit/Base/DebugTest.php b/test/Unit/Base/DebugTest.php
new file mode 100644
index 00000000..2b4218e5
--- /dev/null
+++ b/test/Unit/Base/DebugTest.php
@@ -0,0 +1,128 @@
+createDebug();
+ $debug->client('hello');
+
+ $output = $this->readStream($stream);
+ $this->assertStringContainsString('C: hello', $output);
+ }
+
+ public function testServerOutput(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->server('response');
+
+ $output = $this->readStream($stream);
+ $this->assertStringContainsString('S: response', $output);
+ }
+
+ public function testInfoOutput(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->info('message');
+
+ $output = $this->readStream($stream);
+ $this->assertStringContainsString('>> message', $output);
+ }
+
+ public function testRawOutput(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->raw('rawdata');
+
+ $output = $this->readStream($stream);
+ $this->assertEquals('rawdata', $output);
+ }
+
+ public function testFirstWriteOutputsDateHeader(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->client('first');
+
+ $output = $this->readStream($stream);
+ $this->assertStringContainsString('-----', $output);
+ $this->assertStringContainsString('>> ', $output);
+ $this->assertStringContainsString('C: first', $output);
+ }
+
+ public function testSlowCommandDetection(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->client('first');
+
+ $ref = new ReflectionProperty($debug, '_time');
+ $ref->setAccessible(true);
+ $ref->setValue($debug, microtime(true) - 6);
+
+ $debug->client('second');
+
+ $output = $this->readStream($stream);
+ $this->assertStringContainsString('Slow Command:', $output);
+ }
+
+ public function testDebugDisabledSuppressesOutput(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->debug = false;
+
+ $debug->client('nothing');
+ $debug->server('nothing');
+ $debug->info('nothing');
+
+ $this->assertEquals('', $this->readStream($stream));
+ }
+
+ public function testShutdownClosesStream(): void
+ {
+ [$debug, $stream] = $this->createDebug();
+ $debug->shutdown();
+
+ // After shutdown, writing should produce no output
+ $debug->client('after shutdown');
+ // Stream is closed, so we can't read it — just verify no error
+ $this->assertFalse(is_resource($stream));
+ }
+}
diff --git a/test/Unit/Base/DeprecatedTest.php b/test/Unit/Base/DeprecatedTest.php
new file mode 100644
index 00000000..2c7934e2
--- /dev/null
+++ b/test/Unit/Base/DeprecatedTest.php
@@ -0,0 +1,138 @@
+assertEquals(12345, $result['uidvalidity']);
+ $this->assertEquals(67890, $result['highestmodseq']);
+ $this->assertArrayNotHasKey('messages', $result);
+ $this->assertArrayNotHasKey('uidnext', $result);
+ }
+
+ public function testParseCacheIdWithoutModseq(): void
+ {
+ $result = Horde_Imap_Client_Base_Deprecated::parseCacheId('V12345|U100|M50');
+
+ $this->assertEquals(12345, $result['uidvalidity']);
+ $this->assertEquals(100, $result['uidnext']);
+ $this->assertEquals(50, $result['messages']);
+ $this->assertArrayNotHasKey('highestmodseq', $result);
+ }
+
+ public function testParseCacheIdIgnoresUnknownParts(): void
+ {
+ $result = Horde_Imap_Client_Base_Deprecated::parseCacheId('V1|X999|custom');
+
+ $this->assertEquals(['uidvalidity' => 1], $result);
+ }
+
+ public function testGetCacheIdWithCondstore(): void
+ {
+ $base = $this->createMock(Horde_Imap_Client_Base::class);
+ $base->method('status')
+ ->willReturn([
+ 'uidvalidity' => 1,
+ 'highestmodseq' => 99,
+ 'messages' => 5,
+ 'uidnext' => 10,
+ ]);
+
+ $result = Horde_Imap_Client_Base_Deprecated::getCacheId(
+ $base,
+ 'INBOX',
+ true
+ );
+
+ $this->assertEquals('V1|H99', $result);
+ }
+
+ public function testGetCacheIdWithoutCondstore(): void
+ {
+ $base = $this->createMock(Horde_Imap_Client_Base::class);
+ $base->method('status')
+ ->willReturn([
+ 'uidvalidity' => 1,
+ 'highestmodseq' => 0,
+ 'messages' => 5,
+ 'uidnext' => 10,
+ ]);
+
+ $result = Horde_Imap_Client_Base_Deprecated::getCacheId(
+ $base,
+ 'INBOX',
+ false
+ );
+
+ $this->assertEquals('V1|U10|M5', $result);
+ }
+
+ public function testGetCacheIdWithAdditionalData(): void
+ {
+ $base = $this->createMock(Horde_Imap_Client_Base::class);
+ $base->method('status')
+ ->willReturn([
+ 'uidvalidity' => 1,
+ 'highestmodseq' => 99,
+ 'messages' => 5,
+ 'uidnext' => 10,
+ ]);
+
+ $result = Horde_Imap_Client_Base_Deprecated::getCacheId(
+ $base,
+ 'INBOX',
+ true,
+ ['extra']
+ );
+
+ $this->assertEquals('V1|H99|extra', $result);
+ }
+
+ public function testRoundTrip(): void
+ {
+ $base = $this->createMock(Horde_Imap_Client_Base::class);
+ $base->method('status')
+ ->willReturn([
+ 'uidvalidity' => 42,
+ 'highestmodseq' => 0,
+ 'messages' => 15,
+ 'uidnext' => 200,
+ ]);
+
+ $id = Horde_Imap_Client_Base_Deprecated::getCacheId($base, 'INBOX', false);
+ $parsed = Horde_Imap_Client_Base_Deprecated::parseCacheId($id);
+
+ $this->assertEquals(42, $parsed['uidvalidity']);
+ $this->assertEquals(200, $parsed['uidnext']);
+ $this->assertEquals(15, $parsed['messages']);
+ }
+}
diff --git a/test/Unit/Socket/CatenateTest.php b/test/Unit/Socket/CatenateTest.php
new file mode 100644
index 00000000..e785a6ab
--- /dev/null
+++ b/test/Unit/Socket/CatenateTest.php
@@ -0,0 +1,165 @@
+createMock(Horde_Imap_Client_Data_Fetch::class);
+ $fetchData->method('getFullMsg')->willReturn($stream);
+ $fetchData->method('getHeaders')->willReturn($stream);
+ $fetchData->method('getBodyPart')->willReturn($stream);
+ $fetchData->method('getHeaderText')->willReturn($stream);
+ $fetchData->method('getBodyText')->willReturn($stream);
+ $fetchData->method('getMimeHeader')->willReturn($stream);
+
+ $fetchResult = [$uid => $fetchData];
+
+ $socket = $this->createMock(Horde_Imap_Client_Socket::class);
+ $socket->method('getIdsOb')
+ ->with($uid)
+ ->willReturn(new Horde_Imap_Client_Ids($uid));
+ $socket->method('fetch')
+ ->willReturn($fetchResult);
+
+ $url = new Horde_Imap_Client_Url_Imap();
+ $url->uid = $uid;
+ $url->section = $section;
+ $url->mailbox = $mailbox;
+
+ $catenate = new Horde_Imap_Client_Socket_Catenate($socket);
+
+ return [$catenate, $url, $socket, $fetchData];
+ }
+
+ public function testFetchFullBody(): void
+ {
+ [$catenate, $url, $socket, $fetchData] = $this->createCatenate(42, null);
+
+ $fetchData->expects($this->once())
+ ->method('getFullMsg')
+ ->with(true);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchHeaderFields(): void
+ {
+ [$catenate, $url] = $this->createCatenate(1, 'HEADER.FIELDS (Subject From)');
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchHeaderFieldsNot(): void
+ {
+ [$catenate, $url] = $this->createCatenate(1, 'HEADER.FIELDS.NOT (Subject)');
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchBodyPart(): void
+ {
+ [$catenate, $url, , $fetchData] = $this->createCatenate(1, '1.2');
+
+ $fetchData->expects($this->once())
+ ->method('getBodyPart')
+ ->with('1.2', true);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchHeader(): void
+ {
+ [$catenate, $url, , $fetchData] = $this->createCatenate(1, 'HEADER');
+
+ $fetchData->expects($this->once())
+ ->method('getHeaderText')
+ ->with(0, Horde_Imap_Client_Data_Fetch::HEADER_STREAM);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchNestedHeader(): void
+ {
+ [$catenate, $url, , $fetchData] = $this->createCatenate(1, '2.HEADER');
+
+ $fetchData->expects($this->once())
+ ->method('getHeaderText')
+ ->with('2', Horde_Imap_Client_Data_Fetch::HEADER_STREAM);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchText(): void
+ {
+ [$catenate, $url, , $fetchData] = $this->createCatenate(1, 'TEXT');
+
+ $fetchData->expects($this->once())
+ ->method('getBodyText')
+ ->with(0, true);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testFetchMime(): void
+ {
+ [$catenate, $url, , $fetchData] = $this->createCatenate(1, '2.MIME');
+
+ $fetchData->expects($this->once())
+ ->method('getMimeHeader')
+ ->with('2', Horde_Imap_Client_Data_Fetch::HEADER_STREAM);
+
+ $result = $catenate->fetchFromUrl($url);
+ $this->assertIsResource($result);
+ }
+
+ public function testUnrecognizedSectionReturnsNull(): void
+ {
+ [$catenate, $url] = $this->createCatenate(1, 'UNKNOWN');
+
+ $this->assertNull($catenate->fetchFromUrl($url));
+ }
+}
diff --git a/test/Unit/Src/ConnectionConfigEdgeCaseTest.php b/test/Unit/Src/ConnectionConfigEdgeCaseTest.php
new file mode 100644
index 00000000..080c52d9
--- /dev/null
+++ b/test/Unit/Src/ConnectionConfigEdgeCaseTest.php
@@ -0,0 +1,104 @@
+expectException(Error::class);
+ $cfg->username = 'other';
+ }
+
+ public function testReadonlyPasswordThrowsError(): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass');
+ $this->expectException(Error::class);
+ $cfg->password = 'other';
+ }
+
+ public function testReadonlyPortThrowsError(): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass', 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
+ {
+ $cfg = new ConnectionConfig('user', '');
+ $this->assertSame('', $cfg->password);
+ }
+
+ public function testPortZero(): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass', port: 0);
+ $this->assertSame(0, $cfg->port);
+ }
+
+ public function testNegativeTimeout(): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass', 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);
+ $this->assertSame($context, $cfg->context);
+ }
+
+ public function testCapabilityIgnorePreservesOrder(): void
+ {
+ $ignore = ['CONDSTORE', 'QRESYNC', 'IDLE'];
+ $cfg = new ConnectionConfig('user', 'pass', capabilityIgnore: $ignore);
+ $this->assertSame($ignore, $cfg->capabilityIgnore);
+ }
+
+ public function testIdArrayPreservesKeys(): void
+ {
+ $id = ['name' => 'Horde', 'version' => '6.0'];
+ $cfg = new ConnectionConfig('user', 'pass', id: $id);
+ $this->assertSame($id, $cfg->id);
+ }
+
+ public function testLangMultipleEntries(): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass', lang: ['en', 'de', 'fr']);
+ $this->assertCount(3, $cfg->lang);
+ $this->assertSame(['en', 'de', 'fr'], $cfg->lang);
+ }
+
+ public static function secureModeProvider(): array
+ {
+ return array_map(
+ fn(SecureMode $m) => [$m],
+ SecureMode::cases(),
+ );
+ }
+
+ #[DataProvider('secureModeProvider')]
+ public function testAllSecureModes(SecureMode $mode): void
+ {
+ $cfg = new ConnectionConfig('user', 'pass', secure: $mode);
+ $this->assertSame($mode, $cfg->secure);
+ }
+}
diff --git a/test/Unit/Src/ConnectionConfigTest.php b/test/Unit/Src/ConnectionConfigTest.php
new file mode 100644
index 00000000..cf56a11b
--- /dev/null
+++ b/test/Unit/Src/ConnectionConfigTest.php
@@ -0,0 +1,71 @@
+assertSame('user', $cfg->username);
+ $this->assertSame('pass', $cfg->password);
+ $this->assertSame('localhost', $cfg->hostspec);
+ $this->assertNull($cfg->port);
+ $this->assertSame(SecureMode::None, $cfg->secure);
+ $this->assertSame(30, $cfg->timeout);
+ $this->assertSame(120, $cfg->readTimeout);
+ $this->assertNull($cfg->context);
+ $this->assertSame([], $cfg->capabilityIgnore);
+ $this->assertNull($cfg->id);
+ $this->assertSame([], $cfg->lang);
+ }
+
+ public function testFullConstruction(): void
+ {
+ $cfg = new ConnectionConfig(
+ username: 'admin',
+ password: 'secret',
+ hostspec: 'mail.example.com',
+ port: 993,
+ secure: SecureMode::Ssl,
+ timeout: 60,
+ readTimeout: 300,
+ context: ['ssl' => ['verify_peer' => false]],
+ capabilityIgnore: ['CONDSTORE'],
+ id: ['name' => 'TestClient'],
+ lang: ['en'],
+ );
+
+ $this->assertSame('admin', $cfg->username);
+ $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);
+ }
+
+ public function testPasswordInterface(): void
+ {
+ $pw = new class implements PasswordInterface {
+ public function getPassword(): string
+ {
+ return 'dynamic-pass';
+ }
+ };
+
+ $cfg = new ConnectionConfig('user', $pw);
+
+ $this->assertInstanceOf(PasswordInterface::class, $cfg->password);
+ $this->assertSame('dynamic-pass', $cfg->password->getPassword());
+ }
+}
diff --git a/test/Unit/Src/EnumTest.php b/test/Unit/Src/EnumTest.php
new file mode 100644
index 00000000..d06684a3
--- /dev/null
+++ b/test/Unit/Src/EnumTest.php
@@ -0,0 +1,152 @@
+assertSame('', SecureMode::None->value);
+ $this->assertSame('ssl', SecureMode::Ssl->value);
+ $this->assertSame('tls', SecureMode::Tls->value);
+ $this->assertSame('tlsv1', SecureMode::Tlsv1->value);
+ $this->assertCount(4, SecureMode::cases());
+ }
+
+ public function testOpenModeValues(): void
+ {
+ $this->assertSame(1, OpenMode::Readonly->value);
+ $this->assertSame(2, OpenMode::ReadWrite->value);
+ $this->assertSame(3, OpenMode::Auto->value);
+ }
+
+ public function testMailboxListModeValues(): void
+ {
+ $this->assertSame(1, MailboxListMode::Subscribed->value);
+ $this->assertSame(4, MailboxListMode::All->value);
+ $this->assertCount(5, MailboxListMode::cases());
+ }
+
+ public function testSortCriteriaValues(): void
+ {
+ $this->assertSame(1, SortCriteria::Arrival->value);
+ $this->assertSame(3, SortCriteria::Date->value);
+ $this->assertSame(13, SortCriteria::Relevancy->value);
+ $this->assertSame(15, SortCriteria::DisplayToFallback->value);
+ $this->assertCount(15, SortCriteria::cases());
+ }
+
+ public function testSearchResultTypeValues(): void
+ {
+ $this->assertSame(1, SearchResultType::Count->value);
+ $this->assertSame(2, SearchResultType::Match->value);
+ $this->assertSame(6, SearchResultType::Relevancy->value);
+ $this->assertCount(6, SearchResultType::cases());
+ }
+
+ public function testThreadAlgorithmValues(): void
+ {
+ $this->assertSame(1, ThreadAlgorithm::OrderedSubject->value);
+ $this->assertSame(2, ThreadAlgorithm::References->value);
+ $this->assertSame(3, ThreadAlgorithm::Refs->value);
+ }
+
+ public function testAclRightValues(): void
+ {
+ $this->assertSame('l', AclRight::Lookup->value);
+ $this->assertSame('r', AclRight::Read->value);
+ $this->assertSame('a', AclRight::Administer->value);
+ $this->assertCount(11, AclRight::cases());
+ }
+
+ public function testAclRightOmitsDeprecated(): void
+ {
+ $values = array_map(fn(AclRight $r) => $r->value, AclRight::cases());
+ $this->assertNotContains('c', $values);
+ $this->assertNotContains('d', $values);
+ }
+
+ public function testSystemFlagValues(): void
+ {
+ $this->assertSame('\\answered', SystemFlag::Answered->value);
+ $this->assertSame('\\seen', SystemFlag::Seen->value);
+ $this->assertSame('$mdnsent', SystemFlag::MdnSent->value);
+ $this->assertSame('$junk', SystemFlag::Junk->value);
+ $this->assertCount(10, SystemFlag::cases());
+ }
+
+ public function testSpecialUseValues(): void
+ {
+ $this->assertSame('\\All', SpecialUse::All->value);
+ $this->assertSame('\\Trash', SpecialUse::Trash->value);
+ $this->assertCount(7, SpecialUse::cases());
+ }
+
+ /**
+ * All int-backed enums must be constructable from their value.
+ */
+ public static function intBackedEnumProvider(): array
+ {
+ return [
+ [OpenMode::class, 1, 'Readonly'],
+ [MailboxListMode::class, 4, 'All'],
+ [SortCriteria::class, 3, 'Date'],
+ [SearchResultType::class, 2, 'Match'],
+ [ThreadAlgorithm::class, 2, 'References'],
+ ];
+ }
+
+ #[DataProvider('intBackedEnumProvider')]
+ public function testFromInt(string $enum, int $value, string $expectedName): void
+ {
+ $case = $enum::from($value);
+ $this->assertSame($expectedName, $case->name);
+ }
+
+ /**
+ * All string-backed enums must be constructable from their value.
+ */
+ public static function stringBackedEnumProvider(): array
+ {
+ return [
+ [SecureMode::class, 'ssl', 'Ssl'],
+ [AclRight::class, 'l', 'Lookup'],
+ [SystemFlag::class, '\\answered', 'Answered'],
+ [SpecialUse::class, '\\Drafts', 'Drafts'],
+ ];
+ }
+
+ #[DataProvider('stringBackedEnumProvider')]
+ public function testFromString(string $enum, string $value, string $expectedName): void
+ {
+ $case = $enum::from($value);
+ $this->assertSame($expectedName, $case->name);
+ }
+}
diff --git a/test/Unit/Src/EventHierarchyTest.php b/test/Unit/Src/EventHierarchyTest.php
new file mode 100644
index 00000000..a003f0fe
--- /dev/null
+++ b/test/Unit/Src/EventHierarchyTest.php
@@ -0,0 +1,97 @@
+ 'mail.example.com']);
+ $this->assertSame('connected', $event->getMessage());
+ $this->assertSame(['host' => 'mail.example.com'], $event->getContext());
+ }
+
+ public function testImapEventDefaults(): void
+ {
+ $event = new ConnectionClosed();
+ $this->assertSame('', $event->getMessage());
+ $this->assertSame([], $event->getContext());
+ }
+
+ public static function lifecycleEventProvider(): array
+ {
+ return [
+ [ConnectionEstablished::class],
+ [ConnectionClosed::class],
+ [AuthenticationSucceeded::class],
+ [AuthenticationFailed::class],
+ [CapabilityNegotiated::class],
+ [MailboxSelected::class],
+ [MailboxExpunged::class],
+ [AlertReceived::class],
+ [SlowCommand::class],
+ ];
+ }
+
+ #[DataProvider('lifecycleEventProvider')]
+ public function testLifecycleEventsExtendImapEvent(string $class): void
+ {
+ $event = new $class();
+ $this->assertInstanceOf(ImapEvent::class, $event);
+ $this->assertNotInstanceOf(DiagnosticEvent::class, $event);
+ }
+
+ public static function diagnosticEventProvider(): array
+ {
+ return [
+ [CacheStored::class],
+ [CacheRetrieved::class],
+ [CacheDeleted::class],
+ [CapabilityIgnored::class],
+ ];
+ }
+
+ #[DataProvider('diagnosticEventProvider')]
+ public function testDiagnosticEventsExtendBoth(string $class): void
+ {
+ $event = new $class();
+ $this->assertInstanceOf(DiagnosticEvent::class, $event);
+ $this->assertInstanceOf(ImapEvent::class, $event);
+ }
+}
diff --git a/test/Unit/Src/ExceptionHierarchyTest.php b/test/Unit/Src/ExceptionHierarchyTest.php
new file mode 100644
index 00000000..797ba6c4
--- /dev/null
+++ b/test/Unit/Src/ExceptionHierarchyTest.php
@@ -0,0 +1,124 @@
+assertInstanceOf(RuntimeException::class, $e);
+ }
+
+ public function testConnectionExtendsBase(): void
+ {
+ $e = new ConnectionException('conn');
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testAuthenticationExtendsBase(): void
+ {
+ $e = new AuthenticationException('auth');
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testImapProtocolExtendsBase(): void
+ {
+ $e = new ImapProtocolException('imap');
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testMailboxNotFoundExtendsImapProtocol(): void
+ {
+ $e = new MailboxNotFoundException('mbox');
+ $this->assertInstanceOf(ImapProtocolException::class, $e);
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testCapabilityNotSupportedExtendsImapProtocol(): void
+ {
+ $e = new CapabilityNotSupportedException('cap');
+ $this->assertInstanceOf(ImapProtocolException::class, $e);
+ }
+
+ public function testPop3ProtocolExtendsBase(): void
+ {
+ $e = new Pop3ProtocolException('pop3');
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testServerResponseCarriesData(): void
+ {
+ $e = new ServerResponseException(
+ message: 'NO [TRYCREATE]',
+ code: 0,
+ previous: null,
+ command: 'APPEND',
+ status: 'NO',
+ responseText: '[TRYCREATE] Mailbox does not exist',
+ );
+
+ $this->assertSame('APPEND', $e->command);
+ $this->assertSame('NO', $e->status);
+ $this->assertSame('[TRYCREATE] Mailbox does not exist', $e->responseText);
+ $this->assertSame('NO [TRYCREATE]', $e->getMessage());
+ $this->assertInstanceOf(MailboxProtocolException::class, $e);
+ }
+
+ public function testServerResponseDefaults(): void
+ {
+ $e = new ServerResponseException();
+ $this->assertNull($e->command);
+ $this->assertNull($e->status);
+ $this->assertNull($e->responseText);
+ $this->assertSame('', $e->getMessage());
+ }
+
+ /**
+ * Catching MailboxProtocolException must catch all subtypes.
+ */
+ public function testBroadCatchCoversAll(): void
+ {
+ $exceptions = [
+ new ConnectionException(),
+ new AuthenticationException(),
+ new ImapProtocolException(),
+ new MailboxNotFoundException(),
+ new CapabilityNotSupportedException(),
+ new Pop3ProtocolException(),
+ new ServerResponseException(),
+ ];
+
+ foreach ($exceptions as $e) {
+ $caught = false;
+ try {
+ throw $e;
+ } catch (MailboxProtocolException) {
+ $caught = true;
+ }
+ $this->assertTrue($caught, get_class($e) . ' not caught by MailboxProtocolException');
+ }
+ }
+}
diff --git a/test/Unit/Src/FilteredEventDispatcherEdgeCaseTest.php b/test/Unit/Src/FilteredEventDispatcherEdgeCaseTest.php
new file mode 100644
index 00000000..a6d18bb2
--- /dev/null
+++ b/test/Unit/Src/FilteredEventDispatcherEdgeCaseTest.php
@@ -0,0 +1,165 @@
+ [new CacheStored()],
+ 'CacheRetrieved' => [new CacheRetrieved()],
+ 'CacheDeleted' => [new CacheDeleted()],
+ 'CapabilityIgnored' => [new CapabilityIgnored()],
+ ];
+ }
+
+ #[DataProvider('diagnosticSubclassProvider')]
+ public function testSuppressDiagnosticBaseSuppressesSubclass(object $event): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->never())->method('dispatch');
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($event);
+ $this->assertSame($event, $result);
+ }
+
+ public function testMultipleSuppressClasses(): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->never())->method('dispatch');
+
+ $dispatcher = new FilteredEventDispatcher($inner, [
+ ConnectionEstablished::class,
+ ConnectionClosed::class,
+ ]);
+
+ $dispatcher->dispatch(new ConnectionEstablished());
+ $dispatcher->dispatch(new ConnectionClosed());
+ }
+
+ public function testMultipleSuppressLetOtherEventsThrough(): void
+ {
+ $event = new CacheStored();
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->once())
+ ->method('dispatch')
+ ->with($event)
+ ->willReturn($event);
+
+ $dispatcher = new FilteredEventDispatcher($inner, [
+ ConnectionEstablished::class,
+ ConnectionClosed::class,
+ ]);
+
+ $dispatcher->dispatch($event);
+ }
+
+ public function testInnerDispatcherExceptionPropagates(): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->method('dispatch')->willThrowException(new RuntimeException('bus failure'));
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('bus failure');
+ $dispatcher->dispatch(new ConnectionEstablished());
+ }
+
+ public function testNonImapEventPassesThroughWithDefaultSuppress(): void
+ {
+ $obj = new stdClass();
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->once())
+ ->method('dispatch')
+ ->with($obj)
+ ->willReturn($obj);
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($obj);
+ $this->assertSame($obj, $result);
+ }
+
+ public function testSuppressReturnsSameEventInstance(): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $event = new CacheStored('msg');
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($event);
+
+ $this->assertSame($event, $result);
+ }
+
+ public function testPassthroughReturnsSameEventInstance(): void
+ {
+ $event = new ConnectionEstablished();
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->method('dispatch')->willReturn($event);
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($event);
+
+ $this->assertSame($event, $result);
+ }
+
+ public function testInnerDispatcherCanReturnDifferentObject(): void
+ {
+ $input = new ConnectionEstablished('in');
+ $output = new ConnectionEstablished('out');
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->method('dispatch')->willReturn($output);
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($input);
+
+ $this->assertSame($output, $result);
+ $this->assertNotSame($input, $result);
+ }
+
+ public function testDispatcherImplementsPsrInterface(): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $dispatcher = new FilteredEventDispatcher($inner);
+
+ $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher);
+ }
+
+ public function testEmptySuppressWithDiagnosticEventPassesThrough(): void
+ {
+ $event = new CapabilityIgnored();
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->once())
+ ->method('dispatch')
+ ->with($event)
+ ->willReturn($event);
+
+ $dispatcher = new FilteredEventDispatcher($inner, []);
+ $dispatcher->dispatch($event);
+ }
+}
diff --git a/test/Unit/Src/FilteredEventDispatcherTest.php b/test/Unit/Src/FilteredEventDispatcherTest.php
new file mode 100644
index 00000000..c42cb04e
--- /dev/null
+++ b/test/Unit/Src/FilteredEventDispatcherTest.php
@@ -0,0 +1,74 @@
+createMock(EventDispatcherInterface::class);
+ $inner->expects($this->never())->method('dispatch');
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $event = new CacheStored('stored', ['key' => 'uid:42']);
+
+ $result = $dispatcher->dispatch($event);
+
+ $this->assertSame($event, $result);
+ }
+
+ public function testPassesLifecycleEventThrough(): void
+ {
+ $event = new ConnectionEstablished('connected');
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->once())
+ ->method('dispatch')
+ ->with($event)
+ ->willReturn($event);
+
+ $dispatcher = new FilteredEventDispatcher($inner);
+ $result = $dispatcher->dispatch($event);
+
+ $this->assertSame($event, $result);
+ }
+
+ public function testEmptySuppressPassesEverything(): void
+ {
+ $event = new CacheStored('stored');
+
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->once())
+ ->method('dispatch')
+ ->with($event)
+ ->willReturn($event);
+
+ $dispatcher = new FilteredEventDispatcher($inner, []);
+ $result = $dispatcher->dispatch($event);
+
+ $this->assertSame($event, $result);
+ }
+
+ public function testCustomSuppressList(): void
+ {
+ $inner = $this->createMock(EventDispatcherInterface::class);
+ $inner->expects($this->never())->method('dispatch');
+
+ $dispatcher = new FilteredEventDispatcher($inner, [ConnectionEstablished::class]);
+ $event = new ConnectionEstablished('connected');
+
+ $result = $dispatcher->dispatch($event);
+ $this->assertSame($event, $result);
+ }
+}
diff --git a/test/Unit/Src/ImapEventEdgeCaseTest.php b/test/Unit/Src/ImapEventEdgeCaseTest.php
new file mode 100644
index 00000000..bdd979d6
--- /dev/null
+++ b/test/Unit/Src/ImapEventEdgeCaseTest.php
@@ -0,0 +1,156 @@
+ ['host' => 'mx.example.com', 'port' => 993],
+ 'tls' => ['version' => 'TLSv1.3'],
+ ];
+ $event = new ConnectionEstablished('connected', $ctx);
+
+ $this->assertSame('mx.example.com', $event->getContext()['server']['host']);
+ $this->assertSame(993, $event->getContext()['server']['port']);
+ $this->assertSame('TLSv1.3', $event->getContext()['tls']['version']);
+ }
+
+ public function testContextWithNumericKeys(): void
+ {
+ $ctx = [0 => 'first', 1 => 'second'];
+ $event = new ConnectionEstablished('msg', $ctx);
+ $this->assertSame($ctx, $event->getContext());
+ }
+
+ public function testContextWithMixedValueTypes(): void
+ {
+ $ctx = [
+ 'int' => 42,
+ 'float' => 3.14,
+ 'bool' => true,
+ 'null' => null,
+ 'array' => [1, 2, 3],
+ ];
+ $event = new ConnectionEstablished('msg', $ctx);
+ $result = $event->getContext();
+
+ $this->assertSame(42, $result['int']);
+ $this->assertSame(3.14, $result['float']);
+ $this->assertTrue($result['bool']);
+ $this->assertNull($result['null']);
+ $this->assertSame([1, 2, 3], $result['array']);
+ }
+
+ public function testEmptyStringMessageExplicit(): void
+ {
+ $event = new ConnectionEstablished('');
+ $this->assertSame('', $event->getMessage());
+ }
+
+ public function testLongMessage(): void
+ {
+ $msg = str_repeat('a', 10000);
+ $event = new ConnectionEstablished($msg);
+ $this->assertSame(10000, strlen($event->getMessage()));
+ }
+
+ public function testGetMessageReturnsSameValue(): void
+ {
+ $event = new ConnectionEstablished('test');
+ $this->assertSame($event->getMessage(), $event->getMessage());
+ }
+
+ public function testGetContextReturnsSameStructure(): void
+ {
+ $event = new ConnectionEstablished('test', ['k' => 'v']);
+ $this->assertSame($event->getContext(), $event->getContext());
+ }
+
+ public function testDiagnosticEventInheritsMessageAndContext(): void
+ {
+ $event = new CacheStored('cache hit', ['key' => 'uid:1']);
+ $this->assertSame('cache hit', $event->getMessage());
+ $this->assertSame(['key' => 'uid:1'], $event->getContext());
+ }
+
+ public static function lifecycleEventClassProvider(): array
+ {
+ $classes = [
+ ConnectionEstablished::class,
+ ConnectionClosed::class,
+ AuthenticationSucceeded::class,
+ AuthenticationFailed::class,
+ CapabilityNegotiated::class,
+ MailboxSelected::class,
+ MailboxExpunged::class,
+ AlertReceived::class,
+ SlowCommand::class,
+ ];
+ $pairs = [];
+ for ($i = 0; $i < count($classes); $i++) {
+ for ($j = $i + 1; $j < count($classes); $j++) {
+ $a = (new ReflectionClass($classes[$i]))->getShortName();
+ $b = (new ReflectionClass($classes[$j]))->getShortName();
+ $pairs["$a vs $b"] = [$classes[$i], $classes[$j]];
+ }
+ }
+ return $pairs;
+ }
+
+ #[DataProvider('lifecycleEventClassProvider')]
+ public function testEachLifecycleEventIsDistinctClass(string $a, string $b): void
+ {
+ $this->assertNotSame($a, $b);
+ }
+
+ public static function diagnosticEventClassProvider(): array
+ {
+ $classes = [
+ CacheStored::class,
+ CacheRetrieved::class,
+ CacheDeleted::class,
+ CapabilityIgnored::class,
+ ];
+ $pairs = [];
+ for ($i = 0; $i < count($classes); $i++) {
+ for ($j = $i + 1; $j < count($classes); $j++) {
+ $a = (new ReflectionClass($classes[$i]))->getShortName();
+ $b = (new ReflectionClass($classes[$j]))->getShortName();
+ $pairs["$a vs $b"] = [$classes[$i], $classes[$j]];
+ }
+ }
+ return $pairs;
+ }
+
+ #[DataProvider('diagnosticEventClassProvider')]
+ public function testEachDiagnosticEventIsDistinctClass(string $a, string $b): void
+ {
+ $this->assertNotSame($a, $b);
+ }
+}
diff --git a/test/Unit/Src/ServerResponseExceptionTest.php b/test/Unit/Src/ServerResponseExceptionTest.php
new file mode 100644
index 00000000..74fd6eaf
--- /dev/null
+++ b/test/Unit/Src/ServerResponseExceptionTest.php
@@ -0,0 +1,116 @@
+assertSame($prev, $e->getPrevious());
+ $this->assertSame('root cause', $e->getPrevious()->getMessage());
+ }
+
+ public function testErrorCode(): void
+ {
+ $e = new ServerResponseException('fail', 42);
+ $this->assertSame(42, $e->getCode());
+ }
+
+ public function testAllPropertiesSimultaneously(): void
+ {
+ $e = new ServerResponseException(
+ message: 'NO access denied',
+ code: 99,
+ previous: null,
+ command: 'SELECT',
+ status: 'NO',
+ responseText: 'Access denied',
+ );
+
+ $this->assertSame('NO access denied', $e->getMessage());
+ $this->assertSame(99, $e->getCode());
+ $this->assertNull($e->getPrevious());
+ $this->assertSame('SELECT', $e->command);
+ $this->assertSame('NO', $e->status);
+ $this->assertSame('Access denied', $e->responseText);
+ }
+
+ public function testCommandNullByDefault(): void
+ {
+ $e = new ServerResponseException('msg');
+ $this->assertNull($e->command);
+ }
+
+ public function testStatusNullByDefault(): void
+ {
+ $e = new ServerResponseException('msg');
+ $this->assertNull($e->status);
+ }
+
+ public function testResponseTextNullByDefault(): void
+ {
+ $e = new ServerResponseException('msg');
+ $this->assertNull($e->responseText);
+ }
+
+ public function testReadonlyCommandThrowsError(): void
+ {
+ $e = new ServerResponseException('msg', 0, null, 'SELECT');
+ $this->expectException(Error::class);
+ $e->command = 'FETCH';
+ }
+
+ public function testReadonlyStatusThrowsError(): void
+ {
+ $e = new ServerResponseException('msg', 0, null, null, 'NO');
+ $this->expectException(Error::class);
+ $e->status = 'OK';
+ }
+
+ public function testReadonlyResponseTextThrowsError(): void
+ {
+ $e = new ServerResponseException('msg', 0, null, null, null, 'text');
+ $this->expectException(Error::class);
+ $e->responseText = 'other';
+ }
+
+ public function testEmptyStringProperties(): void
+ {
+ $e = new ServerResponseException('', 0, null, '', '', '');
+ $this->assertSame('', $e->command);
+ $this->assertSame('', $e->status);
+ $this->assertSame('', $e->responseText);
+ $this->assertSame('', $e->getMessage());
+ }
+
+ public function testIsThrowable(): void
+ {
+ $this->assertInstanceOf(Throwable::class, new ServerResponseException());
+ }
+
+ public function testCatchByMailboxProtocolException(): void
+ {
+ $caught = false;
+ try {
+ throw new ServerResponseException('test');
+ } catch (MailboxProtocolException $e) {
+ $caught = true;
+ $this->assertSame('test', $e->getMessage());
+ }
+ $this->assertTrue($caught);
+ }
+}