From a085b4262b7870d0119cd5b24a25c2cff7b416d5 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 12:30:34 +0200 Subject: [PATCH 01/16] Extend unit tests and fix decoding bug --- src/PhpImap/Mailbox.php | 5 +- tests/unit/Fixtures/Mailbox.php | 5 + ...iveMailboxStringDecodingConvertingTest.php | 16 +++ tests/unit/MailboxEncodingTest.php | 104 ++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 tests/unit/MailboxEncodingTest.php diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 9ceaf59b..f51feb50 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1966,9 +1966,8 @@ protected function decodeRFC2231(string $string): string { if (\preg_match("/^(.*?)'.*?'(.*?)$/", $string, $matches)) { $data = $matches[2] ?? ''; - if ($this->isUrlEncoded($data)) { - $string = $this->decodeMimeStr(\urldecode($data)); - } + $decodedData = $this->isUrlEncoded($data) ? \urldecode($data) : $data; + $string = $this->decodeMimeStr($decodedData); } return $string; diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index cf55aa45..68c276b9 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -8,6 +8,11 @@ class Mailbox extends Base { + public function decodeRFC2231ForTests(string $string): string + { + return $this->decodeRFC2231($string); + } + public function getImapPassword(): string { return $this->imapPassword; diff --git a/tests/unit/LiveMailboxStringDecodingConvertingTest.php b/tests/unit/LiveMailboxStringDecodingConvertingTest.php index 5025a4ab..87f641d8 100644 --- a/tests/unit/LiveMailboxStringDecodingConvertingTest.php +++ b/tests/unit/LiveMailboxStringDecodingConvertingTest.php @@ -61,6 +61,22 @@ public function stringDecodeProvider(): Generator 'a05e42c7e14de716cd501e135f3f5e49545f71069de316a1e9f7bb153f9a7356', ]; + yield 'Windows-1251 quoted-printable' => [ + ENCQUOTEDPRINTABLE, + 'windows-1251', + '=CF=F0=E8=E2=E5=F2 =EC=E8=F0', + 'Привет мир', + '830d1964dc8673182a40f9adebf598960d37fbe200405b249774ecfa5b465748', + ]; + + yield 'CP1252 quoted-printable via iconv alias fallback' => [ + ENCQUOTEDPRINTABLE, + 'cp1252', + 'Price =8010', + 'Price €10', + 'eeccbb8eb0acf81c5750271d1a9fd7e0bfb4f3309eae8b4ff07e4acf33e947b9', + ]; + yield 'Emoji utf-8' => [ ENCQUOTEDPRINTABLE, 'utf-8', diff --git a/tests/unit/MailboxEncodingTest.php b/tests/unit/MailboxEncodingTest.php new file mode 100644 index 00000000..37225bda --- /dev/null +++ b/tests/unit/MailboxEncodingTest.php @@ -0,0 +1,104 @@ + + */ + public function convertToUtf8Provider(): array + { + return [ + 'default charset uses configured alias' => [ + "Price \x8010", + 'default', + 'Price €10', + 'cp1252', + ], + 'iconv alias fallback' => [ + "Price \x8010", + 'cp1252', + 'Price €10', + ], + 'unknown charset returns original bytes' => [ + "caf\xe9", + 'x-unknown-charset', + "caf\xe9", + ], + ]; + } + + /** + * @dataProvider convertToUtf8Provider + */ + public function testConvertToUtf8(string $input, string $charset, string $expected, string $defaultCharset = 'default'): void + { + $mailbox = new Fixtures\Mailbox('', '', ''); + $mailbox->decodeMimeStrDefaultCharset = $defaultCharset; + + $this->assertSame($expected, $mailbox->convertToUtf8($input, $charset)); + } + + /** + * @return array + */ + public function isUrlEncodedProvider(): array + { + return [ + 'RFC2231 encoded segment' => ['%E2%82%AC%20rates.txt', true], + 'encoded mime-word' => ['%3D%3FUTF-8%3FQ%3Fmountainguan%3DE6%3DB5%3D8B%3DE8%3DAF%3D95%3F%3D', true], + 'plain ascii' => ['plain-file.txt', false], + 'invalid percent escape' => ['%ZZrates.txt', false], + ]; + } + + /** + * @dataProvider isUrlEncodedProvider + */ + public function testIsUrlEncoded(string $input, bool $expected): void + { + $this->assertSame($expected, (new Fixtures\Mailbox('', '', ''))->isUrlEncoded($input)); + } + + /** + * @return array + */ + public function decodeRFC2231Provider(): array + { + return [ + 'plain ascii filename strips RFC2231 metadata' => [ + "utf-8''plain-file.txt", + 'plain-file.txt', + ], + 'utf-8 filename with language tag' => [ + "utf-8'de'%E2%82%AC%20rates.txt", + '€ rates.txt', + ], + 'url-encoded mime encoded-word' => [ + "utf-8'en'%3D%3FUTF-8%3FQ%3Fmountainguan%3DE6%3DB5%3D8B%3DE8%3DAF%3D95%3F%3D", + 'mountainguan测试', + ], + 'non RFC2231 string is unchanged' => [ + 'plain-file.txt', + 'plain-file.txt', + ], + ]; + } + + /** + * @dataProvider decodeRFC2231Provider + */ + public function testDecodeRFC2231(string $input, string $expected): void + { + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame($expected, $mailbox->decodeRFC2231ForTests($input)); + } +} From d41b4481c3f1024aec62f46a9ee4acf4c652d83a Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 12:45:30 +0200 Subject: [PATCH 02/16] Add note regarding unbundled ext-imap for PHP 8.4.x and newer --- .github/workflows/php_code_coverage.yml | 1 + .github/workflows/php_static_analysis.yml | 1 + .github/workflows/php_unit_tests.yml | 1 + README.md | 19 +++++++++++-------- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/php_code_coverage.yml b/.github/workflows/php_code_coverage.yml index 5eca8851..4e8724d1 100644 --- a/.github/workflows/php_code_coverage.yml +++ b/.github/workflows/php_code_coverage.yml @@ -20,6 +20,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} + extensions: imap coverage: xdebug - uses: actions/checkout@v4 diff --git a/.github/workflows/php_static_analysis.yml b/.github/workflows/php_static_analysis.yml index 3d5004f3..14ef27e7 100644 --- a/.github/workflows/php_static_analysis.yml +++ b/.github/workflows/php_static_analysis.yml @@ -20,6 +20,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} + extensions: imap coverage: none - uses: actions/checkout@v4 diff --git a/.github/workflows/php_unit_tests.yml b/.github/workflows/php_unit_tests.yml index 52207b06..dc264538 100644 --- a/.github/workflows/php_unit_tests.yml +++ b/.github/workflows/php_unit_tests.yml @@ -20,6 +20,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} + extensions: imap coverage: none - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 5be0da1c..72f2e372 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ [![Test Coverage](https://api.codeclimate.com/v1/badges/02f72a4fd695cb7e2976/test_coverage)](https://codeclimate.com/github/barbushin/php-imap/test_coverage) [![Type Coverage](https://shepherd.dev/github/barbushin/php-imap/coverage.svg)](https://shepherd.dev/github/barbushin/php-imap) -Initially released in December 2012, the PHP IMAP Mailbox is a powerful and open source library to connect to a mailbox by POP3, IMAP and NNTP using the PHP IMAP extension. This library allows you to fetch emails from your email server. Extend the functionality or create powerful web applications to handle your incoming emails. +Initially released in December 2012, the PHP IMAP Mailbox is a powerful and open source library to connect to a mailbox by POP3, IMAP and NNTP using the PHP IMAP extension (`ext-imap`). This library allows you to fetch emails from your email server. Extend the functionality or create powerful web applications to handle your incoming emails. ### Features -* Connect to mailbox by POP3/IMAP/NNTP, using [PHP IMAP extension](http://php.net/manual/book.imap.php) +* Connect to mailbox by POP3/IMAP/NNTP, using [PHP IMAP extension](https://www.php.net/manual/book.imap.php) * Get emails with attachments and inline images * Get emails filtered or sorted by custom criteria * Mark emails as seen/unseen @@ -43,14 +43,17 @@ Initially released in December 2012, the PHP IMAP Mailbox is a powerful and open The next major release raises the minimum supported PHP version to PHP 8.2 and is tested on PHP 8.2 through PHP 8.5. -* PHP `fileinfo` extension must be present; so make sure this line is active in your php.ini: `extension=php_fileinfo.dll` -* PHP `iconv` extension must be present; so make sure this line is active in your php.ini: `extension=php_iconv.dll` -* PHP `imap` extension must be present; so make sure this line is active in your php.ini: `extension=php_imap.dll` -* PHP `mbstring` extension must be present; so make sure this line is active in your php.ini: `extension=php_mbstring.dll` -* PHP `json` extension must be present; so make sure this line is active in your php.ini: `extension=json.dll` +* PHP `fileinfo`, `iconv`, `mbstring`, and `json` extensions must be present. +* PHP `ext-imap` must be present. +* On PHP `8.2` and `8.3`, install or enable the IMAP extension provided by your PHP distribution. On Windows, this can still mean enabling `extension=php_imap.dll` in `php.ini`. +* On PHP `8.4` and newer, install IMAP from PECL and enable it for your CLI and web SAPIs. +* When building IMAP from source, you may also need `c-client`, OpenSSL, and Kerberos development libraries. +* `ext-imap` is not thread-safe and should not be used with ZTS builds. ### Installation by Composer +Before running `composer require` or `composer install`, make sure `ext-imap` is installed for the PHP version you are using. + Install the [latest available release](https://github.com/barbushin/php-imap/releases): $ composer require php-imap/php-imap @@ -61,7 +64,7 @@ Install the latest available and stable source code from `master`, which is may ### Run Tests -Before you can run the any tests you may need to run `composer install` to install all (development) dependencies. +Before you can run any tests you need a working `ext-imap` installation and you may need to run `composer install` to install all development dependencies. #### Run all tests From 4800cb6c31d13ac33eb64a8bce89cd6fd3f0313b Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 13:04:27 +0200 Subject: [PATCH 03/16] Fix implicit nullability deprecation - Fix implicit nullability deprecation - Extend unit tests Co-Authored by @Ayesh - many thanks for your pull request! Refs: - https://github.com/barbushin/php-imap/pull/722 - https://wiki.php.net/rfc/deprecate-implicitly-nullable-types - https://php.watch/versions/8.4/implicitly-marking-parameter-type-nullable-deprecated --- src/PhpImap/Exceptions/ConnectionException.php | 2 +- src/PhpImap/Imap.php | 10 +++++----- src/PhpImap/Mailbox.php | 10 +++++----- tests/unit/Fixtures/DataPartInfo.php | 2 +- tests/unit/MailboxTest.php | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/PhpImap/Exceptions/ConnectionException.php b/src/PhpImap/Exceptions/ConnectionException.php index fb644978..b56b3872 100644 --- a/src/PhpImap/Exceptions/ConnectionException.php +++ b/src/PhpImap/Exceptions/ConnectionException.php @@ -13,7 +13,7 @@ */ class ConnectionException extends Exception { - public function __construct(array $message, int $code = 0, Exception $previous = null) + public function __construct(array $message, int $code = 0, ?Exception $previous = null) { parent::__construct(json_encode($message), $code, $previous); } diff --git a/src/PhpImap/Imap.php b/src/PhpImap/Imap.php index 7bc0ebbc..71694e28 100644 --- a/src/PhpImap/Imap.php +++ b/src/PhpImap/Imap.php @@ -79,8 +79,8 @@ public static function append( $imap_stream, string $mailbox, string $message, - string $options = null, - string $internal_date = null + ?string $options = null, + ?string $internal_date = null ): bool { \imap_errors(); // flush errors @@ -816,7 +816,7 @@ public static function search( $imap_stream, string $criteria, int $options = SE_FREE, - string $charset = null, + ?string $charset = null, bool $encodeCriteriaAsUtf7Imap = false ): array { \imap_errors(); // flush errors @@ -906,8 +906,8 @@ public static function sort( int $criteria, bool $reverse, int $options, - string $search_criteria = null, - string $charset = null + ?string $search_criteria = null, + ?string $charset = null ): array { \imap_errors(); // flush errors diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index f51feb50..edb065a0 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -175,7 +175,7 @@ class Mailbox /** * @throws InvalidParameterException */ - public function __construct(string $imapPath, string $login, string $password, string $attachmentsDir = null, string $serverEncoding = 'UTF-8', bool $trimImapPath = true, bool $attachmentFilenameMode = false) + public function __construct(string $imapPath, string $login, string $password, ?string $attachmentsDir = null, string $serverEncoding = 'UTF-8', bool $trimImapPath = true, bool $attachmentFilenameMode = false) { $this->imapPath = (true == $trimImapPath) ? \trim($imapPath) : $imapPath; $this->imapLogin = \trim($login); @@ -423,7 +423,7 @@ public function isOAuthEnabled(): bool * * @throws InvalidParameterException */ - public function setConnectionArgs(int $options = 0, int $retriesNum = 0, array $params = null): void + public function setConnectionArgs(int $options = 0, int $retriesNum = 0, ?array $params = null): void { if (0 !== $options) { if (($options & $this->getSupportedImapOptions()) !== $options) { @@ -1053,7 +1053,7 @@ public function sortMails( int $criteria = SORTARRIVAL, bool $reverse = true, ?string $searchCriteria = 'ALL', - string $charset = null + ?string $charset = null ): array { return Imap::sort( $this->getImapStream(), @@ -1667,8 +1667,8 @@ public function unsubscribeMailbox(string $mailbox): void public function appendMessageToMailbox( $message, string $mailbox = '', - string $options = null, - string $internal_date = null + ?string $options = null, + ?string $internal_date = null ): bool { if ( \is_array($message) && diff --git a/tests/unit/Fixtures/DataPartInfo.php b/tests/unit/Fixtures/DataPartInfo.php index 7f012827..dc8e134d 100644 --- a/tests/unit/Fixtures/DataPartInfo.php +++ b/tests/unit/Fixtures/DataPartInfo.php @@ -16,7 +16,7 @@ public function fetch(): string return $this->decodeAfterFetch($this->data); } - public function setData(string $data = null): void + public function setData(?string $data = null): void { $this->data = $data; } diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 507ff596..e441348b 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -678,7 +678,7 @@ public function connectionArgsProvider(): Generator * * @psalm-param array{DISABLE_AUTHENTICATOR?:string}|array $param */ - public function testSetConnectionArgs(string $assertMethod, int $option, int $retriesNum, array $param = null): void + public function testSetConnectionArgs(string $assertMethod, int $option, int $retriesNum, ?array $param = null): void { $mailbox = $this->getMailbox(); From fc8047f111cfb5001d39e9121b07ceca4b1b6f26 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 13:26:45 +0200 Subject: [PATCH 04/16] Fix wrong German Umlauts conversion --- src/PhpImap/Mailbox.php | 6 +- tests/unit/Fixtures/Mailbox.php | 18 ++++ tests/unit/MailboxAddressParsingTest.php | 103 +++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tests/unit/MailboxAddressParsingTest.php diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index edb065a0..bd8f22ae 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -2027,7 +2027,7 @@ protected function possiblyGetEmailAndNameFromRecipient(object $recipient): ?arr } if ('' !== \trim($recipientMailbox) && '' !== \trim($recipientHost)) { - $recipientEmail = \strtolower($recipientMailbox.'@'.$recipientHost); + $recipientEmail = \mb_strtolower($recipientMailbox.'@'.$recipientHost, 'UTF-8'); $recipientName = (\is_string($recipientPersonal) && '' !== \trim($recipientPersonal)) ? $this->decodeMimeStr($recipientPersonal) : null; return [ @@ -2105,7 +2105,7 @@ protected function possiblyGetHostNameAndAddress(array $t): array } /** @var string */ - $out[] = \strtolower($t[0]->mailbox.'@'.(string) $out[0]); + $out[] = \mb_strtolower($t[0]->mailbox.'@'.(string) $out[0], 'UTF-8'); /** @var array{0:string|null, 1:string|null, 2:string} */ return $out; @@ -2142,7 +2142,7 @@ protected function searchMailboxFromWithOrWithoutDisablingServerEncoding(string * @return string */ static function ($sender) use ($criteria): string { - return $criteria.' FROM '.\mb_strtolower($sender); + return $criteria.' FROM '.\mb_strtolower($sender, 'UTF-8'); }, $senders ))); diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index 68c276b9..18ac0d09 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -13,6 +13,24 @@ public function decodeRFC2231ForTests(string $string): string return $this->decodeRFC2231($string); } + /** + * @return (null|string)[]|null + */ + public function possiblyGetEmailAndNameFromRecipientForTests(object $recipient): ?array + { + return $this->possiblyGetEmailAndNameFromRecipient($recipient); + } + + /** + * @param array $t + * + * @return array{0:string|null, 1:string|null, 2:string} + */ + public function possiblyGetHostNameAndAddressForTests(array $t): array + { + return $this->possiblyGetHostNameAndAddress($t); + } + public function getImapPassword(): string { return $this->imapPassword; diff --git a/tests/unit/MailboxAddressParsingTest.php b/tests/unit/MailboxAddressParsingTest.php new file mode 100644 index 00000000..5165842c --- /dev/null +++ b/tests/unit/MailboxAddressParsingTest.php @@ -0,0 +1,103 @@ +mailbox = 'FÜR.MICH'; + $recipient->host = 'EXAMPLE.DE'; + + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame( + ['für.mich@example.de', null], + $mailbox->possiblyGetEmailAndNameFromRecipientForTests($recipient) + ); + } + + public function testPossiblyGetHostNameAndAddressUsesMultibyteSafeLowercasing(): void + { + $sender = new stdClass(); + $sender->mailbox = 'GRÜNE.POST'; + $sender->host = 'EXAMPLE.DE'; + + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame( + ['EXAMPLE.DE', null, 'grüne.post@example.de'], + $mailbox->possiblyGetHostNameAndAddressForTests([$sender]) + ); + } + + public function testSearchMailboxFromLowercasesSendersUsingUtf8RegardlessOfInternalEncoding(): void + { + $mailbox = new class('', '', '') extends Fixtures\Mailbox { + /** @var array{disableServerEncoding: bool, criteria: string[]} */ + public array $capturedSearchMailboxArguments = []; + + public function searchMailboxFromWithOrWithoutDisablingServerEncodingForTests(string $criteria, bool $disableServerEncoding, string $sender, string ...$senders): array + { + return $this->searchMailboxFromWithOrWithoutDisablingServerEncoding($criteria, $disableServerEncoding, $sender, ...$senders); + } + + /** + * @param bool $disableServerEncoding + * @param string $single_criteria + * @param string ...$criteria + * + * @return int[] + */ + protected function searchMailboxMergeResultsWithOrWithoutDisablingServerEncoding($disableServerEncoding, $single_criteria, ...$criteria) + { + \array_unshift($criteria, $single_criteria); + + $this->capturedSearchMailboxArguments = [ + 'disableServerEncoding' => $disableServerEncoding, + 'criteria' => $criteria, + ]; + + return []; + } + }; + + $previousInternalEncoding = \mb_internal_encoding(); + + try { + \mb_internal_encoding('ISO-8859-1'); + + $this->assertSame( + [], + $mailbox->searchMailboxFromWithOrWithoutDisablingServerEncodingForTests( + 'ALL', + true, + 'FÜR@EXAMPLE.DE', + 'für@example.de', + 'NOREPLY@EXAMPLE.DE' + ) + ); + } finally { + \mb_internal_encoding($previousInternalEncoding); + } + + $this->assertSame( + [ + 'disableServerEncoding' => true, + 'criteria' => [ + 'ALL FROM für@example.de', + 'ALL FROM noreply@example.de', + ], + ], + $mailbox->capturedSearchMailboxArguments + ); + } +} From b35711f49f4a50ef5aae74fafcc3a52c9f7183a9 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 13:45:05 +0200 Subject: [PATCH 05/16] Add fallback for SEEN SINCE searches and document IMAP behavior --- README.md | 5 + src/PhpImap/Mailbox.php | 234 ++++++++++++++++++++++++++++++- tests/unit/MailboxSearchTest.php | 152 ++++++++++++++++++++ 3 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 tests/unit/MailboxSearchTest.php diff --git a/README.md b/README.md index 72f2e372..74b3d220 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ if(!$mailsIds) { die('Mailbox is empty'); } +// If you want to inspect a message without changing its seen state, +// use getMail($id, false) or getRawMail($id, false). + // Get the first message // If '__DIR__' was defined in the first line, it will automatically // save all attachments to the specified directory @@ -142,6 +145,8 @@ echo "\n\nAttachments:\n"; print_r($mail->getAttachments()); ``` +`searchMailbox()` delegates criteria evaluation to PHP's IMAP extension and the IMAP server behind it. This library includes a fallback for simple `SEEN ... SINCE ...` searches because some `ext-imap` / server combinations do not immediately return messages marked as seen earlier on the same day. More complex `imap_search()` behavior still depends on the underlying IMAP implementation. + Method `imap()` allows to call any [PHP IMAP function](https://www.php.net/manual/ref.imap.php) in a context of the instance. Example: ```php diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index bd8f22ae..021e4053 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -93,6 +93,46 @@ class Mailbox public const AUTHENTICATION_TYPE_OAUTH = 'oauth'; + /** @var string[] */ + private const SIMPLE_SEARCH_CRITERIA_WITHOUT_ARGUMENTS = [ + 'ALL', + 'ANSWERED', + 'DELETED', + 'DRAFT', + 'FLAGGED', + 'NEW', + 'OLD', + 'RECENT', + 'SEEN', + 'UNANSWERED', + 'UNDELETED', + 'UNDRAFT', + 'UNFLAGGED', + 'UNSEEN', + ]; + + /** @var string[] */ + private const SIMPLE_SEARCH_CRITERIA_WITH_ONE_ARGUMENT = [ + 'BCC', + 'BEFORE', + 'BODY', + 'CC', + 'FROM', + 'KEYWORD', + 'LARGER', + 'ON', + 'SENTBEFORE', + 'SENTON', + 'SENTSINCE', + 'SINCE', + 'SMALLER', + 'SUBJECT', + 'TEXT', + 'TO', + 'UID', + 'UNKEYWORD', + ]; + public const IMAP_OPTIONS_SUPPORTED_VALUES = OP_READONLY // 2 | OP_ANONYMOUS // 4 @@ -688,13 +728,13 @@ public function getListingFolders(string $pattern = '*'): array */ public function searchMailbox(string $criteria = 'ALL', bool $disableServerEncoding = false): array { - if ($disableServerEncoding) { - /** @psalm-var list */ - return Imap::search($this->getImapStream(), $criteria, $this->imapSearchOption); + $searchResult = $this->searchMailboxUsingImapSearch($criteria, $disableServerEncoding); + + if ([] !== $searchResult || !$this->shouldApplySeenSinceSearchFallback($criteria)) { + return $searchResult; } - /** @psalm-var list */ - return Imap::search($this->getImapStream(), $criteria, $this->imapSearchOption, $this->getServerEncoding()); + return $this->searchMailboxUsingSeenSinceFallback($criteria, $disableServerEncoding); } /** @@ -2153,6 +2193,190 @@ static function ($sender) use ($criteria): string { ); } + /** + * @return int[] + * + * @psalm-return list + */ + protected function searchMailboxUsingImapSearch(string $criteria, bool $disableServerEncoding): array + { + if ($disableServerEncoding) { + /** @psalm-var list */ + return Imap::search($this->getImapStream(), $criteria, $this->imapSearchOption); + } + + /** @psalm-var list */ + return Imap::search($this->getImapStream(), $criteria, $this->imapSearchOption, $this->getServerEncoding()); + } + + protected function shouldApplySeenSinceSearchFallback(string $criteria): bool + { + $parsedCriteria = $this->parseSimpleSearchCriteria($criteria); + + if (null === $parsedCriteria) { + return false; + } + + $keywords = []; + + foreach ($parsedCriteria as $parsedCriterion) { + $keywords[] = $parsedCriterion['keyword']; + } + + return \in_array('SEEN', $keywords, true) + && \in_array('SINCE', $keywords, true) + && !\in_array('UNSEEN', $keywords, true); + } + + /** + * Work around ext-imap / server combinations that do not immediately match + * same-day messages for simple `SEEN ... SINCE ...` searches. + * + * @return int[] + * + * @psalm-return list + */ + protected function searchMailboxUsingSeenSinceFallback(string $criteria, bool $disableServerEncoding): array + { + $criteriaWithoutSeen = $this->removeSimpleSearchCriteriaKeyword($criteria, 'SEEN'); + + if (null === $criteriaWithoutSeen) { + return []; + } + + if ('' === $criteriaWithoutSeen) { + $criteriaWithoutSeen = 'ALL'; + } + + $searchResult = $this->searchMailboxUsingImapSearch($criteriaWithoutSeen, $disableServerEncoding); + + return \array_values(\array_filter( + $searchResult, + function (int $mailId): bool { + return $this->flagIsSet($mailId, '\Seen'); + } + )); + } + + /** + * @return (string|string[])[]|null + * + * @psalm-return list}>|null + */ + protected function parseSimpleSearchCriteria(string $criteria): ?array + { + $tokens = $this->tokenizeSearchCriteria($criteria); + + if ([] === $tokens) { + return []; + } + + $parsedCriteria = []; + + for ($index = 0; $index < \count($tokens); ++$index) { + $token = $tokens[$index]; + + if ($this->searchCriteriaTokenIsQuoted($token)) { + return null; + } + + $keyword = \strtoupper($token); + + if (\in_array($keyword, ['NOT', 'OR'], true)) { + return null; + } + + if (\in_array($keyword, self::SIMPLE_SEARCH_CRITERIA_WITHOUT_ARGUMENTS, true)) { + $parsedCriteria[] = [ + 'keyword' => $keyword, + 'tokens' => [$token], + ]; + + continue; + } + + if ('HEADER' === $keyword) { + if (!isset($tokens[$index + 1], $tokens[$index + 2])) { + return null; + } + + $parsedCriteria[] = [ + 'keyword' => $keyword, + 'tokens' => [$token, $tokens[$index + 1], $tokens[$index + 2]], + ]; + + $index += 2; + + continue; + } + + if (\in_array($keyword, self::SIMPLE_SEARCH_CRITERIA_WITH_ONE_ARGUMENT, true)) { + if (!isset($tokens[$index + 1])) { + return null; + } + + $parsedCriteria[] = [ + 'keyword' => $keyword, + 'tokens' => [$token, $tokens[$index + 1]], + ]; + + ++$index; + + continue; + } + + return null; + } + + /** @var list}> */ + return $parsedCriteria; + } + + protected function removeSimpleSearchCriteriaKeyword(string $criteria, string $keywordToRemove): ?string + { + $parsedCriteria = $this->parseSimpleSearchCriteria($criteria); + + if (null === $parsedCriteria) { + return null; + } + + $criteriaTokens = []; + + foreach ($parsedCriteria as $parsedCriterion) { + if ($keywordToRemove === $parsedCriterion['keyword']) { + continue; + } + + foreach ($parsedCriterion['tokens'] as $token) { + $criteriaTokens[] = $token; + } + } + + return \implode(' ', $criteriaTokens); + } + + /** + * @return string[] + * + * @psalm-return list + */ + protected function tokenizeSearchCriteria(string $criteria): array + { + if ('' === \trim($criteria)) { + return []; + } + + \preg_match_all('/"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"|[^\\s]+/', $criteria, $matches); + + /** @var list */ + return $matches[0]; + } + + protected function searchCriteriaTokenIsQuoted(string $token): bool + { + return \strlen($token) >= 2 && '"' === $token[0] && '"' === $token[\strlen($token) - 1]; + } + /** * Search the mailbox using different criteria, then merge the results. * diff --git a/tests/unit/MailboxSearchTest.php b/tests/unit/MailboxSearchTest.php new file mode 100644 index 00000000..08f6b271 --- /dev/null +++ b/tests/unit/MailboxSearchTest.php @@ -0,0 +1,152 @@ +getSearchTrackingMailbox(); + $criteria = 'SEEN SINCE "28 Nov 2023"'; + + $mailbox->searchResultsByCall[$mailbox->getSearchCallKeyForTests($criteria, false)] = [17]; + + $this->assertSame([17], $mailbox->searchMailbox($criteria)); + $this->assertSame( + [ + [ + 'criteria' => $criteria, + 'disableServerEncoding' => false, + ], + ], + $mailbox->searchCalls + ); + $this->assertSame([], $mailbox->flagCalls); + } + + public function testSearchMailboxFallsBackToClientSideSeenFilteringForSimpleSeenSinceCriteria(): void + { + $mailbox = $this->getSearchTrackingMailbox(); + $criteria = 'SEEN SINCE "28 Nov 2023" SUBJECT "SEEN order"'; + $fallbackCriteria = 'SINCE "28 Nov 2023" SUBJECT "SEEN order"'; + + $mailbox->searchResultsByCall[$mailbox->getSearchCallKeyForTests($criteria, true)] = []; + $mailbox->searchResultsByCall[$mailbox->getSearchCallKeyForTests($fallbackCriteria, true)] = [11, 12, 13]; + $mailbox->seenByMailId = [ + 11 => false, + 12 => true, + 13 => true, + ]; + + $this->assertSame([12, 13], $mailbox->searchMailbox($criteria, true)); + $this->assertSame( + [ + [ + 'criteria' => $criteria, + 'disableServerEncoding' => true, + ], + [ + 'criteria' => $fallbackCriteria, + 'disableServerEncoding' => true, + ], + ], + $mailbox->searchCalls + ); + $this->assertSame( + [ + [ + 'mailId' => 11, + 'flag' => '\Seen', + ], + [ + 'mailId' => 12, + 'flag' => '\Seen', + ], + [ + 'mailId' => 13, + 'flag' => '\Seen', + ], + ], + $mailbox->flagCalls + ); + } + + public function testSearchMailboxDoesNotTreatKeywordSeenArgumentAsSeenCriteria(): void + { + $mailbox = $this->getSearchTrackingMailbox(); + $criteria = 'KEYWORD seen SINCE "28 Nov 2023"'; + + $this->assertSame([], $mailbox->searchMailbox($criteria)); + $this->assertSame( + [ + [ + 'criteria' => $criteria, + 'disableServerEncoding' => false, + ], + ], + $mailbox->searchCalls + ); + $this->assertSame([], $mailbox->flagCalls); + } + + /** + * @return Fixtures\Mailbox&object{ + * searchCalls: list, + * searchResultsByCall: array>, + * seenByMailId: array, + * flagCalls: list + * } + */ + private function getSearchTrackingMailbox(): Fixtures\Mailbox + { + return new class('', '', '') extends Fixtures\Mailbox { + /** @var list */ + public array $searchCalls = []; + + /** @var array> */ + public array $searchResultsByCall = []; + + /** @var array */ + public array $seenByMailId = []; + + /** @var list */ + public array $flagCalls = []; + + public function getSearchCallKeyForTests(string $criteria, bool $disableServerEncoding): string + { + return (string) ((int) $disableServerEncoding).'|'.$criteria; + } + + /** + * @return int[] + * + * @psalm-return list + */ + protected function searchMailboxUsingImapSearch(string $criteria, bool $disableServerEncoding): array + { + $this->searchCalls[] = [ + 'criteria' => $criteria, + 'disableServerEncoding' => $disableServerEncoding, + ]; + + return $this->searchResultsByCall[$this->getSearchCallKeyForTests($criteria, $disableServerEncoding)] ?? []; + } + + public function flagIsSet(int $mailId, string $flag): bool + { + $this->flagCalls[] = [ + 'mailId' => $mailId, + 'flag' => $flag, + ]; + + return $this->seenByMailId[$mailId] ?? false; + } + }; + } +} From 753f3570619e26ba00d0c4307ae20ce7adaca46c Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 13:57:11 +0200 Subject: [PATCH 06/16] Issue 691: Expose missing Message-ID reference headers - Expose messageId in IncomingMailHeader - Expose inReplyTo in IncomingMailHeader - Expose references in IncomingMailHeader - Extend unit tests accordingly Solves #691 --- src/PhpImap/IncomingMailHeader.php | 6 +++ src/PhpImap/Mailbox.php | 56 +++++++++++++++----- tests/unit/Fixtures/Mailbox.php | 13 +++++ tests/unit/MailboxHeaderParsingTest.php | 69 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 tests/unit/MailboxHeaderParsingTest.php diff --git a/src/PhpImap/IncomingMailHeader.php b/src/PhpImap/IncomingMailHeader.php index 908c4f3c..7e522b6f 100644 --- a/src/PhpImap/IncomingMailHeader.php +++ b/src/PhpImap/IncomingMailHeader.php @@ -146,4 +146,10 @@ class IncomingMailHeader /** @var string|null */ public $messageId; + + /** @var string|null */ + public $inReplyTo; + + /** @var string|null */ + public $references; } diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 021e4053..980f59e6 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1171,15 +1171,48 @@ public function getRawMail(int $msgId, bool $markAsSeen = true): string */ public function getMailHeaderFieldValue(string $headersRaw, string $header_field_name): string { - $header_field_value = ''; + $pattern = '/^'.\preg_quote($header_field_name, '/').':([^\r\n]*(?:\r?\n[ \t][^\r\n]*)*)/im'; - if (\preg_match("/$header_field_name\:(.*)/i", $headersRaw, $matches)) { - if (isset($matches[1])) { - return \trim($matches[1]); - } + if (\preg_match($pattern, $headersRaw, $matches) && isset($matches[1]) && \is_string($matches[1])) { + /** @var string */ + $headerFieldValue = \preg_replace('/\r?\n[ \t]+/', ' ', $matches[1]) ?? $matches[1]; + + return \trim($headerFieldValue); } - return $header_field_value; + return ''; + } + + /** + * @psalm-return array{messageId:null|string, inReplyTo:null|string, references:null|string} + */ + protected function getThreadingHeaders(object $head, string $headersRaw): array + { + /** @var scalar|array|object|resource|null */ + $parsedMessageId = $head->message_id ?? null; + + if (null !== $parsedMessageId && !\is_string($parsedMessageId)) { + throw new UnexpectedValueException('Message ID was expected to be a string, '.\gettype($parsedMessageId).' found!'); + } + + $messageId = (\is_string($parsedMessageId) && '' !== \trim($parsedMessageId)) ? \trim($parsedMessageId) : $this->getOptionalMailHeaderFieldValue($headersRaw, 'Message-ID'); + + return [ + 'messageId' => $messageId, + 'inReplyTo' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'In-Reply-To'), + 'references' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'References'), + ]; + } + + protected function getOptionalMailHeaderFieldValue(string $headersRaw, string $headerFieldName): ?string + { + $headerFieldValue = $this->getMailHeaderFieldValue($headersRaw, $headerFieldName); + + if ('' === $headerFieldValue) { + return null; + } + + return $headerFieldValue; } /** @@ -1203,6 +1236,7 @@ public function getMailHeader(int $mailId): IncomingMailHeader * date?:scalar, * Date?:scalar, * subject?:scalar, + * message_id?:scalar, * from?:HOSTNAMEANDADDRESS, * to?:HOSTNAMEANDADDRESS, * cc?:HOSTNAMEANDADDRESS, @@ -1330,12 +1364,10 @@ public function getMailHeader(int $mailId): IncomingMailHeader } } - if (isset($head->message_id)) { - if (!\is_string($head->message_id)) { - throw new UnexpectedValueException('Message ID was expected to be a string, '.\gettype($head->message_id).' found!'); - } - $header->messageId = $head->message_id; - } + $threadingHeaders = $this->getThreadingHeaders($head, $headersRaw); + $header->messageId = $threadingHeaders['messageId']; + $header->inReplyTo = $threadingHeaders['inReplyTo']; + $header->references = $threadingHeaders['references']; return $header; } diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index 18ac0d09..ee97980e 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -13,6 +13,11 @@ public function decodeRFC2231ForTests(string $string): string return $this->decodeRFC2231($string); } + public function getMailHeaderFieldValueForTests(string $headersRaw, string $headerFieldName): string + { + return $this->getMailHeaderFieldValue($headersRaw, $headerFieldName); + } + /** * @return (null|string)[]|null */ @@ -21,6 +26,14 @@ public function possiblyGetEmailAndNameFromRecipientForTests(object $recipient): return $this->possiblyGetEmailAndNameFromRecipient($recipient); } + /** + * @psalm-return array{messageId:null|string, inReplyTo:null|string, references:null|string} + */ + public function getThreadingHeadersForTests(object $head, string $headersRaw): array + { + return $this->getThreadingHeaders($head, $headersRaw); + } + /** * @param array $t * diff --git a/tests/unit/MailboxHeaderParsingTest.php b/tests/unit/MailboxHeaderParsingTest.php new file mode 100644 index 00000000..24203ab3 --- /dev/null +++ b/tests/unit/MailboxHeaderParsingTest.php @@ -0,0 +1,69 @@ +\r\n". + "\t\r\n". + 'Subject: Example'."\r\n"; + + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame( + ' ', + $mailbox->getMailHeaderFieldValueForTests($headersRaw, 'References') + ); + } + + public function testGetThreadingHeadersFallsBackToRawHeadersWhenParsedMessageIdIsMissing(): void + { + $headersRaw = + "Message-ID: \r\n". + "In-Reply-To: \r\n". + "References: \r\n". + "\t\r\n"; + + $head = new stdClass(); + + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame( + [ + 'messageId' => '', + 'inReplyTo' => '', + 'references' => ' ', + ], + $mailbox->getThreadingHeadersForTests($head, $headersRaw) + ); + } + + public function testGetThreadingHeadersPrefersParsedMessageIdWhenAvailable(): void + { + $headersRaw = "Message-ID: \r\n"; + + $head = new stdClass(); + $head->message_id = ''; + + $mailbox = new Fixtures\Mailbox('', '', ''); + + $this->assertSame( + [ + 'messageId' => '', + 'inReplyTo' => null, + 'references' => null, + ], + $mailbox->getThreadingHeadersForTests($head, $headersRaw) + ); + } +} From 90396e699c8a215b4266d7238b281a49c0f87f83 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 14:27:24 +0200 Subject: [PATCH 07/16] Fix broken decodeStringFromUtf7ImapToUtf8() function - src/PhpImap/Mailbox.php:584 now delegates to the existing src/PhpImap/Imap.php:1072 implementation, so Mailbox no longer has its own divergent imap_utf7_decode() path. - Add respective unit tests --- src/PhpImap/Mailbox.php | 8 +------- tests/unit/MailboxEncodingTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 980f59e6..11144e32 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -583,13 +583,7 @@ public function encodeStringToUtf7Imap(string $str): string */ public function decodeStringFromUtf7ImapToUtf8(string $str): string { - $out = imap_utf7_decode($str); - - if (!\is_string($out)) { - throw new UnexpectedValueException('mb_convert_encoding($str, \'UTF-8\', \'UTF7-IMAP\') could not convert $str'); - } - - return $out; + return Imap::decodeStringFromUtf7ImapToUtf8($str); } /** diff --git a/tests/unit/MailboxEncodingTest.php b/tests/unit/MailboxEncodingTest.php index 37225bda..5a8bd62f 100644 --- a/tests/unit/MailboxEncodingTest.php +++ b/tests/unit/MailboxEncodingTest.php @@ -101,4 +101,15 @@ public function testDecodeRFC2231(string $input, string $expected): void $this->assertSame($expected, $mailbox->decodeRFC2231ForTests($input)); } + + public function testDecodeStringFromUtf7ImapToUtf8DecodesMailboxNamesToUtf8(): void + { + $mailbox = new Fixtures\Mailbox('', '', ''); + $encodedMailboxName = '{imap.example.com:993/imap/ssl}INBOX.&bUuL1Q-'; + + $this->assertSame( + '{imap.example.com:993/imap/ssl}INBOX.测试', + $mailbox->decodeStringFromUtf7ImapToUtf8($encodedMailboxName) + ); + } } From e3f9c9dbe130a7f0ee21889a7239d26790764309 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 14:39:21 +0200 Subject: [PATCH 08/16] Fix flattenParts() for PARTY_TYPE_TWO This change should fix all kinds of weird attachment related bugs as stated by the original author of this change. - Fix incorrect sub-part index - Add respective unit tests Co-authored by @mathielen Refs: - https://github.com/barbushin/php-imap/pull/723 --- src/PhpImap/Mailbox.php | 2 +- tests/unit/MailboxTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 11144e32..343e876a 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1387,7 +1387,7 @@ public function flattenParts(array $messageParts, array $flattenedParts = [], st $part_parts = $part->parts; if (self::PART_TYPE_TWO == $part->type) { - $flattenedParts = $this->flattenParts($part_parts, $flattenedParts, $prefix.$index.'.', 0, false); + $flattenedParts = $this->flattenParts($part_parts, $flattenedParts, $prefix.$index.'.', 1, false); } elseif ($fullPrefix) { $flattenedParts = $this->flattenParts($part_parts, $flattenedParts, $prefix.$index.'.'); } else { diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index e441348b..75bf7356 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -210,6 +210,33 @@ public function testServerEncodingOnlyUseSupportedSettings(bool $bool, string $e } } + public function testFlattenPartsStartsRfc822SubPartsWithOne(): void + { + $plainTextPart = (object) [ + 'type' => TYPETEXT, + 'subtype' => 'PLAIN', + ]; + $htmlPart = (object) [ + 'type' => TYPETEXT, + 'subtype' => 'HTML', + ]; + $multipartAlternative = (object) [ + 'type' => TYPEMULTIPART, + 'subtype' => 'ALTERNATIVE', + 'parts' => [$plainTextPart, $htmlPart], + ]; + $rfc822Part = (object) [ + 'type' => Mailbox::PART_TYPE_TWO, + 'subtype' => 'RFC822', + 'parts' => [$multipartAlternative], + ]; + + $flattenedParts = $this->getMailbox()->flattenParts([$rfc822Part]); + + $this->assertSame(['1', '1.1', '1.2'], \array_keys($flattenedParts)); + $this->assertArrayNotHasKey('1.0', $flattenedParts); + } + /** * Test, that the IMAP search option has a default value * 1 => SE_UID From e25c160809ec834ae2f20c31408002a4fbc909b2 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 14:51:52 +0200 Subject: [PATCH 09/16] Deduplicate and fix disposition checks - Implement single case-insensitive disposition check and deduplicate all other similar checks. - Add respective unit tests. Co-authored by @resource0212 Refs: - https://github.com/barbushin/php-imap/pull/716 --- src/PhpImap/Mailbox.php | 20 +++++++++++++------- tests/unit/Fixtures/Mailbox.php | 5 +++++ tests/unit/MailboxTest.php | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 343e876a..011b497d 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1432,6 +1432,14 @@ public function getMail(int $mailId, bool $markAsSeen = true): IncomingMail return $mail; } + protected function hasAttachmentDisposition(object $partStructure): bool + { + /** @var scalar|array|object|resource|null */ + $disposition = $partStructure->disposition ?? null; + + return \is_string($disposition) && 'attachment' === \mb_strtolower($disposition); + } + /** * Download attachment. * @@ -1446,7 +1454,7 @@ public function getMail(int $mailId, bool $markAsSeen = true): IncomingMail */ public function downloadAttachment(DataPartInfo $dataInfo, array $params, object $partStructure, bool $emlOrigin = false): IncomingMailAttachment { - if ('RFC822' == $partStructure->subtype && isset($partStructure->disposition) && 'attachment' == $partStructure->disposition) { + if ('RFC822' == $partStructure->subtype && $this->hasAttachmentDisposition($partStructure)) { $fileName = \strtolower($partStructure->subtype).'.eml'; } elseif ('ALTERNATIVE' == $partStructure->subtype) { $fileName = \strtolower($partStructure->subtype).'.eml'; @@ -1943,9 +1951,7 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part $isAttachment = isset($params['filename']) || isset($params['name']) || isset($partStructure->id); - $dispositionAttachment = (isset($partStructure->disposition) && - \is_string($partStructure->disposition) && - 'attachment' === \mb_strtolower($partStructure->disposition)); + $dispositionAttachment = $this->hasAttachmentDisposition($partStructure); // ignore contentId on body when mail isn't multipart (https://github.com/barbushin/php-imap/issues/71) if ( @@ -1961,7 +1967,7 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part } // check if the part is a subpart of another attachment part (RFC822) - if ('RFC822' === $partStructure->subtype && isset($partStructure->disposition) && 'attachment' === $partStructure->disposition) { + if ('RFC822' === $partStructure->subtype && $dispositionAttachment) { // Although we are downloading each part separately, we are going to download the EML to a single file //incase someone wants to process or parse in another process $attachment = self::downloadAttachment($dataInfo, $params, $partStructure, false); @@ -1993,14 +1999,14 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part if (!empty($partStructure->parts)) { foreach ($partStructure->parts as $subPartNum => $subPartStructure) { - $not_attachment = (!isset($partStructure->disposition) || 'attachment' !== $partStructure->disposition); + $not_attachment = !$dispositionAttachment; if (TYPEMESSAGE === $partStructure->type && 'RFC822' === $partStructure->subtype && $not_attachment) { $this->initMailPart($mail, $subPartStructure, $partNum, $markAsSeen); } elseif (TYPEMULTIPART === $partStructure->type && 'ALTERNATIVE' === $partStructure->subtype && $not_attachment) { // https://github.com/barbushin/php-imap/issues/198 $this->initMailPart($mail, $subPartStructure, $partNum, $markAsSeen); - } elseif ('RFC822' === $partStructure->subtype && isset($partStructure->disposition) && 'attachment' === $partStructure->disposition) { + } elseif ('RFC822' === $partStructure->subtype && $dispositionAttachment) { //If it comes from am EML attachment, download each part separately as a file $this->initMailPart($mail, $subPartStructure, $partNum.'.'.($subPartNum + 1), $markAsSeen, true); } else { diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index ee97980e..df5f3414 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -68,4 +68,9 @@ public function getImapOpenOptionsForTests(): int { return $this->getImapOpenOptions(); } + + public function hasAttachmentDispositionForTests(object $partStructure): bool + { + return $this->hasAttachmentDisposition($partStructure); + } } diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 75bf7356..30f39b7f 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -420,6 +420,39 @@ public function testSetAttachmentsIgnore(bool $paramValue): void $this->assertEquals($mailbox->getAttachmentsIgnore(), $paramValue); } + public function testHasAttachmentDispositionRecognizesMixedCaseAttachment(): void + { + $partStructure = (object) [ + 'disposition' => 'Attachment', + ]; + + $this->assertTrue($this->getMailbox()->hasAttachmentDispositionForTests($partStructure)); + } + + public function testDownloadAttachmentTreatsMixedCaseRfc822DispositionAsEmlAttachment(): void + { + $mailbox = new Fixtures\Mailbox($this->imapPath, $this->login, $this->password, null, $this->serverEncoding); + $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); + $dataInfo->setData("From: sender@example.com\r\n\r\nAttachment body"); + + $partStructure = (object) [ + 'type' => Mailbox::PART_TYPE_TWO, + 'subtype' => 'RFC822', + 'disposition' => 'Attachment', + 'bytes' => 42, + 'encoding' => 0, + 'ifid' => 0, + 'ifsubtype' => 1, + 'ifdescription' => 0, + ]; + + $attachment = $mailbox->downloadAttachment($dataInfo, [], $partStructure); + + $this->assertSame('rfc822.eml', $attachment->name); + $this->assertSame('Attachment', $attachment->disposition); + $this->assertFalse($attachment->emlOrigin); + } + /** * Provides test data for testing encoding. * From 0ac8c44975c1c45bee742e7154783f20a0af938b Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 15:04:26 +0200 Subject: [PATCH 10/16] Preserve original attachment filenames Co-authored by @gitstashgithub Refs: - https://github.com/barbushin/php-imap/pull/676 --- src/PhpImap/Mailbox.php | 10 ++++++- tests/unit/MailboxTest.php | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 011b497d..76beb8e8 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1440,6 +1440,14 @@ protected function hasAttachmentDisposition(object $partStructure): bool return \is_string($disposition) && 'attachment' === \mb_strtolower($disposition); } + protected function sanitizeAttachmentFileSystemName(string $fileName): string + { + return \strtr($fileName, [ + '\\' => '_', + '/' => '_', + ]); + } + /** * Download attachment. * @@ -1519,7 +1527,7 @@ public function downloadAttachment(DataPartInfo $dataInfo, array $params, object if (null != $attachmentsDir) { if (true == $this->getAttachmentFilenameMode()) { - $fileSysName = $attachment->name; + $fileSysName = $this->sanitizeAttachmentFileSystemName($attachment->name); } else { $fileSysName = \bin2hex(\random_bytes(16)).'.bin'; } diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 30f39b7f..29b6b002 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -453,6 +453,62 @@ public function testDownloadAttachmentTreatsMixedCaseRfc822DispositionAsEmlAttac $this->assertFalse($attachment->emlOrigin); } + /** + * @return array + */ + public function unsafeAttachmentFilenameProvider(): array + { + return [ + 'forward slash' => ['foo/bar.txt', 'foo_bar.txt'], + 'backslash' => ['foo\\bar.txt', 'foo_bar.txt'], + ]; + } + + /** + * @dataProvider unsafeAttachmentFilenameProvider + */ + public function testDownloadAttachmentSanitizesFilePathWhenUsingOriginalFilenameMode(string $unsafeName, string $expectedFileName): void + { + $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + $mailbox = new class ($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { + public function decodeMimeStr(string $string): string + { + return $string; + } + }; + $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); + $dataInfo->setData('attachment body'); + $partStructure = (object) [ + 'type' => 3, + 'subtype' => 'OCTET-STREAM', + 'bytes' => 15, + 'encoding' => 0, + 'ifid' => 0, + 'ifsubtype' => 1, + 'ifdescription' => 0, + ]; + $attachmentPath = null; + + \mkdir($attachmentsDir); + + try { + $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => $unsafeName], $partStructure); + $attachmentPath = $attachment->filePath; + + $this->assertSame($unsafeName, $attachment->name); + $this->assertSame($attachmentsDir.DIRECTORY_SEPARATOR.$expectedFileName, $attachmentPath); + $this->assertFileExists($attachmentPath); + } finally { + if (\is_string($attachmentPath) && \file_exists($attachmentPath)) { + \unlink($attachmentPath); + } + + if (\is_dir($attachmentsDir)) { + \rmdir($attachmentsDir); + } + } + } + /** * Provides test data for testing encoding. * From 58bfd434e13bb01439491d591b2c67b5fb6ccb2b Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 15:31:37 +0200 Subject: [PATCH 11/16] Refactor php-cs-fixer config and apply changes --- .php-cs-fixer.dist.php | 21 +- ...parse_all_emails_with_matching_subject.php | 118 ++++----- ..._all_emails_without_saving_attachments.php | 134 +++++----- examples/get_and_parse_unseen_emails.php | 118 ++++----- ...een_emails_save_attachments_one_by_one.php | 150 ++++++------ src/PhpImap/Imap.php | 63 ++--- src/PhpImap/IncomingMail.php | 1 + src/PhpImap/IncomingMailAttachment.php | 1 + src/PhpImap/Mailbox.php | 171 +++++++------ tests/unit/AbstractLiveMailboxTest.php | 28 +-- .../unit/Fixtures/IncomingMailAttachment.php | 7 +- tests/unit/Fixtures/Mailbox.php | 2 +- tests/unit/ImapTest.php | 11 +- tests/unit/IncomingMailTest.php | 6 +- tests/unit/Issue509Test.php | 3 +- tests/unit/Issue519Test.php | 7 +- tests/unit/LiveMailboxIssue250Test.php | 17 +- tests/unit/LiveMailboxIssue490Test.php | 22 +- tests/unit/LiveMailboxIssue501Test.php | 22 +- tests/unit/LiveMailboxIssue514Test.php | 34 +-- ...iveMailboxStringDecodingConvertingTest.php | 2 + tests/unit/LiveMailboxTest.php | 228 +++++++++--------- tests/unit/LiveMailboxTestingTrait.php | 9 +- tests/unit/LiveMailboxWithManualSetupTest.php | 3 +- tests/unit/MailboxAddressParsingTest.php | 1 + tests/unit/MailboxEncodingTest.php | 1 + tests/unit/MailboxHeaderParsingTest.php | 1 + tests/unit/MailboxOAuthTest.php | 6 +- tests/unit/MailboxSearchTest.php | 1 + tests/unit/MailboxTest.php | 46 ++-- 30 files changed, 641 insertions(+), 593 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 924761ff..30f327fc 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,16 +1,17 @@ setRiskyAllowed(true) ->setRules([ '@Symfony' => true, '@Symfony:risky' => true, - '@PHP71Migration' => true, // @PHP72Migration does not exist - '@PHP71Migration:risky' => true, // @PHP72Migration:risky does not exist 'array_syntax' => ['syntax' => 'short'], 'declare_strict_types' => true, 'global_namespace_import' => [ @@ -18,18 +19,28 @@ 'import_constants' => true, 'import_functions' => false, ], + 'include' => true, 'native_constant_invocation' => true, 'native_function_invocation' => [ 'strict' => false, 'include' => ['@compiler_optimized'], ], + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, 'no_superfluous_phpdoc_tags' => true, 'ordered_class_elements' => true, 'ordered_imports' => true, 'php_unit_dedicate_assert' => ['target' => 'newest'], 'php_unit_method_casing' => true, 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], + 'phpdoc_inline_tag_normalizer' => true, + 'phpdoc_no_access' => true, + 'phpdoc_no_package' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'phpdoc_return_self_reference' => true, + 'phpdoc_scalar' => true, 'phpdoc_to_comment' => false, + 'phpdoc_var_without_name' => true, 'void_return' => true, ]) ->setFinder(PhpCsFixer\Finder::create() diff --git a/examples/get_and_parse_all_emails_with_matching_subject.php b/examples/get_and_parse_all_emails_with_matching_subject.php index ccd3d3d7..88a70a65 100644 --- a/examples/get_and_parse_all_emails_with_matching_subject.php +++ b/examples/get_and_parse_all_emails_with_matching_subject.php @@ -1,74 +1,74 @@ - */ - declare(strict_types=1); +/** + * Example: Get and parse all emails which match the subject "part of the subject" with saving their attachments. + * + * @author Sebastian Krätzig + */ +declare(strict_types=1); - require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__.'/../vendor/autoload.php'; - use PhpImap\Exceptions\ConnectionException; - use PhpImap\Mailbox; +use PhpImap\Exceptions\ConnectionException; +use PhpImap\Mailbox; - $mailbox = new Mailbox( - '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder - 'some@gmail.com', // Username for the before configured mailbox - '*********', // Password for the before configured username - __DIR__, // Directory, where attachments will be saved (optional) - 'US-ASCII' // Server encoding (optional) - ); +$mailbox = new Mailbox( + '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder + 'some@gmail.com', // Username for the before configured mailbox + '*********', // Password for the before configured username + __DIR__, // Directory, where attachments will be saved (optional) + 'US-ASCII' // Server encoding (optional) +); - try { - $mail_ids = $mailbox->searchMailbox('SUBJECT "part of the subject"'); - } catch (ConnectionException $ex) { - exit('IMAP connection failed: '.$ex->getMessage()); - } catch (Exception $ex) { - exit('An error occured: '.$ex->getMessage()); - } +try { + $mail_ids = $mailbox->searchMailbox('SUBJECT "part of the subject"'); +} catch (ConnectionException $ex) { + exit('IMAP connection failed: '.$ex->getMessage()); +} catch (Exception $ex) { + exit('An error occured: '.$ex->getMessage()); +} - foreach ($mail_ids as $mail_id) { - echo "+------ P A R S I N G ------+\n"; +foreach ($mail_ids as $mail_id) { + echo "+------ P A R S I N G ------+\n"; - $email = $mailbox->getMail( - $mail_id, // ID of the email, you want to get - false // Do NOT mark emails as seen (optional) - ); + $email = $mailbox->getMail( + $mail_id, // ID of the email, you want to get + false // Do NOT mark emails as seen (optional) + ); - echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; - echo 'from-email: '.(string) $email->fromAddress."\n"; - echo 'to: '.(string) $email->toString."\n"; - echo 'subject: '.(string) $email->subject."\n"; - echo 'message_id: '.(string) $email->messageId."\n"; + echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; + echo 'from-email: '.(string) $email->fromAddress."\n"; + echo 'to: '.(string) $email->toString."\n"; + echo 'subject: '.(string) $email->subject."\n"; + echo 'message_id: '.(string) $email->messageId."\n"; - echo 'mail has attachments? '; - if ($email->hasAttachments()) { - echo "Yes\n"; - } else { - echo "No\n"; - } + echo 'mail has attachments? '; + if ($email->hasAttachments()) { + echo "Yes\n"; + } else { + echo "No\n"; + } - if (!empty($email->getAttachments())) { - echo \count($email->getAttachments())." attachements\n"; - } - if ($email->textHtml) { - echo "Message HTML:\n".$email->textHtml; - } else { - echo "Message Plain:\n".$email->textPlain; - } + if (!empty($email->getAttachments())) { + echo \count($email->getAttachments())." attachements\n"; + } + if ($email->textHtml) { + echo "Message HTML:\n".$email->textHtml; + } else { + echo "Message Plain:\n".$email->textPlain; + } - if (!empty($email->autoSubmitted)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Auto-Reply ------+\n"; - } + if (!empty($email->autoSubmitted)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Auto-Reply ------+\n"; + } - if (!empty($email_content->precedence)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; - } + if (!empty($email_content->precedence)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; } +} - $mailbox->disconnect(); +$mailbox->disconnect(); diff --git a/examples/get_and_parse_all_emails_without_saving_attachments.php b/examples/get_and_parse_all_emails_without_saving_attachments.php index 258129e0..d8520a9f 100644 --- a/examples/get_and_parse_all_emails_without_saving_attachments.php +++ b/examples/get_and_parse_all_emails_without_saving_attachments.php @@ -1,84 +1,84 @@ - */ - declare(strict_types=1); +/** + * Example: Get and parse all emails without saving their attachments. + * + * @author Sebastian Krätzig + */ +declare(strict_types=1); - require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__.'/../vendor/autoload.php'; - use PhpImap\Exceptions\ConnectionException; - use PhpImap\Mailbox; +use PhpImap\Exceptions\ConnectionException; +use PhpImap\Mailbox; - $mailbox = new Mailbox( - '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder - 'some@gmail.com', // Username for the before configured mailbox - '*********', // Password for the before configured username - null, // Directory, where attachments will be saved (optional) - 'US-ASCII' // Server encoding (optional) - ); +$mailbox = new Mailbox( + '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder + 'some@gmail.com', // Username for the before configured mailbox + '*********', // Password for the before configured username + null, // Directory, where attachments will be saved (optional) + 'US-ASCII' // Server encoding (optional) +); - // OR - $mailbox = new Mailbox( - '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder - 'some@gmail.com', // Username for the before configured mailbox - '*********' // Password for the before configured username - ); +// OR +$mailbox = new Mailbox( + '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder + 'some@gmail.com', // Username for the before configured mailbox + '*********' // Password for the before configured username +); - // If you haven't defined the server encoding (charset) in 'new Mailbox()', you can change it any time - $mailbox->setServerEncoding('US-ASCII'); +// If you haven't defined the server encoding (charset) in 'new Mailbox()', you can change it any time +$mailbox->setServerEncoding('US-ASCII'); - try { - $mail_ids = $mailbox->searchMailbox('UNSEEN'); - } catch (ConnectionException $ex) { - exit('IMAP connection failed: '.$ex->getMessage()); - } catch (Exception $ex) { - exit('An error occured: '.$ex->getMessage()); - } +try { + $mail_ids = $mailbox->searchMailbox('UNSEEN'); +} catch (ConnectionException $ex) { + exit('IMAP connection failed: '.$ex->getMessage()); +} catch (Exception $ex) { + exit('An error occured: '.$ex->getMessage()); +} - foreach ($mail_ids as $mail_id) { - echo "+------ P A R S I N G ------+\n"; +foreach ($mail_ids as $mail_id) { + echo "+------ P A R S I N G ------+\n"; - $email = $mailbox->getMail( - $mail_id, // ID of the email, you want to get - false // Do NOT mark emails as seen (optional) - ); + $email = $mailbox->getMail( + $mail_id, // ID of the email, you want to get + false // Do NOT mark emails as seen (optional) + ); - echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; - echo 'from-email: '.(string) $email->fromAddress."\n"; - echo 'to: '.(string) $email->toString."\n"; - echo 'subject: '.(string) $email->subject."\n"; - echo 'message_id: '.(string) $email->messageId."\n"; + echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; + echo 'from-email: '.(string) $email->fromAddress."\n"; + echo 'to: '.(string) $email->toString."\n"; + echo 'subject: '.(string) $email->subject."\n"; + echo 'message_id: '.(string) $email->messageId."\n"; - echo 'mail has attachments? '; - if ($email->hasAttachments()) { - echo "Yes\n"; - } else { - echo "No\n"; - } + echo 'mail has attachments? '; + if ($email->hasAttachments()) { + echo "Yes\n"; + } else { + echo "No\n"; + } - if (!empty($email->getAttachments())) { - echo \count($email->getAttachments())." attachements\n"; - } - if ($email->textHtml) { - echo "Message HTML:\n".$email->textHtml; - } else { - echo "Message Plain:\n".$email->textPlain; - } + if (!empty($email->getAttachments())) { + echo \count($email->getAttachments())." attachements\n"; + } + if ($email->textHtml) { + echo "Message HTML:\n".$email->textHtml; + } else { + echo "Message Plain:\n".$email->textPlain; + } - if (!empty($email->autoSubmitted)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Auto-Reply ------+\n"; - } + if (!empty($email->autoSubmitted)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Auto-Reply ------+\n"; + } - if (!empty($email_content->precedence)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; - } + if (!empty($email_content->precedence)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; } +} - $mailbox->disconnect(); +$mailbox->disconnect(); diff --git a/examples/get_and_parse_unseen_emails.php b/examples/get_and_parse_unseen_emails.php index 12eb8bb2..72748724 100644 --- a/examples/get_and_parse_unseen_emails.php +++ b/examples/get_and_parse_unseen_emails.php @@ -1,74 +1,74 @@ - */ - declare(strict_types=1); +/** + * Example: Get and parse all unseen emails with saving their attachments. + * + * @author Sebastian Krätzig + */ +declare(strict_types=1); - require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__.'/../vendor/autoload.php'; - use PhpImap\Exceptions\ConnectionException; - use PhpImap\Mailbox; +use PhpImap\Exceptions\ConnectionException; +use PhpImap\Mailbox; - $mailbox = new Mailbox( - '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder - 'some@gmail.com', // Username for the before configured mailbox - '*********', // Password for the before configured username - __DIR__, // Directory, where attachments will be saved (optional) - 'US-ASCII' // Server encoding (optional) - ); +$mailbox = new Mailbox( + '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder + 'some@gmail.com', // Username for the before configured mailbox + '*********', // Password for the before configured username + __DIR__, // Directory, where attachments will be saved (optional) + 'US-ASCII' // Server encoding (optional) +); - try { - $mail_ids = $mailbox->searchMailbox('UNSEEN'); - } catch (ConnectionException $ex) { - exit('IMAP connection failed: '.$ex->getErrors('first')); - } catch (Exception $ex) { - exit('An error occured: '.$ex->getMessage()); - } +try { + $mail_ids = $mailbox->searchMailbox('UNSEEN'); +} catch (ConnectionException $ex) { + exit('IMAP connection failed: '.$ex->getErrors('first')); +} catch (Exception $ex) { + exit('An error occured: '.$ex->getMessage()); +} - foreach ($mail_ids as $mail_id) { - echo "+------ P A R S I N G ------+\n"; +foreach ($mail_ids as $mail_id) { + echo "+------ P A R S I N G ------+\n"; - $email = $mailbox->getMail( - $mail_id, // ID of the email, you want to get - false // Do NOT mark emails as seen (optional) - ); + $email = $mailbox->getMail( + $mail_id, // ID of the email, you want to get + false // Do NOT mark emails as seen (optional) + ); - echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; - echo 'from-email: '.(string) $email->fromAddress."\n"; - echo 'to: '.(string) $email->toString."\n"; - echo 'subject: '.(string) $email->subject."\n"; - echo 'message_id: '.(string) $email->messageId."\n"; + echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; + echo 'from-email: '.(string) $email->fromAddress."\n"; + echo 'to: '.(string) $email->toString."\n"; + echo 'subject: '.(string) $email->subject."\n"; + echo 'message_id: '.(string) $email->messageId."\n"; - echo 'mail has attachments? '; - if ($email->hasAttachments()) { - echo "Yes\n"; - } else { - echo "No\n"; - } + echo 'mail has attachments? '; + if ($email->hasAttachments()) { + echo "Yes\n"; + } else { + echo "No\n"; + } - if (!empty($email->getAttachments())) { - echo \count($email->getAttachments())." attachements\n"; - } - if ($email->textHtml) { - echo "Message HTML:\n".$email->textHtml; - } else { - echo "Message Plain:\n".$email->textPlain; - } + if (!empty($email->getAttachments())) { + echo \count($email->getAttachments())." attachements\n"; + } + if ($email->textHtml) { + echo "Message HTML:\n".$email->textHtml; + } else { + echo "Message Plain:\n".$email->textPlain; + } - if (!empty($email->autoSubmitted)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Auto-Reply ------+\n"; - } + if (!empty($email->autoSubmitted)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Auto-Reply ------+\n"; + } - if (!empty($email_content->precedence)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; - } + if (!empty($email_content->precedence)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; } +} - $mailbox->disconnect(); +$mailbox->disconnect(); diff --git a/examples/get_and_parse_unseen_emails_save_attachments_one_by_one.php b/examples/get_and_parse_unseen_emails_save_attachments_one_by_one.php index 54a48995..20571845 100644 --- a/examples/get_and_parse_unseen_emails_save_attachments_one_by_one.php +++ b/examples/get_and_parse_unseen_emails_save_attachments_one_by_one.php @@ -1,92 +1,92 @@ - */ - declare(strict_types=1); - - require_once __DIR__.'/../vendor/autoload.php'; - - use PhpImap\Exceptions\ConnectionException; - use PhpImap\Mailbox; - - $mailbox = new Mailbox( - '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder - 'some@gmail.com', // Username for the before configured mailbox - '*********' // Password for the before configured username +/** + * Example: Get and parse all unseen emails with saving their attachments one by one. + * + * @author Sebastian Krätzig + */ +declare(strict_types=1); + +require_once __DIR__.'/../vendor/autoload.php'; + +use PhpImap\Exceptions\ConnectionException; +use PhpImap\Mailbox; + +$mailbox = new Mailbox( + '{imap.gmail.com:993/imap/ssl}INBOX', // IMAP server and mailbox folder + 'some@gmail.com', // Username for the before configured mailbox + '*********' // Password for the before configured username +); + +try { + $mail_ids = $mailbox->searchMailbox('UNSEEN'); +} catch (ConnectionException $ex) { + exit('IMAP connection failed: '.$ex->getMessage()); +} catch (Exception $ex) { + exit('An error occured: '.$ex->getMessage()); +} + +foreach ($mail_ids as $mail_id) { + echo "+------ P A R S I N G ------+\n"; + + $email = $mailbox->getMail( + $mail_id, // ID of the email, you want to get + false // Do NOT mark emails as seen (optional) ); - try { - $mail_ids = $mailbox->searchMailbox('UNSEEN'); - } catch (ConnectionException $ex) { - exit('IMAP connection failed: '.$ex->getMessage()); - } catch (Exception $ex) { - exit('An error occured: '.$ex->getMessage()); + echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; + echo 'from-email: '.(string) $email->fromAddress."\n"; + echo 'to: '.(string) $email->toString."\n"; + echo 'subject: '.(string) $email->subject."\n"; + echo 'message_id: '.(string) $email->messageId."\n"; + + echo 'mail has attachments? '; + if ($email->hasAttachments()) { + echo "Yes\n"; + } else { + echo "No\n"; } - foreach ($mail_ids as $mail_id) { - echo "+------ P A R S I N G ------+\n"; - - $email = $mailbox->getMail( - $mail_id, // ID of the email, you want to get - false // Do NOT mark emails as seen (optional) - ); - - echo 'from-name: '.(string) ($email->fromName ?? $email->fromAddress)."\n"; - echo 'from-email: '.(string) $email->fromAddress."\n"; - echo 'to: '.(string) $email->toString."\n"; - echo 'subject: '.(string) $email->subject."\n"; - echo 'message_id: '.(string) $email->messageId."\n"; - - echo 'mail has attachments? '; - if ($email->hasAttachments()) { - echo "Yes\n"; - } else { - echo "No\n"; - } - - if (!empty($email->getAttachments())) { - echo \count($email->getAttachments())." attachements\n"; - } + if (!empty($email->getAttachments())) { + echo \count($email->getAttachments())." attachements\n"; + } - // Save attachments one by one - if (!$mailbox->getAttachmentsIgnore()) { - $attachments = $email->getAttachments(); + // Save attachments one by one + if (!$mailbox->getAttachmentsIgnore()) { + $attachments = $email->getAttachments(); - foreach ($attachments as $attachment) { - echo '--> Saving '.(string) $attachment->name.'...'; + foreach ($attachments as $attachment) { + echo '--> Saving '.(string) $attachment->name.'...'; - // Set individually filePath for each single attachment - // In this case, every file will get the current Unix timestamp - $attachment->setFilePath(__DIR__.'/files/'.\time()); + // Set individually filePath for each single attachment + // In this case, every file will get the current Unix timestamp + $attachment->setFilePath(__DIR__.'/files/'.\time()); - if ($attachment->saveToDisk()) { - echo "OK, saved!\n"; - } else { - echo "ERROR, could not save!\n"; - } + if ($attachment->saveToDisk()) { + echo "OK, saved!\n"; + } else { + echo "ERROR, could not save!\n"; } } + } - if ($email->textHtml) { - echo "Message HTML:\n".$email->textHtml; - } else { - echo "Message Plain:\n".$email->textPlain; - } + if ($email->textHtml) { + echo "Message HTML:\n".$email->textHtml; + } else { + echo "Message Plain:\n".$email->textPlain; + } - if (!empty($email->autoSubmitted)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Auto-Reply ------+\n"; - } + if (!empty($email->autoSubmitted)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Auto-Reply ------+\n"; + } - if (!empty($email_content->precedence)) { - // Mark email as "read" / "seen" - $mailbox->markMailAsRead($mail_id); - echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; - } + if (!empty($email_content->precedence)) { + // Mark email as "read" / "seen" + $mailbox->markMailAsRead($mail_id); + echo "+------ IGNORING: Non-Delivery Report/Receipt ------+\n"; } +} - $mailbox->disconnect(); +$mailbox->disconnect(); diff --git a/src/PhpImap/Imap.php b/src/PhpImap/Imap.php index 71694e28..e68c9a50 100644 --- a/src/PhpImap/Imap.php +++ b/src/PhpImap/Imap.php @@ -1,4 +1,5 @@ $criteria + * * @psalm-suppress InvalidArgument * * @todo InvalidArgument, although it's correct: Argument 3 of imap_sort expects int, bool provided https://www.php.net/manual/de/function.imap-sort.php @@ -907,7 +914,7 @@ public static function sort( bool $reverse, int $options, ?string $search_criteria = null, - ?string $charset = null + ?string $charset = null, ): array { \imap_errors(); // flush errors @@ -1005,7 +1012,7 @@ public static function subscribe($imap_stream, string $mailbox): void */ public static function timeout( int $timeout_type, - int $timeout = -1 + int $timeout = -1, ) { \imap_errors(); // flush errors @@ -1029,7 +1036,7 @@ public static function timeout( */ public static function unsubscribe( $imap_stream, - string $mailbox + string $mailbox, ): void { $imap_stream = self::EnsureConnection($imap_stream, __METHOD__, 1); @@ -1083,10 +1090,10 @@ public static function decodeStringFromUtf7ImapToUtf8(string $str): string /** * @param false|resource $maybe * - * @throws InvalidArgumentException if $maybe is not a valid resource - * * @return resource * + * @throws InvalidArgumentException if $maybe is not a valid resource + * * @psalm-pure */ private static function EnsureResource($maybe, string $method, int $argument) @@ -1102,16 +1109,16 @@ private static function EnsureResource($maybe, string $method, int $argument) /** * @param false|resource $maybe * - * @throws Exceptions\ConnectionException if $maybe is not a valid resource - * * @return resource + * + * @throws ConnectionException if $maybe is not a valid resource */ private static function EnsureConnection($maybe, string $method, int $argument) { try { return self::EnsureResource($maybe, $method, $argument); } catch (Throwable $e) { - throw new Exceptions\ConnectionException('Argument '.(string) $argument.' passed to '.$method.' must be valid resource!', 0, $e); + throw new ConnectionException('Argument '.(string) $argument.' passed to '.$method.' must be valid resource!', 0, $e); } } @@ -1138,7 +1145,7 @@ private static function EnsureRange( $msg_number, string $method, int $argument, - bool $allow_sequence = false + bool $allow_sequence = false, ): string { if (!\is_int($msg_number) && !\is_string($msg_number)) { throw new InvalidArgumentException('Argument 1 passed to '.__METHOD__.'() must be an integer or a string!'); diff --git a/src/PhpImap/IncomingMail.php b/src/PhpImap/IncomingMail.php index d9552615..91ed4571 100644 --- a/src/PhpImap/IncomingMail.php +++ b/src/PhpImap/IncomingMail.php @@ -5,6 +5,7 @@ namespace PhpImap; use const FILEINFO_MIME_TYPE; + use InvalidArgumentException; /** diff --git a/src/PhpImap/IncomingMailAttachment.php b/src/PhpImap/IncomingMailAttachment.php index 2e29ce83..f8bbc50c 100644 --- a/src/PhpImap/IncomingMailAttachment.php +++ b/src/PhpImap/IncomingMailAttachment.php @@ -5,6 +5,7 @@ namespace PhpImap; use const FILEINFO_NONE; + use finfo; use UnexpectedValueException; diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 76beb8e8..f22975ac 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -5,12 +5,18 @@ namespace PhpImap; use const CL_EXPUNGE; + use function count; + use const CP_UID; use const DATE_RFC3339; + use DateTime; + use const DIRECTORY_SEPARATOR; + use Exception; + use const FILEINFO_EXTENSION; use const FILEINFO_MIME; use const FILEINFO_MIME_ENCODING; @@ -24,7 +30,9 @@ use const IMAP_OPENTIMEOUT; use const IMAP_READTIMEOUT; use const IMAP_WRITETIMEOUT; + use InvalidArgumentException; + use const OP_ANONYMOUS; use const OP_DEBUG; use const OP_HALFOPEN; @@ -34,18 +42,23 @@ use const OP_SHORTCACHE; use const OP_SILENT; use const PATHINFO_EXTENSION; + use PhpImap\Exceptions\ConnectionException; use PhpImap\Exceptions\InvalidParameterException; + use const SA_ALL; use const SE_FREE; use const SE_UID; use const SORT_NUMERIC; use const SORTARRIVAL; use const ST_UID; + use stdClass; + use const TYPEMESSAGE; use const TYPEMULTIPART; use const TYPETEXT; + use UnexpectedValueException; /** @@ -54,7 +67,6 @@ * @author Barbushin Sergey http://linkedin.com/in/barbushin * * @psalm-type PARTSTRUCTURE_PARAM = object{attribute:string, value?:string} - * * @psalm-type PARTSTRUCTURE = object{ * id?:string, * encoding:int|mixed, @@ -93,6 +105,18 @@ class Mailbox public const AUTHENTICATION_TYPE_OAUTH = 'oauth'; + public const IMAP_OPTIONS_SUPPORTED_VALUES = + OP_READONLY // 2 + | OP_ANONYMOUS // 4 + | OP_HALFOPEN // 64 + | CL_EXPUNGE // 32768 + | OP_DEBUG // 1 + | OP_SHORTCACHE // 8 + | OP_SILENT // 16 + | OP_PROTOTYPE // 32 + | OP_SECURE // 256 + ; + /** @var string[] */ private const SIMPLE_SEARCH_CRITERIA_WITHOUT_ARGUMENTS = [ 'ALL', @@ -133,18 +157,6 @@ class Mailbox 'UNKEYWORD', ]; - public const IMAP_OPTIONS_SUPPORTED_VALUES = - OP_READONLY // 2 - | OP_ANONYMOUS // 4 - | OP_HALFOPEN // 64 - | CL_EXPUNGE // 32768 - | OP_DEBUG // 1 - | OP_SHORTCACHE // 8 - | OP_SILENT // 16 - | OP_PROTOTYPE // 32 - | OP_SECURE // 256 - ; - /** @var string */ public $decodeMimeStrDefaultCharset = 'default'; @@ -161,7 +173,7 @@ class Mailbox protected $authenticationType = self::AUTHENTICATION_TYPE_PASSWORD; /** @var string|null */ - protected $imapOAuthToken = null; + protected $imapOAuthToken; /** @var int */ protected $imapSearchOption = SE_UID; @@ -185,7 +197,7 @@ class Mailbox protected $serverEncoding = 'UTF-8'; /** @var string|null */ - protected $attachmentsDir = null; + protected $attachmentsDir; /** @var bool */ protected $expungeOnDisconnect = true; @@ -917,9 +929,9 @@ public function markMailsAsImportant(array $mailId): void * @param int $mailId A single mail ID * @param string $flag Which you can get are \Seen, \Answered, \Flagged, \Deleted, and \Draft as defined by RFC2060 * - * @return bool True, when the flag is set, false when not - * * @psalm-param int $mailId + * + * @return bool True, when the flag is set, false when not */ public function flagIsSet(int $mailId, string $flag): bool { @@ -1087,7 +1099,7 @@ public function sortMails( int $criteria = SORTARRIVAL, bool $reverse = true, ?string $searchCriteria = 'ALL', - ?string $charset = null + ?string $charset = null, ): array { return Imap::sort( $this->getImapStream(), @@ -1177,38 +1189,6 @@ public function getMailHeaderFieldValue(string $headersRaw, string $header_field return ''; } - /** - * @psalm-return array{messageId:null|string, inReplyTo:null|string, references:null|string} - */ - protected function getThreadingHeaders(object $head, string $headersRaw): array - { - /** @var scalar|array|object|resource|null */ - $parsedMessageId = $head->message_id ?? null; - - if (null !== $parsedMessageId && !\is_string($parsedMessageId)) { - throw new UnexpectedValueException('Message ID was expected to be a string, '.\gettype($parsedMessageId).' found!'); - } - - $messageId = (\is_string($parsedMessageId) && '' !== \trim($parsedMessageId)) ? \trim($parsedMessageId) : $this->getOptionalMailHeaderFieldValue($headersRaw, 'Message-ID'); - - return [ - 'messageId' => $messageId, - 'inReplyTo' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'In-Reply-To'), - 'references' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'References'), - ]; - } - - protected function getOptionalMailHeaderFieldValue(string $headersRaw, string $headerFieldName): ?string - { - $headerFieldValue = $this->getMailHeaderFieldValue($headersRaw, $headerFieldName); - - if ('' === $headerFieldValue) { - return null; - } - - return $headerFieldValue; - } - /** * Get mail header. * @@ -1432,22 +1412,6 @@ public function getMail(int $mailId, bool $markAsSeen = true): IncomingMail return $mail; } - protected function hasAttachmentDisposition(object $partStructure): bool - { - /** @var scalar|array|object|resource|null */ - $disposition = $partStructure->disposition ?? null; - - return \is_string($disposition) && 'attachment' === \mb_strtolower($disposition); - } - - protected function sanitizeAttachmentFileSystemName(string $fileName): string - { - return \strtr($fileName, [ - '\\' => '_', - '/' => '_', - ]); - } - /** * Download attachment. * @@ -1750,12 +1714,12 @@ public function appendMessageToMailbox( $message, string $mailbox = '', ?string $options = null, - ?string $internal_date = null + ?string $internal_date = null, ): bool { if ( - \is_array($message) && - self::EXPECTED_SIZE_OF_MESSAGE_AS_ARRAY === \count($message) && - isset($message[0], $message[1]) + \is_array($message) + && self::EXPECTED_SIZE_OF_MESSAGE_AS_ARRAY === \count($message) + && isset($message[0], $message[1]) ) { $message = Imap::mail_compose($message[0], $message[1]); } @@ -1773,6 +1737,54 @@ public function appendMessageToMailbox( ); } + /** + * @psalm-return array{messageId:null|string, inReplyTo:null|string, references:null|string} + */ + protected function getThreadingHeaders(object $head, string $headersRaw): array + { + /** @var scalar|array|object|resource|null */ + $parsedMessageId = $head->message_id ?? null; + + if (null !== $parsedMessageId && !\is_string($parsedMessageId)) { + throw new UnexpectedValueException('Message ID was expected to be a string, '.\gettype($parsedMessageId).' found!'); + } + + $messageId = (\is_string($parsedMessageId) && '' !== \trim($parsedMessageId)) ? \trim($parsedMessageId) : $this->getOptionalMailHeaderFieldValue($headersRaw, 'Message-ID'); + + return [ + 'messageId' => $messageId, + 'inReplyTo' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'In-Reply-To'), + 'references' => $this->getOptionalMailHeaderFieldValue($headersRaw, 'References'), + ]; + } + + protected function getOptionalMailHeaderFieldValue(string $headersRaw, string $headerFieldName): ?string + { + $headerFieldValue = $this->getMailHeaderFieldValue($headersRaw, $headerFieldName); + + if ('' === $headerFieldValue) { + return null; + } + + return $headerFieldValue; + } + + protected function hasAttachmentDisposition(object $partStructure): bool + { + /** @var scalar|array|object|resource|null */ + $disposition = $partStructure->disposition ?? null; + + return \is_string($disposition) && 'attachment' === \mb_strtolower($disposition); + } + + protected function sanitizeAttachmentFileSystemName(string $fileName): string + { + return \strtr($fileName, [ + '\\' => '_', + '/' => '_', + ]); + } + /** * Returns the list of available encodings in lower case. * @@ -1821,9 +1833,9 @@ protected function getQuota(string $quota_root = 'INBOX'): array /** * Open an IMAP stream to a mailbox. * - * @throws Exception if an error occured - * * @return resource IMAP stream on success + * + * @throws Exception if an error occured */ protected function initImapStream() { @@ -1918,6 +1930,7 @@ protected function getOAuthImapOption(): int * @param string|0 $partNum * * @psalm-param PARTSTRUCTURE $partStructure + * * @psalm-suppress InvalidArgument * * @todo refactor type checking pending resolution of https://github.com/vimeo/psalm/issues/2619 @@ -1963,9 +1976,9 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part // ignore contentId on body when mail isn't multipart (https://github.com/barbushin/php-imap/issues/71) if ( - !$partNum && - TYPETEXT === $partStructure->type && - !$dispositionAttachment + !$partNum + && TYPETEXT === $partStructure->type + && !$dispositionAttachment ) { $isAttachment = false; } @@ -1977,7 +1990,7 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part // check if the part is a subpart of another attachment part (RFC822) if ('RFC822' === $partStructure->subtype && $dispositionAttachment) { // Although we are downloading each part separately, we are going to download the EML to a single file - //incase someone wants to process or parse in another process + // incase someone wants to process or parse in another process $attachment = self::downloadAttachment($dataInfo, $params, $partStructure, false); $mail->addAttachment($attachment); } @@ -2015,7 +2028,7 @@ protected function initMailPart(IncomingMail $mail, object $partStructure, $part // https://github.com/barbushin/php-imap/issues/198 $this->initMailPart($mail, $subPartStructure, $partNum, $markAsSeen); } elseif ('RFC822' === $partStructure->subtype && $dispositionAttachment) { - //If it comes from am EML attachment, download each part separately as a file + // If it comes from am EML attachment, download each part separately as a file $this->initMailPart($mail, $subPartStructure, $partNum.'.'.($subPartNum + 1), $markAsSeen, true); } else { $this->initMailPart($mail, $subPartStructure, $partNum.'.'.($subPartNum + 1), $markAsSeen); @@ -2084,9 +2097,9 @@ protected function getCombinedPath(string $folder, bool $absolute = false): stri } /** - * @psalm-return array{0: string, 1: null|string}|null + * @return (string|null)[]|null * - * @return (null|string)[]|null + * @psalm-return array{0: string, 1: null|string}|null */ protected function possiblyGetEmailAndNameFromRecipient(object $recipient): ?array { diff --git a/tests/unit/AbstractLiveMailboxTest.php b/tests/unit/AbstractLiveMailboxTest.php index f6791027..a4cbedce 100644 --- a/tests/unit/AbstractLiveMailboxTest.php +++ b/tests/unit/AbstractLiveMailboxTest.php @@ -1,4 +1,5 @@ MaybeSkipAppendTest($envelope)) { return; @@ -117,11 +118,10 @@ public function testAppend( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $message = [$envelope, $body]; @@ -137,11 +137,10 @@ public function testAppend( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $mailbox->deleteMail($search[0]); @@ -155,10 +154,9 @@ public function testAppend( $this->assertCount( 0, $mailbox->searchMailbox($search_criteria), - ( - 'If a subject was found,'. - ' then the message is was not expunged as requested.' - ) + + 'If a subject was found,'. + ' then the message is was not expunged as requested.' ); } catch (Throwable $ex) { $exception = $ex; diff --git a/tests/unit/Fixtures/IncomingMailAttachment.php b/tests/unit/Fixtures/IncomingMailAttachment.php index 65c31ac7..b7feeb48 100644 --- a/tests/unit/Fixtures/IncomingMailAttachment.php +++ b/tests/unit/Fixtures/IncomingMailAttachment.php @@ -6,18 +6,19 @@ use const FILEINFO_MIME_TYPE; use const FILEINFO_NONE; + use PhpImap\IncomingMailAttachment as Base; class IncomingMailAttachment extends Base { /** @var string|null */ - public $override_getFileInfo_mime_type = null; + public $override_getFileInfo_mime_type; public function getFileInfo(int $fileinfo_const = FILEINFO_NONE): string { if ( - FILEINFO_MIME_TYPE === $fileinfo_const && - isset($this->override_getFileInfo_mime_type) + FILEINFO_MIME_TYPE === $fileinfo_const + && isset($this->override_getFileInfo_mime_type) ) { return $this->override_getFileInfo_mime_type; } diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index df5f3414..981d8f34 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -19,7 +19,7 @@ public function getMailHeaderFieldValueForTests(string $headersRaw, string $head } /** - * @return (null|string)[]|null + * @return (string|null)[]|null */ public function possiblyGetEmailAndNameFromRecipientForTests(object $recipient): ?array { diff --git a/tests/unit/ImapTest.php b/tests/unit/ImapTest.php index d5cd6fc3..1d15670a 100644 --- a/tests/unit/ImapTest.php +++ b/tests/unit/ImapTest.php @@ -1,7 +1,8 @@ expectException($exception); @@ -111,7 +114,7 @@ public function testOpenFailure( public function testSortEmpty( HiddenString $path, HiddenString $login, - HiddenString $password + HiddenString $password, ): void { [$mailbox, $remove_mailbox, $path] = $this->getMailboxFromArgs([ $path, diff --git a/tests/unit/IncomingMailTest.php b/tests/unit/IncomingMailTest.php index a43ca775..e082b223 100644 --- a/tests/unit/IncomingMailTest.php +++ b/tests/unit/IncomingMailTest.php @@ -1,13 +1,15 @@ - * * @return string[][] + * + * @psalm-return array */ public function provider(): array { diff --git a/tests/unit/LiveMailboxIssue250Test.php b/tests/unit/LiveMailboxIssue250Test.php index b66a4cca..4f29c95b 100644 --- a/tests/unit/LiveMailboxIssue250Test.php +++ b/tests/unit/LiveMailboxIssue250Test.php @@ -1,4 +1,5 @@ 'test', ], ], - ( - 'Subject: '.$random_subject."\r\n". - 'MIME-Version: 1.0'."\r\n". - 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". - "\r\n". - 'test'."\r\n" - ), + + 'Subject: '.$random_subject."\r\n". + 'MIME-Version: 1.0'."\r\n". + 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". + "\r\n". + 'test'."\r\n", ]; } @@ -76,7 +77,7 @@ public function testAppend( array $envelope, array $body, string $expected_compose_result, - bool $pre_compose + bool $pre_compose, ): void { parent::testAppend( $mailbox_args, diff --git a/tests/unit/LiveMailboxIssue490Test.php b/tests/unit/LiveMailboxIssue490Test.php index 695bc93e..e3e11f26 100644 --- a/tests/unit/LiveMailboxIssue490Test.php +++ b/tests/unit/LiveMailboxIssue490Test.php @@ -1,4 +1,5 @@ getMailbox( $imapPath, @@ -63,11 +65,10 @@ public function testGetTextAttachments( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $message = Imap::mail_compose( @@ -108,11 +109,10 @@ public function testGetTextAttachments( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $mail = $mailbox->getMail($search[0], false); diff --git a/tests/unit/LiveMailboxIssue501Test.php b/tests/unit/LiveMailboxIssue501Test.php index 34ebc9ec..41d7e40f 100644 --- a/tests/unit/LiveMailboxIssue501Test.php +++ b/tests/unit/LiveMailboxIssue501Test.php @@ -1,4 +1,5 @@ getMailbox( $imapPath, @@ -83,11 +85,10 @@ public function testGetEmptyBody( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $mailbox->appendMessageToMailbox(Imap::mail_compose( @@ -105,11 +106,10 @@ public function testGetEmptyBody( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $mail = $mailbox->getMail($search[0], false); diff --git a/tests/unit/LiveMailboxIssue514Test.php b/tests/unit/LiveMailboxIssue514Test.php index 1c8edba5..6b15782e 100644 --- a/tests/unit/LiveMailboxIssue514Test.php +++ b/tests/unit/LiveMailboxIssue514Test.php @@ -1,4 +1,5 @@ assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $mailbox->appendMessageToMailbox($message); @@ -127,11 +129,10 @@ public function testEmbed( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $result = $mailbox->getMail($search[0], false); @@ -150,12 +151,11 @@ public function testEmbed( $this->assertCount( 2, $counts, - ( - 'counts should only contain foo.png and foo.webp, found: '. - \implode( - ', ', - \array_keys($counts) - ) + + 'counts should only contain foo.png and foo.webp, found: '. + \implode( + ', ', + \array_keys($counts) ) ); diff --git a/tests/unit/LiveMailboxStringDecodingConvertingTest.php b/tests/unit/LiveMailboxStringDecodingConvertingTest.php index 87f641d8..2a91a596 100644 --- a/tests/unit/LiveMailboxStringDecodingConvertingTest.php +++ b/tests/unit/LiveMailboxStringDecodingConvertingTest.php @@ -1,4 +1,5 @@ 'test', ], ], - ( - 'Subject: '.$random_subject."\r\n". - 'MIME-Version: 1.0'."\r\n". - 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". - "\r\n". - 'test'."\r\n" - ), + + 'Subject: '.$random_subject."\r\n". + 'MIME-Version: 1.0'."\r\n". + 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". + "\r\n". + 'test'."\r\n", ]; $random_subject = 'barbushin/php-imap#448: dot first:'.\bin2hex(\random_bytes(16)); @@ -184,18 +189,17 @@ public function ComposeProvider(): Generator ), ], ], - ( - 'Subject: '.$random_subject."\r\n". - 'MIME-Version: 1.0'."\r\n". - 'Content-Type: APPLICATION/octet-stream; name=.gitignore'."\r\n". - 'Content-Transfer-Encoding: BASE64'."\r\n". - 'Content-Description: .gitignore'."\r\n". - 'Content-Disposition: attachment; filename=.gitignore'."\r\n". - "\r\n". - \base64_encode( - \file_get_contents(__DIR__.'/../../.gitignore') - )."\r\n" - ), + + 'Subject: '.$random_subject."\r\n". + 'MIME-Version: 1.0'."\r\n". + 'Content-Type: APPLICATION/octet-stream; name=.gitignore'."\r\n". + 'Content-Transfer-Encoding: BASE64'."\r\n". + 'Content-Description: .gitignore'."\r\n". + 'Content-Disposition: attachment; filename=.gitignore'."\r\n". + "\r\n". + \base64_encode( + \file_get_contents(__DIR__.'/../../.gitignore') + )."\r\n", ]; $random_subject = 'barbushin/php-imap#448: dot last: '.\bin2hex(\random_bytes(16)); @@ -216,18 +220,17 @@ public function ComposeProvider(): Generator ), ], ], - ( - 'Subject: '.$random_subject."\r\n". - 'MIME-Version: 1.0'."\r\n". - 'Content-Type: APPLICATION/octet-stream; name=gitignore.'."\r\n". - 'Content-Transfer-Encoding: BASE64'."\r\n". - 'Content-Description: gitignore.'."\r\n". - 'Content-Disposition: attachment; filename=gitignore.'."\r\n". - "\r\n". - \base64_encode( - \file_get_contents(__DIR__.'/../../.gitignore') - )."\r\n" - ), + + 'Subject: '.$random_subject."\r\n". + 'MIME-Version: 1.0'."\r\n". + 'Content-Type: APPLICATION/octet-stream; name=gitignore.'."\r\n". + 'Content-Transfer-Encoding: BASE64'."\r\n". + 'Content-Description: gitignore.'."\r\n". + 'Content-Disposition: attachment; filename=gitignore.'."\r\n". + "\r\n". + \base64_encode( + \file_get_contents(__DIR__.'/../../.gitignore') + )."\r\n", ]; $random_subject = 'barbushin/php-imap#391: '.\bin2hex(\random_bytes(16)); @@ -266,31 +269,30 @@ public function ComposeProvider(): Generator 'contents.data' => $random_attachment_b, ], ], - ( - 'Subject: '.$random_subject."\r\n". - 'MIME-Version: 1.0'."\r\n". - 'Content-Type: MULTIPART/MIXED; BOUNDARY="{{REPLACE_BOUNDARY_HERE}}"'."\r\n". - "\r\n". - '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". - 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". - "\r\n". - 'test'."\r\n". - '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". - 'Content-Type: APPLICATION/octet-stream; name=foo.bin'."\r\n". - 'Content-Transfer-Encoding: BASE64'."\r\n". - 'Content-Description: foo.bin'."\r\n". - 'Content-Disposition: attachment; filename=foo.bin'."\r\n". - "\r\n". - $random_attachment_a."\r\n". - '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". - 'Content-Type: APPLICATION/octet-stream; name=foo.bin'."\r\n". - 'Content-Transfer-Encoding: BASE64'."\r\n". - 'Content-Description: foo.bin'."\r\n". - 'Content-Disposition: attachment; filename=foo.bin'."\r\n". - "\r\n". - $random_attachment_b."\r\n". - '--{{REPLACE_BOUNDARY_HERE}}--'."\r\n" - ), + + 'Subject: '.$random_subject."\r\n". + 'MIME-Version: 1.0'."\r\n". + 'Content-Type: MULTIPART/MIXED; BOUNDARY="{{REPLACE_BOUNDARY_HERE}}"'."\r\n". + "\r\n". + '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". + 'Content-Type: TEXT/PLAIN; CHARSET=US-ASCII'."\r\n". + "\r\n". + 'test'."\r\n". + '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". + 'Content-Type: APPLICATION/octet-stream; name=foo.bin'."\r\n". + 'Content-Transfer-Encoding: BASE64'."\r\n". + 'Content-Description: foo.bin'."\r\n". + 'Content-Disposition: attachment; filename=foo.bin'."\r\n". + "\r\n". + $random_attachment_a."\r\n". + '--{{REPLACE_BOUNDARY_HERE}}'."\r\n". + 'Content-Type: APPLICATION/octet-stream; name=foo.bin'."\r\n". + 'Content-Transfer-Encoding: BASE64'."\r\n". + 'Content-Description: foo.bin'."\r\n". + 'Content-Disposition: attachment; filename=foo.bin'."\r\n". + "\r\n". + $random_attachment_b."\r\n". + '--{{REPLACE_BOUNDARY_HERE}}--'."\r\n", ]; } @@ -330,7 +332,7 @@ public function testAppendNudgesMailboxCount( array $envelope, array $body, string $_expected_compose_result, - bool $pre_compose + bool $pre_compose, ): void { if ($this->MaybeSkipAppendTest($envelope)) { return; @@ -355,11 +357,10 @@ public function testAppendNudgesMailboxCount( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $mailbox->appendMessageToMailbox($message); @@ -369,21 +370,19 @@ public function testAppendNudgesMailboxCount( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $this->assertSame( $count + 1, $mailbox->countMails(), - ( - 'If the message count did not increase'. - ' then either the message was not appended,'. - ' or a mesage was removed while the test was running.' - ) + + 'If the message count did not increase'. + ' then either the message was not appended,'. + ' or a mesage was removed while the test was running.' ); $mailbox->deleteMail($search[0]); @@ -396,10 +395,9 @@ public function testAppendNudgesMailboxCount( $this->assertCount( 0, $mailbox->searchMailbox($search_criteria), - ( - 'If a subject was found,'. - ' then the message is was not expunged as requested.' - ) + + 'If a subject was found,'. + ' then the message is was not expunged as requested.' ); } @@ -419,7 +417,7 @@ public function testAppendSingleSearchMatchesSort( array $envelope, array $body, string $_expected_compose_result, - bool $pre_compose + bool $pre_compose, ): void { if ($this->MaybeSkipAppendTest($envelope)) { return; @@ -442,11 +440,10 @@ public function testAppendSingleSearchMatchesSort( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $mailbox->appendMessageToMailbox($message); @@ -456,11 +453,10 @@ public function testAppendSingleSearchMatchesSort( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $this->assertSame( @@ -494,10 +490,9 @@ public function testAppendSingleSearchMatchesSort( $this->assertCount( 0, $mailbox->searchMailbox($search_criteria), - ( - 'If a subject was found,'. - ' then the message is was not expunged as requested.' - ) + + 'If a subject was found,'. + ' then the message is was not expunged as requested.' ); } @@ -517,7 +512,7 @@ public function testAppendRetrievalMatchesExpected( array $envelope, array $body, string $expected_compose_result, - bool $pre_compose + bool $pre_compose, ): void { if ($this->MaybeSkipAppendTest($envelope)) { return; @@ -540,11 +535,10 @@ public function testAppendRetrievalMatchesExpected( $this->assertCount( 0, $search, - ( - 'If a subject was found,'. - ' then the message is insufficiently unique to assert that'. - ' a newly-appended message was actually created.' - ) + + 'If a subject was found,'. + ' then the message is insufficiently unique to assert that'. + ' a newly-appended message was actually created.' ); $mailbox->appendMessageToMailbox($message); @@ -554,11 +548,10 @@ public function testAppendRetrievalMatchesExpected( $this->assertCount( 1, $search, - ( - 'If a subject was not found, '. - ' then Mailbox::appendMessageToMailbox() failed'. - ' despite not throwing an exception.' - ) + + 'If a subject was not found, '. + ' then Mailbox::appendMessageToMailbox() failed'. + ' despite not throwing an exception.' ); $actual_result = $mailbox->getMailMboxFormat($search[0]); @@ -586,11 +579,10 @@ public function testAppendRetrievalMatchesExpected( $this->assertSame( $search_subject, $mail->subject, - ( - 'If a retrieved mail did not have a matching subject'. - ' despite being found via search,'. - ' then something has gone wrong.' - ) + + 'If a retrieved mail did not have a matching subject'. + ' despite being found via search,'. + ' then something has gone wrong.' ); $info = $mailbox->getMailsInfo($search); @@ -600,11 +592,10 @@ public function testAppendRetrievalMatchesExpected( $this->assertSame( $search_subject, $info[0]->subject, - ( - 'If a retrieved mail did not have a matching subject'. - ' despite being found via search,'. - ' then something has gone wrong.' - ) + + 'If a retrieved mail did not have a matching subject'. + ' despite being found via search,'. + ' then something has gone wrong.' ); if (1 === \preg_match( @@ -639,10 +630,9 @@ public function testAppendRetrievalMatchesExpected( $this->assertCount( 0, $mailbox->searchMailbox($search_criteria), - ( - 'If a subject was found,'. - ' then the message is was not expunged as requested.' - ) + + 'If a subject was found,'. + ' then the message is was not expunged as requested.' ); } @@ -656,11 +646,11 @@ public function testAppendRetrievalMatchesExpected( */ protected function ReplaceBoundaryHere( $expected_result, - $actual_result + $actual_result, ) { if ( - 1 === \preg_match('/{{REPLACE_BOUNDARY_HERE}}/', $expected_result) && - 1 === \preg_match( + 1 === \preg_match('/{{REPLACE_BOUNDARY_HERE}}/', $expected_result) + && 1 === \preg_match( '/Content-Type: MULTIPART\/MIXED; BOUNDARY="([^"]+)"/', $actual_result, $matches diff --git a/tests/unit/LiveMailboxTestingTrait.php b/tests/unit/LiveMailboxTestingTrait.php index 3c084347..7d07b296 100644 --- a/tests/unit/LiveMailboxTestingTrait.php +++ b/tests/unit/LiveMailboxTestingTrait.php @@ -1,4 +1,5 @@ getMailboxFromArgs($mailbox_args); diff --git a/tests/unit/MailboxAddressParsingTest.php b/tests/unit/MailboxAddressParsingTest.php index 5165842c..39884990 100644 --- a/tests/unit/MailboxAddressParsingTest.php +++ b/tests/unit/MailboxAddressParsingTest.php @@ -1,4 +1,5 @@ markTestSkipped('OP_XOAUTH2 is not available in this runtime.'); } - /** @var int $readonlyOption */ + /** @var int */ $readonlyOption = \constant('OP_READONLY'); - /** @var int $oauthOption */ + /** @var int */ $oauthOption = \constant('OP_XOAUTH2'); $mailbox = $this->getMailbox(); @@ -107,7 +107,7 @@ public function testSetConnectionArgsAcceptsXoauth2WhenRuntimeSupportsIt(): void $this->markTestSkipped('OP_XOAUTH2 is not available in this runtime.'); } - /** @var int $oauthOption */ + /** @var int */ $oauthOption = \constant('OP_XOAUTH2'); $mailbox = $this->getMailbox(); diff --git a/tests/unit/MailboxSearchTest.php b/tests/unit/MailboxSearchTest.php index 08f6b271..ca50647d 100644 --- a/tests/unit/MailboxSearchTest.php +++ b/tests/unit/MailboxSearchTest.php @@ -1,4 +1,5 @@ - * * @return string[][] + * + * @psalm-return non-empty-list */ public function SetAndGetServerEncodingProvider(): array { @@ -111,8 +119,8 @@ public function SetAndGetServerEncodingProvider(): array ] as $perhaps ) { if ( - \in_array(\trim($perhaps), $supported, true) || - \in_array(\strtoupper(\trim($perhaps)), $supported, true) + \in_array(\trim($perhaps), $supported, true) + || \in_array(\strtoupper(\trim($perhaps)), $supported, true) ) { $data[] = [$perhaps]; } @@ -285,9 +293,9 @@ public function testPathDelimiterHasADefault(): void /** * Provides test data for testing path delimiter. * - * @psalm-return array{0: array{0: '0'}, 1: array{0: '1'}, 2: array{0: '2'}, 3: array{0: '3'}, 4: array{0: '4'}, 5: array{0: '5'}, 6: array{0: '6'}, 7: array{0: '7'}, 8: array{0: '8'}, 9: array{0: '9'}, a: array{0: 'a'}, b: array{0: 'b'}, c: array{0: 'c'}, d: array{0: 'd'}, e: array{0: 'e'}, f: array{0: 'f'}, g: array{0: 'g'}, h: array{0: 'h'}, i: array{0: 'i'}, j: array{0: 'j'}, k: array{0: 'k'}, l: array{0: 'l'}, m: array{0: 'm'}, n: array{0: 'n'}, o: array{0: 'o'}, p: array{0: 'p'}, q: array{0: 'q'}, r: array{0: 'r'}, s: array{0: 's'}, t: array{0: 't'}, u: array{0: 'u'}, v: array{0: 'v'}, w: array{0: 'w'}, x: array{0: 'x'}, y: array{0: 'y'}, z: array{0: 'z'}, !: array{0: '!'}, '\\': array{0: '\'}, $: array{0: '$'}, %: array{0: '%'}, §: array{0: '§'}, &: array{0: '&'}, /: array{0: '/'}, (: array{0: '('}, ): array{0: ')'}, =: array{0: '='}, #: array{0: '#'}, ~: array{0: '~'}, *: array{0: '*'}, +: array{0: '+'}, ,: array{0: ','}, ;: array{0: ';'}, '.': array{0: '.'}, ':': array{0: ':'}, <: array{0: '<'}, >: array{0: '>'}, |: array{0: '|'}, _: array{0: '_'}} - * * @return string[][] + * + * @psalm-return array{0: array{0: '0'}, 1: array{0: '1'}, 2: array{0: '2'}, 3: array{0: '3'}, 4: array{0: '4'}, 5: array{0: '5'}, 6: array{0: '6'}, 7: array{0: '7'}, 8: array{0: '8'}, 9: array{0: '9'}, a: array{0: 'a'}, b: array{0: 'b'}, c: array{0: 'c'}, d: array{0: 'd'}, e: array{0: 'e'}, f: array{0: 'f'}, g: array{0: 'g'}, h: array{0: 'h'}, i: array{0: 'i'}, j: array{0: 'j'}, k: array{0: 'k'}, l: array{0: 'l'}, m: array{0: 'm'}, n: array{0: 'n'}, o: array{0: 'o'}, p: array{0: 'p'}, q: array{0: 'q'}, r: array{0: 'r'}, s: array{0: 's'}, t: array{0: 't'}, u: array{0: 'u'}, v: array{0: 'v'}, w: array{0: 'w'}, x: array{0: 'x'}, y: array{0: 'y'}, z: array{0: 'z'}, !: array{0: '!'}, '\\': array{0: '\'}, $: array{0: '$'}, %: array{0: '%'}, §: array{0: '§'}, &: array{0: '&'}, /: array{0: '/'}, (: array{0: '('}, ): array{0: ')'}, =: array{0: '='}, #: array{0: '#'}, ~: array{0: '~'}, *: array{0: '*'}, +: array{0: '+'}, ,: array{0: ','}, ;: array{0: ';'}, '.': array{0: '.'}, ':': array{0: ':'}, <: array{0: '<'}, >: array{0: '>'}, |: array{0: '|'}, _: array{0: '_'}} */ public function pathDelimiterProvider(): array { @@ -470,7 +478,7 @@ public function unsafeAttachmentFilenameProvider(): array public function testDownloadAttachmentSanitizesFilePathWhenUsingOriginalFilenameMode(string $unsafeName, string $expectedFileName): void { $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); - $mailbox = new class ($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { + $mailbox = new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { public function decodeMimeStr(string $string): string { return $string; @@ -512,9 +520,9 @@ public function decodeMimeStr(string $string): string /** * Provides test data for testing encoding. * - * @psalm-return array{Avañe’ẽ: array{0: 'Avañe’ẽ'}, azərbaycanca: array{0: 'azərbaycanca'}, Bokmål: array{0: 'Bokmål'}, chiCheŵa: array{0: 'chiCheŵa'}, Deutsch: array{0: 'Deutsch'}, 'U.S. English': array{0: 'U.S. English'}, français: array{0: 'français'}, 'Éléments envoyés': array{0: 'Éléments envoyés'}, føroyskt: array{0: 'føroyskt'}, Kĩmĩrũ: array{0: 'Kĩmĩrũ'}, Kɨlaangi: array{0: 'Kɨlaangi'}, oʼzbekcha: array{0: 'oʼzbekcha'}, Plattdüütsch: array{0: 'Plattdüütsch'}, română: array{0: 'română'}, Sängö: array{0: 'Sängö'}, 'Tiếng Việt': array{0: 'Tiếng Việt'}, ɔl-Maa: array{0: 'ɔl-Maa'}, Ελληνικά: array{0: 'Ελληνικά'}, Ўзбек: array{0: 'Ўзбек'}, Азәрбајҹан: array{0: 'Азәрбајҹан'}, Српски: array{0: 'Српски'}, русский: array{0: 'русский'}, 'ѩзыкъ словѣньскъ': array{0: 'ѩзыкъ словѣньскъ'}, العربية: array{0: 'العربية'}, नेपाली: array{0: 'नेपाली'}, 日本語: array{0: '日本語'}, 简体中文: array{0: '简体中文'}, 繁體中文: array{0: '繁體中文'}, 한국어: array{0: '한국어'}, ąčęėįšųūžĄČĘĖĮŠŲŪŽ: array{0: 'ąčęėįšųūžĄČĘĖĮŠŲŪŽ'}} - * * @return string[][] + * + * @psalm-return array{Avañe’ẽ: array{0: 'Avañe’ẽ'}, azərbaycanca: array{0: 'azərbaycanca'}, Bokmål: array{0: 'Bokmål'}, chiCheŵa: array{0: 'chiCheŵa'}, Deutsch: array{0: 'Deutsch'}, 'U.S. English': array{0: 'U.S. English'}, français: array{0: 'français'}, 'Éléments envoyés': array{0: 'Éléments envoyés'}, føroyskt: array{0: 'føroyskt'}, Kĩmĩrũ: array{0: 'Kĩmĩrũ'}, Kɨlaangi: array{0: 'Kɨlaangi'}, oʼzbekcha: array{0: 'oʼzbekcha'}, Plattdüütsch: array{0: 'Plattdüütsch'}, română: array{0: 'română'}, Sängö: array{0: 'Sängö'}, 'Tiếng Việt': array{0: 'Tiếng Việt'}, ɔl-Maa: array{0: 'ɔl-Maa'}, Ελληνικά: array{0: 'Ελληνικά'}, Ўзбек: array{0: 'Ўзбек'}, Азәрбајҹан: array{0: 'Азәрбајҹан'}, Српски: array{0: 'Српски'}, русский: array{0: 'русский'}, 'ѩзыкъ словѣньскъ': array{0: 'ѩзыкъ словѣньскъ'}, العربية: array{0: 'العربية'}, नेपाली: array{0: 'नेपाली'}, 日本語: array{0: '日本語'}, 简体中文: array{0: '简体中文'}, 繁體中文: array{0: '繁體中文'}, 한국어: array{0: '한국어'}, ąčęėįšųūžĄČĘĖĮŠŲŪŽ: array{0: 'ąčęėįšųūžĄČĘĖĮŠŲŪŽ'}} */ public function encodingTestStringsProvider(): array { @@ -580,9 +588,9 @@ public function testMimeDecodingReturnsCorrectValues(string $str): void /** * Provides test data for testing parsing datetimes. * - * @psalm-return array{'Sun, 14 Aug 2005 16:13:03 +0000 (CEST)': array{0: '2005-08-14T16:13:03+00:00', 1: 1124035983}, 'Sun, 14 Aug 2005 16:13:03 +0000': array{0: '2005-08-14T16:13:03+00:00', 1: 1124035983}, 'Sun, 14 Aug 2005 16:13:03 +1000 (CEST)': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, 'Sun, 14 Aug 2005 16:13:03 +1000': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, 'Sun, 14 Aug 2005 16:13:03 -1000': array{0: '2005-08-15T02:13:03+00:00', 1: 1124071983}, 'Sun, 14 Aug 2005 16:13:03 +1100 (CEST)': array{0: '2005-08-14T05:13:03+00:00', 1: 1123996383}, 'Sun, 14 Aug 2005 16:13:03 +1100': array{0: '2005-08-14T05:13:03+00:00', 1: 1123996383}, 'Sun, 14 Aug 2005 16:13:03 -1100': array{0: '2005-08-15T03:13:03+00:00', 1: 1124075583}, '14 Aug 2005 16:13:03 +1000 (CEST)': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, '14 Aug 2005 16:13:03 +1000': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, '14 Aug 2005 16:13:03 -1000': array{0: '2005-08-15T02:13:03+00:00', 1: 1124071983}} - * * @return (int|string)[][] + * + * @psalm-return array{'Sun, 14 Aug 2005 16:13:03 +0000 (CEST)': array{0: '2005-08-14T16:13:03+00:00', 1: 1124035983}, 'Sun, 14 Aug 2005 16:13:03 +0000': array{0: '2005-08-14T16:13:03+00:00', 1: 1124035983}, 'Sun, 14 Aug 2005 16:13:03 +1000 (CEST)': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, 'Sun, 14 Aug 2005 16:13:03 +1000': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, 'Sun, 14 Aug 2005 16:13:03 -1000': array{0: '2005-08-15T02:13:03+00:00', 1: 1124071983}, 'Sun, 14 Aug 2005 16:13:03 +1100 (CEST)': array{0: '2005-08-14T05:13:03+00:00', 1: 1123996383}, 'Sun, 14 Aug 2005 16:13:03 +1100': array{0: '2005-08-14T05:13:03+00:00', 1: 1123996383}, 'Sun, 14 Aug 2005 16:13:03 -1100': array{0: '2005-08-15T03:13:03+00:00', 1: 1124075583}, '14 Aug 2005 16:13:03 +1000 (CEST)': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, '14 Aug 2005 16:13:03 +1000': array{0: '2005-08-14T06:13:03+00:00', 1: 1123999983}, '14 Aug 2005 16:13:03 -1000': array{0: '2005-08-15T02:13:03+00:00', 1: 1124071983}} */ public function datetimeProvider(): array { @@ -619,9 +627,9 @@ public function testParsedDateDifferentTimeZones(string $dateToParse, int $epoch /** * Provides test data for testing parsing invalid / unparseable datetimes. * - * @psalm-return array{'Sun, 14 Aug 2005 16:13:03 +9000 (CEST)': array{0: 'Sun, 14 Aug 2005 16:13:03 +9000 (CEST)'}, 'Sun, 14 Aug 2005 16:13:03 +9000': array{0: 'Sun, 14 Aug 2005 16:13:03 +9000'}, 'Sun, 14 Aug 2005 16:13:03 -9000': array{0: 'Sun, 14 Aug 2005 16:13:03 -9000'}} - * * @return string[][] + * + * @psalm-return array{'Sun, 14 Aug 2005 16:13:03 +9000 (CEST)': array{0: 'Sun, 14 Aug 2005 16:13:03 +9000 (CEST)'}, 'Sun, 14 Aug 2005 16:13:03 +9000': array{0: 'Sun, 14 Aug 2005 16:13:03 +9000'}, 'Sun, 14 Aug 2005 16:13:03 -9000': array{0: 'Sun, 14 Aug 2005 16:13:03 -9000'}} */ public function invalidDatetimeProvider(): array { @@ -812,9 +820,9 @@ public function testSetConnectionArgs(string $assertMethod, int $option, int $re /** * Provides test data for testing mime string decoding. * - * @psalm-return array{'': array{0: '', 1: ''}, '': array{0: '', 1: ''}, '': array{0: '', 1: ''}, '': array{0: '', 1: ''}, 'Some subject here 😘': array{0: '=?UTF-8?q?Some_subject_here_?= =?UTF-8?q?=F0=9F=98=98?=', 1: 'Some subject here 😘'}, mountainguan测试: array{0: '=?UTF-8?Q?mountainguan=E6=B5=8B=E8=AF=95?=', 1: 'mountainguan测试'}, 'This is the Euro symbol \'\'.': array{0: 'This is the Euro symbol ''.', 1: 'This is the Euro symbol ''.'}, 'Some subject here 😘 US-ASCII': array{0: '=?UTF-8?q?Some_subject_here_?= =?UTF-8?q?=F0=9F=98=98?=', 1: 'Some subject here 😘', 2: 'US-ASCII'}, 'mountainguan测试 US-ASCII': array{0: '=?UTF-8?Q?mountainguan=E6=B5=8B=E8=AF=95?=', 1: 'mountainguan测试', 2: 'US-ASCII'}, 'مقتطفات من: صن تزو. \"فن الحرب\". كتب أبل. Something': array{0: 'مقتطفات من: صن تزو. "فن الحرب". كتب أبل. Something', 1: 'مقتطفات من: صن تزو. "فن الحرب". كتب أبل. Something'}, '(事件单编号:TESTA-111111)(通报)入口有陌生人': array{0: '=?utf-8?b?KOS6i+S7tuWNlee8luWPtzpURVNUQS0xMTExMTEpKOmAmuaKpSnl?= =?utf-8?b?haXlj6PmnInpmYznlJ/kuro=?=', 1: '(事件单编号:TESTA-111111)(通报)入口有陌生人'}} - * * @return string[][] + * + * @psalm-return array{'': array{0: '', 1: ''}, '': array{0: '', 1: ''}, '': array{0: '', 1: ''}, '': array{0: '', 1: ''}, 'Some subject here 😘': array{0: '=?UTF-8?q?Some_subject_here_?= =?UTF-8?q?=F0=9F=98=98?=', 1: 'Some subject here 😘'}, mountainguan测试: array{0: '=?UTF-8?Q?mountainguan=E6=B5=8B=E8=AF=95?=', 1: 'mountainguan测试'}, 'This is the Euro symbol \'\'.': array{0: 'This is the Euro symbol ''.', 1: 'This is the Euro symbol ''.'}, 'Some subject here 😘 US-ASCII': array{0: '=?UTF-8?q?Some_subject_here_?= =?UTF-8?q?=F0=9F=98=98?=', 1: 'Some subject here 😘', 2: 'US-ASCII'}, 'mountainguan测试 US-ASCII': array{0: '=?UTF-8?Q?mountainguan=E6=B5=8B=E8=AF=95?=', 1: 'mountainguan测试', 2: 'US-ASCII'}, 'مقتطفات من: صن تزو. \"فن الحرب\". كتب أبل. Something': array{0: 'مقتطفات من: صن تزو. "فن الحرب". كتب أبل. Something', 1: 'مقتطفات من: صن تزو. "فن الحرب". كتب أبل. Something'}, '(事件单编号:TESTA-111111)(通报)入口有陌生人': array{0: '=?utf-8?b?KOS6i+S7tuWNlee8luWPtzpURVNUQS0xMTExMTEpKOmAmuaKpSnl?= =?utf-8?b?haXlj6PmnInpmYznlJ/kuro=?=', 1: '(事件单编号:TESTA-111111)(通报)入口有陌生人'}} */ public function mimeStrDecodingProvider(): array { @@ -849,9 +857,9 @@ public function testDecodeMimeStr(string $str, string $expectedStr, string $serv /** * Provides test data for testing base64 string decoding. * - * @psalm-return array{0: array{0: 'bm8tcmVwbHlAZXhhbXBsZS5jb20=', 1: 'no-reply@example.com'}, 1: array{0: 'TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=', 1: 'Man is distinguished, not only by his reason, but by this singular passion from other animals, which is a lust of the mind, that by a perseverance of delight in the continued and indefatigable generation of knowledge, exceeds the short vehemence of any carnal pleasure.'}, 2: array{0: 'SSBjYW4gZWF0IGdsYXNzIGFuZCBpdCBkb2VzIG5vdCBodXJ0IG1lLg==', 1: 'I can eat glass and it does not hurt me.'}, 3: array{0: '77u/4KSV4KS+4KSa4KSCIOCktuCkleCljeCkqOCli+CkruCljeCkr+CkpOCljeCkpOClgeCkruCljSDgpaQg4KSo4KWL4KSq4KS54KS/4KSo4KS44KWN4KSk4KS/IOCkruCkvuCkruCljSDgpaU=', 1: 'काचं शक्नोम्यत्तुम् । नोपहिनस्ति माम् ॥'}, 4: array{0: 'SmUgcGV1eCBtYW5nZXIgZHUgdmVycmUsIMOnYSBuZSBtZSBmYWl0IHBhcyBtYWwu', 1: 'Je peux manger du verre, ça ne me fait pas mal.'}, 5: array{0: 'UG90IHPEgyBtxINuw6JuYyBzdGljbMSDIMiZaSBlYSBudSBtxIMgcsSDbmXImXRlLg==', 1: 'Pot să mănânc sticlă și ea nu mă rănește.'}, 6: array{0: '5oiR6IO95ZCe5LiL546755KD6ICM5LiN5YK36Lqr6auU44CC', 1: '我能吞下玻璃而不傷身體。'}} - * * @return string[][] + * + * @psalm-return array{0: array{0: 'bm8tcmVwbHlAZXhhbXBsZS5jb20=', 1: 'no-reply@example.com'}, 1: array{0: 'TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sIGJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYWxzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJzZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRlZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZSBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=', 1: 'Man is distinguished, not only by his reason, but by this singular passion from other animals, which is a lust of the mind, that by a perseverance of delight in the continued and indefatigable generation of knowledge, exceeds the short vehemence of any carnal pleasure.'}, 2: array{0: 'SSBjYW4gZWF0IGdsYXNzIGFuZCBpdCBkb2VzIG5vdCBodXJ0IG1lLg==', 1: 'I can eat glass and it does not hurt me.'}, 3: array{0: '77u/4KSV4KS+4KSa4KSCIOCktuCkleCljeCkqOCli+CkruCljeCkr+CkpOCljeCkpOClgeCkruCljSDgpaQg4KSo4KWL4KSq4KS54KS/4KSo4KS44KWN4KSk4KS/IOCkruCkvuCkruCljSDgpaU=', 1: 'काचं शक्नोम्यत्तुम् । नोपहिनस्ति माम् ॥'}, 4: array{0: 'SmUgcGV1eCBtYW5nZXIgZHUgdmVycmUsIMOnYSBuZSBtZSBmYWl0IHBhcyBtYWwu', 1: 'Je peux manger du verre, ça ne me fait pas mal.'}, 5: array{0: 'UG90IHPEgyBtxINuw6JuYyBzdGljbMSDIMiZaSBlYSBudSBtxIMgcsSDbmXImXRlLg==', 1: 'Pot să mănânc sticlă și ea nu mă rănește.'}, 6: array{0: '5oiR6IO95ZCe5LiL546755KD6ICM5LiN5YK36Lqr6auU44CC', 1: '我能吞下玻璃而不傷身體。'}} */ public function Base64DecodeProvider(): array { @@ -876,9 +884,9 @@ public function testBase64Decode(string $input, string $expected): void } /** - * @psalm-return array{0: array{0: string, 1: '', 2: Exceptions\InvalidParameterException::class, 3: 'setAttachmentsDir() expects a string as first parameter!'}, 1: array{0: string, 1: ' ', 2: Exceptions\InvalidParameterException::class, 3: 'setAttachmentsDir() expects a string as first parameter!'}, 2: array{0: string, 1: string, 2: Exceptions\InvalidParameterException::class, 3: string}} - * * @return string[][] + * + * @psalm-return array{0: array{0: string, 1: '', 2: InvalidParameterException::class, 3: 'setAttachmentsDir() expects a string as first parameter!'}, 1: array{0: string, 1: ' ', 2: InvalidParameterException::class, 3: 'setAttachmentsDir() expects a string as first parameter!'}, 2: array{0: string, 1: string, 2: InvalidParameterException::class, 3: string}} */ public function attachmentDirFailureProvider(): array { From 51bc2f06ec6b07889f3d36e0c170dd38f4ab725f Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 15:37:19 +0200 Subject: [PATCH 12/16] Fix failing PHPUnit tests --- src/PhpImap/Imap.php | 7 ++++++- src/PhpImap/Mailbox.php | 9 +++++---- tests/unit/MailboxEncodingTest.php | 3 +++ tests/unit/MailboxTest.php | 9 ++++++--- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/PhpImap/Imap.php b/src/PhpImap/Imap.php index e68c9a50..e10e7cf4 100644 --- a/src/PhpImap/Imap.php +++ b/src/PhpImap/Imap.php @@ -1060,7 +1060,12 @@ public static function unsubscribe( */ public static function encodeStringToUtf7Imap(string $str): string { - $out = \mb_convert_encoding($str, 'UTF7-IMAP', \mb_detect_encoding($str, 'UTF-8, ISO-8859-1, ISO-8859-15', true)); + // mb_detect_encoding() can misclassify valid UTF-8 strings as ISO-8859-1/15. + $sourceEncoding = \mb_check_encoding($str, 'UTF-8') + ? 'UTF-8' + : (\mb_detect_encoding($str, ['ISO-8859-1', 'ISO-8859-15'], true) ?: 'UTF-8'); + + $out = \mb_convert_encoding($str, 'UTF7-IMAP', $sourceEncoding); if (!\is_string($out)) { throw new UnexpectedValueException('mb_convert_encoding($str, \'UTF-8\', {detected}) could not convert $str'); diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index f22975ac..e1a20dc0 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -583,7 +583,7 @@ public function hasImapStream(): bool */ public function encodeStringToUtf7Imap(string $str): string { - return imap_utf7_encode($str); + return Imap::encodeStringToUtf7Imap($str); } /** @@ -1586,10 +1586,11 @@ public function decodeMimeStr(string $string): string */ public function isUrlEncoded(string $string): bool { - $hasInvalidChars = \preg_match('#[^%a-zA-Z0-9\-_\.\+]#', $string); - $hasEscapedChars = \preg_match('#%[a-zA-Z0-9]{2}#', $string); + $hasInvalidChars = 1 === \preg_match('#[^%a-zA-Z0-9\-_\.\+]#', $string); + $hasEscapedChars = 1 === \preg_match('#%[A-Fa-f0-9]{2}#', $string); + $hasInvalidEscapes = 1 === \preg_match('#%(?![A-Fa-f0-9]{2})#', $string); - return !$hasInvalidChars && $hasEscapedChars; + return !$hasInvalidChars && $hasEscapedChars && !$hasInvalidEscapes; } /** diff --git a/tests/unit/MailboxEncodingTest.php b/tests/unit/MailboxEncodingTest.php index 61d2f750..f645ba83 100644 --- a/tests/unit/MailboxEncodingTest.php +++ b/tests/unit/MailboxEncodingTest.php @@ -54,9 +54,12 @@ public function isUrlEncodedProvider(): array { return [ 'RFC2231 encoded segment' => ['%E2%82%AC%20rates.txt', true], + 'lowercase hex escapes' => ['%e2%82%ac%20rates.txt', true], 'encoded mime-word' => ['%3D%3FUTF-8%3FQ%3Fmountainguan%3DE6%3DB5%3D8B%3DE8%3DAF%3D95%3F%3D', true], 'plain ascii' => ['plain-file.txt', false], 'invalid percent escape' => ['%ZZrates.txt', false], + 'mixed valid and invalid percent escapes' => ['%E2%82%ZZrates.txt', false], + 'lone percent sign' => ['rates%.txt', false], ]; } diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 35fa4b34..1ff851b5 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -241,7 +241,10 @@ public function testFlattenPartsStartsRfc822SubPartsWithOne(): void $flattenedParts = $this->getMailbox()->flattenParts([$rfc822Part]); - $this->assertSame(['1', '1.1', '1.2'], \array_keys($flattenedParts)); + $this->assertSame( + ['1', '1.1', '1.2'], + \array_map('strval', \array_keys($flattenedParts)) + ); $this->assertArrayNotHasKey('1.0', $flattenedParts); } @@ -478,6 +481,8 @@ public function unsafeAttachmentFilenameProvider(): array public function testDownloadAttachmentSanitizesFilePathWhenUsingOriginalFilenameMode(string $unsafeName, string $expectedFileName): void { $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + \mkdir($attachmentsDir); + $mailbox = new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { public function decodeMimeStr(string $string): string { @@ -497,8 +502,6 @@ public function decodeMimeStr(string $string): string ]; $attachmentPath = null; - \mkdir($attachmentsDir); - try { $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => $unsafeName], $partStructure); $attachmentPath = $attachment->filePath; From 04106837a9006348b1ffabbba1c4870eb2f14781 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 15:58:44 +0200 Subject: [PATCH 13/16] Add support for keep original attachment filenames The existing boolean `setAttachmentFilenameMode(true|false)` stays intact for backward compatibility, and attachment downloads now either overwrite as before or generate `name (1).ext`, `name (2).ext`, while still sanitizing path separators and respecting the file-path limit (`src/PhpImap/Mailbox.php:1532`, `src/PhpImap/Mailbox.php:1827`). Co-authored by @fglueck Refs: - https://github.com/barbushin/php-imap/pull/645 --- README.md | 6 +- src/PhpImap/Mailbox.php | 106 +++++++++++++++++++++++--- tests/unit/MailboxTest.php | 148 ++++++++++++++++++++++++++++++++----- 3 files changed, 233 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 74b3d220..99aaf4de 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ You can run all PHPUnit tests by running the following command (inside of the in Below, you'll find an example code how you can use this library. For further information and other examples, you may take a look at the [wiki](https://github.com/barbushin/php-imap/wiki). -By default, this library uses random filenames for attachments as identical file names from other emails would overwrite other attachments. If you want to keep the original file name, you can set the attachment filename mode to ``true``, but then you also need to ensure, that those files don't get overwritten by other emails for example. +By default, this library uses random filenames for attachments as identical file names from other emails would overwrite other attachments. If you want to keep the original file name, you can set the attachment filename mode to `true`. For backward compatibility, this still overwrites an existing file with the same name. If you want to keep the original file name and automatically suffix duplicates with ` (1)`, ` (2)`, and so on, set the attachment filename collision mode to `PhpImap\Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX`. ```php // Create PhpImap\Mailbox instance for all further actions @@ -96,6 +96,10 @@ $mailbox = new PhpImap\Mailbox( false // Attachment filename mode (optional; false = random filename; true = original filename) ); +$mailbox->setAttachmentFilenameCollisionMode( + PhpImap\Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX +); + // set some connection arguments (if appropriate) $mailbox->setConnectionArgs( CL_EXPUNGE // expunge deleted mails upon mailbox close diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index e1a20dc0..b621aa92 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -41,8 +41,6 @@ use const OP_SECURE; use const OP_SHORTCACHE; use const OP_SILENT; -use const PATHINFO_EXTENSION; - use PhpImap\Exceptions\ConnectionException; use PhpImap\Exceptions\InvalidParameterException; @@ -105,6 +103,10 @@ class Mailbox public const AUTHENTICATION_TYPE_OAUTH = 'oauth'; + public const ATTACHMENT_FILENAME_COLLISION_OVERWRITE = 1; + + public const ATTACHMENT_FILENAME_COLLISION_SUFFIX = 2; + public const IMAP_OPTIONS_SUPPORTED_VALUES = OP_READONLY // 2 | OP_ANONYMOUS // 4 @@ -218,9 +220,12 @@ class Mailbox /** @var string */ protected $mailboxFolder; - /** @var bool|false */ + /** @var bool */ protected $attachmentFilenameMode = false; + /** @var int */ + protected $attachmentFilenameCollisionMode = self::ATTACHMENT_FILENAME_COLLISION_OVERWRITE; + /** @var resource|null */ private $imapStream; @@ -351,6 +356,41 @@ public function setAttachmentFilenameMode(bool $attachmentFilenameMode): void $this->attachmentFilenameMode = $attachmentFilenameMode; } + /** + * Returns the current collision handling mode for original attachment filenames. + * + * @return int Attachment filename collision mode + * + * @psalm-return 1|2 + */ + public function getAttachmentFilenameCollisionMode(): int + { + return $this->attachmentFilenameCollisionMode; + } + + /** + * Sets / Changes the collision handling mode for original attachment filenames. + * + * @param int $attachmentFilenameCollisionMode Attachment filename collision mode + * + * @psalm-param 1|2 $attachmentFilenameCollisionMode + * + * @throws InvalidParameterException + */ + public function setAttachmentFilenameCollisionMode(int $attachmentFilenameCollisionMode): void + { + $supported_modes = [ + self::ATTACHMENT_FILENAME_COLLISION_OVERWRITE, + self::ATTACHMENT_FILENAME_COLLISION_SUFFIX, + ]; + + if (!\in_array($attachmentFilenameCollisionMode, $supported_modes, true)) { + throw new InvalidParameterException('"'.$attachmentFilenameCollisionMode.'" is not supported by setAttachmentFilenameCollisionMode(). Supported modes are ATTACHMENT_FILENAME_COLLISION_OVERWRITE and ATTACHMENT_FILENAME_COLLISION_SUFFIX.'); + } + + $this->attachmentFilenameCollisionMode = $attachmentFilenameCollisionMode; + } + /** * Returns the current set IMAP search option. * @@ -1491,18 +1531,16 @@ public function downloadAttachment(DataPartInfo $dataInfo, array $params, object if (null != $attachmentsDir) { if (true == $this->getAttachmentFilenameMode()) { - $fileSysName = $this->sanitizeAttachmentFileSystemName($attachment->name); + $fileSysName = $this->resolveAttachmentFileSystemName( + $attachmentsDir, + $this->sanitizeAttachmentFileSystemName($attachment->name) + ); } else { $fileSysName = \bin2hex(\random_bytes(16)).'.bin'; } $filePath = $attachmentsDir.DIRECTORY_SEPARATOR.$fileSysName; - if (\strlen($filePath) > self::MAX_LENGTH_FILEPATH) { - $ext = \pathinfo($filePath, PATHINFO_EXTENSION); - $filePath = \substr($filePath, 0, self::MAX_LENGTH_FILEPATH - 1 - \strlen($ext)).'.'.$ext; - } - $attachment->setFilePath($filePath); $attachment->saveToDisk(); } @@ -1786,6 +1824,56 @@ protected function sanitizeAttachmentFileSystemName(string $fileName): string ]); } + protected function resolveAttachmentFileSystemName(string $attachmentsDir, string $fileSystemName): string + { + if (self::ATTACHMENT_FILENAME_COLLISION_SUFFIX !== $this->getAttachmentFilenameCollisionMode()) { + return $this->fitAttachmentFileSystemNameToPathLimit($attachmentsDir, $fileSystemName); + } + + $collisionIndex = 0; + + do { + $suffix = 0 === $collisionIndex ? '' : ' ('.$collisionIndex.')'; + $candidate = $this->fitAttachmentFileSystemNameToPathLimit($attachmentsDir, $fileSystemName, $suffix); + ++$collisionIndex; + } while (\file_exists($attachmentsDir.DIRECTORY_SEPARATOR.$candidate)); + + return $candidate; + } + + protected function fitAttachmentFileSystemNameToPathLimit(string $attachmentsDir, string $fileSystemName, string $suffix = ''): string + { + [$fileName, $fileExtension] = $this->splitAttachmentFileSystemName($fileSystemName); + + $maxFileNameLength = self::MAX_LENGTH_FILEPATH + - \strlen($attachmentsDir.DIRECTORY_SEPARATOR) + - \strlen($suffix) + - \strlen($fileExtension); + + if (\strlen($fileName) > $maxFileNameLength) { + $fileName = \substr($fileName, 0, \max(1, $maxFileNameLength)); + } + + return $fileName.$suffix.$fileExtension; + } + + /** + * @return array{0:string, 1:string} + */ + protected function splitAttachmentFileSystemName(string $fileSystemName): array + { + $lastDotPosition = \strrpos($fileSystemName, '.'); + + if (false === $lastDotPosition || 0 === $lastDotPosition) { + return [$fileSystemName, '']; + } + + return [ + \substr($fileSystemName, 0, $lastDotPosition), + \substr($fileSystemName, $lastDotPosition), + ]; + } + /** * Returns the list of available encodings in lower case. * diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 1ff851b5..1fbda40c 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.php @@ -464,6 +464,39 @@ public function testDownloadAttachmentTreatsMixedCaseRfc822DispositionAsEmlAttac $this->assertFalse($attachment->emlOrigin); } + /** + * @return array + */ + public function attachmentFilenameCollisionModeProvider(): array + { + return [ + 'overwrite' => [Mailbox::ATTACHMENT_FILENAME_COLLISION_OVERWRITE], + 'suffix' => [Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX], + ]; + } + + /** + * @dataProvider attachmentFilenameCollisionModeProvider + */ + public function testSetAndGetAttachmentFilenameCollisionMode(int $attachmentFilenameCollisionMode): void + { + $mailbox = $this->getMailbox(); + + $mailbox->setAttachmentFilenameCollisionMode($attachmentFilenameCollisionMode); + + $this->assertSame($attachmentFilenameCollisionMode, $mailbox->getAttachmentFilenameCollisionMode()); + } + + public function testSetAttachmentFilenameCollisionModeRejectsUnsupportedValue(): void + { + $mailbox = $this->getMailbox(); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('"3" is not supported by setAttachmentFilenameCollisionMode(). Supported modes are ATTACHMENT_FILENAME_COLLISION_OVERWRITE and ATTACHMENT_FILENAME_COLLISION_SUFFIX.'); + + $mailbox->setAttachmentFilenameCollisionMode(3); + } + /** * @return array */ @@ -483,23 +516,9 @@ public function testDownloadAttachmentSanitizesFilePathWhenUsingOriginalFilename $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); \mkdir($attachmentsDir); - $mailbox = new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { - public function decodeMimeStr(string $string): string - { - return $string; - } - }; - $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); - $dataInfo->setData('attachment body'); - $partStructure = (object) [ - 'type' => 3, - 'subtype' => 'OCTET-STREAM', - 'bytes' => 15, - 'encoding' => 0, - 'ifid' => 0, - 'ifsubtype' => 1, - 'ifdescription' => 0, - ]; + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); $attachmentPath = null; try { @@ -520,6 +539,70 @@ public function decodeMimeStr(string $string): string } } + public function testDownloadAttachmentOverwritesExistingFileByDefaultWhenUsingOriginalFilenameMode(): void + { + $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + \mkdir($attachmentsDir); + + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); + $existingPath = $attachmentsDir.DIRECTORY_SEPARATOR.'report.txt'; + + \file_put_contents($existingPath, 'existing body'); + + try { + $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => 'report.txt'], $partStructure); + + $this->assertSame($existingPath, $attachment->filePath); + $this->assertSame('attachment body', \file_get_contents($existingPath)); + } finally { + if (\file_exists($existingPath)) { + \unlink($existingPath); + } + + if (\is_dir($attachmentsDir)) { + \rmdir($attachmentsDir); + } + } + } + + public function testDownloadAttachmentAddsSuffixWhenConfiguredToAvoidFilenameCollisions(): void + { + $attachmentsDir = \sys_get_temp_dir().DIRECTORY_SEPARATOR.'php-imap-attachment-name-'.\bin2hex(\random_bytes(8)); + \mkdir($attachmentsDir); + + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $mailbox->setAttachmentFilenameCollisionMode(Mailbox::ATTACHMENT_FILENAME_COLLISION_SUFFIX); + + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); + $existingPath = $attachmentsDir.DIRECTORY_SEPARATOR.'foo_bar.txt'; + $expectedPath = $attachmentsDir.DIRECTORY_SEPARATOR.'foo_bar (1).txt'; + + \file_put_contents($existingPath, 'existing body'); + + try { + $attachment = $mailbox->downloadAttachment($dataInfo, ['filename' => 'foo/bar.txt'], $partStructure); + + $this->assertSame($expectedPath, $attachment->filePath); + $this->assertSame('existing body', \file_get_contents($existingPath)); + $this->assertSame('attachment body', \file_get_contents($expectedPath)); + } finally { + if (\file_exists($expectedPath)) { + \unlink($expectedPath); + } + + if (\file_exists($existingPath)) { + \unlink($existingPath); + } + + if (\is_dir($attachmentsDir)) { + \rmdir($attachmentsDir); + } + } + } + /** * Provides test data for testing encoding. * @@ -938,4 +1021,35 @@ protected function getMailbox(): Fixtures\Mailbox { return new Fixtures\Mailbox($this->imapPath, $this->login, $this->password, $this->attachmentsDir, $this->serverEncoding); } + + protected function getAttachmentDownloadMailbox(string $attachmentsDir): Fixtures\Mailbox + { + return new class($this->imapPath, $this->login, $this->password, $attachmentsDir, $this->serverEncoding, true, true) extends Fixtures\Mailbox { + public function decodeMimeStr(string $string): string + { + return $string; + } + }; + } + + protected function getAttachmentDownloadDataInfo(Fixtures\Mailbox $mailbox): Fixtures\DataPartInfo + { + $dataInfo = new Fixtures\DataPartInfo($mailbox, 1, '2', 0, 0); + $dataInfo->setData('attachment body'); + + return $dataInfo; + } + + protected function getAttachmentDownloadPartStructure(): object + { + return (object) [ + 'type' => 3, + 'subtype' => 'OCTET-STREAM', + 'bytes' => 15, + 'encoding' => 0, + 'ifid' => 0, + 'ifsubtype' => 1, + 'ifdescription' => 0, + ]; + } } From 329d63162ac28d454f85bc016be0db511539b32b Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 19:42:41 +0200 Subject: [PATCH 14/16] Fix incorrect IMAP-style sequence-set element handling `src/PhpImap/Imap.php:1149` now accepts proper IMAP-style sequence-set elements with `*` when `allow_sequence` is enabled, so cases like `*`, `1:*`, `*:5`, and mixed lists like `2,4:7,9,12:*` pass validation. --- src/PhpImap/Imap.php | 31 +++++++--- tests/unit/ImapSequenceSetTest.php | 94 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 tests/unit/ImapSequenceSetTest.php diff --git a/src/PhpImap/Imap.php b/src/PhpImap/Imap.php index e10e7cf4..80e4d3e9 100644 --- a/src/PhpImap/Imap.php +++ b/src/PhpImap/Imap.php @@ -1156,20 +1156,35 @@ private static function EnsureRange( throw new InvalidArgumentException('Argument 1 passed to '.__METHOD__.'() must be an integer or a string!'); } - $regex = '/^\d+:\d+$/'; - $suffix = '() did not appear to be a valid message id range!'; + if (\is_int($msg_number) || \preg_match('/^\d+$/', $msg_number)) { + return \sprintf('%1$s:%1$s', $msg_number); + } if ($allow_sequence) { - $regex = '/^\d+(?:(?:,\d+)+|:\d+)$/'; - $suffix = '() did not appear to be a valid message id range or sequence!'; + if (!self::IsValidSequenceSet($msg_number)) { + throw new InvalidArgumentException('Argument '.(string) $argument.' passed to '.$method.'() did not appear to be a valid message id range or sequence!'); + } + + return $msg_number; } - if (\is_int($msg_number) || \preg_match('/^\d+$/', $msg_number)) { - return \sprintf('%1$s:%1$s', $msg_number); - } elseif (1 !== \preg_match($regex, $msg_number)) { - throw new InvalidArgumentException('Argument '.(string) $argument.' passed to '.$method.$suffix); + if (1 !== \preg_match('/^\d+:\d+$/', $msg_number)) { + throw new InvalidArgumentException('Argument '.(string) $argument.' passed to '.$method.'() did not appear to be a valid message id range!'); } return $msg_number; } + + /** + * RFC 3501 sequence-set elements are message numbers or "*", optionally as ranges, comma-separated. + * + * @psalm-pure + */ + private static function IsValidSequenceSet(string $msg_number): bool + { + return 1 === \preg_match( + '/^(?:\*|\d+)(?::(?:\*|\d+))?(?:,(?:\*|\d+)(?::(?:\*|\d+))?)*$/', + $msg_number + ); + } } diff --git a/tests/unit/ImapSequenceSetTest.php b/tests/unit/ImapSequenceSetTest.php new file mode 100644 index 00000000..51a258cb --- /dev/null +++ b/tests/unit/ImapSequenceSetTest.php @@ -0,0 +1,94 @@ + + */ + public function validSequenceSetProvider(): array + { + return [ + 'wildcard only' => ['*'], + 'numeric range' => ['1:5'], + 'range ending with wildcard' => ['1:*'], + 'range starting with wildcard' => ['*:5'], + 'wildcard range' => ['*:*'], + 'comma separated ids' => ['4,5,6'], + 'comma separated mixed sequence set' => ['2,4:7,9,12:*'], + 'wildcard as comma item' => ['1,*'], + 'wildcard range in sequence set' => ['1,3:*,5'], + ]; + } + + /** + * @dataProvider validSequenceSetProvider + */ + public function testEnsureRangeAcceptsValidSequenceSetsWhenAllowed(string $msgNumber): void + { + $this->assertSame($msgNumber, $this->ensureRangeForTests($msgNumber, true)); + } + + public function testEnsureRangeNormalizesSingleMessageIdsWhenSequenceSetsAreAllowed(): void + { + $this->assertSame('123:123', $this->ensureRangeForTests('123', true)); + } + + /** + * @return array + */ + public function invalidSequenceSetProvider(): array + { + return [ + 'empty string' => [''], + 'leading comma' => [',1:5'], + 'trailing comma' => ['1:5,'], + 'double comma' => ['1,,5'], + 'double colon' => ['1::5'], + 'non numeric token' => ['foo'], + 'wildcard in malformed position' => ['2,4:7,9,12:**'], + ]; + } + + /** + * @dataProvider invalidSequenceSetProvider + */ + public function testEnsureRangeRejectsInvalidSequenceSetsWhenAllowed(string $msgNumber): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('did not appear to be a valid message id range or sequence'); + + $this->ensureRangeForTests($msgNumber, true); + } + + public function testEnsureRangeRejectsWildcardsWhenOnlyRangesAreAllowed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('did not appear to be a valid message id range'); + + $this->ensureRangeForTests('1:*'); + } + + private function ensureRangeForTests(int|string $msgNumber, bool $allowSequence = false): string + { + $ensureRange = \Closure::bind( + static function (int|string $msgNumber, bool $allowSequence): string { + return Imap::EnsureRange($msgNumber, __METHOD__, 1, $allowSequence); + }, + null, + Imap::class + ); + + if (!$ensureRange instanceof \Closure) { + throw new \RuntimeException('Could not bind EnsureRange() test helper.'); + } + + return $ensureRange($msgNumber, $allowSequence); + } +} From b5a868080f221bfc42048c3a4221429b55a83bf0 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 20:20:02 +0200 Subject: [PATCH 15/16] Fix boolean received error in getFileInfo() The change is in `src/PhpImap/IncomingMailAttachment.php:132: getFileInfo()` now routes the finfo lookup through a small helper and returns `''` if detection fails instead of propagating false into a string return type. I also added `tests/unit/IncomingMailAttachmentTest.php:1` with one regression test for the failure branch and one sanity check for the normal branch. --- src/PhpImap/IncomingMailAttachment.php | 16 +++++++- tests/unit/IncomingMailAttachmentTest.php | 46 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/unit/IncomingMailAttachmentTest.php diff --git a/src/PhpImap/IncomingMailAttachment.php b/src/PhpImap/IncomingMailAttachment.php index f8bbc50c..5f871b07 100644 --- a/src/PhpImap/IncomingMailAttachment.php +++ b/src/PhpImap/IncomingMailAttachment.php @@ -130,10 +130,24 @@ public function addDataPartInfo(DataPartInfo $dataInfo): void * @psalm-param fileinfoconst $fileinfo_const */ public function getFileInfo(int $fileinfo_const = FILEINFO_NONE): string + { + $fileInfo = $this->detectFileInfo($fileinfo_const, $this->getContents()); + + if (false === $fileInfo) { + return ''; + } + + return $fileInfo; + } + + /** + * @return false|string + */ + protected function detectFileInfo(int $fileinfo_const, string $contents) { $finfo = new finfo($fileinfo_const); - return $finfo->buffer($this->getContents()); + return $finfo->buffer($contents); } /** diff --git a/tests/unit/IncomingMailAttachmentTest.php b/tests/unit/IncomingMailAttachmentTest.php new file mode 100644 index 00000000..620c8e4d --- /dev/null +++ b/tests/unit/IncomingMailAttachmentTest.php @@ -0,0 +1,46 @@ +assertSame('', $attachment->getFileInfo(FILEINFO_MIME_TYPE)); + } + + public function testGetFileInfoReturnsDetectedStringWhenFinfoBufferSucceeds(): void + { + $attachment = new class() extends IncomingMailAttachment { + public function getContents(): string + { + return 'png-contents'; + } + + protected function detectFileInfo(int $fileinfo_const, string $contents) + { + return 'image/png'; + } + }; + + $this->assertSame('image/png', $attachment->getFileInfo(FILEINFO_MIME_TYPE)); + } +} From c8c57d69b0ac00544b8ca54a2894ea1bd4deeb76 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 20:49:17 +0200 Subject: [PATCH 16/16] Issue #700: Automatically expose all custom headers `IncomingMailHeader` now parses the raw header block into a generic `headersByName` map and exposes case-insensitive lookup helpers: `setHeadersRaw()`, `getHeader()`, `getHeaders()`, and `getAllHeaders()` in `src/PhpImap/IncomingMailHeader.php:47`. `Mailbox::getMailHeader()` now always populates that map via `setHeadersRaw()`, so every fetched mail/header gets arbitrary custom headers automatically in `src/PhpImap/Mailbox.php:1292`. The stored keys are normalized to lowercase, so `getHeader('Origin-MessageID')` and `getHeader('origin-messageid')` behave the same. --- README.md | 4 + src/PhpImap/IncomingMailHeader.php | 137 ++++++++++++++++++++++++++ src/PhpImap/Mailbox.php | 2 +- tests/unit/IncomingMailHeaderTest.php | 69 +++++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/unit/IncomingMailHeaderTest.php diff --git a/README.md b/README.md index 99aaf4de..19e05425 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ if($mail->hasAttachments()) { // Print all information of $mail print_r($mail); +// Access arbitrary headers without adding custom properties to the library +$originMessageId = $mail->getHeader('Origin-MessageID'); +$receivedHeaders = $mail->getHeaders('Received'); + // Print all attachements of $mail echo "\n\nAttachments:\n"; print_r($mail->getAttachments()); diff --git a/src/PhpImap/IncomingMailHeader.php b/src/PhpImap/IncomingMailHeader.php index 7e522b6f..8351a2d8 100644 --- a/src/PhpImap/IncomingMailHeader.php +++ b/src/PhpImap/IncomingMailHeader.php @@ -44,6 +44,13 @@ class IncomingMailHeader /** @var string|null */ public $headersRaw; + /** + * @var string[][] + * + * @psalm-var array> + */ + public $headersByName = []; + /** @var object|null */ public $headers; @@ -152,4 +159,134 @@ class IncomingMailHeader /** @var string|null */ public $references; + + public function setHeadersRaw(string $headersRaw): void + { + $this->headersRaw = $headersRaw; + $this->headersByName = $this->parseHeadersRaw($headersRaw); + } + + public function getHeader(string $headerName): ?string + { + $headers = $this->getHeaders($headerName); + + if ([] === $headers) { + return null; + } + + return $headers[0]; + } + + /** + * @return string[] + * + * @psalm-return list + */ + public function getHeaders(string $headerName): array + { + $this->ensureParsedHeadersByName(); + + return $this->headersByName[$this->normalizeHeaderName($headerName)] ?? []; + } + + /** + * @return string[][] + * + * @psalm-return array> + */ + public function getAllHeaders(): array + { + $this->ensureParsedHeadersByName(); + + return $this->headersByName; + } + + protected function ensureParsedHeadersByName(): void + { + if ([] !== $this->headersByName || null === $this->headersRaw) { + return; + } + + $this->headersByName = $this->parseHeadersRaw($this->headersRaw); + } + + /** + * @return string[][] + * + * @psalm-return array> + */ + protected function parseHeadersRaw(string $headersRaw): array + { + $parsedHeaders = []; + $currentHeaderName = null; + $currentHeaderValue = ''; + + foreach ($this->splitHeaderLines($headersRaw) as $line) { + if ('' === $line) { + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + $currentHeaderName = null; + $currentHeaderValue = ''; + + continue; + } + + if (null !== $currentHeaderName && 1 === \preg_match('/^[ \t]/', $line)) { + $currentHeaderValue .= ' '.\ltrim($line); + + continue; + } + + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + + $separatorPosition = \strpos($line, ':'); + + if (false === $separatorPosition) { + $currentHeaderName = null; + $currentHeaderValue = ''; + + continue; + } + + $currentHeaderName = \substr($line, 0, $separatorPosition); + $currentHeaderValue = \trim(\substr($line, $separatorPosition + 1)); + } + + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + + return $parsedHeaders; + } + + /** + * @return string[] + * + * @psalm-return list + */ + protected function splitHeaderLines(string $headersRaw): array + { + /** @var list */ + return \explode("\n", \str_replace(["\r\n", "\r"], "\n", $headersRaw)); + } + + /** + * @param string[][] $parsedHeaders + * @param string|null $headerName + * + * @psalm-param array> $parsedHeaders + */ + protected function storeParsedHeader(array &$parsedHeaders, ?string $headerName, string $headerValue): void + { + if (null === $headerName || '' === \trim($headerName)) { + return; + } + + $headerName = $this->normalizeHeaderName($headerName); + + $parsedHeaders[$headerName] ??= []; + $parsedHeaders[$headerName][] = \trim($headerValue); + } + + protected function normalizeHeaderName(string $headerName): string + { + return \strtolower(\trim($headerName)); + } } diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index b621aa92..d6e2514c 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1290,7 +1290,7 @@ public function getMailHeader(int $mailId): IncomingMailHeader } $header = new IncomingMailHeader(); - $header->headersRaw = $headersRaw; + $header->setHeadersRaw($headersRaw); $header->headers = $head; $header->id = $mailId; $header->imapPath = $this->imapPath; diff --git a/tests/unit/IncomingMailHeaderTest.php b/tests/unit/IncomingMailHeaderTest.php new file mode 100644 index 00000000..d81001ab --- /dev/null +++ b/tests/unit/IncomingMailHeaderTest.php @@ -0,0 +1,69 @@ +setHeadersRaw( + "Origin-MessageID: \r\n". + "X-Trace: first\r\n". + "\tcontinued\r\n". + "x-trace: second\r\n". + "Subject: Example\r\n" + ); + + $this->assertSame('', $header->getHeader('origin-messageid')); + $this->assertSame( + ['first continued', 'second'], + $header->getHeaders('X-TRACE') + ); + $this->assertSame( + [ + 'origin-messageid' => [''], + 'x-trace' => ['first continued', 'second'], + 'subject' => ['Example'], + ], + $header->getAllHeaders() + ); + } + + public function testGetHeaderLazilyParsesDirectlyAssignedHeadersRaw(): void + { + $header = new IncomingMailHeader(); + $header->headersRaw = "X-Custom-Header: custom value\r\n"; + + $this->assertSame('custom value', $header->getHeader('x-custom-header')); + $this->assertSame( + ['custom value'], + $header->headersByName['x-custom-header'] + ); + $this->assertNull($header->getHeader('missing-header')); + $this->assertSame([], $header->getHeaders('missing-header')); + } + + public function testIncomingMailRetainsParsedHeadersAfterSetHeader(): void + { + $header = new IncomingMailHeader(); + $header->setHeadersRaw( + "X-Custom-Header: custom value\r\n". + "Received: mx1.example.test\r\n". + "Received: mx2.example.test\r\n" + ); + + $mail = new IncomingMail(); + $mail->setHeader($header); + + $this->assertSame('custom value', $mail->getHeader('X-Custom-Header')); + $this->assertSame( + ['mx1.example.test', 'mx2.example.test'], + $mail->getHeaders('received') + ); + } +}