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/.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/README.md b/README.md index 5be0da1c..19e05425 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 @@ -79,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 @@ -93,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 @@ -118,6 +125,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 @@ -134,11 +144,17 @@ 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()); ``` +`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/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/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..80e4d3e9 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 @@ -906,8 +913,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 @@ -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); @@ -1053,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'); @@ -1083,10 +1095,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 +1114,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,26 +1150,41 @@ 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!'); } - $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/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..5f871b07 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; @@ -129,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/src/PhpImap/IncomingMailHeader.php b/src/PhpImap/IncomingMailHeader.php index 908c4f3c..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; @@ -146,4 +153,140 @@ class IncomingMailHeader /** @var string|null */ public $messageId; + + /** @var string|null */ + public $inReplyTo; + + /** @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 9ceaf59b..d6e2514c 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; @@ -33,19 +41,22 @@ use const OP_SECURE; 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 +65,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,18 +103,62 @@ 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 - | OP_HALFOPEN // 64 - | CL_EXPUNGE // 32768 - | OP_DEBUG // 1 - | OP_SHORTCACHE // 8 - | OP_SILENT // 16 - | OP_PROTOTYPE // 32 - | OP_SECURE // 256 + | 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', + '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', + ]; + /** @var string */ public $decodeMimeStrDefaultCharset = 'default'; @@ -121,7 +175,7 @@ class Mailbox protected $authenticationType = self::AUTHENTICATION_TYPE_PASSWORD; /** @var string|null */ - protected $imapOAuthToken = null; + protected $imapOAuthToken; /** @var int */ protected $imapSearchOption = SE_UID; @@ -145,7 +199,7 @@ class Mailbox protected $serverEncoding = 'UTF-8'; /** @var string|null */ - protected $attachmentsDir = null; + protected $attachmentsDir; /** @var bool */ protected $expungeOnDisconnect = true; @@ -166,16 +220,19 @@ 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; /** * @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); @@ -299,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. * @@ -423,7 +515,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) { @@ -531,7 +623,7 @@ public function hasImapStream(): bool */ public function encodeStringToUtf7Imap(string $str): string { - return imap_utf7_encode($str); + return Imap::encodeStringToUtf7Imap($str); } /** @@ -543,13 +635,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); } /** @@ -688,13 +774,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); } /** @@ -883,9 +969,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 { @@ -1053,7 +1139,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(), @@ -1131,15 +1217,16 @@ 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 ''; } /** @@ -1163,6 +1250,7 @@ public function getMailHeader(int $mailId): IncomingMailHeader * date?:scalar, * Date?:scalar, * subject?:scalar, + * message_id?:scalar, * from?:HOSTNAMEANDADDRESS, * to?:HOSTNAMEANDADDRESS, * cc?:HOSTNAMEANDADDRESS, @@ -1202,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; @@ -1290,12 +1378,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; } @@ -1321,7 +1407,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 { @@ -1380,7 +1466,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'; @@ -1445,18 +1531,16 @@ public function downloadAttachment(DataPartInfo $dataInfo, array $params, object if (null != $attachmentsDir) { if (true == $this->getAttachmentFilenameMode()) { - $fileSysName = $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(); } @@ -1540,10 +1624,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; } /** @@ -1667,13 +1752,13 @@ 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) && - 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]); } @@ -1691,6 +1776,104 @@ 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, [ + '\\' => '_', + '/' => '_', + ]); + } + + 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. * @@ -1739,9 +1922,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() { @@ -1836,6 +2019,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 @@ -1877,15 +2061,13 @@ 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 ( - !$partNum && - TYPETEXT === $partStructure->type && - !$dispositionAttachment + !$partNum + && TYPETEXT === $partStructure->type + && !$dispositionAttachment ) { $isAttachment = false; } @@ -1895,9 +2077,9 @@ 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 + // incase someone wants to process or parse in another process $attachment = self::downloadAttachment($dataInfo, $params, $partStructure, false); $mail->addAttachment($attachment); } @@ -1927,15 +2109,15 @@ 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) { - //If it comes from am EML attachment, download each part separately as a file + } 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 { $this->initMailPart($mail, $subPartStructure, $partNum.'.'.($subPartNum + 1), $markAsSeen); @@ -1966,9 +2148,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; @@ -2005,9 +2186,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 { @@ -2028,7 +2209,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 [ @@ -2106,7 +2287,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; @@ -2143,7 +2324,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 ))); @@ -2154,6 +2335,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/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/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/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 cf55aa45..981d8f34 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -8,6 +8,42 @@ class Mailbox extends Base { + public function decodeRFC2231ForTests(string $string): string + { + return $this->decodeRFC2231($string); + } + + public function getMailHeaderFieldValueForTests(string $headersRaw, string $headerFieldName): string + { + return $this->getMailHeaderFieldValue($headersRaw, $headerFieldName); + } + + /** + * @return (string|null)[]|null + */ + public function possiblyGetEmailAndNameFromRecipientForTests(object $recipient): ?array + { + 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 + * + * @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; @@ -32,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/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); + } +} 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/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)); + } +} 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') + ); + } +} 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 5025a4ab..2a91a596 100644 --- a/tests/unit/LiveMailboxStringDecodingConvertingTest.php +++ b/tests/unit/LiveMailboxStringDecodingConvertingTest.php @@ -1,4 +1,5 @@ [ + 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/LiveMailboxTest.php b/tests/unit/LiveMailboxTest.php index f27de94a..0d794ddb 100644 --- a/tests/unit/LiveMailboxTest.php +++ b/tests/unit/LiveMailboxTest.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 new file mode 100644 index 00000000..39884990 --- /dev/null +++ b/tests/unit/MailboxAddressParsingTest.php @@ -0,0 +1,104 @@ +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 + ); + } +} diff --git a/tests/unit/MailboxEncodingTest.php b/tests/unit/MailboxEncodingTest.php new file mode 100644 index 00000000..f645ba83 --- /dev/null +++ b/tests/unit/MailboxEncodingTest.php @@ -0,0 +1,119 @@ + + */ + 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], + '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], + ]; + } + + /** + * @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)); + } + + 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) + ); + } +} diff --git a/tests/unit/MailboxHeaderParsingTest.php b/tests/unit/MailboxHeaderParsingTest.php new file mode 100644 index 00000000..356eb268 --- /dev/null +++ b/tests/unit/MailboxHeaderParsingTest.php @@ -0,0 +1,70 @@ +\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) + ); + } +} diff --git a/tests/unit/MailboxOAuthTest.php b/tests/unit/MailboxOAuthTest.php index acc5e529..8d938211 100644 --- a/tests/unit/MailboxOAuthTest.php +++ b/tests/unit/MailboxOAuthTest.php @@ -89,9 +89,9 @@ public function testEnableOAuthAddsXoauth2FlagWhenRuntimeSupportsIt(): void $this->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 new file mode 100644 index 00000000..ca50647d --- /dev/null +++ b/tests/unit/MailboxSearchTest.php @@ -0,0 +1,153 @@ +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; + } + }; + } +} diff --git a/tests/unit/MailboxTest.php b/tests/unit/MailboxTest.php index 507ff596..1fbda40c 100644 --- a/tests/unit/MailboxTest.php +++ b/tests/unit/MailboxTest.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]; } @@ -210,6 +218,36 @@ 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_map('strval', \array_keys($flattenedParts)) + ); + $this->assertArrayNotHasKey('1.0', $flattenedParts); + } + /** * Test, that the IMAP search option has a default value * 1 => SE_UID @@ -258,9 +296,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 { @@ -393,12 +431,184 @@ 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); + } + + /** + * @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 + */ + 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)); + \mkdir($attachmentsDir); + + $mailbox = $this->getAttachmentDownloadMailbox($attachmentsDir); + $dataInfo = $this->getAttachmentDownloadDataInfo($mailbox); + $partStructure = $this->getAttachmentDownloadPartStructure(); + $attachmentPath = null; + + 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); + } + } + } + + 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. * - * @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 { @@ -464,9 +674,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 { @@ -503,9 +713,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 { @@ -678,7 +888,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(); @@ -696,9 +906,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 { @@ -733,9 +943,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 { @@ -760,9 +970,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 { @@ -811,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, + ]; + } }