diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..c49bc6e3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +ko_fi: webklex +custom: ['https://www.buymeacoffee.com/webklex'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8fc2190b..a0571c33 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,12 +7,14 @@ about: Create a report to help us improve **Describe the bug** A clear and concise description of what the bug is. -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error +**Used config** +Please provide the used config, if you are not using the package default config. + +**Code to Reproduce** +The troubling code section which produces the reported bug. +```php +echo "Bug"; +``` **Expected behavior** A clear and concise description of what you expected to happen. @@ -21,9 +23,10 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop / Server (please complete the following information):** - - OS: [e.g. iOS] +- OS: [e.g. Debian 10] - PHP: [e.g. 5.5.9] - - Version [e.g. 22] +- Version [e.g. v2.3.1] +- Provider [e.g. Gmail, Outlook, Dovecot] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/general-help-request.md b/.github/ISSUE_TEMPLATE/general-help-request.md index 6543a1c2..49809d14 100644 --- a/.github/ISSUE_TEMPLATE/general-help-request.md +++ b/.github/ISSUE_TEMPLATE/general-help-request.md @@ -4,4 +4,9 @@ about: Feel free to ask about any project related stuff --- -Please be aware that these issues will be closed if inactive for more then 14 days :) +Please be aware that these issues will be closed if inactive for more then 14 days. + +Also make sure to use https://github.com/Webklex/php-imap/issues/new?template=bug_report.md if you want to report a bug +or https://github.com/Webklex/php-imap/issues/new?template=feature_request.md if you want to suggest a feature. + +Still here? Well clean this out and go ahead :) diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile new file mode 100644 index 00000000..ff0f11e6 --- /dev/null +++ b/.github/docker/Dockerfile @@ -0,0 +1,23 @@ +FROM ubuntu:latest +LABEL maintainer="Webklex " + +RUN apt-get update +RUN apt-get upgrade -y +RUN apt-get install -y sudo dovecot-imapd + +ENV LIVE_MAILBOX=true +ENV LIVE_MAILBOX_HOST=mail.example.local +ENV LIVE_MAILBOX_PORT=993 +ENV LIVE_MAILBOX_ENCRYPTION=ssl +ENV LIVE_MAILBOX_VALIDATE_CERT=true +ENV LIVE_MAILBOX_USERNAME=root@example.local +ENV LIVE_MAILBOX_PASSWORD=foobar +ENV LIVE_MAILBOX_QUOTA_SUPPORT=true + +EXPOSE 993 + +ADD dovecot_setup.sh /root/dovecot_setup.sh +RUN chmod +x /root/dovecot_setup.sh + +CMD ["/bin/bash", "-c", "/root/dovecot_setup.sh && tail -f /dev/null"] + diff --git a/.github/docker/dovecot_setup.sh b/.github/docker/dovecot_setup.sh new file mode 100644 index 00000000..25a51928 --- /dev/null +++ b/.github/docker/dovecot_setup.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +set -ex + +sudo apt-get -q update +sudo apt-get -q -y install dovecot-imapd + +{ + echo "127.0.0.1 $LIVE_MAILBOX_HOST" +} | sudo tee -a /etc/hosts + +SSL_CERT="/etc/ssl/certs/dovecot.crt" +SSL_KEY="/etc/ssl/private/dovecot.key" + +sudo openssl req -new -x509 -days 3 -nodes \ + -out "$SSL_CERT" \ + -keyout "$SSL_KEY" \ + -subj "/C=EU/ST=Europe/L=Home/O=Webklex/OU=Webklex DEV/CN=""$LIVE_MAILBOX_HOST" + +sudo chown root:dovecot "$SSL_CERT" "$SSL_KEY" +sudo chmod 0440 "$SSL_CERT" +sudo chmod 0400 "$SSL_KEY" + +DOVECOT_CONF="/etc/dovecot/local.conf" +MAIL_CONF="/etc/dovecot/conf.d/10-mail.conf" +IMAP_CONF="/etc/dovecot/conf.d/20-imap.conf" +QUOTA_CONF="/etc/dovecot/conf.d/90-quota.conf" +sudo touch "$DOVECOT_CONF" "$MAIL_CONF" "$IMAP_CONF" "$QUOTA_CONF" +sudo chown root:dovecot "$DOVECOT_CONF" "$MAIL_CONF" "$IMAP_CONF" "$QUOTA_CONF" +sudo chmod 0640 "$DOVECOT_CONF" "$MAIL_CONF" "$IMAP_CONF" "$QUOTA_CONF" + +{ + echo "ssl = required" + echo "disable_plaintext_auth = yes" + echo "ssl_cert = <""$SSL_CERT" + echo "ssl_key = <""$SSL_KEY" + echo "ssl_protocols = !SSLv2 !SSLv3" + echo "ssl_cipher_list = AES128+EECDH:AES128+EDH" +} | sudo tee -a "$DOVECOT_CONF" + +{ + echo "mail_plugins = \$mail_plugins quota" +} | sudo tee -a "$MAIL_CONF" + +{ + echo "protocol imap {" + echo " mail_plugins = \$mail_plugins imap_quota" + echo "}" +} | sudo tee -a "$IMAP_CONF" + +{ + echo "plugin {" + echo " quota = maildir:User quota" + echo " quota_rule = *:storage=1G" + echo "}" +} | sudo tee -a "$QUOTA_CONF" + +sudo useradd --create-home --shell /bin/false "$LIVE_MAILBOX_USERNAME" +echo "$LIVE_MAILBOX_USERNAME"":""$LIVE_MAILBOX_PASSWORD" | sudo chpasswd + +sudo service dovecot restart + +sudo doveadm auth test -x service=imap "$LIVE_MAILBOX_USERNAME" "$LIVE_MAILBOX_PASSWORD" \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..682cbd51 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,50 @@ +name: Tests + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +permissions: + contents: read + +jobs: + phpunit: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: ['8.0', 8.1, 8.2, 8.3, 8.4] + + name: PHP ${{ matrix.php }} + + env: + LIVE_MAILBOX: true + LIVE_MAILBOX_DEBUG: true + LIVE_MAILBOX_HOST: mail.example.local + LIVE_MAILBOX_PORT: 993 + LIVE_MAILBOX_USERNAME: root@example.local + LIVE_MAILBOX_ENCRYPTION: ssl + LIVE_MAILBOX_PASSWORD: foobar + LIVE_MAILBOX_QUOTA_SUPPORT: true + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: openssl, json, mbstring, iconv, fileinfo, libxml, zip + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - run: "sh .github/docker/dovecot_setup.sh" + + - name: Execute tests + run: vendor/bin/phpunit \ No newline at end of file diff --git a/.gitignore b/.gitignore index 116f35f2..d1816ad9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ vendor composer.lock -.idea \ No newline at end of file +.idea +/build/ +test.php +.phpunit.result.cache +phpunit.xml \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 07213f9e..00000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: php - -php: - - 5.5 - - 5.6 - - 7.0 - - 7.1 - - 7.2 - -matrix: - fast_finish: true - -sudo: false - -install: composer install --no-interaction - -notifications: - email: - on_success: always - on_failure: always - diff --git a/CHANGELOG.md b/CHANGELOG.md index 8723410b..438ee606 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,864 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip ## [UNRELEASED] ### Fixed -- Point to root namespace if handling native functions #279 +- Reading whole lines from stream #576 (thanks @wurst-hans) ### Added - NaN +### Breaking changes +- NaN + +## [6.2.0] - 2025-04-25 +### Fixed +- When using the chunk function, some messages do not have an element with index 0 #552 #553 (thanks @zeddmaster) +- Get folders list in hierarchical order #560 #561 (thanks @rskrzypczak) +- Fix remaining implicit marking of parameters as nullable (PHP 8.4) #566 (thanks @steffenweber) +- Fix case sensitivity of folder attribute parsing (\NoSelect, \NoInferiors) #469 #571 (thanks @smajti1) +- Fix error on getUid(null) with 0 results (#499) #573 (thanks @pierement) +- Fix Date parsing on non-standard format from Aqua Mail #574 #575 (thanks @lm-cmxkonzepte) + +### Added +- SSL stream context options added #238 #546 (thanks @llemoine) +- Support copy/move Message with utf7 folder path #559 (thanks @loc4l) +- Public `Query::search()` method #565 (Thanks @madbob) + +## [6.1.0] - 2025-01-19 +### Fixed +- Filename sanitization is now optional (enabled via default) +- Address parsing improved and extended to include more cases +- Boundary parsing fixed and improved to support more formats #544 +- Decode partially encoded address names #511 +- Enforce RFC822 parsing if enabled #462 + +### Added +- Security configuration options added +- Spoofing detection added #40 +- RFC4315 MOVE fallback added #123 (thanks @freescout-help-desk) +- Content fetching RFC standard support added #510 (thanks @ybizeul) +- Support unescaped dates inside the search conditions #542 +- `Client::clone()` looses account configuration #521 (thanks @netpok) + +## [6.0.0] - 2025-01-17 +### Fixed +- Fixed date issue if timezone is UT and a 2 digit year #429 (thanks @ferrisbuellers) +- Make the space optional after a comma separator #437 (thanks @marc0adam) +- Fix bug when multipart message getHTMLBody() method returns null #455 (thanks @michalkortas) +- Fix: Improve return type hints and return docblocks for query classes #470 (thanks @olliescase) +- Fix - Query - Chunked - Resolved infinite loop when start chunk > 1 #477 (thanks @NeekTheNook) +- Attachment with symbols in filename #436 (thanks @nuernbergerA) +- Ignore possible untagged lines after IDLE and DONE commands #445 (thanks @gazben) +- Fix Empty Child Folder Error #474 (thanks @bierpub) +- Filename sanitization improved #501 (thanks @neolip) +- `Client::getFolderPath()` return null if folder is not set #506 (thanks @arnolem) +- Fix implicit marking of parameters as nullable, deprecated in PHP 8.4 #518 (thanks @campbell-m) + +### Added +- IMAP STATUS command support added `Folder::status()` #424 (thanks @InterLinked1) +- Add attributes and special flags #428 (thanks @sazanof) +- Better connection check for IMAP #449 (thanks @thin-k-design) +- Config handling moved into a new class `Config::class` to allow class serialization (sponsored by elb-BIT GmbH) +- Support for Carbon 3 added #483 +- Custom decoder support added +- Decoding filename with non-standard encoding #535 (thanks @grnsv) + +### Breaking changes +- The decoder config has been moved from `options.decoder` to `decoding` and contains now the `decoder` class to used as well as their decoding fallbacks +- `Folder::getStatus()` no longer returns the results of `EXAMINE` but `STATUS` instead. If you want to use `EXAMINE` you can use the `Folder::examine()` method instead. +- `ClientManager::class` has now longer access to all configs. Config handling has been moved to its own class `Config::class`. If you want to access the config you can use the retriever method `::getConfig()` instead. Example: `$client->getConfig()` or `$message->getConfig()`, etc. +- `ClientManager::get` isn't available anymore. Use the regular config accessor instead. Example: `$cm->getConfig()->get($key)` +- `M̀essage::getConfig()` now returns the client configuration instead of the fetching options configuration. Please use `$message->getOptions()` instead. +- `Attachment::getConfig()` now returns the client configuration instead of the fetching options configuration. Please use `$attachment->getOptions()` instead. +- `Header::getConfig()` now returns the client configuration instead of the fetching options configuration. Please use `$header->getOptions()` instead. +- `M̀essage::setConfig` now expects the client configuration instead of the fetching options configuration. Please use `$message->setOptions` instead. +- `Attachment::setConfig` now expects the client configuration instead of the fetching options configuration. Please use `$attachment->setOptions` instead. +- `Header::setConfig` now expects the client configuration instead of the fetching options configuration. Please use `$header->setOptions` instead. +- All protocol constructors now require a `Config::class` instance +- The `Client::class` constructor now require a `Config::class` instance +- The `Part::class` constructor now require a `Config::class` instance +- The `Header::class` constructor now require a `Config::class` instance +- The `Message::fromFile` method now requires a `Config::class` instance +- The `Message::fromString` method now requires a `Config::class` instance +- The `Message::boot` method now requires a `Config::class` instance +- The `Message::decode` method has been removed. Use `Message::getDecoder()->decode($str)` instead. +- The `Message::getEncoding` method has been removed. Use `Message::getDecoder()->getEncoding($str)` instead. +- The `Message::convertEncoding` method has been removed. Use `Message::getDecoder()->convertEncoding()` instead. +- The `Header::decode` method has been removed. Use `Header::getDecoder()->decode($str)` instead. + +## [5.5.0] - 2023-06-28 +### Fixed +- Error token length mismatch in `ImapProtocol::readResponse` #400 +- Attachment name parsing fixed #410 #421 (thanks @nuernbergerA) +- Additional Attachment name fallback added to prevent missing attachments +- Attachment id is now static (based on the raw part content) instead of random +- Always parse the attachment description if it is available + +### Added +- Attachment content hash added + + +## [5.4.0] - 2023-06-24 +### Fixed +- Legacy protocol support fixed (object to array conversion) #411 +- Header value decoding improved #410 +- Protocol exception handling improved (bad response message added) #408 +- Prevent fetching singular rfc partials from running indefinitely #407 +- Subject with colon ";" is truncated #401 +- Catching and handling iconv decoding exception #397 + +### Added +- Additional timestamp formats added #198 #392 (thanks @esk-ap) + + +## [5.3.0] - Security patch - 2023-06-20 +### Fixed +- Potential RCE through path traversal fixed #414 (special thanks @angelej) + +### Security Impact and Mitigation +Impacted are all versions below v5.3.0. +If possible, update to >= v5.3.0 as soon as possible. Impacted was the `Attachment::save` +method which could be used to write files to the local filesystem. The path was not +properly sanitized and could be used to write files to arbitrary locations. + +However, the `Attachment::save` method is not used by default and has to be called +manually. If you are using this method without providing a sanitized path, you are +affected by this vulnerability. +If you are not using this method or are providing a sanitized path, you are not affected +by this vulnerability and no immediate action is required. + +If you have any questions, please feel welcome to join this issue: https://github.com/Webklex/php-imap/issues/416 +#### Timeline +- 17.06.23 21:30: Vulnerability reported +- 18.06.23 19:14: Vulnerability confirmed +- 19.06.23 18:41: Vulnerability fixed via PR #414 +- 20.06.23 13:45: Security patch released +- 21.06.23 20:48: CVE-2023-35169 got assigned +- 21.06.23 20:58: Advisory released https://github.com/Webklex/php-imap/security/advisories/GHSA-47p7-xfcc-4pv9 + + +## [5.2.0] - 2023-04-11 +### Fixed +- Use all available methods to detect the attachment extension instead of just one +- Allow the `LIST` command response to be empty #393 +- Initialize folder children attributes on class initialization + +### Added +- Soft fail option added to all folder fetching methods. If soft fail is enabled, the method will return an empty collection instead of throwing an exception if the folder doesn't exist + + +## [5.1.0] - 2023-03-16 +### Fixed +- IMAP Quota root command fixed +- Prevent line-breaks in folder path caused by special chars +- Partial fix for #362 (allow overview response to be empty) +- `Message::setConfig()` config parameter type set to array +- Reset the protocol uid cache if the session gets expunged +- Set the "seen" flag only if the flag isn't set and the fetch option isn't `IMAP::FT_PEEK` +- `Message::is()` date comparison fixed +- `Message::$client` could not be set to null +- `in_reply_to` and `references` parsing fixed +- Prevent message body parser from injecting empty lines +- Don't parse regular inline message parts without name or filename as attachment +- `Message::hasTextBody()` and `Message::hasHtmlBody()` should return `false` if the body is empty +- Imap-Protocol "empty response" detection extended to catch an empty response caused by a broken resource stream +- `iconv_mime_decode()` is now used with `ICONV_MIME_DECODE_CONTINUE_ON_ERROR` to prevent the decoding from failing +- Date decoding rules extended to support more date formats +- Unset the currently active folder if it gets deleted (prevent infinite loop) +- Attachment name and filename parsing fixed and improved to support more formats +- Check if the next uid is available (after copying or moving a message) before fetching it #381 +- Default pagination `$total` attribute value set to 0 #385 (thanks @hhniao) +- Use attachment ID as fallback filename for saving an attachment +- Address decoding error detection added #388 + +### Added +- Extended UTF-7 support added (RFC2060) #383 +- `Protocol::sizes()` support added (fetch the message byte size via RFC822.SIZE). Accessible through `Message::getSize()` #379 (thanks @didi1357) +- `Message::hasFlag()` method added to check if a message has a specific flag +- `Message::getConfig()` method added to get the current message configuration +- `Folder::select()` method added to select a folder +- `Message::getAvailableFlags()` method added to get all available flags +- Live mailbox and fixture tests added +- `Attribute::map()` method added to map all attribute values +- `Header::has()` method added to check if a header attribute / value exist +- All part attributes are now accessible via linked attribute +- Restore a message from string `Message::fromString()` + + +## [5.0.1] - 2023-03-01 +### Fixed +- More unique ID generation to prevent multiple attachments with same ID #363 (thanks @Guite) +- Not all attachments are pushed to the collection #372 (thanks @AdrianKuriata) +- Partial fix for #362 (allow search response to be empty) +- Unsafe usage of switch case. #354 #366 (thanks @shuergab) +- Fix use of ST_MSGN as sequence method #356 (thanks @gioid) +- Prevent infinite loop in ImapProtocol #316 (thanks @thin-k-design) + + +## [5.0.0] - 2023-01-18 +### Fixed +- The message uid and message number will only be fetched if accessed and wasn't previously set #326 #285 (thanks @szymekjanaczek) +- Fix undefined attachment name when headers use "filename*=" format #301 (thanks @JulienChavee) +- Fixed `ImapProtocol::logout` always throws 'not connected' Exception after upgraded to 4.1.2 #351 +- Protocol interface and methods unified +- Strict attribute and return types introduced where ever possible +- Parallel messages during idle #338 +- Idle timeout / stale resource stream issue fixed +- Syntax updated to support php 8 features +- Get the attachment file extension from the filename if no mimetype detection library is available +- Prevent the structure parsing from parsing an empty part +- Convert all header keys to their lower case representation +- Restructure the decode function #355 (thanks @istid) + +### Added +- Unit tests added #347 #242 (thanks @sergiy-petrov, @boekkooi-lengoo) +- `Client::clone()` method added to clone a client instance +- Save an entire message (including its headers) `Message::save()` +- Restore a message from a local or remote file `Message::fromFile()` +- Protocol resource stream accessor added `Protocol::getStream()` +- Protocol resource stream meta data accessor added `Protocol::meta()` +- ImapProtocol resource stream reset method added `ImapProtocol::reset()` +- Protocol `Response::class` introduced to handle and unify all protocol requests +- Static mask config accessor added `ClientManager::getMask()` added +- An `Attribute::class` instance can be treated as array +- Get the current client account configuration via `Client::getConfig()` +- Delete a folder via `Client::deleteFolder()` + +### Breaking changes +- PHP ^8.0.2 required +- `nesbot/carbon` version bumped to ^2.62.1 +- `phpunit/phpunit` version bumped to ^9.5.10 +- `Header::get()` always returns an `Attribute::class` instance +- `Attribute::class` accessor methods renamed to shorten their names and improve the readability +- All protocol methods that used to return `array|bool` will now always return a `Response::class` instance. +- `ResponseException::class` gets thrown if a response is empty or contains errors +- Message client is optional and can be null (e.g. if used in combination with `Message::fromFile()`) +- The message text or html body is now "" if its empty and not `null` + + +## [4.1.2] - 2022-12-14 +### Fixed +- Attachment ID can return an empty value #318 +- Additional message date format added #345 (thanks @amorebietakoUdala) + + +## [4.1.1] - 2022-11-16 +### Fixed +- Fix for extension recognition #325 (thanks @pwoszczyk) +- Missing null check added #327 (thanks @spanjeta) +- Leading white-space in response causes an infinite loop #321 (thanks @thin-k-design) +- Fix error when creating folders with special chars #319 (thanks @thin-k-design) +- `Client::getFoldersWithStatus()` recursive loading fixed #312 (thanks @szymekjanaczek) +- Fix Folder name encoding error in `Folder::appendMessage()` #306 #307 (thanks @rskrzypczak) + + +## [4.1.0] - 2022-10-18 +### Fixed +- Fix assumedNextTaggedLine bug #288 (thanks @Blear) +- Fix empty response error for blank lines #274 (thanks @bierpub) +- Fix empty body #233 (thanks @latypoff) +- Fix imap_reopen folder argument #234 (thanks @latypoff) + +### Added +- Added possibility of loading a Folder status #298 (thanks @szymekjanaczek) + + +## [4.0.2] - 2022-08-26 +### Fixed +- RFC 822 3.1.1. long header fields regular expression fixed #268 #269 (thanks @hbraehne) + + +## [4.0.1] - 2022-08-25 +### Fixed +- Type casting added to several ImapProtocol return values #261 +- Remove IMAP::OP_READONLY flag from imap_reopen if POP3 or NNTP protocol is selected #135 (thanks @xianzhe18) +- Several statements optimized and redundant checks removed +- Check if the Protocol supports the fetch method if extensions are present +- Detect `NONEXISTENT` errors while selecting or examining a folder #266 +- Missing type cast added to `PaginatedCollection::paginate` #267 (thanks @rogerb87) +- Fix multiline header unfolding #250 (thanks @sulgie-eitea) +- Fix problem with illegal offset error #226 (thanks @szymekjanaczek) +- Typos fixed + +### Affected Classes +- [Query::class](src/Query/Query.php) +- [ImapProtocol::class](src/Connection/Protocols/ImapProtocol.php) +- [LegacyProtocol::class](src/Connection/Protocols/LegacyProtocol.php) +- [PaginatedCollection::class](src/Support/PaginatedCollection.php) + + +## [4.0.0] - 2022-08-19 +### Fixed +- PHP dependency updated to support php v8.0 #212 #214 (thanks @freescout-helpdesk) +- Method return and argument types added +- Imap `DONE` method refactored +- UID cache loop fixed +- `HasEvent::getEvent` return value set to mixed to allow multiple event types +- Protocol line reader changed to `fread` (stream_context timeout issue fixed) +- Issue setting the client timeout fixed +- IMAP Connection debugging improved +- `Folder::idle()` method reworked and several issues fixed #170 #229 #237 #249 #258 +- Datetime conversion rules extended #189 #173 + +### Affected Classes +- [Client::class](src/Client.php) +- [Folder::class](src/Folder.php) +- [ImapProtocol::class](src/Connection/Protocols/ImapProtocol.php) +- [HasEvents::class](src/Traits/HasEvents.php) + +### Breaking changes +- No longer supports php >=5.5.9 but instead requires at least php v7.0.0. +- `HasEvent::getEvent` returns a mixed result. Either an `Event` or a class string representing the event class. +- The error message, if the connection fails to read the next line, is now `empty response` instead of `failed to read - connection closed?`. +- The `$auto_reconnect` used with `Folder::indle()` is deprecated and doesn't serve any purpose anymore. + + +## [3.2.0] - 2022-03-07 +### Fixed +- Fix attribute serialization #179 (thanks @netpok) +- Use real tls instead of starttls #180 (thanks @netpok) +- Allow to fully overwrite default config arrays #194 (thanks @laurent-rizer) +- Query::chunked does not loop over the last chunk #196 (thanks @laurent-rizer) +- Fix isAttachment that did not properly take in consideration dispositions options #195 (thanks @laurent-rizer) +- Extend date parsing error message #173 +- Fixed 'Where' method replaces the content with uppercase #148 +- Don't surround numeric search values with quotes +- Context added to `InvalidWhereQueryCriteriaException` +- Redundant `stream_set_timeout()` removed + +### Added +- UID Cache added #204 (thanks @HelloSebastian) +- Query::class extended with `getByUidLower`, `getByUidLowerOrEqual` , `getByUidGreaterOrEqual` , `getByUidGreater` to fetch certain ranges of uids #201 (thanks @HelloSebastian) +- Check if IDLE is supported if `Folder::idle()` is called #199 (thanks @HelloSebastian) +- Fallback date support added. The config option `options.fallback_date` is used as fallback date is it is set. Otherwise, an exception will be thrown #198 +- UID filter support added +- Make boundary regex configurable #169 #150 #126 #121 #111 #152 #108 (thanks @EthraZa) +- IMAP ID support added #174 +- Enable debug mode via config +- Custom UID alternative support added +- Fetch additional extensions using `Folder::query(["FEATURE_NAME"])` +- Optionally move a message during "deletion" instead of just "flagging" it #106 (thanks @EthraZa) +- `WhereQuery::where()` accepts now a wide range of criteria / values. #104 + +### Affected Classes +- [Part::class](src/Part.php) +- [Query::class](src/Query/Query.php) +- [Client::class](src/Client.php) +- [Header::class](src/Header.php) +- [Protocol::class](src/Connection/Protocols/Protocol.php) +- [ClientManager::class](src/ClientManager.php) + +### Breaking changes +- If you are using the legacy protocol to search, the results no longer return false if the search criteria could not be interpreted but instead return an empty array. This will ensure it is compatible to the rest of this library and no longer result in a potential type confusion. +- `Folder::idle` will throw an `Webklex\PHPIMAP\Exceptions\NotSupportedCapabilityException` exception if IMAP isn't supported by the mail server +- All protocol methods which had a `boolean` `$uid` option no longer support a boolean value. Use `IMAP::ST_UID` or `IMAP::NIL` instead. If you want to use an alternative to `UID` just use the string instead. +- Default config option `options.sequence` changed from `IMAP::ST_MSGN` to `IMAP::ST_UID`. +- `Folder::query()` no longer accepts a charset string. It has been replaced by an extension array, which provides the ability to automatically fetch additional features. + + +## [3.1.0-alpha] - 2022-02-03 +### Fixed +- Fix attribute serialization #179 (thanks @netpok) +- Use real tls instead of starttls #180 (thanks @netpok) +- Allow to fully overwrite default config arrays #194 (thanks @laurent-rizer) +- Query::chunked does not loop over the last chunk #196 (thanks @laurent-rizer) +- Fix isAttachment that did not properly take in consideration dispositions options #195 (thanks @laurent-rizer) + +### Affected Classes +- [Header::class](src/Header.php) +- [Protocol::class](src/Connection/Protocols/Protocol.php) +- [Query::class](src/Query/Query.php) +- [Part::class](src/Part.php) +- [ClientManager::class](src/ClientManager.php) + +## [3.0.0-alpha] - 2021-11-04 +### Fixed +- Extend date parsing error message #173 +- Fixed 'Where' method replaces the content with uppercase #148 +- Don't surround numeric search values with quotes +- Context added to `InvalidWhereQueryCriteriaException` +- Redundant `stream_set_timeout()` removed + +### Added +- Make boundary regex configurable #169 #150 #126 #121 #111 #152 #108 (thanks @EthraZa) +- IMAP ID support added #174 +- Enable debug mode via config +- Custom UID alternative support added +- Fetch additional extensions using `Folder::query(["FEATURE_NAME"])` +- Optionally move a message during "deletion" instead of just "flagging" it #106 (thanks @EthraZa) +- `WhereQuery::where()` accepts now a wide range of criteria / values. #104 + +### Affected Classes +- [Header::class](src/Header.php) +- [Protocol::class](src/Connection/Protocols/Protocol.php) +- [Query::class](src/Query/Query.php) +- [WhereQuery::class](src/Query/WhereQuery.php) +- [Message::class](src/Message.php) + +### Breaking changes +- All protocol methods which had a `boolean` `$uid` option no longer support a boolean. Use `IMAP::ST_UID` or `IMAP::NIL` instead. If you want to use an alternative to `UID` just use the string instead. +- Default config option `options.sequence` changed from `IMAP::ST_MSGN` to `IMAP::ST_UID`. +- `Folder::query()` no longer accepts a charset string. It has been replaced by an extension array, which provides the ability to automatically fetch additional features. + +## [2.7.2] - 2021-09-27 +### Fixed +- Fixed problem with skipping last line of the response. #166 (thanks @szymekjanaczek) + +## [2.7.1] - 2021-09-08 +### Added +- Added `UID` as available search criteria #161 (thanks @szymekjanaczek) + +## [2.7.0] - 2021-09-04 +### Fixed +- Fixes handling of long header lines which are seperated by `\r\n\t` (thanks @Oliver-Holz) +- Fixes to line parsing with multiple addresses (thanks @Oliver-Holz) + +### Added +- Expose message folder path #154 (thanks @Magiczne) +- Adds mailparse_rfc822_parse_addresses integration (thanks @Oliver-Holz) +- Added moveManyMessages method (thanks @Magiczne) +- Added copyManyMessages method (thanks @Magiczne) + +### Affected Classes +- [Header::class](src/Header.php) +- [Message::class](src/Message.php) + +## [2.6.0] - 2021-08-20 +### Fixed +- POP3 fixes #151 (thanks @Korko) + +### Added +- Added imap 4 handling. #146 (thanks @szymekjanaczek) +- Added laravel's conditionable methods. #147 (thanks @szymekjanaczek) + +### Affected Classes +- [Query::class](src/Query/Query.php) +- [Client::class](src/Client.php) + +## [2.5.1] - 2021-06-19 +### Fixed +- Fix setting default mask from config #133 (thanks @shacky) +- Chunked fetch fails in case of less available mails than page size #114 +- Protocol::createStream() exception information fixed #137 +- Legacy methods (headers, content, flags) fixed #125 +- Legacy connection cycle fixed #124 (thanks @zssarkany) + +### Added +- Disable rfc822 header parsing via config option #115 + +## [2.5.0] - 2021-02-01 +### Fixed +- Attachment saving filename fixed +- Unnecessary parameter removed from `Client::getTimeout()` +- Missing encryption variable added - could have caused problems with unencrypted communications +- Prefer attachment filename attribute over name attribute #82 +- Missing connection settings added to `Folder:idle()` auto mode #89 +- Message move / copy expect a folder path #79 +- `Client::getFolder()` updated to circumvent special edge cases #79 +- Missing connection status checks added to various methods +- Unused default attribute `message_no` removed from `Message::class` + +### Added +- Dynamic Attribute access support added (e.g `$message->from[0]`) +- Message not found exception added #93 +- Chunked fetching support added `Query::chunked()`. Just in case you can't fetch all messages at once +- "Soft fail" support added +- Count method added to `Attribute:class` +- Convert an Attribute instance into a Carbon date object #95 + +### Affected Classes +- [Attachment::class](src/Attachment.php) +- [Attribute::class](src/Attribute.php) +- [Query::class](src/Query/Query.php) +- [Message::class](src/Message.php) +- [Client::class](src/Client.php) +- [Folder::class](src/Folder.php) + +### Breaking changes +- A new exception can occur if a message can't be fetched (`\Webklex\PHPIMAP\Exceptions\MessageNotFoundException::class`) +- `Message::move()` and `Message::copy()` no longer accept folder names as folder path +- A `Message::class` instance might no longer have a `message_no` attribute + +## [2.4.4] - 2021-01-22 +### Fixed +- Boundary detection simplified #90 +- Prevent potential body overwriting #90 +- CSV files are no longer regarded as plain body +- Boundary detection overhauled to support "related" and "alternative" multipart messages #90 #91 + +### Affected Classes +- [Structure::class](src/Structure.php) +- [Message::class](src/Message.php) +- [Header::class](src/Header.php) +- [Part::class](src/Part.php) + +## [2.4.3] - 2021-01-21 +### Fixed +- Attachment detection updated #82 #90 +- Timeout handling improved +- Additional utf-8 checks added to prevent decoding of unencoded values #76 + +### Added +- Auto reconnect option added to `Folder::idle()` #89 + +### Affected Classes +- [Folder::class](src/Folder.php) +- [Part::class](src/Part.php) +- [Client::class](src/Client.php) +- [Header::class](src/Header.php) + +## [2.4.2] - 2021-01-09 +### Fixed +- Attachment::save() return error 'A facade root has not been set' #87 +- Unused dependencies removed +- Fix PHP 8 error that changes null back in to an empty string. #88 (thanks @mennovanhout) +- Fix regex to be case insensitive #88 (thanks @mennovanhout) + +### Affected Classes +- [Attachment::class](src/Attachment.php) +- [Address::class](src/Address.php) +- [Attribute::class](src/Attribute.php) +- [Structure::class](src/Structure.php) + +## [2.4.1] - 2021-01-06 +### Fixed +- Debug line position fixed +- Handle incomplete address to string conversion #83 +- Configured message key gets overwritten by the first fetched message #84 + +### Affected Classes +- [Address::class](src/Address.php) +- [Query::class](src/Query/Query.php) + +## [2.4.0] - 2021-01-03 +### Fixed +- Get partial overview when `IMAP::ST_UID` is set #74 +- Unnecessary "'" removed from address names +- Folder referral typo fixed +- Legacy protocol fixed +- Treat message collection keys always as strings + +### Added +- Configurable supported default flags added +- Message attribute class added to unify value handling +- Address class added and integrated +- Alias `Message::attachments()` for `Message::getAttachments()` added +- Alias `Message::addFlag()` for `Message::setFlag()` added +- Alias `Message::removeFlag()` for `Message::unsetFlag()` added +- Alias `Message::flags()` for `Message::getFlags()` added +- New Exception `MessageFlagException::class` added +- New method `Message::setSequenceId($id)` added +- Optional Header attributizion option added + +### Affected Classes +- [Folder::class](src/Folder.php) +- [Header::class](src/Header.php) +- [Message::class](src/Message.php) +- [Address::class](src/Address.php) +- [Query::class](src/Query/Query.php) +- [Attribute::class](src/Attribute.php) + +### Breaking changes +- Stringified message headers are now separated by ", " instead of " ". +- All message header values such as subject, message_id, from, to, etc now consists of an `Àttribute::class` instance (should behave the same way as before, but might cause some problem in certain edge cases) +- The formal address object "from", "to", etc now consists of an `Address::class` instance (should behave the same way as before, but might cause some problem in certain edge cases) +- When fetching or manipulating message flags a `MessageFlagException::class` exception can be thrown if a runtime error occurs +- Learn more about the new `Attribute` class here: [www.php-imap.com/api/attribute](https://www.php-imap.com/api/attribute) +- Learn more about the new `Address` class here: [www.php-imap.com/api/address](https://www.php-imap.com/api/address) +- Folder attribute "referal" is now called "referral" + +## [2.3.1] - 2020-12-30 +### Fixed +- Missing RFC attributes added +- Set the message sequence when idling +- Missing UID commands added #64 + +### Added +- Get a message by its message number +- Get a message by its uid #72 #66 #63 + +### Affected Classes +- [Message::class](src/Message.php) +- [Folder::class](src/Folder.php) +- [Query::class](src/Query/Query.php) + +## [2.3.0] - 2020-12-21 +### Fixed +- Cert validation issue fixed +- Allow boundaries ending with a space or semicolon (thanks [@smartilabs](https://github.com/smartilabs)) +- Ignore IMAP DONE command response #57 +- Default `options.fetch` set to `IMAP::FT_PEEK` +- Address parsing fixed #60 +- Alternative rfc822 header parsing fixed #60 +- Parse more than one Received: header #61 +- Fetch folder overview fixed +- `Message::getTextBody()` fallback value fixed + +### Added +- Proxy support added +- Flexible disposition support added #58 +- New `options.message_key` option `uid` added +- Protocol UID support added +- Flexible sequence type support added + +### Affected Classes +- [Structure::class](src/Structure.php) +- [Query::class](src/Query/Query.php) +- [Client::class](src/Client.php) +- [Header::class](src/Header.php) +- [Folder::class](src/Folder.php) +- [Part::class](src/Part.php) + +### Breaking changes +- Depending on your configuration, your certificates actually get checked. Which can cause an aborted connection if the certificate can not be validated. +- Messages don't get flagged as read unless you are using your own custom config. +- All `Header::class` attribute keys are now in a snake_format and no longer minus-separated. +- `Message::getTextBody()` no longer returns false if no text body is present. `null` is returned instead. + +## [2.2.5] - 2020-12-11 +### Fixed +- Missing array decoder method added #51 (thanks [@lutchin](https://github.com/lutchin)) +- Additional checks added to prevent message from getting marked as seen #33 +- Boundary parsing improved #39 #36 (thanks [@AntonioDiPassio-AppSys](https://github.com/AntonioDiPassio-AppSys)) +- Idle operation updated #44 + +### Added +- Force a folder to be opened + +### Affected Classes +- [Header::class](src/Header.php) +- [Folder::class](src/Folder.php) +- [Query::class](src/Query/Query.php) +- [Message::class](src/Message.php) +- [Structure::class](src/Structure.php) + +## [2.2.4] - 2020-12-08 +### Fixed +- Search performance increased by fetching all headers, bodies and flags at once #42 +- Legacy protocol support updated +- Fix Query pagination. (#52 [@mikemiller891](https://github.com/mikemiller891)) + +### Added +- Missing message setter methods added +- `Folder::overview()` method added to fetch all headers of all messages in the current folder + +### Affected Classes +- [Message::class](src/Message.php) +- [Folder::class](src/Folder.php) +- [Query::class](src/Query/Query.php) +- [PaginatedCollection::class](src/Support/PaginatedCollection.php) + +## [2.2.3] - 2020-11-02 +### Fixed +- Text/Html body fetched as attachment if subtype is null #34 +- Potential header overwriting through header extensions #35 +- Prevent empty attachments #37 + +### Added +- Set fetch order during query #41 [@Max13](https://github.com/Max13) + +### Affected Classes +- [Message::class](src/Message.php) +- [Part::class](src/Part.php) +- [Header::class](src/Header.php) +- [Query::class](src/Query/Query.php) + + +## [2.2.2] - 2020-10-20 +### Fixed +- IMAP::FT_PEEK removing "Seen" flag issue fixed #33 + +### Affected Classes +- [Message::class](src/Message.php) + +## [2.2.1] - 2020-10-19 +### Fixed +- Header decoding problem fixed #31 + +### Added +- Search for messages by message-Id +- Search for messages by In-Reply-To +- Message threading added `Message::thread()` +- Default folder locations added + +### Affected Classes +- [Query::class](src/Query/Query.php) +- [Message::class](src/Message.php) +- [Header::class](src/Header.php) + + +## [2.2.0] - 2020-10-16 +### Fixed +- Prevent text bodies from being fetched as attachment #27 +- Missing variable check added to prevent exception while parsing an address [webklex/laravel-imap #356](https://github.com/Webklex/laravel-imap/issues/356) +- Missing variable check added to prevent exception while parsing a part subtype #27 +- Missing variable check added to prevent exception while parsing a part content-type [webklex/laravel-imap #356](https://github.com/Webklex/laravel-imap/issues/356) +- Mixed message header attribute `in_reply_to` "unified" to be always an array #26 +- Potential message moving / copying problem fixed #29 +- Move messages by using `Protocol::moveMessage()` instead of `Protocol::copyMessage()` and `Message::delete()` #29 + +### Added +- `Protocol::moveMessage()` method added #29 + +### Affected Classes +- [Message::class](src/Message.php) +- [Header::class](src/Header.php) +- [Part::class](src/Part.php) + +### Breaking changes +- Text bodies might no longer get fetched as attachment +- `Message::$in_reply_to` type changed from mixed to array + +## [2.1.13] - 2020-10-13 +### Fixed +- Boundary detection problem fixed (#28 [@DasTobbel](https://github.com/DasTobbel)) +- Content-Type detection problem fixed (#28 [@DasTobbel](https://github.com/DasTobbel)) + +### Affected Classes +- [Structure::class](src/Structure.php) + +## [2.1.12] - 2020-10-13 +### Fixed +- If content disposition is multiline, implode the array to a simple string (#25 [@DasTobbel](https://github.com/DasTobbel)) + +### Affected Classes +- [Part::class](src/Part.php) + +## [2.1.11] - 2020-10-13 +### Fixed +- Potential problematic prefixed white-spaces removed from header attributes + +### Added +- Expended `Client::getFolder($name, $deleimiter = null)` to accept either a folder name or path ([@DasTobbel](https://github.com/DasTobbel)) +- Special MS-Exchange header decoding support added + +### Affected Classes +- [Client::class](src/Client.php) +- [Header::class](src/Header.php) + +## [2.1.10] - 2020-10-09 +### Added +- `ClientManager::make()` method added to support undefined accounts + +### Affected Classes +- [ClientManager::class](src/ClientManager.php) + +## [2.1.9] - 2020-10-08 +### Fixed +- Fix inline attachments and embedded images (#22 [@dwalczyk](https://github.com/dwalczyk)) + +### Added +- Alternative attachment names support added (#20 [@oneFoldSoftware](https://github.com/oneFoldSoftware)) +- Fetch message content without leaving a "Seen" flag behind + ### Affected Classes -- [Query::class](src/Query/WhereQuery.php) - [Attachment::class](src/Attachment.php) +- [Message::class](src/Message.php) +- [Part::class](src/Part.php) +- [Query::class](src/Query/Query.php) + +## [2.1.8] - 2020-10-08 +### Fixed +- Possible error during address decoding fixed (#16 [@Slauta](https://github.com/Slauta)) +- Flag event dispatching fixed #15 + +### Added +- Support multiple boundaries (#17, #19 [@dwalczyk](https://github.com/dwalczyk)) + +### Affected Classes +- [Structure::class](src/Structure.php) + +## [2.1.7] - 2020-10-03 +### Fixed +- Fixed `Query::paginate()` (#13 #14 by [@Max13](https://github.com/Max13)) + +### Affected Classes +- [Query::class](src/Query/Query.php) + +## [2.1.6] - 2020-10-02 +### Fixed +- `Message::getAttributes()` hasn't returned all parameters + +### Affected Classes +- [Message::class](src/Message.php) + +### Added +- Part number added to attachment +- `Client::getFolderByPath()` added (#12 by [@Max13](https://github.com/Max13)) +- `Client::getFolderByName()` added (#12 by [@Max13](https://github.com/Max13)) +- Throws exceptions if the authentication fails (#11 by [@Max13](https://github.com/Max13)) + +### Affected Classes +- [Client::class](src/Client.php) + +## [2.1.5] - 2020-09-30 +### Fixed +- Wrong message content property reference fixed (#10) + +## [2.1.4] - 2020-09-30 +### Fixed +- Fix header extension values +- Part header detection method changed (#10) + +### Affected Classes +- [Header::class](src/Header.php) +- [Part::class](src/Part.php) + +## [2.1.3] - 2020-09-29 +### Fixed +- Possible decoding problem fixed +- `Str::class` dependency removed from `Header::class` + +### Affected Classes +- [Header::class](src/Header.php) + +## [2.1.2] - 2020-09-28 +### Fixed +- Dependency problem in `Attachement::getExtension()` fixed (#9) + +### Affected Classes +- [Attachment::class](src/Attachment.php) + +## [2.1.1] - 2020-09-23 +### Fixed +- Missing default config parameter added + +### Added +- Default account config fallback added + +### Affected Classes +- [Client::class](src/Client.php) + +## [2.1.0] - 2020-09-22 +### Fixed +- Quota handling fixed + +### Added +- Event system and callbacks added + +### Affected Classes - [Client::class](src/Client.php) - [Folder::class](src/Folder.php) - [Message::class](src/Message.php) +## [2.0.1] - 2020-09-20 +### Fixed +- Carbon dependency fixed + +## [2.0.0] - 2020-09-20 +### Fixed +- Missing pagination item records fixed + +### Added +- php-imap module replaced by direct socket communication +- Legacy support added +- IDLE support added +- oAuth support added +- Charset detection method updated +- Decoding fallback charsets added + +### Affected Classes +- All + ## [1.4.5] - 2019-01-23 ### Fixed - .csv attachement is not processed @@ -87,7 +933,7 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip - Imap client timeout can be modified and read #186 - Decoder config options added #175 - Message search criteria "NOT" added #181 -- Invalid message date exception added +- Invalid message date exception added - Blade examples ### Breaking changes diff --git a/README.md b/README.md index 68cebd51..fa6cd7a1 100755 --- a/README.md +++ b/README.md @@ -1,170 +1,97 @@ + # IMAP Library for PHP -[![Latest Version on Packagist][ico-version]][link-packagist] -[![Software License][ico-license]](LICENSE.md) +[![Latest release on Packagist][ico-release]][link-packagist] +[![Latest prerelease on Packagist][ico-prerelease]][link-packagist] +[![Software License][ico-license]][link-license] [![Total Downloads][ico-downloads]][link-downloads] +[![Hits][ico-hits]][link-hits] +[![Discord][ico-discord]][link-discord] +[![Snyk][ico-snyk]][link-snyk] -## Description -PHPIMAP is an easy way to integrate the native php imap library into your php app. +## Description +PHP-IMAP is a wrapper for common IMAP communication without the need to have the php-imap module installed / enabled. +The protocol is completely integrated and therefore supports IMAP IDLE operation and the "new" oAuth authentication +process as well. +You can enable the `php-imap` module in order to handle edge cases, improve message decoding quality and is required if +you want to use legacy protocols such as pop3. ->This package is a fork of one of my other projects [webklex/laravel-imap](https://github.com/Webklex/laravel-imap) -which was specially designed to work with the laravel framework. +Official documentation: [php-imap.com](https://www.php-imap.com/) ->Since this library received way more interest than I thought, I decided to take the core features and -and move them into a new repository. +Laravel wrapper: [webklex/laravel-imap](https://github.com/Webklex/laravel-imap) ->This repository will be part of [webklex/laravel-imap](https://github.com/Webklex/laravel-imap) within the next few releases. +Discord: [discord.gg/rd4cN9h6][link-discord] ## Table of Contents - -- [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) - - [Basic usage example](#basic-usage-example) - - [Folder / Mailbox](#folder--mailbox) - - [Search](#search-for-messages) - - [Counting messages](#counting-messages) - - [Result limiting](#result-limiting) - - [Pagination](#pagination) - - [View examples](#view-examples) - - [Fetch a specific message](#fetch-a-specific-message) - - [Message flags](#message-flags) - - [Attachments](#attachments) - - [Advanced fetching](#advanced-fetching) - - [Masking](#masking) - - [Specials](#specials) -- [Support](#support) -- [Documentation](#documentation) - - [Client::class](#clientclass) - - [Message::class](#messageclass) - - [Folder::class](#folderclass) - - [Query::class](#queryclass) - - [Attachment::class](#attachmentclass) - - [Mask::class](#maskclass) - - [MessageMask::class](#messagemaskclass) - - [AttachmentMask::class](#attachmentmaskclass) - - [MessageCollection::class](#messagecollectionclass) - - [AttachmentCollection::class](#attachmentcollectionclass) - - [FolderCollection::class](#foldercollectionclass) - - [FlagCollection::class](#flagcollectionclass) +- [Documentations](#documentations) +- [Compatibility](#compatibility) +- [Basic usage example](#basic-usage-example) +- [Sponsors](#sponsors) +- [Testing](#testing) - [Known issues](#known-issues) -- [Milestones & upcoming features](#milestones--upcoming-features) +- [Support](#support) +- [Features & pull requests](#features--pull-requests) +- [Alternatives & Different Flavors](#alternatives--different-flavors) - [Security](#security) - [Credits](#credits) - [License](#license) -## Installation - -1) Install the php-imap library if it isn't already installed: -``` shell -sudo apt-get install php*-imap php*-mbstring php*-mcrypt && sudo apache2ctl graceful -``` +## Documentations +- Legacy (< v2.0.0): [legacy documentation](https://github.com/Webklex/php-imap/tree/1.4.5) +- Core documentation: [php-imap.com](https://www.php-imap.com/) -You might also want to check `phpinfo()` if the extension is enabled. -2) Now install the Laravel IMAP package by running the following command: - -``` shell -composer require webklex/php-imap -``` +## Compatibility +| Version | PHP 5.6 | PHP 7 | PHP 8 | +|:--------|:-------:|:-----:|:-----:| +| v6.x | / | / | X | +| v5.x | / | / | X | +| v4.x | / | X | X | +| v3.x | / | X | / | +| v2.x | X | X | / | +| v1.x | X | / | / | -3) Create your own custom config file like [config/imap.php](src/config/imap.php): - -## Configuration - -Supported protocols: -- `imap` — Use IMAP [default] -- `pop3` — Use POP3 -- `nntp` — Use NNTP - -The following encryption methods are supported: -- `false` — Disable encryption -- `ssl` — Use SSL -- `tls` — Use TLS -- `starttls` — Use STARTTLS (alias TLS) -- `notls` — Use NoTLS - -Detailed [config/imap.php](src/config/imap.php) configuration: - - `default` — used default account. It will be used as default for any missing account parameters. If however the default account is missing a parameter the package default will be used. Set to `false` to disable this functionality. - - `accounts` — all available accounts - - `default` — The account identifier (in this case `default` but could also be `fooBar` etc). - - `host` — imap host - - `port` — imap port - - `encryption` — desired encryption method - - `validate_cert` — decide weather you want to verify the certificate or not - - `username` — imap account username - - `password` — imap account password - - `date_format` — The default date format is used to convert any given Carbon::class object into a valid date string. (`d-M-Y`, `d-M-y`, `d M y`) - - `options` — additional fetch options - - `delimiter` — you can use any supported char such as ".", "/", etc - - `fetch` — `IMAP::FT_UID` (message marked as read by fetching the message) or `IMAP::FT_PEEK` (fetch the message without setting the "read" flag) - - `fetch_body` — If set to `false` all messages will be fetched without the body and any potential attachments - - `fetch_attachment` — If set to `false` all messages will be fetched without any attachments - - `fetch_flags` — If set to `false` all messages will be fetched without any flags - - `message_key` — Message key identifier option - - `fetch_order` — Message fetch order - - `open` — special configuration for imap_open() - - `DISABLE_AUTHENTICATOR` — Disable authentication properties. - - `decoder` — Currently only the message subject and attachment name decoder can be set - - `masks` — Default [masking](#masking) config - - `message` — Default message mask - - `attachment` — Default attachment mask - -## Usage -#### Basic usage example +## Basic usage example This is a basic example, which will echo out all Mails within all imap folders and will move every message into INBOX.read. Please be aware that this should not be -tested in real live but it gives an impression on how things work. +tested in real life and is only meant to give an impression on how things work. -``` php +```php use Webklex\PHPIMAP\ClientManager; -use Webklex\PHPIMAP\Client; -$cm = new ClientManager('path/to/config/imap.php'); - -// or use an array of options instead -$cm = new ClientManager(array $options = []); +require_once "vendor/autoload.php"; -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$oClient = $cm->account('account_identifier'); +$cm = new ClientManager('path/to/config/imap.php'); -// or create a new instance manually -$oClient = new Client([ - 'host' => 'somehost.com', - 'port' => 993, - 'encryption' => 'ssl', - 'validate_cert' => true, - 'username' => 'username', - 'password' => 'password', - 'protocol' => 'imap' -]); +/** @var \Webklex\PHPIMAP\Client $client */ +$client = $cm->account('account_identifier'); //Connect to the IMAP Server -$oClient->connect(); +$client->connect(); //Get all Mailboxes -/** @var \Webklex\PHPIMAP\Support\FolderCollection $aFolder */ -$aFolder = $oClient->getFolders(); +/** @var \Webklex\PHPIMAP\Support\FolderCollection $folders */ +$folders = $client->getFolders(); //Loop through every Mailbox -/** @var \Webklex\PHPIMAP\Folder $oFolder */ -foreach($aFolder as $oFolder){ +/** @var \Webklex\PHPIMAP\Folder $folder */ +foreach($folders as $folder){ -    //Get all Messages of the current Mailbox $oFolder -    /** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -    $aMessage = $oFolder->messages()->all()->get(); + //Get all Messages of the current Mailbox $folder + /** @var \Webklex\PHPIMAP\Support\MessageCollection $messages */ + $messages = $folder->messages()->all()->get(); - /** @var \Webklex\PHPIMAP\Message $oMessage */ - foreach($aMessage as $oMessage){ - echo $oMessage->getSubject().'
'; - echo 'Attachments: '.$oMessage->getAttachments()->count().'
'; - echo $oMessage->getHTMLBody(true); + /** @var \Webklex\PHPIMAP\Message $message */ + foreach($messages as $message){ + echo $message->getSubject().'
'; + echo 'Attachments: '.$message->getAttachments()->count().'
'; + echo $message->getHTMLBody(); //Move the current Message to 'INBOX.read' - if($oMessage->moveToFolder('INBOX.read') == true){ - echo 'Message has ben moved'; + if($message->move('INBOX.read') == true){ + echo 'Message has been moved'; }else{ echo 'Message could not be moved'; } @@ -172,665 +99,147 @@ foreach($aFolder as $oFolder){ } ``` -#### Folder / Mailbox -There is an experimental function available to get a Folder instance by name. -For an easier access please take a look at the new config option `imap.options.delimiter` however the `getFolder` -method takes three options: the required (string) $folder_name and two optional variables. An integer $attributes which -seems to be sometimes 32 or 64 (I honestly have no clue what this number does, so feel free to enlighten me and anyone -else) and a delimiter which if it isn't set will use the default option configured inside the [config/imap.php](src/config/imap.php) file. -> If you are using Exchange you might want to set all parameter and the last `$prefix_address` to `false` e.g. `$oClient->getFolder('name', 32, null, false)` #234 - -``` php -/** @var \Webklex\PHPIMAP\Client $oClient */ - -/** @var \Webklex\PHPIMAP\Folder $oFolder */ -$oFolder = $oClient->getFolder('INBOX.name'); -``` - -List all available folders: -``` php -/** @var \Webklex\PHPIMAP\Client $oClient */ - -/** @var \Webklex\PHPIMAP\Support\FolderCollection $aFolder */ -$aFolder = $oClient->getFolders(); -``` - - -#### Search for messages -Search for specific emails: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -//Get all messages -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->all()->get(); - -//Get all messages from example@domain.com -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->from('example@domain.com')->get(); - -//Get all messages since march 15 2018 -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->since('15.03.2018')->get(); - -//Get all messages within the last 5 days -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->since(now()->subDays(5))->get(); -//Or for older laravel versions.. -$aMessage = $oFolder->query()->since(\Carbon::now()->subDays(5))->get(); - -//Get all messages containing "hello world" -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->text('hello world')->get(); - -//Get all unseen messages containing "hello world" -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->unseen()->text('hello world')->get(); - -//Extended custom search query for all messages containing "hello world" -//and have been received since march 15 2018 -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->text('hello world')->since('15.03.2018')->get(); -$aMessage = $oFolder->query()->Text('hello world')->Since('15.03.2018')->get(); -$aMessage = $oFolder->query()->whereText('hello world')->whereSince('15.03.2018')->get(); - -// Build a custom search query -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query() -->where([['TEXT', 'Hello world'], ['SINCE', \Carbon::parse('15.03.2018')]]) -->get(); - -//!EXPERIMENTAL! -//Get all messages NOT containing "hello world" -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->notText('hello world')->get(); -$aMessage = $oFolder->query()->not_text('hello world')->get(); -$aMessage = $oFolder->query()->not()->text('hello world')->get(); - -//Get all messages by custom search criteria -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->where(["CUSTOM_FOOBAR" => "fooBar"]])->get(); -``` - -Available search aliases for a better code reading: -``` php -// Folder::search() is just an alias for Folder::query() -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->search()->text('hello world')->since('15.03.2018')->get(); - -// Folder::messages() is just an alias for Folder::query() -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->messages()->text('hello world')->since('15.03.2018')->get(); - -``` -All available query / search methods can be found here: [Query::class](src/IMAP/Query/WhereQuery.php) - -Available search criteria: -- `ALL` — return all messages matching the rest of the criteria -- `ANSWERED` — match messages with the \\ANSWERED flag set -- `BCC` "string" — match messages with "string" in the Bcc: field -- `BEFORE` "date" — match messages with Date: before "date" -- `BODY` "string" — match messages with "string" in the body of the message -- `CC` "string" — match messages with "string" in the Cc: field -- `DELETED` — match deleted messages -- `FLAGGED` — match messages with the \\FLAGGED (sometimes referred to as Important or Urgent) flag set -- `FROM` "string" — match messages with "string" in the From: field -- `KEYWORD` "string" — match messages with "string" as a keyword -- `NEW` — match new messages -- `NOT` — not matching -- `OLD` — match old messages -- `ON` "date" — match messages with Date: matching "date" -- `RECENT` — match messages with the \\RECENT flag set -- `SEEN` — match messages that have been read (the \\SEEN flag is set) -- `SINCE` "date" — match messages with Date: after "date" -- `SUBJECT` "string" — match messages with "string" in the Subject: -- `TEXT` "string" — match messages with text "string" -- `TO` "string" — match messages with "string" in the To: -- `UNANSWERED` — match messages that have not been answered -- `UNDELETED` — match messages that are not deleted -- `UNFLAGGED` — match messages that are not flagged -- `UNKEYWORD` "string" — match messages that do not have the keyword "string" -- `UNSEEN` — match messages which have not been read yet - -Further information: -- http://php.net/manual/en/function.imap-search.php -- https://tools.ietf.org/html/rfc1176 -- https://tools.ietf.org/html/rfc1064 -- https://tools.ietf.org/html/rfc822 -- https://gist.github.com/martinrusev/6121028 - -#### Result limiting -Limiting the request emails: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -//Get all messages for page 2 since march 15 2018 where each page contains 10 messages -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->since('15.03.2018')->limit(10, 2)->get(); -``` - -#### Counting messages -Count all available messages matching the current search criteria: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -//Count all messages -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$count = $oFolder->query()->all()->count(); - -//Count all messages since march 15 2018 -$count = $oFolder->query()->since('15.03.2018')->count(); -``` - -#### Pagination -Paginate a query: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -/** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */ -$paginator = $oFolder->query()->since('15.03.2018')->paginate(); -``` -Paginate a message collection: -``` php -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ - -/** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */ -$paginator = $aMessage->paginate(); -``` -View example for a paginated list: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -/** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */ -$paginator = $oFolder->search() -->since(\Carbon::now()->subDays(14))->get() -->paginate($perPage = 5, $page = null, $pageName = 'imap_blade_example'); -``` - -``` html - - - - - - - - - - - count() > 0): ?> - - - - - - - - - - - - - - -
UIDSubjectFromAttachments
getUid(); ?>getSubject(); ?>getFrom()[0]->mail; ?>getAttachments()->count() > 0 ? 'yes' : 'no'; ?>
No messages found
- -links(); ?> -``` -> You can also paginate a Folder-, Attachment- or FlagCollection instance. - - -#### View examples -You can find a few blade examples under [/examples](https://github.com/Webklex/php-imap/tree/master/examples). - -#### Fetch a specific message -Get a specific message by uid (Please note that the uid is not unique and can change): -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -/** @var \Webklex\PHPIMAP\Message $oMessage */ -$oMessage = $oFolder->getMessage($uid = 1); -``` - -#### Message flags -Flag or "unflag" a message: -``` php -/** @var \Webklex\PHPIMAP\Message $oMessage */ -$oMessage->setFlag(['Seen', 'Spam']); -$oMessage->unsetFlag('Spam'); -``` +## Sponsors +[![elb-BIT][ico-sponsor-elb-bit]][link-sponsor-elb-bit] +[![Feline][ico-sponsor-feline]][link-sponsor-feline] -Mark all messages as "read" while fetching: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->text('Hello world')->markAsRead()->get(); -``` -Don't mark all messages as "read" while fetching: -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->text('Hello world')->leaveUnread()->get(); +## Testing +To run the tests, please execute the following command: +```bash +composer test ``` -#### Attachments -Save message attachments: -``` php -/** @var \Webklex\PHPIMAP\Message $oMessage */ - -/** @var \Webklex\PHPIMAP\Support\AttachmentCollection $aAttachment */ -$aAttachment = $oMessage->getAttachments(); - -$aAttachment->each(function ($oAttachment) { - /** @var \Webklex\PHPIMAP\Attachment $oAttachment */ - $oAttachment->save(); -}); +### Quick-Test / Static Test +To disable all test which require a live mailbox, please copy the `phpunit.xml.dist` to `phpunit.xml` and adjust the configuration: +```xml + + + ``` -#### Advanced fetching -Fetch messages without body fetching (decrease load): -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ +### Full-Test / Live Mailbox Test +To run all tests, you need to provide a valid imap configuration. -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->whereText('Hello world')->setFetchBody(false)->get(); - -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->whereAll()->setFetchBody(false)->setFetchAttachment(); +To provide a valid imap configuration, please copy the `phpunit.xml.dist` to `phpunit.xml` and adjust the configuration: +```xml + + + + + + + + + + + ``` -Fetch messages without body, flag and attachment fetching (decrease load): -``` php -/** @var \Webklex\PHPIMAP\Folder $oFolder */ - -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->whereText('Hello world') -->setFetchFlags(false) -->setFetchBody(false) -->setFetchAttachment(false) -->get(); - -/** @var \Webklex\PHPIMAP\Support\MessageCollection $aMessage */ -$aMessage = $oFolder->query()->whereAll() -->setFetchFlags(false) -->setFetchBody(false) -->setFetchAttachment(false) -->get(); -``` +The test account should **not** contain any important data, as it will be deleted during the test. +Furthermore, the test account should be able to create new folders, move messages and should **not** be used by any other +application during the test. -#### Masking -Laravel-IMAP already comes with two default masks [MessageMask::class](#messagemaskclass) and [AttachmentMask::class](#attachmentmaskclass). +It's recommended to use a dedicated test account for this purpose. You can use the provided `Dockerfile` to create an imap server used for testing purposes. -The masked instance has to be called manually and is designed to add custom functionality. +Build the docker image: +```bash +cd .github/docker -You can call the default mask by calling the mask method without any arguments. -``` php -/** @var \Webklex\PHPIMAP\Message $oMessage */ -$mask = $oMessage->mask(); +docker build -t php-imap-server . ``` - -There are several methods available to set the default mask: -``` php -/** @var \Webklex\PHPIMAP\Client $oClient */ -/** @var \Webklex\PHPIMAP\Message $oMessage */ - -$message_mask = \Webklex\PHPIMAP\Support\Masks\MessageMask::class; - -$oClient->setDefaultMessageMask($message_mask); -$oMessage->setMask($message_mask); -$mask = $oMessage->mask($message_mask); +Run the docker image: +```bash +docker run --name imap-server -p 993:993 --rm -d php-imap-server ``` -The last one wont set the mask but generate a masked instance using the provided mask. - -You could also set the default masks inside your `config/imap.php` file under `masks`. - -You can also apply a mask on [attachments](#attachmentclass): -``` php -/** @var \Webklex\PHPIMAP\Client $oClient */ -/** @var \Webklex\PHPIMAP\Attachment $oAttachment */ -$attachment_mask = \Webklex\PHPIMAP\Support\Masks\AttachmentMask::class; - -$oClient->setDefaultAttachmentMask($attachment_mask); -$oAttachment->setMask($attachment_mask); -$mask = $oAttachment->mask($attachment_mask); +Stop the docker image: +```bash +docker stop imap-server ``` -If you want to implement your own mask just extend [MessageMask::class](#messagemaskclass), [AttachmentMask::class](#attachmentmaskclass) -or [Mask::class](#maskclass) and implement your desired logic: - -``` php -/** @var \Webklex\PHPIMAP\Message $oMessage */ -class CustomMessageMask extends \Webklex\PHPIMAP\Support\Masks\MessageMask { - - /** - * New custom method which can be called through a mask - * @return string - */ - public function token(){ - return implode('-', [$this->message_id, $this->uid, $this->message_no]); - } -} - -$mask = $oMessage->mask(CustomMessageMask::class); - -echo $mask->token().'@'.$mask->uid; -``` - -Additional examples can be found here: -- [Custom message mask](https://github.com/Webklex/laravel-imap/blob/master/examples/custom_message_mask.php) -- [Custom attachment mask](https://github.com/Webklex/laravel-imap/blob/master/examples/custom_attachment_mask.php) +### Known issues +| Error | Solution | +|:---------------------------------------------------------------------------|:----------------------------------------------------------------------------------------| +| Kerberos error: No credentials cache file found (try running kinit) (...) | Uncomment "DISABLE_AUTHENTICATOR" inside your config and use the `legacy-imap` protocol | -#### Specials -Find the folder containing a message: -``` php -$oFolder = $aMessage->getContainingFolder(); -``` ## Support If you encounter any problems or if you find a bug, please don't hesitate to create a new [issue](https://github.com/Webklex/php-imap/issues). -However please be aware that it might take some time to get an answer. +However, please be aware that it might take some time to get an answer. +Off-topic, rude or abusive issues will be deleted without any notice. + +If you need **commercial** support, feel free to send me a mail at github@webklex.com. -If you need **immediate** or **commercial** support, feel free to send me a mail at github@webklex.com. ##### A little notice -If you write source code in your issue, please consider to format it correctly. This makes it so much nicer to read +If you write source code in your issue, please consider to format it correctly. This makes it so much nicer to read and people are more likely to comment and help :) -``` php +```php echo 'your php code...'; ``` will turn into: -``` php -echo 'your php code...'; -``` +```php +echo 'your php code...'; +``` + -### Features & pull requests -Everyone can contribute to this project. Every pull request will be considered but it can also happen to be declined. -To prevent unnecessary work, please consider to create a [feature issue](https://github.com/Webklex/php-imap/issues/new?template=feature_request.md) -first, if you're planning to do bigger changes. Of course you can also create a new [feature issue](https://github.com/Webklex/php-imap/issues/new?template=feature_request.md) +## Features & pull requests +Everyone can contribute to this project. Every pull request will be considered, but it can also happen to be declined. +To prevent unnecessary work, please consider to create a [feature issue](https://github.com/Webklex/php-imap/issues/new?template=feature_request.md) +first, if you're planning to do bigger changes. Of course, you can also create a new [feature issue](https://github.com/Webklex/php-imap/issues/new?template=feature_request.md) if you're just wishing a feature ;) ->Off topic, rude or abusive issues will be deleted without any notice. - -## Documentation -### [Client::class](src/Client.php) - -| Method | Arguments | Return | Description | -| ------------------------- | ------------------------------------------------------------------------------- | :---------------: | ---------------------------------------------------------------------------------------------------------------------------- | -| setConfig | array $config | self | Set the Client configuration. Take a look at `config/imap.php` for more inspiration. | -| getConnection | resource $connection | resource | Get the current imap resource | -| setReadOnly | bool $readOnly | self | Set read only property and reconnect if it's necessary. | -| isReadOnly | | bool | Determine if connection is in read only mode. | -| isConnected | | bool | Determine if connection was established. | -| checkConnection | | | Determine if connection was established and connect if not. | -| connect | int $attempts | | Connect to server. | -| disconnect | | | Disconnect from server. | -| getFolder | string $folder_name, int $attributes = 32, int or null $delimiter, bool $prefix_address | Folder | Get a Folder instance by name | -| getFolders | bool $hierarchical, string or null $parent_folder | FolderCollection | Get folders list. If hierarchical order is set to true, it will make a tree of folders, otherwise it will return flat array. | -| openFolder | string or Folder $folder, integer $attempts | | Open a given folder. | -| createFolder | string $name | boolean | Create a new folder. | -| renameFolder | string $old_name, string $new_name | boolean | Rename a folder. | -| deleteFolder | string $name | boolean | Delete a folder. | -| getMessages | Folder $folder, string $criteria, bool $fetch_body, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get messages from folder. | -| getUnseenMessages | Folder $folder, string $criteria, bool $fetch_body, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get Unseen messages from folder. | -| searchMessages | array $where, Folder $folder, $fetch_options, bool $fetch_body, string $charset, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get specific messages from a given folder. | -| getQuota | | array | Retrieve the quota level settings, and usage statics per mailbox | -| getQuotaRoot | string $quota_root | array | Retrieve the quota settings per user | -| countMessages | | int | Gets the number of messages in the current mailbox | -| countRecentMessages | | int | Gets the number of recent messages in current mailbox | -| getAlerts | | array | Returns all IMAP alert messages that have occurred | -| getErrors | | array | Returns all of the IMAP errors that have occurred | -| getLastError | | string | Gets the last IMAP error that occurred during this page request | -| expunge | | bool | Delete all messages marked for deletion | -| checkCurrentMailbox | | object | Check current mailbox | -| setTimeout | string or int $type, int $timeout | boolean | Set the timeout for certain imap operations: 1: Open, 2: Read, 3: Write, 4: Close | -| getTimeout | string or int $type | int | Check current mailbox | -| setDefaultMessageMask | string $mask | self | Set the default message mask class | -| getDefaultMessageMask | | string | Get the current default message mask class name | -| setDefaultAttachmentMask | string $mask | self | Set the default attachment mask class | -| getDefaultAttachmentMask | | string | Get the current default attachment mask class name | -| getFolderPath | | string | Get the current folder path | - -### [Message::class](src/Message.php) - -| Method | Arguments | Return | Description | -| --------------- | ----------------------------- | :------------------: | -------------------------------------- | -| parseBody | | Message | Parse the Message body | -| delete | boolean $expunge | boolean | Delete the current Message | -| restore | boolean $expunge | boolean | Restore a deleted Message | -| copy | string $mailbox, int $options | boolean | Copy the current Messages to a mailbox | -| move | string $mailbox, int $options | boolean | Move the current Messages to a mailbox | -| getContainingFolder | Folder or null $folder | Folder or null | Get the folder containing the message | -| moveToFolder | string $mailbox, boolean $expunge, boolean $create_folder | Message | Move the Message into an other Folder | -| setFlag | string or array $flag | boolean | Set one or many flags | -| unsetFlag | string or array $flag | boolean | Unset one or many flags | -| hasTextBody | | | Check if the Message has a text body | -| hasHTMLBody | | | Check if the Message has a html body | -| getTextBody | | string | Get the Message text body | -| getHTMLBody | | string | Get the Message html body | -| getAttachments | | AttachmentCollection | Get all message attachments | -| hasAttachments | | boolean | Checks if there are any attachments present | -| getClient | | Client | Get the current Client instance | -| getUid | | string | Get the current UID | -| getFetchOptions | | string | Get the current fetch option | -| getMsglist | | integer | Get the current message list | -| getHeaderInfo | | object | Get the current header_info object | -| getHeader | | string | Get the current raw header | -| getMessageId | | string | Get the current message ID | -| getMessageNo | | integer | Get the current message number | -| getPriority | | integer | Get the current message priority | -| getSubject | | string | Get the current subject | -| getReferences | | mixed | Get any potentially present references | -| getDate | | Carbon | Get the current date object | -| getFrom | | array | Get the current from information | -| getTo | | array | Get the current to information | -| getCc | | array | Get the current cc information | -| getBcc | | array | Get the current bcc information | -| getReplyTo | | array | Get the current reply to information | -| getInReplyTo | | string | Get the current In-Reply-To | -| getSender | | array | Get the current sender information | -| getBodies | | mixed | Get the current bodies | -| getRawBody | | mixed | Get the current raw message body | -| getFlags | | FlagCollection | Get the current message flags | -| is | | boolean | Does this message match another one? | -| getStructure | | object | The raw message structure | -| getFolder | | Folder | The current folder | -| mask | string $mask = null | Mask | Get a masked instance | -| setMask | string $mask | Message | Set the mask class | -| getMask | | string | Get the current mask class name | - -### [Folder::class](src/Folder.php) - -| Method | Arguments | Return | Description | -| ----------------- | ----------------------------------------------------------------------------------- | :---------------: | ---------------------------------------------- | -| hasChildren | | bool | Determine if folder has children. | -| setChildren | array $children | self | Set children. | -| getMessage | integer $uid, integer or null $msglist, int or null fetch_options, bool $fetch_body, bool $fetch_attachment, bool $fetch_flags | Message | Get a specific message from folder. | -| getMessages | string $criteria, int or null $fetch_options, bool $fetch_body, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get messages from folder. | -| getUnseenMessages | string $criteria, int or null $fetch_options, bool $fetch_body, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get Unseen messages from folder. | -| searchMessages | array $where, int or null $fetch_options, bool $fetch_body, string $charset, bool $fetch_attachment, bool $fetch_flags | MessageCollection | Get specific messages from a given folder. | -| delete | | | Delete the current Mailbox | -| move | string $mailbox | | Move or Rename the current Mailbox | -| getStatus | integer $options | object | Returns status information on a mailbox | -| appendMessage | string $message, string $options, string $internal_date | bool | Append a string message to the current mailbox | -| getClient | | Client | Get the current Client instance | -| query | string $charset = 'UTF-8' | WhereQuery | Get the current Client instance | -| messages | string $charset = 'UTF-8' | WhereQuery | Alias for Folder::query() | -| search | string $charset = 'UTF-8' | WhereQuery | Alias for Folder::query() | - -### [Query::class](src/Query/WhereQuery.php) - -| Method | Arguments | Return | Description | -| ------------------ | --------------------------------- | :---------------: | ---------------------------------------------- | -| where | mixed $criteria, $value = null | WhereQuery | Add new criteria to the current query | -| orWhere | Closure $closure | WhereQuery | If supported you can perform extended search requests | -| andWhere | Closure $closure | WhereQuery | If supported you can perform extended search requests | -| all | | WhereQuery | Select all available messages | -| answered | | WhereQuery | Select answered messages | -| deleted | | WhereQuery | Select deleted messages | -| new | | WhereQuery | Select new messages | -| not | | WhereQuery | Not select messages | -| old | | WhereQuery | Select old messages | -| recent | | WhereQuery | Select recent messages | -| seen | | WhereQuery | Select seen messages | -| unanswered | | WhereQuery | Select unanswered messages | -| undeleted | | WhereQuery | Select undeleted messages | -| unflagged | | WhereQuery | Select unflagged messages | -| unseen | | WhereQuery | Select unseen messages | -| noXSpam | | WhereQuery | Select as no xspam flagged messages | -| isXSpam | | WhereQuery | Select as xspam flagged messages | -| language | string $value | WhereQuery | Select messages matching a given language | -| unkeyword | string $value | WhereQuery | Select messages matching a given unkeyword | -| messageId | string $value | WhereQuery | Select messages matching a given message id | -| to | string $value | WhereQuery | Select messages matching a given receiver (To:) | -| text | string $value | WhereQuery | Select messages matching a given text body | -| subject | string $value | WhereQuery | Select messages matching a given subject | -| since | string $value | WhereQuery | Select messages since a given date | -| on | string $value | WhereQuery | Select messages on a given date | -| keyword | string $value | WhereQuery | Select messages matching a given keyword | -| from | string $value | WhereQuery | Select messages matching a given sender (From:) | -| flagged | string $value | WhereQuery | Select messages matching a given flag | -| cc | string $value | WhereQuery | Select messages matching a given receiver (CC:) | -| body | string $value | WhereQuery | Select messages matching a given HTML body | -| before | string $value | WhereQuery | Select messages before a given date | -| bcc | string $value | WhereQuery | Select messages matching a given receiver (BCC:) | -| count | | integer | Count all available messages matching the current search criteria | -| get | | MessageCollection | Fetch messages with the current query | -| limit | integer $limit, integer $page = 1 | WhereQuery | Limit the amount of messages being fetched | -| setFetchOptions | boolean $fetch_options | WhereQuery | Set the fetch options | -| setFetchBody | boolean $fetch_body | WhereQuery | Set the fetch body option | -| getFetchAttachment | boolean $fetch_attachment | WhereQuery | Set the fetch attachment option | -| setFetchFlags | boolean $fetch_flags | WhereQuery | Set the fetch flags option | -| leaveUnread | | WhereQuery | Don't mark all messages as "read" while fetching: | -| markAsRead | | WhereQuery | Mark all messages as "read" while fetching | -| paginate | int $perPage = 5, $page = null, $pageName = 'imap_page' | LengthAwarePaginator | Paginate the current query. | - - -### [Attachment::class](src/Attachment.php) - -| Method | Arguments | Return | Description | -| -------------- | ------------------------------ | :------------: | ------------------------------------------------------ | -| getContent | | string or null | Get attachment content | -| getMimeType | | string or null | Get attachment mime type | -| getExtension | | string or null | Get a guessed attachment extension | -| getId | | string or null | Get attachment id | -| getName | | string or null | Get attachment name | -| getPartNumber | | int or null | Get attachment part number | -| getContent | | string or null | Get attachment content | -| setSize | | int or null | Get attachment size | -| getType | | string or null | Get attachment type | -| getDisposition | | string or null | Get attachment disposition | -| getContentType | | string or null | Get attachment content type | -| getImgSrc | | string or null | Get attachment image source as base64 encoded data url | -| save | string $path, string $filename | boolean | Save the attachment content to your filesystem | -| mask | string $mask = null | Mask | Get a masked instance | -| setMask | string $mask | Attachment | Set the mask class | -| getMask | | string | Get the current mask class name | - -### [Mask::class](src/Support/Masks/Mask.php) - -| Method | Arguments | Return | Description | -| -------------- | ------------------------------ | :------------: | ------------------------------------------------------ | -| getParent | | Masked parent | Get the masked parent object | -| getAttributes | | array | Get all cloned attributes | -| __get | | mixed | Access any cloned parent attribute | -| __set | | mixed | Set any cloned parent attribute | -| __inherit | | mixed | All public methods of the given parent are callable | - -### [MessageMask::class](src/Support/Masks/MessageMask.php) - -| Method | Arguments | Return | Description | -| ----------------------------------- | -------------------------------------- | :------------: | ------------------------------------------ | -| getHtmlBody | | string or null | Get HTML body | -| getCustomHTMLBody | callable or bool $callback | string or null | Get a custom HTML body | -| getHTMLBodyWithEmbeddedBase64Images | | string or null | Get HTML body with embedded base64 images | -| getHTMLBodyWithEmbeddedUrlImages | string $route_name, array $params = [] | string or null | Get HTML body with embedded routed images | - -### [AttachmentMask::class](src/Support/Masks/AttachmentMask.php) - -| Method | Arguments | Return | Description | -| -------------- | ------------------------------ | :------------: | ------------------------------------------------------ | -| getContentBase64Encoded | | string or null | Get attachment content | -| getImageSrc | | string or null | Get attachment mime type | - -### [MessageCollection::class](src/Support/MessageCollection.php) -Extends [Illuminate\Support\Collection::class](https://laravel.com/api/5.4/Illuminate/Support/Collection.html) - -| Method | Arguments | Return | Description | -| -------- | --------------------------------------------------- | :------------------: | -------------------------------- | -| paginate | int $perPage = 15, $page = null, $pageName = 'page' | LengthAwarePaginator | Paginate the current collection. | - -### [FlagCollection::class](src/Support/FlagCollection.php) -Extends [Illuminate\Support\Collection::class](https://laravel.com/api/5.4/Illuminate/Support/Collection.html) - -| Method | Arguments | Return | Description | -| -------- | --------------------------------------------------- | :------------------: | -------------------------------- | -| paginate | int $perPage = 15, $page = null, $pageName = 'page' | LengthAwarePaginator | Paginate the current collection. | - -### [AttachmentCollection::class](src/Support/AttachmentCollection.php) -Extends [Illuminate\Support\Collection::class](https://laravel.com/api/5.4/Illuminate/Support/Collection.html) - -| Method | Arguments | Return | Description | -| -------- | --------------------------------------------------- | :------------------: | -------------------------------- | -| paginate | int $perPage = 15, $page = null, $pageName = 'page' | LengthAwarePaginator | Paginate the current collection. | - -### [FolderCollection::class](src/Support/FolderCollection.php) -Extends [Illuminate\Support\Collection::class](https://laravel.com/api/5.4/Illuminate/Support/Collection.html) - -| Method | Arguments | Return | Description | -| -------- | --------------------------------------------------- | :------------------: | -------------------------------- | -| paginate | int $perPage = 15, $page = null, $pageName = 'page' | LengthAwarePaginator | Paginate the current collection. | -### Known issues -| Error | Solution | -| ------------------------------------------------------------------------- | ---------------------------------------------------------- | -| Kerberos error: No credentials cache file found (try running kinit) (...) | Uncomment "DISABLE_AUTHENTICATOR" inside `config/imap.php` | -| imap_fetchbody() expects parameter 4 to be long, string given (...) | Make sure that `imap.options.fetch` is a valid integer | -| Use of undefined constant FT_UID - assumed 'FT_UID' (...) | Please take a look at [#14](https://github.com/Webklex/php-imap/issues/14) [#30](https://github.com/Webklex/php-imap/issues/30) | -| DateTime::__construct(): Failed to parse time string (...) | Please report any new invalid timestamps to [#45](https://github.com/Webklex/php-imap/issues/45) | -| imap_open(): Couldn't open (...) Please log in your web browser: (...) | In order to use IMAP on some services (such as Gmail) you need to enable it first. [Google help page]( https://support.google.com/mail/answer/7126229?hl=en) | -| imap_headerinfo(): Bad message number | This happens if no Message number is available. Please make sure Message::parseHeader() has run before | -| imap_fetchheader(): Bad message number | Try to change the `message_key` [#243](https://github.com/Webklex/laravel-imap/issues/243) +## Alternatives & Different Flavors +This library and especially the code flavor It's written in, is certainly not for everyone. If you are looking for a +different approach, you might want to check out the following libraries: +- [ddeboer/imap](https://github.com/ddeboer/imap) +- [barbushin/php-imap](https://github.com/barbushin/php-imap) +- [DirectoryTree/ImapEngine](https://github.com/DirectoryTree/ImapEngine) -## Milestones & upcoming features -* Wiki!! - ## Change log +Please see [CHANGELOG][link-changelog] for more information what has changed recently. -Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. ## Security - If you discover any security related issues, please email github@webklex.com instead of using the issue tracker. -## Credits +## Credits - [Webklex][link-author] - [All Contributors][link-contributors] ## License +The MIT License (MIT). Please see [License File][link-license] for more information. -The MIT License (MIT). Please see [License File](LICENSE.md) for more information. -[ico-version]: https://img.shields.io/packagist/v/Webklex/php-imap.svg?style=flat-square +[ico-release]: https://img.shields.io/packagist/v/Webklex/php-imap.svg?style=flat-square&label=version +[ico-prerelease]: https://img.shields.io/github/v/release/webklex/php-imap?include_prereleases&style=flat-square&label=pre-release [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square -[ico-travis]: https://img.shields.io/travis/Webklex/php-imap/master.svg?style=flat-square -[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/Webklex/php-imap.svg?style=flat-square -[ico-code-quality]: https://img.shields.io/scrutinizer/g/Webklex/php-imap.svg?style=flat-square [ico-downloads]: https://img.shields.io/packagist/dt/Webklex/php-imap.svg?style=flat-square +[ico-hits]: https://hits.webklex.com/svg/webklex/php-imap +[ico-snyk]: https://snyk-widget.herokuapp.com/badge/composer/webklex/php-imap/badge.svg +[ico-discord]: https://img.shields.io/static/v1?label=discord&message=open&color=5865f2&style=flat-square [link-packagist]: https://packagist.org/packages/Webklex/php-imap -[link-travis]: https://travis-ci.org/Webklex/php-imap -[link-scrutinizer]: https://scrutinizer-ci.com/g/Webklex/php-imap/code-structure -[link-code-quality]: https://scrutinizer-ci.com/g/Webklex/php-imap [link-downloads]: https://packagist.org/packages/Webklex/php-imap [link-author]: https://github.com/webklex [link-contributors]: https://github.com/Webklex/php-imap/graphs/contributors -[link-jetbrains]: https://www.jetbrains.com +[link-license]: https://github.com/Webklex/php-imap/blob/master/LICENSE +[link-changelog]: https://github.com/Webklex/php-imap/blob/master/CHANGELOG.md +[link-hits]: https://hits.webklex.com +[link-snyk]: https://snyk.io/vuln/composer:webklex%2Fphp-imap +[link-discord]: https://discord.gg/vUHrbfbDr9 + + +[ico-sponsor-feline]: https://cdn.feline.dk/public/feline.png +[link-sponsor-feline]: https://www.feline.dk +[ico-sponsor-elb-bit]: https://www.elb-bit.de/user/themes/deliver/images/logo_small.png +[link-sponsor-elb-bit]: https://www.elb-bit.de?ref=webklex/php-imap \ No newline at end of file diff --git a/composer.json b/composer.json index 0ff14414..334275fa 100644 --- a/composer.json +++ b/composer.json @@ -19,15 +19,25 @@ } ], "require": { - "php": ">=5.5.9", - "ext-imap": "*", + "php": "^8.0.2", + "ext-openssl": "*", + "ext-json": "*", "ext-mbstring": "*", "ext-iconv": "*", + "ext-libxml": "*", + "ext-zip": "*", "ext-fileinfo": "*", - "nesbot/carbon": ">=1.33", + "nesbot/carbon": "^2.62.1|^3.2.4", "symfony/http-foundation": ">=2.8.0", "illuminate/pagination": ">=5.0.0" }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, "autoload": { "psr-4": { "Webklex\\PHPIMAP\\": "src" @@ -43,7 +53,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "6.0-dev" } }, "minimum-stability": "dev", diff --git a/examples/custom_attachment_mask.php b/examples/custom_attachment_mask.php index 177ed6f9..eb4973e3 100644 --- a/examples/custom_attachment_mask.php +++ b/examples/custom_attachment_mask.php @@ -16,7 +16,7 @@ class CustomAttachmentMask extends \Webklex\PHPIMAP\Support\Masks\AttachmentMask * New custom method which can be called through a mask * @return string */ - public function token(){ + public function token(): string { return implode('-', [$this->id, $this->getMessage()->getUid(), $this->name]); } @@ -24,7 +24,7 @@ public function token(){ * Custom attachment saving method * @return bool */ - public function custom_save() { + public function custom_save(): bool { $path = "foo".DIRECTORY_SEPARATOR."bar".DIRECTORY_SEPARATOR; $filename = $this->token(); @@ -33,14 +33,15 @@ public function custom_save() { } -/** @var \Webklex\PHPIMAP\Client $oClient */ $cm = new \Webklex\PHPIMAP\ClientManager('path/to/config/imap.php'); -$oClient = $cm->account('default'); -$oClient->connect(); -$oClient->setDefaultAttachmentMask(CustomAttachmentMask::class); + +/** @var \Webklex\PHPIMAP\Client $client */ +$client = $cm->account('default'); +$client->connect(); +$client->setDefaultAttachmentMask(CustomAttachmentMask::class); /** @var \Webklex\PHPIMAP\Folder $folder */ -$folder = $oClient->getFolder('INBOX'); +$folder = $client->getFolder('INBOX'); /** @var \Webklex\PHPIMAP\Message $message */ $message = $folder->query()->limit(1)->get()->first(); diff --git a/examples/custom_message_mask.php b/examples/custom_message_mask.php index 855c4385..0463c65b 100644 --- a/examples/custom_message_mask.php +++ b/examples/custom_message_mask.php @@ -16,7 +16,7 @@ class CustomMessageMask extends \Webklex\PHPIMAP\Support\Masks\MessageMask { * New custom method which can be called through a mask * @return string */ - public function token(){ + public function token(): string { return implode('-', [$this->message_id, $this->uid, $this->message_no]); } @@ -24,19 +24,20 @@ public function token(){ * Get number of message attachments * @return integer */ - public function getAttachmentCount() { + public function getAttachmentCount(): int { return $this->getAttachments()->count(); } } -/** @var \Webklex\PHPIMAP\Client $oClient */ $cm = new \Webklex\PHPIMAP\ClientManager('path/to/config/imap.php'); -$oClient = $cm->account('default'); -$oClient->connect(); + +/** @var \Webklex\PHPIMAP\Client $client */ +$client = $cm->account('default'); +$client->connect(); /** @var \Webklex\PHPIMAP\Folder $folder */ -$folder = $oClient->getFolder('INBOX'); +$folder = $client->getFolder('INBOX'); /** @var \Webklex\PHPIMAP\Message $message */ $message = $folder->query()->limit(1)->get()->first(); @@ -44,7 +45,7 @@ public function getAttachmentCount() { /** @var CustomMessageMask $masked_message */ $masked_message = $message->mask(CustomMessageMask::class); -echo 'Token for uid ['.$masked_message->uid.']: '.$masked_message->token().' @atms:'.$masked_message->getAttachmentCount(); +echo 'Token for uid [' . $masked_message->uid . ']: ' . $masked_message->token() . ' @atms:' . $masked_message->getAttachmentCount(); $masked_message->setFlag('seen'); diff --git a/examples/folder_structure.blade.php b/examples/folder_structure.blade.php index 701e991c..a80dfb6c 100644 --- a/examples/folder_structure.blade.php +++ b/examples/folder_structure.blade.php @@ -12,7 +12,7 @@ /** * @var \Webklex\PHPIMAP\Support\FolderCollection $paginator - * @var \Webklex\PHPIMAP\Folder $oFolder + * @var \Webklex\PHPIMAP\Folder $folder */ ?> @@ -25,17 +25,17 @@ count() > 0): ?> - + - name; ?> - search()->unseen()->leaveUnread()->setFetchBody(false)->setFetchAttachment(false)->get()->count(); ?> + name; ?> + search()->unseen()->setFetchBody(false)->count(); ?> No folders found - + diff --git a/examples/message_table.blade.php b/examples/message_table.blade.php index 517a399b..c3bd7af8 100644 --- a/examples/message_table.blade.php +++ b/examples/message_table.blade.php @@ -12,8 +12,7 @@ /** * @var \Webklex\PHPIMAP\Support\FolderCollection $paginator - * @var \Webklex\PHPIMAP\Folder $oFolder - * @var \Webklex\PHPIMAP\Message $oMessage + * @var \Webklex\PHPIMAP\Message $message */ ?> @@ -28,18 +27,18 @@ count() > 0): ?> - - - getUid(); ?> - getSubject(); ?> - getFrom()[0]->mail; ?> - getAttachments()->count() > 0 ? 'yes' : 'no'; ?> - - + + + getUid(); ?> + getSubject(); ?> + getFrom()[0]->mail; ?> + getAttachments()->count() > 0 ? 'yes' : 'no'; ?> + + - - No messages found - + + No messages found + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..02d56a89 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + src/ + + + + + + + + + + tests + tests/fixtures + tests/issues + tests/live + + + + + + + + + + + + + + + + + diff --git a/src/Address.php b/src/Address.php new file mode 100644 index 00000000..87c6479e --- /dev/null +++ b/src/Address.php @@ -0,0 +1,108 @@ +personal = $object->personal ?? ''; } + if (property_exists($object, "mailbox")){ $this->mailbox = $object->mailbox ?? ''; } + if (property_exists($object, "host")){ $this->host = $object->host ?? ''; } + if (property_exists($object, "mail")){ $this->mail = $object->mail ?? ''; } + if (property_exists($object, "full")){ $this->full = $object->full ?? ''; } + $this->boot(); + } + + /** + * Boot the address + */ + private function boot(): void { + if($this->mail === "" && $this->mailbox !== "" && $this->host !== ""){ + $this->mail = $this->mailbox . "@" . $this->host; + }elseif($this->mail === "" && $this->mailbox !== ""){ + $this->mail = $this->mailbox; + } + + if($this->full === "" && $this->mail !== "" && $this->personal !== ""){ + $this->full = $this->personal . " <" . $this->mail . ">"; + }elseif($this->full === "" && $this->mail !== ""){ + $this->full = $this->mail; + } + } + + + /** + * Return the stringified address + * + * @return string + */ + public function __toString() { + return $this->full ?: ""; + } + + /** + * Return the serialized address + * + * @return array + */ + public function __serialize(){ + return [ + "personal" => $this->personal, + "mailbox" => $this->mailbox, + "host" => $this->host, + "mail" => $this->mail, + "full" => $this->full, + ]; + } + + /** + * Convert instance to array + * + * @return array + */ + public function toArray(): array { + return $this->__serialize(); + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function toString(): string { + return $this->__toString(); + } +} \ No newline at end of file diff --git a/src/Attachment.php b/src/Attachment.php index 8f617f56..e2ce8128 100755 --- a/src/Attachment.php +++ b/src/Attachment.php @@ -13,8 +13,8 @@ namespace Webklex\PHPIMAP; use Illuminate\Support\Str; -use Illuminate\Support\Facades\File; -use Symfony\Component\HttpFoundation\File\MimeType\ExtensionGuesser; +use Webklex\PHPIMAP\Decoder\DecoderInterface; +use Webklex\PHPIMAP\Exceptions\DecoderNotFoundException; use Webklex\PHPIMAP\Exceptions\MaskNotFoundException; use Webklex\PHPIMAP\Exceptions\MethodNotFoundException; use Webklex\PHPIMAP\Support\Masks\AttachmentMask; @@ -24,15 +24,18 @@ * * @package Webklex\PHPIMAP * - * @property integer part_number - * @property integer size - * @property string content - * @property string type - * @property string content_type - * @property string id - * @property string name - * @property string disposition - * @property string img_src + * @property integer $part_number + * @property integer $size + * @property string $content + * @property string $type + * @property string $content_type + * @property string $id + * @property string $hash + * @property string $name + * @property string $description + * @property string $filename + * @property ?string $disposition + * @property string $img_src * * @method integer getPartNumber() * @method integer setPartNumber(integer $part_number) @@ -44,6 +47,8 @@ * @method string setContentType(string $content_type) * @method string getId() * @method string setId(string $id) + * @method string getHash() + * @method string setHash(string $hash) * @method string getSize() * @method string setSize(integer $size) * @method string getName() @@ -53,53 +58,87 @@ */ class Attachment { - /** @var Message $oMessage */ - protected $oMessage; + /** + * @var Message $message + */ + protected Message $message; - /** @var array $config */ - protected $config = []; + /** + * Used config + * + * @var Config $config + */ + protected Config $config; - /** @var object $structure */ - protected $structure; + /** + * Attachment options + * + * @var array $options + */ + protected array $options = []; + + /** @var Part $part */ + protected Part $part; + + /** + * Decoder instance + * + * @var DecoderInterface $decoder + */ + protected DecoderInterface $decoder; - /** @var array $attributes */ - protected $attributes = [ - 'part_number' => 1, - 'content' => null, - 'type' => null, + /** + * Attribute holder + * + * @var array $attributes + */ + protected array $attributes = [ + 'content' => null, + 'hash' => null, + 'type' => null, + 'part_number' => 0, 'content_type' => null, - 'id' => null, - 'name' => null, - 'disposition' => null, - 'img_src' => null, - 'size' => null, + 'id' => null, + 'name' => null, + 'filename' => null, + 'description' => null, + 'disposition' => null, + 'img_src' => null, + 'size' => null, ]; /** * Default mask + * * @var string $mask */ - protected $mask = AttachmentMask::class; + protected string $mask = AttachmentMask::class; /** * Attachment constructor. - * - * @param Message $oMessage - * @param object $structure - * @param integer $part_number - * - * @throws Exceptions\ConnectionFailedException + * @param Message $message + * @param Part $part + * @throws DecoderNotFoundException */ - public function __construct(Message $oMessage, $structure, $part_number = 1) { - $this->config = ClientManager::get('options'); - - $this->oMessage = $oMessage; - $this->structure = $structure; - $this->part_number = ($part_number) ? $part_number : $this->part_number; - - $default_mask = $this->oMessage->getClient()->getDefaultAttachmentMask(); - if($default_mask != null) { - $this->mask = $default_mask; + public function __construct(Message $message, Part $part) { + $this->message = $message; + $this->config = $this->message->getConfig(); + $this->options = $this->config->get('options'); + $this->decoder = $this->config->getDecoder("attachment"); + + $this->part = $part; + $this->part_number = $part->part_number; + + if ($this->message->getClient()) { + $default_mask = $this->message->getClient()?->getDefaultAttachmentMask(); + if ($default_mask != null) { + $this->mask = $default_mask; + } + } else { + $default_mask = $this->config->getMask("attachment"); + if ($default_mask != "") { + $this->mask = $default_mask; + } } $this->findType(); @@ -114,16 +153,16 @@ public function __construct(Message $oMessage, $structure, $part_number = 1) { * @return mixed * @throws MethodNotFoundException */ - public function __call($method, $arguments) { - if(strtolower(substr($method, 0, 3)) === 'get') { + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { $name = Str::snake(substr($method, 3)); - if(isset($this->attributes[$name])) { + if (isset($this->attributes[$name])) { return $this->attributes[$name]; } return null; - }elseif (strtolower(substr($method, 0, 3)) === 'set') { + } elseif (strtolower(substr($method, 0, 3)) === 'set') { $name = Str::snake(substr($method, 3)); $this->attributes[$name] = array_pop($arguments); @@ -131,10 +170,11 @@ public function __call($method, $arguments) { return $this->attributes[$name]; } - throw new MethodNotFoundException("Method ".self::class.'::'.$method.'() is not supported'); + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); } /** + * Magic setter * @param $name * @param $value * @@ -147,12 +187,13 @@ public function __set($name, $value) { } /** + * magic getter * @param $name * * @return mixed|null */ public function __get($name) { - if(isset($this->attributes[$name])) { + if (isset($this->attributes[$name])) { return $this->attributes[$name]; } @@ -162,161 +203,204 @@ public function __get($name) { /** * Determine the structure type */ - protected function findType() { - switch ($this->structure->type) { - case IMAP::ATTACHMENT_TYPE_MESSAGE: - $this->type = 'message'; - break; - case IMAP::ATTACHMENT_TYPE_APPLICATION: - $this->type = 'application'; - break; - case IMAP::ATTACHMENT_TYPE_AUDIO: - $this->type = 'audio'; - break; - case IMAP::ATTACHMENT_TYPE_IMAGE: - $this->type = 'image'; - break; - case IMAP::ATTACHMENT_TYPE_VIDEO: - $this->type = 'video'; - break; - case IMAP::ATTACHMENT_TYPE_MODEL: - $this->type = 'model'; - break; - case IMAP::ATTACHMENT_TYPE_TEXT: - $this->type = 'text'; - break; - case IMAP::ATTACHMENT_TYPE_MULTIPART: - $this->type = 'multipart'; - break; - default: - $this->type = 'other'; - break; - } + protected function findType(): void { + $this->type = match ($this->part->type) { + IMAP::ATTACHMENT_TYPE_MESSAGE => 'message', + IMAP::ATTACHMENT_TYPE_APPLICATION => 'application', + IMAP::ATTACHMENT_TYPE_AUDIO => 'audio', + IMAP::ATTACHMENT_TYPE_IMAGE => 'image', + IMAP::ATTACHMENT_TYPE_VIDEO => 'video', + IMAP::ATTACHMENT_TYPE_MODEL => 'model', + IMAP::ATTACHMENT_TYPE_TEXT => 'text', + IMAP::ATTACHMENT_TYPE_MULTIPART => 'multipart', + default => 'other', + }; } /** * Fetch the given attachment - * - * @throws Exceptions\ConnectionFailedException */ - protected function fetch() { + protected function fetch(): void { + $content = $this->part->content; + + $this->content_type = $this->part->content_type; + $this->content = $this->decoder->decode($content, $this->part->encoding); + + // Create a hash of the raw part - this can be used to identify the attachment in the message context. However, + // it is not guaranteed to be unique and collisions are possible. + // Some additional online resources: + // - https://en.wikipedia.org/wiki/Hash_collision + // - https://www.php.net/manual/en/function.hash.php + // - https://php.watch/articles/php-hash-benchmark + // Benchmark speeds: + // -xxh3 ~15.19(GB/s) (requires php-xxhash extension or >= php8.1) + // -crc32c ~14.12(GB/s) + // -sha256 ~0.25(GB/s) + // xxh3 would be nice to use, because of its extra speed and 32 instead of 8 bytes, but it is not compatible with + // php < 8.1. crc32c is the next fastest and is compatible with php >= 5.1. sha256 is the slowest, but is compatible + // with php >= 5.1 and is the most likely to be unique. crc32c is the best compromise between speed and uniqueness. + // Unique enough for our purposes, but not so slow that it could be a bottleneck. + $this->hash = hash("crc32c", $this->part->getHeader()->raw."\r\n\r\n".$this->part->content); + + if (($id = $this->part->id) !== null) { + $this->id = str_replace(['<', '>'], '', $id); + }else { + $this->id = $this->hash; + } - $content = \imap_fetchbody($this->oMessage->getClient()->getConnection(), $this->oMessage->getUid(), $this->part_number, $this->oMessage->getFetchOptions() | FT_UID); + $this->size = $this->part->bytes; + $this->disposition = $this->part->disposition; - $this->content_type = $this->type.'/'.strtolower($this->structure->subtype); - $this->content = $this->oMessage->decodeString($content, $this->structure->encoding); + if (($filename = $this->part->filename) !== null) { + $this->filename = $this->decodeName($filename); + } - if (property_exists($this->structure, 'id')) { - $this->id = str_replace(['<', '>'], '', $this->structure->id); + if (($description = $this->part->description) !== null) { + $this->description = $this->part->getHeader()->getDecoder()->decode($description); } - if (property_exists($this->structure, 'bytes')) { - $this->size = $this->structure->bytes; + if (($name = $this->part->name) !== null) { + $this->name = $this->decodeName($name); } - if (property_exists($this->structure, 'dparameters')) { - foreach ($this->structure->dparameters as $parameter) { - if (strtolower($parameter->attribute) == "filename") { - $this->setName($parameter->value); - $this->disposition = property_exists($this->structure, 'disposition') ? $this->structure->disposition : null; - break; + if (IMAP::ATTACHMENT_TYPE_MESSAGE == $this->part->type) { + if ($this->part->ifdescription) { + if (!$this->name) { + $this->name = $this->part->description; } + } else if (!$this->name) { + $this->name = $this->part->subtype; } } + $this->attributes = array_merge($this->part->getHeader()->getAttributes(), $this->attributes); - if (IMAP::ATTACHMENT_TYPE_MESSAGE == $this->structure->type) { - if ($this->structure->ifdescription) { - $this->setName($this->structure->description); - } else { - $this->setName($this->structure->subtype); - } + if (!$this->filename) { + $this->filename = $this->hash; } - if (!$this->name && property_exists($this->structure, 'parameters')) { - foreach ($this->structure->parameters as $parameter) { - if (strtolower($parameter->attribute) == "name") { - $this->setName($parameter->value); - $this->disposition = property_exists($this->structure, 'disposition') ? $this->structure->disposition : null; - break; - } - } + if (!$this->name && $this->filename != "") { + $this->name = $this->filename; } } /** * Save the attachment content to your filesystem - * - * @param string|null $path + * @param string $path * @param string|null $filename * * @return boolean */ - public function save($path = null, $filename = null) { - $path = $path ?: storage_path(); - $filename = $filename ?: $this->getName(); - - $path = substr($path, -1) == DIRECTORY_SEPARATOR ? $path : $path.DIRECTORY_SEPARATOR; + public function save(string $path, ?string $filename = null): bool { + $filename = $filename ? $this->decodeName($filename) : $this->filename; - return File::put($path.$filename, $this->getContent()) !== false; + return file_put_contents($path . DIRECTORY_SEPARATOR . $filename, $this->getContent()) !== false; } /** - * @param $name - */ - public function setName($name) { - if($this->config['decoder']['message']['subject'] === 'utf-8') { - $this->name = \imap_utf8($name); - }else{ - $this->name = mb_decode_mimeheader($name); - } - } - - /** - * @return null|string + * Decode a given name + * @param string|null $name * - * @deprecated 1.4.0:2.0.0 No longer needed. Use AttachmentMask::getImageSrc() instead + * @return string */ - public function getImgSrc() { - if ($this->type == 'image' && $this->img_src == null) { - $this->img_src = 'data:'.$this->content_type.';base64,'.base64_encode($this->content); + public function decodeName(?string $name): string { + if ($name !== null) { + if (str_contains($name, "''")) { + $parts = explode("''", $name); + if (EncodingAliases::has($parts[0])) { + $encoding = $parts[0]; + $name = implode("''", array_slice($parts, 1)); + } + } + + $decoder = $this->decoder->getOptions()['message']; + if (preg_match('/=\?([^?]+)\?(Q|B)\?(.+)\?=/i', $name, $matches)) { + $name = $this->part->getHeader()->getDecoder()->decode($name); + } elseif ($decoder === 'utf-8' && extension_loaded('imap')) { + $name = \imap_utf8($name); + } + + // check if $name is url encoded + if (preg_match('/%[0-9A-F]{2}/i', $name)) { + $name = urldecode($name); + } + + if (isset($encoding)) { + $name = EncodingAliases::convert($name, $encoding); + } + + if($this->config->get('security.sanitize_filenames', true)) { + $name = $this->sanitizeName($name); + } + + return $name; } - return $this->img_src; + return ""; } /** + * Get the attachment mime type + * * @return string|null */ - public function getMimeType(){ + public function getMimeType(): ?string { return (new \finfo())->buffer($this->getContent(), FILEINFO_MIME_TYPE); } /** + * Try to guess the attachment file extension + * * @return string|null */ - public function getExtension(){ - return ExtensionGuesser::getInstance()->guess($this->getMimeType()); + public function getExtension(): ?string { + $extension = null; + $guesser = "\Symfony\Component\Mime\MimeTypes"; + if (class_exists($guesser) !== false) { + /** @var Symfony\Component\Mime\MimeTypes $guesser */ + $extensions = $guesser::getDefault()->getExtensions($this->getMimeType()); + $extension = $extensions[0] ?? null; + } + if ($extension === null) { + $deprecated_guesser = "\Symfony\Component\HttpFoundation\File\MimeType\ExtensionGuesser"; + if (class_exists($deprecated_guesser) !== false) { + /** @var \Symfony\Component\HttpFoundation\File\MimeType\ExtensionGuesser $deprecated_guesser */ + $extension = $deprecated_guesser::getInstance()->guess($this->getMimeType()); + } + } + if ($extension === null) { + $parts = explode(".", $this->filename); + $extension = count($parts) > 1 ? end($parts) : null; + } + if ($extension === null) { + $parts = explode(".", $this->name); + $extension = count($parts) > 1 ? end($parts) : null; + } + return $extension; } /** + * Get all attributes + * * @return array */ - public function getAttributes(){ + public function getAttributes(): array { return $this->attributes; } /** * @return Message */ - public function getMessage(){ - return $this->oMessage; + public function getMessage(): Message { + return $this->message; } /** + * Set the default mask * @param $mask + * * @return $this */ - public function setMask($mask){ - if(class_exists($mask)){ + public function setMask($mask): Attachment { + if (class_exists($mask)) { $this->mask = $mask; } @@ -324,12 +408,53 @@ public function setMask($mask){ } /** + * Get the used default mask + * * @return string */ - public function getMask(){ + public function getMask(): string { return $this->mask; } + /** + * Get the attachment options + * @return array + */ + public function getOptions(): array { + return $this->options; + } + + /** + * Set the attachment options + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): Attachment { + $this->options = $options; + return $this; + } + + /** + * Get the used config + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } + + /** + * Set the used config + * @param Config $config + * + * @return $this + */ + public function setConfig(Config $config): Attachment { + $this->config = $config; + return $this; + } + /** * Get a masked instance by providing a mask name * @param string|null $mask @@ -337,12 +462,54 @@ public function getMask(){ * @return mixed * @throws MaskNotFoundException */ - public function mask($mask = null){ + public function mask(?string $mask = null): mixed { $mask = $mask !== null ? $mask : $this->mask; - if(class_exists($mask)){ + if (class_exists($mask)) { return new $mask($this); } - throw new MaskNotFoundException("Unknown mask provided: ".$mask); + throw new MaskNotFoundException("Unknown mask provided: " . $mask); + } + + /** + * Get the decoder instance + * + * @return DecoderInterface + */ + public function getDecoder(): DecoderInterface { + return $this->decoder; + } + + /** + * Set the decoder instance + * @param DecoderInterface $decoder + * + * @return $this + */ + public function setDecoder(DecoderInterface $decoder): static { + $this->decoder = $decoder; + return $this; + } + + /** + * Sanitize a given name to prevent common attacks + * !!IMPORTANT!! Do not rely on this method alone - this is just the bare minimum. Additional measures should be taken + * to ensure that the file is safe to use. + * @param string $name + * + * @return string + */ + private function sanitizeName(string $name): string { + $replaces = [ + '/\\\\/' => '', + '/[\/\0:]+/' => '', + '/\.+/' => '.', + ]; + $name_starts_with_dots = str_starts_with($name, '..'); + $name = preg_replace(array_keys($replaces), array_values($replaces), $name); + if($name_starts_with_dots) { + return substr($name, 1); + } + return $name; } -} \ No newline at end of file +} diff --git a/src/Attribute.php b/src/Attribute.php new file mode 100644 index 00000000..c50cab75 --- /dev/null +++ b/src/Attribute.php @@ -0,0 +1,325 @@ +setName($name); + $this->add($value); + } + + /** + * Handle class invocation calls + * + * @return array|string + */ + public function __invoke(): array|string { + if ($this->count() > 1) { + return $this->toArray(); + } + return $this->toString(); + } + + /** + * Return the serialized address + * + * @return array + */ + public function __serialize(){ + return $this->values; + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function __toString() { + return implode(", ", $this->values); + } + + /** + * Return the stringified attribute + * + * @return string + */ + public function toString(): string { + return $this->__toString(); + } + + /** + * Convert instance to array + * + * @return array + */ + public function toArray(): array { + return $this->__serialize(); + } + + /** + * Convert first value to a date object + * + * @return Carbon + */ + public function toDate(): Carbon { + $date = $this->first(); + if ($date instanceof Carbon) return $date; + + return Carbon::parse($date); + } + + /** + * Determine if a value exists at a given key. + * + * @param int|string $key + * @return bool + */ + public function has(mixed $key = 0): bool { + return array_key_exists($key, $this->values); + } + + /** + * Determine if a value exists at a given key. + * + * @param int|string $key + * @return bool + */ + public function exist(mixed $key = 0): bool { + return $this->has($key); + } + + /** + * Check if the attribute contains the given value + * @param mixed $value + * + * @return bool + */ + public function contains(mixed $value): bool { + return in_array($value, $this->values, true); + } + + /** + * Get a value by a given key. + * + * @param int|string $key + * @return mixed + */ + public function get(int|string $key = 0): mixed { + return $this->values[$key] ?? null; + } + + /** + * Set the value by a given key. + * + * @param mixed $key + * @param mixed $value + * @return Attribute + */ + public function set(mixed $value, mixed $key = 0): Attribute { + if (is_null($key)) { + $this->values[] = $value; + } else { + $this->values[$key] = $value; + } + return $this; + } + + /** + * Unset a value by a given key. + * + * @param int|string $key + * @return Attribute + */ + public function remove(int|string $key = 0): Attribute { + if (isset($this->values[$key])) { + unset($this->values[$key]); + } + return $this; + } + + /** + * Add one or more values to the attribute + * @param array|mixed $value + * @param boolean $strict + * + * @return Attribute + */ + public function add(mixed $value, bool $strict = false): Attribute { + if (is_array($value)) { + return $this->merge($value, $strict); + }elseif ($value !== null) { + $this->attach($value, $strict); + } + + return $this; + } + + /** + * Merge a given array of values with the current values array + * @param array $values + * @param boolean $strict + * + * @return Attribute + */ + public function merge(array $values, bool $strict = false): Attribute { + foreach ($values as $value) { + $this->attach($value, $strict); + } + + return $this; + } + + /** + * Attach a given value to the current value array + * @param $value + * @param bool $strict + * @return Attribute + */ + public function attach($value, bool $strict = false): Attribute { + if ($strict === true) { + if ($this->contains($value) === false) { + $this->values[] = $value; + } + }else{ + $this->values[] = $value; + } + return $this; + } + + /** + * Set the attribute name + * @param $name + * + * @return Attribute + */ + public function setName($name): Attribute { + $this->name = $name; + + return $this; + } + + /** + * Get the attribute name + * + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * Get all values + * + * @return array + */ + public function all(): array { + reset($this->values); + return $this->values; + } + + /** + * Get the first value if possible + * + * @return mixed|null + */ + public function first(): mixed { + return reset($this->values); + } + + /** + * Get the last value if possible + * + * @return mixed|null + */ + public function last(): mixed { + return end($this->values); + } + + /** + * Get the number of values + * + * @return int + */ + public function count(): int { + return count($this->values); + } + + /** + * @see ArrayAccess::offsetExists + * @param mixed $offset + * @return bool + */ + public function offsetExists(mixed $offset): bool { + return $this->has($offset); + } + + /** + * @see ArrayAccess::offsetGet + * @param mixed $offset + * @return mixed + */ + public function offsetGet(mixed $offset): mixed { + return $this->get($offset); + } + + /** + * @see ArrayAccess::offsetSet + * @param mixed $offset + * @param mixed $value + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void { + $this->set($value, $offset); + } + + /** + * @see ArrayAccess::offsetUnset + * @param mixed $offset + * @return void + */ + public function offsetUnset(mixed $offset): void { + $this->remove($offset); + } + + /** + * @param callable $callback + * @return array + */ + public function map(callable $callback): array { + return array_map($callback, $this->values); + } +} \ No newline at end of file diff --git a/src/Client.php b/src/Client.php index 5862694c..5eb26ff2 100755 --- a/src/Client.php +++ b/src/Client.php @@ -12,16 +12,24 @@ namespace Webklex\PHPIMAP; +use ErrorException; +use Webklex\PHPIMAP\Connection\Protocols\ImapProtocol; +use Webklex\PHPIMAP\Connection\Protocols\LegacyProtocol; +use Webklex\PHPIMAP\Connection\Protocols\ProtocolInterface; +use Webklex\PHPIMAP\Exceptions\AuthFailedException; use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; -use Webklex\PHPIMAP\Exceptions\GetMessagesFailedException; -use Webklex\PHPIMAP\Exceptions\InvalidImapTimeoutTypeException; -use Webklex\PHPIMAP\Exceptions\MailboxFetchingException; +use Webklex\PHPIMAP\Exceptions\EventNotFoundException; +use Webklex\PHPIMAP\Exceptions\FolderFetchingException; +use Webklex\PHPIMAP\Exceptions\ImapBadRequestException; +use Webklex\PHPIMAP\Exceptions\ImapServerErrorException; use Webklex\PHPIMAP\Exceptions\MaskNotFoundException; -use Webklex\PHPIMAP\Exceptions\MessageSearchValidationException; +use Webklex\PHPIMAP\Exceptions\ProtocolNotSupportedException; +use Webklex\PHPIMAP\Exceptions\ResponseException; +use Webklex\PHPIMAP\Exceptions\RuntimeException; use Webklex\PHPIMAP\Support\FolderCollection; use Webklex\PHPIMAP\Support\Masks\AttachmentMask; use Webklex\PHPIMAP\Support\Masks\MessageMask; -use Webklex\PHPIMAP\Support\MessageCollection; +use Webklex\PHPIMAP\Traits\HasEvents; /** * Class Client @@ -29,347 +37,576 @@ * @package Webklex\PHPIMAP */ class Client { + use HasEvents; + /** + * Connection resource + * + * @var ?ProtocolInterface + */ + public ?ProtocolInterface $connection = null; /** - * @var boolean|resource + * Client configuration + * + * @var Config */ - public $connection = false; + protected Config $config; /** * Server hostname. * * @var string */ - public $host; + public string $host; /** * Server port. * * @var int */ - public $port; + public int $port; /** * Service protocol. * - * @var int + * @var string */ - public $protocol; + public string $protocol; /** * Server encryption. - * Supported: none, ssl, tls, or notls. + * Supported: none, ssl, tls, starttls or notls. * * @var string */ - public $encryption; + public string $encryption; /** * If server has to validate cert. * - * @var mixed + * @var bool + */ + public bool $validate_cert = true; + + /** + * Proxy settings + * @var array */ - public $validate_cert; + protected array $proxy = [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ]; + /** - * Account username/ + * SSL stream context options + * + * @see https://www.php.net/manual/en/context.ssl.php for possible options * - * @var mixed + * @var array */ - public $username; + protected array $ssl_options = []; /** - * Account password. + * Connection timeout + * @var int $timeout + */ + public int $timeout; + + /** + * Account username * * @var string */ - public $password; + public string $username; /** - * Read only parameter. + * Account password. * - * @var bool + * @var string */ - protected $read_only = false; + public string $password; /** - * Active folder. + * Additional data fetched from the server. * - * @var Folder + * @var array */ - protected $active_folder = false; + public array $extensions; /** - * Connected parameter + * Account rfc. * - * @var bool + * @var string */ - protected $connected = false; + public string $rfc; /** - * IMAP errors that might have ben occurred + * Account authentication method. * - * @var array $errors + * @var ?string */ - protected $errors = []; + public ?string $authentication; /** - * All valid and available account config parameters + * Active folder path. * - * @var array $validConfigKeys + * @var ?string */ - protected $valid_config_keys = ['host', 'port', 'encryption', 'validate_cert', 'username', 'password', 'protocol']; + protected ?string $active_folder = null; /** + * Default message mask + * * @var string $default_message_mask */ - protected $default_message_mask = MessageMask::class; + protected string $default_message_mask = MessageMask::class; /** + * Default attachment mask + * * @var string $default_attachment_mask */ - protected $default_attachment_mask = AttachmentMask::class; + protected string $default_attachment_mask = AttachmentMask::class; + + /** + * Used default account values + * + * @var array $default_account_config + */ + protected array $default_account_config = [ + 'host' => 'localhost', + 'port' => 993, + 'protocol' => 'imap', + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => '', + 'password' => '', + 'rfc' => 'RFC822', + 'authentication' => null, + "extensions" => [], + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + 'ssl_options' => [], + "timeout" => 30, + ]; /** * Client constructor. - * @param array $config + * @param Config $config * * @throws MaskNotFoundException */ - public function __construct($config = []) { + public function __construct(Config $config) { $this->setConfig($config); - $this->setMaskFromConfig($config); + $this->setMaskFromConfig(); + $this->setEventsFromConfig(); } /** * Client destructor + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException */ public function __destruct() { $this->disconnect(); } /** - * Set the Client configuration + * Clone the current Client instance * - * @param array $config + * @return Client + * @throws MaskNotFoundException + */ + public function clone(): Client { + $client = new self($this->config); + $client->events = $this->events; + $client->timeout = $this->timeout; + $client->active_folder = $this->active_folder; + $client->default_account_config = $this->default_account_config; + $config = $this->getAccountConfig(); + foreach($config as $key => $value) { + $client->setAccountConfig($key, $config); + } + $client->default_message_mask = $this->default_message_mask; + $client->default_attachment_mask = $this->default_message_mask; + return $client; + } + + /** + * Set the Client configuration + * @param Config $config * * @return self */ - public function setConfig(array $config) { - $default_account = ClientManager::get('default'); - $default_config = ClientManager::get("accounts.$default_account"); + public function setConfig(Config $config): Client { + $this->config = $config; + $default_account = $this->config->get('default'); + $default_config = $this->config->get("accounts.$default_account"); - foreach ($this->valid_config_keys as $key) { - $this->$key = isset($config[$key]) ? $config[$key] : $default_config[$key]; + foreach ($this->default_account_config as $key => $value) { + $this->setAccountConfig($key, $default_config); } return $this; } + /** + * Get the current config + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } + + /** + * Set a specific account config + * @param string $key + * @param array $default_config + */ + private function setAccountConfig(string $key, array $default_config): void { + $value = $this->default_account_config[$key]; + if(isset($default_config[$key])) { + $value = $default_config[$key]; + } + $this->$key = $value; + } + + /** + * Get the current account config + * + * @return array + */ + public function getAccountConfig(): array { + $config = []; + foreach($this->default_account_config as $key => $value) { + if(property_exists($this, $key)) { + $config[$key] = $this->$key; + } + } + return $config; + } + + /** + * Look for a possible events in any available config + */ + protected function setEventsFromConfig(): void { + $this->events = $this->config->get("events"); + if(isset($config['events'])){ + foreach($config['events'] as $section => $events) { + $this->events[$section] = array_merge($this->events[$section], $events); + } + } + } + /** * Look for a possible mask in any available config - * @param $config * * @throws MaskNotFoundException */ - protected function setMaskFromConfig($config) { - $default_config = ClientManager::get("masks"); + protected function setMaskFromConfig(): void { + $masks = $this->config->get("masks"); - if(isset($config['masks'])){ - if(isset($config['masks']['message'])) { - if(class_exists($config['masks']['message'])) { - $this->default_message_mask = $config['masks']['message']; + if(isset($masks)){ + if(isset($masks['message'])) { + if(class_exists($masks['message'])) { + $this->default_message_mask = $masks['message']; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$config['masks']['message']); + throw new MaskNotFoundException("Unknown mask provided: ".$masks['message']); } }else{ - if(class_exists($default_config['message'])) { - $this->default_message_mask = $default_config['message']; + $default_mask = $this->config->getMask("message"); + if($default_mask != ""){ + $this->default_message_mask = $default_mask; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$default_config['message']); + throw new MaskNotFoundException("Unknown message mask provided"); } } - if(isset($config['masks']['attachment'])) { - if(class_exists($config['masks']['attachment'])) { - $this->default_message_mask = $config['masks']['attachment']; + if(isset($masks['attachment'])) { + if(class_exists($masks['attachment'])) { + $this->default_attachment_mask = $masks['attachment']; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$config['masks']['attachment']); + throw new MaskNotFoundException("Unknown mask provided: ". $masks['attachment']); } }else{ - if(class_exists($default_config['attachment'])) { - $this->default_message_mask = $default_config['attachment']; + $default_mask = $this->config->getMask("attachment"); + if($default_mask != ""){ + $this->default_attachment_mask = $default_mask; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$default_config['attachment']); + throw new MaskNotFoundException("Unknown attachment mask provided"); } } }else{ - if(class_exists($default_config['message'])) { - $this->default_message_mask = $default_config['message']; + $default_mask = $this->config->getMask("message"); + if($default_mask != ""){ + $this->default_message_mask = $default_mask; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$default_config['message']); + throw new MaskNotFoundException("Unknown message mask provided"); } - if(class_exists($default_config['attachment'])) { - $this->default_message_mask = $default_config['attachment']; + $default_mask = $this->config->getMask("attachment"); + if($default_mask != ""){ + $this->default_attachment_mask = $default_mask; }else{ - throw new MaskNotFoundException("Unknown mask provided: ".$default_config['attachment']); + throw new MaskNotFoundException("Unknown attachment mask provided"); } } - } /** * Get the current imap resource * - * @return bool|resource + * @return ProtocolInterface * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function getConnection() { + public function getConnection(): ProtocolInterface { $this->checkConnection(); return $this->connection; } - /** - * Set read only property and reconnect if it's necessary. - * - * @param bool $read_only - * - * @return self - */ - public function setReadOnly($read_only = true) { - $this->read_only = $read_only; - - return $this; - } - /** * Determine if connection was established. * * @return bool */ - public function isConnected() { - return $this->connected; + public function isConnected(): bool { + return $this->connection && $this->connection->connected(); } /** - * Determine if connection is in read only mode. + * Determine if connection was established and connect if not. + * Returns true if the connection was closed and has been reopened. * - * @return bool + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function isReadOnly() { - return $this->read_only; + public function checkConnection(): bool { + try { + if (!$this->isConnected()) { + $this->connect(); + return true; + } + } catch (\Throwable) { + $this->connect(); + } + return false; } /** - * Determine if connection was established and connect if not. + * Force the connection to reconnect * * @throws ConnectionFailedException - */ - public function checkConnection() { - if (!$this->isConnected() || $this->connection === false) { - $this->connect(); + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function reconnect(): void { + if ($this->isConnected()) { + $this->disconnect(); } + $this->connect(); } /** * Connect to server. * - * @param int $attempts - * * @return $this * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function connect($attempts = 3) { + public function connect(): Client { $this->disconnect(); + $protocol = strtolower($this->protocol); + + if (in_array($protocol, ['imap', 'imap4', 'imap4rev1'])) { + $this->connection = new ImapProtocol($this->config, $this->validate_cert, $this->encryption); + $this->connection->setConnectionTimeout($this->timeout); + $this->connection->setProxy($this->proxy); + $this->connection->setSslOptions($this->ssl_options); + }else{ + if (extension_loaded('imap') === false) { + throw new ConnectionFailedException("connection setup failed", 0, new ProtocolNotSupportedException($protocol." is an unsupported protocol")); + } + $this->connection = new LegacyProtocol($this->config, $this->validate_cert, $this->encryption); + if (str_starts_with($protocol, "legacy-")) { + $protocol = substr($protocol, 7); + } + $this->connection->setProtocol($protocol); + } + + if ($this->config->get('options.debug')) { + $this->connection->enableDebug(); + } + + if (!$this->config->get('options.uid_cache')) { + $this->connection->disableUidCache(); + } try { - $this->connection = \imap_open( - $this->getAddress(), - $this->username, - $this->password, - $this->getOptions(), - $attempts, - ClientManager::get('options.open') - ); - $this->connected = !!$this->connection; - } catch (\ErrorException $e) { - $errors = \imap_errors(); - $message = $e->getMessage().'. '.implode("; ", (is_array($errors) ? $errors : array())); - - throw new ConnectionFailedException($message); + $this->connection->connect($this->host, $this->port); + } catch (ErrorException|RuntimeException $e) { + throw new ConnectionFailedException("connection setup failed", 0, $e); } + $this->authenticate(); return $this; } + /** + * Authenticate the current session + * + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + protected function authenticate(): void { + if ($this->authentication == "oauth") { + if (!$this->connection->authenticate($this->username, $this->password)->validatedData()) { + throw new AuthFailedException(); + } + } elseif (!$this->connection->login($this->username, $this->password)->validatedData()) { + throw new AuthFailedException(); + } + } + /** * Disconnect from server. * * @return $this + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException */ - public function disconnect() { - if ($this->isConnected() && $this->connection !== false && is_integer($this->connection) === false) { - $this->errors = array_merge($this->errors, \imap_errors() ?: []); - $this->connected = !\imap_close($this->connection, IMAP::CL_EXPUNGE); + public function disconnect(): Client { + if ($this->isConnected()) { + $this->connection->logout(); } + $this->active_folder = null; return $this; } /** * Get a folder instance by a folder name - * --------------------------------------------- - * PLEASE NOTE: This is an experimental function - * --------------------------------------------- - * @param string $folder_name - * @param int $attributes - * @param null|string $delimiter - * @param boolean $prefix_address - * - * @return Folder - */ - public function getFolder($folder_name, $attributes = 32, $delimiter = null, $prefix_address = true) { - - $delimiter = $delimiter === null ? ClientManager::get('imap.options.delimiter', '/') : $delimiter; + * @param string $folder_name + * @param string|null $delimiter + * @param bool $utf7 + * @return Folder|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function getFolder(string $folder_name, ?string $delimiter = null, bool $utf7 = false): ?Folder { + // Set delimiter to false to force selection via getFolderByName (maybe useful for uncommon folder names) + $delimiter = is_null($delimiter) ? $this->config->get('options.delimiter', "/") : $delimiter; + + if (str_contains($folder_name, (string)$delimiter)) { + return $this->getFolderByPath($folder_name, $utf7); + } - $folder_name = $prefix_address ? $this->getAddress().$folder_name : $folder_name; + return $this->getFolderByName($folder_name); + } - $oFolder = new Folder($this, (object) [ - 'name' => $folder_name, - 'attributes' => $attributes, - 'delimiter' => $delimiter - ]); + /** + * Get a folder instance by a folder name + * @param $folder_name + * @param bool $soft_fail If true, it will return null instead of throwing an exception + * + * @return Folder|null + * @throws FolderFetchingException + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getFolderByName($folder_name, bool $soft_fail = false): ?Folder { + return $this->getFolders(false, null, $soft_fail)->where("name", $folder_name)->first(); + } - return $oFolder; + /** + * Get a folder instance by a folder path + * @param $folder_path + * @param bool $utf7 + * @param bool $soft_fail If true, it will return null instead of throwing an exception + * + * @return Folder|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function getFolderByPath($folder_path, bool $utf7 = false, bool $soft_fail = false): ?Folder { + if (!$utf7) $folder_path = EncodingAliases::convert($folder_path, "utf-8", "utf7-imap"); + return $this->getFolders(false, null, $soft_fail)->where("path", $folder_path)->first(); } /** * Get folders list. * If hierarchical order is set to true, it will make a tree of folders, otherwise it will return flat array. * - * @param boolean $hierarchical + * @param boolean $hierarchical * @param string|null $parent_folder + * @param bool $soft_fail If true, it will return an empty collection instead of throwing an exception * * @return FolderCollection + * @throws AuthFailedException * @throws ConnectionFailedException - * @throws MailboxFetchingException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException */ - public function getFolders($hierarchical = true, $parent_folder = null) { + public function getFolders(bool $hierarchical = true, ?string $parent_folder = null, bool $soft_fail = false): FolderCollection { $this->checkConnection(); $folders = FolderCollection::make([]); $pattern = $parent_folder.($hierarchical ? '%' : '*'); + $items = $this->connection->folders('', $pattern)->validatedData(); - $items = \imap_getmailboxes($this->connection, $this->getAddress(), $pattern); - if(is_array($items)){ - foreach ($items as $item) { - $folder = new Folder($this, $item); + if(!empty($items)){ + foreach ($items as $folder_name => $item) { + $folder = new Folder($this, $folder_name, $item["delimiter"], $item["flags"]); if ($hierarchical && $folder->hasChildren()) { - $pattern = $folder->full_name.$folder->delimiter.'%'; + $pattern = $folder->path.$folder->delimiter.'%'; - $children = $this->getFolders(true, $pattern); + $children = $this->getFolders(true, $pattern, true); $folder->setChildren($children); } @@ -377,330 +614,324 @@ public function getFolders($hierarchical = true, $parent_folder = null) { } return $folders; - }else{ - throw new MailboxFetchingException($this->getLastError()); + }else if (!$soft_fail){ + throw new FolderFetchingException("failed to fetch any folders"); } + + return $folders; } /** - * Open folder. + * Get folders list. + * If hierarchical order is set to true, it will make a tree of folders, otherwise it will return flat array. * - * @param string|Folder $folder_path - * @param int $attempts + * @param boolean $hierarchical + * @param string|null $parent_folder + * @param bool $soft_fail If true, it will return an empty collection instead of throwing an exception * + * @return FolderCollection + * @throws FolderFetchingException * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function openFolder($folder_path, $attempts = 3) { + public function getFoldersWithStatus(bool $hierarchical = true, ?string $parent_folder = null, bool $soft_fail = false): FolderCollection { $this->checkConnection(); + $folders = FolderCollection::make([]); - if(property_exists($folder_path, 'path')) { - $folder_path = $folder_path->path; - } + $pattern = $parent_folder.($hierarchical ? '%' : '*'); + $items = $this->connection->folders('', $pattern)->validatedData(); - if ($this->active_folder !== $folder_path) { - $this->active_folder = $folder_path; + if(!empty($items)){ + foreach ($items as $folder_name => $item) { + $folder = new Folder($this, $folder_name, $item["delimiter"], $item["flags"]); - \imap_reopen($this->getConnection(), $folder_path, $this->getOptions(), $attempts); - } - } + if ($hierarchical && $folder->hasChildren()) { + $pattern = $folder->path.$folder->delimiter.'%'; - /** - * Create a new Folder - * @param string $name - * @param boolean $expunge - * - * @return bool - * @throws ConnectionFailedException - */ - public function createFolder($name, $expunge = true) { - $this->checkConnection(); - $status = \imap_createmailbox($this->getConnection(), $this->getAddress() . \imap_utf7_encode($name)); - if($expunge) $this->expunge(); + $children = $this->getFoldersWithStatus(true, $pattern, true); + $folder->setChildren($children); + } - return $status; - } + $folder->loadStatus(); + $folders->push($folder); + } - /** - * Rename Folder - * @param string $old_name - * @param string $new_name - * @param boolean $expunge - * - * @return bool - * @throws ConnectionFailedException - */ - public function renameFolder($old_name, $new_name, $expunge = true) { - $this->checkConnection(); - $status = \imap_renamemailbox($this->getConnection(), $this->getAddress() . \imap_utf7_encode($old_name), $this->getAddress() . \imap_utf7_encode($new_name)); - if($expunge) $this->expunge(); + return $folders; + }else if (!$soft_fail){ + throw new FolderFetchingException("failed to fetch any folders"); + } - return $status; + return $folders; } /** - * Delete Folder - * @param string $name - * @param boolean $expunge + * Open a given folder. + * @param string $folder_path + * @param boolean $force_select * - * @return bool + * @return array * @throws ConnectionFailedException - */ - public function deleteFolder($name, $expunge = true) { + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function openFolder(string $folder_path, bool $force_select = false): array { + if ($this->active_folder == $folder_path && $this->isConnected() && $force_select === false) { + return []; + } $this->checkConnection(); - $status = \imap_deletemailbox($this->getConnection(), $this->getAddress() . \imap_utf7_encode($name)); - if($expunge) $this->expunge(); - - return $status; + $this->active_folder = $folder_path; + return $this->connection->selectFolder($folder_path)->validatedData(); } /** - * Get messages from folder. - * - * @param Folder $folder - * @param string $criteria - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * - * @return MessageCollection - * @throws ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException + * Set active folder + * @param string|null $folder_path * - * @deprecated 1.0.5.2:2.0.0 No longer needed. Use Folder::getMessages() instead - * @see Folder::getMessages() + * @return void */ - public function getMessages(Folder $folder, $criteria = 'ALL', $fetch_options = null, $fetch_body = true, $fetch_attachment = true, $fetch_flags = false) { - return $folder->getMessages($criteria, $fetch_options, $fetch_body, $fetch_attachment, $fetch_flags); + public function setActiveFolder(?string $folder_path = null): void { + $this->active_folder = $folder_path; } /** - * Get all unseen messages from folder - * - * @param Folder $folder - * @param string $criteria - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * - * @return MessageCollection - * @throws ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException - * @throws MessageSearchValidationException + * Get active folder * - * @deprecated 1.0.5:2.0.0 No longer needed. Use Folder::getMessages('UNSEEN') instead - * @see Folder::getMessages() + * @return string|null */ - public function getUnseenMessages(Folder $folder, $criteria = 'UNSEEN', $fetch_options = null, $fetch_body = true, $fetch_attachment = true, $fetch_flags = false) { - return $folder->getUnseenMessages($criteria, $fetch_options, $fetch_body, $fetch_attachment, $fetch_flags); + public function getActiveFolder(): ?string { + return $this->active_folder; } /** - * Search messages by a given search criteria - * - * @param array $where - * @param Folder $folder - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param string $charset - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * - * @return MessageCollection + * Create a new Folder + * @param string $folder_path + * @param boolean $expunge + * @param bool $utf7 + * @return Folder + * @throws AuthFailedException * @throws ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException - * - * @deprecated 1.0.5:2.0.0 No longer needed. Use Folder::searchMessages() instead - * @see Folder::searchMessages() - * - */ - public function searchMessages(array $where, Folder $folder, $fetch_options = null, $fetch_body = true, $charset = "UTF-8", $fetch_attachment = true, $fetch_flags = false) { - return $folder->searchMessages($where, $fetch_options, $fetch_body, $charset, $fetch_attachment, $fetch_flags); - } + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function createFolder(string $folder_path, bool $expunge = true, bool $utf7 = false): Folder { + $this->checkConnection(); - /** - * Get option for \imap_open and \imap_reopen. - * It supports only isReadOnly feature. - * - * @return int - */ - protected function getOptions() { - return ($this->isReadOnly()) ? IMAP::OP_READONLY : 0; - } + if (!$utf7) $folder_path = EncodingAliases::convert($folder_path, "utf-8", "UTF7-IMAP"); - /** - * Get full address of mailbox. - * - * @return string - */ - protected function getAddress() { - $address = "{".$this->host.":".$this->port."/".($this->protocol ? $this->protocol : 'imap'); - if (!$this->validate_cert) { - $address .= '/novalidate-cert'; - } - if (in_array($this->encryption,['tls', 'notls', 'ssl'])) { - $address .= '/'.$this->encryption; - } elseif ($this->encryption === "starttls") { - $address .= '/tls'; - } + $status = $this->connection->createFolder($folder_path)->validatedData(); - $address .= '}'; + if($expunge) $this->expunge(); - return $address; + $folder = $this->getFolderByPath($folder_path, true); + if($status && $folder) { + $this->dispatch("folder", "new", $folder); + } + + return $folder; } /** - * Retrieve the quota level settings, and usage statics per mailbox + * Delete a given folder + * @param string $folder_path + * @param boolean $expunge * * @return array + * @throws AuthFailedException * @throws ConnectionFailedException - */ - public function getQuota() { + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function deleteFolder(string $folder_path, bool $expunge = true): array { $this->checkConnection(); - return \imap_get_quota($this->getConnection(), 'user.'.$this->username); + + $folder = $this->getFolderByPath($folder_path); + if ($this->active_folder == $folder->path){ + $this->active_folder = null; + } + $status = $this->getConnection()->deleteFolder($folder->path)->validatedData(); + if ($expunge) $this->expunge(); + + $this->dispatch("folder", "deleted", $folder); + + return $status; } /** - * Retrieve the quota settings per user - * - * @param string $quota_root + * Check a given folder + * @param string $folder_path * * @return array * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function getQuotaRoot($quota_root = 'INBOX') { + public function checkFolder(string $folder_path): array { $this->checkConnection(); - return \imap_get_quotaroot($this->getConnection(), $quota_root); + return $this->connection->examineFolder($folder_path)->validatedData(); } /** - * Gets the number of messages in the current mailbox + * Get the current active folder * - * @return int - * @throws ConnectionFailedException + * @return null|string */ - public function countMessages() { - $this->checkConnection(); - return \imap_num_msg($this->connection); + public function getFolderPath(): ?string { + return $this->active_folder; } /** - * Gets the number of recent messages in current mailbox + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param array|null $ids + * @return array * - * @return int * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function countRecentMessages() { + public function Id(?array $ids = null): array { $this->checkConnection(); - return \imap_num_recent($this->connection); + return $this->connection->ID($ids)->validatedData(); } /** - * Returns all IMAP alert messages that have occurred + * Retrieve the quota level settings, and usage statics per mailbox * * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function getAlerts() { - return \imap_alerts(); + public function getQuota(): array { + $this->checkConnection(); + return $this->connection->getQuota($this->username)->validatedData(); } /** - * Returns all of the IMAP errors that have occurred + * Retrieve the quota settings per user + * @param string $quota_root * * @return array + * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function getErrors() { - $this->errors = array_merge($this->errors, \imap_errors() ?: []); - - return $this->errors; + public function getQuotaRoot(string $quota_root = 'INBOX'): array { + $this->checkConnection(); + return $this->connection->getQuotaRoot($quota_root)->validatedData(); } /** - * Gets the last IMAP error that occurred during this page request + * Delete all messages marked for deletion * - * @return string + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException */ - public function getLastError() { - return \imap_last_error(); + public function expunge(): array { + $this->checkConnection(); + return $this->connection->expunge()->validatedData(); } /** - * Delete all messages marked for deletion + * Set the connection timeout + * @param integer $timeout * - * @return bool + * @return ProtocolInterface * @throws ConnectionFailedException - */ - public function expunge() { - $this->checkConnection(); - return \imap_expunge($this->connection); + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function setTimeout(int $timeout): ProtocolInterface { + $this->timeout = $timeout; + if ($this->isConnected()) { + $this->connection->setConnectionTimeout($timeout); + $this->reconnect(); + } + return $this->connection; } /** - * Check current mailbox + * Get the connection timeout * - * @return object { - * Date [string(37) "Wed, 8 Mar 2017 22:17:54 +0100 (CET)"] current system time formatted according to » RFC2822 - * Driver [string(4) "imap"] protocol used to access this mailbox: POP3, IMAP, NNTP - * Mailbox ["{root@example.com:993/imap/user="root@example.com"}INBOX"] the mailbox name - * Nmsgs [int(1)] number of messages in the mailbox - * Recent [int(0)] number of recent messages in the mailbox - * } + * @return int * @throws ConnectionFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function checkCurrentMailbox() { + public function getTimeout(): int { $this->checkConnection(); - return \imap_check($this->connection); + return $this->connection->getConnectionTimeout(); } /** - * Set the imap timeout for a given operation type - * @param $type - * @param $timeout + * Get the default message mask * - * @return mixed - * @throws InvalidImapTimeoutTypeException + * @return string */ - public function setTimeout($type, $timeout) { - if(0 <= $type && $type <= 4) { - return \imap_timeout($type, $timeout); - } - - throw new InvalidImapTimeoutTypeException("Invalid imap timeout type provided."); + public function getDefaultMessageMask(): string { + return $this->default_message_mask; } /** - * Get the timeout for a certain operation - * @param $type + * Get the default events for a given section + * @param $section * - * @return mixed - * @throws InvalidImapTimeoutTypeException + * @return array */ - public function getTimeout($type){ - if(0 <= $type && $type <= 4) { - return \imap_timeout($type); + public function getDefaultEvents($section): array { + if (isset($this->events[$section])) { + return is_array($this->events[$section]) ? $this->events[$section] : []; } - - throw new InvalidImapTimeoutTypeException("Invalid imap timeout type provided."); + return []; } /** - * @return string - */ - public function getDefaultMessageMask(){ - return $this->default_message_mask; - } - - /** - * @param $mask + * Set the default message mask + * @param string $mask * * @return $this * @throws MaskNotFoundException */ - public function setDefaultMessageMask($mask) { + public function setDefaultMessageMask(string $mask): Client { if(class_exists($mask)) { $this->default_message_mask = $mask; @@ -711,19 +942,22 @@ public function setDefaultMessageMask($mask) { } /** + * Get the default attachment mask + * * @return string */ - public function getDefaultAttachmentMask(){ + public function getDefaultAttachmentMask(): string { return $this->default_attachment_mask; } /** - * @param $mask + * Set the default attachment mask + * @param string $mask * * @return $this * @throws MaskNotFoundException */ - public function setDefaultAttachmentMask($mask) { + public function setDefaultAttachmentMask(string $mask): Client { if(class_exists($mask)) { $this->default_attachment_mask = $mask; @@ -732,13 +966,4 @@ public function setDefaultAttachmentMask($mask) { throw new MaskNotFoundException("Unknown mask provided: ".$mask); } - - /** - * Get the current active folder - * - * @return Folder - */ - public function getFolderPath(){ - return $this->active_folder; - } } diff --git a/src/ClientManager.php b/src/ClientManager.php index afb7f1b5..6f66886b 100644 --- a/src/ClientManager.php +++ b/src/ClientManager.php @@ -16,90 +16,69 @@ * Class ClientManager * * @package Webklex\IMAP - * - * @mixin Client */ class ClientManager { /** * All library config * - * @var array $config + * @var Config $config */ - public static $config = []; + public Config $config; /** * @var array $accounts */ - protected $accounts = []; + protected array $accounts = []; /** - * Client constructor. - * - * @param array|string $config + * ClientManager constructor. + * @param array|string|Config $config */ - public function __construct($config = []) { + public function __construct(array|string|Config $config = []) { $this->setConfig($config); } /** * Dynamically pass calls to the default account. - * - * @param string $method - * @param array $parameters + * @param string $method + * @param array $parameters * * @return mixed * @throws Exceptions\MaskNotFoundException */ - public function __call($method, $parameters) { + public function __call(string $method, array $parameters) { $callable = [$this->account(), $method]; return call_user_func_array($callable, $parameters); } /** - * Get a dotted config parameter - * - * @param string $key - * @param null $default + * Safely create a new client instance which is not listed in accounts + * @param array $config * - * @return mixed|null + * @return Client + * @throws Exceptions\MaskNotFoundException */ - public static function get($key, $default = null) { - $parts = explode('.', $key); - $value = null; - foreach($parts as $part) { - if($value === null) { - if(isset(self::$config[$part])) { - $value = self::$config[$part]; - }else{ - break; - } - }else{ - if(isset($value[$part])) { - $value = $value[$part]; - }else{ - break; - } - } - } - - return $value === null ? $default : $value; + public function make(array $config): Client { + $name = $this->config->getDefaultAccount(); + $clientConfig = $this->config->all(); + $clientConfig["accounts"] = [$name => $config]; + return new Client(Config::make($clientConfig)); } /** * Resolve a account instance. - * - * @param string $name + * @param string|null $name * * @return Client * @throws Exceptions\MaskNotFoundException */ - public function account($name = null) { - $name = $name ?: $this->getDefaultAccount(); + public function account(?string $name = null): Client { + $name = $name ?: $this->config->getDefaultAccount(); - // If the connection has not been resolved yet we will resolve it now as all - // of the connections are resolved when they are actually needed so we do + // If the connection has not been resolved we will resolve it now as all + // the connections are resolved when they are actually needed, so we do // not make any unnecessary connection to the various queue end-points. if (!isset($this->accounts[$name])) { $this->accounts[$name] = $this->resolve($name); @@ -109,54 +88,18 @@ public function account($name = null) { } /** - * Resolve a account. - * - * @param string $name + * Resolve an account. + * @param string $name * * @return Client * @throws Exceptions\MaskNotFoundException */ - protected function resolve($name) { - $config = $this->getClientConfig($name); + protected function resolve(string $name): Client { + $config = $this->config->getClientConfig($name); return new Client($config); } - /** - * Get the account configuration. - * - * @param string $name - * - * @return array - */ - protected function getClientConfig($name) { - if ($name === null || $name === 'null') { - return ['driver' => 'null']; - } - - return self::$config["accounts"][$name]; - } - - /** - * Get the name of the default account. - * - * @return string - */ - public function getDefaultAccount() { - return self::$config['default']; - } - - /** - * Set the name of the default account. - * - * @param string $name - * - * @return void - */ - public function setDefaultAccount($name) { - self::$config['default'] = $name; - } - /** * Merge the vendor settings with the local config @@ -165,93 +108,24 @@ public function setDefaultAccount($name) { * If however the default account is missing a parameter the package default account parameter will be used. * This can be disabled by setting imap.default in your config file to 'false' * - * @param array|string $config + * @param array|string|Config $config * * @return $this */ - public function setConfig($config) { - - if(is_array($config) === false) { - $config = require $config; + public function setConfig(array|string|Config $config): ClientManager { + if (!$config instanceof Config) { + $config = Config::make($config); } - - $config_key = 'imap'; - $path = __DIR__.'/config/'.$config_key.'.php'; - - $vendor_config = require $path; - $config = $this->array_merge_recursive_distinct($vendor_config, $config); - - if(is_array($config)){ - if(isset($config['default'])){ - if(isset($config['accounts']) && $config['default'] != false){ - - $default_config = $vendor_config['accounts']['default']; - if(isset($config['accounts'][$config['default']])){ - $default_config = array_merge($default_config, $config['accounts'][$config['default']]); - } - - if(is_array($config['accounts'])){ - foreach($config['accounts'] as $account_key => $account){ - $config['accounts'][$account_key] = array_merge($default_config, $account); - } - } - } - } - } - - self::$config = $config; + $this->config = $config; return $this; } /** - * Marge arrays recursively and distinct - * - * Merges any number of arrays / parameters recursively, replacing - * entries with string keys with values from latter arrays. - * If the entry or the next value to be assigned is an array, then it - * automatically treats both arguments as an array. - * Numeric entries are appended, not replaced, but only if they are - * unique - * - * @param array $array1 Initial array to merge. - * @param array ... Variable list of arrays to recursively merge. - * - * @return array|mixed - * - * @link http://www.php.net/manual/en/function.array-merge-recursive.php#96201 - * @author Mark Roduner + * Get the config instance + * @return Config */ - private function array_merge_recursive_distinct() { - - $arrays = func_get_args(); - $base = array_shift($arrays); - - if(!is_array($base)) $base = empty($base) ? array() : array($base); - - foreach($arrays as $append) { - - if(!is_array($append)) $append = array($append); - - foreach($append as $key => $value) { - - if(!array_key_exists($key, $base) and !is_numeric($key)) { - $base[$key] = $append[$key]; - continue; - } - - if(is_array($value) or is_array($base[$key])) { - $base[$key] = $this->array_merge_recursive_distinct($base[$key], $append[$key]); - } else if(is_numeric($key)) { - if(!in_array($value, $base)) $base[] = $value; - } else { - $base[$key] = $value; - } - - } - - } - - return $base; + public function getConfig(): Config { + return $this->config; } } \ No newline at end of file diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 00000000..f2f1a174 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,295 @@ +config = $config; + } + + /** + * Get a dotted config parameter + * @param string $key + * @param null $default + * + * @return mixed|null + */ + public function get(string $key, $default = null): mixed { + $parts = explode('.', $key); + $value = null; + foreach ($parts as $part) { + if ($value === null) { + if (isset($this->config[$part])) { + $value = $this->config[$part]; + } else { + break; + } + } else { + if (isset($value[$part])) { + $value = $value[$part]; + } else { + break; + } + } + } + + return $value === null ? $default : $value; + } + + /** + * Set a dotted config parameter + * @param string $key + * @param string|array|mixed$value + * + * @return void + */ + public function set(string $key, mixed $value): void { + $parts = explode('.', $key); + $config = &$this->config; + + foreach ($parts as $part) { + if (!isset($config[$part])) { + $config[$part] = []; + } + $config = &$config[$part]; + } + + if(is_array($config) && is_array($value)){ + $config = array_merge($config, $value); + }else{ + $config = $value; + } + } + + /** + * Get the decoder for a given name + * @param $name string Decoder name + * + * @return DecoderInterface + * @throws DecoderNotFoundException + */ + public function getDecoder(string $name): DecoderInterface { + $default_decoders = $this->get('decoding.decoder', [ + 'header' => \Webklex\PHPIMAP\Decoder\HeaderDecoder::class, + 'message' => \Webklex\PHPIMAP\Decoder\MessageDecoder::class, + 'attachment' => \Webklex\PHPIMAP\Decoder\AttachmentDecoder::class + ]); + $options = $this->get('decoding.options', [ + 'header' => 'utf-8', + 'message' => 'utf-8', + 'attachment' => 'utf-8', + ]); + if (isset($default_decoders[$name])) { + if (class_exists($default_decoders[$name])) { + return new $default_decoders[$name]($options); + } + } + throw new DecoderNotFoundException(); + } + + /** + * Get the mask for a given section + * @param string $section section name such as "message" or "attachment" + * + * @return string|null + */ + public function getMask(string $section): ?string { + $default_masks = $this->get('masks', []); + if (isset($default_masks[$section])) { + if (class_exists($default_masks[$section])) { + return $default_masks[$section]; + } + } + return null; + } + + /** + * Get the account configuration. + * @param string|null $name + * + * @return self + */ + public function getClientConfig(?string $name): self { + $config = $this->all(); + $defaultName = $this->getDefaultAccount(); + $defaultAccount = $this->get('accounts.'.$defaultName, []); + + if ($name === null || $name === 'null' || $name === "") { + $account = $defaultAccount; + $name = $defaultName; + }else{ + $account = $this->get('accounts.'.$name, $defaultAccount); + } + + $config["default"] = $name; + $config["accounts"] = [ + $name => $account + ]; + + return new self($config); + } + + /** + * Get the name of the default account. + * + * @return string + */ + public function getDefaultAccount(): string { + return $this->get('default', 'default'); + } + + /** + * Set the name of the default account. + * @param string $name + * + * @return void + */ + public function setDefaultAccount(string $name): void { + $this->set('default', $name); + } + + /** + * Create a new instance of the Config class + * @param array|string $config + * @return Config + */ + public static function make(array|string $config = []): Config { + if (is_array($config) === false) { + $config = require $config; + } + + $config_key = 'imap'; + $path = __DIR__ . '/config/' . $config_key . '.php'; + + $vendor_config = require $path; + $config = self::array_merge_recursive_distinct($vendor_config, $config); + + if (isset($config['default'])) { + if (isset($config['accounts']) && $config['default']) { + + $default_config = $vendor_config['accounts']['default']; + if (isset($config['accounts'][$config['default']])) { + $default_config = array_merge($default_config, $config['accounts'][$config['default']]); + } + + if (is_array($config['accounts'])) { + foreach ($config['accounts'] as $account_key => $account) { + $config['accounts'][$account_key] = array_merge($default_config, $account); + } + } + } + } + + return new self($config); + } + + /** + * Marge arrays recursively and distinct + * + * Merges any number of arrays / parameters recursively, replacing + * entries with string keys with values from latter arrays. + * If the entry or the next value to be assigned is an array, then it + * automatically treats both arguments as an array. + * Numeric entries are appended, not replaced, but only if they are + * unique + * + * @return array + * + * @link http://www.php.net/manual/en/function.array-merge-recursive.php#96201 + * @author Mark Roduner + */ + private static function array_merge_recursive_distinct(): array { + $arrays = func_get_args(); + $base = array_shift($arrays); + + // From https://stackoverflow.com/a/173479 + $isAssoc = function(array $arr) { + if (array() === $arr) return false; + return array_keys($arr) !== range(0, count($arr) - 1); + }; + + if (!is_array($base)) $base = empty($base) ? array() : array($base); + + foreach ($arrays as $append) { + if (!is_array($append)) $append = array($append); + + foreach ($append as $key => $value) { + + if (!array_key_exists($key, $base) and !is_numeric($key)) { + $base[$key] = $value; + continue; + } + + if ((is_array($value) && $isAssoc($value)) || (is_array($base[$key]) && $isAssoc($base[$key]))) { + // If the arrays are not associates we don't want to array_merge_recursive_distinct + // else merging $baseConfig['dispositions'] = ['attachment', 'inline'] with $customConfig['dispositions'] = ['attachment'] + // results in $resultConfig['dispositions'] = ['attachment', 'inline'] + $base[$key] = self::array_merge_recursive_distinct($base[$key], $value); + } else if (is_numeric($key)) { + if (!in_array($value, $base)) $base[] = $value; + } else { + $base[$key] = $value; + } + + } + + } + + return $base; + } + + /** + * Get all configuration values + * @return array + */ + public function all(): array { + return $this->config; + } + + /** + * Check if a configuration value exists + * @param string $key + * @return bool + */ + public function has(string $key): bool { + return $this->get($key) !== null; + } + + /** + * Remove all configuration values + * @return $this + */ + public function clear(): static { + $this->config = []; + return $this; + } +} \ No newline at end of file diff --git a/src/Connection/Protocols/ImapProtocol.php b/src/Connection/Protocols/ImapProtocol.php new file mode 100644 index 00000000..6871d57f --- /dev/null +++ b/src/Connection/Protocols/ImapProtocol.php @@ -0,0 +1,1481 @@ +config = $config; + $this->setCertValidation($cert_validation); + $this->encryption = $encryption; + } + + /** + * Handle the class destruction / tear down + */ + public function __destruct() { + $this->logout(); + } + + /** + * Open connection to IMAP server + * @param string $host hostname or IP address of IMAP server + * @param int|null $port of IMAP server, default is 143 and 993 for ssl + * + * @throws ConnectionFailedException + */ + public function connect(string $host, ?int $port = null): bool { + $transport = 'tcp'; + $encryption = ''; + + if ($this->encryption) { + $encryption = strtolower($this->encryption); + if (in_array($encryption, ['ssl', 'tls'])) { + $transport = $encryption; + $port = $port === null ? 993 : $port; + } + } + $port = $port === null ? 143 : $port; + try { + $response = new Response(0, $this->debug); + $this->stream = $this->createStream($transport, $host, $port, $this->connection_timeout); + if (!$this->stream || !$this->assumedNextLine($response, '* OK')) { + throw new ConnectionFailedException('connection refused'); + } + if ($encryption == 'starttls') { + $this->enableStartTls(); + } + } catch (Exception $e) { + throw new ConnectionFailedException('connection failed', 0, $e); + } + return true; + } + + /** + * Check if the current session is connected + * + * @return bool + * @throws ImapBadRequestException + */ + public function connected(): bool { + if ((bool)$this->stream) { + try { + $this->requestAndResponse('NOOP'); + return true; + } catch (ImapServerErrorException|RuntimeException) { + return false; + } + } + return false; + } + + /** + * Enable tls on the current connection + * + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + protected function enableStartTls(): void { + $response = $this->requestAndResponse('STARTTLS'); + $result = $response->successful() && stream_socket_enable_crypto($this->stream, true, $this->getCryptoMethod()); + if (!$result) { + throw new ConnectionFailedException('failed to enable TLS'); + } + } + + /** + * Get the next line from stream + * + * @return string next line + * @throws RuntimeException + */ + public function nextLine(Response $response): string { + $line = fgets($this->stream); + if ($line === false || $line === '') { + throw new RuntimeException('empty response'); + } + $response->addResponse($line); + if ($this->debug) echo "<< " . $line; + return $line; + } + + /** + * Get the next line and check if it starts with a given string + * @param Response $response + * @param string $start + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextLine(Response $response, string $start): bool { + return str_starts_with($this->nextLine($response), $start); + } + + /** + * Get the next line and check if it starts with a given string + * The server can send untagged status updates starting with '*' if we are not looking for a status update, + * the untagged lines will be ignored. + * + * @param Response $response + * @param string $start + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextLineIgnoreUntagged(Response $response, string $start): bool { + do { + $line = $this->nextLine($response); + } while (!(str_starts_with($start, '*')) && $this->isUntaggedLine($line)); + + return str_starts_with($line, $start); + } + + /** + * Get the next line and split the tag + * @param string|null $tag reference tag + * + * @return string next line + * @throws RuntimeException + */ + protected function nextTaggedLine(Response $response, ?string &$tag): string { + $line = $this->nextLine($response); + if (str_contains($line, ' ')) { + list($tag, $line) = explode(' ', $line, 2); + } + + return $line ?? ''; + } + + /** + * Get the next line and split the tag + * The server can send untagged status updates starting with '*', the untagged lines will be ignored. + * + * @param string|null $tag reference tag + * + * @return string next line + * @throws RuntimeException + */ + protected function nextTaggedLineIgnoreUntagged(Response $response, &$tag): string { + do { + $line = $this->nextLine($response); + } while ($this->isUntaggedLine($line)); + + list($tag, $line) = explode(' ', $line, 2); + + return $line; + } + + /** + * Get the next line and check if it contains a given string and split the tag + * @param Response $response + * @param string $start + * @param $tag + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextTaggedLine(Response $response, string $start, &$tag): bool { + return str_contains($this->nextTaggedLine($response, $tag), $start); + } + + /** + * Get the next line and check if it contains a given string and split the tag + * @param string $start + * @param $tag + * + * @return bool + * @throws RuntimeException + */ + protected function assumedNextTaggedLineIgnoreUntagged(Response $response, string $start, &$tag): bool { + $line = $this->nextTaggedLineIgnoreUntagged($response, $tag); + return strpos($line, $start) !== false; + } + + /** + * RFC3501 - 2.2.2 + * Data transmitted by the server to the client and status responses + * that do not indicate command completion are prefixed with the token + * "*", and are called untagged responses. + * + * @param string $line + * @return bool + */ + protected function isUntaggedLine(string $line) : bool { + return str_starts_with($line, '* '); + } + + /** + * Split a given line in values. A value is literal of any form or a list + * @param Response $response + * @param string $line + * + * @return array + * @throws RuntimeException + */ + protected function decodeLine(Response $response, string $line): array { + $tokens = []; + $stack = []; + + // replace any trailing including spaces with a single space + $line = rtrim($line) . ' '; + while (($pos = strpos($line, ' ')) !== false) { + $token = substr($line, 0, $pos); + if (!strlen($token)) { + $line = substr($line, $pos + 1); + continue; + } + while ($token[0] == '(') { + $stack[] = $tokens; + $tokens = []; + $token = substr($token, 1); + } + if ($token[0] == '"') { + if (preg_match('%^\(*\"((.|\\\|\")*?)\"( |$)%', $line, $matches)) { + $tokens[] = $matches[1]; + $line = substr($line, strlen($matches[0])); + continue; + } + } + if ($token[0] == '{') { + $endPos = strpos($token, '}'); + $chars = substr($token, 1, $endPos - 1); + if (is_numeric($chars)) { + $token = ''; + while (strlen($token) < $chars) { + $token .= $this->nextLine($response); + } + $line = ''; + if (strlen($token) > $chars) { + $line = substr($token, $chars); + $token = substr($token, 0, $chars); + } else { + $line .= $this->nextLine($response); + } + $tokens[] = $token; + $line = trim($line) . ' '; + continue; + } + } + if ($stack && $token[strlen($token) - 1] == ')') { + // closing braces are not separated by spaces, so we need to count them + $braces = strlen($token); + $token = rtrim($token, ')'); + // only count braces if more than one + $braces -= strlen($token) + 1; + // only add if token had more than just closing braces + if (rtrim($token) != '') { + $tokens[] = rtrim($token); + } + $token = $tokens; + $tokens = array_pop($stack); + // special handling if more than one closing brace + while ($braces-- > 0) { + $tokens[] = $token; + $token = $tokens; + $tokens = array_pop($stack); + } + } + $tokens[] = $token; + $line = substr($line, $pos + 1); + } + + // maybe the server forgot to send some closing braces + while ($stack) { + $child = $tokens; + $tokens = array_pop($stack); + $tokens[] = $child; + } + + return $tokens; + } + + /** + * Read abd decode a response "line" + * @param Response $response + * @param array|string $tokens to decode + * @param string $wantedTag targeted tag + * @param bool $dontParse if true only the unparsed line is returned in $tokens + * + * @return bool + * @throws RuntimeException + */ + public function readLine(Response $response, array|string &$tokens = [], string $wantedTag = '*', bool $dontParse = false): bool { + $line = $this->nextTaggedLine($response, $tag); // get next tag + if (!$dontParse) { + $tokens = $this->decodeLine($response, $line); + } else { + $tokens = $line; + } + + // if tag is wanted tag we might be at the end of a multiline response + return $tag == $wantedTag; + } + + /** + * Read all lines of response until given tag is found + * @param Response $response + * @param string $tag request tag + * @param bool $dontParse if true every line is returned unparsed instead of the decoded tokens + * + * @return array + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function readResponse(Response $response, string $tag, bool $dontParse = false): array { + $lines = []; + $tokens = ""; // define $tokens variable before first use + do { + $readAll = $this->readLine($response, $tokens, $tag, $dontParse); + $lines[] = $tokens; + } while (!$readAll); + + $original = $tokens; + if ($dontParse) { + // First two chars are still needed for the response code + $tokens = [trim(substr($tokens, 0, 3))]; + } + + $original = is_array($original) ? $original : [$original]; + + + // last line has response code + if ($tokens[0] == 'OK') { + return $lines ?: [true]; + } elseif ($tokens[0] == 'NO' || $tokens[0] == 'BAD' || $tokens[0] == 'BYE') { + throw new ImapServerErrorException($this->stringifyArray($original)); + } + + throw new ImapBadRequestException($this->stringifyArray($original)); + } + + /** + * Convert an array to a string + * @param array $arr array to stringify + * + * @return string stringified array + */ + private function stringifyArray(array $arr): string { + $string = ""; + foreach ($arr as $value) { + if (is_array($value)) { + $string .= "(" . $this->stringifyArray($value) . ")"; + } else { + $string .= $value . " "; + } + } + return $string; + } + + /** + * Send a new request + * @param string $command + * @param array $tokens additional parameters to command, use escapeString() to prepare + * @param string|null $tag provide a tag otherwise an autogenerated is returned + * + * @return Response + * @throws RuntimeException + */ + public function sendRequest(string $command, array $tokens = [], ?string &$tag = null): Response { + if (!$tag) { + $this->noun++; + $tag = 'TAG' . $this->noun; + } + + $line = $tag . ' ' . $command; + + $response = new Response($this->noun, $this->debug); + + foreach ($tokens as $token) { + if (is_array($token)) { + $this->write($response, $line . ' ' . $token[0]); + if (!$this->assumedNextLine($response, '+ ')) { + throw new RuntimeException('failed to send literal string'); + } + $line = $token[1]; + } else { + $line .= ' ' . $token; + } + } + $this->write($response, $line); + + return $response; + } + + /** + * Write data to the current stream + * @param Response $response + * @param string $data + * + * @return void + * @throws RuntimeException + */ + public function write(Response $response, string $data): void { + $command = $data . "\r\n"; + if ($this->debug) echo ">> " . $command . "\n"; + + $response->addCommand($command); + + if (fwrite($this->stream, $command) === false) { + throw new RuntimeException('failed to write - connection closed?'); + } + } + + /** + * Send a request and get response at once + * + * @param string $command + * @param array $tokens parameters as in sendRequest() + * @param bool $dontParse if true unparsed lines are returned instead of tokens + * + * @return Response response as in readResponse() + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function requestAndResponse(string $command, array $tokens = [], bool $dontParse = false): Response { + $response = $this->sendRequest($command, $tokens, $tag); + $response->setResult($this->readResponse($response, $tag, $dontParse)); + + return $response; + } + + /** + * Escape one or more literals i.e. for sendRequest + * @param array|string $string the literal/-s + * + * @return string|array escape literals, literals with newline ar returned + * as array('{size}', 'string'); + */ + public function escapeString(array|string $string): array|string { + if (func_num_args() < 2) { + if (str_contains($string, "\n")) { + return ['{' . strlen($string) . '}', $string]; + } else { + return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $string) . '"'; + } + } + $result = []; + foreach (func_get_args() as $string) { + $result[] = $this->escapeString($string); + } + return $result; + } + + /** + * Escape a list with literals or lists + * @param array $list list with literals or lists as PHP array + * + * @return string escaped list for imap + */ + public function escapeList(array $list): string { + $result = []; + foreach ($list as $v) { + if (!is_array($v)) { + $result[] = $v; + continue; + } + $result[] = $this->escapeList($v); + } + return '(' . implode(' ', $result) . ')'; + } + + /** + * Login to a new session. + * + * @param string $user username + * @param string $password password + * + * @return Response + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + */ + public function login(string $user, string $password): Response { + try { + $command = 'LOGIN'; + $params = $this->escapeString($user, $password); + + return $this->requestAndResponse($command, $params, true); + } catch (RuntimeException $e) { + throw new AuthFailedException("failed to authenticate", 0, $e); + } + } + + /** + * Authenticate your current IMAP session. + * @param string $user username + * @param string $token access token + * + * @return Response + * @throws AuthFailedException + */ + public function authenticate(string $user, string $token): Response { + try { + $authenticateParams = ['XOAUTH2', base64_encode("user=$user\1auth=Bearer $token\1\1")]; + $response = $this->sendRequest('AUTHENTICATE', $authenticateParams); + + while (true) { + $tokens = ""; + $is_plus = $this->readLine($response, $tokens, '+', true); + if ($is_plus) { + // try to log the challenge somewhere where it can be found + error_log("got an extra server challenge: $tokens"); + // respond with an empty response. + $response->stack($this->sendRequest('')); + } else { + if (preg_match('/^NO /i', $tokens) || + preg_match('/^BAD /i', $tokens)) { + error_log("got failure response: $tokens"); + return $response->addError("got failure response: $tokens"); + } else if (preg_match("/^OK /i", $tokens)) { + return $response->setResult(is_array($tokens) ? $tokens : [$tokens]); + } + } + } + } catch (RuntimeException $e) { + throw new AuthFailedException("failed to authenticate", 0, $e); + } + } + + /** + * Logout of imap server + * + * @return Response + */ + public function logout(): Response { + if (!$this->stream) { + $this->reset(); + return new Response(0, $this->debug); + } elseif ($this->meta()["timed_out"]) { + $this->reset(); + return new Response(0, $this->debug); + } + + $result = null; + try { + $result = $this->requestAndResponse('LOGOUT', [], true); + fclose($this->stream); + } catch (Throwable) { + } + + $this->reset(); + + return $result ?? new Response(0, $this->debug); + } + + /** + * Reset the current stream and uid cache + * + * @return void + */ + public function reset(): void { + $this->stream = null; + $this->uid_cache = []; + } + + /** + * Get an array of available capabilities + * + * @return Response list of capabilities + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function getCapabilities(): Response { + $response = $this->requestAndResponse('CAPABILITY'); + + if (!$response->getResponse()) return $response; + + return $response->setResult($response->validatedData()[0]); + } + + /** + * Examine and select have the same response. + * @param string $command can be 'EXAMINE' or 'SELECT' + * @param string $folder target folder + * + * @return Response + * @throws RuntimeException + */ + public function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): Response { + $response = $this->sendRequest($command, [$this->escapeString($folder)], $tag); + + $result = []; + $tokens = []; // define $tokens variable before first use + while (!$this->readLine($response, $tokens, $tag)) { + if ($tokens[0] == 'FLAGS') { + array_shift($tokens); + $result['flags'] = $tokens; + continue; + } + switch ($tokens[1]) { + case 'EXISTS': + case 'RECENT': + $result[strtolower($tokens[1])] = (int)$tokens[0]; + break; + case '[UIDVALIDITY': + $result['uidvalidity'] = (int)$tokens[2]; + break; + case '[UIDNEXT': + $result['uidnext'] = (int)$tokens[2]; + break; + case '[UNSEEN': + $result['unseen'] = (int)$tokens[2]; + break; + case '[NONEXISTENT]': + throw new RuntimeException("folder doesn't exist"); + default: + // ignore + break; + } + } + + $response->setResult($result); + + if ($tokens[0] != 'OK') { + $response->addError("request failed"); + } + return $response; + } + + /** + * Change the current folder + * @param string $folder change to this folder + * + * @return Response see examineOrSelect() + * @throws RuntimeException + */ + public function selectFolder(string $folder = 'INBOX'): Response { + $this->uid_cache = []; + + return $this->examineOrSelect('SELECT', $folder); + } + + /** + * Examine a given folder + * @param string $folder examine this folder + * + * @return Response see examineOrSelect() + * @throws RuntimeException + */ + public function examineFolder(string $folder = 'INBOX'): Response { + return $this->examineOrSelect('EXAMINE', $folder); + } + + /** + * Get the status of a given folder + * + * @param string $folder + * @param string[] $arguments + * @return Response list of STATUS items + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function folderStatus(string $folder = 'INBOX', $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): Response { + $response = $this->requestAndResponse('STATUS', [$this->escapeString($folder), $this->escapeList($arguments)]); + $data = $response->validatedData(); + + if (!isset($data[0]) || !isset($data[0][2])) { + throw new RuntimeException("folder status could not be fetched"); + } + + $result = []; + $key = null; + foreach ($data[0][2] as $value) { + if ($key === null) { + $key = $value; + } else { + $result[strtolower($key)] = (int)$value; + $key = null; + } + } + + $response->setResult($result); + + return $response; + } + + /** + * Fetch one or more items of one or more messages + * @param array|string $items items to fetch [RFC822.HEADER, FLAGS, RFC822.TEXT, etc] + * @param array|int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response if only one item of one message is fetched it's returned as string + * if items of one message are fetched it's returned as (name => value) + * if one item of messages are fetched it's returned as (msgno => value) + * if items of messages are fetched it's returned as (msgno => (name => value)) + * @throws RuntimeException + */ + public function fetch(array|string $items, array|int $from, mixed $to = null, int|string $uid = IMAP::ST_UID): Response { + if (is_array($from) && count($from) > 1) { + $set = implode(',', $from); + } elseif (is_array($from) && count($from) === 1) { + $from = array_values($from); + $set = $from[0] . ':' . $from[0]; + } elseif ($to === null) { + $set = $from . ':' . $from; + } elseif ($to == INF) { + $set = $from . ':*'; + } else { + $set = $from . ':' . (int)$to; + } + + $items = (array)$items; + $itemList = $this->escapeList($items); + + $response = $this->sendRequest($this->buildUIDCommand("FETCH", $uid), [$set, $itemList], $tag); + $result = []; + $tokens = []; // define $tokens variable before first use + while (!$this->readLine($response, $tokens, $tag)) { + // ignore other responses + if ($tokens[1] != 'FETCH') { + continue; + } + + $uidKey = 0; + $data = []; + + // find array key of UID value; try the last elements, or search for it + if ($uid === IMAP::ST_UID) { + $count = count($tokens[2]); + if ($tokens[2][$count - 2] == 'UID') { + $uidKey = $count - 1; + } else if ($tokens[2][0] == 'UID') { + $uidKey = 1; + } else { + $found = array_search('UID', $tokens[2]); + if ($found === false || $found === -1) { + continue; + } + + $uidKey = $found + 1; + } + } + + // ignore other messages + if ($to === null && !is_array($from) && ($uid === IMAP::ST_UID ? $tokens[2][$uidKey] != $from : $tokens[0] != $from)) { + continue; + } + + // if we only want one item we return that one directly + if (count($items) == 1) { + if ($tokens[2][0] == $items[0]) { + $data = $tokens[2][1]; + } elseif ($uid === IMAP::ST_UID && $tokens[2][2] == $items[0]) { + $data = $tokens[2][3]; + } else { + $expectedResponse = 0; + // maybe the server send another field we didn't wanted + $count = count($tokens[2]); + // we start with 2, because 0 was already checked + for ($i = 2; $i < $count; $i += 2) { + if ($tokens[2][$i] != $items[0]) { + continue; + } + $data = $tokens[2][$i + 1]; + $expectedResponse = 1; + break; + } + if (!$expectedResponse) { + continue; + } + } + } else { + while (key($tokens[2]) !== null) { + $data[current($tokens[2])] = next($tokens[2]); + next($tokens[2]); + } + } + + // if we want only one message we can ignore everything else and just return + if ($to === null && !is_array($from) && ($uid === IMAP::ST_UID ? $tokens[2][$uidKey] == $from : $tokens[0] == $from)) { + // we still need to read all lines + if (!$this->readLine($response, $tokens, $tag)) + return $response->setResult($data); + } + if ($uid === IMAP::ST_UID) { + $result[$tokens[2][$uidKey]] = $data; + } else { + $result[$tokens[0]] = $data; + } + } + + if ($to === null && !is_array($from)) { + throw new RuntimeException('the single id was not found in response'); + } + + return $response->setResult($result); + } + + /** + * Fetch message body (without headers) + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function content(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + $rfc = $rfc ?? "RFC822"; + $item = $rfc === "BODY" ? "BODY[TEXT]" : "$rfc.TEXT"; + return $this->fetch([$item], is_array($uids) ? $uids : [$uids], null, $uid); + } + + /** + * Fetch message headers + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function headers(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["$rfc.HEADER"], is_array($uids) ? $uids : [$uids], null, $uid); + } + + /** + * Fetch message flags + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function flags(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["FLAGS"], is_array($uids) ? $uids : [$uids], null, $uid); + } + + /** + * Fetch message sizes + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function sizes(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->fetch(["RFC822.SIZE"], is_array($uids) ? $uids : [$uids], null, $uid); + } + + /** + * Get uid for a given id + * @param int|null $id message number + * + * @return Response message number for given message or all messages as array + * @throws MessageNotFoundException + */ + public function getUid(?int $id = null): Response { + if (!$this->enable_uid_cache || empty($this->uid_cache) || count($this->uid_cache) <= 0) { + try { + $this->setUidCache((array)$this->fetch('http://github.com/UID', 1, INF)->data()); // set cache for this folder + } catch (RuntimeException) { + } + } + $uids = $this->uid_cache; + + if ($id == null) { + return Response::empty($this->debug)->setResult($uids)->setCanBeEmpty(true); + } + + foreach ($uids as $k => $v) { + if ($k == $id) { + return Response::empty($this->debug)->setResult($v); + } + } + + // clear uid cache and run method again + if ($this->enable_uid_cache && $this->uid_cache) { + $this->setUidCache(null); + return $this->getUid($id); + } + + throw new MessageNotFoundException('unique id not found'); + } + + /** + * Get a message number for a uid + * @param string $id uid + * + * @return Response message number + * @throws MessageNotFoundException + */ + public function getMessageNumber(string $id): Response { + foreach ($this->getUid()->data() as $k => $v) { + if ($v == $id) { + return Response::empty($this->debug)->setResult((int)$k); + } + } + + throw new MessageNotFoundException('message number not found: ' . $id); + } + + /** + * Get a list of available folders + * + * @param string $reference mailbox reference for list + * @param string $folder mailbox name match with wildcards + * + * @return Response folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..)) + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function folders(string $reference = '', string $folder = '*'): Response { + $response = $this->requestAndResponse('LIST', $this->escapeString($reference, $folder))->setCanBeEmpty(true); + $list = $response->data(); + + $result = []; + if ($list[0] !== true) { + foreach ($list as $item) { + if (count($item) != 4 || $item[0] != 'LIST') { + continue; + } + $item[3] = str_replace("\\\\", "\\", str_replace("\\\"", "\"", $item[3])); + $result[$item[3]] = ['delimiter' => $item[2], 'flags' => $item[1]]; + } + } + + return $response->setResult($result); + } + + /** + * Manage flags + * + * @param array|string $flags flags to set, add or remove - see $mode + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * @param string|null $item command used to store a flag + * + * @return Response new flags if $silent is false, else true or false depending on success + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function store( + array|string $flags, int $from, ?int $to = null, ?string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, ?string $item = null + ): Response { + $flags = $this->escapeList(is_array($flags) ? $flags : [$flags]); + $set = $this->buildSet($from, $to); + + $command = $this->buildUIDCommand("STORE", $uid); + $item = ($mode == '-' ? "-" : "+") . ($item === null ? "FLAGS" : $item) . ($silent ? '.SILENT' : ""); + + $response = $this->requestAndResponse($command, [$set, $item, $flags], $silent); + + if ($silent) { + return $response; + } + + $result = []; + foreach ($response as $token) { + if ($token[1] != 'FETCH' || $token[2][0] != 'FLAGS') { + continue; + } + $result[$token[0]] = $token[2][1]; + } + + + return $response->setResult($result); + } + + /** + * Append a new message to given folder + * + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param string|null $date date for new message + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function appendMessage(string $folder, string $message, ?array $flags = null, ?string $date = null): Response { + $tokens = []; + $tokens[] = $this->escapeString($folder); + if ($flags !== null) { + $tokens[] = $this->escapeList($flags); + } + if ($date !== null) { + $tokens[] = $this->escapeString($date); + } + $tokens[] = $this->escapeString($message); + + return $this->requestAndResponse('APPEND', $tokens, true); + } + + /** + * Copy a message set from current folder to another folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function copyMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response { + $set = $this->buildSet($from, $to); + $command = $this->buildUIDCommand("COPY", $uid); + + return $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true); + } + + /** + * Copy multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("COPY", $uid); + + $set = implode(',', $messages); + $tokens = [$set, $this->escapeString($folder)]; + + return $this->requestAndResponse($command, $tokens, true); + } + + /** + * Move a message set from current folder to another folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function moveMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response { + $set = $this->buildSet($from, $to); + $command = $this->buildUIDCommand("MOVE", $uid); + + $result = $this->requestAndResponse($command, [$set, $this->escapeString($folder)], true); + // RFC4315 fallback to COPY, STORE and EXPUNGE. + // Required for cases where MOVE isn't supported by the server. So we copy the message to the target folder, + // mark the original message as deleted and expunge the mailbox. + // See the following links for more information: + // - https://github.com/freescout-help-desk/freescout/issues/4313 + // - https://github.com/Webklex/php-imap/issues/123 + if (!$result->boolean()) { + $result = $this->copyMessage($folder, $from, $to, $uid); + if (!$result->boolean()) { + return $result; + } + $result = $this->store(['\Deleted'], $from, $to, null, true, $uid); + if (!$result->boolean()) { + return $result; + } + return $this->expunge(); + } + return $result; + } + + /** + * Move multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("MOVE", $uid); + $set = implode(',', $messages); + $tokens = [$set, $this->escapeString($folder)]; + + $result = $this->requestAndResponse($command, $tokens, true); + // RFC4315 fallback to COPY, STORE and EXPUNGE. + // Required for cases where MOVE isn't supported by the server. So we copy the message to the target folder, + // mark the original message as deleted and expunge the mailbox. + // See the following links for more information: + // - https://github.com/freescout-help-desk/freescout/issues/4313 + // - https://github.com/Webklex/php-imap/issues/123 + if (!$result->boolean()) { + $result = $this->copyManyMessages($messages, $folder, $uid); + if (!$result->boolean()) { + return $result; + } + foreach ($messages as $message) { + $result = $this->store(['\Deleted'], $message, $message, null, true, $uid); + if (!$result->boolean()) { + return $result; + } + } + return $this->expunge(); + } + return $result; + } + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param array|null $ids + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function ID($ids = null): Response { + $token = "NIL"; + if (is_array($ids) && !empty($ids)) { + $token = "("; + foreach ($ids as $id) { + $token .= '"' . $id . '" '; + } + $token = rtrim($token) . ")"; + } + + return $this->requestAndResponse("ID", [$token], true); + } + + /** + * Create a new folder (and parent folders if needed) + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function createFolder(string $folder): Response { + return $this->requestAndResponse('CREATE', [$this->escapeString($folder)], true); + } + + /** + * Rename an existing folder + * + * @param string $old old name + * @param string $new new name + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function renameFolder(string $old, string $new): Response { + return $this->requestAndResponse('RENAME', $this->escapeString($old, $new), true); + } + + /** + * Delete a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function deleteFolder(string $folder): Response { + return $this->requestAndResponse('DELETE', [$this->escapeString($folder)], true); + } + + /** + * Subscribe to a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function subscribeFolder(string $folder): Response { + return $this->requestAndResponse('SUBSCRIBE', [$this->escapeString($folder)], true); + } + + /** + * Unsubscribe from a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function unsubscribeFolder(string $folder): Response { + return $this->requestAndResponse('UNSUBSCRIBE', [$this->escapeString($folder)], true); + } + + /** + * Apply session saved changes to the server + * + * @return Response + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function expunge(): Response { + $this->uid_cache = []; + return $this->requestAndResponse('EXPUNGE'); + } + + /** + * Send noop command + * + * @return Response + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function noop(): Response { + return $this->requestAndResponse('NOOP'); + } + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * + * @param $username + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * + * @Doc https://www.rfc-editor.org/rfc/rfc2087.txt + */ + public function getQuota($username): Response { + $command = "GETQUOTA"; + $params = ['"#user/' . $username . '"']; + + return $this->requestAndResponse($command, $params); + } + + /** + * Retrieve the quota settings per user + * + * @param string $quota_root + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * + * @Doc https://www.rfc-editor.org/rfc/rfc2087.txt + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response { + $command = "GETQUOTAROOT"; + $params = [$quota_root]; + + return $this->requestAndResponse($command, $params); + } + + /** + * Send idle command + * + * @throws RuntimeException + */ + public function idle(): void { + $response = $this->sendRequest("IDLE"); + if (!$this->assumedNextLineIgnoreUntagged($response, '+ ')) { + throw new RuntimeException('idle failed'); + } + } + + /** + * Send done command + * @throws RuntimeException + */ + public function done(): bool { + $response = new Response($this->noun, $this->debug); + $this->write($response, "DONE"); + if (!$this->assumedNextTaggedLineIgnoreUntagged($response, 'OK', $tags)) { + throw new RuntimeException('done failed'); + } + return true; + } + + /** + * Search for matching messages + * + * @param array $params + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response message ids + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response { + $command = $this->buildUIDCommand("SEARCH", $uid); + $response = $this->requestAndResponse($command, $params)->setCanBeEmpty(true); + + foreach ($response->data() as $ids) { + if ($ids[0] === 'SEARCH') { + array_shift($ids); + return $response->setResult($ids); + } + } + + return $response; + } + + /** + * Get a message overview + * @param string $sequence + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + * @throws MessageNotFoundException + * @throws InvalidMessageDateException + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response { + $result = []; + list($from, $to) = explode(":", $sequence); + + $response = $this->getUid(); + $ids = []; + foreach ($response->data() as $msgn => $v) { + $id = $uid === IMAP::ST_UID ? $v : $msgn; + if (($to >= $id && $from <= $id) || ($to === "*" && $from <= $id)) { + $ids[] = $id; + } + } + if (!empty($ids)) { + $headers = $this->headers($ids, "RFC822", $uid); + $response->stack($headers); + foreach ($headers->data() as $id => $raw_header) { + $result[$id] = (new Header($raw_header, $this->config))->getAttributes(); + } + } + return $response->setResult($result)->setCanBeEmpty(true); + } + + /** + * Enable the debug mode + * + * @return void + */ + public function enableDebug(): void { + $this->debug = true; + } + + /** + * Disable the debug mode + * + * @return void + */ + public function disableDebug(): void { + $this->debug = false; + } + + /** + * Build a valid UID number set + * @param $from + * @param null $to + * + * @return int|string + */ + public function buildSet($from, $to = null): int|string { + $set = (int)$from; + if ($to !== null) { + $set .= ':' . ($to == INF ? '*' : (int)$to); + } + return $set; + } +} diff --git a/src/Connection/Protocols/LegacyProtocol.php b/src/Connection/Protocols/LegacyProtocol.php new file mode 100644 index 00000000..81b52f77 --- /dev/null +++ b/src/Connection/Protocols/LegacyProtocol.php @@ -0,0 +1,825 @@ +config = $config; + $this->setCertValidation($cert_validation); + $this->encryption = $encryption; + } + + /** + * Public destructor + */ + public function __destruct() { + $this->logout(); + } + + /** + * Save the information for a nw connection + * @param string $host + * @param int|null $port + */ + public function connect(string $host, ?int $port = null): void { + if ($this->encryption) { + $encryption = strtolower($this->encryption); + if ($encryption == "ssl") { + $port = $port === null ? 993 : $port; + } + } + $port = $port === null ? 143 : $port; + $this->host = $host; + $this->port = $port; + } + + /** + * Login to a new session. + * @param string $user username + * @param string $password password + * + * @return Response + */ + public function login(string $user, string $password): Response { + return $this->response()->wrap(function($response) use ($user, $password) { + /** @var Response $response */ + try { + $this->stream = \imap_open( + $this->getAddress(), + $user, + $password, + 0, + $attempts = 3, + $this->config->get('options.open') + ); + $response->addCommand("imap_open"); + } catch (\ErrorException $e) { + $errors = \imap_errors(); + $message = $e->getMessage() . '. ' . implode("; ", (is_array($errors) ? $errors : array())); + throw new AuthFailedException($message); + } + + if (!$this->stream) { + $errors = \imap_errors(); + $message = implode("; ", (is_array($errors) ? $errors : array())); + throw new AuthFailedException($message); + } + + $errors = \imap_errors(); + $response->addCommand("imap_errors"); + if (is_array($errors)) { + $status = $this->examineFolder(); + $response->stack($status); + if ($status->data()['exists'] !== 0) { + $message = implode("; ", $errors); + throw new RuntimeException($message); + } + } + + if ($this->stream !== false) { + return ["TAG" . $response->Noun() . " OK [] Logged in\r\n"]; + } + + $response->addError("failed to login"); + return []; + }); + } + + /** + * Authenticate your current session. + * @param string $user username + * @param string $token access token + * + * @return Response + */ + public function authenticate(string $user, string $token): Response { + return $this->login($user, $token); + } + + /** + * Get full address of mailbox. + * + * @return string + */ + protected function getAddress(): string { + $address = "{" . $this->host . ":" . $this->port . "/" . $this->protocol; + if (!$this->cert_validation) { + $address .= '/novalidate-cert'; + } + if (in_array($this->encryption, ['tls', 'notls', 'ssl'])) { + $address .= '/' . $this->encryption; + } elseif ($this->encryption === "starttls") { + $address .= '/tls'; + } + + $address .= '}'; + + return $address; + } + + /** + * Logout of the current session + * + * @return Response + */ + public function logout(): Response { + return $this->response()->wrap(function($response) { + /** @var Response $response */ + if ($this->stream) { + $this->uid_cache = []; + $response->addCommand("imap_close"); + if (\imap_close($this->stream, IMAP::CL_EXPUNGE)) { + $this->stream = false; + return [ + 0 => "BYE Logging out\r\n", + 1 => "TAG" . $response->Noun() . " OK Logout completed (0.001 + 0.000 secs).\r\n", + ]; + } + $this->stream = false; + } + return []; + }); + } + + /** + * Get an array of available capabilities + * + * @throws MethodNotSupportedException + */ + public function getCapabilities(): Response { + throw new MethodNotSupportedException(); + } + + /** + * Change the current folder + * @param string $folder change to this folder + * + * @return Response see examineOrselect() + * @throws RuntimeException + */ + public function selectFolder(string $folder = 'INBOX'): Response { + $flags = IMAP::OP_READONLY; + if (in_array($this->protocol, ["pop3", "nntp"])) { + $flags = IMAP::NIL; + } + if ($this->stream === false) { + throw new RuntimeException("failed to reopen stream."); + } + + return $this->response("imap_reopen")->wrap(function($response) use ($folder, $flags) { + /** @var Response $response */ + \imap_reopen($this->stream, $this->getAddress() . $folder, $flags, 3); + $this->uid_cache = []; + + $status = $this->examineFolder($folder); + $response->stack($status); + + return $status->data(); + }); + } + + /** + * Examine a given folder + * @param string $folder examine this folder + * + * @return Response + * @throws RuntimeException + */ + public function examineFolder(string $folder = 'INBOX'): Response { + if (str_starts_with($folder, ".")) { + throw new RuntimeException("Segmentation fault prevented. Folders starts with an illegal char '.'."); + } + return $this->response("imap_status")->wrap(function($response) use ($folder) { + /** @var Response $response */ + $status = \imap_status($this->stream, $this->getAddress() . $folder, IMAP::SA_ALL); + + return $status ? [ + "flags" => [], + "exists" => $status->messages, + "recent" => $status->recent, + "unseen" => $status->unseen, + "uidnext" => $status->uidnext, + ] : []; + }); + } + + /** + * Get the status of a given folder + * + * @return Response list of STATUS items + * @throws MethodNotSupportedException + */ + public function folderStatus(string $folder = 'INBOX', $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): Response { + throw new MethodNotSupportedException(); + } + + /** + * Fetch message content + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function content(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetchbody"); + $result[$id] = \imap_fetchbody($this->stream, $id, "", $uid === IMAP::ST_UID ? IMAP::ST_UID : IMAP::NIL); + } + + return $result; + }); + } + + /** + * Fetch message headers + * @param int|array $uids + * @param string $rfc + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function headers(int|array $uids, string $rfc = "RFC822", int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetchheader"); + $result[$id] = \imap_fetchheader($this->stream, $id, $uid ? IMAP::ST_UID : IMAP::NIL); + } + + return $result; + }); + } + + /** + * Fetch message flags + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function flags(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + foreach ($uids as $id) { + $response->addCommand("imap_fetch_overview"); + $raw_flags = \imap_fetch_overview($this->stream, $id, $uid ? IMAP::ST_UID : IMAP::NIL); + $flags = []; + if (is_array($raw_flags) && isset($raw_flags[0])) { + $raw_flags = (array)$raw_flags[0]; + foreach ($raw_flags as $flag => $value) { + if ($value === 1 && in_array($flag, ["size", "uid", "msgno", "update"]) === false) { + $flags[] = "\\" . ucfirst($flag); + } + } + } + $result[$id] = $flags; + } + + return $result; + }); + } + + /** + * Fetch message sizes + * @param int|array $uids + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function sizes(int|array $uids, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($uids, $uid) { + /** @var Response $response */ + $result = []; + $uids = is_array($uids) ? $uids : [$uids]; + $uid_text = implode("','", $uids); + $response->addCommand("imap_fetch_overview"); + if ($uid == IMAP::ST_UID) { + $raw_overview = \imap_fetch_overview($this->stream, $uid_text, IMAP::FT_UID); + } else { + $raw_overview = \imap_fetch_overview($this->stream, $uid_text); + } + if ($raw_overview !== false) { + foreach ($raw_overview as $overview_element) { + $overview_element = (array)$overview_element; + $result[$overview_element[$uid == IMAP::ST_UID ? 'uid' : 'msgno']] = $overview_element['size']; + } + } + return $result; + }); + } + + /** + * Get uid for a given id + * @param int|null $id message number + * + * @return Response message number for given message or all messages as array + */ + public function getUid(?int $id = null): Response { + return $this->response()->wrap(function($response) use ($id) { + /** @var Response $response */ + if ($id === null) { + if ($this->enable_uid_cache && $this->uid_cache) { + return $this->uid_cache; + } + + $overview = $this->overview("1:*"); + $response->stack($overview); + $uids = []; + foreach ($overview->data() as $set) { + $uids[$set->msgno] = $set->uid; + } + + $this->setUidCache($uids); + return $uids; + } + + $response->addCommand("imap_uid"); + $uid = \imap_uid($this->stream, $id); + if ($uid) { + return $uid; + } + + return []; + }); + } + + /** + * Get the message number of a given uid + * @param string $id uid + * + * @return Response message number + */ + public function getMessageNumber(string $id): Response { + return $this->response("imap_msgno")->wrap(function($response) use ($id) { + /** @var Response $response */ + return \imap_msgno($this->stream, $id); + }); + } + + /** + * Get a message overview + * @param string $sequence uid sequence + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_fetch_overview")->wrap(function($response) use ($sequence, $uid) { + /** @var Response $response */ + return \imap_fetch_overview($this->stream, $sequence, $uid ? IMAP::ST_UID : IMAP::NIL) ?: []; + }); + } + + /** + * Get a list of available folders + * @param string $reference mailbox reference for list + * @param string $folder mailbox name match with wildcards + * + * @return Response folders that matched $folder as array(name => array('delimiter' => .., 'flags' => ..)) + */ + public function folders(string $reference = '', string $folder = '*'): Response { + return $this->response("imap_getmailboxes")->wrap(function($response) use ($reference, $folder) { + /** @var Response $response */ + $result = []; + + $items = \imap_getmailboxes($this->stream, $this->getAddress(), $reference . $folder); + if (is_array($items)) { + foreach ($items as $item) { + $name = $this->decodeFolderName($item->name); + $result[$name] = ['delimiter' => $item->delimiter, 'flags' => []]; + } + } else { + throw new RuntimeException(\imap_last_error()); + } + + return $result; + }); + } + + /** + * Manage flags + * @param array|string $flags flags to set, add or remove - see $mode + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * @param string|null $item unused attribute + * + * @return Response new flags if $silent is false, else true or false depending on success + */ + public function store(array|string $flags, int $from, ?int $to = null, ?string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, ?string $item = null): Response { + $flag = trim(is_array($flags) ? implode(" ", $flags) : $flags); + + return $this->response()->wrap(function($response) use ($mode, $from, $flag, $uid, $silent) { + /** @var Response $response */ + + if ($mode == "+") { + $response->addCommand("imap_setflag_full"); + $status = \imap_setflag_full($this->stream, $from, $flag, $uid ? IMAP::ST_UID : IMAP::NIL); + } else { + $response->addCommand("imap_clearflag_full"); + $status = \imap_clearflag_full($this->stream, $from, $flag, $uid ? IMAP::ST_UID : IMAP::NIL); + } + + if ($silent === true) { + if ($status) { + return [ + "TAG" . $response->Noun() . " OK Store completed (0.001 + 0.000 secs).\r\n" + ]; + } + return []; + } + + return $this->flags($from); + }); + } + + /** + * Append a new message to given folder + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param mixed $date date for new message + * + * @return Response + */ + public function appendMessage(string $folder, string $message, ?array $flags = null, mixed $date = null): Response { + return $this->response("imap_append")->wrap(function($response) use ($folder, $message, $flags, $date) { + /** @var Response $response */ + if ($date != null) { + if ($date instanceof \Carbon\Carbon) { + $date = $date->format('d-M-Y H:i:s O'); + } + if (\imap_append($this->stream, $this->getAddress() . $folder, $message, $flags, $date)) { + return [ + "OK Append completed (0.001 + 0.000 secs).\r\n" + ]; + } + } else if (\imap_append($this->stream, $this->getAddress() . $folder, $message, $flags)) { + return [ + "OK Append completed (0.001 + 0.000 secs).\r\n" + ]; + } + return []; + }); + } + + /** + * Copy message set from current folder to other folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response + */ + public function copyMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_mail_copy")->wrap(function($response) use ($from, $folder, $uid) { + /** @var Response $response */ + + if (\imap_mail_copy($this->stream, $from, $this->getAddress() . $folder, $uid ? IMAP::ST_UID : IMAP::NIL)) { + return [ + "TAG" . $response->Noun() . " OK Copy completed (0.001 + 0.000 secs).\r\n" + ]; + } + throw new ImapBadRequestException("Invalid ID $from"); + }); + } + + /** + * Copy multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response Tokens if operation successful, false if an error occurred + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($messages, $folder, $uid) { + /** @var Response $response */ + foreach ($messages as $msg) { + $copy_response = $this->copyMessage($folder, $msg, null, $uid); + $response->stack($copy_response); + if (empty($copy_response->data())) { + return [ + "TAG" . $response->Noun() . " BAD Copy failed (0.001 + 0.000 secs).\r\n", + "Invalid ID $msg\r\n" + ]; + } + } + return [ + "TAG" . $response->Noun() . " OK Copy completed (0.001 + 0.000 secs).\r\n" + ]; + }); + } + + /** + * Move a message set from current folder to another folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response success + */ + public function moveMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_mail_move")->wrap(function($response) use ($from, $folder, $uid) { + if (\imap_mail_move($this->stream, $from, $this->getAddress() . $folder, $uid ? IMAP::ST_UID : IMAP::NIL)) { + return [ + "TAG" . $response->Noun() . " OK Move completed (0.001 + 0.000 secs).\r\n" + ]; + } + throw new ImapBadRequestException("Invalid ID $from"); + }); + } + + /** + * Move multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws ImapBadRequestException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response { + return $this->response()->wrap(function($response) use ($messages, $folder, $uid) { + foreach ($messages as $msg) { + $move_response = $this->moveMessage($folder, $msg, null, $uid); + $response = $response->include($response); + if (empty($move_response->data())) { + return [ + "TAG" . $response->Noun() . " BAD Move failed (0.001 + 0.000 secs).\r\n", + "Invalid ID $msg\r\n" + ]; + } + } + return [ + "TAG" . $response->Noun() . " OK Move completed (0.001 + 0.000 secs).\r\n" + ]; + }); + } + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param null $ids + * @return Response + * + * @throws MethodNotSupportedException + */ + public function ID($ids = null): Response { + throw new MethodNotSupportedException(); + } + + /** + * Create a new folder (and parent folders if needed) + * @param string $folder folder name + * + * @return Response + */ + public function createFolder(string $folder): Response { + return $this->response("imap_createmailbox")->wrap(function($response) use ($folder) { + return \imap_createmailbox($this->stream, $this->getAddress() . $folder) ? [ + 0 => "TAG" . $response->Noun() . " OK Create completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Rename an existing folder + * @param string $old old name + * @param string $new new name + * + * @return Response + */ + public function renameFolder(string $old, string $new): Response { + return $this->response("imap_renamemailbox")->wrap(function($response) use ($old, $new) { + return \imap_renamemailbox($this->stream, $this->getAddress() . $old, $this->getAddress() . $new) ? [ + 0 => "TAG" . $response->Noun() . " OK Move completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Delete a folder + * @param string $folder folder name + * + * @return Response + */ + public function deleteFolder(string $folder): Response { + return $this->response("imap_deletemailbox")->wrap(function($response) use ($folder) { + return \imap_deletemailbox($this->stream, $this->getAddress() . $folder) ? [ + 0 => "OK Delete completed (0.004 + 0.000 + 0.003 secs).\r\n", + ] : []; + }); + } + + /** + * Subscribe to a folder + * @param string $folder folder name + * + * @throws MethodNotSupportedException + */ + public function subscribeFolder(string $folder): Response { + throw new MethodNotSupportedException(); + } + + /** + * Unsubscribe from a folder + * @param string $folder folder name + * + * @throws MethodNotSupportedException + */ + public function unsubscribeFolder(string $folder): Response { + throw new MethodNotSupportedException(); + } + + /** + * Apply session saved changes to the server + * + * @return Response + */ + public function expunge(): Response { + return $this->response("imap_expunge")->wrap(function($response) { + return \imap_expunge($this->stream) ? [ + 0 => "TAG" . $response->Noun() . " OK Expunge completed (0.001 + 0.000 secs).\r\n", + ] : []; + }); + } + + /** + * Send noop command + * + * @throws MethodNotSupportedException + */ + public function noop(): Response { + throw new MethodNotSupportedException(); + } + + /** + * Send idle command + * + * @throws MethodNotSupportedException + */ + public function idle() { + throw new MethodNotSupportedException(); + } + + /** + * Send done command + * + * @throws MethodNotSupportedException + */ + public function done() { + throw new MethodNotSupportedException(); + } + + /** + * Search for matching messages + * @param array $params + * @param int|string $uid set to IMAP::ST_UID if you pass message unique identifiers instead of numbers. + * + * @return Response message ids + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response { + return $this->response("imap_search")->wrap(function($response) use ($params, $uid) { + $response->setCanBeEmpty(true); + $result = \imap_search($this->stream, $params[0], $uid ? IMAP::ST_UID : IMAP::NIL); + return $result ?: []; + }); + } + + /** + * Enable the debug mode + */ + public function enableDebug() { + $this->debug = true; + } + + /** + * Disable the debug mode + */ + public function disableDebug() { + $this->debug = false; + } + + /** + * Decode name. + * It converts UTF7-IMAP encoding to UTF-8. + * + * @param $name + * + * @return array|false|string|string[]|null + */ + protected function decodeFolderName($name): array|bool|string|null { + preg_match('#\{(.*)}(.*)#', $name, $preg); + return mb_convert_encoding($preg[2], "UTF-8", "UTF7-IMAP"); + } + + /** + * @return string + */ + public function getProtocol(): string { + return $this->protocol; + } + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * @param $username + * + * @return Response + */ + public function getQuota($username): Response { + return $this->response("imap_get_quota")->wrap(function($response) use ($username) { + $result = \imap_get_quota($this->stream, 'user.' . $username); + return $result ?: []; + }); + } + + /** + * Retrieve the quota settings per user + * @param string $quota_root + * + * @return Response + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response { + return $this->response("imap_get_quotaroot")->wrap(function($response) use ($quota_root) { + $result = \imap_get_quotaroot($this->stream, $this->getAddress() . $quota_root); + return $result ?: []; + }); + } + + /** + * @param string $protocol + * @return LegacyProtocol + */ + public function setProtocol(string $protocol): LegacyProtocol { + if (($pos = strpos($protocol, "legacy")) > 0) { + $protocol = substr($protocol, 0, ($pos + 2) * -1); + } + $this->protocol = $protocol; + return $this; + } + + /** + * Create a new Response instance + * @param string|null $command + * + * @return Response + */ + protected function response(?string $command = ""): Response { + return Response::make(0, $command == "" ? [] : [$command], [], $this->debug); + } +} diff --git a/src/Connection/Protocols/Protocol.php b/src/Connection/Protocols/Protocol.php new file mode 100644 index 00000000..a1404b4b --- /dev/null +++ b/src/Connection/Protocols/Protocol.php @@ -0,0 +1,417 @@ + null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ]; + + /** + * SSL stream context options + * + * @see https://www.php.net/manual/en/context.ssl.php for possible options + * + * @var array + */ + protected array $ssl_options = []; + + /** + * Cache for uid of active folder. + * + * @var array + */ + protected array $uid_cache = []; + + /** + * Get an available cryptographic method + * + * @return int + */ + public function getCryptoMethod(): int { + // Allow the best TLS version(s) we can + $cryptoMethod = STREAM_CRYPTO_METHOD_TLS_CLIENT; + + // PHP 5.6.7 dropped inclusion of TLS 1.1 and 1.2 in STREAM_CRYPTO_METHOD_TLS_CLIENT + // so add them back in manually if we can + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { + $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + }elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { + $cryptoMethod = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; + } + + return $cryptoMethod; + } + + /** + * Enable SSL certificate validation + * + * @return Protocol + */ + public function enableCertValidation(): Protocol { + $this->cert_validation = true; + return $this; + } + + /** + * Disable SSL certificate validation + * @return Protocol + */ + public function disableCertValidation(): Protocol { + $this->cert_validation = false; + return $this; + } + + /** + * Set SSL certificate validation + * @var int $cert_validation + * + * @return Protocol + */ + public function setCertValidation(int $cert_validation): Protocol { + $this->cert_validation = $cert_validation; + return $this; + } + + /** + * Should we validate SSL certificate? + * + * @return bool + */ + public function getCertValidation(): bool { + return $this->cert_validation; + } + + /** + * Set connection proxy settings + * @var array $options + * + * @return Protocol + */ + public function setProxy(array $options): Protocol { + foreach ($this->proxy as $key => $val) { + if (isset($options[$key])) { + $this->proxy[$key] = $options[$key]; + } + } + + return $this; + } + + /** + * Get the current proxy settings + * + * @return array + */ + public function getProxy(): array { + return $this->proxy; + } + + /** + * Set SSL context options settings + * @var array $options + * + * @return Protocol + */ + public function setSslOptions(array $options): Protocol + { + $this->ssl_options = $options; + + return $this; + } + + /** + * Get the current SSL context options settings + * + * @return array + */ + public function getSslOptions(): array { + return $this->ssl_options; + } + + /** + * Prepare socket options + * @return array + *@var string $transport + * + */ + private function defaultSocketOptions(string $transport): array { + $options = []; + if ($this->encryption) { + $options["ssl"] = [ + 'verify_peer_name' => $this->getCertValidation(), + 'verify_peer' => $this->getCertValidation(), + ]; + + if (count($this->ssl_options)) { + /* Get the ssl context options from the config, but prioritize the 'validate_cert' config over the ssl context options */ + $options["ssl"] = array_replace($this->ssl_options, $options["ssl"]); + } + } + + if ($this->proxy["socket"] != null) { + $options[$transport]["proxy"] = $this->proxy["socket"]; + $options[$transport]["request_fulluri"] = $this->proxy["request_fulluri"]; + + if ($this->proxy["username"] != null) { + $auth = base64_encode($this->proxy["username"].':'.$this->proxy["password"]); + + $options[$transport]["header"] = [ + "Proxy-Authorization: Basic $auth" + ]; + } + } + + return $options; + } + + /** + * Create a new resource stream + * @param $transport + * @param string $host hostname or IP address of IMAP server + * @param int $port of IMAP server, default is 143 (993 for ssl) + * @param int $timeout timeout in seconds for initiating session + * + * @return resource The socket created. + * @throws ConnectionFailedException + */ + public function createStream($transport, string $host, int $port, int $timeout) { + $socket = "$transport://$host:$port"; + $stream = stream_socket_client($socket, $errno, $errstr, $timeout, + STREAM_CLIENT_CONNECT, + stream_context_create($this->defaultSocketOptions($transport)) + ); + + if (!$stream) { + throw new ConnectionFailedException($errstr, $errno); + } + + if (false === stream_set_timeout($stream, $timeout)) { + throw new ConnectionFailedException('Failed to set stream timeout'); + } + + return $stream; + } + + /** + * Get the current connection timeout + * + * @return int + */ + public function getConnectionTimeout(): int { + return $this->connection_timeout; + } + + /** + * Set the connection timeout + * @param int $connection_timeout + * + * @return Protocol + */ + public function setConnectionTimeout(int $connection_timeout): Protocol { + $this->connection_timeout = $connection_timeout; + return $this; + } + + /** + * Get the UID key string + * @param int|string $uid + * + * @return string + */ + public function getUIDKey(int|string $uid): string { + if ($uid == IMAP::ST_UID || $uid == IMAP::FT_UID) { + return "UID"; + } + if (strlen($uid) > 0 && !is_numeric($uid)) { + return (string)$uid; + } + + return ""; + } + + /** + * Build a UID / MSGN command + * @param string $command + * @param int|string $uid + * + * @return string + */ + public function buildUIDCommand(string $command, int|string $uid): string { + return trim($this->getUIDKey($uid)." ".$command); + } + + /** + * Set the uid cache of current active folder + * + * @param array|null $uids + */ + public function setUidCache(?array $uids): void { + if (is_null($uids)) { + $this->uid_cache = []; + return; + } + + $messageNumber = 1; + + $uid_cache = []; + foreach ($uids as $uid) { + $uid_cache[$messageNumber++] = (int)$uid; + } + + $this->uid_cache = $uid_cache; + } + + /** + * Enable the uid cache + * + * @return void + */ + public function enableUidCache(): void { + $this->enable_uid_cache = true; + } + + /** + * Disable the uid cache + * + * @return void + */ + public function disableUidCache(): void { + $this->enable_uid_cache = false; + } + + /** + * Set the encryption method + * @param string $encryption + * + * @return void + */ + public function setEncryption(string $encryption): void { + $this->encryption = $encryption; + } + + /** + * Get the encryption method + * @return string + */ + public function getEncryption(): string { + return $this->encryption; + } + + /** + * Check if the current session is connected + * + * @return bool + */ + public function connected(): bool { + return (bool)$this->stream; + } + + /** + * Retrieves header/metadata from the resource stream + * + * @return array + */ + public function meta(): array { + if (!$this->stream) { + return [ + "crypto" => [ + "protocol" => "", + "cipher_name" => "", + "cipher_bits" => 0, + "cipher_version" => "", + ], + "timed_out" => true, + "blocked" => true, + "eof" => true, + "stream_type" => "tcp_socket/unknown", + "mode" => "c", + "unread_bytes" => 0, + "seekable" => false, + ]; + } + return stream_get_meta_data($this->stream); + } + + /** + * Get the resource stream + * + * @return mixed + */ + public function getStream(): mixed { + return $this->stream; + } + + /** + * Set the Config instance + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } +} diff --git a/src/Connection/Protocols/ProtocolInterface.php b/src/Connection/Protocols/ProtocolInterface.php new file mode 100644 index 00000000..eb6d7c9d --- /dev/null +++ b/src/Connection/Protocols/ProtocolInterface.php @@ -0,0 +1,458 @@ + array('delim' => .., 'flags' => ..)) + * @throws RuntimeException + */ + public function folders(string $reference = '', string $folder = '*'): Response; + + /** + * Set message flags + * @param array|string $flags flags to set, add or remove + * @param int $from message for items or start message if $to !== null + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param string|null $mode '+' to add flags, '-' to remove flags, everything else sets the flags as given + * @param bool $silent if false the return values are the new flags for the wanted messages + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * @param string|null $item command used to store a flag + * + * @return Response containing the new flags if $silent is false, else true or false depending on success + * @throws RuntimeException + */ + public function store(array|string $flags, int $from, ?int $to = null, ?string $mode = null, bool $silent = true, int|string $uid = IMAP::ST_UID, ?string $item = null): Response; + + /** + * Append a new message to given folder + * @param string $folder name of target folder + * @param string $message full message content + * @param array|null $flags flags for new message + * @param string|null $date date for new message + * + * @return Response + * @throws RuntimeException + */ + public function appendMessage(string $folder, string $message, ?array $flags = null, ?string $date = null): Response; + + /** + * Copy message set from current folder to other folder + * + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + */ + public function copyMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response; + + /** + * Copy multiple messages to the target folder + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws RuntimeException + */ + public function copyManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response; + + /** + * Move a message set from current folder to another folder + * @param string $folder destination folder + * @param $from + * @param int|null $to if null only one message ($from) is fetched, else it's the + * last message, INF means last message available + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + */ + public function moveMessage(string $folder, $from, ?int $to = null, int|string $uid = IMAP::ST_UID): Response; + + /** + * Move multiple messages to the target folder + * + * @param array $messages List of message identifiers + * @param string $folder Destination folder + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response Tokens if operation successful, false if an error occurred + * @throws RuntimeException + */ + public function moveManyMessages(array $messages, string $folder, int|string $uid = IMAP::ST_UID): Response; + + /** + * Exchange identification information + * Ref.: https://datatracker.ietf.org/doc/html/rfc2971 + * + * @param null $ids + * @return Response + * + * @throws RuntimeException + */ + public function ID($ids = null): Response; + + /** + * Create a new folder + * + * @param string $folder folder name + * @return Response + * @throws RuntimeException + */ + public function createFolder(string $folder): Response; + + /** + * Rename an existing folder + * + * @param string $old old name + * @param string $new new name + * @return Response + * @throws RuntimeException + */ + public function renameFolder(string $old, string $new): Response; + + /** + * Delete a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function deleteFolder(string $folder): Response; + + /** + * Subscribe to a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function subscribeFolder(string $folder): Response; + + /** + * Unsubscribe from a folder + * + * @param string $folder folder name + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function unsubscribeFolder(string $folder): Response; + + /** + * Send idle command + * + * @throws RuntimeException + */ + public function idle(); + + /** + * Send done command + * @throws RuntimeException + */ + public function done(); + + /** + * Apply session saved changes to the server + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function expunge(): Response; + + /** + * Retrieve the quota level settings, and usage statics per mailbox + * @param $username + * + * @return Response + * @throws RuntimeException + */ + public function getQuota($username): Response; + + /** + * Retrieve the quota settings per user + * + * @param string $quota_root + * + * @return Response + * @throws ConnectionFailedException + */ + public function getQuotaRoot(string $quota_root = 'INBOX'): Response; + + /** + * Send noop command + * + * @return Response + * + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function noop(): Response; + + /** + * Do a search request + * + * @param array $params + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response containing the message ids + * @throws RuntimeException + */ + public function search(array $params, int|string $uid = IMAP::ST_UID): Response; + + /** + * Get a message overview + * @param string $sequence uid sequence + * @param int|string $uid set to IMAP::ST_UID or any string representing the UID - set to IMAP::ST_MSGN to use + * message numbers instead. + * + * @return Response + * @throws RuntimeException + * @throws MessageNotFoundException + * @throws InvalidMessageDateException + */ + public function overview(string $sequence, int|string $uid = IMAP::ST_UID): Response; + + /** + * Enable the debug mode + */ + public function enableDebug(); + + /** + * Disable the debug mode + */ + public function disableDebug(); + + /** + * Enable uid caching + */ + public function enableUidCache(); + + /** + * Disable uid caching + */ + public function disableUidCache(); + + /** + * Set the uid cache of current active folder + * + * @param array|null $uids + */ + public function setUidCache(?array $uids); +} diff --git a/src/Connection/Protocols/Response.php b/src/Connection/Protocols/Response.php new file mode 100644 index 00000000..9a30d56d --- /dev/null +++ b/src/Connection/Protocols/Response.php @@ -0,0 +1,417 @@ +debug = $debug; + $this->noun = $noun > 0 ? $noun : (int)str_replace(".", "", (string)microtime(true)); + } + + /** + * Make a new response instance + * @param int $noun + * @param array $commands + * @param array $responses + * @param bool $debug + * + * @return Response + */ + public static function make(int $noun, array $commands = [], array $responses = [], bool $debug = false): Response { + return (new self($noun, $debug))->setCommands($commands)->setResponse($responses); + } + + /** + * Create a new empty response + * @param bool $debug + * + * @return Response + */ + public static function empty(bool $debug = false): Response { + return (new self(0, $debug)); + } + + /** + * Stack another response + * @param Response $response + * + * @return void + */ + public function stack(Response $response): void { + $this->response_stack[] = $response; + } + + /** + * Get the associated response stack + * + * @return array + */ + public function getStack(): array { + return $this->response_stack; + } + + /** + * Get all assigned commands + * + * @return array + */ + public function getCommands(): array { + return $this->commands; + } + + /** + * Add a new command + * @param string $command + * + * @return Response + */ + public function addCommand(string $command): Response { + $this->commands[] = $command; + return $this; + } + + /** + * Set and overwrite all commands + * @param array $commands + * + * @return Response + */ + public function setCommands(array $commands): Response { + $this->commands = $commands; + return $this; + } + + /** + * Get all set errors + * + * @return array + */ + public function getErrors(): array { + $errors = $this->errors; + foreach($this->getStack() as $response) { + $errors = array_merge($errors, $response->getErrors()); + } + return $errors; + } + + /** + * Set and overwrite all existing errors + * @param array $errors + * + * @return Response + */ + public function setErrors(array $errors): Response { + $this->errors = $errors; + return $this; + } + + /** + * Set the response + * @param string $error + * + * @return Response + */ + public function addError(string $error): Response { + $this->errors[] = $error; + return $this; + } + + /** + * Set the response + * @param array $response + * + * @return Response + */ + public function addResponse(mixed $response): Response { + $this->response[] = $response; + return $this; + } + + /** + * Set the response + * @param array $response + * + * @return Response + */ + public function setResponse(array $response): Response { + $this->response = $response; + return $this; + } + + /** + * Get the assigned response + * + * @return array + */ + public function getResponse(): array { + return $this->response; + } + + /** + * Set the result data + * @param mixed $result + * + * @return Response + */ + public function setResult(mixed $result): Response { + $this->result = $result; + return $this; + } + + /** + * Wrap a result bearing action + * @param callable $callback + * + * @return Response + */ + public function wrap(callable $callback): Response { + $this->result = call_user_func($callback, $this); + return $this; + } + + /** + * Get the response data + * + * @return mixed + */ + public function data(): mixed { + if ($this->result !== null) { + return $this->result; + } + return $this->getResponse(); + } + + /** + * Get the response data as array + * + * @return array + */ + public function array(): array { + $data = $this->data(); + if(is_array($data)){ + return $data; + } + return [$data]; + } + + /** + * Get the response data as string + * + * @return string + */ + public function string(): string { + $data = $this->data(); + if(is_array($data)){ + return implode(" ", $data); + } + return (string)$data; + } + + /** + * Get the response data as integer + * + * @return int + */ + public function integer(): int { + $data = $this->data(); + if(is_array($data) && isset($data[0])){ + return (int)$data[0]; + } + return (int)$data; + } + + /** + * Get the response data as boolean + * + * @return bool + */ + public function boolean(): bool { + return (bool)$this->data(); + } + + /** + * Validate and retrieve the response data + * + * @throws ResponseException + */ + public function validatedData(): mixed { + return $this->validate()->data(); + } + + /** + * Validate the response date + * + * @throws ResponseException + */ + public function validate(): Response { + if ($this->failed()) { + throw ResponseException::make($this, $this->debug); + } + return $this; + } + + /** + * Check if the Response can be considered successful + * + * @return bool + */ + public function successful(): bool { + foreach(array_merge($this->getResponse(), $this->array()) as $data) { + if (!$this->verify_data($data)) { + return false; + } + } + foreach($this->getStack() as $response) { + if (!$response->successful()) { + return false; + } + } + return ($this->boolean() || $this->canBeEmpty()) && !$this->getErrors(); + } + + + /** + * Check if the Response can be considered failed + * @param mixed $data + * + * @return bool + */ + public function verify_data(mixed $data): bool { + if (is_array($data)) { + foreach ($data as $line) { + if (is_array($line)) { + if(!$this->verify_data($line)){ + return false; + } + }else{ + if (!$this->verify_line((string)$line)) { + return false; + } + } + } + }else{ + if (!$this->verify_line((string)$data)) { + return false; + } + } + return true; + } + + /** + * Verify a single line + * @param string $line + * + * @return bool + */ + public function verify_line(string $line): bool { + return !str_starts_with($line, "TAG".$this->noun." BAD ") && !str_starts_with($line, "TAG".$this->noun." NO "); + } + + /** + * Check if the Response can be considered failed + * + * @return bool + */ + public function failed(): bool { + return !$this->successful(); + } + + /** + * Get the Response noun + * + * @return int + */ + public function Noun(): int { + return $this->noun; + } + + /** + * Set the Response to be allowed to be empty + * @param bool $can_be_empty + * + * @return $this + */ + public function setCanBeEmpty(bool $can_be_empty): Response { + $this->can_be_empty = $can_be_empty; + return $this; + } + + /** + * Check if the Response can be empty + * + * @return bool + */ + public function canBeEmpty(): bool { + return $this->can_be_empty; + } +} \ No newline at end of file diff --git a/src/Decoder/AttachmentDecoder.php b/src/Decoder/AttachmentDecoder.php new file mode 100644 index 00000000..22780353 --- /dev/null +++ b/src/Decoder/AttachmentDecoder.php @@ -0,0 +1,25 @@ +options = array_merge([ + 'header' => 'utf-8', + 'message' => 'utf-8', + 'attachment' => 'utf-8', + ], $this->options); + } + + /** + * Decode a given value + * @param array|string|null $value + * @param string|null $encoding + * @return mixed + */ + public function decode(array|string|null $value, ?string $encoding = null): mixed { + return $value; + } + + /** + * Convert the encoding + * @param string $str The string to convert + * @param string $from The source encoding + * @param string $to The target encoding + * + * @return mixed|string + */ + public function convertEncoding(string $str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + $from = EncodingAliases::get($from, $this->fallback_encoding); + $to = EncodingAliases::get($to, $this->fallback_encoding); + + if ($from === $to) { + return $str; + } + + return EncodingAliases::convert($str, $from, $to); + } + + /** + * Decode MIME header elements + * @link https://php.net/manual/en/function.imap-mime-header-decode.php + * @param string $text The MIME text + * + * @return array Returns an array of objects. Each *object has two properties, charset and text. + */ + public function mimeHeaderDecode(string $text): array { + if (extension_loaded('imap')) { + $result = \imap_mime_header_decode($text); + return is_array($result) ? $result : []; + } + $charset = $this->getEncoding($text); + return [(object)[ + "charset" => $charset, + "text" => $this->convertEncoding($text, $charset) + ]]; + } + + /** + * Test if a given value is utf-8 encoded + * @param $value + * + * @return bool + */ + public static function isUTF8($value): bool { + return str_starts_with(strtolower($value), '=?utf-8?'); + } + + /** + * Check if a given pair of strings has been decoded + * @param $encoded + * @param $decoded + * + * @return bool + */ + public static function notDecoded($encoded, $decoded): bool { + return str_starts_with($decoded, '=?') + && strlen($decoded) - 2 === strpos($decoded, '?=') + && str_contains($encoded, $decoded); + } + + /** + * Set the configuration used for decoding + * @param array $config + * + * @return Decoder + */ + public function setOptions(array $config): static { + $this->options = $config; + return $this; + } + + /** + * Get the configuration used for decoding + * + * @return array + */ + public function getOptions(): array { + return $this->options; + } + + /** + * Get the fallback encoding + * + * @return string + */ + public function getFallbackEncoding(): string { + return $this->fallback_encoding; + } + + /** + * Set the fallback encoding + * + * @param string $fallback_encoding + * @return Decoder + */ + public function setFallbackEncoding(string $fallback_encoding): static { + $this->fallback_encoding = $fallback_encoding; + return $this; + } +} \ No newline at end of file diff --git a/src/Decoder/DecoderInterface.php b/src/Decoder/DecoderInterface.php new file mode 100644 index 00000000..e4874602 --- /dev/null +++ b/src/Decoder/DecoderInterface.php @@ -0,0 +1,52 @@ +decodeHeaderArray($value); + } + $original_value = $value; + $decoder = $this->options['header']; + + if ($value !== null) { + if ($decoder === 'utf-8') { + $decoded_values = $this->mimeHeaderDecode($value); + $tempValue = ""; + foreach ($decoded_values as $decoded_value) { + $tempValue .= $this->convertEncoding($decoded_value->text, $decoded_value->charset); + } + if ($tempValue) { + $value = $tempValue; + } else if (extension_loaded('imap')) { + $value = \imap_utf8($value); + } else if (function_exists('iconv_mime_decode')) { + $value = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + } else { + $value = mb_decode_mimeheader($value); + } + } elseif ($decoder === 'iconv') { + $value = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, "UTF-8"); + } else if (self::isUTF8($value)) { + $value = mb_decode_mimeheader($value); + } + + if (self::notDecoded($original_value, $value)) { + $value = $this->convertEncoding($original_value, $this->getEncoding($original_value)); + } + } + + return $value; + } + + /** + * Get the encoding of a given abject + * @param object|string $structure + * + * @return string + */ + public function getEncoding(object|string $structure): string { + if (property_exists($structure, 'parameters')) { + foreach ($structure->parameters as $parameter) { + if (strtolower($parameter->attribute) == "charset") { + return EncodingAliases::get($parameter->value == "default" ? EncodingAliases::detectEncoding($parameter->value) : $parameter->value, $this->fallback_encoding); + } + } + } elseif (property_exists($structure, 'charset')) { + return EncodingAliases::get($structure->charset == "default" ? EncodingAliases::detectEncoding($structure->charset) : $structure->charset, $this->fallback_encoding); + } elseif (is_string($structure) === true) { + $result = mb_detect_encoding($structure); + return $result === false ? $this->fallback_encoding : $result; + } + + return $this->fallback_encoding; + } + + + /** + * Decode a given array + * @param array $values + * + * @return array + */ + private function decodeHeaderArray(array $values): array { + foreach ($values as $key => $value) { + $values[$key] = $this->decode($value); + } + return $values; + } + +} \ No newline at end of file diff --git a/src/Decoder/MessageDecoder.php b/src/Decoder/MessageDecoder.php new file mode 100644 index 00000000..4a2cff2f --- /dev/null +++ b/src/Decoder/MessageDecoder.php @@ -0,0 +1,119 @@ +decode($item); + }, $value); + } + + switch ($encoding) { + case IMAP::MESSAGE_ENC_BINARY: + if (extension_loaded('imap')) { + return base64_decode(\imap_binary($value)); + } + return base64_decode($value); + case IMAP::MESSAGE_ENC_BASE64: + return base64_decode($value); + case IMAP::MESSAGE_ENC_QUOTED_PRINTABLE: + return quoted_printable_decode($value); + case IMAP::MESSAGE_ENC_8BIT: + case IMAP::MESSAGE_ENC_7BIT: + case IMAP::MESSAGE_ENC_OTHER: + default: + return $value; + } + } + + /** + * Get the encoding of a given abject + * @param object|string $structure + * + * @return string + */ + public function getEncoding(object|string $structure): string { + if (property_exists($structure, 'parameters')) { + foreach ($structure->parameters as $parameter) { + if (strtolower($parameter->attribute) == "charset") { + return EncodingAliases::get($parameter->value, "ISO-8859-2"); + } + } + } elseif (property_exists($structure, 'charset')) { + return EncodingAliases::get($structure->charset, "ISO-8859-2"); + } elseif (is_string($structure) === true) { + return EncodingAliases::detectEncoding($structure); + } + + return $this->fallback_encoding; + } + + + /** + * Convert the encoding + * @param $str + * @param string $from + * @param string $to + * + * @return mixed|string + */ + public function convertEncoding($str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + $from = EncodingAliases::get($from); + $to = EncodingAliases::get($to); + + if ($from === $to) { + return $str; + } + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if (strtolower($from ?? '') == 'us-ascii' && $to == 'UTF-8') { + return $str; + } + + if (function_exists('iconv') && !EncodingAliases::isUtf7($from) && !EncodingAliases::isUtf7($to)) { + try { + return iconv($from, $to.'//IGNORE', $str); + } catch (Exception) { + return @iconv($from, $to, $str); + } + } else { + if (!$from) { + return mb_convert_encoding($str, $to); + } + return mb_convert_encoding($str, $to, $from); + } + } + +} \ No newline at end of file diff --git a/src/EncodingAliases.php b/src/EncodingAliases.php index 79f610e2..8a3dc0fb 100644 --- a/src/EncodingAliases.php +++ b/src/EncodingAliases.php @@ -22,9 +22,10 @@ class EncodingAliases { /** * Contains email encoding mappings + * * @var array */ - private static $aliases = [ + private static array $aliases = [ /* |-------------------------------------------------------------------------- | Email encoding aliases @@ -106,6 +107,8 @@ class EncodingAliases { "ibm864" => "IBM864", "utf-8" => "UTF-8", "utf-7" => "UTF-7", + "utf-7-imap" => "UTF7-IMAP", + "utf7-imap" => "UTF7-IMAP", "shift_jis" => "Shift_JIS", "big5" => "Big5", "euc-jp" => "EUC-JP", @@ -465,17 +468,124 @@ class EncodingAliases { ]; /** - * Returns proper encoding mapping, if exsists. If it doesn't, return unchanged $encoding - * - * @param string $encoding + * Returns proper encoding mapping, if exists. If it doesn't, return unchanged $encoding + * @param string|null $encoding + * @param string|null $fallback + * * @return string */ - public static function get($encoding) { - if (isset(self::$aliases[strtolower($encoding)])) { - return self::$aliases[strtolower($encoding)]; - } else { - return $encoding; + public static function get(?string $encoding, ?string $fallback = null): string { + if (isset(self::$aliases[strtolower($encoding ?? '')])) { + return self::$aliases[strtolower($encoding ?? '')]; } + return $fallback ?: $encoding; + } + + + /** + * Convert the encoding of a string + * @param $str + * @param string $from + * @param string $to + * + * @return mixed + */ + public static function convert($str, string $from = "ISO-8859-2", string $to = "UTF-8"): mixed { + $from = self::get($from, self::detectEncoding($str)); + $to = self::get($to, self::detectEncoding($str)); + + if ($from === $to) { + return $str; + } + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if (strtolower($from) == 'us-ascii' && $to == 'UTF-8') { + return $str; + } + + try { + if (function_exists('iconv') && !self::isUtf7($from) && !self::isUtf7($to)) { + return iconv($from, $to, $str); + } + if (!$from) { + return mb_convert_encoding($str, $to); + } + return mb_convert_encoding($str, $to, $from); + } catch (\Exception $e) { + if (str_contains($from, '-')) { + $from = str_replace('-', '', $from); + return self::convert($str, $from, $to); + } + return $str; + } + } + + /** + * Attempts to detect the encoding of a string + * @param string $string + * + * @return string + */ + public static function detectEncoding(string $string): string { + $encoding = mb_detect_encoding($string, array_filter(self::getEncodings(), function($value){ + return !in_array($value, [ + 'ISO-8859-6-I', 'ISO-8859-6-E', 'ISO-8859-8-I', 'ISO-8859-8-E', + 'ISO-8859-11', 'ISO-8859-13', 'ISO-8859-14', 'ISO-8859-15', 'ISO-8859-16', 'ISO-IR-111',"ISO-2022-CN", + "windows-1250", "windows-1253", "windows-1255", "windows-1256", "windows-1257", "windows-1258", + "IBM852", "IBM855", "IBM857", "IBM866", "IBM864", "IBM862", "KOI8-R", "KOI8-U", + "TIS-620", "ISO-8859-1", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", + "VISCII", "T.61-8bit", "Big5-HKSCS", "windows-874", "macintosh", "ISO-8859-12", "ISO-8859-7", + "IMAP-UTF-7" + ]); + }), true); + if ($encoding === false) { + $encoding = 'UTF-8'; + } + return $encoding; + } + + /** + * Returns all available encodings + * + * @return array + */ + public static function getEncodings(): array { + $encodings = []; + foreach (self::$aliases as $encoding) { + if (!in_array($encoding, $encodings)) { + $encodings[] = $encoding; + } + } + return $encodings; + } + + /** + * Returns true if the encoding is UTF-7 like + * @param string $encoding + * + * @return bool + */ + public static function isUtf7(string $encoding): bool { + return str_contains(str_replace("-", "", strtolower($encoding)), "utf7"); + } + + /** + * Check if an encoding is supported + * @param string $encoding + * + * @return bool + */ + public static function has(string $encoding): bool { + return isset(self::$aliases[strtolower($encoding)]); } - } diff --git a/src/Events/Event.php b/src/Events/Event.php new file mode 100644 index 00000000..f9e3e8f6 --- /dev/null +++ b/src/Events/Event.php @@ -0,0 +1,28 @@ +message = $arguments[0]; + $this->flag = $arguments[1]; + } +} diff --git a/src/Events/FolderDeletedEvent.php b/src/Events/FolderDeletedEvent.php new file mode 100644 index 00000000..89b5083f --- /dev/null +++ b/src/Events/FolderDeletedEvent.php @@ -0,0 +1,22 @@ +old_folder = $folders[0]; + $this->new_folder = $folders[1]; + } +} diff --git a/src/Events/FolderNewEvent.php b/src/Events/FolderNewEvent.php new file mode 100644 index 00000000..0c576cad --- /dev/null +++ b/src/Events/FolderNewEvent.php @@ -0,0 +1,36 @@ +folder = $folders[0]; + } +} diff --git a/src/Events/MessageCopiedEvent.php b/src/Events/MessageCopiedEvent.php new file mode 100644 index 00000000..a6a3a447 --- /dev/null +++ b/src/Events/MessageCopiedEvent.php @@ -0,0 +1,22 @@ +old_message = $messages[0]; + $this->new_message = $messages[1]; + } +} diff --git a/src/Events/MessageNewEvent.php b/src/Events/MessageNewEvent.php new file mode 100644 index 00000000..38892c49 --- /dev/null +++ b/src/Events/MessageNewEvent.php @@ -0,0 +1,36 @@ +message = $messages[0]; + } +} diff --git a/src/Events/MessageRestoredEvent.php b/src/Events/MessageRestoredEvent.php new file mode 100644 index 00000000..25b6520a --- /dev/null +++ b/src/Events/MessageRestoredEvent.php @@ -0,0 +1,22 @@ +getErrors() as $error) { + $message .= "\t- $error\n"; + } + + if(!$response->data()) { + $message .= "\t- Empty response\n"; + } + + if ($debug) { + $message .= self::debug_message($response); + } + + foreach($response->getStack() as $_response) { + $exception = self::make($_response, $debug, $exception); + } + + return new self($message."Error occurred", 0, $exception); + } + + /** + * Generate a debug message containing all commands send and responses received + * @param Response $response + * + * @return string + */ + protected static function debug_message(Response $response): string { + $commands = $response->getCommands(); + $message = "Commands send:\n"; + if ($commands) { + foreach($commands as $command) { + $message .= "\t".str_replace("\r\n", "\\r\\n", $command)."\n"; + } + }else{ + $message .= "\tNo command send!\n"; + } + + $responses = $response->getResponse(); + $message .= "Responses received:\n"; + if ($responses) { + foreach($responses as $_response) { + if (is_array($_response)) { + foreach($_response as $value) { + $message .= "\t".str_replace("\r\n", "\\r\\n", "$value")."\n"; + } + }else{ + $message .= "\t".str_replace("\r\n", "\\r\\n", "$_response")."\n"; + } + } + }else{ + $message .= "\tNo responses received!\n"; + } + + return $message; + } +} diff --git a/src/Exceptions/RuntimeException.php b/src/Exceptions/RuntimeException.php new file mode 100644 index 00000000..926be1d1 --- /dev/null +++ b/src/Exceptions/RuntimeException.php @@ -0,0 +1,24 @@ +client = $client; - $this->setDelimiter($structure->delimiter); - $this->path = $structure->name; - $this->full_name = $this->decodeName($structure->name); - $this->name = $this->getSimpleName($this->delimiter, $this->full_name); + $this->events["message"] = $client->getDefaultEvents("message"); + $this->events["folder"] = $client->getDefaultEvents("folder"); - $this->parseAttributes($structure->attributes); + $this->setDelimiter($delimiter); + $this->path = $folder_name; + $this->full_name = $this->decodeName($folder_name); + $this->name = $this->getSimpleName($this->delimiter, $this->full_name); + $this->children = new FolderCollection(); + $this->has_children = false; + + $this->parseAttributes($attributes); } /** * Get a new search query instance - * @param string $charset + * @param string[] $extensions * * @return WhereQuery - * @throws Exceptions\ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException */ - public function query($charset = 'UTF-8'){ + public function query(array $extensions = []): WhereQuery { $this->getClient()->checkConnection(); $this->getClient()->openFolder($this->path); + $extensions = count($extensions) > 0 ? $extensions : $this->getClient()->extensions; - return new WhereQuery($this->getClient(), $charset); + return new WhereQuery($this->getClient(), $extensions); } /** - * @inheritdoc self::query($charset = 'UTF-8') - * @throws Exceptions\ConnectionFailedException + * Get a new search query instance + * @param string[] $extensions + * + * @return WhereQuery + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException */ - public function search($charset = 'UTF-8'){ - return $this->query($charset); + public function search(array $extensions = []): WhereQuery { + return $this->query($extensions); } /** - * @inheritdoc self::query($charset = 'UTF-8') - * @throws Exceptions\ConnectionFailedException + * Get a new search query instance + * @param string[] $extensions + * + * @return WhereQuery + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ResponseException */ - public function messages($charset = 'UTF-8'){ - return $this->query($charset); + public function messages(array $extensions = []): WhereQuery { + return $this->query($extensions); } /** @@ -161,286 +201,377 @@ public function messages($charset = 'UTF-8'){ * * @return bool */ - public function hasChildren() { + public function hasChildren(): bool { return $this->has_children; } /** * Set children. + * @param FolderCollection $children * - * @param FolderCollection|array $children - * - * @return self + * @return Folder */ - public function setChildren($children = []) { + public function setChildren(FolderCollection $children): Folder { $this->children = $children; return $this; } /** - * Get a specific message by UID + * Get children. * - * @param integer $uid Please note that the uid is not unique and can change - * @param integer|null $msglist - * @param integer|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags + * @return FolderCollection + */ + public function getChildren(): FolderCollection { + return $this->children; + } + + /** + * Decode name. + * It converts UTF7-IMAP encoding to UTF-8. + * @param $name * - * @return Message|null - * @throws Exceptions\ConnectionFailedException - * @throws Exceptions\InvalidMessageDateException + * @return string|array|bool|string[]|null */ - public function getMessage($uid, $msglist = null, $fetch_options = null, $fetch_body = false, $fetch_attachment = false, $fetch_flags = true) { - $this->client->openFolder($this->path); - if (\imap_msgno($this->getClient()->getConnection(), $uid) > 0) { - return new Message($uid, $msglist, $this->getClient(), $fetch_options, $fetch_body, $fetch_attachment, $fetch_flags); + protected function decodeName($name): string|array|bool|null { + $parts = []; + foreach (explode($this->delimiter, $name) as $item) { + $parts[] = EncodingAliases::convert($item, "UTF7-IMAP"); } - return null; + return implode($this->delimiter, $parts); } /** - * Get all messages - * - * @param string $criteria - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * @param int|null $limit - * @param int $page - * @param string $charset + * Get simple name (without parent folders). + * @param $delimiter + * @param $full_name * - * @return MessageCollection - * @throws Exceptions\ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException + * @return string|bool */ - public function getMessages($criteria = 'ALL', $fetch_options = null, $fetch_body = true, $fetch_attachment = true, $fetch_flags = true, $limit = null, $page = 1, $charset = "UTF-8") { - - return $this->query($charset)->where($criteria)->setFetchOptions($fetch_options)->setFetchBody($fetch_body) - ->setFetchAttachment($fetch_attachment)->setFetchFlags($fetch_flags) - ->limit($limit, $page)->get(); + protected function getSimpleName($delimiter, $full_name): string|bool { + $arr = explode($delimiter, $full_name); + return end($arr); } /** - * Get all unseen messages - * - * @param string $criteria - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * @param int|null $limit - * @param int $page - * @param string $charset - * - * @return MessageCollection - * @throws Exceptions\ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException - * @throws MessageSearchValidationException - * - * @deprecated 1.0.5:2.0.0 No longer needed. Use Folder::getMessages('UNSEEN') instead - * @see Folder::getMessages() + * Parse attributes and set it to object properties. + * @param $attributes */ - public function getUnseenMessages($criteria = 'UNSEEN', $fetch_options = null, $fetch_body = true, $fetch_attachment = true, $fetch_flags = true, $limit = null, $page = 1, $charset = "UTF-8") { - return $this->getMessages($criteria, $fetch_options, $fetch_body, $fetch_attachment, $fetch_flags, $limit, $page, $charset); + protected function parseAttributes($attributes): void { + $this->no_inferiors = in_array('\NoInferiors', $attributes, true) || \in_array('\Noinferiors', $attributes, true); + $this->no_select = in_array('\NoSelect', $attributes, true) || \in_array('\Noselect', $attributes, true); + $this->marked = in_array('\Marked', $attributes); + $this->referral = in_array('\Referral', $attributes); + $this->has_children = in_array('\HasChildren', $attributes); } /** - * Search messages by a given search criteria - * - * @param array $where Is a two dimensional array where each array represents a criteria set: - * --------------------------------------------------------------------------------------- - * The following sample would search for all messages received from someone@example.com or - * contain the text "Hello world!": - * [['FROM' => 'someone@example.com'],[' TEXT' => 'Hello world!']] - * --------------------------------------------------------------------------------------- - * The following sample would search for all messages received since march 15 2018: - * [['SINCE' => Carbon::parse('15.03.2018')]] - * --------------------------------------------------------------------------------------- - * The following sample would search for all flagged messages: - * [['FLAGGED']] - * --------------------------------------------------------------------------------------- - * @param int|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * @param int|null $limit - * @param int $page - * @param string $charset - * - * @return MessageCollection - * - * @throws Exceptions\ConnectionFailedException - * @throws Exceptions\InvalidWhereQueryCriteriaException - * @throws GetMessagesFailedException - * - * @doc http://php.net/manual/en/function.imap-search.php - * \imap_search() only supports IMAP2 search criterias, because the function mail_criteria() (from c-client lib) - * is used in ext/imap/php_imap.c for parsing the search string. - * IMAP2 search criteria is defined in RFC 1176, section "tag SEARCH search_criteria". - * - * https://tools.ietf.org/html/rfc1176 - INTERACTIVE MAIL ACCESS PROTOCOL - VERSION 2 - * https://tools.ietf.org/html/rfc1064 - INTERACTIVE MAIL ACCESS PROTOCOL - VERSION 2 - * https://tools.ietf.org/html/rfc822 - STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES - * Date and time example from RFC822: - * date-time = [ day "," ] date time ; dd mm yy - * ; hh:mm:ss zzz - * - * day = "Mon" / "Tue" / "Wed" / "Thu" / "Fri" / "Sat" / "Sun" - * - * date = 1*2DIGIT month 2DIGIT ; day month year - * ; e.g. 20 Jun 82 - * - * month = "Jan" / "Feb" / "Mar" / "Apr" / "May" / "Jun" / "Jul" / "Aug" / "Sep" / "Oct" / "Nov" / "Dec" - * - * time = hour zone ; ANSI and Military - * - * hour = 2DIGIT ":" 2DIGIT [":" 2DIGIT] ; 00:00:00 - 23:59:59 - * - * zone = "UT" / "GMT" ; Universal Time - * ; North American : UT - * = "EST" / "EDT" ; Eastern: - 5/ - 4 - * = "CST" / "CDT" ; Central: - 6/ - 5 - * = "MST" / "MDT" ; Mountain: - 7/ - 6 - * = "PST" / "PDT" ; Pacific: - 8/ - 7 - * = 1ALPHA ; Military: Z = UT; - * ; A:-1; (J not used) - * ; M:-12; N:+1; Y:+12 - * / ( ("+" / "-") 4DIGIT ) ; Local differential - * ; hours+min. (HHMM) - * - * @deprecated 1.2.1:2.0.0 No longer needed. Use Folder::query() instead - * @see Folder::query() - */ - public function searchMessages(array $where, $fetch_options = null, $fetch_body = true, $fetch_attachment = true, $fetch_flags = true, $limit = null, $page = 1, $charset = "UTF-8") { - $this->getClient()->checkConnection(); + * Move or rename the current folder + * @param string $new_name + * @param boolean $expunge + * + * @return array + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function move(string $new_name, bool $expunge = true): array { + $this->client->checkConnection(); + $status = $this->client->getConnection()->renameFolder($this->full_name, $new_name)->validatedData(); + if ($expunge) $this->client->expunge(); - return $this->query($charset)->where($where)->setFetchOptions($fetch_options)->setFetchBody($fetch_body) - ->setFetchAttachment($fetch_attachment)->setFetchFlags($fetch_flags) - ->limit($limit, $page)->get(); + $folder = $this->client->getFolder($new_name); + $this->dispatch("folder", "moved", $this, $folder); + return $status; } /** - * Decode name. - * It converts UTF7-IMAP encoding to UTF-8. - * - * @param $name - * - * @return mixed|string + * Get a message overview + * @param string|null $sequence uid sequence + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws InvalidMessageDateException + * @throws MessageNotFoundException + * @throws ResponseException */ - protected function decodeName($name) { - preg_match('#\{(.*)\}(.*)#', $name, $preg); - return mb_convert_encoding($preg[2], "UTF-8", "UTF7-IMAP"); + public function overview(?string $sequence = null): array { + $this->client->openFolder($this->path); + $sequence = $sequence === null ? "1:*" : $sequence; + $uid = $this->client->getConfig()->get('options.sequence', IMAP::ST_MSGN); + $response = $this->client->getConnection()->overview($sequence, $uid); + return $response->validatedData(); } /** - * Get simple name (without parent folders). - * - * @param $delimiter - * @param $full_name - * - * @return mixed + * Append a string message to the current mailbox + * @param string $message + * @param array|null $options + * @param string|Carbon|null $internal_date + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException */ - protected function getSimpleName($delimiter, $full_name) { - $arr = explode($delimiter, $full_name); + public function appendMessage(string $message, ?array $options = null, Carbon|string|null $internal_date = null): array { + /** + * Check if $internal_date is parsed. If it is null it should not be set. Otherwise, the message can't be stored. + * If this parameter is set, it will set the INTERNALDATE on the appended message. The parameter should be a + * date string that conforms to the rfc2060 specifications for a date_time value or be a Carbon object. + */ - return end($arr); + if ($internal_date instanceof Carbon) { + $internal_date = $internal_date->format('d-M-Y H:i:s O'); + } + + return $this->client->getConnection()->appendMessage($this->path, $message, $options, $internal_date)->validatedData(); } /** - * Parse attributes and set it to object properties. + * Rename the current folder + * @param string $new_name + * @param boolean $expunge * - * @param $attributes + * @return array + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws RuntimeException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws AuthFailedException + * @throws ResponseException */ - protected function parseAttributes($attributes) { - $this->no_inferiors = ($attributes & LATT_NOINFERIORS) ? true : false; - $this->no_select = ($attributes & LATT_NOSELECT) ? true : false; - $this->marked = ($attributes & LATT_MARKED) ? true : false; - $this->referal = ($attributes & LATT_REFERRAL) ? true : false; - $this->has_children = ($attributes & LATT_HASCHILDREN) ? true : false; + public function rename(string $new_name, bool $expunge = true): array { + return $this->move($new_name, $expunge); } /** - * Delete the current Mailbox + * Delete the current folder * @param boolean $expunge * - * @return bool - * - * @throws Exceptions\ConnectionFailedException + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws EventNotFoundException + * @throws AuthFailedException + * @throws ResponseException */ - public function delete($expunge = true) { - $status = \imap_deletemailbox($this->client->getConnection(), $this->path); - if($expunge) $this->client->expunge(); + public function delete(bool $expunge = true): array { + $status = $this->client->getConnection()->deleteFolder($this->path)->validatedData(); + if ($this->client->getActiveFolder() == $this->path){ + $this->client->setActiveFolder(); + } + + if ($expunge) $this->client->expunge(); + + $this->dispatch("folder", "deleted", $this); return $status; } /** - * Move or Rename the current Mailbox - * - * @param string $target_mailbox - * @param boolean $expunge - * - * @return bool - * - * @throws Exceptions\ConnectionFailedException + * Subscribe the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException */ - public function move($target_mailbox, $expunge = true) { - $status = \imap_renamemailbox($this->client->getConnection(), $this->path, $target_mailbox); - if($expunge) $this->client->expunge(); + public function subscribe(): array { + $this->client->openFolder($this->path); + return $this->client->getConnection()->subscribeFolder($this->path)->validatedData(); + } - return $status; + /** + * Unsubscribe the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function unsubscribe(): array { + $this->client->openFolder($this->path); + return $this->client->getConnection()->unsubscribeFolder($this->path)->validatedData(); } /** - * Returns status information on a mailbox - * - * @param integer $options - * SA_MESSAGES - set $status->messages to the number of messages in the mailbox - * SA_RECENT - set $status->recent to the number of recent messages in the mailbox - * SA_UNSEEN - set $status->unseen to the number of unseen (new) messages in the mailbox - * SA_UIDNEXT - set $status->uidnext to the next uid to be used in the mailbox - * SA_UIDVALIDITY - set $status->uidvalidity to a constant that changes when uids for the mailbox may no longer be valid - * SA_ALL - set all of the above - * - * @return object - * @throws Exceptions\ConnectionFailedException + * Idle the current connection + * @param callable $callback function(Message $message) gets called if a new message is received + * @param integer $timeout max 1740 seconds - recommended by rfc2177 §3. Should not be lower than the servers "* OK Still here" message interval + * + * @throws ConnectionFailedException + * @throws RuntimeException + * @throws AuthFailedException + * @throws NotSupportedCapabilityException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException */ - public function getStatus($options) { - return \imap_status($this->client->getConnection(), $this->path, $options); + public function idle(callable $callback, int $timeout = 300): void { + $this->client->setTimeout($timeout); + + if (!in_array("IDLE", $this->client->getConnection()->getCapabilities()->validatedData())) { + throw new Exceptions\NotSupportedCapabilityException("IMAP server does not support IDLE"); + } + + $idle_client = $this->client->clone(); + $idle_client->connect(); + $idle_client->openFolder($this->path, true); + $idle_client->getConnection()->idle(); + + $last_action = Carbon::now()->addSeconds($timeout); + + $sequence = $this->client->getConfig()->get('options.sequence', IMAP::ST_MSGN); + + while (true) { + try { + // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand + $line = $idle_client->getConnection()->nextLine(Response::empty()); + } catch (Exceptions\RuntimeException $e) { + if(strpos($e->getMessage(), "empty response") >= 0 && $idle_client->getConnection()->connected()) { + continue; + } + if(!str_contains($e->getMessage(), "connection closed")) { + throw $e; + } + } + + if (($pos = strpos($line, "EXISTS")) !== false) { + $msgn = (int)substr($line, 2, $pos - 2); + + // Check if the stream is still alive or should be considered stale + if (!$this->client->isConnected() || $last_action->isBefore(Carbon::now())) { + // Reset the connection before interacting with it. Otherwise, the resource might be stale which + // would result in a stuck interaction. If you know of a way of detecting a stale resource, please + // feel free to improve this logic. I tried a lot but nothing seem to work reliably... + // Things that didn't work: + // - Closing the resource with fclose() + // - Verifying the resource with stream_get_meta_data() + // - Bool validating the resource stream (e.g.: (bool)$stream) + // - Sending a NOOP command + // - Sending a null package + // - Reading a null package + // - Catching the fs warning + + // This polymorphic call is fine - Protocol::idle() will throw an exception beforehand + $this->client->getConnection()->reset(); + // Establish a new connection + $this->client->connect(); + } + $last_action = Carbon::now()->addSeconds($timeout); + + // Always reopen the folder - otherwise the new message number isn't known to the current remote session + $this->client->openFolder($this->path, true); + + $message = $this->query()->getMessageByMsgn($msgn); + $message->setSequence($sequence); + $callback($message); + + $this->dispatch("message", "new", $message); + } + } } /** - * Append a string message to the current mailbox + * Get folder status information from the EXAMINE command + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function status(): array { + return $this->client->getConnection()->folderStatus($this->path)->validatedData(); + } + + /** + * Get folder status information from the EXAMINE command * - * @param string $message - * @param string $options - * @param string $internal_date + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException * - * @return bool - * @throws Exceptions\ConnectionFailedException + * @deprecated Use Folder::status() instead */ - public function appendMessage($message, $options = null, $internal_date = null) { - /** - * Check if $internal_date is parsed. If it is null it should not be set. Otherwise the message can't be stored. - * If this parameter is set, it will set the INTERNALDATE on the appended message. The parameter should be a - * date string that conforms to the rfc2060 specifications for a date_time value or be a Carbon object. - */ + public function getStatus(): array { + return $this->status(); + } - if ($internal_date != null) { - if ($internal_date instanceof Carbon){ - $internal_date = $internal_date->format('d-M-Y H:i:s O'); - } - return \imap_append($this->client->getConnection(), $this->path, $message, $options, $internal_date); - } + /** + * Load folder status information from the EXAMINE command + * @return Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function loadStatus(): Folder { + $this->status = $this->examine(); + return $this; + } - return \imap_append($this->client->getConnection(), $this->path, $message, $options); + /** + * Examine the current folder + * + * @return array + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws AuthFailedException + * @throws ResponseException + */ + public function examine(): array { + return $this->client->getConnection()->examineFolder($this->path)->validatedData(); + } + + /** + * Select the current folder + * + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + public function select(): array { + return $this->client->getConnection()->selectFolder($this->path)->validatedData(); } /** @@ -448,16 +579,17 @@ public function appendMessage($message, $options = null, $internal_date = null) * * @return Client */ - public function getClient() { + public function getClient(): Client { return $this->client; } /** + * Set the delimiter * @param $delimiter */ - public function setDelimiter($delimiter){ - if(in_array($delimiter, [null, '', ' ', false]) === true) { - $delimiter = ClientManager::get('options.delimiter', '/'); + public function setDelimiter($delimiter): void { + if (in_array($delimiter, [null, '', ' ', false]) === true) { + $delimiter = $this->client->getConfig()->get('options.delimiter', '/'); } $this->delimiter = $delimiter; diff --git a/src/Header.php b/src/Header.php new file mode 100644 index 00000000..d18e98a1 --- /dev/null +++ b/src/Header.php @@ -0,0 +1,871 @@ +decoder = $config->getDecoder("header"); + $this->raw = $raw_header; + $this->config = $config; + $this->options = $this->config->get('options'); + $this->parse(); + } + + /** + * Call dynamic attribute setter and getter methods + * @param string $method + * @param array $arguments + * + * @return Attribute|mixed + * @throws MethodNotFoundException + */ + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { + $name = preg_replace('/(.)(?=[A-Z])/u', '$1_', substr(strtolower($method), 3)); + + if (in_array($name, array_keys($this->attributes))) { + return $this->attributes[$name]; + } + + } + + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); + } + + /** + * Magic getter + * @param $name + * + * @return Attribute|null + */ + public function __get($name) { + return $this->get($name); + } + + /** + * Get a specific header attribute + * @param $name + * + * @return Attribute + */ + public function get($name): Attribute { + $name = str_replace(["-", " "], "_", strtolower($name)); + if (isset($this->attributes[$name])) { + return $this->attributes[$name]; + } + + return new Attribute($name); + } + + /** + * Check if a specific attribute exists + * @param string $name + * + * @return bool + */ + public function has(string $name): bool { + $name = str_replace(["-", " "], "_", strtolower($name)); + return isset($this->attributes[$name]); + } + + /** + * Set a specific attribute + * @param string $name + * @param array|mixed $value + * @param boolean $strict + * + * @return Attribute|array + */ + public function set(string $name, mixed $value, bool $strict = false): Attribute|array { + if (isset($this->attributes[$name]) && $strict === false) { + $this->attributes[$name]->add($value, true); + } else { + $this->attributes[$name] = new Attribute($name, $value); + } + + return $this->attributes[$name]; + } + + /** + * Perform a regex match all on the raw header and return the first result + * @param $pattern + * + * @return mixed|null + */ + public function find($pattern): mixed { + if (preg_match_all($pattern, $this->raw, $matches)) { + if (isset($matches[1])) { + if (count($matches[1]) > 0) { + return $matches[1][0]; + } + } + } + return null; + } + + /** + * Try to find a boundary if possible + * + * @return string|null + */ + public function getBoundary(): ?string { + $regex = $this->options["boundary"] ?? "/boundary=(.*?(?=;)|(.*))/i"; + $boundary = $this->find($regex); + + if ($boundary === null) { + return null; + } + + return $this->clearBoundaryString($boundary); + } + + /** + * Remove all unwanted chars from a given boundary + * @param string $str + * + * @return string + */ + private function clearBoundaryString(string $str): string { + return str_replace(['"', '\r', '\n', "\n", "\r", ";", "\s"], "", $str); + } + + /** + * Parse the raw headers + * + * @throws InvalidMessageDateException + * @throws SpoofingAttemptDetectedException + */ + protected function parse(): void { + $header = $this->rfc822_parse_headers($this->raw); + + $this->extractAddresses($header); + + if (property_exists($header, 'subject')) { + $this->set("subject", $this->decoder->decode($header->subject)); + } + if (property_exists($header, 'references')) { + $this->set("references", array_map(function($item) { + return str_replace(['<', '>'], '', $item); + }, explode(" ", $header->references))); + } + if (property_exists($header, 'message_id')) { + $this->set("message_id", str_replace(['<', '>'], '', $header->message_id)); + } + if (property_exists($header, 'in_reply_to')) { + $this->set("in_reply_to", str_replace(['<', '>'], '', $header->in_reply_to)); + } + + $this->parseDate($header); + foreach ($header as $key => $value) { + $key = trim(rtrim(strtolower($key))); + if (!isset($this->attributes[$key])) { + $this->set($key, $value); + } + } + + $this->extractHeaderExtensions(); + $this->findPriority(); + + if($this->config->get('security.detect_spoofing', true)) { + // Detect spoofing + $this->detectSpoofing(); + } + } + + /** + * Parse mail headers from a string + * @link https://php.net/manual/en/function.imap-rfc822-parse-headers.php + * @param $raw_headers + * + * @return object + */ + public function rfc822_parse_headers($raw_headers): object { + $headers = []; + $imap_headers = []; + if (extension_loaded('imap') && $this->options["rfc822"]) { + $raw_imap_headers = (array)\imap_rfc822_parse_headers($raw_headers); + foreach ($raw_imap_headers as $key => $values) { + $key = strtolower(str_replace("-", "_", $key)); + $imap_headers[$key] = $values; + } + } + $lines = explode("\r\n", preg_replace("/\r\n\s/", ' ', $raw_headers)); + $prev_header = null; + foreach ($lines as $line) { + if (str_starts_with($line, "\n")) { + $line = substr($line, 1); + } + + if (str_starts_with($line, "\t")) { + $line = substr($line, 1); + $line = trim(rtrim($line)); + if ($prev_header !== null) { + $headers[$prev_header][] = $line; + } + } elseif (str_starts_with($line, " ")) { + $line = substr($line, 1); + $line = trim(rtrim($line)); + if ($prev_header !== null) { + if (!isset($headers[$prev_header])) { + $headers[$prev_header] = ""; + } + if (is_array($headers[$prev_header])) { + $headers[$prev_header][] = $line; + } else { + $headers[$prev_header] .= $line; + } + } + } else { + if (($pos = strpos($line, ":")) > 0) { + $key = trim(rtrim(strtolower(substr($line, 0, $pos)))); + $key = strtolower(str_replace("-", "_", $key)); + + $value = trim(rtrim(substr($line, $pos + 1))); + if (isset($headers[$key])) { + $headers[$key][] = $value; + } else { + $headers[$key] = [$value]; + } + $prev_header = $key; + } + } + } + + foreach ($headers as $key => $values) { + if (isset($imap_headers[$key])) { + continue; + } + $value = null; + switch ((string)$key) { + case 'from': + case 'to': + case 'cc': + case 'bcc': + case 'reply_to': + case 'sender': + $value = $this->decodeAddresses($values); + $headers[$key . "address"] = implode(", ", $values); + break; + case 'subject': + $value = implode(" ", $values); + break; + default: + if (is_array($values)) { + foreach ($values as $k => $v) { + if ($v == "") { + unset($values[$k]); + } + } + $available_values = count($values); + if ($available_values === 1) { + $value = array_pop($values); + } elseif ($available_values === 2) { + $value = implode(" ", $values); + } elseif ($available_values > 2) { + $value = array_values($values); + } else { + $value = ""; + } + } + break; + } + $headers[$key] = $value; + } + + return (object)array_merge($headers, $imap_headers); + } + + /** + * Try to extract the priority from a given raw header string + */ + private function findPriority(): void { + $priority = $this->get("x_priority"); + + $priority = match ((int)"$priority") { + IMAP::MESSAGE_PRIORITY_HIGHEST => IMAP::MESSAGE_PRIORITY_HIGHEST, + IMAP::MESSAGE_PRIORITY_HIGH => IMAP::MESSAGE_PRIORITY_HIGH, + IMAP::MESSAGE_PRIORITY_NORMAL => IMAP::MESSAGE_PRIORITY_NORMAL, + IMAP::MESSAGE_PRIORITY_LOW => IMAP::MESSAGE_PRIORITY_LOW, + IMAP::MESSAGE_PRIORITY_LOWEST => IMAP::MESSAGE_PRIORITY_LOWEST, + default => IMAP::MESSAGE_PRIORITY_UNKNOWN, + }; + + $this->set("priority", $priority); + } + + /** + * Extract a given part as address array from a given header + * @param $values + * + * @return array + */ + private function decodeAddresses($values): array { + $addresses = []; + + if (extension_loaded('mailparse') && $this->options["rfc822"]) { + foreach ($values as $address) { + foreach (\mailparse_rfc822_parse_addresses($address) as $parsed_address) { + if (isset($parsed_address['address'])) { + $mail_address = explode('@', $parsed_address['address']); + if (count($mail_address) == 2) { + $addresses[] = (object)[ + "personal" => $parsed_address['display'] ?? '', + "mailbox" => $mail_address[0], + "host" => $mail_address[1], + ]; + } + } + } + } + + return $addresses; + } + + foreach ($values as $address) { + foreach (preg_split('/, ?(?=(?:[^"]*"[^"]*")*[^"]*$)/', $address) as $split_address) { + $split_address = trim(rtrim($split_address)); + + if (strpos($split_address, ",") == strlen($split_address) - 1) { + $split_address = substr($split_address, 0, -1); + } + if (preg_match( + '/^(?:(?P.+)\s)?(?(name)<|[^\s]+?)(?(name)>|>?)$/', + $split_address, + $matches + )) { + $name = trim(rtrim($matches["name"])); + $email = trim(rtrim($matches["email"])); + list($mailbox, $host) = array_pad(explode("@", $email), 2, null); + $addresses[] = (object)[ + "personal" => $name, + "mailbox" => $mailbox, + "host" => $host, + ]; + }elseif (preg_match( + '/^((?P.+)<)(?P[^<]+?)>$/', + $split_address, + $matches + )) { + $name = trim(rtrim($matches["name"])); + if(str_starts_with($name, "\"") && str_ends_with($name, "\"")) { + $name = substr($name, 1, -1); + }elseif(str_starts_with($name, "'") && str_ends_with($name, "'")) { + $name = substr($name, 1, -1); + } + $email = trim(rtrim($matches["email"])); + list($mailbox, $host) = array_pad(explode("@", $email), 2, null); + $addresses[] = (object)[ + "personal" => $name, + "mailbox" => $mailbox, + "host" => $host, + ]; + } + } + } + + return $addresses; + } + + /** + * Extract a given part as address array from a given header + * @param object $header + */ + private function extractAddresses(object $header): void { + foreach (['from', 'to', 'cc', 'bcc', 'reply_to', 'sender', 'return_path', 'envelope_from', 'envelope_to', 'delivered_to'] as $key) { + if (property_exists($header, $key)) { + $this->set($key, $this->parseAddresses($header->$key)); + } + } + } + + /** + * Parse Addresses + * @param $list + * + * @return array + */ + private function parseAddresses($list): array { + $addresses = []; + + if (is_array($list) === false) { + if(is_string($list)) { + if (preg_match( + '/^(?:(?P.+)\s)?(?(name)<|[^\s]+?)(?(name)>|>?)$/', + $list, + $matches + )) { + $name = trim(rtrim($matches["name"])); + $email = trim(rtrim($matches["email"])); + list($mailbox, $host) = array_pad(explode("@", $email), 2, null); + if($mailbox === ">") { // Fix trailing ">" in malformed mailboxes + $mailbox = ""; + } + if($name === "" && $mailbox === "" && $host === "") { + return $addresses; + } + $list = [ + (object)[ + "personal" => $name, + "mailbox" => $mailbox, + "host" => $host, + ] + ]; + }elseif (preg_match( + '/^((?P.+)<)(?P[^<]+?)>$/', + $list, + $matches + )) { + $name = trim(rtrim($matches["name"])); + $email = trim(rtrim($matches["email"])); + if(str_starts_with($name, "\"") && str_ends_with($name, "\"")) { + $name = substr($name, 1, -1); + }elseif(str_starts_with($name, "'") && str_ends_with($name, "'")) { + $name = substr($name, 1, -1); + } + list($mailbox, $host) = array_pad(explode("@", $email), 2, null); + if($mailbox === ">") { // Fix trailing ">" in malformed mailboxes + $mailbox = ""; + } + if($name === "" && $mailbox === "" && $host === "") { + return $addresses; + } + $list = [ + (object)[ + "personal" => $name, + "mailbox" => $mailbox, + "host" => $host, + ] + ]; + }else{ + return $addresses; + } + }else{ + return $addresses; + } + } + + foreach ($list as $item) { + $address = (object)$item; + + if (!property_exists($address, 'mailbox')) { + $address->mailbox = false; + } + if (!property_exists($address, 'host')) { + $address->host = false; + } + if (!property_exists($address, 'personal')) { + $address->personal = false; + } else { + $personal_slices = explode(" ", $address->personal); + $address->personal = ""; + foreach ($personal_slices as $slice) { + $personalParts = $this->decoder->mimeHeaderDecode($slice); + + $personal = ''; + foreach ($personalParts as $p) { + $personal .= $this->decoder->convertEncoding($p->text, $this->decoder->getEncoding($p)); + } + + if (str_starts_with($personal, "'")) { + $personal = str_replace("'", "", $personal); + } + $personal = $this->decoder->decode($personal); + $address->personal .= $personal . " "; + } + $address->personal = trim(rtrim($address->personal)); + } + + if ($address->host == ".SYNTAX-ERROR.") { + $address->host = ""; + }elseif ($address->host == "UNKNOWN") { + $address->host = ""; + } + if ($address->mailbox == "UNEXPECTED_DATA_AFTER_ADDRESS") { + $address->mailbox = ""; + }elseif ($address->mailbox == "MISSING_MAILBOX_TERMINATOR") { + $address->mailbox = ""; + } + + $addresses[] = new Address($address); + } + + return $addresses; + } + + /** + * Search and extract potential header extensions + */ + private function extractHeaderExtensions(): void { + foreach ($this->attributes as $key => $value) { + if (is_array($value)) { + $value = implode(", ", $value); + } else { + $value = (string)$value; + } + // Only parse strings and don't parse any attributes like the user-agent + if (!in_array($key, ["user-agent", "subject", "received"])) { + if (str_contains($value, ";") && str_contains($value, "=")) { + $_attributes = $this->read_attribute($value); + foreach($_attributes as $_key => $_value) { + if($_value === "") { + $this->set($key, $_key); + } + if (!isset($this->attributes[$_key])) { + $this->set($_key, $_value); + } + } + } + } + } + } + + /** + * Read a given attribute string + * - this isn't pretty, but it works - feel free to improve :) + * @param string $raw_attribute + * @return array + */ + private function read_attribute(string $raw_attribute): array { + $attributes = []; + $key = ''; + $value = ''; + $inside_word = false; + $inside_key = true; + $escaped = false; + foreach (str_split($raw_attribute) as $char) { + if($escaped) { + $escaped = false; + continue; + } + if($inside_word) { + if($char === '\\') { + $escaped = true; + }elseif($char === "\"" && $value !== "") { + $inside_word = false; + }else{ + $value .= $char; + } + }else{ + if($inside_key) { + if($char === '"') { + $inside_word = true; + }elseif($char === ';'){ + $attributes[$key] = $value; + $key = ''; + $value = ''; + $inside_key = true; + }elseif($char === '=') { + $inside_key = false; + }else{ + $key .= $char; + } + }else{ + if($char === '"' && $value === "") { + $inside_word = true; + }elseif($char === ';'){ + $attributes[$key] = $value; + $key = ''; + $value = ''; + $inside_key = true; + }else{ + $value .= $char; + } + } + } + } + $attributes[$key] = $value; + $result = []; + + foreach($attributes as $key => $value) { + if (($pos = strpos($key, "*")) !== false) { + $key = substr($key, 0, $pos); + } + $key = trim(rtrim(strtolower($key))); + + if(!isset($result[$key])) { + $result[$key] = ""; + } + $value = trim(rtrim(str_replace(["\r", "\n"], "", $value))); + if(str_starts_with($value, "\"") && str_ends_with($value, "\"")) { + $value = substr($value, 1, -1); + } + $result[$key] .= $value; + } + return $result; + } + + /** + * Exception handling for invalid dates + * + * Known bad and "invalid" formats: + * ^ Datetime ^ Problem ^ Cause + * | Mon, 20 Nov 2017 20:31:31 +0800 (GMT+8:00) | Double timezone specification | A Windows feature + * | Thu, 8 Nov 2018 08:54:58 -0200 (-02) | + * | | and invalid timezone (max 6 char) | + * | 04 Jan 2018 10:12:47 UT | Missing letter "C" | Unknown + * | Thu, 31 May 2018 18:15:00 +0800 (added by) | Non-standard details added by the | Unknown + * | | mail server | + * | Sat, 31 Aug 2013 20:08:23 +0580 | Invalid timezone | PHPMailer bug https://sourceforge.net/p/phpmailer/mailman/message/6132703/ + * | Mi., 23 Apr. 2025 09:48:37 +0200 (MESZ) | Non-standard localized format | Aqua Mail S/MIME implementation + * + * Please report any new invalid timestamps to [#45](https://github.com/Webklex/php-imap/issues) + * + * @param object $header + * + * @throws InvalidMessageDateException + */ + private function parseDate(object $header): void { + + if (property_exists($header, 'date')) { + $date = $header->date; + + if (preg_match('/\+0580/', $date)) { + $date = str_replace('+0580', '+0530', $date); + } + + $date = trim(rtrim($date)); + try { + if (str_contains($date, ' ')) { + $date = str_replace(' ', ' ', $date); + } + if (str_contains($date, ' UT ')) { + $date = str_replace(' UT ', ' UTC ', $date); + } + $parsed_date = Carbon::parse($date); + } catch (\Exception $e) { + switch (true) { + case preg_match('/([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}\-[0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})+$/i', $date) > 0: + $date = Carbon::createFromFormat("Y.m.d-H.i.s", $date); + break; + case preg_match('/([0-9]{2} [A-Z]{3} [0-9]{4} [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+-][0-9]{1,4} [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+-][0-9]{1,4})+$/i', $date) > 0: + $parts = explode(' ', $date); + array_splice($parts, -2); + $date = implode(' ', $parts); + break; + case preg_match('/([A-Z]{2,4}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $array = explode(',', $date); + array_shift($array); + $date = Carbon::createFromFormat("d M Y H:i:s O", trim(implode(',', $array))); + break; + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ ([0-9]{2}|[0-9]{4})\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: + $date .= 'C'; + break; + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}[\,]\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $date = str_replace(',', '', $date); + break; + // match case for: Di., 15 Feb. 2022 06:52:44 +0100 (MEZ)/Di., 15 Feb. 2022 06:52:44 +0100 (MEZ) and Mi., 23 Apr. 2025 09:48:37 +0200 (MESZ) + case preg_match('/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \([A-Z]{3,4}\))(\/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \([A-Z]{3,4}\))+)?$/i', $date) > 0: + $dates = explode('/', $date); + $date = array_shift($dates); + $array = explode(',', $date); + array_shift($array); + $date = trim(implode(',', $array)); + $array = explode(' ', $date); + array_pop($array); + $date = trim(implode(' ', $array)); + $date = Carbon::createFromFormat("d M. Y H:i:s O", $date); + break; + // match case for: fr., 25 nov. 2022 06:27:14 +0100/fr., 25 nov. 2022 06:27:14 +0100 + case preg_match('/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})\/([A-Z]{2,3}\.\,\ [0-9]{1,2}\ [A-Z]{2,3}\.\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4})+$/i', $date) > 0: + $dates = explode('/', $date); + $date = array_shift($dates); + $array = explode(',', $date); + array_shift($array); + $date = trim(implode(',', $array)); + $date = Carbon::createFromFormat("d M. Y H:i:s O", $date); + break; + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ \+[0-9]{2,4}\ \(\+[0-9]{1,2}\))+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}[\,|\ \,]\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}.*)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([A-Z]{2,3}\, \ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: + case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{2,4}\ [0-9]{2}\:[0-9]{2}\:[0-9]{2}\ [A-Z]{2}\ \-[0-9]{2}\:[0-9]{2}\ \([A-Z]{2,3}\ \-[0-9]{2}:[0-9]{2}\))+$/i', $date) > 0: + $array = explode('(', $date); + $array = array_reverse($array); + $date = trim(array_pop($array)); + break; + } + try { + $parsed_date = Carbon::parse($date); + } catch (\Exception $_e) { + if (!isset($this->options["fallback_date"])) { + throw new InvalidMessageDateException("Invalid message date. ID:" . $this->get("message_id") . " Date:" . $header->date . "/" . $date, 1100, $e); + } else { + $parsed_date = Carbon::parse($this->options["fallback_date"]); + } + } + } + + $this->set("date", $parsed_date); + } + } + + /** + * Get all available attributes + * + * @return array + */ + public function getAttributes(): array { + return $this->attributes; + } + + /** + * Set all header attributes + * @param array $attributes + * + * @return Header + */ + public function setAttributes(array $attributes): Header { + $this->attributes = $attributes; + return $this; + } + + /** + * Set the configuration used for parsing a raw header + * @param array $config + * + * @return Header + */ + public function setOptions(array $config): Header { + $this->options = $config; + return $this; + } + + /** + * Get the configuration used for parsing a raw header + * + * @return array + */ + public function getOptions(): array { + return $this->options; + } + + /** + * Set the configuration used for parsing a raw header + * @param Config $config + * + * @return Header + */ + public function setConfig(Config $config): Header { + $this->config = $config; + return $this; + } + + /** + * Get the configuration used for parsing a raw header + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } + + /** + * Get the decoder instance + * + * @return DecoderInterface + */ + public function getDecoder(): DecoderInterface { + return $this->decoder; + } + + /** + * Set the decoder instance + * @param DecoderInterface $decoder + * + * @return $this + */ + public function setDecoder(DecoderInterface $decoder): static { + $this->decoder = $decoder; + return $this; + } + + /** + * Detect spoofing by checking the from, reply_to, return_path, sender and envelope_from headers + * @throws SpoofingAttemptDetectedException + */ + private function detectSpoofing(): void { + $header_keys = ["from", "reply_to", "return_path", "sender", "envelope_from"]; + $potential_senders = []; + foreach($header_keys as $key) { + $header = $this->get($key); + foreach ($header->toArray() as $address) { + $potential_senders[] = $address->mailbox . "@" . $address->host; + } + } + if(count($potential_senders) > 1) { + $this->set("spoofed", true); + if($this->config->get('security.detect_spoofing_exception', false)) { + throw new SpoofingAttemptDetectedException("Potential spoofing detected. Message ID: " . $this->get("message_id") . " Senders: " . implode(", ", $potential_senders)); + } + } + } + +} diff --git a/src/IMAP.php b/src/IMAP.php index 314b1922..41ae8248 100644 --- a/src/IMAP.php +++ b/src/IMAP.php @@ -223,6 +223,7 @@ class IMAP { */ const ST_UID = 1; const ST_SILENT = 2; + const ST_MSGN = 3; const ST_SET = 4; /** diff --git a/src/Message.php b/src/Message.php index 4c1f048e..9d15a7b8 100755 --- a/src/Message.php +++ b/src/Message.php @@ -12,222 +12,391 @@ namespace Webklex\PHPIMAP; -use Carbon\Carbon; +use Exception; +use Illuminate\Support\Str; +use ReflectionClass; +use ReflectionException; +use Webklex\PHPIMAP\Decoder\Decoder; +use Webklex\PHPIMAP\Decoder\DecoderInterface; +use Webklex\PHPIMAP\Exceptions\AuthFailedException; +use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; +use Webklex\PHPIMAP\Exceptions\DecoderNotFoundException; +use Webklex\PHPIMAP\Exceptions\EventNotFoundException; +use Webklex\PHPIMAP\Exceptions\FolderFetchingException; +use Webklex\PHPIMAP\Exceptions\GetMessagesFailedException; +use Webklex\PHPIMAP\Exceptions\ImapBadRequestException; +use Webklex\PHPIMAP\Exceptions\ImapServerErrorException; use Webklex\PHPIMAP\Exceptions\InvalidMessageDateException; use Webklex\PHPIMAP\Exceptions\MaskNotFoundException; +use Webklex\PHPIMAP\Exceptions\MessageContentFetchingException; +use Webklex\PHPIMAP\Exceptions\MessageFlagException; +use Webklex\PHPIMAP\Exceptions\MessageHeaderFetchingException; +use Webklex\PHPIMAP\Exceptions\MessageNotFoundException; +use Webklex\PHPIMAP\Exceptions\MessageSizeFetchingException; use Webklex\PHPIMAP\Exceptions\MethodNotFoundException; +use Webklex\PHPIMAP\Exceptions\ResponseException; +use Webklex\PHPIMAP\Exceptions\RuntimeException; use Webklex\PHPIMAP\Support\AttachmentCollection; use Webklex\PHPIMAP\Support\FlagCollection; use Webklex\PHPIMAP\Support\Masks\MessageMask; -use Illuminate\Support\Str; +use Webklex\PHPIMAP\Support\MessageCollection; +use Webklex\PHPIMAP\Traits\HasEvents; /** * Class Message * * @package Webklex\PHPIMAP * - * @property integer msglist - * @property integer uid - * @property integer msgn - * @property integer priority - * @property string subject - * @property string message_id - * @property string message_no - * @property string references - * @property carbon date - * @property array from - * @property array to - * @property array cc - * @property array bcc - * @property array reply_to - * @property array in_reply_to - * @property array sender + * @property integer $msglist + * @property integer $uid + * @property integer $msgn + * @property integer $size + * @property Attribute $subject + * @property Attribute $message_id + * @property Attribute $message_no + * @property Attribute $references + * @property Attribute $date + * @property Attribute $from + * @property Attribute $to + * @property Attribute $cc + * @property Attribute $bcc + * @property Attribute $reply_to + * @property Attribute $in_reply_to + * @property Attribute $sender * * @method integer getMsglist() - * @method integer setMsglist(integer $msglist) + * @method integer setMsglist($msglist) * @method integer getUid() - * @method integer setUid(integer $uid) * @method integer getMsgn() - * @method integer setMsgn(integer $msgn) - * @method integer getPriority() - * @method integer setPriority(integer $priority) - * @method string getSubject() - * @method string setSubject(string $subject) - * @method string getMessageId() - * @method string setMessageId(string $message_id) - * @method string getMessageNo() - * @method string setMessageNo(string $message_no) - * @method string getReferences() - * @method string setReferences(string $references) - * @method carbon getDate() - * @method carbon setDate(carbon $date) - * @method array getFrom() - * @method array setFrom(array $from) - * @method array getTo() - * @method array setTo(array $to) - * @method array getCc() - * @method array setCc(array $cc) - * @method array getBcc() - * @method array setBcc(array $bcc) - * @method array getReplyTo() - * @method array setReplyTo(array $reply_to) - * @method array getInReplyTo() - * @method array setInReplyTo(array $in_reply_to) - * @method array getSender() - * @method array setSender(array $sender) + * @method integer getSize() + * @method Attribute getPriority() + * @method Attribute getSubject() + * @method Attribute getMessageId() + * @method Attribute getMessageNo() + * @method Attribute getReferences() + * @method Attribute getDate() + * @method Attribute getFrom() + * @method Attribute getTo() + * @method Attribute getCc() + * @method Attribute getBcc() + * @method Attribute getReplyTo() + * @method Attribute getInReplyTo() + * @method Attribute getSender() */ class Message { + use HasEvents; /** * Client instance * - * @var Client + * @var ?Client */ - private $client = Client::class; + private ?Client $client; /** * Default mask + * * @var string $mask */ - protected $mask = MessageMask::class; - - /** @var array $config */ - protected $config = []; - - /** @var array $attributes */ - protected $attributes = [ - 'message_id' => '', - 'message_no' => null, - 'subject' => '', - 'references' => null, - 'date' => null, - 'from' => [], - 'to' => [], - 'cc' => [], - 'bcc' => [], - 'reply_to' => [], - 'in_reply_to' => '', - 'sender' => [], - 'priority' => 0, - ]; + protected string $mask = MessageMask::class; + + /** + * Used options + * + * @var array $options + */ + protected array $options = []; + + /** + * All library configs + * + * @var Config $config + */ + protected Config $config; + + /** + * Decoder instance + * + * @var DecoderInterface $decoder + */ + protected DecoderInterface $decoder; + + /** + * Attribute holder + * + * @var Attribute[]|array $attributes + */ + protected array $attributes = []; /** * The message folder path * * @var string $folder_path */ - protected $folder_path; + protected string $folder_path; /** * Fetch body options * - * @var integer + * @var ?integer */ - public $fetch_options = null; + public ?int $fetch_options = null; /** - * Fetch body options - * - * @var bool + * @var integer */ - public $fetch_body = null; + protected int $sequence = IMAP::NIL; /** - * Fetch attachments options + * Fetch body options * * @var bool */ - public $fetch_attachment = null; + public bool $fetch_body = true; /** * Fetch flags options * * @var bool */ - public $fetch_flags = null; + public bool $fetch_flags = true; /** - * @var string $header + * @var ?Header $header */ - public $header = null; + public ?Header $header = null; /** - * @var null|object $header_info + * Raw message body + * + * @var string $raw_body */ - public $header_info = null; + protected string $raw_body = ""; - /** @var null|string $raw_body */ - public $raw_body = null; - - /** @var null $structure */ - protected $structure = null; + /** + * Message structure + * + * @var ?Structure $structure + */ + protected ?Structure $structure = null; /** * Message body components * - * @var array $bodies - * @var AttachmentCollection|array $attachments - * @var FlagCollection|array $flags + * @var array $bodies */ - public $bodies = []; - public $attachments = []; - public $flags = []; + public array $bodies = []; + + /** @var AttachmentCollection $attachments */ + public AttachmentCollection $attachments; + + /** @var FlagCollection $flags */ + public FlagCollection $flags; /** * A list of all available and supported flags * - * @var array $available_flags + * @var ?array $available_flags */ - private $available_flags = ['recent', 'flagged', 'answered', 'deleted', 'seen', 'draft']; + private ?array $available_flags = null; /** * Message constructor. + * @param integer $uid + * @param integer|null $msglist + * @param Client $client + * @param integer|null $fetch_options + * @param boolean $fetch_body + * @param boolean $fetch_flags + * @param integer|null $sequence * - * @param integer $uid - * @param integer|null $msglist - * @param Client $client - * @param integer|null $fetch_options - * @param boolean $fetch_body - * @param boolean $fetch_attachment - * @param boolean $fetch_flags - * - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException */ - public function __construct($uid, $msglist, Client $client, $fetch_options = null, $fetch_body = false, $fetch_attachment = false, $fetch_flags = false) { + public function __construct(int $uid, ?int $msglist, Client $client, ?int $fetch_options = null, bool $fetch_body = false, bool $fetch_flags = false, ?int $sequence = null) { + $this->boot($client->getConfig()); $default_mask = $client->getDefaultMessageMask(); - if($default_mask != null) { + if ($default_mask != null) { $this->mask = $default_mask; } + $this->events["message"] = $client->getDefaultEvents("message"); + $this->events["flag"] = $client->getDefaultEvents("flag"); $this->folder_path = $client->getFolderPath(); - $this->config = ClientManager::get('options'); - + $this->setSequence($sequence); $this->setFetchOption($fetch_options); $this->setFetchBodyOption($fetch_body); - $this->setFetchAttachmentOption($fetch_attachment); $this->setFetchFlagsOption($fetch_flags); - $this->attachments = AttachmentCollection::make([]); - $this->flags = FlagCollection::make([]); - - $this->msglist = $msglist; $this->client = $client; + $this->client->openFolder($this->folder_path); - $this->uid = ($this->fetch_options == IMAP::FT_UID) ? $uid : $uid; - $this->msgn = ($this->fetch_options == IMAP::FT_UID) ? \imap_msgno($this->client->getConnection(), $uid) : $uid; - - $this->parseHeader(); + $this->setSequenceId($uid, $msglist); - if ($this->getFetchFlagsOption() === true) { + if ($this->fetch_options == IMAP::FT_PEEK) { $this->parseFlags(); } + $this->parseHeader(); + if ($this->getFetchBodyOption() === true) { $this->parseBody(); } + + if ($this->getFetchFlagsOption() === true && $this->fetch_options !== IMAP::FT_PEEK) { + $this->parseFlags(); + } + } + + /** + * Create a new instance without fetching the message header and providing them raw instead + * @param int $uid + * @param int|null $msglist + * @param Client $client + * @param string $raw_header + * @param string $raw_body + * @param array $raw_flags + * @param null $fetch_options + * @param null $sequence + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + public static function make(int $uid, ?int $msglist, Client $client, string $raw_header, string $raw_body, array $raw_flags, $fetch_options = null, $sequence = null): Message { + $reflection = new ReflectionClass(self::class); + /** @var Message $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + $instance->boot($client->getConfig()); + + $default_mask = $client->getDefaultMessageMask(); + if ($default_mask != null) { + $instance->setMask($default_mask); + } + $instance->setEvents([ + "message" => $client->getDefaultEvents("message"), + "flag" => $client->getDefaultEvents("flag"), + ]); + $instance->setFolderPath($client->getFolderPath()); + $instance->setSequence($sequence); + $instance->setFetchOption($fetch_options); + + $instance->setClient($client); + $instance->setSequenceId($uid, $msglist); + + $instance->parseRawHeader($raw_header); + $instance->parseRawFlags($raw_flags); + $instance->parseRawBody($raw_body); + $instance->peek(); + + return $instance; + } + + /** + * Create a new message instance by reading and loading a file or remote location + * @param string $filename + * @param ?Config $config + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public static function fromFile(string $filename, ?Config $config = null): Message { + $blob = file_get_contents($filename); + if ($blob === false) { + throw new RuntimeException("Unable to read file"); + } + return self::fromString($blob, $config); + } + + /** + * Create a new message instance by reading and loading a string + * @param string $blob + * @param ?Config $config + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public static function fromString(string $blob, ?Config $config = null): Message { + $reflection = new ReflectionClass(self::class); + /** @var Message $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + $instance->boot($config); + + $default_mask = $instance->getConfig()->getMask("message"); + if($default_mask != ""){ + $instance->setMask($default_mask); + }else{ + throw new MaskNotFoundException("Unknown message mask provided"); + } + + if(!str_contains($blob, "\r\n")){ + $blob = str_replace("\n", "\r\n", $blob); + } + $raw_header = substr($blob, 0, strpos($blob, "\r\n\r\n")); + $raw_body = substr($blob, strlen($raw_header)+4); + + $instance->parseRawHeader($raw_header); + $instance->parseRawBody($raw_body); + + $instance->setUid(0); + + return $instance; + } + + /** + * Boot a new instance + * @param ?Config $config + * @throws DecoderNotFoundException + */ + public function boot(?Config $config = null): void { + $this->attributes = []; + $this->client = null; + $this->config = $config ?? Config::make(); + $this->decoder = $this->config->getDecoder("message"); + + $this->options = $this->config->get('options'); + $this->available_flags = $this->config->get('flags'); + + $this->attachments = AttachmentCollection::make(); + $this->flags = FlagCollection::make(); } /** @@ -236,31 +405,34 @@ public function __construct($uid, $msglist, Client $client, $fetch_options = nul * @param array $arguments * * @return mixed + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException * @throws MethodNotFoundException + * @throws MessageSizeFetchingException + * @throws RuntimeException + * @throws ResponseException */ - public function __call($method, $arguments) { - if(strtolower(substr($method, 0, 3)) === 'get') { + public function __call(string $method, array $arguments) { + if (strtolower(substr($method, 0, 3)) === 'get') { $name = Str::snake(substr($method, 3)); - - if(in_array($name, array_keys($this->attributes))) { - return $this->attributes[$name]; - } - - }elseif (strtolower(substr($method, 0, 3)) === 'set') { + return $this->get($name); + } elseif (strtolower(substr($method, 0, 3)) === 'set') { $name = Str::snake(substr($method, 3)); - if(in_array($name, array_keys($this->attributes))) { - $this->attributes[$name] = array_pop($arguments); - - return $this->attributes[$name]; + if (in_array($name, array_keys($this->attributes))) { + return $this->__set($name, array_pop($arguments)); } } - throw new MethodNotFoundException("Method ".self::class.'::'.$method.'() is not supported'); + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); } /** + * Magic setter * @param $name * @param $value * @@ -273,44 +445,57 @@ public function __set($name, $value) { } /** + * Magic getter * @param $name * - * @return mixed|null + * @return Attribute|mixed|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws MessageSizeFetchingException + * @throws RuntimeException + * @throws ResponseException */ public function __get($name) { - if(isset($this->attributes[$name])) { - return $this->attributes[$name]; - } - - return null; + return $this->get($name); } /** - * Copy the current Messages to a mailbox - * - * @param $mailbox - * @param int $options + * Get an available message or message header attribute + * @param $name * - * @return bool - * @throws Exceptions\ConnectionFailedException + * @return Attribute|mixed|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + * @throws MessageSizeFetchingException */ - public function copy($mailbox, $options = 0) { - $this->client->openFolder($this->folder_path); - return \imap_mail_copy($this->client->getConnection(), $this->uid, $mailbox, IMAP::CP_UID); - } + public function get($name): mixed { + if (isset($this->attributes[$name]) && $this->attributes[$name] !== null) { + return $this->attributes[$name]; + } - /** - * Move the current Messages to a mailbox - * - * @param $mailbox - * @param int $options - * - * @return bool - * @throws Exceptions\ConnectionFailedException - */ - public function move($mailbox, $options = 0) { - $this->client->openFolder($this->folder_path); - return \imap_mail_move($this->client->getConnection(), $this->uid, $mailbox, IMAP::CP_UID); + switch ($name){ + case "uid": + $this->attributes[$name] = $this->client->getConnection()->getUid($this->msgn)->validate()->integer(); + return $this->attributes[$name]; + case "msgn": + $this->attributes[$name] = $this->client->getConnection()->getMessageNumber($this->uid)->validate()->integer(); + return $this->attributes[$name]; + case "size": + if (!isset($this->attributes[$name])) { + $this->fetchSize(); + } + return $this->attributes[$name]; + } + + return $this->header->get($name); } /** @@ -318,21 +503,21 @@ public function move($mailbox, $options = 0) { * * @return bool */ - public function hasTextBody() { - return isset($this->bodies['text']); + public function hasTextBody(): bool { + return isset($this->bodies['text']) && $this->bodies['text'] !== ""; } /** * Get the Message text body * - * @return mixed + * @return string */ - public function getTextBody() { + public function getTextBody(): string { if (!isset($this->bodies['text'])) { - return false; + return ""; } - return $this->bodies['text']->content; + return $this->bodies['text']; } /** @@ -340,319 +525,193 @@ public function getTextBody() { * * @return bool */ - public function hasHTMLBody() { - return isset($this->bodies['html']); + public function hasHTMLBody(): bool { + return isset($this->bodies['html']) && $this->bodies['html'] !== ""; } /** * Get the Message html body - * If $replaceImages is callable it should expect string $body as first parameter, $oAttachment as second and return - * the resulting $body. - * - * @var bool|callable $replaceImages * - * @return string|null - * - * @deprecated 1.4.0:2.0.0 No longer needed. Use AttachmentMask::getImageSrc() instead + * @return string */ - public function getHTMLBody($replaceImages = false) { + public function getHTMLBody(): string { if (!isset($this->bodies['html'])) { - return null; + return ""; } - $body = $this->bodies['html']->content; - if ($replaceImages !== false) { - $this->attachments->each(function($oAttachment) use(&$body, $replaceImages) { - /** @var Attachment $oAttachment */ - if(is_callable($replaceImages)) { - $body = $replaceImages($body, $oAttachment); - }elseif(is_string($replaceImages)) { - call_user_func($replaceImages, [$body, $oAttachment]); - }else{ - if ($oAttachment->id && $oAttachment->getImgSrc() != null) { - $body = str_replace('cid:'.$oAttachment->id, $oAttachment->getImgSrc(), $body); - } - } - }); - } - - return $body; + return $this->bodies['html']; } /** * Parse all defined headers * - * @return void - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException * @throws InvalidMessageDateException + * @throws MessageHeaderFetchingException + * @throws ResponseException */ - private function parseHeader() { - $this->client->openFolder($this->folder_path); - $this->header = $header = \imap_fetchheader($this->client->getConnection(), $this->uid, IMAP::FT_UID); - - $this->priority = $this->extractPriority($this->header); - - if ($this->header) { - $header = \imap_rfc822_parse_headers($this->header); - } - - if (property_exists($header, 'subject')) { - if($this->config['decoder']['message']['subject'] === 'utf-8') { - $this->subject = \imap_utf8($header->subject); - }else{ - $this->subject = mb_decode_mimeheader($header->subject); - } - } - - foreach(['from', 'to', 'cc', 'bcc', 'reply_to', 'sender'] as $part){ - $this->extractHeaderAddressPart($header, $part); - } - - if (property_exists($header, 'references')) { - $this->references = $header->references; - } - if (property_exists($header, 'in_reply_to')) { - $this->in_reply_to = str_replace(['<', '>'], '', $header->in_reply_to); - } - if (property_exists($header, 'message_id')) { - $this->message_id = str_replace(['<', '>'], '', $header->message_id); - } - if (property_exists($header, 'Msgno')) { - $messageNo = (int) trim($header->Msgno); - $this->message_no = ($this->fetch_options == IMAP::FT_UID) ? $messageNo : \imap_msgno($this->client->getConnection(), $messageNo); - } else { - $this->message_no = \imap_msgno($this->client->getConnection(), $this->getUid()); - } - - $this->date = $this->parseDate($header); - } - - /** - * Try to extract the priority from a given raw header string - * @param string $header - * - * @return int|null - */ - private function extractPriority($header) { - if(preg_match('/x\-priority\:.*([0-9]{1,2})/i', $header, $priority)){ - $priority = isset($priority[1]) ? (int) $priority[1] : 0; - switch($priority){ - case IMAP::MESSAGE_PRIORITY_HIGHEST; - $priority = IMAP::MESSAGE_PRIORITY_HIGHEST; - break; - case IMAP::MESSAGE_PRIORITY_HIGH; - $priority = IMAP::MESSAGE_PRIORITY_HIGH; - break; - case IMAP::MESSAGE_PRIORITY_NORMAL; - $priority = IMAP::MESSAGE_PRIORITY_NORMAL; - break; - case IMAP::MESSAGE_PRIORITY_LOW; - $priority = IMAP::MESSAGE_PRIORITY_LOW; - break; - case IMAP::MESSAGE_PRIORITY_LOWEST; - $priority = IMAP::MESSAGE_PRIORITY_LOWEST; - break; - default: - $priority = IMAP::MESSAGE_PRIORITY_UNKNOWN; - break; - } + private function parseHeader(): void { + $sequence_id = $this->getSequenceId(); + $headers = $this->client->getConnection()->headers([$sequence_id], "RFC822", $this->sequence)->setCanBeEmpty(true)->validatedData(); + if (!isset($headers[$sequence_id])) { + throw new MessageHeaderFetchingException("no headers found", 0); } - return $priority; + $this->parseRawHeader($headers[$sequence_id]); } /** - * Exception handling for invalid dates + * @param string $raw_header * - * Currently known invalid formats: - * ^ Datetime ^ Problem ^ Cause - * | Mon, 20 Nov 2017 20:31:31 +0800 (GMT+8:00) | Double timezone specification | A Windows feature - * | Thu, 8 Nov 2018 08:54:58 -0200 (-02) | - * | | and invalid timezone (max 6 char) | - * | 04 Jan 2018 10:12:47 UT | Missing letter "C" | Unknown - * | Thu, 31 May 2018 18:15:00 +0800 (added by) | Non-standard details added by the | Unknown - * | | mail server | - * | Sat, 31 Aug 2013 20:08:23 +0580 | Invalid timezone | PHPMailer bug https://sourceforge.net/p/phpmailer/mailman/message/6132703/ - * - * Please report any new invalid timestamps to [#45](https://github.com/Webklex/laravel-imap/issues/45) - * - * @param object $header - * - * @return Carbon|null * @throws InvalidMessageDateException */ - private function parseDate($header) { - $parsed_date = null; + public function parseRawHeader(string $raw_header): void { + $this->header = new Header($raw_header, $this->getConfig()); + } - if (property_exists($header, 'date')) { - $date = $header->date; + /** + * Parse additional raw flags + * @param array $raw_flags + */ + public function parseRawFlags(array $raw_flags): void { + $this->flags = FlagCollection::make(); - if(preg_match('/\+0580/', $date)) { - $date = str_replace('+0580', '+0530', $date); + foreach ($raw_flags as $flag) { + if (str_starts_with($flag, "\\")) { + $flag = substr($flag, 1); } - - $date = trim(rtrim($date)); - try { - $parsed_date = Carbon::parse($date); - } catch (\Exception $e) { - switch (true) { - case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: - case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ UT)+$/i', $date) > 0: - $date .= 'C'; - break; - case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ \+[0-9]{2,4}\ \(\+[0-9]{1,2}\))+$/i', $date) > 0: - case preg_match('/([A-Z]{2,3}[\,|\ \,]\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}.*)+$/i', $date) > 0: - case preg_match('/([A-Z]{2,3}\,\ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: - case preg_match('/([A-Z]{2,3}\, \ [0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{4}\ [0-9]{1,2}\:[0-9]{1,2}\:[0-9]{1,2}\ [\-|\+][0-9]{4}\ \(.*)\)+$/i', $date) > 0: - case preg_match('/([0-9]{1,2}\ [A-Z]{2,3}\ [0-9]{2,4}\ [0-9]{2}\:[0-9]{2}\:[0-9]{2}\ [A-Z]{2}\ \-[0-9]{2}\:[0-9]{2}\ \([A-Z]{2,3}\ \-[0-9]{2}:[0-9]{2}\))+$/i', $date) > 0: - $array = explode('(', $date); - $array = array_reverse($array); - $date = trim(array_pop($array)); - break; - } - try{ - $parsed_date = Carbon::parse($date); - } catch (\Exception $_e) { - throw new InvalidMessageDateException("Invalid message date. ID:".$this->getMessageId(), 1000, $e); - } + $flag_key = strtolower($flag); + if ($this->available_flags === null || in_array($flag_key, $this->available_flags)) { + $this->flags->put($flag_key, $flag); } } - - return $parsed_date; } /** * Parse additional flags * * @return void - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - private function parseFlags() { - $this->flags = FlagCollection::make([]); - + private function parseFlags(): void { $this->client->openFolder($this->folder_path); - $flags = \imap_fetch_overview($this->client->getConnection(), $this->uid, IMAP::FT_UID); - if (is_array($flags) && isset($flags[0])) { - foreach($this->available_flags as $flag) { - $this->parseFlag($flags, $flag); - } - } - } + $this->flags = FlagCollection::make(); - /** - * Extract a possible flag information from a given array - * @param array $flags - * @param string $flag - */ - private function parseFlag($flags, $flag) { - $flag = strtolower($flag); + $sequence_id = $this->getSequenceId(); + try { + $flags = $this->client->getConnection()->flags([$sequence_id], $this->sequence)->setCanBeEmpty(true)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be fetched", 0, $e); + } - if (property_exists($flags[0], strtoupper($flag))) { - $this->flags->put($flag, $flags[0]->{strtoupper($flag)}); - } elseif (property_exists($flags[0], ucfirst($flag))) { - $this->flags->put($flag, $flags[0]->{ucfirst($flag)}); - } elseif (property_exists($flags[0], $flag)) { - $this->flags->put($flag, $flags[0]->$flag); + if (isset($flags[$sequence_id])) { + $this->parseRawFlags($flags[$sequence_id]); } } /** - * Get the current Message header info + * Parse the Message body * - * @return object - * @throws Exceptions\ConnectionFailedException + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function getHeaderInfo() { - if ($this->header_info == null) { - $this->client->openFolder($this->folder_path); - $this->header_info = \imap_headerinfo($this->client->getConnection(), $this->getMessageNo()); + public function parseBody(): Message { + $this->client->openFolder($this->folder_path); + + $sequence_id = $this->getSequenceId(); + try { + $contents = $this->client->getConnection()->content([$sequence_id], $this->client->rfc, $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageContentFetchingException("failed to fetch content", 0, $e); } + if (!isset($contents[$sequence_id])) { + throw new MessageContentFetchingException("no content found", 0); + } + $content = $contents[$sequence_id]; + + $body = $this->parseRawBody($content); + $this->peek(); - return $this->header_info; + return $body; } /** - * Extract a given part as address array from a given header - * @param object $header - * @param string $part + * Fetches the size for this message. + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageSizeFetchingException + * @throws ResponseException + * @throws RuntimeException */ - private function extractHeaderAddressPart($header, $part) { - if (property_exists($header, $part)) { - $this->$part = $this->parseAddresses($header->$part); + private function fetchSize(): void { + $sequence_id = $this->getSequenceId(); + $sizes = $this->client->getConnection()->sizes([$sequence_id], $this->sequence)->validatedData(); + if (!isset($sizes[$sequence_id])) { + throw new MessageSizeFetchingException("sizes did not set an array entry for the supplied sequence_id", 0); } + $this->attributes["size"] = $sizes[$sequence_id]; } /** - * Parse Addresses - * @param $list + * Handle auto "Seen" flag handling * - * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - private function parseAddresses($list) { - $addresses = []; - - foreach ($list as $item) { - $address = (object) $item; - - if (!property_exists($address, 'mailbox')) { - $address->mailbox = false; - } - if (!property_exists($address, 'host')) { - $address->host = false; + public function peek(): void { + if ($this->fetch_options == IMAP::FT_PEEK) { + if ($this->getFlags()->get("seen") == null) { + $this->unsetFlag("Seen"); } - if (!property_exists($address, 'personal')) { - $address->personal = false; - } - if (!property_exists($address, 'personal')) { - $address->personal = false; - } else { - $personalParts = \imap_mime_header_decode($address->personal); - - if(is_array($personalParts)) { - $address->personal = ''; - foreach ($personalParts as $p) { - $encoding = $this->getEncoding($p->text); - $address->personal .= $this->convertEncoding($p->text, $encoding); - } - } - } - - $address->mail = ($address->mailbox && $address->host) ? $address->mailbox.'@'.$address->host : false; - $address->full = ($address->personal) ? $address->personal.' <'.$address->mail.'>' : $address->mail; - - $addresses[] = $address; + } elseif ($this->getFlags()->get("seen") == null) { + $this->setFlag("Seen"); } - - return $addresses; } /** - * Parse the Message body + * Parse a given message body + * @param string $raw_body * - * @return $this - * @throws Exceptions\ConnectionFailedException + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws RuntimeException + * @throws ResponseException */ - public function parseBody() { - $this->client->openFolder($this->folder_path); - $this->structure = \imap_fetchstructure($this->client->getConnection(), $this->uid, IMAP::FT_UID); - - if(property_exists($this->structure, 'parts')){ - $parts = $this->structure->parts; - - foreach ($parts as $part) { - foreach ($part->parameters as $parameter) { - if($parameter->attribute == "charset") { - $encoding = $parameter->value; - - $encoding = preg_replace('/Content-Transfer-Encoding/', '', $encoding); - $encoding = preg_replace('/iso-8859-8-i/', 'iso-8859-8', $encoding); - - $parameter->value = $encoding; - } - } - } - } - + public function parseRawBody(string $raw_body): Message { + $this->structure = new Structure($raw_body, $this->header); $this->fetchStructure($this->structure); return $this; @@ -660,105 +719,83 @@ public function parseBody() { /** * Fetch the Message structure + * @param Structure $structure * - * @param $structure - * @param mixed $partNumber - * - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - private function fetchStructure($structure, $partNumber = null) { - $this->client->openFolder($this->folder_path); - - if ($structure->type == IMAP::MESSAGE_TYPE_TEXT && - ($structure->ifdisposition == 0 || - (empty($structure->disposition) || strtolower($structure->disposition) != 'attachment') - ) - ) { - if (strtolower($structure->subtype) == "plain" || strtolower($structure->subtype) == "csv") { - if (!$partNumber) { - $partNumber = 1; - } + private function fetchStructure(Structure $structure): void { + $this->client?->openFolder($this->folder_path); - $encoding = $this->getEncoding($structure); - - $content = \imap_fetchbody($this->client->getConnection(), $this->uid, $partNumber, $this->fetch_options | IMAP::FT_UID); - $content = $this->decodeString($content, $structure->encoding); - - // We don't need to do convertEncoding() if charset is ASCII (us-ascii): - // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded - // https://stackoverflow.com/a/11303410 - // - // us-ascii is the same as ASCII: - // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) - // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and - // based on the typographical symbols predominantly in use there. - // https://en.wikipedia.org/wiki/ASCII - // - // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. - if ($encoding != 'us-ascii') { - $content = $this->convertEncoding($content, $encoding); - } - - $body = new \stdClass; - $body->type = "text"; - $body->content = $content; - - $this->bodies['text'] = $body; - - $this->fetchAttachment($structure, $partNumber); - - } elseif (strtolower($structure->subtype) == "html") { - if (!$partNumber) { - $partNumber = 1; - } + foreach ($structure->parts as $part) { + $this->fetchPart($part); + } + } - $encoding = $this->getEncoding($structure); + /** + * Fetch a given part + * @param Part $part + */ + private function fetchPart(Part $part): void { + if ($part->isAttachment()) { + $this->fetchAttachment($part); + } else { + $encoding = $this->decoder->getEncoding($part); + + $content = $this->decoder->decode($part->content, $part->encoding); + + // We don't need to do convertEncoding() if charset is ASCII (us-ascii): + // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded + // https://stackoverflow.com/a/11303410 + // + // us-ascii is the same as ASCII: + // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) + // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and + // based on the typographical symbols predominantly in use there. + // https://en.wikipedia.org/wiki/ASCII + // + // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. + if ($encoding != 'us-ascii') { + $content = $this->decoder->convertEncoding($content, $encoding); + } - $content = \imap_fetchbody($this->client->getConnection(), $this->uid, $partNumber, $this->fetch_options | IMAP::FT_UID); - $content = $this->decodeString($content, $structure->encoding); - if ($encoding != 'us-ascii') { - $content = $this->convertEncoding($content, $encoding); - } + $this->addBody($part->subtype ?? '', $content); + } + } - $body = new \stdClass; - $body->type = "html"; - $body->content = $content; + /** + * Add a body to the message + * @param string $subtype + * @param string $content + * + * @return void + */ + protected function addBody(string $subtype, string $content): void { + $subtype = strtolower($subtype); + $subtype = $subtype == "plain" || $subtype == "" ? "text" : $subtype; - $this->bodies['html'] = $body; - } elseif ($structure->ifdisposition == 1 && strtolower($structure->disposition) == 'attachment') { - if ($this->getFetchAttachmentOption() === true) { - $this->fetchAttachment($structure, $partNumber); - } - } - } elseif ($structure->type == IMAP::MESSAGE_TYPE_MULTIPART) { - foreach ($structure->parts as $index => $subStruct) { - $prefix = ""; - if ($partNumber) { - $prefix = $partNumber."."; - } - $this->fetchStructure($subStruct, $prefix.($index + 1)); + if (isset($this->bodies[$subtype]) && $this->bodies[$subtype] !== null && $this->bodies[$subtype] !== "") { + if ($content !== "") { + $this->bodies[$subtype] .= "\n".$content; } } else { - if ($this->getFetchAttachmentOption() === true) { - $this->fetchAttachment($structure, $partNumber); - } + $this->bodies[$subtype] = $content; } } /** * Fetch the Message attachment - * - * @param object $structure - * @param mixed $partNumber - * - * @throws Exceptions\ConnectionFailedException + * @param Part $part */ - protected function fetchAttachment($structure, $partNumber) { + protected function fetchAttachment(Part $part): void { + $oAttachment = new Attachment($this, $part); - $oAttachment = new Attachment($this, $structure, $partNumber); - - if ($oAttachment->getName() !== null) { - if ($oAttachment->getId() !== null) { + if ($oAttachment->getSize() > 0) { + if ($oAttachment->getId() !== null && $this->attachments->offsetExists($oAttachment->getId())) { $this->attachments->put($oAttachment->getId(), $oAttachment); } else { $this->attachments->push($oAttachment); @@ -768,16 +805,15 @@ protected function fetchAttachment($structure, $partNumber) { /** * Fail proof setter for $fetch_option - * * @param $option * - * @return $this + * @return Message */ - public function setFetchOption($option) { + public function setFetchOption($option): Message { if (is_long($option) === true) { $this->fetch_options = $option; } elseif (is_null($option) === true) { - $config = ClientManager::get('options.fetch', IMAP::FT_UID); + $config = $this->config->get('options.fetch', IMAP::FT_UID); $this->fetch_options = is_long($config) ? $config : 1; } @@ -785,36 +821,34 @@ public function setFetchOption($option) { } /** - * Fail proof setter for $fetch_body - * - * @param $option + * Set the sequence type + * @param int|null $sequence * - * @return $this + * @return Message */ - public function setFetchBodyOption($option) { - if (is_bool($option)) { - $this->fetch_body = $option; - } elseif (is_null($option)) { - $config = ClientManager::get('options.fetch_body', true); - $this->fetch_body = is_bool($config) ? $config : true; + public function setSequence(?int $sequence): Message { + if (is_long($sequence)) { + $this->sequence = $sequence; + } elseif (is_null($sequence)) { + $config = $this->config->get('options.sequence', IMAP::ST_MSGN); + $this->sequence = is_long($config) ? $config : IMAP::ST_MSGN; } return $this; } /** - * Fail proof setter for $fetch_attachment - * + * Fail proof setter for $fetch_body * @param $option * - * @return $this + * @return Message */ - public function setFetchAttachmentOption($option) { + public function setFetchBodyOption($option): Message { if (is_bool($option)) { - $this->fetch_attachment = $option; + $this->fetch_body = $option; } elseif (is_null($option)) { - $config = ClientManager::get('options.fetch_attachment', true); - $this->fetch_attachment = is_bool($config) ? $config : true; + $config = $this->config->get('options.fetch_body', true); + $this->fetch_body = is_bool($config) ? $config : true; } return $this; @@ -822,16 +856,15 @@ public function setFetchAttachmentOption($option) { /** * Fail proof setter for $fetch_flags - * * @param $option * - * @return $this + * @return Message */ - public function setFetchFlagsOption($option) { + public function setFetchFlagsOption($option): Message { if (is_bool($option)) { $this->fetch_flags = $option; } elseif (is_null($option)) { - $config = ClientManager::get('options.fetch_flags', true); + $config = $this->config->get('options.fetch_flags', true); $this->fetch_flags = is_bool($config) ? $config : true; } @@ -839,185 +872,267 @@ public function setFetchFlagsOption($option) { } /** - * Decode a given string + * Get the messages folder * - * @param $string - * @param $encoding - * - * @return string + * @return ?Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function decodeString($string, $encoding) { - switch ($encoding) { - case IMAP::MESSAGE_ENC_7BIT: - return $string; - case IMAP::MESSAGE_ENC_8BIT: - return quoted_printable_decode(\imap_8bit($string)); - case IMAP::MESSAGE_ENC_BINARY: - return \imap_binary($string); - case IMAP::MESSAGE_ENC_BASE64: - return \imap_base64($string); - case IMAP::MESSAGE_ENC_QUOTED_PRINTABLE: - return quoted_printable_decode($string); - case IMAP::MESSAGE_ENC_OTHER: - return $string; - default: - return $string; - } + public function getFolder(): ?Folder { + return $this->client->getFolderByPath($this->folder_path); } /** - * Convert the encoding - * - * @param $str - * @param string $from - * @param string $to + * Create a message thread based on the current message + * @param Folder|null $sent_folder + * @param MessageCollection|null $thread + * @param Folder|null $folder * - * @return mixed|string + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function convertEncoding($str, $from = "ISO-8859-2", $to = "UTF-8") { + public function thread(?Folder $sent_folder = null, ?MessageCollection &$thread = null, ?Folder $folder = null): MessageCollection { + $thread = $thread ?: MessageCollection::make(); + $folder = $folder ?: $this->getFolder(); + $sent_folder = $sent_folder ?: $this->client->getFolderByPath($this->config->get("options.common_folders.sent", "INBOX/Sent")); + + /** @var Message $message */ + foreach ($thread as $message) { + if ($message->message_id->first() == $this->message_id->first()) { + return $thread; + } + } + $thread->push($this); - $from = EncodingAliases::get($from); - $to = EncodingAliases::get($to); + $this->fetchThreadByInReplyTo($thread, $this->message_id, $folder, $folder, $sent_folder); + $this->fetchThreadByInReplyTo($thread, $this->message_id, $sent_folder, $folder, $sent_folder); - if ($from === $to) { - return $str; + foreach ($this->in_reply_to->all() as $in_reply_to) { + $this->fetchThreadByMessageId($thread, $in_reply_to, $folder, $folder, $sent_folder); + $this->fetchThreadByMessageId($thread, $in_reply_to, $sent_folder, $folder, $sent_folder); } - // We don't need to do convertEncoding() if charset is ASCII (us-ascii): - // ASCII is a subset of UTF-8, so all ASCII files are already UTF-8 encoded - // https://stackoverflow.com/a/11303410 - // - // us-ascii is the same as ASCII: - // ASCII is the traditional name for the encoding system; the Internet Assigned Numbers Authority (IANA) - // prefers the updated name US-ASCII, which clarifies that this system was developed in the US and - // based on the typographical symbols predominantly in use there. - // https://en.wikipedia.org/wiki/ASCII - // - // convertEncoding() function basically means convertToUtf8(), so when we convert ASCII string into UTF-8 it gets broken. - if (strtolower($from) == 'us-ascii' && $to == 'UTF-8') { - return $str; - } + return $thread; + } - if (function_exists('iconv') && $from != 'UTF-7' && $to != 'UTF-7') { - return @iconv($from, $to.'//IGNORE', $str); - } else { - if (!$from) { - return mb_convert_encoding($str, $to); - } - return mb_convert_encoding($str, $to, $from); - } + /** + * Fetch a partial thread by message id + * @param MessageCollection $thread + * @param string $in_reply_to + * @param Folder $primary_folder + * @param Folder $secondary_folder + * @param Folder $sent_folder + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetchThreadByInReplyTo(MessageCollection &$thread, string $in_reply_to, Folder $primary_folder, Folder $secondary_folder, Folder $sent_folder): void { + $primary_folder->query()->inReplyTo($in_reply_to) + ->setFetchBody($this->getFetchBodyOption()) + ->leaveUnread()->get()->each(function($message) use (&$thread, $secondary_folder, $sent_folder) { + /** @var Message $message */ + $message->thread($sent_folder, $thread, $secondary_folder); + }); } /** - * Get the encoding of a given abject + * Fetch a partial thread by message id + * @param MessageCollection $thread + * @param string $message_id + * @param Folder $primary_folder + * @param Folder $secondary_folder + * @param Folder $sent_folder * - * @param object|string $structure + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetchThreadByMessageId(MessageCollection &$thread, string $message_id, Folder $primary_folder, Folder $secondary_folder, Folder $sent_folder): void { + $primary_folder->query()->messageId($message_id) + ->setFetchBody($this->getFetchBodyOption()) + ->leaveUnread()->get()->each(function($message) use (&$thread, $secondary_folder, $sent_folder) { + /** @var Message $message */ + $message->thread($sent_folder, $thread, $secondary_folder); + }); + } + + /** + * Copy the current Messages to a mailbox + * @param string $folder_path + * @param boolean $expunge + * @param bool $utf7 * - * @return string + * @return null|Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException */ - public function getEncoding($structure) { - if (property_exists($structure, 'parameters')) { - foreach ($structure->parameters as $parameter) { - if (strtolower($parameter->attribute) == "charset") { - return EncodingAliases::get($parameter->value); - } + public function copy(string $folder_path, bool $expunge = false, bool $utf7 = false): ?Message { + $this->client->openFolder($folder_path); + $status = $this->client->getConnection()->examineFolder($folder_path)->validatedData(); + + if (isset($status["uidnext"])) { + $next_uid = $status["uidnext"]; + if ((int)$next_uid <= 0) { + return null; + } + + /** @var Folder $folder */ + $folder = $this->client->getFolderByPath($folder_path, $utf7); + + $this->client->openFolder($this->folder_path); + if ($this->client->getConnection()->copyMessage($folder->path, $this->getSequenceId(), null, $this->sequence)->validatedData()) { + return $this->fetchNewMail($folder, $next_uid, "copied", $expunge); } - }elseif (is_string($structure) === true){ - return mb_detect_encoding($structure); } - return 'UTF-8'; + return null; } /** - * Find the folder containing this message. - * @param null|Folder $folder where to start searching from (top-level inbox by default) + * Move the current Messages to a mailbox + * @param string $folder_path + * @param boolean $expunge + * @param bool $utf7 * - * @return mixed|null|Folder - * @throws Exceptions\ConnectionFailedException - * @throws Exceptions\MailboxFetchingException + * @return Message|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException * @throws InvalidMessageDateException - * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException */ - public function getContainingFolder(Folder $folder = null) { - $folder = $folder ?: $this->client->getFolders()->first(); - $this->client->checkConnection(); - - // Try finding the message by uid in the current folder - $client = new Client; - $client->openFolder($folder->path); - $uidMatches = \imap_fetch_overview($client->getConnection(), $this->uid, IMAP::FT_UID); - $uidMatch = count($uidMatches) - ? new Message($uidMatches[0]->uid, $uidMatches[0]->msgno, $client) - : null; - $client->disconnect(); - - // \imap_fetch_overview() on a parent folder will return the matching message - // even when the message is in a child folder so we need to recursively - // search the children - foreach ($folder->children as $child) { - $childFolder = $this->getContainingFolder($child); - - if ($childFolder) { - return $childFolder; + public function move(string $folder_path, bool $expunge = false, bool $utf7 = false): ?Message { + $this->client->openFolder($folder_path); + $status = $this->client->getConnection()->examineFolder($folder_path)->validatedData(); + + if (isset($status["uidnext"])) { + $next_uid = $status["uidnext"]; + if ((int)$next_uid <= 0) { + return null; } - } - // before returning the parent - if ($this->is($uidMatch)) { - return $folder; + /** @var Folder $folder */ + $folder = $this->client->getFolderByPath($folder_path, $utf7); + + $this->client->openFolder($this->folder_path); + if ($this->client->getConnection()->moveMessage($folder->path, $this->getSequenceId(), null, $this->sequence)->validatedData()) { + return $this->fetchNewMail($folder, $next_uid, "moved", $expunge); + } } - // or signalling that the message was not found in any folder return null; } - public function getFolder(){ - return $this->client->getFolder($this->folder_path); - } - /** - * Move the Message into an other Folder - * @param string $mailbox - * @param bool $expunge - * @param bool $create_folder + * Fetch a new message and fire a given event + * @param Folder $folder + * @param int $next_uid + * @param string $event + * @param boolean $expunge * - * @return null|Message - * @throws Exceptions\ConnectionFailedException + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException */ - public function moveToFolder($mailbox = 'INBOX', $expunge = false, $create_folder = true) { - - if($create_folder) $this->client->createFolder($mailbox, true); - - $target_folder = $this->client->getFolder($mailbox); - $target_status = $target_folder->getStatus(IMAP::SA_ALL); - - $this->client->openFolder($this->folder_path); - $status = \imap_mail_move($this->client->getConnection(), $this->uid, $mailbox, IMAP::CP_UID); + protected function fetchNewMail(Folder $folder, int $next_uid, string $event, bool $expunge): Message { + if ($expunge) $this->client->expunge(); - if($status === true){ - if($expunge) $this->client->expunge(); - $this->client->openFolder($target_folder->path); + $this->client->openFolder($folder->path); - return $target_folder->getMessage($target_status->uidnext, null, $this->fetch_options, $this->fetch_body, $this->fetch_attachment, $this->fetch_flags); + if ($this->sequence === IMAP::ST_UID) { + $sequence_id = $next_uid; + } else { + $sequence_id = $this->client->getConnection()->getMessageNumber($next_uid)->validatedData(); } - return null; + $message = $folder->query()->getMessage($sequence_id, null, $this->sequence); + $this->dispatch("message", $event, $this, $message); + + return $message; } /** * Delete the current Message * @param bool $expunge + * @param string|null $trash_path + * @param boolean $force_move * * @return bool - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException */ - public function delete($expunge = true) { - $this->client->openFolder($this->folder_path); + public function delete(bool $expunge = true, ?string $trash_path = null, bool $force_move = false): bool { + $status = $this->setFlag("Deleted"); + if ($force_move) { + $trash_path = $trash_path === null ? $this->config["common_folders"]["trash"] : $trash_path; + $this->move($trash_path); + } + if ($expunge) $this->client->expunge(); - $status = \imap_delete($this->client->getConnection(), $this->uid, IMAP::FT_UID); - if($expunge) $this->client->expunge(); + $this->dispatch("message", "deleted", $this); return $status; } @@ -1027,178 +1142,287 @@ public function delete($expunge = true) { * @param boolean $expunge * * @return bool - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function restore($expunge = true) { - $this->client->openFolder($this->folder_path); + public function restore(bool $expunge = true): bool { + $status = $this->unsetFlag("Deleted"); + if ($expunge) $this->client->expunge(); - $status = \imap_undelete($this->client->getConnection(), $this->uid, IMAP::FT_UID); - if($expunge) $this->client->expunge(); + $this->dispatch("message", "restored", $this); return $status; } /** - * Get all message attachments. + * Set a given flag + * @param array|string $flag * - * @return AttachmentCollection + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function getAttachments() { - return $this->attachments; + public function setFlag(array|string $flag): bool { + $this->client->openFolder($this->folder_path); + $flag = "\\" . trim(is_array($flag) ? implode(" \\", $flag) : $flag); + $sequence_id = $this->getSequenceId(); + try { + $status = $this->client->getConnection()->store([$flag], $sequence_id, $sequence_id, "+", true, $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be set", 0, $e); + } + $this->parseFlags(); + + $this->dispatch("flag", "new", $this, $flag); + + return (bool)$status; } /** - * Checks if there are any attachments present + * Unset a given flag + * @param array|string $flag * - * @return boolean + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function hasAttachments() { - return $this->attachments->isEmpty() === false; + public function unsetFlag(array|string $flag): bool { + $this->client->openFolder($this->folder_path); + + $flag = "\\" . trim(is_array($flag) ? implode(" \\", $flag) : $flag); + $sequence_id = $this->getSequenceId(); + try { + $status = $this->client->getConnection()->store([$flag], $sequence_id, $sequence_id, "-", true, $this->sequence)->validatedData(); + } catch (Exceptions\RuntimeException $e) { + throw new MessageFlagException("flag could not be removed", 0, $e); + } + $this->parseFlags(); + + $this->dispatch("flag", "deleted", $this, $flag); + + return (bool)$status; } /** * Set a given flag - * @param string|array $flag + * @param array|string $flag * * @return bool - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function setFlag($flag) { - $this->client->openFolder($this->folder_path); - - $flag = "\\".trim(is_array($flag) ? implode(" \\", $flag) : $flag); - $status = \imap_setflag_full($this->client->getConnection(), $this->getUid(), $flag, SE_UID); - $this->parseFlags(); - - return $status; + public function addFlag(array|string $flag): bool { + return $this->setFlag($flag); } /** * Unset a given flag - * @param string|array $flag + * @param array|string $flag * * @return bool - * @throws Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageFlagException + * @throws RuntimeException + * @throws ResponseException */ - public function unsetFlag($flag) { - $this->client->openFolder($this->folder_path); + public function removeFlag(array|string $flag): bool { + return $this->unsetFlag($flag); + } - $flag = "\\".trim(is_array($flag) ? implode(" \\", $flag) : $flag); - $status = \imap_clearflag_full($this->client->getConnection(), $this->getUid(), $flag, SE_UID); - $this->parseFlags(); + /** + * Get all message attachments. + * + * @return AttachmentCollection + */ + public function getAttachments(): AttachmentCollection { + return $this->attachments; + } - return $status; + /** + * Get all message attachments. + * + * @return AttachmentCollection + */ + public function attachments(): AttachmentCollection { + return $this->getAttachments(); } /** - * @return null|object|string - * @throws Exceptions\ConnectionFailedException + * Checks if there are any attachments present + * + * @return boolean */ - public function getRawBody() { - if ($this->raw_body === null) { - $this->client->openFolder($this->folder_path); + public function hasAttachments(): bool { + return $this->attachments->isEmpty() === false; + } - $this->raw_body = \imap_fetchbody($this->client->getConnection(), $this->getUid(), '', $this->fetch_options | IMAP::FT_UID); + /** + * Get the raw body + * + * @return string + */ + public function getRawBody(): string { + if ($this->raw_body === "") { + $this->raw_body = $this->structure->raw; } return $this->raw_body; } /** - * @return string + * Get the message header + * + * @return ?Header */ - public function getHeader() { + public function getHeader(): ?Header { return $this->header; } /** - * @return Client + * Get the current client + * + * @return ?Client */ - public function getClient() { + public function getClient(): ?Client { return $this->client; } /** - * @return integer + * Get the used fetch option + * + * @return ?integer */ - public function getFetchOptions() { + public function getFetchOptions(): ?int { return $this->fetch_options; } /** + * Get the used fetch body option + * * @return boolean */ - public function getFetchBodyOption() { + public function getFetchBodyOption(): bool { return $this->fetch_body; } /** + * Get the used fetch flags option + * * @return boolean */ - public function getFetchAttachmentOption() { - return $this->fetch_attachment; + public function getFetchFlagsOption(): bool { + return $this->fetch_flags; } /** - * @return boolean + * Get all available bodies + * + * @return array */ - public function getFetchFlagsOption() { - return $this->fetch_flags; + public function getBodies(): array { + return $this->bodies; } /** - * @return mixed + * Get all set flags + * + * @return FlagCollection */ - public function getBodies() { - return $this->bodies; + public function getFlags(): FlagCollection { + return $this->flags; } /** + * Get all set flags + * * @return FlagCollection */ - public function getFlags() { - return $this->flags; + public function flags(): FlagCollection { + return $this->getFlags(); } /** - * @return object|null + * Check if a flag is set + * + * @param string $flag + * @return boolean */ - public function getStructure(){ - return $this->structure; + public function hasFlag(string $flag): bool { + $flag = str_replace("\\", "", strtolower($flag)); + return $this->getFlags()->has($flag); } /** - * Does this message match another one? + * Get the fetched structure * - * A match means same uid, message id, subject and date/time. + * @return Structure|null + */ + public function getStructure(): ?Structure { + return $this->structure; + } + + /** + * Check if a message matches another by comparing basic attributes * - * @param null|Message $message + * @param null|Message $message * @return boolean */ - public function is(Message $message = null) { + public function is(?Message $message = null): bool { if (is_null($message)) { return false; } return $this->uid == $message->uid - && $this->message_id == $message->message_id - && $this->subject == $message->subject - && $this->date->eq($message->date); + && $this->message_id->first() == $message->message_id->first() + && $this->subject->first() == $message->subject->first() + && $this->date->toDate()->eq($message->date->toDate()); } /** + * Get all message attributes + * * @return array */ - public function getAttributes(){ - return $this->attributes; + public function getAttributes(): array { + return array_merge($this->attributes, $this->header->getAttributes()); } /** + * Set the message mask * @param $mask - * @return $this + * + * @return Message */ - public function setMask($mask){ - if(class_exists($mask)){ + public function setMask($mask): Message { + if (class_exists($mask)) { $this->mask = $mask; } @@ -1206,25 +1430,245 @@ public function setMask($mask){ } /** + * Get the used message mask + * * @return string */ - public function getMask(){ + public function getMask(): string { return $this->mask; } /** * Get a masked instance by providing a mask name - * @param string|null $mask + * @param mixed|null $mask * * @return mixed * @throws MaskNotFoundException */ - public function mask($mask = null){ + public function mask(mixed $mask = null): mixed { $mask = $mask !== null ? $mask : $this->mask; - if(class_exists($mask)){ + if (class_exists($mask)) { return new $mask($this); } - throw new MaskNotFoundException("Unknown mask provided: ".$mask); + throw new MaskNotFoundException("Unknown mask provided: " . $mask); + } + + /** + * Get the message path aka folder path + * + * @return string + */ + public function getFolderPath(): string { + return $this->folder_path; + } + + /** + * Set the message path aka folder path + * @param $folder_path + * + * @return Message + */ + public function setFolderPath($folder_path): Message { + $this->folder_path = $folder_path; + + return $this; + } + + /** + * Set the config + * @param Config $config + * + * @return Message + */ + public function setConfig(Config $config): Message { + $this->config = $config; + + return $this; + } + + /** + * Get the config + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } + + /** + * Set the options + * @param array $options + * + * @return Message + */ + public function setOptions(array $options): Message { + $this->options = $options; + + return $this; + } + + /** + * Get the options + * + * @return array + */ + public function getOptions(): array { + return $this->options; + } + + /** + * Set the available flags + * @param $available_flags + * + * @return Message + */ + public function setAvailableFlags($available_flags): Message { + $this->available_flags = $available_flags; + + return $this; + } + + /** + * Get the available flags + * + * @return array + */ + public function getAvailableFlags(): array { + return $this->available_flags; + } + + /** + * Set the attachment collection + * @param $attachments + * + * @return Message + */ + public function setAttachments($attachments): Message { + $this->attachments = $attachments; + + return $this; + } + + /** + * Set the flag collection + * @param $flags + * + * @return Message + */ + public function setFlags($flags): Message { + $this->flags = $flags; + + return $this; + } + + /** + * Set the client + * @param $client + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + public function setClient($client): Message { + $this->client = $client; + $this->client?->openFolder($this->folder_path); + + return $this; + } + + /** + * Set the message number + * @param int $uid + * + * @return Message + */ + public function setUid(int $uid): Message { + $this->uid = $uid; + $this->msgn = null; + $this->msglist = null; + + return $this; + } + + /** + * Set the message number + * @param int $msgn + * @param int|null $msglist + * + * @return Message + */ + public function setMsgn(int $msgn, ?int $msglist = null): Message { + $this->msgn = $msgn; + $this->msglist = $msglist; + $this->uid = null; + + return $this; + } + + /** + * Get the current sequence type + * + * @return int + */ + public function getSequence(): int { + return $this->sequence; + } + + /** + * Get the current sequence id (either a UID or a message number!) + * + * @return int + */ + public function getSequenceId(): int { + return $this->sequence === IMAP::ST_UID ? $this->uid : $this->msgn; + } + + /** + * Set the sequence id + * @param $uid + * @param int|null $msglist + */ + public function setSequenceId($uid, ?int $msglist = null): void { + if ($this->getSequence() === IMAP::ST_UID) { + $this->setUid($uid); + $this->setMsglist($msglist); + } else { + $this->setMsgn($uid, $msglist); + } + } + + /** + * Get the decoder instance + * + * @return DecoderInterface + */ + public function getDecoder(): DecoderInterface { + return $this->decoder; + } + + /** + * Set the decoder instance + * @param DecoderInterface $decoder + * + * @return $this + */ + public function setDecoder(DecoderInterface $decoder): static { + $this->decoder = $decoder; + return $this; + } + + /** + * Safe the entire message in a file + * @param $filename + * + * @return bool|int + */ + public function save($filename): bool|int { + return file_put_contents($filename, $this->header->raw."\r\n\r\n".$this->structure->raw); } } diff --git a/src/Part.php b/src/Part.php new file mode 100644 index 00000000..3c55bdfb --- /dev/null +++ b/src/Part.php @@ -0,0 +1,324 @@ +raw = $raw_part; + $this->config = $config; + $this->header = $header; + $this->part_number = $part_number; + $this->parse(); + } + + /** + * Parse the raw parts + * + * @throws InvalidMessageDateException + */ + protected function parse(): void { + if ($this->header === null) { + $body = $this->findHeaders(); + }else{ + $body = $this->raw; + } + + $this->parseDisposition(); + $this->parseDescription(); + $this->parseEncoding(); + + $this->charset = $this->header->get("charset")->first(); + $this->name = $this->header->get("name"); + $this->filename = $this->header->get("filename"); + + if($this->header->get("id")->exist()) { + $this->id = $this->header->get("id"); + } else if($this->header->get("x_attachment_id")->exist()){ + $this->id = $this->header->get("x_attachment_id"); + } else if($this->header->get("content_id")->exist()){ + $this->id = strtr($this->header->get("content_id"), [ + '<' => '', + '>' => '' + ]); + } + + $content_types = $this->header->get("content_type")->all(); + if(!empty($content_types)){ + $this->subtype = $this->parseSubtype($content_types); + $content_type = $content_types[0]; + $parts = explode(';', $content_type); + $this->content_type = trim($parts[0]); + } + + $this->content = trim(rtrim($body)); + $this->bytes = strlen($this->content); + } + + /** + * Find all available headers and return the leftover body segment + * + * @return string + * @throws InvalidMessageDateException + */ + private function findHeaders(): string { + $body = $this->raw; + while (($pos = strpos($body, "\r\n")) > 0) { + $body = substr($body, $pos + 2); + } + $headers = substr($this->raw, 0, strlen($body) * -1); + $body = substr($body, 0, -2); + + $this->header = new Header($headers, $this->config); + + return $body; + } + + /** + * Try to parse the subtype if any is present + * @param $content_type + * + * @return ?string + */ + private function parseSubtype($content_type): ?string { + if (is_array($content_type)) { + foreach ($content_type as $part){ + if ((strpos($part, "/")) !== false){ + return $this->parseSubtype($part); + } + } + return null; + } + if (($pos = strpos($content_type, "/")) !== false){ + return substr(explode(";", $content_type)[0], $pos + 1); + } + return null; + } + + /** + * Try to parse the disposition if any is present + */ + private function parseDisposition(): void { + $content_disposition = $this->header->get("content_disposition")->first(); + if($content_disposition) { + $this->ifdisposition = true; + $this->disposition = (is_array($content_disposition)) ? implode(' ', $content_disposition) : explode(";", $content_disposition)[0]; + } + } + + /** + * Try to parse the description if any is present + */ + private function parseDescription(): void { + $content_description = $this->header->get("content_description")->first(); + if($content_description) { + $this->ifdescription = true; + $this->description = $content_description; + } + } + + /** + * Try to parse the encoding if any is present + */ + private function parseEncoding(): void { + $encoding = $this->header->get("content_transfer_encoding")->first(); + if($encoding) { + $this->encoding = match (strtolower($encoding)) { + "quoted-printable" => IMAP::MESSAGE_ENC_QUOTED_PRINTABLE, + "base64" => IMAP::MESSAGE_ENC_BASE64, + "7bit" => IMAP::MESSAGE_ENC_7BIT, + "8bit" => IMAP::MESSAGE_ENC_8BIT, + "binary" => IMAP::MESSAGE_ENC_BINARY, + default => IMAP::MESSAGE_ENC_OTHER, + }; + } + } + + /** + * Check if the current part represents an attachment + * + * @return bool + */ + public function isAttachment(): bool { + $valid_disposition = in_array(strtolower($this->disposition ?? ''), $this->config->get('options.dispositions')); + + if ($this->type == IMAP::MESSAGE_TYPE_TEXT && ($this->ifdisposition == 0 || empty($this->disposition) || !$valid_disposition)) { + if (($this->subtype == null || in_array((strtolower($this->subtype)), ["plain", "html"])) && $this->filename == null && $this->name == null) { + return false; + } + } + + if ($this->disposition === "inline" && $this->filename == null && $this->name == null && !$this->header->has("content_id")) { + return false; + } + return true; + } + + /** + * Get the part header + * + * @return Header|null + */ + public function getHeader(): ?Header { + return $this->header; + } + + /** + * Get the Config instance + * + * @return Config + */ + public function getConfig(): Config { + return $this->config; + } + +} diff --git a/src/Query/Query.php b/src/Query/Query.php index 28ffa0ad..bed64a95 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -13,10 +13,25 @@ namespace Webklex\PHPIMAP\Query; use Carbon\Carbon; +use Exception; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use ReflectionException; use Webklex\PHPIMAP\Client; -use Webklex\PHPIMAP\ClientManager; +use Webklex\PHPIMAP\Exceptions\AuthFailedException; +use Webklex\PHPIMAP\Exceptions\ConnectionFailedException; +use Webklex\PHPIMAP\Exceptions\EventNotFoundException; use Webklex\PHPIMAP\Exceptions\GetMessagesFailedException; +use Webklex\PHPIMAP\Exceptions\ImapBadRequestException; +use Webklex\PHPIMAP\Exceptions\ImapServerErrorException; +use Webklex\PHPIMAP\Exceptions\InvalidMessageDateException; +use Webklex\PHPIMAP\Exceptions\MessageContentFetchingException; +use Webklex\PHPIMAP\Exceptions\MessageFlagException; +use Webklex\PHPIMAP\Exceptions\MessageHeaderFetchingException; +use Webklex\PHPIMAP\Exceptions\MessageNotFoundException; use Webklex\PHPIMAP\Exceptions\MessageSearchValidationException; +use Webklex\PHPIMAP\Exceptions\ResponseException; +use Webklex\PHPIMAP\Exceptions\RuntimeException; use Webklex\PHPIMAP\IMAP; use Webklex\PHPIMAP\Message; use Webklex\PHPIMAP\Support\MessageCollection; @@ -28,60 +43,79 @@ */ class Query { - /** @var array $query */ - protected $query; + /** @var Collection $query */ + protected Collection $query; - /** @var string $raw_query */ - protected $raw_query; + /** @var string $raw_query */ + protected string $raw_query; - /** @var string $charset */ - protected $charset; + /** @var string[] $extensions */ + protected array $extensions; /** @var Client $client */ - protected $client; + protected Client $client; - /** @var int $limit */ - protected $limit = null; + /** @var ?int $limit */ + protected ?int $limit = null; /** @var int $page */ - protected $page = 1; + protected int $page = 1; - /** @var int $fetch_options */ - protected $fetch_options = null; + /** @var ?int $fetch_options */ + protected ?int $fetch_options = null; - /** @var int $fetch_body */ - protected $fetch_body = true; + /** @var boolean $fetch_body */ + protected bool $fetch_body = true; - /** @var int $fetch_attachment */ - protected $fetch_attachment = true; + /** @var boolean $fetch_flags */ + protected bool $fetch_flags = true; - /** @var int $fetch_flags */ - protected $fetch_flags = true; + /** @var int|string $sequence */ + protected mixed $sequence = IMAP::NIL; + + /** @var string $fetch_order */ + protected string $fetch_order; /** @var string $date_format */ - protected $date_format; + protected string $date_format; + + /** @var bool $soft_fail */ + protected bool $soft_fail = false; + + /** @var array $errors */ + protected array $errors = []; /** * Query constructor. * @param Client $client - * @param string $charset + * @param string[] $extensions */ - public function __construct(Client $client, $charset = 'UTF-8') { + public function __construct(Client $client, array $extensions = []) { $this->setClient($client); + $config = $this->client->getConfig(); - if(ClientManager::get('options.fetch') === IMAP::FT_PEEK) $this->leaveUnread(); + $this->sequence = $config->get('options.sequence', IMAP::ST_MSGN); + if ($config->get('options.fetch') === IMAP::FT_PEEK) $this->leaveUnread(); - $this->date_format = ClientManager::get('date_format', 'd M y'); + if ($config->get('options.fetch_order') === 'desc') { + $this->fetch_order = 'desc'; + } else { + $this->fetch_order = 'asc'; + } + + $this->date_format = $config->get('date_format', 'd M y'); + $this->soft_fail = $config->get('options.soft_fail', false); - $this->charset = $charset; - $this->query = collect(); + $this->setExtensions($extensions); + $this->query = new Collection(); $this->boot(); } /** * Instance boot method for additional functionality */ - protected function boot(){} + protected function boot(): void { + } /** * Parse a given value @@ -89,29 +123,27 @@ protected function boot(){} * * @return string */ - protected function parse_value($value){ - switch(true){ - case $value instanceof \Carbon\Carbon: - $value = $value->format($this->date_format); - break; + protected function parse_value(mixed $value): string { + if ($value instanceof Carbon) { + $value = $value->format($this->date_format); } - return (string) $value; + return (string)$value; } /** * Check if a given date is a valid carbon object and if not try to convert it - * @param $date + * @param mixed $date * * @return Carbon * @throws MessageSearchValidationException */ - protected function parse_date($date) { - if($date instanceof \Carbon\Carbon) return $date; + protected function parse_date(mixed $date): Carbon { + if ($date instanceof Carbon) return $date; try { $date = Carbon::parse($date); - } catch (\Exception $e) { + } catch (Exception) { throw new MessageSearchValidationException(); } @@ -119,159 +151,554 @@ protected function parse_date($date) { } /** - * Don't mark messages as read when fetching + * Get the raw IMAP search query * - * @return $this + * @return string */ - public function leaveUnread() { - $this->setFetchOptions(IMAP::FT_PEEK); + public function generate_query(): string { + $query = ''; + $this->query->each(function($statement) use (&$query) { + if (count($statement) == 1) { + $query .= $statement[0]; + } else { + if ($statement[1] === null) { + $query .= $statement[0]; + } else { + if (is_numeric($statement[1]) || ( + ($statement[0] === 'SINCE' || $statement[0] === 'BEFORE') && + $this->client->getConfig()->get('options.unescaped_search_dates', false) + )) { + $query .= $statement[0] . ' ' . $statement[1]; + } else { + $query .= $statement[0] . ' "' . $statement[1] . '"'; + } + } + } + $query .= ' '; - return $this; + }); + + $this->raw_query = trim($query); + + return $this->raw_query; } /** - * Mark all messages as read when fetching + * Perform an imap search request * - * @return $this + * @return Collection + * @throws GetMessagesFailedException + * @throws AuthFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException */ - public function markAsRead() { - $this->setFetchOptions(IMAP::FT_UID); + public function search(): Collection { + $this->generate_query(); - return $this; + try { + $available_messages = $this->client->getConnection()->search([$this->getRawQuery()], $this->sequence)->validatedData(); + return new Collection($available_messages); + } catch (RuntimeException|ConnectionFailedException $e) { + throw new GetMessagesFailedException("failed to fetch messages", 0, $e); + } } /** - * Perform an imap search request + * Count all available messages matching the current search criteria * - * @return \Illuminate\Support\Collection - * @throws \Webklex\PHPIMAP\Exceptions\ConnectionFailedException + * @return int + * @throws AuthFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException */ - protected function search(){ - $this->generate_query(); + public function count(): int { + return $this->search()->count(); + } - /** - * Don't set the charset if it isn't used - prevent strange outlook mail server errors - * @see https://github.com/Webklex/laravel-imap/issues/100 - */ - if($this->getCharset() === null){ - $available_messages = \imap_search($this->getClient()->getConnection(), $this->getRawQuery(), IMAP::SE_UID); - }else{ - $available_messages = \imap_search($this->getClient()->getConnection(), $this->getRawQuery(), IMAP::SE_UID, $this->getCharset()); + /** + * Fetch a given id collection + * @param Collection $available_messages + * + * @return array + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException + */ + protected function fetch(Collection $available_messages): array { + if ($this->fetch_order === 'desc') { + $available_messages = $available_messages->reverse(); + } + + $uids = $available_messages->forPage($this->page, $this->limit)->toArray(); + $extensions = $this->getExtensions(); + if (empty($extensions) === false && method_exists($this->client->getConnection(), "fetch")) { + // this polymorphic call is fine - the method exists at this point + $extensions = $this->client->getConnection()->fetch($extensions, $uids, null, $this->sequence)->validatedData(); } + $flags = $this->client->getConnection()->flags($uids, $this->sequence)->validatedData(); + $headers = $this->client->getConnection()->headers($uids, "RFC822", $this->sequence)->validatedData(); - if ($available_messages !== false) { - return collect($available_messages); + $contents = []; + if ($this->getFetchBody()) { + $contents = $this->client->getConnection()->content($uids, $this->client->rfc, $this->sequence)->validatedData(); } - return collect(); + return [ + "uids" => $uids, + "flags" => $flags, + "headers" => $headers, + "contents" => $contents, + "extensions" => $extensions, + ]; } /** - * Count all available messages matching the current search criteria + * Make a new message from given raw components + * @param integer $uid + * @param integer $msglist + * @param string $header + * @param string $content + * @param array $flags * - * @return int - * @throws \Webklex\PHPIMAP\Exceptions\ConnectionFailedException + * @return Message|null + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws ResponseException */ - public function count() { - return $this->search()->count(); + protected function make(int $uid, int $msglist, string $header, string $content, array $flags): ?Message { + try { + return Message::make($uid, $msglist, $this->getClient(), $header, $content, $flags, $this->getFetchOptions(), $this->sequence); + } catch (RuntimeException|MessageFlagException|InvalidMessageDateException|MessageContentFetchingException $e) { + $this->setError($uid, $e); + } + + $this->handleException($uid); + + return null; } /** - * Fetch the current query and return all found messages + * Get the message key for a given message + * @param string $message_key + * @param integer $msglist + * @param Message $message + * + * @return string + */ + protected function getMessageKey(string $message_key, int $msglist, Message $message): string { + $key = match ($message_key) { + 'number' => $message->getMessageNo(), + 'list' => $msglist, + 'uid' => $message->getUid(), + default => $message->getMessageId(), + }; + return (string)$key; + } + + /** + * Curates a given collection aof messages + * @param Collection $available_messages * * @return MessageCollection * @throws GetMessagesFailedException */ - public function get() { - $messages = MessageCollection::make([]); - + public function curate_messages(Collection $available_messages): MessageCollection { try { - $available_messages = $this->search(); - $available_messages_count = $available_messages->count(); + if ($available_messages->count() > 0) { + return $this->populate($available_messages); + } + return MessageCollection::make(); + } catch (Exception $e) { + throw new GetMessagesFailedException($e->getMessage(), 0, $e); + } + } - if ($available_messages_count > 0) { + /** + * Populate a given id collection and receive a fully fetched message collection + * @param Collection $available_messages + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + protected function populate(Collection $available_messages): MessageCollection { + $messages = MessageCollection::make(); + $config = $this->client->getConfig(); - $messages->total($available_messages_count); + $messages->total($available_messages->count()); - $options = ClientManager::get('options'); + $message_key = $config->get('options.message_key'); - if(strtolower($options['fetch_order']) === 'desc'){ - $available_messages = $available_messages->reverse(); - } + $raw_messages = $this->fetch($available_messages); - $query =& $this; - - $available_messages->forPage($this->page, $this->limit)->each(function($msgno, $msglist) use(&$messages, $options, $query) { - $oMessage = new Message($msgno, $msglist, $query->getClient(), $query->getFetchOptions(), $query->getFetchBody(), $query->getFetchAttachment(), $query->getFetchFlags()); - switch ($options['message_key']){ - case 'number': - $message_key = $oMessage->getMessageNo(); - break; - case 'list': - $message_key = $msglist; - break; - default: - $message_key = $oMessage->getMessageId(); - break; + $msglist = 0; + foreach ($raw_messages["headers"] as $uid => $header) { + $content = $raw_messages["contents"][$uid] ?? ""; + $flag = $raw_messages["flags"][$uid] ?? []; + $extensions = $raw_messages["extensions"][$uid] ?? []; - } - $messages->put($message_key, $oMessage); - }); + $message = $this->make($uid, $msglist, $header, $content, $flag); + foreach ($extensions as $key => $extension) { + $message->getHeader()->set($key, $extension); + } + if ($message !== null) { + $key = $this->getMessageKey($message_key, $msglist, $message); + $messages->put("$key", $message); } + $msglist++; + } + + return $messages; + } + + /** + * Fetch the current query and return all found messages + * + * @return MessageCollection + * @throws AuthFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + */ + public function get(): MessageCollection { + return $this->curate_messages($this->search()); + } - return $messages; - } catch (\Exception $e) { - throw new GetMessagesFailedException($e->getMessage()); + /** + * Fetch the current query as chunked requests + * @param callable $callback + * @param int $chunk_size + * @param int $start_chunk + * + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ReflectionException + * @throws RuntimeException + * @throws ResponseException + */ + public function chunked(callable $callback, int $chunk_size = 10, int $start_chunk = 1): void { + $start_chunk = max($start_chunk,1); + $chunk_size = max($chunk_size,1); + $skipped_messages_count = $chunk_size * ($start_chunk-1); + + $available_messages = $this->search(); + $available_messages_count = max($available_messages->count() - $skipped_messages_count,0); + + if ($available_messages_count > 0) { + $old_limit = $this->limit; + $old_page = $this->page; + + $this->limit = $chunk_size; + $this->page = $start_chunk; + $handled_messages_count = 0; + do { + $messages = $this->populate($available_messages); + $handled_messages_count += $messages->count(); + $callback($messages, $this->page); + $this->page++; + } while ($handled_messages_count < $available_messages_count); + $this->limit = $old_limit; + $this->page = $old_page; } } /** * Paginate the current query - * @param int $per_page - * @param null $page - * @param string $page_name + * @param int $per_page Results you which to receive per page + * @param null $page The current page you are on (e.g. 0, 1, 2, ...) use `null` to enable auto mode + * @param string $page_name The page name / uri parameter used for the generated links and the auto mode * - * @return \Illuminate\Pagination\LengthAwarePaginator + * @return LengthAwarePaginator + * @throws AuthFailedException * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException */ - public function paginate($per_page = 5, $page = null, $page_name = 'imap_page'){ - $this->page = $page > $this->page ? $page : $this->page; + public function paginate(int $per_page = 5, $page = null, string $page_name = 'imap_page'): LengthAwarePaginator { + if ($page === null && isset($_GET[$page_name]) && $_GET[$page_name] > 0) { + $this->page = intval($_GET[$page_name]); + } elseif ($page > 0) { + $this->page = (int)$page; + } + $this->limit = $per_page; - return $this->get()->paginate($per_page, $this->page, $page_name); + return $this->get()->paginate($per_page, $this->page, $page_name, true); } /** - * Get the raw IMAP search query + * Get a new Message instance + * @param int $uid + * @param null $msglist + * @param null $sequence * - * @return string + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException */ - public function generate_query() { - $query = ''; - $this->query->each(function($statement) use(&$query) { - if (count($statement) == 1) { - $query .= $statement[0]; - } else { - if($statement[1] === null){ - $query .= $statement[0]; - }else{ - $query .= $statement[0].' "'.$statement[1].'"'; + public function getMessage(int $uid, $msglist = null, $sequence = null): Message { + return new Message($uid, $msglist, $this->getClient(), $this->getFetchOptions(), $this->getFetchBody(), $this->getFetchFlags(), $sequence ?: $this->sequence); + } + + /** + * Get a message by its message number + * @param $msgn + * @param null $msglist + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function getMessageByMsgn($msgn, $msglist = null): Message { + return $this->getMessage($msgn, $msglist, IMAP::ST_MSGN); + } + + /** + * Get a message by its uid + * @param $uid + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws RuntimeException + * @throws ResponseException + */ + public function getMessageByUid($uid): Message { + return $this->getMessage($uid, null, IMAP::ST_UID); + } + + /** + * Filter all available uids by a given closure and get a curated list of messages + * @param callable $closure + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function filter(callable $closure): MessageCollection { + $connection = $this->getClient()->getConnection(); + + $uids = $connection->getUid()->validatedData(); + $available_messages = new Collection(); + if (is_array($uids)) { + foreach ($uids as $id) { + if ($closure($id)) { + $available_messages->push($id); } } - $query .= ' '; + } + + return $this->curate_messages($available_messages); + } + /** + * Get all messages with an uid greater or equal to a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidGreaterOrEqual(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id >= $uid; }); + } - $this->raw_query = trim($query); + /** + * Get all messages with an uid greater than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidGreater(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id > $uid; + }); + } - return $this->raw_query; + /** + * Get all messages with an uid lower than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLower(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id < $uid; + }); + } + + /** + * Get all messages with an uid lower or equal to a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLowerOrEqual(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id <= $uid; + }); + } + + /** + * Get all messages with an uid greater than a given UID + * @param int $uid + * + * @return MessageCollection + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws GetMessagesFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws RuntimeException + * @throws ResponseException + */ + public function getByUidLowerThan(int $uid): MessageCollection { + return $this->filter(function($id) use ($uid) { + return $id < $uid; + }); + } + + /** + * Don't mark messages as read when fetching + * + * @return $this + */ + public function leaveUnread(): static { + $this->setFetchOptions(IMAP::FT_PEEK); + + return $this; + } + + /** + * Mark all messages as read when fetching + * + * @return $this + */ + public function markAsRead(): static { + $this->setFetchOptions(IMAP::FT_UID); + + return $this; + } + + /** + * Set the sequence type + * @param int $sequence + * + * @return $this + */ + public function setSequence(int $sequence): static { + $this->sequence = $sequence; + + return $this; + } + + /** + * Get the sequence type + * + * @return int|string + */ + public function getSequence(): int|string { + return $this->sequence; } /** * @return Client - * @throws \Webklex\PHPIMAP\Exceptions\ConnectionFailedException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + * @throws ResponseException */ - public function getClient() { + public function getClient(): Client { $this->client->checkConnection(); return $this->client; } @@ -283,187 +710,394 @@ public function getClient() { * * @return $this */ - public function limit($limit, $page = 1) { - if($page >= 1) $this->page = $page; + public function limit(int $limit, int $page = 1): static { + if ($page >= 1) $this->page = $page; $this->limit = $limit; return $this; } /** - * @return array + * Get the current query collection + * + * @return Collection */ - public function getQuery() { + public function getQuery(): Collection { return $this->query; } /** + * Set all query parameters * @param array $query - * @return Query + * + * @return $this */ - public function setQuery($query) { - $this->query = $query; + public function setQuery(array $query): static { + $this->query = new Collection($query); return $this; } /** + * Get the raw query + * * @return string */ - public function getRawQuery() { + public function getRawQuery(): string { return $this->raw_query; } /** + * Set the raw query * @param string $raw_query - * @return Query + * + * @return $this */ - public function setRawQuery($raw_query) { + public function setRawQuery(string $raw_query): static { $this->raw_query = $raw_query; return $this; } /** - * @return string + * Get all applied extensions + * + * @return string[] */ - public function getCharset() { - return $this->charset; + public function getExtensions(): array { + return $this->extensions; } /** - * @param string $charset - * @return Query + * Set all extensions that should be used + * @param string[] $extensions + * + * @return $this */ - public function setCharset($charset) { - $this->charset = $charset; + public function setExtensions(array $extensions): static { + $this->extensions = $extensions; + if (count($this->extensions) > 0) { + if (in_array("UID", $this->extensions) === false) { + $this->extensions[] = "UID"; + } + } return $this; } /** + * Set the client instance * @param Client $client - * @return Query + * + * @return $this */ - public function setClient(Client $client) { + public function setClient(Client $client): static { $this->client = $client; return $this; } /** - * @return int + * Get the set fetch limit + * + * @return ?int */ - public function getLimit() { + public function getLimit(): ?int { return $this->limit; } /** + * Set the fetch limit * @param int $limit - * @return Query + * + * @return $this */ - public function setLimit($limit) { + public function setLimit(int $limit): static { $this->limit = $limit <= 0 ? null : $limit; return $this; } /** + * Get the set page + * * @return int */ - public function getPage() { + public function getPage(): int { return $this->page; } /** + * Set the page * @param int $page - * @return Query + * + * @return $this */ - public function setPage($page) { + public function setPage(int $page): static { $this->page = $page; return $this; } /** - * @param boolean $fetch_options - * @return Query + * Set the fetch option flag + * @param int $fetch_options + * + * @return $this */ - public function setFetchOptions($fetch_options) { + public function setFetchOptions(int $fetch_options): static { $this->fetch_options = $fetch_options; return $this; } /** - * @param boolean $fetch_options - * @return Query + * Set the fetch option flag + * @param int $fetch_options + * + * @return $this */ - public function fetchOptions($fetch_options) { + public function fetchOptions(int $fetch_options): static { return $this->setFetchOptions($fetch_options); } /** - * @return int + * Get the fetch option flag + * + * @return ?int */ - public function getFetchOptions() { + public function getFetchOptions(): ?int { return $this->fetch_options; } /** + * Get the fetch body flag + * * @return boolean */ - public function getFetchBody() { + public function getFetchBody(): bool { return $this->fetch_body; } /** + * Set the fetch body flag * @param boolean $fetch_body - * @return Query + * + * @return $this */ - public function setFetchBody($fetch_body) { + public function setFetchBody(bool $fetch_body): static { $this->fetch_body = $fetch_body; return $this; } /** + * Set the fetch body flag * @param boolean $fetch_body - * @return Query + * + * @return $this */ - public function fetchBody($fetch_body) { + public function fetchBody(bool $fetch_body): static { return $this->setFetchBody($fetch_body); } /** - * @return boolean + * Get the fetch body flag + * + * @return bool */ - public function getFetchAttachment() { - return $this->fetch_attachment; + public function getFetchFlags(): bool { + return $this->fetch_flags; } /** - * @param boolean $fetch_attachment - * @return Query + * Set the fetch flag + * @param bool $fetch_flags + * + * @return $this */ - public function setFetchAttachment($fetch_attachment) { - $this->fetch_attachment = $fetch_attachment; + public function setFetchFlags(bool $fetch_flags): static { + $this->fetch_flags = $fetch_flags; return $this; } /** - * @param boolean $fetch_attachment - * @return Query + * Set the fetch order + * @param string $fetch_order + * + * @return $this */ - public function fetchAttachment($fetch_attachment) { - return $this->setFetchAttachment($fetch_attachment); + public function setFetchOrder(string $fetch_order): static { + $fetch_order = strtolower($fetch_order); + + if (in_array($fetch_order, ['asc', 'desc'])) { + $this->fetch_order = $fetch_order; + } + + return $this; } /** - * @return int + * Set the fetch order + * @param string $fetch_order + * + * @return $this */ - public function getFetchFlags() { - return $this->fetch_flags; + public function fetchOrder(string $fetch_order): static { + return $this->setFetchOrder($fetch_order); } /** - * @param int $fetch_flags - * @return Query + * Get the fetch order + * + * @return string */ - public function setFetchFlags($fetch_flags) { - $this->fetch_flags = $fetch_flags; + public function getFetchOrder(): string { + return $this->fetch_order; + } + + /** + * Set the fetch order to ascending + * + * @return $this + */ + public function setFetchOrderAsc(): static { + return $this->setFetchOrder('asc'); + } + + /** + * Set the fetch order to ascending + * + * @return $this + */ + public function fetchOrderAsc(): static { + return $this->setFetchOrderAsc(); + } + + /** + * Set the fetch order to descending + * + * @return $this + */ + public function setFetchOrderDesc(): static { + return $this->setFetchOrder('desc'); + } + + /** + * Set the fetch order to descending + * + * @return $this + */ + public function fetchOrderDesc(): static { + return $this->setFetchOrderDesc(); + } + + /** + * Set soft fail mode + * @var boolean $state + * + * @return $this + */ + public function softFail(bool $state = true): static { + return $this->setSoftFail($state); + } + + /** + * Set soft fail mode + * + * @var boolean $state + * @return $this + */ + public function setSoftFail(bool $state = true): static { + $this->soft_fail = $state; + return $this; } -} \ No newline at end of file + + /** + * Get soft fail mode + * + * @return boolean + */ + public function getSoftFail(): bool { + return $this->soft_fail; + } + + /** + * Handle the exception for a given uid + * @param integer $uid + * + * @throws GetMessagesFailedException + */ + protected function handleException(int $uid): void { + if ($this->soft_fail === false && $this->hasError($uid)) { + $error = $this->getError($uid); + throw new GetMessagesFailedException($error->getMessage(), 0, $error); + } + } + + /** + * Add a new error to the error holder + * @param integer $uid + * @param Exception $error + */ + protected function setError(int $uid, Exception $error): void { + $this->errors[$uid] = $error; + } + + /** + * Check if there are any errors / exceptions present + * @var ?integer $uid + * + * @return boolean + */ + public function hasErrors(?int $uid = null): bool { + if ($uid !== null) { + return $this->hasError($uid); + } + return count($this->errors) > 0; + } + + /** + * Check if there is an error / exception present + * @var integer $uid + * + * @return boolean + */ + public function hasError(int $uid): bool { + return isset($this->errors[$uid]); + } + + /** + * Get all available errors / exceptions + * + * @return array + */ + public function errors(): array { + return $this->getErrors(); + } + + /** + * Get all available errors / exceptions + * + * @return array + */ + public function getErrors(): array { + return $this->errors; + } + + /** + * Get a specific error / exception + * @var integer $uid + * + * @return Exception|null + */ + public function error(int $uid): ?Exception { + return $this->getError($uid); + } + + /** + * Get a specific error / exception + * @var integer $uid + * + * @return ?Exception + */ + public function getError(int $uid): ?Exception { + if ($this->hasError($uid)) { + return $this->errors[$uid]; + } + return null; + } +} diff --git a/src/Query/WhereQuery.php b/src/Query/WhereQuery.php index 54a5add3..5636cf35 100755 --- a/src/Query/WhereQuery.php +++ b/src/Query/WhereQuery.php @@ -12,6 +12,7 @@ namespace Webklex\PHPIMAP\Query; +use Closure; use Illuminate\Support\Str; use Webklex\PHPIMAP\Exceptions\InvalidWhereQueryCriteriaException; use Webklex\PHPIMAP\Exceptions\MethodNotFoundException; @@ -21,7 +22,7 @@ * Class WhereQuery * * @package Webklex\PHPIMAP\Query - * + * * @method WhereQuery all() * @method WhereQuery answered() * @method WhereQuery deleted() @@ -33,6 +34,7 @@ * @method WhereQuery undeleted() * @method WhereQuery unflagged() * @method WhereQuery unseen() + * @method WhereQuery not() * @method WhereQuery unkeyword($value) * @method WhereQuery to($value) * @method WhereQuery text($value) @@ -46,6 +48,8 @@ * @method WhereQuery body($value) * @method WhereQuery before($date) * @method WhereQuery bcc($value) + * @method WhereQuery inReplyTo($value) + * @method WhereQuery messageId($value) * * @mixin Query */ @@ -54,11 +58,11 @@ class WhereQuery extends Query { /** * @var array $available_criteria */ - protected $available_criteria = [ + protected array $available_criteria = [ 'OR', 'AND', 'ALL', 'ANSWERED', 'BCC', 'BEFORE', 'BODY', 'CC', 'DELETED', 'FLAGGED', 'FROM', 'KEYWORD', 'NEW', 'NOT', 'OLD', 'ON', 'RECENT', 'SEEN', 'SINCE', 'SUBJECT', 'TEXT', 'TO', - 'UNANSWERED', 'UNDELETED', 'UNFLAGGED', 'UNKEYWORD', 'UNSEEN' + 'UNANSWERED', 'UNDELETED', 'UNFLAGGED', 'UNKEYWORD', 'UNSEEN', 'UID' ]; /** @@ -70,22 +74,27 @@ class WhereQuery extends Query { * @throws InvalidWhereQueryCriteriaException * @throws MethodNotFoundException */ - public function __call($name, $arguments) { + public function __call(string $name, ?array $arguments) { $that = $this; $name = Str::camel($name); - if(strtolower(substr($name, 0, 3)) === 'not') { + if (strtolower(substr($name, 0, 3)) === 'not') { $that = $that->whereNot(); $name = substr($name, 3); } - $method = 'where'.ucfirst($name); - if(method_exists($this, $method) === true){ + if (!str_contains(strtolower($name), "where")) { + $method = 'where' . ucfirst($name); + } else { + $method = lcfirst($name); + } + + if (method_exists($this, $method) === true) { return call_user_func_array([$that, $method], $arguments); } - throw new MethodNotFoundException("Method ".self::class.'::'.$method.'() is not supported'); + throw new MethodNotFoundException("Method " . self::class . '::' . $method . '() is not supported'); } /** @@ -95,106 +104,125 @@ public function __call($name, $arguments) { * @return string * @throws InvalidWhereQueryCriteriaException */ - protected function validate_criteria($criteria) { - $criteria = strtoupper($criteria); - - if (substr($criteria, 0, 6) === "CUSTOM") { - return substr($criteria, 6); + protected function validate_criteria($criteria): string { + $command = strtoupper($criteria); + if (str_starts_with($command, "CUSTOM ")) { + return substr($criteria, 7); } - if(in_array($criteria, $this->available_criteria) === false) { - throw new InvalidWhereQueryCriteriaException(); + if (in_array($command, $this->available_criteria) === false) { + throw new InvalidWhereQueryCriteriaException("Invalid imap search criteria: $command"); } return $criteria; } /** + * Register search parameters * @param mixed $criteria - * @param null $value + * @param mixed $value * * @return $this * @throws InvalidWhereQueryCriteriaException - */ - public function where($criteria, $value = null) { - if(is_array($criteria)){ - foreach($criteria as $arguments){ - if(count($arguments) == 1){ - $this->where($arguments[0]); - }elseif(count($arguments) == 2){ - $this->where($arguments[0], $arguments[1]); + * + * Examples: + * $query->from("someone@email.tld")->seen(); + * $query->whereFrom("someone@email.tld")->whereSeen(); + * $query->where([["FROM" => "someone@email.tld"], ["SEEN"]]); + * $query->where(["FROM" => "someone@email.tld"])->where(["SEEN"]); + * $query->where(["FROM" => "someone@email.tld", "SEEN"]); + * $query->where("FROM", "someone@email.tld")->where("SEEN"); + */ + public function where(mixed $criteria, mixed $value = null): static { + if (is_array($criteria)) { + foreach ($criteria as $key => $value) { + if (is_numeric($key)) { + $this->where($value); + }else{ + $this->where($key, $value); } } - }else{ - $criteria = $this->validate_criteria($criteria); - $value = $this->parse_value($value); - - if($value === null || $value === ''){ - $this->query->push([$criteria]); - }else{ - $this->query->push([$criteria, $value]); - } + } else { + $this->push_search_criteria($criteria, $value); } return $this; } /** - * @param \Closure $closure + * Push a given search criteria and value pair to the search query + * @param $criteria string + * @param $value mixed + * + * @throws InvalidWhereQueryCriteriaException + */ + protected function push_search_criteria(string $criteria, mixed $value): void { + $criteria = $this->validate_criteria($criteria); + $value = $this->parse_value($value); + + if ($value === '') { + $this->query->push([$criteria]); + } else { + $this->query->push([$criteria, $value]); + } + } + + /** + * @param Closure|null $closure * * @return $this */ - public function orWhere(\Closure $closure = null) { + public function orWhere(?Closure $closure = null): static { $this->query->push(['OR']); - if($closure !== null) $closure($this); + if ($closure !== null) $closure($this); return $this; } /** - * @param \Closure $closure + * @param Closure|null $closure * * @return $this */ - public function andWhere(\Closure $closure = null) { + public function andWhere(?Closure $closure = null): static { $this->query->push(['AND']); - if($closure !== null) $closure($this); + if ($closure !== null) $closure($this); return $this; } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereAll() { + public function whereAll(): static { return $this->where('ALL'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereAnswered() { + public function whereAnswered(): static { return $this->where('ANSWERED'); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereBcc($value) { + public function whereBcc(string $value): static { return $this->where('BCC', $value); } /** * @param mixed $value - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException * @throws MessageSearchValidationException */ - public function whereBefore($value) { + public function whereBefore(mixed $value): static { $date = $this->parse_date($value); return $this->where('BEFORE', $date); } @@ -202,121 +230,121 @@ public function whereBefore($value) { /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereBody($value) { + public function whereBody(string $value): static { return $this->where('BODY', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereCc($value) { + public function whereCc(string $value): static { return $this->where('CC', $value); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereDeleted() { + public function whereDeleted(): static { return $this->where('DELETED'); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereFlagged($value) { + public function whereFlagged(string $value): static { return $this->where('FLAGGED', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereFrom($value) { + public function whereFrom(string $value): static { return $this->where('FROM', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereKeyword($value) { + public function whereKeyword(string $value): static { return $this->where('KEYWORD', $value); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereNew() { + public function whereNew(): static { return $this->where('NEW'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereNot() { + public function whereNot(): static { return $this->where('NOT'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereOld() { + public function whereOld(): static { return $this->where('OLD'); } /** * @param mixed $value * - * @return WhereQuery + * @return $this * @throws MessageSearchValidationException * @throws InvalidWhereQueryCriteriaException */ - public function whereOn($value) { + public function whereOn(mixed $value): static { $date = $this->parse_date($value); return $this->where('ON', $date); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereRecent() { + public function whereRecent(): static { return $this->where('RECENT'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereSeen() { + public function whereSeen(): static { return $this->where('SEEN'); } /** * @param mixed $value * - * @return WhereQuery + * @return $this * @throws MessageSearchValidationException * @throws InvalidWhereQueryCriteriaException */ - public function whereSince($value) { + public function whereSince(mixed $value): static { $date = $this->parse_date($value); return $this->where('SINCE', $date); } @@ -324,108 +352,204 @@ public function whereSince($value) { /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereSubject($value) { + public function whereSubject(string $value): static { return $this->where('SUBJECT', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereText($value) { + public function whereText(string $value): static { return $this->where('TEXT', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereTo($value) { + public function whereTo(string $value): static { return $this->where('TO', $value); } /** * @param string $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereUnkeyword($value) { + public function whereUnkeyword(string $value): static { return $this->where('UNKEYWORD', $value); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereUnanswered() { + public function whereUnanswered(): static { return $this->where('UNANSWERED'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereUndeleted() { + public function whereUndeleted(): static { return $this->where('UNDELETED'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereUnflagged() { + public function whereUnflagged(): static { return $this->where('UNFLAGGED'); } /** - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereUnseen() { + public function whereUnseen(): static { return $this->where('UNSEEN'); } /** - * @param $msg_id + * @return $this + * @throws InvalidWhereQueryCriteriaException + */ + public function whereNoXSpam(): static { + return $this->where("CUSTOM X-Spam-Flag NO"); + } + + /** + * @return $this + * @throws InvalidWhereQueryCriteriaException + */ + public function whereIsXSpam(): static { + return $this->where("CUSTOM X-Spam-Flag YES"); + } + + /** + * Search for a specific header value + * @param $header + * @param $value * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereMessageId($msg_id) { - return $this->where("Message-ID <$msg_id>"); + public function whereHeader($header, $value): static { + return $this->where("CUSTOM HEADER $header $value"); } /** - * @return WhereQuery + * Search for a specific message id + * @param $messageId + * + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereNoXSpam(){ - return $this->where("X-Spam-Flag NO"); + public function whereMessageId($messageId): static { + return $this->whereHeader("Message-ID", $messageId); } /** - * @return WhereQuery + * Search for a specific message id + * @param $messageId + * + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereIsXSpam(){ - return $this->where("X-Spam-Flag YES"); + public function whereInReplyTo($messageId): static { + return $this->whereHeader("In-Reply-To", $messageId); } /** * @param $country_code * - * @return WhereQuery + * @return $this * @throws InvalidWhereQueryCriteriaException */ - public function whereLanguage($country_code){ + public function whereLanguage($country_code): static { return $this->where("Content-Language $country_code"); } + + /** + * Get message be it UID. + * + * @param int|string $uid + * + * @return $this + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUid(int|string $uid): static { + return $this->where('UID', $uid); + } + + /** + * Get messages by their UIDs. + * + * @param array $uids + * + * @return $this + * @throws InvalidWhereQueryCriteriaException + */ + public function whereUidIn(array $uids): static { + $uids = implode(',', $uids); + return $this->where('UID', $uids); + } + + /** + * Apply the callback if the given "value" is truthy. + * copied from @url https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/Traits/Conditionable.php + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|null + */ + public function when(mixed $value, callable $callback, ?callable $default = null): mixed { + if ($value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } + + /** + * Apply the callback if the given "value" is falsy. + * copied from @url https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/Traits/Conditionable.php + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function unless(mixed $value, callable $callback, ?callable $default = null): mixed { + if (!$value) { + return $callback($this, $value) ?: $this; + } elseif ($default) { + return $default($this, $value) ?: $this; + } + + return $this; + } + + /** + * Get all available search criteria + * + * @return array|string[] + */ + public function getAvailableCriteria(): array { + return $this->available_criteria; + } } \ No newline at end of file diff --git a/src/Structure.php b/src/Structure.php new file mode 100644 index 00000000..11f4cd66 --- /dev/null +++ b/src/Structure.php @@ -0,0 +1,172 @@ +raw = $raw_structure; + $this->header = $header; + $this->options = $header->getConfig()->get('options'); + $this->parse(); + } + + /** + * Parse the given raw structure + * + * @throws MessageContentFetchingException + * @throws InvalidMessageDateException + */ + protected function parse(): void { + $this->findContentType(); + $this->parts = $this->find_parts(); + } + + /** + * Determine the message content type + */ + public function findContentType(): void { + $content_type = $this->header->get("content_type")->first(); + if($content_type && stripos($content_type, 'multipart') === 0) { + $this->type = IMAP::MESSAGE_TYPE_MULTIPART; + }else{ + $this->type = IMAP::MESSAGE_TYPE_TEXT; + } + } + + /** + * Find all available headers and return the leftover body segment + * @var string $context + * @var integer $part_number + * + * @return Part[] + * @throws InvalidMessageDateException + */ + private function parsePart(string $context, int $part_number = 0): array { + $body = $context; + while (($pos = strpos($body, "\r\n")) > 0) { + $body = substr($body, $pos + 2); + } + $headers = substr($context, 0, strlen($body) * -1); + $body = substr($body, 0, -2); + + $config = $this->header->getConfig(); + $headers = new Header($headers, $config); + if (($boundary = $headers->getBoundary()) !== null) { + $parts = $this->detectParts($boundary, $body, $part_number); + + if(count($parts) > 1) { + return $parts; + } + } + + return [new Part($body, $this->header->getConfig(), $headers, $part_number)]; + } + + /** + * @param string $boundary + * @param string $context + * @param int $part_number + * + * @return array + * @throws InvalidMessageDateException + */ + private function detectParts(string $boundary, string $context, int $part_number = 0): array { + $base_parts = explode( "--".$boundary, $context); + if(count($base_parts) == 0) { + $base_parts = explode($boundary, $context); + } + $final_parts = []; + foreach($base_parts as $ctx) { + $ctx = substr($ctx, 2); + if ($ctx !== "--" && $ctx != "" && $ctx != "\r\n") { + $parts = $this->parsePart($ctx, $part_number); + foreach ($parts as $part) { + $final_parts[] = $part; + $part_number = $part->part_number; + } + $part_number++; + } + } + return $final_parts; + } + + /** + * Find all available parts + * + * @return array + * @throws MessageContentFetchingException + * @throws InvalidMessageDateException + */ + public function find_parts(): array { + if($this->type === IMAP::MESSAGE_TYPE_MULTIPART) { + if (($boundary = $this->header->getBoundary()) === null) { + throw new MessageContentFetchingException("no content found", 0); + } + + return $this->detectParts($boundary, $this->raw); + } + + return [new Part($this->raw, $this->header->getConfig(), $this->header)]; + } +} diff --git a/src/Support/AttachmentCollection.php b/src/Support/AttachmentCollection.php index 8b3f9c32..9d2af20d 100644 --- a/src/Support/AttachmentCollection.php +++ b/src/Support/AttachmentCollection.php @@ -12,10 +12,14 @@ namespace Webklex\PHPIMAP\Support; +use Illuminate\Support\Collection; +use Webklex\PHPIMAP\Attachment; + /** * Class AttachmentCollection * * @package Webklex\PHPIMAP\Support + * @implements Collection */ class AttachmentCollection extends PaginatedCollection { diff --git a/src/Support/FlagCollection.php b/src/Support/FlagCollection.php index 929594ee..b8bf352c 100644 --- a/src/Support/FlagCollection.php +++ b/src/Support/FlagCollection.php @@ -12,10 +12,13 @@ namespace Webklex\PHPIMAP\Support; +use Illuminate\Support\Collection; + /** * Class FlagCollection * * @package Webklex\PHPIMAP\Support + * @implements Collection */ class FlagCollection extends PaginatedCollection { diff --git a/src/Support/FolderCollection.php b/src/Support/FolderCollection.php index f74ec4d4..212ddb0a 100644 --- a/src/Support/FolderCollection.php +++ b/src/Support/FolderCollection.php @@ -12,10 +12,14 @@ namespace Webklex\PHPIMAP\Support; +use Illuminate\Support\Collection; +use Webklex\PHPIMAP\Folder; + /** * Class FolderCollection * * @package Webklex\PHPIMAP\Support + * @implements Collection */ class FolderCollection extends PaginatedCollection { diff --git a/src/Support/Masks/AttachmentMask.php b/src/Support/Masks/AttachmentMask.php index ad93940c..2559c5b9 100644 --- a/src/Support/Masks/AttachmentMask.php +++ b/src/Support/Masks/AttachmentMask.php @@ -18,27 +18,28 @@ * Class AttachmentMask * * @package Webklex\PHPIMAP\Support\Masks + * @mixin Attachment */ class AttachmentMask extends Mask { /** @var Attachment $parent */ - protected $parent; + protected mixed $parent; /** * Get the attachment content base64 encoded * * @return string|null */ - public function getContentBase64Encoded() { + public function getContentBase64Encoded(): ?string { return base64_encode($this->parent->content); } /** - * Get an base64 image src string + * Get a base64 image src string * * @return string|null */ - public function getImageSrc() { + public function getImageSrc(): ?string { return 'data:'.$this->parent->content_type.';base64,'.$this->getContentBase64Encoded(); } } \ No newline at end of file diff --git a/src/Support/Masks/Mask.php b/src/Support/Masks/Mask.php index f679d090..2101f574 100755 --- a/src/Support/Masks/Mask.php +++ b/src/Support/Masks/Mask.php @@ -23,14 +23,18 @@ class Mask { /** + * Available attributes + * * @var array $attributes */ - protected $attributes = []; + protected array $attributes = []; /** - * @var object $parent + * Parent instance + * + * @var mixed $parent */ - protected $parent; + protected mixed $parent; /** * Mask constructor. @@ -49,7 +53,7 @@ public function __construct($parent) { /** * Boot method made to be used by any custom mask */ - protected function boot(){} + protected function boot(): void {} /** * Call dynamic attribute setter and getter methods and inherit the parent calls @@ -59,7 +63,7 @@ protected function boot(){} * @return mixed * @throws MethodNotFoundException */ - public function __call($method, $arguments) { + public function __call(string $method, array $arguments) { if(strtolower(substr($method, 0, 3)) === 'get') { $name = Str::snake(substr($method, 3)); @@ -86,6 +90,7 @@ public function __call($method, $arguments) { } /** + * Magic setter * @param $name * @param $value * @@ -98,6 +103,7 @@ public function __set($name, $value) { } /** + * Magic getter * @param $name * * @return mixed|null @@ -111,16 +117,20 @@ public function __get($name) { } /** + * Get the parent instance + * * @return mixed */ - public function getParent(){ + public function getParent(): mixed { return $this->parent; } /** + * Get all available attributes + * * @return array */ - public function getAttributes(){ + public function getAttributes(): array { return $this->attributes; } diff --git a/src/Support/Masks/MessageMask.php b/src/Support/Masks/MessageMask.php index 30186c20..aa3623f9 100644 --- a/src/Support/Masks/MessageMask.php +++ b/src/Support/Masks/MessageMask.php @@ -19,14 +19,16 @@ * Class MessageMask * * @package Webklex\PHPIMAP\Support\Masks + * @mixin Message */ class MessageMask extends Mask { /** @var Message $parent */ - protected $parent; + protected mixed $parent; /** * Get the message html body + * * @return null */ public function getHtmlBody(){ @@ -35,20 +37,23 @@ public function getHtmlBody(){ return null; } - return $bodies['html']->content; + if(is_object($bodies['html']) && property_exists($bodies['html'], 'content')) { + return $bodies['html']->content; + } + return $bodies['html']; } /** * Get the Message html body filtered by an optional callback - * @param callable|bool $callback + * @param callable|null $callback * * @return string|null */ - public function getCustomHTMLBody($callback = false) { + public function getCustomHTMLBody(?callable $callback = null): ?string { $body = $this->getHtmlBody(); if($body === null) return null; - if ($callback !== false) { + if ($callback !== null) { $aAttachment = $this->parent->getAttachments(); $aAttachment->each(function($oAttachment) use(&$body, $callback) { /** @var Attachment $oAttachment */ @@ -69,11 +74,11 @@ public function getCustomHTMLBody($callback = false) { * * @return string|null */ - public function getHTMLBodyWithEmbeddedBase64Images() { + public function getHTMLBodyWithEmbeddedBase64Images(): ?string { return $this->getCustomHTMLBody(function($body, $oAttachment){ - /** @var \Webklex\PHPIMAP\Attachment $oAttachment */ - if ($oAttachment->id && $oAttachment->getImgSrc() != null) { - $body = str_replace('cid:'.$oAttachment->id, $oAttachment->getImgSrc(), $body); + /** @var Attachment $oAttachment */ + if ($oAttachment->id) { + $body = str_replace('cid:'.$oAttachment->id, 'data:'.$oAttachment->getContentType().';base64, '.base64_encode($oAttachment->getContent()), $body); } return $body; diff --git a/src/Support/MessageCollection.php b/src/Support/MessageCollection.php index 6d1249fa..1ca97a70 100644 --- a/src/Support/MessageCollection.php +++ b/src/Support/MessageCollection.php @@ -12,11 +12,15 @@ namespace Webklex\PHPIMAP\Support; +use Illuminate\Support\Collection; +use Webklex\PHPIMAP\Message; + /** * Class MessageCollection * * @package Webklex\PHPIMAP\Support + * @implements Collection */ class MessageCollection extends PaginatedCollection { -} \ No newline at end of file +} diff --git a/src/Support/PaginatedCollection.php b/src/Support/PaginatedCollection.php index 49ebca3c..3b3470e9 100644 --- a/src/Support/PaginatedCollection.php +++ b/src/Support/PaginatedCollection.php @@ -23,24 +23,28 @@ */ class PaginatedCollection extends Collection { - /** @var int $total */ - protected $total; + /** + * Number of total entries + * + * @var int $total + */ + protected int $total = 0; /** * Paginate the current collection. - * - * @param int $per_page + * @param int $per_page * @param int|null $page - * @param string $page_name + * @param string $page_name + * @param boolean $prepaginated * * @return LengthAwarePaginator */ - public function paginate($per_page = 15, $page = null, $page_name = 'page') { + public function paginate(int $per_page = 15, ?int $page = null, string $page_name = 'page', bool $prepaginated = false): LengthAwarePaginator { $page = $page ?: Paginator::resolveCurrentPage($page_name); - $total = $this->total ? $this->total : $this->count(); + $total = $this->total ?: $this->count(); - $results = $total ? $this->forPage($page, $per_page) : $this->all(); + $results = !$prepaginated && $total ? $this->forPage($page, $per_page)->toArray() : $this->all(); return $this->paginator($results, $total, $per_page, $page, [ 'path' => Paginator::resolveCurrentPath(), @@ -50,29 +54,29 @@ public function paginate($per_page = 15, $page = null, $page_name = 'page') { /** * Create a new length-aware paginator instance. - * - * @param array $items - * @param int $total - * @param int $per_page - * @param int|null $current_page + * @param array $items + * @param int $total + * @param int $per_page + * @param int|null $current_page * @param array $options * * @return LengthAwarePaginator */ - protected function paginator($items, $total, $per_page, $current_page, array $options) { + protected function paginator(array $items, int $total, int $per_page, ?int $current_page, array $options): LengthAwarePaginator { return new LengthAwarePaginator($items, $total, $per_page, $current_page, $options); } /** + * Get and set the total amount * @param null $total * * @return int|null */ - public function total($total = null) { + public function total($total = null): ?int { if($total === null) { return $this->total; } return $this->total = $total; } -} \ No newline at end of file +} diff --git a/src/Traits/HasEvents.php b/src/Traits/HasEvents.php new file mode 100644 index 00000000..6dc382b7 --- /dev/null +++ b/src/Traits/HasEvents.php @@ -0,0 +1,86 @@ +events[$section])) { + $this->events[$section][$event] = $class; + } + } + + /** + * Set all events + * @param array $events + */ + public function setEvents(array $events): void { + $this->events = $events; + } + + /** + * Get a specific event callback + * @param string $section + * @param string $event + * + * @return Event|string + * @throws EventNotFoundException + */ + public function getEvent(string $section, string $event): Event|string { + if (isset($this->events[$section])) { + return $this->events[$section][$event]; + } + throw new EventNotFoundException(); + } + + /** + * Get all events + * + * @return array + */ + public function getEvents(): array { + return $this->events; + } + + /** + * Dispatch a specific event. + * @throws EventNotFoundException + */ + public function dispatch(string $section, string $event, mixed ...$args): void { + $event = $this->getEvent($section, $event); + $event::dispatch(...$args); + } + +} \ No newline at end of file diff --git a/src/config/imap.php b/src/config/imap.php index 6d97f561..abf8e6e6 100644 --- a/src/config/imap.php +++ b/src/config/imap.php @@ -14,7 +14,18 @@ /* |-------------------------------------------------------------------------- - | IMAP default account + | Default date format + |-------------------------------------------------------------------------- + | + | The default date format is used to convert any given Carbon::class object into a valid date string. + | These are currently known working formats: "d-M-Y", "d-M-y", "d M y" + | + */ + 'date_format' => 'd-M-Y', + + /* + |-------------------------------------------------------------------------- + | Default account |-------------------------------------------------------------------------- | | The default account identifier. It will be used as default for any missing account parameters. @@ -26,18 +37,33 @@ /* |-------------------------------------------------------------------------- - | Default date format + | Security options |-------------------------------------------------------------------------- | - | The default date format is used to convert any given Carbon::class object into a valid date string. - | These are currently known working formats: "d-M-Y", "d-M-y", "d M y" + | You can enable or disable certain security features here by setting them to true or false to enable or disable + | them. + | -detect_spoofing: + | Detect spoofing attempts by checking the message sender against the message headers. + | Default TRUE + | -detect_spoofing_exception: + | Throw an exception if a spoofing attempt is detected. + | Default FALSE + | -sanitize_filenames: + | Sanitize attachment filenames by removing any unwanted and potentially dangerous characters. This is not a + | 100% secure solution, but it should help to prevent some common attacks. Please sanitize the filenames + | again if you need a more secure solution. + | Default TRUE | */ - 'date_format' => 'd-M-Y', + 'security' => [ + "detect_spoofing" => true, + "detect_spoofing_exception" => false, + "sanitize_filenames" => true, + ], /* |-------------------------------------------------------------------------- - | Available IMAP accounts + | Available accounts |-------------------------------------------------------------------------- | | Please list all IMAP accounts which you are planning to use within the @@ -54,6 +80,16 @@ 'validate_cert' => true, 'username' => 'root@example.com', 'password' => '', + 'authentication' => null, + 'rfc' => 'RFC822', // If you are using iCloud, you might want to set this to 'BODY' + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + "timeout" => 30, + "extensions" => [] ], /* @@ -64,6 +100,7 @@ 'validate_cert' => true, 'username' => 'example@gmail.com', 'password' => 'PASSWORD', + 'authentication' => 'oauth', ], 'another' => [ // account identifier @@ -73,6 +110,7 @@ 'validate_cert' => true, 'username' => '', 'password' => '', + 'authentication' => null, ] */ ], @@ -87,54 +125,135 @@ | This option is only used when calling $oClient-> | You can use any supported char such as ".", "/", (...) | -Fetch option: - | IMAP::FT_UID - Message marked as read by fetching the message - | IMAP::FT_PEEK - Fetch the message without setting the "read" flag + | IMAP::FT_UID - Message marked as read by fetching the body message + | IMAP::FT_PEEK - Fetch the message without setting the "seen" flag + | -Fetch sequence id: + | IMAP::ST_UID - Fetch message components using the message uid + | IMAP::ST_MSGN - Fetch message components using the message number | -Body download option | Default TRUE - | -Attachment download option - | Default TRUE | -Flag download option | Default TRUE + | -Soft fail + | Default FALSE - Set to TRUE if you want to ignore certain exception while fetching bulk messages + | -RFC822 + | Default TRUE - Set to FALSE to prevent the usage of \imap_rfc822_parse_headers(). + | See https://github.com/Webklex/php-imap/issues/115 for more information. + | -Debug enable to trace communication traffic + | -UID cache enable the UID cache + | -Fallback date is used if the given message date could not be parsed + | -Boundary regex used to detect message boundaries. If you are having problems with empty messages, missing + | attachments or anything like this. Be advised that it likes to break which causes new problems.. | -Message key identifier option - | You can choose between 'id', 'number' or 'list' + | You can choose between the following: | 'id' - Use the MessageID as array key (default, might cause hickups with yahoo mail) | 'number' - Use the message number as array key (isn't always unique and can cause some interesting behavior) | 'list' - Use the message list number as array key (incrementing integer (does not always start at 0 or 1) + | 'uid' - Use the message uid as array key (isn't always unique and can cause some interesting behavior) | -Fetch order | 'asc' - Order all messages ascending (probably results in oldest first) | 'desc' - Order all messages descending (probably results in newest first) + | -Disposition types potentially considered an attachment + | Default ['attachment', 'inline'] + | -Common folders + | Default folder locations and paths assumed if none is provided | -Open IMAP options: | DISABLE_AUTHENTICATOR - Disable authentication properties. | Use 'GSSAPI' if you encounter the following | error: "Kerberos error: No credentials cache | file found (try running kinit) (...)" | or ['GSSAPI','PLAIN'] if you are using outlook mail - | -Decoder options (currently only the message subject and attachment name decoder can be set) - | 'utf-8' - Uses imap_utf8($string) to decode a string - | 'mimeheader' - Uses mb_decode_mimeheader($string) to decode a string | */ 'options' => [ 'delimiter' => '/', - 'fetch' => \Webklex\PHPIMAP\IMAP::FT_UID, + 'fetch' => \Webklex\PHPIMAP\IMAP::FT_PEEK, + 'sequence' => \Webklex\PHPIMAP\IMAP::ST_UID, 'fetch_body' => true, - 'fetch_attachment' => true, 'fetch_flags' => true, - 'message_key' => 'id', + 'soft_fail' => false, + 'rfc822' => true, + 'debug' => false, + 'unescaped_search_dates' => false, + 'uid_cache' => true, + // 'fallback_date' => "01.01.1970 00:00:00", + 'boundary' => '/boundary=(.*?(?=;)|(.*))/i', + 'message_key' => 'list', 'fetch_order' => 'asc', + 'dispositions' => ['attachment', 'inline'], + 'common_folders' => [ + "root" => "INBOX", + "junk" => "INBOX/Junk", + "draft" => "INBOX/Drafts", + "sent" => "INBOX/Sent", + "trash" => "INBOX/Trash", + ], 'open' => [ // 'DISABLE_AUTHENTICATOR' => 'GSSAPI' + ] + ], + + /** + * |-------------------------------------------------------------------------- + * | Available decoding options + * |-------------------------------------------------------------------------- + * | + * | Available php imap config parameters are listed below + * | -options: Decoder options (currently only the message subject and attachment name decoder can be set) + * | 'utf-8' - Uses imap_utf8($string) to decode a string + * | 'mimeheader' - Uses mb_decode_mimeheader($string) to decode a string + * | -decoder: Decoder to be used. Can be replaced by custom decoders if needed. + * | 'header' - HeaderDecoder + * | 'message' - MessageDecoder + * | 'attachment' - AttachmentDecoder + */ + 'decoding' => [ + 'options' => [ + 'header' => 'utf-8', // mimeheader + 'message' => 'utf-8', // mimeheader + 'attachment' => 'utf-8' // mimeheader ], 'decoder' => [ - 'message' => [ - 'subject' => 'utf-8' // mimeheader - ], - 'attachment' => [ - 'name' => 'utf-8' // mimeheader - ] + 'header' => \Webklex\PHPIMAP\Decoder\HeaderDecoder::class, + 'message' => \Webklex\PHPIMAP\Decoder\MessageDecoder::class, + 'attachment' => \Webklex\PHPIMAP\Decoder\AttachmentDecoder::class ] ], + /* + |-------------------------------------------------------------------------- + | Available flags + |-------------------------------------------------------------------------- + | + | List all available / supported flags. Set to null to accept all given flags. + */ + 'flags' => ['recent', 'flagged', 'answered', 'deleted', 'seen', 'draft'], + + /* + |-------------------------------------------------------------------------- + | Available events + |-------------------------------------------------------------------------- + | + */ + 'events' => [ + "message" => [ + 'new' => \Webklex\PHPIMAP\Events\MessageNewEvent::class, + 'moved' => \Webklex\PHPIMAP\Events\MessageMovedEvent::class, + 'copied' => \Webklex\PHPIMAP\Events\MessageCopiedEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\MessageDeletedEvent::class, + 'restored' => \Webklex\PHPIMAP\Events\MessageRestoredEvent::class, + ], + "folder" => [ + 'new' => \Webklex\PHPIMAP\Events\FolderNewEvent::class, + 'moved' => \Webklex\PHPIMAP\Events\FolderMovedEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\FolderDeletedEvent::class, + ], + "flag" => [ + 'new' => \Webklex\PHPIMAP\Events\FlagNewEvent::class, + 'deleted' => \Webklex\PHPIMAP\Events\FlagDeletedEvent::class, + ], + ], + /* |-------------------------------------------------------------------------- | Available masking options diff --git a/tests/AddressTest.php b/tests/AddressTest.php new file mode 100644 index 00000000..442462fb --- /dev/null +++ b/tests/AddressTest.php @@ -0,0 +1,72 @@ + "Username", + "mailbox" => "info", + "host" => "domain.tld", + "mail" => "info@domain.tld", + "full" => "Username ", + ]; + + /** + * Address test + * + * @return void + */ + public function testAddress(): void { + $address = new Address((object)$this->data); + + self::assertSame("Username", $address->personal); + self::assertSame("info", $address->mailbox); + self::assertSame("domain.tld", $address->host); + self::assertSame("info@domain.tld", $address->mail); + self::assertSame("Username ", $address->full); + } + + /** + * Test Address to string conversion + * + * @return void + */ + public function testAddressToStringConversion(): void { + $address = new Address((object)$this->data); + + self::assertSame("Username ", (string)$address); + } + + /** + * Test Address serialization + * + * @return void + */ + public function testAddressSerialization(): void { + $address = new Address((object)$this->data); + + foreach($address as $key => $value) { + self::assertSame($this->data[$key], $value); + } + + } +} \ No newline at end of file diff --git a/tests/AttachmentTest.php b/tests/AttachmentTest.php new file mode 100644 index 00000000..c9ba2f17 --- /dev/null +++ b/tests/AttachmentTest.php @@ -0,0 +1,37 @@ +getFixture("attachment_encoded_filename.eml"); + $this->attachment = $message->getAttachments()->first(); + } + /** + * @dataProvider decodeNameDataProvider + */ + public function testDecodeName(string $input, string $output): void + { + $name = $this->attachment->decodeName($input); + $this->assertEquals($output, $name); + } + + public function decodeNameDataProvider(): array + { + return [ + ['../../../../../../../../../../../var/www/shell.php', 'varwwwshell.php'], + ['test..xml', 'test.xml'], + [chr(0), ''], + ['C:\\file.txt', 'Cfile.txt'], + ]; + } +} diff --git a/tests/AttributeTest.php b/tests/AttributeTest.php new file mode 100644 index 00000000..8374894f --- /dev/null +++ b/tests/AttributeTest.php @@ -0,0 +1,75 @@ +toString()); + self::assertSame("foo", $attribute->getName()); + self::assertSame("foos", $attribute->setName("foos")->getName()); + } + + /** + * Date Attribute test + * + * @return void + */ + public function testDateAttribute(): void { + $attribute = new Attribute("foo", "2022-12-26 08:07:14 GMT-0800"); + + self::assertInstanceOf(Carbon::class, $attribute->toDate()); + self::assertSame("2022-12-26 08:07:14 GMT-0800", $attribute->toDate()->format("Y-m-d H:i:s T")); + } + + /** + * Array Attribute test + * + * @return void + */ + public function testArrayAttribute(): void { + $attribute = new Attribute("foo", ["bar"]); + + self::assertSame("bar", $attribute->toString()); + + $attribute->add("bars"); + self::assertSame(true, $attribute->has(1)); + self::assertSame("bars", $attribute->get(1)); + self::assertSame(true, $attribute->contains("bars")); + self::assertSame("foo, bars", $attribute->set("foo", 0)->toString()); + + $attribute->remove(0); + self::assertSame("bars", $attribute->toString()); + + self::assertSame("bars, foos", $attribute->merge(["foos", "bars"], true)->toString()); + self::assertSame("bars, foos, foos, donk", $attribute->merge(["foos", "donk"], false)->toString()); + + self::assertSame(4, $attribute->count()); + + self::assertSame("donk", $attribute->last()); + self::assertSame("bars", $attribute->first()); + + self::assertSame(["bars", "foos", "foos", "donk"], array_values($attribute->all())); + } +} \ No newline at end of file diff --git a/tests/ClientManagerTest.php b/tests/ClientManagerTest.php new file mode 100644 index 00000000..e7910cf1 --- /dev/null +++ b/tests/ClientManagerTest.php @@ -0,0 +1,94 @@ +cm = new ClientManager(); + } + + /** + * Test if the config can be accessed + * + * @return void + */ + public function testConfigAccessorAccount(): void { + $config = $this->cm->getConfig(); + self::assertInstanceOf(Config::class, $config); + self::assertSame("default", $config->get("default")); + self::assertSame("d-M-Y", $config->get("date_format")); + self::assertSame(IMAP::FT_PEEK, $config->get("options.fetch")); + self::assertSame([], $config->get("options.open")); + } + + /** + * Test creating a client instance + * + * @throws MaskNotFoundException + */ + public function testMakeClient(): void { + self::assertInstanceOf(Client::class, $this->cm->make([])); + } + + /** + * Test accessing accounts + * + * @throws MaskNotFoundException + */ + public function testAccountAccessor(): void { + self::assertSame("default", $this->cm->getConfig()->getDefaultAccount()); + self::assertNotEmpty($this->cm->account("default")); + + $this->cm->getConfig()->setDefaultAccount("foo"); + self::assertSame("foo", $this->cm->getConfig()->getDefaultAccount()); + $this->cm->getConfig()->setDefaultAccount("default"); + } + + /** + * Test setting a config + * + * @throws MaskNotFoundException + */ + public function testSetConfig(): void { + $config = [ + "default" => "foo", + "options" => [ + "fetch" => IMAP::ST_MSGN, + "open" => "foo" + ] + ]; + $cm = new ClientManager($config); + + self::assertSame("foo", $cm->getConfig()->getDefaultAccount()); + self::assertInstanceOf(Client::class, $cm->account("foo")); + self::assertSame(IMAP::ST_MSGN, $cm->getConfig()->get("options.fetch")); + self::assertSame(false, is_array($cm->getConfig()->get("options.open"))); + + } +} \ No newline at end of file diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 00000000..73a01be7 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,348 @@ + [ + "default" => [ + 'protocol' => 'imap', + 'encryption' => 'ssl', + 'username' => 'foo@domain.tld', + 'password' => 'bar', + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + ]] + ]); + $this->client = new Client($config); + } + + /** + * Client test + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws RuntimeException + */ + public function testClient(): void { + $this->createNewProtocolMockup(); + + self::assertInstanceOf(ImapProtocol::class, $this->client->getConnection()); + self::assertSame(true, $this->client->isConnected()); + self::assertSame(false, $this->client->checkConnection()); + self::assertSame(30, $this->client->getTimeout()); + self::assertSame(MessageMask::class, $this->client->getDefaultMessageMask()); + self::assertSame(AttachmentMask::class, $this->client->getDefaultAttachmentMask()); + self::assertArrayHasKey("new", $this->client->getDefaultEvents("message")); + } + + /** + * @throws MaskNotFoundException + */ + public function testClientClone(): void { + $config = Config::make([ + "accounts" => [ + "default" => [ + 'host' => 'example.com', + 'port' => 993, + 'protocol' => 'imap', //might also use imap, [pop3 or nntp (untested)] + 'encryption' => 'ssl', // Supported: false, 'ssl', 'tls' + 'validate_cert' => true, + 'username' => 'root@example.com', + 'password' => 'foo', + 'authentication' => null, + 'rfc' => 'RFC822', // If you are using iCloud, you might want to set this to 'BODY' + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + "timeout" => 30, + "extensions" => [] + ]] + ]); + $client = new Client($config); + $clone = $client->clone(); + self::assertInstanceOf(Client::class, $clone); + self::assertSame($client->getConfig(), $clone->getConfig()); + self::assertSame($client->getAccountConfig(), $clone->getAccountConfig()); + self::assertSame($client->host, $clone->host); + } + + public function testClientLogout(): void { + $this->createNewProtocolMockup(); + + $this->protocol->expects($this->any())->method('logout')->willReturn(Response::empty()->setResponse([ + 0 => "BYE Logging out\r\n", + 1 => "OK Logout completed (0.001 + 0.000 secs).\r\n", + ])); + self::assertInstanceOf(Client::class, $this->client->disconnect()); + + } + + public function testClientExpunge(): void { + $this->createNewProtocolMockup(); + $this->protocol->expects($this->any())->method('expunge')->willReturn(Response::empty()->setResponse([ + 0 => "OK", + 1 => "Expunge", + 2 => "completed", + 3 => [ + 0 => "0.001", + 1 => "+", + 2 => "0.000", + 3 => "secs).", + ], + ])); + self::assertNotEmpty($this->client->expunge()); + + } + + public function testClientFolders(): void { + $this->createNewProtocolMockup(); + $this->protocol->expects($this->any())->method('expunge')->willReturn(Response::empty()->setResponse([ + 0 => "OK", + 1 => "Expunge", + 2 => "completed", + 3 => [ + 0 => "0.001", + 1 => "+", + 2 => "0.000", + 3 => "secs).", + ], + ])); + + $this->protocol->expects($this->any())->method('selectFolder')->willReturn(Response::empty()->setResponse([ + "flags" => [ + 0 => [ + 0 => "\Answered", + 1 => "\Flagged", + 2 => "\Deleted", + 3 => "\Seen", + 4 => "\Draft", + 5 => "NonJunk", + 6 => "unknown-1", + ], + ], + "exists" => 139, + "recent" => 0, + "unseen" => 94, + "uidvalidity" => 1488899637, + "uidnext" => 278, + ])); + self::assertNotEmpty($this->client->openFolder("INBOX")); + self::assertSame("INBOX", $this->client->getFolderPath()); + + $this->protocol->expects($this->any())->method('examineFolder')->willReturn(Response::empty()->setResponse([ + "flags" => [ + 0 => [ + 0 => "\Answered", + 1 => "\Flagged", + 2 => "\Deleted", + 3 => "\Seen", + 4 => "\Draft", + 5 => "NonJunk", + 6 => "unknown-1", + ], + ], + "exists" => 139, + "recent" => 0, + "unseen" => 94, + "uidvalidity" => 1488899637, + "uidnext" => 278, + ])); + self::assertNotEmpty($this->client->checkFolder("INBOX")); + + $this->protocol->expects($this->any())->method('folders')->with($this->identicalTo(""), $this->identicalTo("*"))->willReturn(Response::empty()->setResponse([ + "INBOX" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasChildren", + ], + ], + "INBOX.new" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.9AL56dEMTTgUKOAz" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.U9PsHCvXxAffYvie" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.Trash" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + 1 => "\Trash", + ], + ], + "INBOX.processing" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.Sent" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + 1 => "\Sent", + ], + ], + "INBOX.OzDWCXKV3t241koc" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.5F3bIVTtBcJEqIVe" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.8J3rll6eOBWnTxIU" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + "INBOX.Junk" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + 1 => "\Junk", + ], + ], + "INBOX.Drafts" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + 1 => "\Drafts", + ], + ], + "INBOX.test" => [ + "delimiter" => ".", + "flags" => [ + 0 => "\HasNoChildren", + ], + ], + ])); + + $this->protocol->expects($this->any())->method('createFolder')->willReturn(Response::empty()->setResponse([ + 0 => "OK Create completed (0.004 + 0.000 + 0.003 secs).\r\n", + ])); + self::assertNotEmpty($this->client->createFolder("INBOX.new")); + + $this->protocol->expects($this->any())->method('deleteFolder')->willReturn(Response::empty()->setResponse([ + 0 => "OK Delete completed (0.007 + 0.000 + 0.006 secs).\r\n", + ])); + self::assertNotEmpty($this->client->deleteFolder("INBOX.new")); + + self::assertInstanceOf(Folder::class, $this->client->getFolderByPath("INBOX.new")); + self::assertInstanceOf(Folder::class, $this->client->getFolderByName("new")); + self::assertInstanceOf(Folder::class, $this->client->getFolder("INBOX.new", ".")); + self::assertInstanceOf(Folder::class, $this->client->getFolder("new")); + } + + public function testClientId(): void { + $this->createNewProtocolMockup(); + $this->protocol->expects($this->any())->method('ID')->willReturn(Response::empty()->setResponse([ + 0 => "ID (\"name\" \"Dovecot\")\r\n", + 1 => "OK ID completed (0.001 + 0.000 secs).\r\n" + + ])); + self::assertSame("ID (\"name\" \"Dovecot\")\r\n", $this->client->Id()[0]); + + } + + public function testClientConfig(): void { + $config = $this->client->getConfig()->get("accounts.".$this->client->getConfig()->getDefaultAccount()); + self::assertSame("foo@domain.tld", $config["username"]); + self::assertSame("bar", $config["password"]); + self::assertSame("localhost", $config["host"]); + self::assertSame(true, $config["validate_cert"]); + self::assertSame(993, $config["port"]); + + $this->client->getConfig()->set("accounts.".$this->client->getConfig()->getDefaultAccount(), [ + "host" => "domain.tld", + 'password' => 'bar', + ]); + $config = $this->client->getConfig()->get("accounts.".$this->client->getConfig()->getDefaultAccount()); + + self::assertSame("bar", $config["password"]); + self::assertSame("domain.tld", $config["host"]); + self::assertSame(true, $config["validate_cert"]); + } + + protected function createNewProtocolMockup() { + $this->protocol = $this->createMock(ImapProtocol::class); + + $this->protocol->expects($this->any())->method('connected')->willReturn(true); + $this->protocol->expects($this->any())->method('getConnectionTimeout')->willReturn(30); + + $this->protocol + ->expects($this->any()) + ->method('createStream') + //->will($this->onConsecutiveCalls(true)); + ->willReturn(true); + + $this->client->connection = $this->protocol; + } +} \ No newline at end of file diff --git a/tests/HeaderTest.php b/tests/HeaderTest.php new file mode 100644 index 00000000..0ec60c6c --- /dev/null +++ b/tests/HeaderTest.php @@ -0,0 +1,204 @@ +config = Config::make(); + } + + /** + * Test parsing email headers + * + * @throws InvalidMessageDateException + */ + public function testHeaderParsing(): void { + $email = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "1366671050@github.com.eml"])); + if (!str_contains($email, "\r\n")) { + $email = str_replace("\n", "\r\n", $email); + } + + $raw_header = substr($email, 0, strpos($email, "\r\n\r\n")); + + $header = new Header($raw_header, $this->config); + $subject = $header->get("subject"); + $returnPath = $header->get("return_path"); + /** @var Carbon $date */ + $date = $header->get("date")->first(); + /** @var Address $from */ + $from = $header->get("from")->first(); + /** @var Address $to */ + $to = $header->get("to")->first(); + + self::assertSame($raw_header, $header->raw); + self::assertSame([ + 0 => 'from mx.domain.tld by localhost with LMTP id SABVMNfGqWP+PAAA0J78UA (envelope-from ) for ; Mon, 26 Dec 2022 17:07:51 +0100', + 1 => 'from localhost (localhost [127.0.0.1]) by mx.domain.tld (Postfix) with ESMTP id C3828140227 for ; Mon, 26 Dec 2022 17:07:51 +0100 (CET)', + 2 => 'from mx.domain.tld ([127.0.0.1]) by localhost (mx.domain.tld [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id JcIS9RuNBTNx for ; Mon, 26 Dec 2022 17:07:21 +0100 (CET)', + 3 => 'from smtp.github.com (out-26.smtp.github.com [192.30.252.209]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by mx.domain.tld (Postfix) with ESMTPS id 6410B13FEB2 for ; Mon, 26 Dec 2022 17:07:21 +0100 (CET)', + 4 => 'from github-lowworker-891b8d2.va3-iad.github.net (github-lowworker-891b8d2.va3-iad.github.net [10.48.109.104]) by smtp.github.com (Postfix) with ESMTP id 176985E0200 for ; Mon, 26 Dec 2022 08:07:14 -0800 (PST)', + ], $header->get("received")->toArray()); + self::assertInstanceOf(Attribute::class, $subject); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", $subject->toString()); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", (string)$header->subject); + self::assertSame("noreply@github.com", $returnPath->toString()); + self::assertSame("return_path", $returnPath->getName()); + self::assertSame("-4.299", (string)$header->get("X-Spam-Score")); + self::assertSame("Webklex/php-imap/issues/349/1365266070@github.com", (string)$header->get("Message-ID")); + self::assertSame(5, $header->get("received")->count()); + self::assertSame(IMAP::MESSAGE_PRIORITY_UNKNOWN, (int)$header->get("priority")()); + + self::assertSame("Username", $from->personal); + self::assertSame("notifications", $from->mailbox); + self::assertSame("github.com", $from->host); + self::assertSame("notifications@github.com", $from->mail); + self::assertSame("Username ", $from->full); + + self::assertSame("Webklex/php-imap", $to->personal); + self::assertSame("php-imap", $to->mailbox); + self::assertSame("noreply.github.com", $to->host); + self::assertSame("php-imap@noreply.github.com", $to->mail); + self::assertSame("Webklex/php-imap ", $to->full); + + self::assertInstanceOf(Carbon::class, $date); + self::assertSame("2022-12-26 08:07:14 GMT-0800", $date->format("Y-m-d H:i:s T")); + + self::assertSame(51, count($header->getAttributes())); + } + + public function testRfc822ParseHeaders() { + $mock = $this->getMockBuilder(Header::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $config = new \ReflectionProperty($mock, 'options'); + $config->setAccessible(true); + $config->setValue($mock, $this->config->get("options")); + + $mockHeader = "Content-Type: text/csv; charset=WINDOWS-1252; name*0=\"TH_Is_a_F ile name example 20221013.c\"; name*1=sv\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Disposition: attachment; filename*0=\"TH_Is_a_F ile name example 20221013.c\"; filename*1=\"sv\"\r\n"; + + $expected = new \stdClass(); + $expected->content_type = 'text/csv; charset=WINDOWS-1252; name*0="TH_Is_a_F ile name example 20221013.c"; name*1=sv'; + $expected->content_transfer_encoding = 'quoted-printable'; + $expected->content_disposition = 'attachment; filename*0="TH_Is_a_F ile name example 20221013.c"; filename*1="sv"'; + + $this->assertEquals($expected, $mock->rfc822_parse_headers($mockHeader)); + } + + public function testExtractHeaderExtensions() { + $mock = $this->getMockBuilder(Header::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $method = new \ReflectionMethod($mock, 'extractHeaderExtensions'); + $method->setAccessible(true); + + $mockAttributes = [ + 'content_type' => new Attribute('content_type', 'text/csv; charset=WINDOWS-1252; name*0="TH_Is_a_F ile name example 20221013.c"; name*1=sv'), + 'content_transfer_encoding' => new Attribute('content_transfer_encoding', 'quoted-printable'), + 'content_disposition' => new Attribute('content_disposition', 'attachment; filename*0="TH_Is_a_F ile name example 20221013.c"; filename*1="sv"; attribute_test=attribute_test_value'), + ]; + + $attributes = new \ReflectionProperty($mock, 'attributes'); + $attributes->setAccessible(true); + $attributes->setValue($mock, $mockAttributes); + + $method->invoke($mock); + + $this->assertArrayHasKey('filename', $mock->getAttributes()); + $this->assertArrayNotHasKey('filename*0', $mock->getAttributes()); + $this->assertEquals('TH_Is_a_F ile name example 20221013.csv', $mock->get('filename')); + + $this->assertArrayHasKey('name', $mock->getAttributes()); + $this->assertArrayNotHasKey('name*0', $mock->getAttributes()); + $this->assertEquals('TH_Is_a_F ile name example 20221013.csv', $mock->get('name')); + + $this->assertArrayHasKey('content_type', $mock->getAttributes()); + $this->assertEquals('text/csv', $mock->get('content_type')->last()); + + $this->assertArrayHasKey('charset', $mock->getAttributes()); + $this->assertEquals('WINDOWS-1252', $mock->get('charset')->last()); + + $this->assertArrayHasKey('content_transfer_encoding', $mock->getAttributes()); + $this->assertEquals('quoted-printable', $mock->get('content_transfer_encoding')); + + $this->assertArrayHasKey('content_disposition', $mock->getAttributes()); + $this->assertEquals('attachment', $mock->get('content_disposition')->last()); + $this->assertEquals('quoted-printable', $mock->get('content_transfer_encoding')); + + $this->assertArrayHasKey('attribute_test', $mock->getAttributes()); + $this->assertEquals('attribute_test_value', $mock->get('attribute_test')); + } + + public function testExtractHeaderExtensions2() { + $mock = $this->getMockBuilder(Header::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $method = new \ReflectionMethod($mock, 'extractHeaderExtensions'); + $method->setAccessible(true); + + $mockAttributes = [ + 'content_type' => new Attribute('content_type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; name="=?utf-8?Q?=D0=A2=D0=B8=D0=BF=D0=BE=D0=B2=D0=BE=D0=B9_?= =?utf-8?Q?=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82_=D0=BF?= =?utf-8?Q?=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=BB=D0=B5=D0=BD?= =?utf-8?Q?=D0=B8=D1=8F_=D1=8D=D0=BB=D0=B5=D0=BA=D1=82?= =?utf-8?Q?=D1=80=D0=BE=D1=8D=D0=BD=D0=B5=D1=80=D0=B3=D0=B8=D0=B8_=D0=B2_?= =?utf-8?Q?=D0=9A=D0=9F_=D0=97=D0=B2=D0=B5=D0=B7=D0=B4?= =?utf-8?Q?=D0=BD=D1=8B=D0=B9=2Exlsx?="'), + 'content_transfer_encoding' => new Attribute('content_transfer_encoding', 'base64'), + 'content_disposition' => new Attribute('content_disposition', 'attachment; name*0*=utf-8\'\'%D0%A2%D0%B8%D0%BF%D0%BE%D0%B2%D0%BE%D0%B9%20; name*1*=%D1%80%D0%B0%D1%81%D1%87%D0%B5%D1%82%20%D0%BF; name*2*=%D0%BE%D1%82%D1%80%D0%B5%D0%B1%D0%BB%D0%B5%D0%BD; name*3*=%D0%B8%D1%8F%20%D1%8D%D0%BB%D0%B5%D0%BA%D1%82; name*4*=%D1%80%D0%BE%D1%8D%D0%BD%D0%B5%D1%80%D0%B3%D0%B8; name*5*=%D0%B8%20%D0%B2%20%D0%9A%D0%9F%20%D0%97%D0%B2%D0%B5%D0%B7%D0%B4; name*6*=%D0%BD%D1%8B%D0%B9.xlsx; filename*0*=utf-8\'\'%D0%A2%D0%B8%D0%BF%D0%BE%D0%B2%D0%BE%D0%B9%20; filename*1*=%D1%80%D0%B0%D1%81%D1%87%D0%B5%D1%82%20%D0%BF; filename*2*=%D0%BE%D1%82%D1%80%D0%B5%D0%B1%D0%BB%D0%B5%D0%BD; filename*3*=%D0%B8%D1%8F%20%D1%8D%D0%BB%D0%B5%D0%BA%D1%82; filename*4*=%D1%80%D0%BE%D1%8D%D0%BD%D0%B5%D1%80%D0%B3%D0%B8; filename*5*=%D0%B8%20%D0%B2%20%D0%9A%D0%9F%20%D0%97; filename*6*=%D0%B2%D0%B5%D0%B7%D0%B4%D0%BD%D1%8B%D0%B9.xlsx; attribute_test=attribute_test_value'), + ]; + + $attributes = new \ReflectionProperty($mock, 'attributes'); + $attributes->setAccessible(true); + $attributes->setValue($mock, $mockAttributes); + + $method->invoke($mock); + + $this->assertArrayHasKey('filename', $mock->getAttributes()); + $this->assertArrayNotHasKey('filename*0', $mock->getAttributes()); + $this->assertEquals('utf-8\'\'%D0%A2%D0%B8%D0%BF%D0%BE%D0%B2%D0%BE%D0%B9%20%D1%80%D0%B0%D1%81%D1%87%D0%B5%D1%82%20%D0%BF%D0%BE%D1%82%D1%80%D0%B5%D0%B1%D0%BB%D0%B5%D0%BD%D0%B8%D1%8F%20%D1%8D%D0%BB%D0%B5%D0%BA%D1%82%D1%80%D0%BE%D1%8D%D0%BD%D0%B5%D1%80%D0%B3%D0%B8%D0%B8%20%D0%B2%20%D0%9A%D0%9F%20%D0%97%D0%B2%D0%B5%D0%B7%D0%B4%D0%BD%D1%8B%D0%B9.xlsx', $mock->get('filename')); + + $this->assertArrayHasKey('name', $mock->getAttributes()); + $this->assertArrayNotHasKey('name*0', $mock->getAttributes()); + $this->assertEquals('=?utf-8?Q?=D0=A2=D0=B8=D0=BF=D0=BE=D0=B2=D0=BE=D0=B9_?= =?utf-8?Q?=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82_=D0=BF?= =?utf-8?Q?=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=BB=D0=B5=D0=BD?= =?utf-8?Q?=D0=B8=D1=8F_=D1=8D=D0=BB=D0=B5=D0=BA=D1=82?= =?utf-8?Q?=D1=80=D0=BE=D1=8D=D0=BD=D0=B5=D1=80=D0=B3=D0=B8=D0=B8_=D0=B2_?= =?utf-8?Q?=D0=9A=D0=9F_=D0=97=D0=B2=D0=B5=D0=B7=D0=B4?= =?utf-8?Q?=D0=BD=D1=8B=D0=B9=2Exlsx?=', $mock->get('name')); + + $this->assertArrayHasKey('content_type', $mock->getAttributes()); + $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $mock->get('content_type')->last()); + + $this->assertArrayHasKey('content_transfer_encoding', $mock->getAttributes()); + $this->assertEquals('base64', $mock->get('content_transfer_encoding')); + + $this->assertArrayHasKey('content_disposition', $mock->getAttributes()); + $this->assertEquals('attachment', $mock->get('content_disposition')->last()); + + $this->assertArrayHasKey('attribute_test', $mock->getAttributes()); + $this->assertEquals('attribute_test_value', $mock->get('attribute_test')); + } +} \ No newline at end of file diff --git a/tests/ImapProtocolTest.php b/tests/ImapProtocolTest.php new file mode 100644 index 00000000..30f3a200 --- /dev/null +++ b/tests/ImapProtocolTest.php @@ -0,0 +1,64 @@ +config = Config::make(); + } + + + /** + * ImapProtocol test + * + * @return void + */ + public function testImapProtocol(): void { + + $protocol = new ImapProtocol($this->config, false); + self::assertSame(false, $protocol->getCertValidation()); + self::assertSame("", $protocol->getEncryption()); + + $protocol->setCertValidation(true); + $protocol->setEncryption("ssl"); + + self::assertSame(true, $protocol->getCertValidation()); + self::assertSame("ssl", $protocol->getEncryption()); + + $protocol->setSslOptions([ + 'verify_peer' => true, + 'cafile' => '/dummy/path/for/testing', + 'peer_fingerprint' => ['md5' => 40], + ]); + + self::assertSame([ + 'verify_peer' => true, + 'cafile' => '/dummy/path/for/testing', + 'peer_fingerprint' => ['md5' => 40], + ], $protocol->getSslOptions()); + } +} \ No newline at end of file diff --git a/tests/MessageTest.php b/tests/MessageTest.php new file mode 100644 index 00000000..0ce55cd0 --- /dev/null +++ b/tests/MessageTest.php @@ -0,0 +1,302 @@ + [ + "default" => [ + 'protocol' => 'imap', + 'encryption' => 'ssl', + 'username' => 'foo@domain.tld', + 'password' => 'bar', + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + ]] + ]); + $this->client = new Client($config); + } + + /** + * Message test + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageNotFoundException + * @throws MessageSizeFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public function testMessage(): void { + $this->createNewProtocolMockup(); + + $email = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "1366671050@github.com.eml"])); + if(!str_contains($email, "\r\n")){ + $email = str_replace("\n", "\r\n", $email); + } + + $raw_header = substr($email, 0, strpos($email, "\r\n\r\n")); + $raw_body = substr($email, strlen($raw_header)+8); + + $this->protocol->expects($this->any())->method('getUid')->willReturn(Response::empty()->setResult(22)); + $this->protocol->expects($this->any())->method('getMessageNumber')->willReturn(Response::empty()->setResult(21)); + $this->protocol->expects($this->any())->method('flags')->willReturn(Response::empty()->setResult([22 => [0 => "\\Seen"]])); + + self::assertNotEmpty($this->client->openFolder("INBOX")); + + $message = Message::make(22, null, $this->client, $raw_header, $raw_body, [0 => "\\Seen"], IMAP::ST_UID); + + self::assertInstanceOf(Client::class, $message->getClient()); + self::assertSame(22, $message->uid); + self::assertSame(21, $message->msgn); + self::assertContains("Seen", $message->flags()->toArray()); + + $subject = $message->get("subject"); + $returnPath = $message->get("Return-Path"); + + self::assertInstanceOf(Attribute::class, $subject); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", $subject->toString()); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", (string)$message->subject); + self::assertSame("noreply@github.com", $returnPath->toString()); + self::assertSame("return_path", $returnPath->getName()); + self::assertSame("-4.299", (string)$message->get("X-Spam-Score")); + self::assertSame("Webklex/php-imap/issues/349/1365266070@github.com", (string)$message->get("Message-ID")); + self::assertSame(5, $message->get("received")->count()); + self::assertSame(IMAP::MESSAGE_PRIORITY_UNKNOWN, (int)$message->get("priority")()); + } + + /** + * Test getMessageNumber + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetMessageNumber(): void { + $this->createNewProtocolMockup(); + $this->protocol->expects($this->any())->method('getMessageNumber')->willReturn(Response::empty()->setResult("")); + + self::assertNotEmpty($this->client->openFolder("INBOX")); + + try { + $this->client->getConnection()->getMessageNumber(21)->validatedData(); + $this->fail("Message number should not exist"); + } catch (ResponseException $e) { + self::assertTrue(true); + } + + } + + /** + * Test loadMessageFromFile + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageNotFoundException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + * @throws MessageSizeFetchingException + */ + public function testLoadMessageFromFile(): void { + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "1366671050@github.com.eml"]); + $message = Message::fromFile($filename); + + $subject = $message->get("subject"); + $returnPath = $message->get("Return-Path"); + + self::assertInstanceOf(Attribute::class, $subject); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", $subject->toString()); + self::assertSame("Re: [Webklex/php-imap] Read all folders? (Issue #349)", (string)$message->subject); + self::assertSame("noreply@github.com", $returnPath->toString()); + self::assertSame("return_path", $returnPath->getName()); + self::assertSame("-4.299", (string)$message->get("X-Spam-Score")); + self::assertSame("Webklex/php-imap/issues/349/1365266070@github.com", (string)$message->get("Message-ID")); + self::assertSame(5, $message->get("received")->count()); + self::assertSame(IMAP::MESSAGE_PRIORITY_UNKNOWN, (int)$message->get("priority")()); + + self::assertNull($message->getClient()); + self::assertSame(0, $message->uid); + + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "example_attachment.eml"]); + $message = Message::fromFile($filename); + + $subject = $message->get("subject"); + $returnPath = $message->get("Return-Path"); + + self::assertInstanceOf(Attribute::class, $subject); + self::assertSame("ogqMVHhz7swLaq2PfSWsZj0k99w8wtMbrb4RuHdNg53i76B7icIIM0zIWpwGFtnk", $subject->toString()); + self::assertSame("ogqMVHhz7swLaq2PfSWsZj0k99w8wtMbrb4RuHdNg53i76B7icIIM0zIWpwGFtnk", (string)$message->subject); + self::assertSame("someone@domain.tld", $returnPath->toString()); + self::assertSame("return_path", $returnPath->getName()); + self::assertSame("1.103", (string)$message->get("X-Spam-Score")); + self::assertSame("d3a5e91963cb805cee975687d5acb1c6@swift.generated", (string)$message->get("Message-ID")); + self::assertSame(4, $message->get("received")->count()); + self::assertSame(IMAP::MESSAGE_PRIORITY_HIGHEST, (int)$message->get("priority")()); + + self::assertNull($message->getClient()); + self::assertSame(0, $message->uid); + self::assertSame(1, $message->getAttachments()->count()); + + /** @var Attachment $attachment */ + $attachment = $message->getAttachments()->first(); + self::assertSame("attachment", $attachment->disposition); + self::assertSame("znk551MP3TP3WPp9Kl1gnLErrWEgkJFAtvaKqkTgrk3dKI8dX38YT8BaVxRcOERN", $attachment->content); + self::assertSame("application/octet-stream", $attachment->content_type); + self::assertSame("6mfFxiU5Yhv9WYJx.txt", $attachment->name); + self::assertSame(2, $attachment->part_number); + self::assertSame("text", $attachment->type); + self::assertNotEmpty($attachment->id); + self::assertSame(90, $attachment->size); + self::assertSame("txt", $attachment->getExtension()); + self::assertInstanceOf(Message::class, $attachment->getMessage()); + self::assertSame("text/plain", $attachment->getMimeType()); + } + + /** + * Test issue #348 + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public function testIssue348() { + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "issue-348.eml"]); + $message = Message::fromFile($filename); + + self::assertSame(1, $message->getAttachments()->count()); + + /** @var Attachment $attachment */ + $attachment = $message->getAttachments()->first(); + + self::assertSame("attachment", $attachment->disposition); + self::assertSame("application/pdf", $attachment->content_type); + self::assertSame("Kelvinsong—Font_test_page_bold.pdf", $attachment->name); + self::assertSame(1, $attachment->part_number); + self::assertSame("text", $attachment->type); + self::assertNotEmpty($attachment->id); + self::assertSame(92384, $attachment->size); + self::assertSame("pdf", $attachment->getExtension()); + self::assertInstanceOf(Message::class, $attachment->getMessage()); + self::assertSame("application/pdf", $attachment->getMimeType()); + } + + /** + * Create a new protocol mockup + * + * @return void + */ + protected function createNewProtocolMockup(): void { + $this->protocol = $this->createMock(ImapProtocol::class); + + $this->protocol->expects($this->any())->method('createStream')->willReturn(true); + $this->protocol->expects($this->any())->method('connected')->willReturn(true); + $this->protocol->expects($this->any())->method('getConnectionTimeout')->willReturn(30); + $this->protocol->expects($this->any())->method('logout')->willReturn(Response::empty()->setResponse([ + 0 => "BYE Logging out\r\n", + 1 => "OK Logout completed (0.001 + 0.000 secs).\r\n", + ])); + $this->protocol->expects($this->any())->method('selectFolder')->willReturn(Response::empty()->setResponse([ + "flags" => [ + 0 => [ + 0 => "\Answered", + 1 => "\Flagged", + 2 => "\Deleted", + 3 => "\Seen", + 4 => "\Draft", + 5 => "NonJunk", + 6 => "unknown-1", + ], + ], + "exists" => 139, + "recent" => 0, + "unseen" => 94, + "uidvalidity" => 1488899637, + "uidnext" => 278, + ])); + + $this->client->connection = $this->protocol; + } +} \ No newline at end of file diff --git a/tests/PartTest.php b/tests/PartTest.php new file mode 100644 index 00000000..8543c46b --- /dev/null +++ b/tests/PartTest.php @@ -0,0 +1,107 @@ +config = Config::make(); + } + + /** + * Test parsing a text Part + * @throws InvalidMessageDateException + */ + public function testTextPart(): void { + $raw_headers = "Content-Type: text/plain;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n"; + $raw_body = "\r\nAny updates?"; + + $headers = new Header($raw_headers, $this->config); + $part = new Part($raw_body, $this->config, $headers, 0); + + self::assertSame("UTF-8", $part->charset); + self::assertSame("text/plain", $part->content_type); + self::assertSame(12, $part->bytes); + self::assertSame(0, $part->part_number); + self::assertSame(false, $part->ifdisposition); + self::assertSame(false, $part->isAttachment()); + self::assertSame("Any updates?", $part->content); + self::assertSame(IMAP::MESSAGE_TYPE_TEXT, $part->type); + self::assertSame(IMAP::MESSAGE_ENC_7BIT, $part->encoding); + } + + /** + * Test parsing a html Part + * @throws InvalidMessageDateException + */ + public function testHTMLPart(): void { + $raw_headers = "Content-Type: text/html;\r\n charset=UTF-8\r\nContent-Transfer-Encoding: 7bit\r\n"; + $raw_body = "\r\n

\r\n

Any updates?

"; + + $headers = new Header($raw_headers, $this->config); + $part = new Part($raw_body, $this->config, $headers, 0); + + self::assertSame("UTF-8", $part->charset); + self::assertSame("text/html", $part->content_type); + self::assertSame(39, $part->bytes); + self::assertSame(0, $part->part_number); + self::assertSame(false, $part->ifdisposition); + self::assertSame(false, $part->isAttachment()); + self::assertSame("

\r\n

Any updates?

", $part->content); + self::assertSame(IMAP::MESSAGE_TYPE_TEXT, $part->type); + self::assertSame(IMAP::MESSAGE_ENC_7BIT, $part->encoding); + } + + /** + * Test parsing a html Part + * @throws InvalidMessageDateException + */ + public function testBase64Part(): void { + $raw_headers = "Content-Type: application/octet-stream; name=6mfFxiU5Yhv9WYJx.txt\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=6mfFxiU5Yhv9WYJx.txt\r\n"; + $raw_body = "em5rNTUxTVAzVFAzV1BwOUtsMWduTEVycldFZ2tKRkF0dmFLcWtUZ3JrM2RLSThkWDM4WVQ4QmFW\r\neFJjT0VSTg=="; + + $headers = new Header($raw_headers, $this->config); + $part = new Part($raw_body, $this->config, $headers, 0); + + self::assertSame("", $part->charset); + self::assertSame("application/octet-stream", $part->content_type); + self::assertSame(90, $part->bytes); + self::assertSame(0, $part->part_number); + self::assertSame("znk551MP3TP3WPp9Kl1gnLErrWEgkJFAtvaKqkTgrk3dKI8dX38YT8BaVxRcOERN", base64_decode($part->content)); + self::assertSame(true, $part->ifdisposition); + self::assertSame("attachment", $part->disposition); + self::assertSame("6mfFxiU5Yhv9WYJx.txt", $part->name); + self::assertSame("6mfFxiU5Yhv9WYJx.txt", $part->filename); + self::assertSame(true, $part->isAttachment()); + self::assertSame(IMAP::MESSAGE_TYPE_TEXT, $part->type); + self::assertSame(IMAP::MESSAGE_ENC_BASE64, $part->encoding); + } +} \ No newline at end of file diff --git a/tests/StructureTest.php b/tests/StructureTest.php new file mode 100644 index 00000000..22d71bbf --- /dev/null +++ b/tests/StructureTest.php @@ -0,0 +1,68 @@ +config = Config::make(); + } + + /** + * Test parsing email headers + * + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + */ + public function testStructureParsing(): void { + $email = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "messages", "1366671050@github.com.eml"])); + if(!str_contains($email, "\r\n")){ + $email = str_replace("\n", "\r\n", $email); + } + + $raw_header = substr($email, 0, strpos($email, "\r\n\r\n")); + $raw_body = substr($email, strlen($raw_header)+8); + + $header = new Header($raw_header, $this->config); + $structure = new Structure($raw_body, $header); + + self::assertSame(2, count($structure->parts)); + + $textPart = $structure->parts[0]; + + self::assertSame("UTF-8", $textPart->charset); + self::assertSame("text/plain", $textPart->content_type); + self::assertSame(278, $textPart->bytes); + + $htmlPart = $structure->parts[1]; + + self::assertSame("UTF-8", $htmlPart->charset); + self::assertSame("text/html", $htmlPart->content_type); + self::assertSame(1478, $htmlPart->bytes); + } +} \ No newline at end of file diff --git a/tests/fixtures/AttachmentEncodedFilenameTest.php b/tests/fixtures/AttachmentEncodedFilenameTest.php new file mode 100644 index 00000000..b5da3597 --- /dev/null +++ b/tests/fixtures/AttachmentEncodedFilenameTest.php @@ -0,0 +1,52 @@ +getFixture("attachment_encoded_filename.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("multipart/mixed", $message->content_type->last()); + self::assertFalse($message->hasTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("Prostřeno_2014_poslední volné termíny.xls", $attachment->filename); + self::assertEquals("Prostřeno_2014_poslední volné termíny.xls", $attachment->name); + self::assertEquals('xls', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/vnd.ms-excel", $attachment->content_type); + self::assertEquals("a0ef7cfbc05b73dbcb298fe0bc224b41900cdaf60f9904e3fea5ba6c7670013c", hash("sha256", $attachment->content)); + self::assertEquals(146, $attachment->size); + self::assertEquals(0, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/AttachmentLongFilenameTest.php b/tests/fixtures/AttachmentLongFilenameTest.php new file mode 100644 index 00000000..5a3e00ed --- /dev/null +++ b/tests/fixtures/AttachmentLongFilenameTest.php @@ -0,0 +1,79 @@ +getFixture("attachment_long_filename.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("multipart/mixed", $message->content_type->last()); + self::assertFalse($message->hasTextBody()); + self::assertFalse($message->hasHTMLBody()); + + $attachments = $message->attachments(); + self::assertCount(3, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("Buchungsbestätigung- Rechnung-Geschäftsbedingungen-Nr.B123-45 - XXXX xxxxxxxxxxxxxxxxx XxxX, Lüdxxxxxxxx - VM Klaus XXXXXX - xxxxxxxx.pdf", $attachment->name); + self::assertEquals("Buchungsbestätigung- Rechnung-Geschäftsbedingungen-Nr.B123-45 - XXXXX xxxxxxxxxxxxxxxxx XxxX, Lüxxxxxxxxxx - VM Klaus XXXXXX - xxxxxxxx.pdf", $attachment->filename); + self::assertEquals('text', $attachment->type); + self::assertEquals('pdf', $attachment->getExtension()); + self::assertEquals("text/plain", $attachment->content_type); + self::assertEquals("ca51ce1fb15acc6d69b8a5700256172fcc507e02073e6f19592e341bd6508ab8", hash("sha256", $attachment->content)); + self::assertEquals(4, $attachment->size); + self::assertEquals(0, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('01_A€àäąбيد@Z-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz.txt', $attachment->name); + self::assertEquals("cebd34e48eaa06311da3d3130d5a9b465b096dc1094a6548f8c94c24ca52f34e", hash("sha256", $attachment->filename)); + self::assertEquals('text', $attachment->type); + self::assertEquals('txt', $attachment->getExtension()); + self::assertEquals("text/plain", $attachment->content_type); + self::assertEquals("ca51ce1fb15acc6d69b8a5700256172fcc507e02073e6f19592e341bd6508ab8", hash("sha256", $attachment->content)); + self::assertEquals(4, $attachment->size); + self::assertEquals(1, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[2]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('02_A€àäąбيد@Z-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz.txt', $attachment->name); + self::assertEquals('02_A€àäąбيد@Z-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstuvz.txt', $attachment->filename); + self::assertEquals('text', $attachment->type); + self::assertEquals("text/plain", $attachment->content_type); + self::assertEquals('txt', $attachment->getExtension()); + self::assertEquals("ca51ce1fb15acc6d69b8a5700256172fcc507e02073e6f19592e341bd6508ab8", hash("sha256", $attachment->content)); + self::assertEquals(4, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/AttachmentNoDispositionTest.php b/tests/fixtures/AttachmentNoDispositionTest.php new file mode 100644 index 00000000..aa1ef946 --- /dev/null +++ b/tests/fixtures/AttachmentNoDispositionTest.php @@ -0,0 +1,55 @@ +getFixture("attachment_no_disposition.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("multipart/mixed", $message->content_type->last()); + self::assertFalse($message->hasTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('26ed3dd2', $attachment->filename); + self::assertEquals('26ed3dd2', $attachment->id); + self::assertEquals("Prostřeno_2014_poslední volné termíny.xls", $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('xls', $attachment->getExtension()); + self::assertEquals("application/vnd.ms-excel", $attachment->content_type); + self::assertEquals("a0ef7cfbc05b73dbcb298fe0bc224b41900cdaf60f9904e3fea5ba6c7670013c", hash("sha256", $attachment->content)); + self::assertEquals(146, $attachment->size); + self::assertEquals(0, $attachment->part_number); + self::assertNull($attachment->disposition); + self::assertNotEmpty($attachment->id); + self::assertEmpty($attachment->content_id); + } +} \ No newline at end of file diff --git a/tests/fixtures/BccTest.php b/tests/fixtures/BccTest.php new file mode 100644 index 00000000..be8ef72e --- /dev/null +++ b/tests/fixtures/BccTest.php @@ -0,0 +1,49 @@ +getFixture("bcc.eml"); + + self::assertEquals("test", $message->subject); + self::assertSame([ + 'personal' => '', + 'mailbox' => 'return-path', + 'host' => 'here.com', + 'mail' => 'return-path@here.com', + 'full' => 'return-path@here.com', + ], $message->return_path->first()->toArray()); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("text/plain", $message->content_type); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from); + self::assertEquals("to@here.com", $message->to); + self::assertEquals("A_€@{è_Z ", $message->bcc); + self::assertEquals("sender@here.com", $message->sender); + self::assertEquals("reply-to@here.com", $message->reply_to); + } +} \ No newline at end of file diff --git a/tests/fixtures/BooleanDecodedContentTest.php b/tests/fixtures/BooleanDecodedContentTest.php new file mode 100644 index 00000000..5d15fe57 --- /dev/null +++ b/tests/fixtures/BooleanDecodedContentTest.php @@ -0,0 +1,55 @@ +getFixture("boolean_decoded_content.eml"); + + self::assertEquals("Nuu", $message->subject); + self::assertEquals("Here is the problem mail\r\n \r\nBody text", $message->getTextBody()); + self::assertEquals("Here is the problem mail\r\n \r\nBody text", $message->getHTMLBody()); + + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from); + self::assertEquals("to@here.com", $message->to); + + $attachments = $message->getAttachments(); + self::assertCount(1, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("Example Domain.pdf", $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('pdf', $attachment->getExtension()); + self::assertEquals("application/pdf", $attachment->content_type); + self::assertEquals("1c449aaab4f509012fa5eaa180fd017eb7724ccacabdffc1c6066d3756dcde5c", hash("sha256", $attachment->content)); + self::assertEquals(53, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/DateTemplateTest.php b/tests/fixtures/DateTemplateTest.php new file mode 100644 index 00000000..77dfd7bb --- /dev/null +++ b/tests/fixtures/DateTemplateTest.php @@ -0,0 +1,116 @@ + "2019-04-05 10:10:49", + "04 Jan 2018 10:12:47 UT" => "2018-01-04 10:12:47", + "22 Jun 18 03:56:36 PM -05:00 (GMT -05:00)" => "2018-06-22 20:56:36", + "Sat, 31 Aug 2013 20:08:23 +0580" => "2013-08-31 14:38:23", + "Fri, 1 Feb 2019 01:30:04 +0600 (+06)" => "2019-01-31 19:30:04", + "Mon, 4 Feb 2019 04:03:49 -0300 (-03)" => "2019-02-04 07:03:49", + "Sun, 6 Apr 2008 21:24:33 UT" => "2008-04-06 21:24:33", + "Wed, 11 Sep 2019 15:23:06 +0600 (+06)" => "2019-09-11 09:23:06", + "14 Sep 2019 00:10:08 UT +0200" => "2019-09-14 00:10:08", + "Tue, 08 Nov 2022 18:47:20 +0000 14:03:33 +0000" => "2022-11-08 18:47:20", + "Sat, 10, Dec 2022 09:35:19 +0100" => "2022-12-10 08:35:19", + "Thur, 16 Mar 2023 15:33:07 +0400" => "2023-03-16 11:33:07", + "fr., 25 nov. 2022 06:27:14 +0100/fr., 25 nov. 2022 06:27:14 +0100" => "2022-11-25 05:27:14", + "Di., 15 Feb. 2022 06:52:44 +0100 (MEZ)/Di., 15 Feb. 2022 06:52:44 +0100 (MEZ)" => "2022-02-15 05:52:44", + "Mi., 23 Apr. 2025 09:48:37 +0200 (MESZ)" => "2025-04-23 07:48:37", + ]; + + /** + * Test the fixture date-template.eml + * + * @return void + * @throws InvalidMessageDateException + * @throws ReflectionException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + public function testFixture() : void { + try { + $message = $this->getFixture("date-template.eml"); + $this->fail("Expected InvalidMessageDateException"); + } catch (InvalidMessageDateException $e) { + self::assertTrue(true); + } + + self::$manager->setConfig([ + "options" => [ + "fallback_date" => "2021-01-01 00:00:00", + ], + ]); + $message = $this->getFixture("date-template.eml", self::$manager->getConfig()); + + self::assertEquals("test", $message->subject); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2021-01-01 00:00:00", $message->date->first()->timezone("UTC")->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", (string)$message->from); + self::assertEquals("to@here.com", $message->to); + + self::$manager->setConfig([ + "options" => [ + "fallback_date" => null, + ], + ]); + + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", "date-template.eml"]); + $blob = file_get_contents($filename); + self::assertNotFalse($blob); + + foreach ($this->dates as $date => $expected) { + $message = Message::fromString(str_replace("%date_raw_header%", $date, $blob)); + self::assertEquals("test", $message->subject); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals($expected, $message->date->first()->timezone("UTC")->format("Y-m-d H:i:s"), "Date \"$date\" should be \"$expected\""); + self::assertEquals("from@there.com", (string)$message->from); + self::assertEquals("to@here.com", $message->to); + } + } +} \ No newline at end of file diff --git a/tests/fixtures/EmailAddressTest.php b/tests/fixtures/EmailAddressTest.php new file mode 100644 index 00000000..0f87cf6a --- /dev/null +++ b/tests/fixtures/EmailAddressTest.php @@ -0,0 +1,59 @@ +getFixture("email_address.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("123@example.com", $message->message_id); + self::assertEquals("Hi\r\nHow are you?", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertFalse($message->date->first()); + self::assertEquals("no_host", (string)$message->from); + self::assertEquals("", $message->to); + self::assertEquals("This one: is \"right\" , No-address", (string)$message->cc); + } +} \ No newline at end of file diff --git a/tests/fixtures/EmbeddedEmailTest.php b/tests/fixtures/EmbeddedEmailTest.php new file mode 100644 index 00000000..024a8885 --- /dev/null +++ b/tests/fixtures/EmbeddedEmailTest.php @@ -0,0 +1,63 @@ +getFixture("embedded_email.eml"); + + self::assertEquals("embedded message", $message->subject); + self::assertEquals([ + 'from webmail.my-office.cz (localhost [127.0.0.1]) by keira.cofis.cz ; Fri, 29 Jan 2016 14:25:40 +0100', + ], $message->received->toArray()); + self::assertEquals("7e5798da5747415e5b82fdce042ab2a6@cerstor.cz", $message->message_id); + self::assertEquals("demo@cerstor.cz", $message->return_path); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("Roundcube Webmail/1.0.0", $message->user_agent); + self::assertEquals("email that contains embedded message", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2016-01-29 13:25:40", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("demo@cerstor.cz", $message->from); + self::assertEquals("demo@cerstor.cz", $message->x_sender); + self::assertEquals("demo@cerstor.cz", $message->to); + + $attachments = $message->getAttachments(); + self::assertCount(1, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("demo.eml", $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('eml', $attachment->getExtension()); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("a1f965f10a9872e902a82dde039a237e863f522d238a1cb1968fe3396dbcac65", hash("sha256", $attachment->content)); + self::assertEquals(893, $attachment->size); + self::assertEquals(1, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/EmbeddedEmailWithoutContentDispositionEmbeddedTest.php b/tests/fixtures/EmbeddedEmailWithoutContentDispositionEmbeddedTest.php new file mode 100644 index 00000000..387b0de0 --- /dev/null +++ b/tests/fixtures/EmbeddedEmailWithoutContentDispositionEmbeddedTest.php @@ -0,0 +1,74 @@ +getFixture("embedded_email_without_content_disposition-embedded.eml"); + + self::assertEquals("embedded_message_subject", $message->subject); + self::assertEquals([ + 'from webmail.my-office.cz (localhost [127.0.0.1]) by keira.cofis.cz ; Fri, 29 Jan 2016 14:25:40 +0100', + ], $message->received->toArray()); + self::assertEquals("AC39946EBF5C034B87BABD5343E96979012671D40E38@VM002.cerk.cc", $message->message_id); + self::assertEquals("pl-PL, nl-NL", $message->accept_language); + self::assertEquals("pl-PL", $message->content_language); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("some txt", $message->getTextBody()); + self::assertEquals("\r\n

some txt

\r\n", $message->getHTMLBody()); + + self::assertEquals("2019-04-05 10:10:49", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("demo@cerstor.cz", $message->from); + self::assertEquals("demo@cerstor.cz", $message->to); + + $attachments = $message->getAttachments(); + self::assertCount(2, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("file1.xlsx", $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('xlsx', $attachment->getExtension()); + self::assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $attachment->content_type); + self::assertEquals("87737d24c106b96e177f9564af6712e2c6d3e932c0632bfbab69c88b0bb934dc", hash("sha256", $attachment->content)); + self::assertEquals(40, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("file2.xlsx", $attachment->name); + self::assertEquals('xlsx', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $attachment->content_type); + self::assertEquals("87737d24c106b96e177f9564af6712e2c6d3e932c0632bfbab69c88b0bb934dc", hash("sha256", $attachment->content)); + self::assertEquals(40, $attachment->size); + self::assertEquals(3, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/EmbeddedEmailWithoutContentDispositionTest.php b/tests/fixtures/EmbeddedEmailWithoutContentDispositionTest.php new file mode 100644 index 00000000..2ec5a4fa --- /dev/null +++ b/tests/fixtures/EmbeddedEmailWithoutContentDispositionTest.php @@ -0,0 +1,97 @@ +getFixture("embedded_email_without_content_disposition.eml"); + + self::assertEquals("Subject", $message->subject); + self::assertEquals([ + 'from webmail.my-office.cz (localhost [127.0.0.1]) by keira.cofis.cz ; Fri, 29 Jan 2016 14:25:40 +0100', + ], $message->received->toArray()); + self::assertEquals("AC39946EBF5C034B87BABD5343E96979012671D9F7E4@VM002.cerk.cc", $message->message_id); + self::assertEquals("pl-PL, nl-NL", $message->accept_language); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("TexT\r\n\r\n[cid:file.jpg]", $message->getTextBody()); + self::assertEquals("

TexT

", $message->getHTMLBody()); + + self::assertEquals("2019-04-05 11:48:50", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("demo@cerstor.cz", $message->from); + self::assertEquals("demo@cerstor.cz", $message->to); + + $attachments = $message->getAttachments(); + self::assertCount(4, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("file.jpg", $attachment->name); + self::assertEquals('jpg', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("image/jpeg", $attachment->content_type); + self::assertEquals("6b7fa434f92a8b80aab02d9bf1a12e49ffcae424e4013a1c4f68b67e3d2bbcd0", hash("sha256", $attachment->content)); + self::assertEquals(96, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('a1abc19a', $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('', $attachment->getExtension()); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("2476c8b91a93c6b2fe1bfff593cb55956c2fe8e7ca6de9ad2dc9d101efe7a867", hash("sha256", $attachment->content)); + self::assertEquals(2073, $attachment->size); + self::assertEquals(3, $attachment->part_number); + self::assertNull($attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[2]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("file3.xlsx", $attachment->name); + self::assertEquals('xlsx', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $attachment->content_type); + self::assertEquals("87737d24c106b96e177f9564af6712e2c6d3e932c0632bfbab69c88b0bb934dc", hash("sha256", $attachment->content)); + self::assertEquals(40, $attachment->size); + self::assertEquals(4, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[3]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("file4.zip", $attachment->name); + self::assertEquals('zip', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/x-zip-compressed", $attachment->content_type); + self::assertEquals("87737d24c106b96e177f9564af6712e2c6d3e932c0632bfbab69c88b0bb934dc", hash("sha256", $attachment->content)); + self::assertEquals(40, $attachment->size); + self::assertEquals(5, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/ExampleBounceTest.php b/tests/fixtures/ExampleBounceTest.php new file mode 100644 index 00000000..8153518d --- /dev/null +++ b/tests/fixtures/ExampleBounceTest.php @@ -0,0 +1,103 @@ +getFixture("example_bounce.eml"); + + self::assertEquals([ + 'personal' => '', + 'mailbox' => '', + 'host' => '', + 'mail' => '', + 'full' => '', + ], (array)$message->return_path->first()); + self::assertEquals([ + 0 => 'from somewhere.your-server.de by somewhere.your-server.de with LMTP id 3TP8LrElAGSOaAAAmBr1xw (envelope-from <>); Thu, 02 Mar 2023 05:27:29 +0100', + 1 => 'from somewhere06.your-server.de ([1b21:2f8:e0a:50e4::2]) by somewhere.your-server.de with esmtps (TLS1.3) tls TLS_AES_256_GCM_SHA384 (Exim 4.94.2) id 1pXaXR-0006xQ-BN for demo@foo.de; Thu, 02 Mar 2023 05:27:29 +0100', + 2 => 'from [192.168.0.10] (helo=sslproxy01.your-server.de) by somewhere06.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) (Exim 4.92) id 1pXaXO-000LYP-9R for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + 3 => 'from localhost ([127.0.0.1] helo=sslproxy01.your-server.de) by sslproxy01.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) (Exim 4.92) id 1pXaXO-0008gy-7x for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + 4 => 'from Debian-exim by sslproxy01.your-server.de with local (Exim 4.92) id 1pXaXO-0008gb-6g for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + ], $message->received->all()); + self::assertEquals("demo@foo.de", $message->envelope_to); + self::assertEquals("Thu, 02 Mar 2023 05:27:29 +0100", $message->delivery_date); + self::assertEquals([ + 0 => 'somewhere.your-server.de; iprev=pass (somewhere06.your-server.de) smtp.remote-ip=1b21:2f8:e0a:50e4::2; spf=none smtp.mailfrom=<>; dmarc=skipped', + 1 => 'somewhere.your-server.de' + ], $message->authentication_results->all()); + self::assertEquals([ + 0 => 'from somewhere.your-server.de by somewhere.your-server.de with LMTP id 3TP8LrElAGSOaAAAmBr1xw (envelope-from <>); Thu, 02 Mar 2023 05:27:29 +0100', + 1 => 'from somewhere06.your-server.de ([1b21:2f8:e0a:50e4::2]) by somewhere.your-server.de with esmtps (TLS1.3) tls TLS_AES_256_GCM_SHA384 (Exim 4.94.2) id 1pXaXR-0006xQ-BN for demo@foo.de; Thu, 02 Mar 2023 05:27:29 +0100', + 2 => 'from [192.168.0.10] (helo=sslproxy01.your-server.de) by somewhere06.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) (Exim 4.92) id 1pXaXO-000LYP-9R for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + 3 => 'from localhost ([127.0.0.1] helo=sslproxy01.your-server.de) by sslproxy01.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) (Exim 4.92) id 1pXaXO-0008gy-7x for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + 4 => 'from Debian-exim by sslproxy01.your-server.de with local (Exim 4.92) id 1pXaXO-0008gb-6g for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100', + ], $message->received->all()); + self::assertEquals("ding@ding.de", $message->x_failed_recipients); + self::assertEquals("auto-replied", $message->auto_submitted); + self::assertEquals("Mail Delivery System ", $message->from); + self::assertEquals("demo@foo.de", $message->to); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("Mail delivery failed", $message->subject); + self::assertEquals("E1pXaXO-0008gb-6g@sslproxy01.your-server.de", $message->message_id); + self::assertEquals("2023-03-02 04:27:26", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("Clear (ClamAV 0.103.8/26827/Wed Mar 1 09:28:49 2023)", $message->x_virus_scanned); + self::assertEquals("0.0 (/)", $message->x_spam_score); + self::assertEquals("bar-demo@foo.de", $message->delivered_to); + self::assertEquals("multipart/report", $message->content_type->last()); + self::assertEquals("5d4847c21c8891e73d62c8246f260a46496958041a499f33ecd47444fdaa591b", hash("sha256", $message->getTextBody())); + self::assertFalse($message->hasHTMLBody()); + + $attachments = $message->attachments(); + self::assertCount(2, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('c541a506', $attachment->filename); + self::assertEquals("c541a506", $attachment->name); + self::assertEquals('', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("message/delivery-status", $attachment->content_type); + self::assertEquals("85ac09d1d74b2d85853084dc22abcad205a6bfde62d6056e3a933ffe7e82e45c", hash("sha256", $attachment->content)); + self::assertEquals(267, $attachment->size); + self::assertEquals(1, $attachment->part_number); + self::assertNull($attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('da786518', $attachment->filename); + self::assertEquals("da786518", $attachment->name); + self::assertEquals('', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("7525331f5fab23ea77f595b995336aca7b8dad12db00ada14abebe7fe5b96e10", hash("sha256", $attachment->content)); + self::assertEquals(776, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertNull($attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/FixtureTestCase.php b/tests/fixtures/FixtureTestCase.php new file mode 100644 index 00000000..cb64a9a6 --- /dev/null +++ b/tests/fixtures/FixtureTestCase.php @@ -0,0 +1,94 @@ + [ + "debug" => $_ENV["LIVE_MAILBOX_DEBUG"] ?? false, + ], + 'accounts' => [ + 'default' => [ + 'host' => getenv("LIVE_MAILBOX_HOST"), + 'port' => getenv("LIVE_MAILBOX_PORT"), + 'encryption' => getenv("LIVE_MAILBOX_ENCRYPTION"), + 'validate_cert' => getenv("LIVE_MAILBOX_VALIDATE_CERT"), + 'username' => getenv("LIVE_MAILBOX_USERNAME"), + 'password' => getenv("LIVE_MAILBOX_PASSWORD"), + 'protocol' => 'imap', //might also use imap, [pop3 or nntp (untested)] + ], + ], + ]); + return self::$manager; + } + + /** + * Get a fixture message + * @param string $template + * + * @return Message + * @throws ReflectionException + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + final public function getFixture(string $template, ?Config $config = null) : Message { + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", $template]); + $message = Message::fromFile($filename, $config); + self::assertInstanceOf(Message::class, $message); + + return $message; + } +} \ No newline at end of file diff --git a/tests/fixtures/FourNestedEmailsTest.php b/tests/fixtures/FourNestedEmailsTest.php new file mode 100644 index 00000000..e6c37ccd --- /dev/null +++ b/tests/fixtures/FourNestedEmailsTest.php @@ -0,0 +1,55 @@ +getFixture("four_nested_emails.eml"); + + self::assertEquals("3-third-subject", $message->subject); + self::assertEquals("3-third-content", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertFalse($message->date->first()); + self::assertEquals("test@example.com", $message->from->first()->mail); + self::assertEquals("test@example.com", $message->to->first()->mail); + + $attachments = $message->getAttachments(); + self::assertCount(1, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("2-second-email.eml", $attachment->name); + self::assertEquals('text', $attachment->type); + self::assertEquals('eml', $attachment->getExtension()); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("85012e6a26d064a0288ee62618b3192687385adb4a4e27e48a28f738a325ca46", hash("sha256", $attachment->content)); + self::assertEquals(1376, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + } +} \ No newline at end of file diff --git a/tests/fixtures/GbkCharsetTest.php b/tests/fixtures/GbkCharsetTest.php new file mode 100644 index 00000000..7492d908 --- /dev/null +++ b/tests/fixtures/GbkCharsetTest.php @@ -0,0 +1,37 @@ +getFixture("gbk_charset.eml"); + + self::assertEquals("Nuu", $message->subject); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/HtmlOnlyTest.php b/tests/fixtures/HtmlOnlyTest.php new file mode 100644 index 00000000..90ef44e3 --- /dev/null +++ b/tests/fixtures/HtmlOnlyTest.php @@ -0,0 +1,37 @@ +getFixture("html_only.eml"); + + self::assertEquals("Nuu", $message->subject); + self::assertEquals("Hi", $message->getHTMLBody()); + self::assertFalse($message->hasTextBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/ImapMimeHeaderDecodeReturnsFalseTest.php b/tests/fixtures/ImapMimeHeaderDecodeReturnsFalseTest.php new file mode 100644 index 00000000..7f90a4ec --- /dev/null +++ b/tests/fixtures/ImapMimeHeaderDecodeReturnsFalseTest.php @@ -0,0 +1,37 @@ +getFixture("imap_mime_header_decode_returns_false.eml"); + + self::assertEquals("=?UTF-8?B?nnDusSNdG92w6Fuw61fMjAxOF8wMy0xMzMyNTMzMTkzLnBkZg==?=", $message->subject->first()); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/InlineAttachmentTest.php b/tests/fixtures/InlineAttachmentTest.php new file mode 100644 index 00000000..edb380cd --- /dev/null +++ b/tests/fixtures/InlineAttachmentTest.php @@ -0,0 +1,62 @@ +getFixture("inline_attachment.eml"); + + self::assertEquals("", $message->subject); + self::assertFalse($message->hasTextBody()); + self::assertEquals('', $message->getHTMLBody()); + + self::assertFalse($message->date->first()); + self::assertFalse($message->from->first()); + self::assertFalse($message->to->first()); + + + $attachments = $message->attachments(); + self::assertInstanceOf(AttachmentCollection::class, $attachments); + self::assertCount(1, $attachments); + + $attachment = $attachments[0]; + + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals('d2913999', $attachment->name); + self::assertEquals('d2913999', $attachment->filename); + self::assertEquals('ii_15f0aad691bb745f', $attachment->id); + self::assertEquals('text', $attachment->type); + self::assertEquals('', $attachment->getExtension()); + self::assertEquals("image/png", $attachment->content_type); + self::assertEquals("6568c9e9c35a7fa06f236e89f704d8c9b47183a24f2c978dba6c92e2747e3a13", hash("sha256", $attachment->content)); + self::assertEquals(1486, $attachment->size); + self::assertEquals(1, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertEquals("", $attachment->content_id); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/KsC56011987HeadersTest.php b/tests/fixtures/KsC56011987HeadersTest.php new file mode 100644 index 00000000..944e9bfb --- /dev/null +++ b/tests/fixtures/KsC56011987HeadersTest.php @@ -0,0 +1,46 @@ +getFixture("ks_c_5601-1987_headers.eml"); + + self::assertEquals("RE: 회원님께 Ersi님이 메시지를 보냈습니다.", $message->subject); + self::assertEquals("=?ks_c_5601-1987?B?yLi/+LTUsrIgRXJzabTUwMwguN69w8H2uKYgurizwr3AtM+02S4=?=", $message->thread_topic); + self::assertEquals("1.0", $message->mime_version); + self::assertEquals("Content", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("to@here.com", $message->to->first()->mail); + + + $from = $message->from->first(); + self::assertEquals("김 현진", $from->personal); + self::assertEquals("from", $from->mailbox); + self::assertEquals("there.com", $from->host); + self::assertEquals("from@there.com", $from->mail); + self::assertEquals("김 현진 ", $from->full); + } +} \ No newline at end of file diff --git a/tests/fixtures/MailThatIsAttachmentTest.php b/tests/fixtures/MailThatIsAttachmentTest.php new file mode 100644 index 00000000..0f4164c9 --- /dev/null +++ b/tests/fixtures/MailThatIsAttachmentTest.php @@ -0,0 +1,63 @@ +getFixture("mail_that_is_attachment.eml"); + + self::assertEquals("Report domain: yyy.cz Submitter: google.com Report-ID: 2244696771454641389", $message->subject); + self::assertEquals("2244696771454641389@google.com", $message->message_id); + self::assertEquals("1.0", $message->mime_version); + self::assertFalse($message->hasTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2015-02-15 10:21:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("xxx@yyy.cz", $message->to->first()->mail); + self::assertEquals("xxx@yyy.cz", $message->sender->first()->mail); + + $from = $message->from->first(); + self::assertEquals("noreply-dmarc-support via xxx", $from->personal); + self::assertEquals("xxx", $from->mailbox); + self::assertEquals("yyy.cz", $from->host); + self::assertEquals("xxx@yyy.cz", $from->mail); + self::assertEquals("noreply-dmarc-support via xxx ", $from->full); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("google.com!yyy.cz!1423872000!1423958399.zip", $attachment->name); + self::assertEquals('zip', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/zip", $attachment->content_type); + self::assertEquals("c0d4f47b6fde124cea7460c3e509440d1a062705f550b0502b8ba0cbf621c97a", hash("sha256", $attachment->content)); + self::assertEquals(1062, $attachment->size); + self::assertEquals(0, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/MissingDateTest.php b/tests/fixtures/MissingDateTest.php new file mode 100644 index 00000000..93595adc --- /dev/null +++ b/tests/fixtures/MissingDateTest.php @@ -0,0 +1,37 @@ +getFixture("missing_date.eml"); + + self::assertEquals("Nuu", $message->getSubject()); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertFalse($message->date->first()); + self::assertEquals("from@here.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/MissingFromTest.php b/tests/fixtures/MissingFromTest.php new file mode 100644 index 00000000..5d57340c --- /dev/null +++ b/tests/fixtures/MissingFromTest.php @@ -0,0 +1,37 @@ +getFixture("missing_from.eml"); + + self::assertEquals("Nuu", $message->getSubject()); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertFalse($message->from->first()); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/MixedFilenameTest.php b/tests/fixtures/MixedFilenameTest.php new file mode 100644 index 00000000..b2be0e32 --- /dev/null +++ b/tests/fixtures/MixedFilenameTest.php @@ -0,0 +1,61 @@ +getFixture("mixed_filename.eml"); + + self::assertEquals("Свежий прайс-лист", $message->subject); + self::assertFalse($message->hasTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2018-02-02 19:23:06", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + + $from = $message->from->first(); + self::assertEquals("Прайсы || ПартКом", $from->personal); + self::assertEquals("support", $from->mailbox); + self::assertEquals("part-kom.ru", $from->host); + self::assertEquals("support@part-kom.ru", $from->mail); + self::assertEquals("Прайсы || ПартКом ", $from->full); + + self::assertEquals("foo@bar.com", $message->to->first()); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("Price4VladDaKar.xlsx", $attachment->name); + self::assertEquals('xlsx', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/octet-stream", $attachment->content_type); + self::assertEquals("b832983842b0ad65db69e4c7096444c540a2393e2d43f70c2c9b8b9fceeedbb1", hash('sha256', $attachment->content)); + self::assertEquals(94, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/MultipartWithoutBodyTest.php b/tests/fixtures/MultipartWithoutBodyTest.php new file mode 100644 index 00000000..01112bb7 --- /dev/null +++ b/tests/fixtures/MultipartWithoutBodyTest.php @@ -0,0 +1,62 @@ +getFixture("multipart_without_body.eml"); + + self::assertEquals("This mail will not contain a body", $message->subject); + self::assertEquals("This mail will not contain a body", $message->getTextBody()); + self::assertEquals("d76dfb1ff3231e3efe1675c971ce73f722b906cc049d328db0d255f8d3f65568", hash("sha256", $message->getHTMLBody())); + self::assertEquals("2023-03-11 08:24:31", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("Foo Bülow Bar ", $message->from); + self::assertEquals("some one ", $message->to); + self::assertEquals([ + 0 => 'from AS8PR02MB6805.eurprd02.prod.outlook.com (2603:10a6:20b:252::8) by PA4PR02MB7071.eurprd02.prod.outlook.com with HTTPS; Sat, 11 Mar 2023 08:24:33 +0000', + 1 => 'from omef0ahNgeoJu.eurprd02.prod.outlook.com (2603:10a6:10:33c::12) by AS8PR02MB6805.eurprd02.prod.outlook.com (2603:10a6:20b:252::8) with Microsoft SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6178.19; Sat, 11 Mar 2023 08:24:31 +0000', + 2 => 'from omef0ahNgeoJu.eurprd02.prod.outlook.com ([fe80::38c0:9c40:7fc6:93a7]) by omef0ahNgeoJu.eurprd02.prod.outlook.com ([fe80::38c0:9c40:7fc6:93a7%7]) with mapi id 15.20.6178.019; Sat, 11 Mar 2023 08:24:31 +0000', + ], $message->received->all()); + self::assertEquals("This mail will not contain a body", $message->thread_topic); + self::assertEquals("AdlT8uVmpHPvImbCRM6E9LODIvAcQA==", $message->thread_index); + self::assertEquals("omef0ahNgeoJuEB51C568ED2227A2DAABB5BB9@omef0ahNgeoJu.eurprd02.prod.outlook.com", $message->message_id); + self::assertEquals("da-DK, en-US", $message->accept_language); + self::assertEquals("en-US", $message->content_language); + self::assertEquals("Internal", $message->x_ms_exchange_organization_authAs); + self::assertEquals("04", $message->x_ms_exchange_organization_authMechanism); + self::assertEquals("omef0ahNgeoJu.eurprd02.prod.outlook.com", $message->x_ms_exchange_organization_authSource); + self::assertEquals("", $message->x_ms_Has_Attach); + self::assertEquals("aa546a02-2b7a-4fb1-7fd4-08db220a09f1", $message->x_ms_exchange_organization_Network_Message_Id); + self::assertEquals("-1", $message->x_ms_exchange_organization_SCL); + self::assertEquals("", $message->x_ms_TNEF_Correlator); + self::assertEquals("0", $message->x_ms_exchange_organization_RecordReviewCfmType); + self::assertEquals("Email", $message->x_ms_publictraffictype); + self::assertEquals("ucf:0;jmr:0;auth:0;dest:I;ENG:(910001)(944506478)(944626604)(920097)(425001)(930097);", $message->X_Microsoft_Antispam_Mailbox_Delivery->first()); + self::assertEquals("0712b5fe22cf6e75fa220501c1a6715a61098983df9e69bad4000c07531c1295", hash("sha256", $message->X_Microsoft_Antispam_Message_Info)); + self::assertEquals("multipart/alternative", $message->Content_Type->last()); + self::assertEquals("1.0", $message->mime_version); + + self::assertCount(0, $message->getAttachments()); + } +} \ No newline at end of file diff --git a/tests/fixtures/MultipleHtmlPartsAndAttachmentsTest.php b/tests/fixtures/MultipleHtmlPartsAndAttachmentsTest.php new file mode 100644 index 00000000..48bc0172 --- /dev/null +++ b/tests/fixtures/MultipleHtmlPartsAndAttachmentsTest.php @@ -0,0 +1,76 @@ +getFixture("multiple_html_parts_and_attachments.eml"); + + self::assertEquals("multiple_html_parts_and_attachments", $message->subject); + self::assertEquals("This is the first html part\r\n\r\n\r\n\r\nThis is the second html part\r\n\r\n\r\n\r\nThis is the last html part\r\nhttps://www.there.com", $message->getTextBody()); + self::assertEquals("This is the first html part

\n

This is the second html part

\n

This is the last html part
https://www.there.com



\r\n
", $message->getHTMLBody()); + + self::assertEquals("2023-02-16 09:19:02", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + + $from = $message->from->first(); + self::assertEquals("FromName", $from->personal); + self::assertEquals("from", $from->mailbox); + self::assertEquals("there.com", $from->host); + self::assertEquals("from@there.com", $from->mail); + self::assertEquals("FromName ", $from->full); + + self::assertEquals("to@there.com", $message->to->first()); + + $attachments = $message->attachments(); + self::assertInstanceOf(AttachmentCollection::class, $attachments); + self::assertCount(2, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("attachment1.pdf", $attachment->name); + self::assertEquals('pdf', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/pdf", $attachment->content_type); + self::assertEquals("c162adf19e0f67e26ef0b7f791b33a60b2c23b175560a505dc7f9ec490206e49", hash("sha256", $attachment->content)); + self::assertEquals(4814, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("attachment2.pdf", $attachment->name); + self::assertEquals('pdf', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/pdf", $attachment->content_type); + self::assertEquals("a337b37e9d3edb172a249639919f0eee3d344db352046d15f8f9887e55855a25", hash("sha256", $attachment->content)); + self::assertEquals(5090, $attachment->size); + self::assertEquals(4, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/MultipleNestedAttachmentsTest.php b/tests/fixtures/MultipleNestedAttachmentsTest.php new file mode 100644 index 00000000..a3cba097 --- /dev/null +++ b/tests/fixtures/MultipleNestedAttachmentsTest.php @@ -0,0 +1,69 @@ +getFixture("multiple_nested_attachments.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("------------------------------------------------------------------------", $message->getTextBody()); + self::assertEquals("\r\n \r\n\r\n \r\n \r\n \r\n


\r\n

\r\n
\r\n \r\n \r\n  \"\"\r\n
\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n

\r\n

\r\n
\r\n
\r\n \r\n", $message->getHTMLBody()); + + self::assertEquals("2018-01-15 09:54:09", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertFalse($message->from->first()); + self::assertFalse($message->to->first()); + + $attachments = $message->attachments(); + self::assertInstanceOf(AttachmentCollection::class, $attachments); + self::assertCount(2, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("mleokdgdlgkkecep.png", $attachment->name); + self::assertEquals('png', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("image/png", $attachment->content_type); + self::assertEquals("e0e99b0bd6d5ea3ced99add53cc98b6f8eea6eae8ddd773fd06f3489289385fb", hash("sha256", $attachment->content)); + self::assertEquals(114, $attachment->size); + self::assertEquals(3, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("FF4D00-1.png", $attachment->name); + self::assertEquals('png', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("image/png", $attachment->content_type); + self::assertEquals("e0e99b0bd6d5ea3ced99add53cc98b6f8eea6eae8ddd773fd06f3489289385fb", hash("sha256", $attachment->content)); + self::assertEquals(114, $attachment->size); + self::assertEquals(4, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/NestesEmbeddedWithAttachmentTest.php b/tests/fixtures/NestesEmbeddedWithAttachmentTest.php new file mode 100644 index 00000000..e1ff57ea --- /dev/null +++ b/tests/fixtures/NestesEmbeddedWithAttachmentTest.php @@ -0,0 +1,69 @@ +getFixture("nestes_embedded_with_attachment.eml"); + + self::assertEquals("Nuu", $message->subject); + self::assertEquals("Dear Sarah", $message->getTextBody()); + self::assertEquals("\r\n\r\n
Dear Sarah,
\r\n", $message->getHTMLBody()); + + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + + $attachments = $message->attachments(); + self::assertInstanceOf(AttachmentCollection::class, $attachments); + self::assertCount(2, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("first.eml", $attachment->name); + self::assertEquals('eml', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("From: from@there.com\r\nTo: to@here.com\r\nSubject: FIRST\r\nDate: Sat, 28 Apr 2018 14:37:16 -0400\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed;\r\n boundary=\"----=_NextPart_000_222_000\"\r\n\r\nThis is a multi-part message in MIME format.\r\n\r\n------=_NextPart_000_222_000\r\nContent-Type: multipart/alternative;\r\n boundary=\"----=_NextPart_000_222_111\"\r\n\r\n\r\n------=_NextPart_000_222_111\r\nContent-Type: text/plain;\r\n charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nPlease respond directly to this email to update your RMA\r\n\r\n\r\n2018-04-17T11:04:03-04:00\r\n------=_NextPart_000_222_111\r\nContent-Type: text/html;\r\n charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n\r\n\r\n
Please respond directly to this =\r\nemail to=20\r\nupdate your RMA
\r\n\r\n------=_NextPart_000_222_111--\r\n\r\n------=_NextPart_000_222_000\r\nContent-Type: image/png;\r\n name=\"chrome.png\"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment;\r\n filename=\"chrome.png\"\r\n\r\niVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAB+FBMVEUAAAA/mUPidDHiLi5Cn0Xk\r\nNTPmeUrkdUg/m0Q0pEfcpSbwaVdKskg+lUP4zA/iLi3msSHkOjVAmETdJSjtYFE/lkPnRj3sWUs8\r\nkkLeqCVIq0fxvhXqUkbVmSjwa1n1yBLepyX1xxP0xRXqUkboST9KukpHpUbuvRrzrhF/ljbwalju\r\nZFM4jELaoSdLtElJrUj1xxP6zwzfqSU4i0HYnydMtUlIqUfywxb60AxZqEXaoifgMCXptR9MtklH\r\npEY2iUHWnSjvvRr70QujkC+pUC/90glMuEnlOjVMt0j70QriLS1LtEnnRj3qUUXfIidOjsxAhcZF\r\no0bjNDH0xxNLr0dIrUdmntVTkMoyfL8jcLBRuErhJyrgKyb4zA/5zg3tYFBBmUTmQTnhMinruBzv\r\nvhnxwxZ/st+Ktt5zp9hqota2vtK6y9FemNBblc9HiMiTtMbFtsM6gcPV2r6dwroseLrMrbQrdLGd\r\nyKoobKbo3Zh+ynrgVllZulTsXE3rV0pIqUf42UVUo0JyjEHoS0HmsiHRGR/lmRz/1hjqnxjvpRWf\r\nwtOhusaz0LRGf7FEfbDVmqHXlJeW0pbXq5bec3fX0nTnzmuJuWvhoFFhm0FtrziBsjaAaDCYWC+u\r\nSi6jQS3FsSfLJiTirCOkuCG1KiG+wSC+GBvgyhTszQ64Z77KAAAARXRSTlMAIQRDLyUgCwsE6ebm\r\n5ubg2dLR0byXl4FDQzU1NDEuLSUgC+vr6urq6ubb29vb2tra2tG8vLu7u7uXl5eXgYGBgYGBLiUA\r\nLabIAAABsElEQVQoz12S9VPjQBxHt8VaOA6HE+AOzv1wd7pJk5I2adpCC7RUcHd3d3fXf5PvLkxh\r\neD++z+yb7GSRlwD/+Hj/APQCZWxM5M+goF+RMbHK594v+tPoiN1uHxkt+xzt9+R9wnRTZZQpXQ0T\r\n5uP1IQxToyOAZiQu5HEpjeA4SWIoksRxNiGC1tRZJ4LNxgHgnU5nJZBDvuDdl8lzQRBsQ+s9PZt7\r\ns7Pz8wsL39/DkIfZ4xlB2Gqsq62ta9oxVlVrNZpihFRpGO9fzQw1ms0NDWZz07iGkJmIFH8xxkc3\r\na/WWlubmFkv9AB2SEpDvKxbjidN2faseaNV3zoHXvv7wMODJdkOHAegweAfFPx4G67KluxzottCU\r\n9n8CUqXzcIQdXOytAHqXxomvykhEKN9EFutG22p//0rbNvHVxiJywa8yS2KDfV1dfbu31H8jF1RH\r\niTKtWYeHxUvq3bn0pyjCRaiRU6aDO+gb3aEfEeVNsDgm8zzLy9egPa7Qt8TSJdwhjplk06HH43ZN\r\nJ3s91KKCHQ5x4sw1fRGYDZ0n1L4FKb9/BP5JLYxToheoFCVxz57PPS8UhhEpLBVeAAAAAElFTkSu\r\nQmCC\r\n\r\n------=_NextPart_000_222_000--", $attachment->content); + self::assertEquals(2535, $attachment->size); + self::assertEquals(3, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("second.eml", $attachment->name); + self::assertEquals('eml', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("From: from@there.com\r\nTo: to@here.com\r\nSubject: SECOND\r\nDate: Sat, 28 Apr 2018 13:37:30 -0400\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative;\r\n boundary=\"----=_NextPart_000_333_000\"\r\n\r\nThis is a multi-part message in MIME format.\r\n\r\n------=_NextPart_000_333_000\r\nContent-Type: text/plain;\r\n charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nT whom it may concern:\r\n------=_NextPart_000_333_000\r\nContent-Type: text/html;\r\n charset=\"UTF-8\"\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n\r\n\r\n
T whom it may concern:
\r\n\r\n\r\n------=_NextPart_000_333_000--", $attachment->content); + self::assertEquals(631, $attachment->size); + self::assertEquals(4, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/NullContentCharsetTest.php b/tests/fixtures/NullContentCharsetTest.php new file mode 100644 index 00000000..f8e6c63f --- /dev/null +++ b/tests/fixtures/NullContentCharsetTest.php @@ -0,0 +1,39 @@ +getFixture("null_content_charset.eml"); + + self::assertEquals("test", $message->getSubject()); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertEquals("1.0", $message->mime_version); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/PecTest.php b/tests/fixtures/PecTest.php new file mode 100644 index 00000000..341ad185 --- /dev/null +++ b/tests/fixtures/PecTest.php @@ -0,0 +1,82 @@ +getFixture("pec.eml"); + + self::assertEquals("Certified", $message->subject); + self::assertEquals("Signed", $message->getTextBody()); + self::assertEquals("Signed", $message->getHTMLBody()); + + self::assertEquals("2017-10-02 10:13:43", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("test@example.com", $message->from->first()->mail); + self::assertEquals("test@example.com", $message->to->first()->mail); + + $attachments = $message->attachments(); + + self::assertInstanceOf(AttachmentCollection::class, $attachments); + self::assertCount(3, $attachments); + + $attachment = $attachments[0]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("data.xml", $attachment->name); + self::assertEquals('xml', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/xml", $attachment->content_type); + self::assertEquals("", $attachment->content); + self::assertEquals(8, $attachment->size); + self::assertEquals(3, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[1]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("postacert.eml", $attachment->name); + self::assertEquals('eml', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("message/rfc822", $attachment->content_type); + self::assertEquals("To: test@example.com\r\nFrom: test@example.com\r\nSubject: test-subject\r\nDate: Mon, 2 Oct 2017 12:13:50 +0200\r\nContent-Type: text/plain; charset=iso-8859-15; format=flowed\r\nContent-Transfer-Encoding: 7bit\r\n\r\ntest-content", $attachment->content); + self::assertEquals(216, $attachment->size); + self::assertEquals(4, $attachment->part_number); + self::assertEquals("inline", $attachment->disposition); + self::assertNotEmpty($attachment->id); + + $attachment = $attachments[2]; + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("smime.p7s", $attachment->name); + self::assertEquals('p7s', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("application/x-pkcs7-signature", $attachment->content_type); + self::assertEquals("1", $attachment->content); + self::assertEquals(4, $attachment->size); + self::assertEquals(5, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/PlainOnlyTest.php b/tests/fixtures/PlainOnlyTest.php new file mode 100644 index 00000000..b3a65bfe --- /dev/null +++ b/tests/fixtures/PlainOnlyTest.php @@ -0,0 +1,37 @@ +getFixture("plain_only.eml"); + + self::assertEquals("Nuu", $message->getSubject()); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/PlainTextAttachmentTest.php b/tests/fixtures/PlainTextAttachmentTest.php new file mode 100644 index 00000000..2e9993cc --- /dev/null +++ b/tests/fixtures/PlainTextAttachmentTest.php @@ -0,0 +1,54 @@ +getFixture("plain_text_attachment.eml"); + + self::assertEquals("Plain text attachment", $message->subject); + self::assertEquals("Test", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2018-08-21 07:05:14", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("a.txt", $attachment->name); + self::assertEquals('txt', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertNull($attachment->content_type); + self::assertEquals("Hi!", $attachment->content); + self::assertEquals(4, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/ReferencesTest.php b/tests/fixtures/ReferencesTest.php new file mode 100644 index 00000000..c86aa96f --- /dev/null +++ b/tests/fixtures/ReferencesTest.php @@ -0,0 +1,54 @@ +getFixture("references.eml"); + + self::assertEquals("", $message->subject); + self::assertEquals("Hi\r\nHow are you?", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertFalse($message->date->first()); + + self::assertEquals("b9e87bd5e661a645ed6e3b832828fcc5@example.com", $message->in_reply_to); + self::assertEquals("", $message->from->first()->personal); + self::assertEquals("", $message->from->first()->host); + self::assertEquals("no_host", $message->from->first()->mail); + self::assertFalse($message->to->first()); + + self::assertEquals([ + "231d9ac57aec7d8c1a0eacfeab8af6f3@example.com", + "08F04024-A5B3-4FDE-BF2C-6710DE97D8D9@example.com" + ], $message->getReferences()->all()); + + self::assertEquals([ + 'This one: is "right" ', + 'No-address' + ], $message->cc->map(function($address){ + /** @var \Webklex\PHPIMAP\Address $address */ + return $address->full; + })); + } +} \ No newline at end of file diff --git a/tests/fixtures/SimpleMultipartTest.php b/tests/fixtures/SimpleMultipartTest.php new file mode 100644 index 00000000..d2a2d885 --- /dev/null +++ b/tests/fixtures/SimpleMultipartTest.php @@ -0,0 +1,37 @@ +getFixture("simple_multipart.eml"); + + self::assertEquals("test", $message->getSubject()); + self::assertEquals("MyPlain", $message->getTextBody()); + self::assertEquals("MyHtml", $message->getHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/StructuredWithAttachmentTest.php b/tests/fixtures/StructuredWithAttachmentTest.php new file mode 100644 index 00000000..3fc591c3 --- /dev/null +++ b/tests/fixtures/StructuredWithAttachmentTest.php @@ -0,0 +1,55 @@ +getFixture("structured_with_attachment.eml"); + + self::assertEquals("Test", $message->getSubject()); + self::assertEquals("Test", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + + self::assertEquals("2017-09-29 08:55:23", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + + self::assertCount(1, $message->attachments()); + + $attachment = $message->attachments()->first(); + self::assertInstanceOf(Attachment::class, $attachment); + self::assertEquals("MyFile.txt", $attachment->name); + self::assertEquals('txt', $attachment->getExtension()); + self::assertEquals('text', $attachment->type); + self::assertEquals("text/plain", $attachment->content_type); + self::assertEquals("MyFileContent", $attachment->content); + self::assertEquals(20, $attachment->size); + self::assertEquals(2, $attachment->part_number); + self::assertEquals("attachment", $attachment->disposition); + self::assertNotEmpty($attachment->id); + } +} \ No newline at end of file diff --git a/tests/fixtures/UndefinedCharsetHeaderTest.php b/tests/fixtures/UndefinedCharsetHeaderTest.php new file mode 100644 index 00000000..f7a51cb0 --- /dev/null +++ b/tests/fixtures/UndefinedCharsetHeaderTest.php @@ -0,0 +1,64 @@ +getFixture("undefined_charset_header.eml"); + + self::assertEquals("", $message->get("x-real-to")); + self::assertEquals("1.0", $message->get("mime-version")); + self::assertEquals("Mon, 27 Feb 2017 13:21:44 +0930", $message->get("Resent-Date")); + self::assertEquals("", $message->get("Resent-From")); + self::assertEquals("BlaBla", $message->get("X-Stored-In")); + self::assertSame([ + 'personal' => '', + 'mailbox' => 'info', + 'host' => 'bla.bla', + 'mail' => 'info@bla.bla', + 'full' => 'info@bla.bla', + ], $message->get("Return-Path")->first()->toArray()); + self::assertEquals([ + 'from by bla.bla (CommuniGate Pro RULE 6.1.13) with RULE id 14057804; Mon, 27 Feb 2017 13:21:44 +0930', + ], $message->get("Received")->all()); + self::assertEquals(")", $message->getHTMLBody()); + self::assertFalse($message->hasTextBody()); + self::assertEquals("2017-02-27 03:51:29", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + + $from = $message->from->first(); + self::assertInstanceOf(Address::class, $from); + + self::assertEquals("myGov", $from->personal); + self::assertEquals("info", $from->mailbox); + self::assertEquals("bla.bla", $from->host); + self::assertEquals("info@bla.bla", $from->mail); + self::assertEquals("myGov ", $from->full); + + self::assertEquals("sales@bla.bla", $message->to->first()->mail); + self::assertEquals("Submit your tax refund | Australian Taxation Office.", $message->subject); + self::assertEquals("201702270351.BGF77614@bla.bla", $message->message_id); + } +} \ No newline at end of file diff --git a/tests/fixtures/UndisclosedRecipientsMinusTest.php b/tests/fixtures/UndisclosedRecipientsMinusTest.php new file mode 100644 index 00000000..f9ee7999 --- /dev/null +++ b/tests/fixtures/UndisclosedRecipientsMinusTest.php @@ -0,0 +1,42 @@ +getFixture("undisclosed_recipients_minus.eml"); + + self::assertEquals("test", $message->subject); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from); + self::assertEquals([ + "undisclosed-recipients", + "" + ], $message->to->map(function ($item) { + return $item->mailbox; + })); + } +} \ No newline at end of file diff --git a/tests/fixtures/UndisclosedRecipientsSpaceTest.php b/tests/fixtures/UndisclosedRecipientsSpaceTest.php new file mode 100644 index 00000000..b86321eb --- /dev/null +++ b/tests/fixtures/UndisclosedRecipientsSpaceTest.php @@ -0,0 +1,42 @@ +getFixture("undisclosed_recipients_space.eml"); + + self::assertEquals("test", $message->subject); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from); + self::assertEquals([ + "Undisclosed recipients", + "" + ], $message->to->map(function ($item) { + return $item->mailbox; + })); + } +} \ No newline at end of file diff --git a/tests/fixtures/UndisclosedRecipientsTest.php b/tests/fixtures/UndisclosedRecipientsTest.php new file mode 100644 index 00000000..f5f44164 --- /dev/null +++ b/tests/fixtures/UndisclosedRecipientsTest.php @@ -0,0 +1,42 @@ +getFixture("undisclosed_recipients.eml"); + + self::assertEquals("test", $message->subject); + self::assertEquals("Hi!", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from); + self::assertEquals([ + "Undisclosed Recipients", + "" + ], $message->to->map(function ($item) { + return $item->mailbox; + })); + } +} \ No newline at end of file diff --git a/tests/fixtures/UnknownEncodingTest.php b/tests/fixtures/UnknownEncodingTest.php new file mode 100644 index 00000000..3a570cbd --- /dev/null +++ b/tests/fixtures/UnknownEncodingTest.php @@ -0,0 +1,37 @@ +getFixture("unknown_encoding.eml"); + + self::assertEquals("test", $message->getSubject()); + self::assertEquals("MyPlain", $message->getTextBody()); + self::assertEquals("MyHtml", $message->getHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/WithoutCharsetPlainOnlyTest.php b/tests/fixtures/WithoutCharsetPlainOnlyTest.php new file mode 100644 index 00000000..497334d5 --- /dev/null +++ b/tests/fixtures/WithoutCharsetPlainOnlyTest.php @@ -0,0 +1,37 @@ +getFixture("without_charset_plain_only.eml"); + + self::assertEquals("Nuu", $message->getSubject()); + self::assertEquals("Hi", $message->getTextBody()); + self::assertFalse($message->hasHTMLBody()); + self::assertEquals("2017-09-13 11:05:45", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/fixtures/WithoutCharsetSimpleMultipartTest.php b/tests/fixtures/WithoutCharsetSimpleMultipartTest.php new file mode 100644 index 00000000..2a9ea2a0 --- /dev/null +++ b/tests/fixtures/WithoutCharsetSimpleMultipartTest.php @@ -0,0 +1,37 @@ +getFixture("without_charset_simple_multipart.eml"); + + self::assertEquals("test", $message->getSubject()); + self::assertEquals("MyPlain", $message->getTextBody()); + self::assertEquals("MyHtml", $message->getHTMLBody()); + self::assertEquals("2017-09-27 10:48:51", $message->date->first()->setTimezone('UTC')->format("Y-m-d H:i:s")); + self::assertEquals("from@there.com", $message->from->first()->mail); + self::assertEquals("to@here.com", $message->to->first()->mail); + } +} \ No newline at end of file diff --git a/tests/issues/Issue275Test.php b/tests/issues/Issue275Test.php new file mode 100644 index 00000000..4049998d --- /dev/null +++ b/tests/issues/Issue275Test.php @@ -0,0 +1,38 @@ +subject); + self::assertSame("Asdf testing123 this is a body", $message->getTextBody()); + } + + public function testIssueEmail2() { + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", "issue-275-2.eml"]); + $message = Message::fromFile($filename); + + $body = "Test\r\n\r\nMed venlig hilsen\r\nMartin Larsen\r\nFeline Holidays A/S\r\nTlf 78 77 04 12"; + + self::assertSame("Test 1017", (string)$message->subject); + self::assertSame($body, $message->getTextBody()); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue355Test.php b/tests/issues/Issue355Test.php new file mode 100644 index 00000000..0fa6d07e --- /dev/null +++ b/tests/issues/Issue355Test.php @@ -0,0 +1,30 @@ +get("subject"); + + $this->assertEquals("Re: Uppdaterat ärende (447899), kostnader för hjälp med stadgeändring enligt ny lagstiftning", $subject->toString()); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue379Test.php b/tests/issues/Issue379Test.php new file mode 100644 index 00000000..99b67921 --- /dev/null +++ b/tests/issues/Issue379Test.php @@ -0,0 +1,61 @@ +getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + $this->assertEquals(214, $message->getSize()); + + // Clean up + $this->assertTrue($message->delete(true)); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue382Test.php b/tests/issues/Issue382Test.php new file mode 100644 index 00000000..4e638827 --- /dev/null +++ b/tests/issues/Issue382Test.php @@ -0,0 +1,33 @@ +from->first(); + + self::assertSame("Mail Delivery System", $from->personal); + self::assertSame("MAILER-DAEMON", $from->mailbox); + self::assertSame("mta-09.someserver.com", $from->host); + self::assertSame("MAILER-DAEMON@mta-09.someserver.com", $from->mail); + self::assertSame("Mail Delivery System ", $from->full); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue383Test.php b/tests/issues/Issue383Test.php new file mode 100644 index 00000000..30cb3a9b --- /dev/null +++ b/tests/issues/Issue383Test.php @@ -0,0 +1,69 @@ +getClient(); + $client->connect(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'Entwürfe+']); + + $folder = $client->getFolder($folder_path); + $this->deleteFolder($folder); + + $folder = $client->createFolder($folder_path, false); + self::assertInstanceOf(Folder::class, $folder); + + $folder = $this->getFolder($folder_path); + self::assertInstanceOf(Folder::class, $folder); + + $this->assertEquals('Entwürfe+', $folder->name); + $this->assertEquals($folder_path, $folder->full_name); + + $folder_path = implode($delimiter, ['INBOX', 'Entw&APw-rfe+']); + $this->assertEquals($folder_path, $folder->path); + + // Clean up + if ($this->deleteFolder($folder) === false) { + $this->fail("Could not delete folder: " . $folder->path); + } + } +} \ No newline at end of file diff --git a/tests/issues/Issue393Test.php b/tests/issues/Issue393Test.php new file mode 100644 index 00000000..017ff535 --- /dev/null +++ b/tests/issues/Issue393Test.php @@ -0,0 +1,62 @@ +getClient(); + $client->connect(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $pattern = implode($delimiter, ['doesnt_exist', '%']); + + $folder = $client->getFolder('doesnt_exist'); + $this->deleteFolder($folder); + + $folders = $client->getFolders(true, $pattern, true); + self::assertCount(0, $folders); + + try { + $client->getFolders(true, $pattern, false); + $this->fail('Expected FolderFetchingException::class exception not thrown'); + } catch (FolderFetchingException $e) { + self::assertInstanceOf(FolderFetchingException::class, $e); + } + } +} \ No newline at end of file diff --git a/tests/issues/Issue401Test.php b/tests/issues/Issue401Test.php new file mode 100644 index 00000000..e250a550 --- /dev/null +++ b/tests/issues/Issue401Test.php @@ -0,0 +1,27 @@ +subject); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue407Test.php b/tests/issues/Issue407Test.php new file mode 100644 index 00000000..4adcbb39 --- /dev/null +++ b/tests/issues/Issue407Test.php @@ -0,0 +1,57 @@ +getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + self::assertInstanceOf(Message::class, $message); + + $message->setFlag("Seen"); + + $flags = $this->getClient()->getConnection()->flags($message->uid, IMAP::ST_UID)->validatedData(); + + self::assertIsArray($flags); + self::assertSame(1, count($flags)); + self::assertSame("\\Seen", $flags[$message->uid][0]); + + $message->delete(); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue40Test.php b/tests/issues/Issue40Test.php new file mode 100644 index 00000000..7288484c --- /dev/null +++ b/tests/issues/Issue40Test.php @@ -0,0 +1,74 @@ +getFixture("issue-40.eml"); + + self::assertSame("Zly from", (string)$message->subject); + self::assertSame([ + 'personal' => '', + 'mailbox' => 'faked_sender', + 'host' => 'sender_domain.pl', + 'mail' => 'faked_sender@sender_domain.pl', + 'full' => 'faked_sender@sender_domain.pl', + ], $message->from->first()->toArray()); + self::assertSame([ + 'personal' => '', + 'mailbox' => 'real_sender', + 'host' => 'sender_domain.pl', + 'mail' => 'real_sender@sender_domain.pl', + 'full' => ' ', + ], (array)$message->return_path->first()); + self::assertSame(true, $message->spoofed->first()); + + $config = $message->getConfig(); + self::assertSame(false, $config->get("security.detect_spoofing_exception")); + $config->set("security.detect_spoofing_exception", true); + self::assertSame(true, $config->get("security.detect_spoofing_exception")); + + $this->expectException(SpoofingAttemptDetectedException::class); + $this->getFixture("issue-40.eml", $config); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue410Test.php b/tests/issues/Issue410Test.php new file mode 100644 index 00000000..4f8b831d --- /dev/null +++ b/tests/issues/Issue410Test.php @@ -0,0 +1,111 @@ +getFixture("issue-410.eml"); + + self::assertSame("☆第132号 「ガーデン&エクステリア」専門店のためのQ&Aサロン 【月刊エクステリア・ワーク】", (string)$message->subject); + + $attachments = $message->getAttachments(); + + self::assertSame(1, $attachments->count()); + + $attachment = $attachments->first(); + self::assertSame("☆第132号 「ガーデン&エクステリア」専門店のためのQ&Aサロン 【月刊エクステリア・ワーク】", $attachment->filename); + self::assertSame("☆第132号 「ガーデン&エクステリア」専門店のためのQ&Aサロン 【月刊エクステリア・ワーク】", $attachment->name); + } + + /** + * @throws RuntimeException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws ImapBadRequestException + * @throws InvalidMessageDateException + * @throws ConnectionFailedException + * @throws \ReflectionException + * @throws ImapServerErrorException + * @throws AuthFailedException + * @throws MaskNotFoundException + */ + public function testIssueEmailB() { + $message = $this->getFixture("issue-410b.eml"); + + self::assertSame("386 - 400021804 - 19., Heiligenstädter Straße 80 - 0819306 - Anfrage Vergabevorschlag", (string)$message->subject); + + $attachments = $message->getAttachments(); + + self::assertSame(1, $attachments->count()); + + $attachment = $attachments->first(); + self::assertSame("2021_Mängelliste_0819306.xlsx", $attachment->description); + self::assertSame("2021_Mängelliste_0819306.xlsx", $attachment->filename); + self::assertSame("2021_Mängelliste_0819306.xlsx", $attachment->name); + } + + /** + * @throws RuntimeException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws ImapBadRequestException + * @throws ConnectionFailedException + * @throws InvalidMessageDateException + * @throws ImapServerErrorException + * @throws AuthFailedException + * @throws \ReflectionException + * @throws MaskNotFoundException + */ + public function testIssueEmailSymbols() { + $message = $this->getFixture("issue-410symbols.eml"); + + $attachments = $message->getAttachments(); + + self::assertSame(1, $attachments->count()); + + /** @var Attachment $attachment */ + $attachment = $attachments->first(); + self::assertSame("Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf", $attachment->description); + self::assertSame("Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf", $attachment->name); + self::assertSame("Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf", $attachment->filename); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue412Test.php b/tests/issues/Issue412Test.php new file mode 100644 index 00000000..bfaa883d --- /dev/null +++ b/tests/issues/Issue412Test.php @@ -0,0 +1,31 @@ +subject); + self::assertSame("64254d63e92a36ee02c760676351e60a", md5($message->getTextBody())); + self::assertSame("2e4de288f6a1ed658548ed11fcdb1d79", md5($message->getHTMLBody())); + self::assertSame(0, $message->attachments()->count()); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue413Test.php b/tests/issues/Issue413Test.php new file mode 100644 index 00000000..2162d6b5 --- /dev/null +++ b/tests/issues/Issue413Test.php @@ -0,0 +1,82 @@ +getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + /** @var Message $message */ + $_message = $this->appendMessageTemplate($folder, 'issue-413.eml'); + + $message = $folder->messages()->getMessageByMsgn($_message->msgn); + self::assertEquals($message->uid, $_message->uid); + + self::assertSame("Test Message", (string)$message->subject); + self::assertSame("This is just a test, so ignore it (if you can!)\r\n\r\nTony Marston", $message->getTextBody()); + + $message->delete(); + } + + /** + * Static parsing test + * + * @return void + * @throws \ReflectionException + * @throws \Webklex\PHPIMAP\Exceptions\AuthFailedException + * @throws \Webklex\PHPIMAP\Exceptions\ConnectionFailedException + * @throws \Webklex\PHPIMAP\Exceptions\ImapBadRequestException + * @throws \Webklex\PHPIMAP\Exceptions\ImapServerErrorException + * @throws \Webklex\PHPIMAP\Exceptions\InvalidMessageDateException + * @throws \Webklex\PHPIMAP\Exceptions\MaskNotFoundException + * @throws \Webklex\PHPIMAP\Exceptions\MessageContentFetchingException + * @throws \Webklex\PHPIMAP\Exceptions\ResponseException + * @throws \Webklex\PHPIMAP\Exceptions\RuntimeException + */ + public function testIssueEmail() { + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", "issue-413.eml"]); + $message = Message::fromFile($filename); + + self::assertSame("Test Message", (string)$message->subject); + self::assertSame("This is just a test, so ignore it (if you can!)\r\n\r\nTony Marston", $message->getTextBody()); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue414Test.php b/tests/issues/Issue414Test.php new file mode 100644 index 00000000..36a1d300 --- /dev/null +++ b/tests/issues/Issue414Test.php @@ -0,0 +1,43 @@ +subject); + + $attachments = $message->getAttachments(); + + self::assertSame(2, $attachments->count()); + + $attachment = $attachments->first(); + self::assertEmpty($attachment->description); + self::assertSame("exampleMyFile.txt", $attachment->filename); + self::assertSame("exampleMyFile.txt", $attachment->name); + self::assertSame("be62f7e6", $attachment->id); + + $attachment = $attachments->last(); + self::assertEmpty($attachment->description); + self::assertSame("phpfoo", $attachment->filename); + self::assertSame("phpfoo", $attachment->name); + self::assertSame("12e1d38b", $attachment->hash); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue420Test.php b/tests/issues/Issue420Test.php new file mode 100644 index 00000000..0cac9b6e --- /dev/null +++ b/tests/issues/Issue420Test.php @@ -0,0 +1,31 @@ +get("subject"); + + // Ticket No: [��17] Mailbox Inbox - (17) Incoming failed messages + $this->assertEquals('Ticket No: [??17] Mailbox Inbox - (17) Incoming failed messages', utf8_decode($subject->toString())); + } + +} \ No newline at end of file diff --git a/tests/issues/Issue462Test.php b/tests/issues/Issue462Test.php new file mode 100644 index 00000000..e7fc2126 --- /dev/null +++ b/tests/issues/Issue462Test.php @@ -0,0 +1,48 @@ +set('options.rfc822', false); + $message = $this->getFixture("issue-462.eml", $config); + self::assertSame("Undeliverable: Some subject", (string)$message->subject); + self::assertSame("postmaster@ ", (string)$message->from->first()); + } +} \ No newline at end of file diff --git a/tests/issues/Issue469Test.php b/tests/issues/Issue469Test.php new file mode 100644 index 00000000..13bd841b --- /dev/null +++ b/tests/issues/Issue469Test.php @@ -0,0 +1,42 @@ +createStub(Client::class); + $folder_name = '[Gmail]'; + $delimiter = '/'; + + $attributes = [ + '\NoInferiors', + '\NoSelect', + ]; + $folder = new Folder($client, $folder_name, $delimiter, $attributes); + + $attributes_lowercase = [ + '\Noinferiors', + '\Noselect', + ]; + $folder_lowercase = new Folder($client, $folder_name, $delimiter, $attributes_lowercase); + + self::assertSame( + $folder->no_inferiors, + $folder_lowercase->no_inferiors, + 'The parsed "\NoInferiors" attribute does not match the parsed "\Noinferiors" attribute' + ); + self::assertSame( + $folder->no_select, + $folder_lowercase->no_select, + 'The parsed "\NoSelect" attribute does not match the parsed "\Noselect" attribute' + ); + } +} \ No newline at end of file diff --git a/tests/issues/Issue511Test.php b/tests/issues/Issue511Test.php new file mode 100644 index 00000000..4782bace --- /dev/null +++ b/tests/issues/Issue511Test.php @@ -0,0 +1,50 @@ +getFixture("issue-511.eml"); + self::assertSame("RE: [EXTERNAL] Re: Lorem Ipsum /40 one", (string)$message->subject); + self::assertSame("COMPANYNAME | usługi ", (string)$message->from->first()); + } +} \ No newline at end of file diff --git a/tests/issues/Issue544Test.php b/tests/issues/Issue544Test.php new file mode 100644 index 00000000..b8ee0e7e --- /dev/null +++ b/tests/issues/Issue544Test.php @@ -0,0 +1,61 @@ +getFixture("issue-544.eml"); + + self::assertSame("Test bad boundary", (string)$message->subject); + + $attachments = $message->getAttachments(); + + self::assertSame(1, $attachments->count()); + + /** @var Attachment $attachment */ + $attachment = $attachments->first(); + self::assertSame("file.pdf", $attachment->name); + self::assertSame("file.pdf", $attachment->filename); + self::assertStringStartsWith("%PDF-1.4", $attachment->content); + self::assertStringEndsWith("%%EOF\n", $attachment->content); + self::assertSame(14938, $attachment->size); + } +} \ No newline at end of file diff --git a/tests/live/ClientTest.php b/tests/live/ClientTest.php new file mode 100644 index 00000000..1d224d57 --- /dev/null +++ b/tests/live/ClientTest.php @@ -0,0 +1,318 @@ +getClient()->connect()); + } + + /** + * Test if the connection is working + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testIsConnected(): void { + $client = $this->getClient()->connect(); + + self::assertTrue($client->isConnected()); + } + + /** + * Test if the connection state can be determined + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testDisconnect(): void { + $client = $this->getClient()->connect(); + + self::assertFalse($client->disconnect()->isConnected()); + } + + /** + * Test to get the default inbox folder + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws FolderFetchingException + */ + public function testGetFolder(): void { + $client = $this->getClient()->connect(); + + $folder = $client->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + } + + /** + * Test to get the default inbox folder by name + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFolderByName(): void { + $client = $this->getClient()->connect(); + + $folder = $client->getFolderByName('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + } + + /** + * Test to get the default inbox folder by path + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFolderByPath(): void { + $client = $this->getClient()->connect(); + + $folder = $client->getFolderByPath('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + } + + /** + * Test to get all folders + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFolders(): void { + $client = $this->getClient()->connect(); + + $folders = $client->getFolders(false); + self::assertTrue($folders->count() > 0); + } + + public function testGetFoldersWithStatus(): void { + $client = $this->getClient()->connect(); + + $folders = $client->getFoldersWithStatus(false); + self::assertTrue($folders->count() > 0); + } + + public function testOpenFolder(): void { + $client = $this->getClient()->connect(); + + $status = $client->openFolder("INBOX"); + self::assertTrue(isset($status["flags"]) && count($status["flags"]) > 0); + self::assertTrue(($status["uidnext"] ?? 0) > 0); + self::assertTrue(($status["uidvalidity"] ?? 0) > 0); + self::assertTrue(($status["recent"] ?? -1) >= 0); + self::assertTrue(($status["exists"] ?? -1) >= 0); + } + + public function testCreateFolder(): void { + $client = $this->getClient()->connect(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', $this->getSpecialChars()]); + + $folder = $client->getFolder($folder_path); + + $this->deleteFolder($folder); + + $folder = $client->createFolder($folder_path, false); + self::assertInstanceOf(Folder::class, $folder); + + $folder = $this->getFolder($folder_path); + self::assertInstanceOf(Folder::class, $folder); + + $this->assertEquals($this->getSpecialChars(), $folder->name); + $this->assertEquals($folder_path, $folder->full_name); + + $folder_path = implode($delimiter, ['INBOX', EncodingAliases::convert($this->getSpecialChars(), "utf-8", "utf7-imap")]); + $this->assertEquals($folder_path, $folder->path); + + // Clean up + if ($this->deleteFolder($folder) === false) { + $this->fail("Could not delete folder: " . $folder->path); + } + } + + public function testCheckFolder(): void { + $client = $this->getClient()->connect(); + + $status = $client->checkFolder("INBOX"); + self::assertTrue(isset($status["flags"]) && count($status["flags"]) > 0); + self::assertTrue(($status["uidnext"] ?? 0) > 0); + self::assertTrue(($status["uidvalidity"] ?? 0) > 0); + self::assertTrue(($status["recent"] ?? -1) >= 0); + self::assertTrue(($status["exists"] ?? -1) >= 0); + } + + public function testGetFolderPath(): void { + $client = $this->getClient()->connect(); + + self::assertIsArray($client->openFolder("INBOX")); + self::assertEquals("INBOX", $client->getFolderPath()); + } + + public function testId(): void { + $client = $this->getClient()->connect(); + + $info = $client->Id(); + self::assertIsArray($info); + $valid = false; + foreach ($info as $value) { + if (str_starts_with($value, "OK")) { + $valid = true; + break; + } + } + self::assertTrue($valid); + } + + public function testGetQuotaRoot(): void { + if (!getenv("LIVE_MAILBOX_QUOTA_SUPPORT")) { + $this->markTestSkipped("Quota support is not enabled"); + } + + $client = $this->getClient()->connect(); + + $quota = $client->getQuotaRoot("INBOX"); + self::assertIsArray($quota); + self::assertTrue(count($quota) > 1); + self::assertIsArray($quota[0]); + self::assertEquals("INBOX", $quota[0][1]); + self::assertIsArray($quota[1]); + self::assertIsArray($quota[1][2]); + self::assertTrue($quota[1][2][2] > 0); + } + + public function testSetTimeout(): void { + $client = $this->getClient()->connect(); + + self::assertInstanceOf(ProtocolInterface::class, $client->setTimeout(57)); + self::assertEquals(57, $client->getTimeout()); + } + + public function testExpunge(): void { + $client = $this->getClient()->connect(); + + $client->openFolder("INBOX"); + $status = $client->expunge(); + + self::assertIsArray($status); + self::assertIsArray($status[0]); + self::assertEquals("OK", $status[0][0]); + } + + public function testGetDefaultMessageMask(): void { + $client = $this->getClient(); + + self::assertEquals(MessageMask::class, $client->getDefaultMessageMask()); + } + + public function testGetDefaultEvents(): void { + $client = $this->getClient(); + + self::assertIsArray($client->getDefaultEvents("message")); + } + + public function testSetDefaultMessageMask(): void { + $client = $this->getClient(); + + self::assertInstanceOf(Client::class, $client->setDefaultMessageMask(AttachmentMask::class)); + self::assertEquals(AttachmentMask::class, $client->getDefaultMessageMask()); + + $client->setDefaultMessageMask(MessageMask::class); + } + + public function testGetDefaultAttachmentMask(): void { + $client = $this->getClient(); + + self::assertEquals(AttachmentMask::class, $client->getDefaultAttachmentMask()); + } + + public function testSetDefaultAttachmentMask(): void { + $client = $this->getClient(); + + self::assertInstanceOf(Client::class, $client->setDefaultAttachmentMask(MessageMask::class)); + self::assertEquals(MessageMask::class, $client->getDefaultAttachmentMask()); + + $client->setDefaultAttachmentMask(AttachmentMask::class); + } +} \ No newline at end of file diff --git a/tests/live/FolderTest.php b/tests/live/FolderTest.php new file mode 100644 index 00000000..178329cf --- /dev/null +++ b/tests/live/FolderTest.php @@ -0,0 +1,447 @@ +getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + self::assertInstanceOf(WhereQuery::class, $folder->query()); + self::assertInstanceOf(WhereQuery::class, $folder->search()); + self::assertInstanceOf(WhereQuery::class, $folder->messages()); + } + + /** + * Test Folder::hasChildren() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws EventNotFoundException + */ + public function testHasChildren(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $child_path = implode($delimiter, ['INBOX', 'test']); + if ($folder->getClient()->getFolder($child_path) === null) { + $folder->getClient()->createFolder($child_path, false); + $folder = $this->getFolder('INBOX'); + } + + self::assertTrue($folder->hasChildren()); + } + + /** + * Test Folder::setChildren() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetChildren(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $child_path = implode($delimiter, ['INBOX', 'test']); + if ($folder->getClient()->getFolder($child_path) === null) { + $folder->getClient()->createFolder($child_path, false); + $folder = $this->getFolder('INBOX'); + } + self::assertTrue($folder->hasChildren()); + + $folder->setChildren(new FolderCollection()); + self::assertTrue($folder->getChildren()->isEmpty()); + } + + /** + * Test Folder::getChildren() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetChildren(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $child_path = implode($delimiter, ['INBOX', 'test']); + if ($folder->getClient()->getFolder($child_path) === null) { + $folder->getClient()->createFolder($child_path, false); + } + + $folder = $folder->getClient()->getFolders()->where('name', 'INBOX')->first(); + self::assertInstanceOf(Folder::class, $folder); + + self::assertTrue($folder->hasChildren()); + self::assertFalse($folder->getChildren()->isEmpty()); + } + + /** + * Test Folder::move() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testMove(): void { + $client = $this->getClient(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'test']); + + $folder = $client->getFolder($folder_path); + if ($folder === null) { + $folder = $client->createFolder($folder_path, false); + } + $new_folder_path = implode($delimiter, ['INBOX', 'other']); + $new_folder = $client->getFolder($new_folder_path); + $new_folder?->delete(false); + + $status = $folder->move($new_folder_path, false); + self::assertIsArray($status); + self::assertTrue(str_starts_with($status[0], 'OK')); + + $new_folder = $client->getFolder($new_folder_path); + self::assertEquals($new_folder_path, $new_folder->path); + self::assertEquals('other', $new_folder->name); + + if ($this->deleteFolder($new_folder) === false) { + $this->fail("Could not delete folder: " . $new_folder->path); + } + } + + /** + * Test Folder::delete() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testDelete(): void { + $client = $this->getClient(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'test']); + + $folder = $client->getFolder($folder_path); + if ($folder === null) { + $folder = $client->createFolder($folder_path, false); + } + self::assertInstanceOf(Folder::class, $folder); + + if ($this->deleteFolder($folder) === false) { + $this->fail("Could not delete folder: " . $folder->path); + } + } + + /** + * Test Folder::overview() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + */ + public function testOverview(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $folder->select(); + + // Test empty overview + $overview = $folder->overview(); + self::assertIsArray($overview); + self::assertCount(0, $overview); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + + $overview = $folder->overview(); + + self::assertIsArray($overview); + self::assertCount(1, $overview); + + self::assertEquals($message->from->first()->full, end($overview)["from"]->toString()); + + self::assertTrue($message->delete()); + } + + /** + * Test Folder::appendMessage() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testAppendMessage(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals("Example", $message->subject); + self::assertEquals("to@someone-else.com", $message->to); + self::assertEquals("from@someone.com", $message->from); + + // Clean up + $this->assertTrue($message->delete(true)); + } + + /** + * Test Folder::subscribe() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSubscribe(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $status = $folder->subscribe(); + self::assertIsArray($status); + self::assertTrue(str_starts_with($status[0], 'OK')); + + // Clean up + $folder->unsubscribe(); + } + + /** + * Test Folder::unsubscribe() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testUnsubscribe(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $folder->subscribe(); + + $status = $folder->subscribe(); + self::assertIsArray($status); + self::assertTrue(str_starts_with($status[0], 'OK')); + } + + /** + * Test Folder::status() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testStatus(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $status = $folder->status(); + self::assertEquals(0, $status['messages']); + self::assertEquals(0, $status['recent']); + self::assertEquals(0, $status['unseen']); + self::assertGreaterThan(0, $status['uidnext']); + self::assertGreaterThan(0, $status['uidvalidity']); + } + + /** + * Test Folder::examine() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testExamine(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $status = $folder->examine(); + self::assertTrue(isset($status["flags"]) && count($status["flags"]) > 0); + self::assertTrue(($status["uidnext"] ?? 0) > 0); + self::assertTrue(($status["uidvalidity"] ?? 0) > 0); + self::assertTrue(($status["recent"] ?? -1) >= 0); + self::assertTrue(($status["exists"] ?? -1) >= 0); + } + + /** + * Test Folder::getClient() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetClient(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + self::assertInstanceOf(Client::class, $folder->getClient()); + } + + /** + * Test Folder::setDelimiter() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetDelimiter(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $folder->setDelimiter("/"); + self::assertEquals("/", $folder->delimiter); + + $folder->setDelimiter("."); + self::assertEquals(".", $folder->delimiter); + + $default_delimiter = $this->getManager()->getConfig()->get("options.delimiter", "/"); + $folder->setDelimiter(null); + self::assertEquals($default_delimiter, $folder->delimiter); + } + +} \ No newline at end of file diff --git a/tests/live/LegacyTest.php b/tests/live/LegacyTest.php new file mode 100644 index 00000000..3e385eb0 --- /dev/null +++ b/tests/live/LegacyTest.php @@ -0,0 +1,475 @@ +markTestSkipped("This test requires a live mailbox. Please set the LIVE_MAILBOX environment variable to run this test."); + } + + parent::__construct($name, $data, $dataName); + $manager = new ClientManager([ + 'options' => [ + "debug" => $_ENV["LIVE_MAILBOX_DEBUG"] ?? false, + ], + 'accounts' => [ + 'legacy' => [ + 'host' => getenv("LIVE_MAILBOX_HOST"), + 'port' => getenv("LIVE_MAILBOX_PORT"), + 'encryption' => getenv("LIVE_MAILBOX_ENCRYPTION"), + 'validate_cert' => getenv("LIVE_MAILBOX_VALIDATE_CERT"), + 'username' => getenv("LIVE_MAILBOX_USERNAME"), + 'password' => getenv("LIVE_MAILBOX_PASSWORD"), + 'protocol' => 'legacy-imap', + ], + ], + ]); + self::$client = $manager->account('legacy'); + self::$client->connect(); + self::assertInstanceOf(Client::class, self::$client->connect()); + } + + /** + * @throws RuntimeException + * @throws MessageFlagException + * @throws MessageContentFetchingException + * @throws ResponseException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ConnectionFailedException + * @throws InvalidMessageDateException + * @throws AuthFailedException + * @throws MessageHeaderFetchingException + */ + public function testSizes(): void { + + $delimiter = self::$client->getConfig()->get("options.delimiter"); + $child_path = implode($delimiter, ['INBOX', 'test']); + if (self::$client->getFolder($child_path) === null) { + self::$client->createFolder($child_path, false); + } + $folder = $this->getFolder($child_path); + + self::assertInstanceOf(Folder::class, $folder); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals(214, $message->size); + self::assertEquals(214, self::$client->getConnection()->sizes($message->uid)->array()[$message->uid]); + } + + /** + * Try to create a new query instance + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testQuery(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + self::assertInstanceOf(WhereQuery::class, $folder->query()); + self::assertInstanceOf(WhereQuery::class, $folder->search()); + self::assertInstanceOf(WhereQuery::class, $folder->messages()); + } + + /** + * Get a folder + * @param string $folder_path + * + * @return Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws FolderFetchingException + */ + final protected function getFolder(string $folder_path = "INDEX"): Folder { + $folder = self::$client->getFolderByPath($folder_path); + self::assertInstanceOf(Folder::class, $folder); + + return $folder; + } + + + /** + * Append a message to a folder + * @param Folder $folder + * @param string $message + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function appendMessage(Folder $folder, string $message): Message { + $status = $folder->select(); + if (!isset($status['uidnext'])) { + $this->fail("No UIDNEXT returned"); + } + + $response = $folder->appendMessage($message); + $valid_response = false; + foreach ($response as $line) { + if (str_starts_with($line, 'OK')) { + $valid_response = true; + break; + } + } + if (!$valid_response) { + $this->fail("Failed to append message: ".implode("\n", $response)); + } + + $message = $folder->messages()->getMessageByUid($status['uidnext']); + self::assertInstanceOf(Message::class, $message); + + return $message; + } + + /** + * Append a message template to a folder + * @param Folder $folder + * @param string $template + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function appendMessageTemplate(Folder $folder, string $template): Message { + $content = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", $template])); + return $this->appendMessage($folder, $content); + } + + /** + * Delete a folder if it is given + * @param Folder|null $folder + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function deleteFolder(?Folder $folder = null): bool { + $response = $folder?->delete(false); + if (is_array($response)) { + $valid_response = false; + foreach ($response as $line) { + if (str_starts_with($line, 'OK')) { + $valid_response = true; + break; + } + } + if (!$valid_response) { + $this->fail("Failed to delete mailbox: ".implode("\n", $response)); + } + return $valid_response; + } + return false; + } + + /** + * Try to create a new query instance with a where clause + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + * @throws GetMessagesFailedException + * @throws InvalidWhereQueryCriteriaException + * @throws MessageSearchValidationException + */ + public function testQueryWhere(): void { + $delimiter = self::$client->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'search']); + + $folder = self::$client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = self::$client->createFolder($folder_path, false); + + $messages = [ + $this->appendMessageTemplate($folder, '1366671050@github.com.eml'), + $this->appendMessageTemplate($folder, 'attachment_encoded_filename.eml'), + $this->appendMessageTemplate($folder, 'attachment_long_filename.eml'), + $this->appendMessageTemplate($folder, 'attachment_no_disposition.eml'), + $this->appendMessageTemplate($folder, 'bcc.eml'), + $this->appendMessageTemplate($folder, 'boolean_decoded_content.eml'), + $this->appendMessageTemplate($folder, 'email_address.eml'), + $this->appendMessageTemplate($folder, 'embedded_email.eml'), + $this->appendMessageTemplate($folder, 'embedded_email_without_content_disposition.eml'), + $this->appendMessageTemplate($folder, 'embedded_email_without_content_disposition-embedded.eml'), + $this->appendMessageTemplate($folder, 'example_attachment.eml'), + $this->appendMessageTemplate($folder, 'example_bounce.eml'), + $this->appendMessageTemplate($folder, 'four_nested_emails.eml'), + $this->appendMessageTemplate($folder, 'gbk_charset.eml'), + $this->appendMessageTemplate($folder, 'html_only.eml'), + $this->appendMessageTemplate($folder, 'imap_mime_header_decode_returns_false.eml'), + $this->appendMessageTemplate($folder, 'inline_attachment.eml'), + $this->appendMessageTemplate($folder, 'issue-275.eml'), + $this->appendMessageTemplate($folder, 'issue-275-2.eml'), + $this->appendMessageTemplate($folder, 'issue-348.eml'), + $this->appendMessageTemplate($folder, 'ks_c_5601-1987_headers.eml'), + $this->appendMessageTemplate($folder, 'mail_that_is_attachment.eml'), + $this->appendMessageTemplate($folder, 'missing_date.eml'), + $this->appendMessageTemplate($folder, 'missing_from.eml'), + $this->appendMessageTemplate($folder, 'mixed_filename.eml'), + $this->appendMessageTemplate($folder, 'multipart_without_body.eml'), + $this->appendMessageTemplate($folder, 'multiple_html_parts_and_attachments.eml'), + $this->appendMessageTemplate($folder, 'multiple_nested_attachments.eml'), + $this->appendMessageTemplate($folder, 'nestes_embedded_with_attachment.eml'), + $this->appendMessageTemplate($folder, 'null_content_charset.eml'), + $this->appendMessageTemplate($folder, 'pec.eml'), + $this->appendMessageTemplate($folder, 'plain.eml'), + $this->appendMessageTemplate($folder, 'plain_only.eml'), + $this->appendMessageTemplate($folder, 'plain_text_attachment.eml'), + $this->appendMessageTemplate($folder, 'references.eml'), + $this->appendMessageTemplate($folder, 'simple_multipart.eml'), + $this->appendMessageTemplate($folder, 'structured_with_attachment.eml'), + $this->appendMessageTemplate($folder, 'thread_my_topic.eml'), + $this->appendMessageTemplate($folder, 'thread_re_my_topic.eml'), + $this->appendMessageTemplate($folder, 'thread_unrelated.eml'), + $this->appendMessageTemplate($folder, 'undefined_charset_header.eml'), + $this->appendMessageTemplate($folder, 'undisclosed_recipients_minus.eml'), + $this->appendMessageTemplate($folder, 'undisclosed_recipients_space.eml'), + $this->appendMessageTemplate($folder, 'unknown_encoding.eml'), + $this->appendMessageTemplate($folder, 'without_charset_plain_only.eml'), + $this->appendMessageTemplate($folder, 'without_charset_simple_multipart.eml'), + ]; + + $folder->getClient()->expunge(); + + $query = $folder->query()->all(); + self::assertEquals(count($messages), $query->count()); + + $query = $folder->query()->whereSubject("test"); + self::assertEquals(11, $query->count()); + + $query = $folder->query()->whereOn(Carbon::now()); + self::assertEquals(count($messages), $query->count()); + + self::assertTrue($this->deleteFolder($folder)); + } + + /** + * Test query where criteria + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testQueryWhereCriteria(): void { + self::$client->reconnect(); + + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $this->assertWhereSearchCriteria($folder, 'SUBJECT', 'Test'); + $this->assertWhereSearchCriteria($folder, 'BODY', 'Test'); + $this->assertWhereSearchCriteria($folder, 'TEXT', 'Test'); + $this->assertWhereSearchCriteria($folder, 'KEYWORD', 'Test'); + $this->assertWhereSearchCriteria($folder, 'UNKEYWORD', 'Test'); + $this->assertWhereSearchCriteria($folder, 'FLAGGED', 'Seen'); + $this->assertWhereSearchCriteria($folder, 'UNFLAGGED', 'Seen'); + $this->assertHeaderSearchCriteria($folder, 'Message-ID', 'Seen'); + $this->assertHeaderSearchCriteria($folder, 'In-Reply-To', 'Seen'); + $this->assertWhereSearchCriteria($folder, 'BCC', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'CC', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'FROM', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'TO', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'UID', '1'); + $this->assertWhereSearchCriteria($folder, 'UID', '1,2'); + $this->assertWhereSearchCriteria($folder, 'ALL'); + $this->assertWhereSearchCriteria($folder, 'NEW'); + $this->assertWhereSearchCriteria($folder, 'OLD'); + $this->assertWhereSearchCriteria($folder, 'SEEN'); + $this->assertWhereSearchCriteria($folder, 'UNSEEN'); + $this->assertWhereSearchCriteria($folder, 'RECENT'); + $this->assertWhereSearchCriteria($folder, 'ANSWERED'); + $this->assertWhereSearchCriteria($folder, 'UNANSWERED'); + $this->assertWhereSearchCriteria($folder, 'DELETED'); + $this->assertWhereSearchCriteria($folder, 'UNDELETED'); + $this->assertHeaderSearchCriteria($folder, 'Content-Language','en_US'); + $this->assertWhereSearchCriteria($folder, 'CUSTOM X-Spam-Flag NO'); + $this->assertWhereSearchCriteria($folder, 'CUSTOM X-Spam-Flag YES'); + $this->assertWhereSearchCriteria($folder, 'NOT'); + $this->assertWhereSearchCriteria($folder, 'OR'); + $this->assertWhereSearchCriteria($folder, 'AND'); + $this->assertWhereSearchCriteria($folder, 'BEFORE', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'BEFORE', Carbon::now()->subDays(1), true); + $this->assertWhereSearchCriteria($folder, 'ON', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'ON', Carbon::now()->subDays(1), true); + $this->assertWhereSearchCriteria($folder, 'SINCE', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'SINCE', Carbon::now()->subDays(1), true); + } + + /** + * Assert where search criteria + * @param Folder $folder + * @param string $criteria + * @param string|Carbon|null $value + * @param bool $date + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws ResponseException + * @throws RuntimeException + */ + protected function assertWhereSearchCriteria(Folder $folder, string $criteria, Carbon|string|null $value = null, bool $date = false): void { + $query = $folder->query()->where($criteria, $value); + self::assertInstanceOf(WhereQuery::class, $query); + + $item = $query->getQuery()->first(); + $criteria = str_replace("CUSTOM ", "", $criteria); + $expected = $value === null ? [$criteria] : [$criteria, $value]; + if ($date === true && $value instanceof Carbon) { + $date_format = $folder->getClient()->getConfig()->get('date_format', 'd M y'); + $expected[1] = $value->format($date_format); + } + + self::assertIsArray($item); + self::assertIsString($item[0]); + if($value !== null) { + self::assertCount(2, $item); + self::assertIsString($item[1]); + }else{ + self::assertCount(1, $item); + } + self::assertSame($expected, $item); + } + + /** + * Assert header search criteria + * @param Folder $folder + * @param string $criteria + * @param mixed|null $value + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws ResponseException + * @throws RuntimeException + */ + protected function assertHeaderSearchCriteria(Folder $folder, string $criteria, mixed $value = null): void { + $query = $folder->query()->whereHeader($criteria, $value); + self::assertInstanceOf(WhereQuery::class, $query); + + $item = $query->getQuery()->first(); + + self::assertIsArray($item); + self::assertIsString($item[0]); + self::assertCount(1, $item); + self::assertSame(['HEADER '.$criteria.' '.$value], $item); + } +} \ No newline at end of file diff --git a/tests/live/LiveMailboxTestCase.php b/tests/live/LiveMailboxTestCase.php new file mode 100644 index 00000000..d0f1b680 --- /dev/null +++ b/tests/live/LiveMailboxTestCase.php @@ -0,0 +1,220 @@ +-@#[]_ß_б_π_€_✔_你_يد_Z_'; + + /** + * Client manager + * @var ClientManager $manager + */ + protected static ClientManager $manager; + + /** + * Get the client manager + * + * @return ClientManager + */ + final protected function getManager(): ClientManager { + if (!isset(self::$manager)) { + self::$manager = new ClientManager([ + 'options' => [ + "debug" => $_ENV["LIVE_MAILBOX_DEBUG"] ?? false, + ], + 'accounts' => [ + 'default' => [ + 'host' => getenv("LIVE_MAILBOX_HOST"), + 'port' => getenv("LIVE_MAILBOX_PORT"), + 'encryption' => getenv("LIVE_MAILBOX_ENCRYPTION"), + 'validate_cert' => getenv("LIVE_MAILBOX_VALIDATE_CERT"), + 'username' => getenv("LIVE_MAILBOX_USERNAME"), + 'password' => getenv("LIVE_MAILBOX_PASSWORD"), + 'protocol' => 'imap', //might also use imap, [pop3 or nntp (untested)] + ], + ], + ]); + } + return self::$manager; + } + + /** + * Get the client + * + * @return Client + * @throws MaskNotFoundException + */ + final protected function getClient(): Client { + if (!getenv("LIVE_MAILBOX") ?? false) { + $this->markTestSkipped("This test requires a live mailbox. Please set the LIVE_MAILBOX environment variable to run this test."); + } + return $this->getManager()->account('default'); + } + + /** + * Get special chars + * + * @return string + */ + final protected function getSpecialChars(): string { + return self::SPECIAL_CHARS; + } + + /** + * Get a folder + * @param string $folder_path + * + * @return Folder + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws FolderFetchingException + */ + final protected function getFolder(string $folder_path = "INDEX"): Folder { + $client = $this->getClient(); + self::assertInstanceOf(Client::class, $client->connect()); + + $folder = $client->getFolderByPath($folder_path); + self::assertInstanceOf(Folder::class, $folder); + + return $folder; + } + + /** + * Append a message to a folder + * @param Folder $folder + * @param string $message + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function appendMessage(Folder $folder, string $message): Message { + $status = $folder->select(); + if (!isset($status['uidnext'])) { + $this->fail("No UIDNEXT returned"); + } + + $response = $folder->appendMessage($message); + $valid_response = false; + foreach ($response as $line) { + if (str_starts_with($line, 'OK')) { + $valid_response = true; + break; + } + } + if (!$valid_response) { + $this->fail("Failed to append message: ".implode("\n", $response)); + } + + $message = $folder->messages()->getMessageByUid($status['uidnext']); + self::assertInstanceOf(Message::class, $message); + + return $message; + } + + /** + * Append a message template to a folder + * @param Folder $folder + * @param string $template + * + * @return Message + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function appendMessageTemplate(Folder $folder, string $template): Message { + $content = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", $template])); + return $this->appendMessage($folder, $content); + } + + /** + * Delete a folder if it is given + * @param Folder|null $folder + * + * @return bool + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws ResponseException + * @throws RuntimeException + */ + final protected function deleteFolder(?Folder $folder = null): bool { + $response = $folder?->delete(false); + if (is_array($response)) { + $valid_response = false; + foreach ($response as $line) { + if (str_starts_with($line, 'OK')) { + $valid_response = true; + break; + } + } + if (!$valid_response) { + $this->fail("Failed to delete mailbox: ".implode("\n", $response)); + } + return $valid_response; + } + return false; + } +} \ No newline at end of file diff --git a/tests/live/MessageTest.php b/tests/live/MessageTest.php new file mode 100644 index 00000000..841b125b --- /dev/null +++ b/tests/live/MessageTest.php @@ -0,0 +1,2410 @@ +getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + self::assertInstanceOf(Message::class, $message); + + return $message; + } + + /** + * Test Message::convertEncoding() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + * @throws MessageNotFoundException + */ + public function testConvertEncoding(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("Entwürfe+", $message->getDecoder()->convertEncoding("Entw&APw-rfe+", "UTF7-IMAP", "UTF-8")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::thread() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws GetMessagesFailedException + */ + public function testThread(): void { + $client = $this->getClient(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'thread']); + + $folder = $client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = $client->createFolder($folder_path, false); + + $message1 = $this->appendMessageTemplate($folder, "thread_my_topic.eml"); + $message2 = $this->appendMessageTemplate($folder, "thread_re_my_topic.eml"); + $message3 = $this->appendMessageTemplate($folder, "thread_unrelated.eml"); + + $thread = $message1->thread($folder); + self::assertCount(2, $thread); + + $thread = $message2->thread($folder); + self::assertCount(2, $thread); + + $thread = $message3->thread($folder); + self::assertCount(1, $thread); + + // Cleanup + self::assertTrue($message1->delete()); + self::assertTrue($message2->delete()); + self::assertTrue($message3->delete()); + $client->expunge(); + + self::assertTrue($this->deleteFolder($folder)); + } + + /** + * Test Message::hasAttachments() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testHasAttachments(): void { + $message = $this->getDefaultMessage(); + self::assertFalse($message->hasAttachments()); + + $folder = $message->getFolder(); + self::assertInstanceOf(Folder::class, $folder); + self::assertTrue($message->delete()); + + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertInstanceOf(Message::class, $message); + self::assertTrue($message->hasAttachments()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFetchOptions() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFetchOptions(): void { + $message = $this->getDefaultMessage(); + self::assertEquals(IMAP::FT_PEEK, $message->getFetchOptions()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getMessageId() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetMessageId(): void { + $folder = $this->getFolder('INBOX'); + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals("d3a5e91963cb805cee975687d5acb1c6@swift.generated", $message->getMessageId()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getReplyTo() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetReplyTo(): void { + $folder = $this->getFolder('INBOX'); + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals("testreply_to ", $message->getReplyTo()); + self::assertEquals("someone@domain.tld", $message->getReplyTo()->first()->mail); + self::assertEquals("testreply_to", $message->getReplyTo()->first()->personal); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setSequence() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetSequence(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + + $message->setSequence(IMAP::ST_MSGN); + self::assertEquals($message->msgn, $message->getSequenceId()); + + $message->setSequence(null); + self::assertEquals($message->uid, $message->getSequenceId()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getEvent() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetEvent(): void { + $message = $this->getDefaultMessage(); + + $message->setEvent("message", "test", "test"); + self::assertEquals("test", $message->getEvent("message", "test")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::__construct() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function test__construct(): void { + $message = $this->getDefaultMessage(); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFlag() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFlag(): void { + $message = $this->getDefaultMessage(); + + self::assertTrue($message->setFlag("seen")); + self::assertTrue($message->getFlags()->has("seen")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getMsgn() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetMsgn(): void { + $client = $this->getClient(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'test']); + + $folder = $client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = $client->createFolder($folder_path, false); + + $message = $this->appendMessageTemplate($folder, "plain.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals(1, $message->getMsgn()); + + // Cleanup + self::assertTrue($message->delete()); + self::assertTrue($this->deleteFolder($folder)); + } + + /** + * Test Message::peek() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testPeek(): void { + $message = $this->getDefaultMessage(); + self::assertFalse($message->getFlags()->has("seen")); + self::assertEquals(IMAP::FT_PEEK, $message->getFetchOptions()); + $message->peek(); + self::assertFalse($message->getFlags()->has("seen")); + + $message->setFetchOption(IMAP::FT_UID); + self::assertEquals(IMAP::FT_UID, $message->getFetchOptions()); + $message->peek(); + self::assertTrue($message->getFlags()->has("seen")); + + // Cleanup + self::assertTrue($message->delete()); + + } + + /** + * Test Message::unsetFlag() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testUnsetFlag(): void { + $message = $this->getDefaultMessage(); + + self::assertFalse($message->getFlags()->has("seen")); + + self::assertTrue($message->setFlag("seen")); + self::assertTrue($message->getFlags()->has("seen")); + + self::assertTrue($message->unsetFlag("seen")); + self::assertFalse($message->getFlags()->has("seen")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setSequenceId() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetSequenceId(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + + $original_sequence = $message->getSequenceId(); + + $message->setSequenceId(1, IMAP::ST_MSGN); + self::assertEquals(1, $message->getSequenceId()); + + $message->setSequenceId(1); + self::assertEquals(1, $message->getSequenceId()); + + $message->setSequenceId($original_sequence); + + // Cleanup + self::assertTrue($message->delete()); + + } + + /** + * Test Message::getTo() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetTo(): void { + $message = $this->getDefaultMessage(); + $folder = $message->getFolder(); + self::assertInstanceOf(Folder::class, $folder); + + self::assertEquals("to@someone-else.com", $message->getTo()); + self::assertTrue($message->delete()); + + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertInstanceOf(Message::class, $message); + + self::assertEquals("testnameto ", $message->getTo()); + self::assertEquals("testnameto", $message->getTo()->first()->personal); + self::assertEquals("someone@domain.tld", $message->getTo()->first()->mail); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setUid() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetUid(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + + $original_sequence = $message->getSequenceId(); + + $message->setUid(789); + self::assertEquals(789, $message->uid); + + $message->setUid($original_sequence); + + // Cleanup + self::assertTrue($message->delete()); + + } + + /** + * Test Message::getUid() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetUid(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + + $original_sequence = $message->uid; + + $message->setUid(789); + self::assertEquals(789, $message->uid); + self::assertEquals(789, $message->getUid()); + + $message->setUid($original_sequence); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::hasTextBody() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testHasTextBody(): void { + $message = $this->getDefaultMessage(); + self::assertTrue($message->hasTextBody()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::__get() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function test__get(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + self::assertEquals("Example", $message->subject); + self::assertEquals("to@someone-else.com", $message->to); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getDate() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetDate(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(Carbon::class, $message->getDate()->toDate()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setMask() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetMask(): void { + $message = $this->getDefaultMessage(); + self::assertEquals(MessageMask::class, $message->getMask()); + + $message->setMask(AttachmentMask::class); + self::assertEquals(AttachmentMask::class, $message->getMask()); + + $message->setMask(MessageMask::class); + self::assertEquals(MessageMask::class, $message->getMask()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getSequenceId() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetSequenceId(): void { + $message = $this->getDefaultMessage(); + self::assertEquals($message->uid, $message->getSequenceId()); + + $original_sequence = $message->getSequenceId(); + + $message->setSequenceId(789, IMAP::ST_MSGN); + self::assertEquals(789, $message->getSequenceId()); + + $message->setSequenceId(789); + self::assertEquals(789, $message->getSequenceId()); + + $message->setSequenceId($original_sequence); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setConfig() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetConfig(): void { + $message = $this->getDefaultMessage(); + + $options = $message->getOptions(); + self::assertIsArray($options); + + $message->setOptions(["foo" => "bar"]); + self::assertArrayHasKey("foo", $message->getOptions()); + + $message->setOptions($options); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getEvents() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetEvents(): void { + $message = $this->getDefaultMessage(); + + $events = $message->getEvents(); + self::assertIsArray($events); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFetchOption() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFetchOption(): void { + $message = $this->getDefaultMessage(); + + $fetch_option = $message->fetch_options; + + $message->setFetchOption(IMAP::FT_UID); + self::assertEquals(IMAP::FT_UID, $message->fetch_options); + + $message->setFetchOption(IMAP::FT_PEEK); + self::assertEquals(IMAP::FT_PEEK, $message->fetch_options); + + $message->setFetchOption(IMAP::FT_UID | IMAP::FT_PEEK); + self::assertEquals(IMAP::FT_UID | IMAP::FT_PEEK, $message->fetch_options); + + $message->setFetchOption($fetch_option); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getMsglist() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetMsglist(): void { + $message = $this->getDefaultMessage(); + + self::assertEquals(0, (int)$message->getMsglist()->toString()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::decodeString() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testDecodeString(): void { + $message = $this->getDefaultMessage(); + + $string = '

Test

'; + self::assertEquals('

Test

', $message->getDecoder()->decode($string, IMAP::MESSAGE_ENC_QUOTED_PRINTABLE)); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::attachments() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testAttachments(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertTrue($message->hasAttachments()); + self::assertSameSize([1], $message->attachments()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getMask() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetMask(): void { + $message = $this->getDefaultMessage(); + self::assertEquals(MessageMask::class, $message->getMask()); + + $message->setMask(AttachmentMask::class); + self::assertEquals(AttachmentMask::class, $message->getMask()); + + $message->setMask(MessageMask::class); + self::assertEquals(MessageMask::class, $message->getMask()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::hasHTMLBody() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testHasHTMLBody(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "1366671050@github.com.eml"); + self::assertTrue($message->hasHTMLBody()); + + // Cleanup + self::assertTrue($message->delete()); + + $message = $this->getDefaultMessage(); + self::assertFalse($message->hasHTMLBody()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setEvents() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetEvents(): void { + $message = $this->getDefaultMessage(); + + $events = $message->getEvents(); + self::assertIsArray($events); + + $message->setEvents(["foo" => "bar"]); + self::assertArrayHasKey("foo", $message->getEvents()); + + $message->setEvents($events); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::__set() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function test__set(): void { + $message = $this->getDefaultMessage(); + + $message->foo = "bar"; + self::assertEquals("bar", $message->getFoo()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getHTMLBody() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetHTMLBody(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "1366671050@github.com.eml"); + self::assertTrue($message->hasHTMLBody()); + self::assertIsString($message->getHTMLBody()); + + // Cleanup + self::assertTrue($message->delete()); + + $message = $this->getDefaultMessage(); + self::assertFalse($message->hasHTMLBody()); + self::assertEmpty($message->getHTMLBody()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getSequence() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetSequence(): void { + $message = $this->getDefaultMessage(); + self::assertEquals(IMAP::ST_UID, $message->getSequence()); + + $original_sequence = $message->getSequence(); + + $message->setSequence(IMAP::ST_MSGN); + self::assertEquals(IMAP::ST_MSGN, $message->getSequence()); + + $message->setSequence($original_sequence); + self::assertEquals(IMAP::ST_UID, $message->getSequence()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::restore() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testRestore(): void { + $message = $this->getDefaultMessage(); + + $message->setFlag("deleted"); + self::assertTrue($message->hasFlag("deleted")); + + $message->restore(); + self::assertFalse($message->hasFlag("deleted")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getPriority() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetPriority(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertEquals(1, $message->getPriority()->first()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setAttachments() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetAttachments(): void { + $message = $this->getDefaultMessage(); + + $message->setAttachments(new AttachmentCollection(["foo" => "bar"])); + self::assertIsArray($message->attachments()->toArray()); + self::assertTrue($message->attachments()->has("foo")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFrom() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFrom(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("from@someone.com", $message->getFrom()->first()->mail); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setEvent() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetEvent(): void { + $message = $this->getDefaultMessage(); + + $message->setEvent("message", "bar", "foo"); + self::assertArrayHasKey("bar", $message->getEvents()["message"]); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getInReplyTo() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetInReplyTo(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("", $message->getInReplyTo()); + + // Cleanup + self::assertTrue($message->delete()); + + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "1366671050@github.com.eml"); + self::assertEquals("Webklex/php-imap/issues/349@github.com", $message->getInReplyTo()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::copy() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testCopy(): void { + $message = $this->getDefaultMessage(); + $client = $message->getClient(); + self::assertInstanceOf(Client::class, $client); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'test']); + + $folder = $client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = $client->createFolder($folder_path, false); + self::assertInstanceOf(Folder::class, $folder); + + $new_message = $message->copy($folder->path, true); + self::assertInstanceOf(Message::class, $new_message); + self::assertEquals($folder->path, $new_message->getFolder()->path); + + // Cleanup + self::assertTrue($message->delete()); + self::assertTrue($new_message->delete()); + + } + + /** + * Test Message::getBodies() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetBodies(): void { + $message = $this->getDefaultMessage(); + self::assertIsArray($message->getBodies()); + self::assertCount(1, $message->getBodies()); + + // Cleanup + self::assertTrue($message->delete()); + + } + + /** + * Test Message::getFlags() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFlags(): void { + $message = $this->getDefaultMessage(); + self::assertIsArray($message->getFlags()->all()); + + self::assertFalse($message->hasFlag("seen")); + + self::assertTrue($message->setFlag("seen")); + self::assertTrue($message->getFlags()->has("seen")); + self::assertTrue($message->hasFlag("seen")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::addFlag() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testAddFlag(): void { + $message = $this->getDefaultMessage(); + self::assertFalse($message->hasFlag("seen")); + + self::assertTrue($message->addFlag("seen")); + self::assertTrue($message->hasFlag("seen")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getSubject() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetSubject(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("Example", $message->getSubject()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getClient() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetClient(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(Client::class, $message->getClient()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFetchFlagsOption() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFetchFlagsOption(): void { + $message = $this->getDefaultMessage(); + + self::assertTrue($message->getFetchFlagsOption()); + $message->setFetchFlagsOption(false); + self::assertFalse($message->getFetchFlagsOption()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::mask() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testMask(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(MessageMask::class, $message->mask()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setMsglist() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetMsglist(): void { + $message = $this->getDefaultMessage(); + $message->setMsglist("foo"); + self::assertEquals("foo", $message->getMsglist()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::flags() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testFlags(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(FlagCollection::class, $message->flags()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getAttributes() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetAttributes(): void { + $message = $this->getDefaultMessage(); + self::assertIsArray($message->getAttributes()); + self::assertArrayHasKey("subject", $message->getAttributes()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getAttachments() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetAttachments(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(AttachmentCollection::class, $message->getAttachments()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getRawBody() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetRawBody(): void { + $message = $this->getDefaultMessage(); + self::assertIsString($message->getRawBody()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::is() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testIs(): void { + $message = $this->getDefaultMessage(); + self::assertTrue($message->is($message)); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFlags() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFlags(): void { + $message = $this->getDefaultMessage(); + $message->setFlags(new FlagCollection()); + self::assertFalse($message->hasFlag("recent")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::make() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws ResponseException + * @throws RuntimeException + * @throws ReflectionException + */ + public function testMake(): void { + $folder = $this->getFolder('INBOX'); + $folder->getClient()->openFolder($folder->path); + + $email = file_get_contents(implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", "1366671050@github.com.eml"])); + if(!str_contains($email, "\r\n")){ + $email = str_replace("\n", "\r\n", $email); + } + + $raw_header = substr($email, 0, strpos($email, "\r\n\r\n")); + $raw_body = substr($email, strlen($raw_header)+8); + + $message = Message::make(0, null, $folder->getClient(), $raw_header, $raw_body, [0 => "\\Seen"], IMAP::ST_UID); + self::assertInstanceOf(Message::class, $message); + } + + /** + * Test Message::setAvailableFlags() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetAvailableFlags(): void { + $message = $this->getDefaultMessage(); + + $message->setAvailableFlags(["foo"]); + self::assertSameSize(["foo"], $message->getAvailableFlags()); + self::assertEquals("foo", $message->getAvailableFlags()[0]); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getSender() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetSender(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "example_attachment.eml"); + self::assertEquals("testsender ", $message->getSender()); + self::assertEquals("testsender", $message->getSender()->first()->personal); + self::assertEquals("someone@domain.tld", $message->getSender()->first()->mail); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::fromFile() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws ReflectionException + * @throws ResponseException + * @throws RuntimeException + */ + public function testFromFile(): void { + $this->getManager(); + $filename = implode(DIRECTORY_SEPARATOR, [__DIR__, "..", "messages", "1366671050@github.com.eml"]); + $message = Message::fromFile($filename); + self::assertInstanceOf(Message::class, $message); + } + + /** + * Test Message::getStructure() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetStructure(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(Structure::class, $message->getStructure()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::get() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + * @throws MessageSizeFetchingException + */ + public function testGet(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("Example", $message->get("subject")); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getSize() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetSize(): void { + $message = $this->getDefaultMessage(); + self::assertEquals(214, $message->getSize()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getHeader() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetHeader(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(Header::class, $message->getHeader()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getReferences() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetReferences(): void { + $folder = $this->getFolder('INBOX'); + + $message = $this->appendMessageTemplate($folder, "1366671050@github.com.eml"); + self::assertIsArray($message->getReferences()->all()); + self::assertEquals("Webklex/php-imap/issues/349@github.com", $message->getReferences()->first()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFolderPath() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFolderPath(): void { + $message = $this->getDefaultMessage(); + + $folder_path = $message->getFolderPath(); + + $message->setFolderPath("foo"); + self::assertEquals("foo", $message->getFolderPath()); + + $message->setFolderPath($folder_path); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getTextBody() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetTextBody(): void { + $message = $this->getDefaultMessage(); + self::assertIsString($message->getTextBody()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::move() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testMove(): void { + $message = $this->getDefaultMessage(); + $client = $message->getClient(); + self::assertInstanceOf(Client::class, $client); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'test']); + + $folder = $client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = $client->createFolder($folder_path, false); + self::assertInstanceOf(Folder::class, $folder); + + $message = $message->move($folder->path, true); + self::assertInstanceOf(Message::class, $message); + self::assertEquals($folder->path, $message->getFolder()->path); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFolderPath() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFolderPath(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("INBOX", $message->getFolderPath()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFolder() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFolder(): void { + $message = $this->getDefaultMessage(); + self::assertInstanceOf(Folder::class, $message->getFolder()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFetchBodyOption() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFetchBodyOption(): void { + $message = $this->getDefaultMessage(); + self::assertTrue($message->getFetchBodyOption()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setFetchBodyOption() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetFetchBodyOption(): void { + $message = $this->getDefaultMessage(); + + $message->setFetchBodyOption(false); + self::assertFalse($message->getFetchBodyOption()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::getFetchFlagsOption() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testGetFetchFlagsOption(): void { + $message = $this->getDefaultMessage(); + self::assertTrue($message->getFetchFlagsOption()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::__call() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function test__call(): void { + $message = $this->getDefaultMessage(); + self::assertEquals("Example", $message->getSubject()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setClient() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetClient(): void { + $message = $this->getDefaultMessage(); + $client = $message->getClient(); + self::assertInstanceOf(Client::class, $client); + + $message->setClient(null); + self::assertNull($message->getClient()); + + $message->setClient($client); + self::assertInstanceOf(Client::class, $message->getClient()); + + // Cleanup + self::assertTrue($message->delete()); + } + + /** + * Test Message::setMsgn() + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws MessageNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testSetMsgn(): void { + $message = $this->getDefaultMessage(); + + $uid = $message->getUid(); + $message->setMsgn(789); + self::assertEquals(789, $message->getMsgn()); + $message->setUid($uid); + + // Cleanup + self::assertTrue($message->delete()); + } +} diff --git a/tests/live/QueryTest.php b/tests/live/QueryTest.php new file mode 100644 index 00000000..194d804e --- /dev/null +++ b/tests/live/QueryTest.php @@ -0,0 +1,283 @@ +getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + self::assertInstanceOf(WhereQuery::class, $folder->query()); + self::assertInstanceOf(WhereQuery::class, $folder->search()); + self::assertInstanceOf(WhereQuery::class, $folder->messages()); + } + + /** + * Try to create a new query instance with a where clause + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws EventNotFoundException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidMessageDateException + * @throws MaskNotFoundException + * @throws MessageContentFetchingException + * @throws MessageFlagException + * @throws MessageHeaderFetchingException + * @throws ResponseException + * @throws RuntimeException + * @throws GetMessagesFailedException + * @throws InvalidWhereQueryCriteriaException + * @throws MessageSearchValidationException + */ + public function testQueryWhere(): void { + $client = $this->getClient(); + + $delimiter = $this->getManager()->getConfig()->get("options.delimiter"); + $folder_path = implode($delimiter, ['INBOX', 'search']); + + $folder = $client->getFolder($folder_path); + if ($folder !== null) { + self::assertTrue($this->deleteFolder($folder)); + } + $folder = $client->createFolder($folder_path, false); + + $messages = [ + $this->appendMessageTemplate($folder, '1366671050@github.com.eml'), + $this->appendMessageTemplate($folder, 'attachment_encoded_filename.eml'), + $this->appendMessageTemplate($folder, 'attachment_long_filename.eml'), + $this->appendMessageTemplate($folder, 'attachment_no_disposition.eml'), + $this->appendMessageTemplate($folder, 'bcc.eml'), + $this->appendMessageTemplate($folder, 'boolean_decoded_content.eml'), + $this->appendMessageTemplate($folder, 'email_address.eml'), + $this->appendMessageTemplate($folder, 'embedded_email.eml'), + $this->appendMessageTemplate($folder, 'embedded_email_without_content_disposition.eml'), + $this->appendMessageTemplate($folder, 'embedded_email_without_content_disposition-embedded.eml'), + $this->appendMessageTemplate($folder, 'example_attachment.eml'), + $this->appendMessageTemplate($folder, 'example_bounce.eml'), + $this->appendMessageTemplate($folder, 'four_nested_emails.eml'), + $this->appendMessageTemplate($folder, 'gbk_charset.eml'), + $this->appendMessageTemplate($folder, 'html_only.eml'), + $this->appendMessageTemplate($folder, 'imap_mime_header_decode_returns_false.eml'), + $this->appendMessageTemplate($folder, 'inline_attachment.eml'), + $this->appendMessageTemplate($folder, 'issue-275.eml'), + $this->appendMessageTemplate($folder, 'issue-275-2.eml'), + $this->appendMessageTemplate($folder, 'issue-348.eml'), + $this->appendMessageTemplate($folder, 'ks_c_5601-1987_headers.eml'), + $this->appendMessageTemplate($folder, 'mail_that_is_attachment.eml'), + $this->appendMessageTemplate($folder, 'missing_date.eml'), + $this->appendMessageTemplate($folder, 'missing_from.eml'), + $this->appendMessageTemplate($folder, 'mixed_filename.eml'), + $this->appendMessageTemplate($folder, 'multipart_without_body.eml'), + $this->appendMessageTemplate($folder, 'multiple_html_parts_and_attachments.eml'), + $this->appendMessageTemplate($folder, 'multiple_nested_attachments.eml'), + $this->appendMessageTemplate($folder, 'nestes_embedded_with_attachment.eml'), + $this->appendMessageTemplate($folder, 'null_content_charset.eml'), + $this->appendMessageTemplate($folder, 'pec.eml'), + $this->appendMessageTemplate($folder, 'plain.eml'), + $this->appendMessageTemplate($folder, 'plain_only.eml'), + $this->appendMessageTemplate($folder, 'plain_text_attachment.eml'), + $this->appendMessageTemplate($folder, 'references.eml'), + $this->appendMessageTemplate($folder, 'simple_multipart.eml'), + $this->appendMessageTemplate($folder, 'structured_with_attachment.eml'), + $this->appendMessageTemplate($folder, 'thread_my_topic.eml'), + $this->appendMessageTemplate($folder, 'thread_re_my_topic.eml'), + $this->appendMessageTemplate($folder, 'thread_unrelated.eml'), + $this->appendMessageTemplate($folder, 'undefined_charset_header.eml'), + $this->appendMessageTemplate($folder, 'undisclosed_recipients_minus.eml'), + $this->appendMessageTemplate($folder, 'undisclosed_recipients_space.eml'), + $this->appendMessageTemplate($folder, 'unknown_encoding.eml'), + $this->appendMessageTemplate($folder, 'without_charset_plain_only.eml'), + $this->appendMessageTemplate($folder, 'without_charset_simple_multipart.eml'), + ]; + + $folder->getClient()->expunge(); + + $query = $folder->query()->all(); + self::assertEquals(count($messages), $query->count()); + + $query = $folder->query()->whereSubject("test"); + self::assertEquals(11, $query->count()); + + $query = $folder->query()->whereOn(Carbon::now()); + self::assertEquals(count($messages), $query->count()); + + self::assertTrue($this->deleteFolder($folder)); + } + + /** + * Test query where criteria + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws FolderFetchingException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws MaskNotFoundException + * @throws ResponseException + * @throws RuntimeException + */ + public function testQueryWhereCriteria(): void { + $folder = $this->getFolder('INBOX'); + self::assertInstanceOf(Folder::class, $folder); + + $this->assertWhereSearchCriteria($folder, 'SUBJECT', 'Test'); + $this->assertWhereSearchCriteria($folder, 'BODY', 'Test'); + $this->assertWhereSearchCriteria($folder, 'TEXT', 'Test'); + $this->assertWhereSearchCriteria($folder, 'KEYWORD', 'Test'); + $this->assertWhereSearchCriteria($folder, 'UNKEYWORD', 'Test'); + $this->assertWhereSearchCriteria($folder, 'FLAGGED', 'Seen'); + $this->assertWhereSearchCriteria($folder, 'UNFLAGGED', 'Seen'); + $this->assertHeaderSearchCriteria($folder, 'Message-ID', 'Seen'); + $this->assertHeaderSearchCriteria($folder, 'In-Reply-To', 'Seen'); + $this->assertWhereSearchCriteria($folder, 'BCC', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'CC', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'FROM', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'TO', 'test@example.com'); + $this->assertWhereSearchCriteria($folder, 'UID', '1'); + $this->assertWhereSearchCriteria($folder, 'UID', '1,2'); + $this->assertWhereSearchCriteria($folder, 'ALL'); + $this->assertWhereSearchCriteria($folder, 'NEW'); + $this->assertWhereSearchCriteria($folder, 'OLD'); + $this->assertWhereSearchCriteria($folder, 'SEEN'); + $this->assertWhereSearchCriteria($folder, 'UNSEEN'); + $this->assertWhereSearchCriteria($folder, 'RECENT'); + $this->assertWhereSearchCriteria($folder, 'ANSWERED'); + $this->assertWhereSearchCriteria($folder, 'UNANSWERED'); + $this->assertWhereSearchCriteria($folder, 'DELETED'); + $this->assertWhereSearchCriteria($folder, 'UNDELETED'); + $this->assertHeaderSearchCriteria($folder, 'Content-Language','en_US'); + $this->assertWhereSearchCriteria($folder, 'CUSTOM X-Spam-Flag NO'); + $this->assertWhereSearchCriteria($folder, 'CUSTOM X-Spam-Flag YES'); + $this->assertWhereSearchCriteria($folder, 'NOT'); + $this->assertWhereSearchCriteria($folder, 'OR'); + $this->assertWhereSearchCriteria($folder, 'AND'); + $this->assertWhereSearchCriteria($folder, 'BEFORE', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'BEFORE', Carbon::now()->subDays(1), true); + $this->assertWhereSearchCriteria($folder, 'ON', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'ON', Carbon::now()->subDays(1), true); + $this->assertWhereSearchCriteria($folder, 'SINCE', '01-Jan-2020', true); + $this->assertWhereSearchCriteria($folder, 'SINCE', Carbon::now()->subDays(1), true); + } + + /** + * Assert where search criteria + * @param Folder $folder + * @param string $criteria + * @param string|Carbon|null $value + * @param bool $date + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws ResponseException + * @throws RuntimeException + */ + protected function assertWhereSearchCriteria(Folder $folder, string $criteria, Carbon|string|null $value = null, bool $date = false): void { + $query = $folder->query()->where($criteria, $value); + self::assertInstanceOf(WhereQuery::class, $query); + + $item = $query->getQuery()->first(); + $criteria = str_replace("CUSTOM ", "", $criteria); + $expected = $value === null ? [$criteria] : [$criteria, $value]; + if ($date === true && $value instanceof Carbon) { + $date_format = $folder->getClient()->getConfig()->get('date_format', 'd M y'); + $expected[1] = $value->format($date_format); + } + + self::assertIsArray($item); + self::assertIsString($item[0]); + if($value !== null) { + self::assertCount(2, $item); + self::assertIsString($item[1]); + }else{ + self::assertCount(1, $item); + } + self::assertSame($expected, $item); + } + + /** + * Assert header search criteria + * @param Folder $folder + * @param string $criteria + * @param mixed|null $value + * + * @return void + * @throws AuthFailedException + * @throws ConnectionFailedException + * @throws ImapBadRequestException + * @throws ImapServerErrorException + * @throws InvalidWhereQueryCriteriaException + * @throws ResponseException + * @throws RuntimeException + */ + protected function assertHeaderSearchCriteria(Folder $folder, string $criteria, mixed $value = null): void { + $query = $folder->query()->whereHeader($criteria, $value); + self::assertInstanceOf(WhereQuery::class, $query); + + $item = $query->getQuery()->first(); + + self::assertIsArray($item); + self::assertIsString($item[0]); + self::assertCount(1, $item); + self::assertSame(['HEADER '.$criteria.' '.$value], $item); + } +} \ No newline at end of file diff --git a/tests/messages/1366671050@github.com.eml b/tests/messages/1366671050@github.com.eml new file mode 100644 index 00000000..97cb7ad1 --- /dev/null +++ b/tests/messages/1366671050@github.com.eml @@ -0,0 +1,108 @@ +Return-Path: +Delivered-To: someone@domain.tld +Received: from mx.domain.tld + by localhost with LMTP + id SABVMNfGqWP+PAAA0J78UA + (envelope-from ) + for ; Mon, 26 Dec 2022 17:07:51 +0100 +Received: from localhost (localhost [127.0.0.1]) + by mx.domain.tld (Postfix) with ESMTP id C3828140227 + for ; Mon, 26 Dec 2022 17:07:51 +0100 (CET) +X-Virus-Scanned: Debian amavisd-new at mx.domain.tld +X-Spam-Flag: NO +X-Spam-Score: -4.299 +X-Spam-Level: +X-Spam-Status: No, score=-4.299 required=6.31 tests=[BAYES_00=-1.9, + DKIMWL_WL_HIGH=-0.001, DKIM_SIGNED=0.1, DKIM_VALID=-0.1, + DKIM_VALID_AU=-0.1, DKIM_VALID_EF=-0.1, HTML_IMAGE_ONLY_16=1.092, + HTML_MESSAGE=0.001, MAILING_LIST_MULTI=-1, RCVD_IN_DNSWL_MED=-2.3, + RCVD_IN_MSPIKE_H2=-0.001, T_KAM_HTML_FONT_INVALID=0.01] + autolearn=ham autolearn_force=no +Received: from mx.domain.tld ([127.0.0.1]) + by localhost (mx.domain.tld [127.0.0.1]) (amavisd-new, port 10024) + with ESMTP id JcIS9RuNBTNx for ; + Mon, 26 Dec 2022 17:07:21 +0100 (CET) +Received: from smtp.github.com (out-26.smtp.github.com [192.30.252.209]) + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) + (No client certificate requested) + by mx.domain.tld (Postfix) with ESMTPS id 6410B13FEB2 + for ; Mon, 26 Dec 2022 17:07:21 +0100 (CET) +Received: from github-lowworker-891b8d2.va3-iad.github.net (github-lowworker-891b8d2.va3-iad.github.net [10.48.109.104]) + by smtp.github.com (Postfix) with ESMTP id 176985E0200 + for ; Mon, 26 Dec 2022 08:07:14 -0800 (PST) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=github.com; + s=pf2014; t=1672070834; + bh=v91TPiLpM/cpUb4lgt2NMIUfM4HOIxCEWMR1+JTco+Q=; + h=Date:From:Reply-To:To:Cc:In-Reply-To:References:Subject:List-ID: + List-Archive:List-Post:List-Unsubscribe:From; + b=jW4Tac9IjWAPbEImyiYf1bzGLzY3ceohVbBg1V8BlpMTQ+o+yY3YB0eOe20hAsqZR + jrDjArx7rKQcslqBFL/b2B1C51rHuCBrz2cccLEERu9l/u0mTGCxTNtCRXHbCKbnR1 + VLWBeFLjATHth83kK6Kt7lkVuty+G3V1B6ZKPhCI= +Date: Mon, 26 Dec 2022 08:07:14 -0800 +From: Username +Reply-To: Webklex/php-imap +To: Webklex/php-imap +Cc: Subscribed +Message-ID: +In-Reply-To: +References: +Subject: Re: [Webklex/php-imap] Read all folders? (Issue #349) +Mime-Version: 1.0 +Content-Type: multipart/alternative; + boundary="--==_mimepart_63a9c6b293fe_65b5c71014155a"; + charset=UTF-8 +Content-Transfer-Encoding: 7bit +Precedence: list +X-GitHub-Sender: consigliere23 +X-GitHub-Recipient: Webklex +X-GitHub-Reason: subscribed +List-ID: Webklex/php-imap +List-Archive: https://github.com/Webklex/php-imap +List-Post: +List-Unsubscribe: , + +X-Auto-Response-Suppress: All +X-GitHub-Recipient-Address: someone@domain.tld + + +----==_mimepart_63a9c6b293fe_65b5c71014155a +Content-Type: text/plain; + charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Any updates? + +-- +Reply to this email directly or view it on GitHub: +https://github.com/Webklex/php-imap/issues/349#issuecomment-1365266070 +You are receiving this because you are subscribed to this thread. + +Message ID: +----==_mimepart_63a9c6b293fe_65b5c71014155a +Content-Type: text/html; + charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

+

Any updates?

+ +


Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you are subscribed to this thread.Message ID: <Webklex/php-imap/issues/349/1365266070@github.com>

+ +----==_mimepart_63a9c6b293fe_65b5c71014155a-- diff --git a/tests/messages/attachment_encoded_filename.eml b/tests/messages/attachment_encoded_filename.eml new file mode 100644 index 00000000..231d0452 --- /dev/null +++ b/tests/messages/attachment_encoded_filename.eml @@ -0,0 +1,10 @@ +Content-Type: multipart/mixed; + boundary="BOUNDARY" + +--BOUNDARY +Content-Type: application/vnd.ms-excel; name="=?UTF-8?Q?Prost=C5=99eno=5F2014=5Fposledn=C3=AD_voln=C3=A9_term=C3=ADny.xls?="; charset="UTF-8" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="=?UTF-8?Q?Prost=C5=99eno=5F2014=5Fposledn=C3=AD_voln=C3=A9_term=C3=ADny.xls?=" + +0M8R4KGxGuEAAAAAAAAAAAAAAAAAAAAAPgADAP7/CQAGAAAAAAAAAAAAAAACAAAAwgAAAAAA +AAAAEAAA/v///wAAAAD+////AAAAAMAAAADBAAAA//////////////////////////////// diff --git a/tests/messages/attachment_long_filename.eml b/tests/messages/attachment_long_filename.eml new file mode 100644 index 00000000..8630280f --- /dev/null +++ b/tests/messages/attachment_long_filename.eml @@ -0,0 +1,55 @@ +Content-Type: multipart/mixed; + boundary="BOUNDARY" + +--BOUNDARY +Content-Type: text/plain; + name*0*=utf-8''Buchungsbest%C3%A4tigung-%20Rechnung-Gesch%C3%A4ftsbedingung; + name*1*=en-Nr.B123-45%20-%20XXXX%20xxxxxxxxxxxxxxxxx%20XxxX%2C%20L%C3%BCd; + name*2*=xxxxxxxx%20-%20VM%20Klaus%20XXXXXX%20-%20xxxxxxxx.pdf +Content-Disposition: attachment; + filename*0*=utf-8''Buchungsbest%C3%A4tigung-%20Rechnung-Gesch%C3%A4ftsbedin; + filename*1*=gungen-Nr.B123-45%20-%20XXXXX%20xxxxxxxxxxxxxxxxx%20XxxX%2C; + filename*2*=%20L%C3%BCxxxxxxxxxx%20-%20VM%20Klaus%20XXXXXX%20-%20xxxxxxxx.p; + filename*3*=df +Content-Transfer-Encoding: base64 + +SGkh +--BOUNDARY +Content-Type: text/plain; charset=UTF-8; + name="=?UTF-8?B?MDFfQeKCrMOgw6TEhdCx2YrYr0BaLTAxMjM0NTY3ODktcXdlcnR5dWlv?= + =?UTF-8?Q?pasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzx?= + =?UTF-8?Q?cvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstu?= + =?UTF-8?Q?vz.txt?=" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename*0*=iso-8859-15''%30%31%5F%41%A4%E0%E4%3F%3F%3F%3F%40%5A%2D%30%31; + filename*1*=%32%33%34%35%36%37%38%39%2D%71%77%65%72%74%79%75%69%6F%70%61; + filename*2*=%73%64%66%67%68%6A%6B%6C%7A%78%63%76%62%6E%6D%6F%70%71%72%73; + filename*3*=%74%75%76%7A%2D%30%31%32%33%34%35%36%37%38%39%2D%71%77%65%72; + filename*4*=%74%79%75%69%6F%70%61%73%64%66%67%68%6A%6B%6C%7A%78%63%76%62; + filename*5*=%6E%6D%6F%70%71%72%73%74%75%76%7A%2D%30%31%32%33%34%35%36%37; + filename*6*=%38%39%2D%71%77%65%72%74%79%75%69%6F%70%61%73%64%66%67%68%6A; + filename*7*=%6B%6C%7A%78%63%76%62%6E%6D%6F%70%71%72%73%74%75%76%7A%2E%74; + filename*8*=%78%74 + +SGkh +--BOUNDARY +Content-Type: text/plain; charset=UTF-8; + name="=?UTF-8?B?MDJfQeKCrMOgw6TEhdCx2YrYr0BaLTAxMjM0NTY3ODktcXdlcnR5dWlv?= + =?UTF-8?Q?pasdfghjklzxcvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzx?= + =?UTF-8?Q?cvbnmopqrstuvz-0123456789-qwertyuiopasdfghjklzxcvbnmopqrstu?= + =?UTF-8?Q?vz.txt?=" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename*0*=UTF-8''%30%32%5F%41%E2%82%AC%C3%A0%C3%A4%C4%85%D0%B1%D9%8A%D8; + filename*1*=%AF%40%5A%2D%30%31%32%33%34%35%36%37%38%39%2D%71%77%65%72%74; + filename*2*=%79%75%69%6F%70%61%73%64%66%67%68%6A%6B%6C%7A%78%63%76%62%6E; + filename*3*=%6D%6F%70%71%72%73%74%75%76%7A%2D%30%31%32%33%34%35%36%37%38; + filename*4*=%39%2D%71%77%65%72%74%79%75%69%6F%70%61%73%64%66%67%68%6A%6B; + filename*5*=%6C%7A%78%63%76%62%6E%6D%6F%70%71%72%73%74%75%76%7A%2D%30%31; + filename*6*=%32%33%34%35%36%37%38%39%2D%71%77%65%72%74%79%75%69%6F%70%61; + filename*7*=%73%64%66%67%68%6A%6B%6C%7A%78%63%76%62%6E%6D%6F%70%71%72%73; + filename*8*=%74%75%76%7A%2E%74%78%74 + +SGkh +--BOUNDARY-- diff --git a/tests/messages/attachment_no_disposition.eml b/tests/messages/attachment_no_disposition.eml new file mode 100644 index 00000000..ce0ea3e9 --- /dev/null +++ b/tests/messages/attachment_no_disposition.eml @@ -0,0 +1,9 @@ +Content-Type: multipart/mixed; + boundary="BOUNDARY" + +--BOUNDARY +Content-Type: application/vnd.ms-excel; name="=?UTF-8?Q?Prost=C5=99eno=5F2014=5Fposledn=C3=AD_voln=C3=A9_term=C3=ADny.xls?="; charset="UTF-8" +Content-Transfer-Encoding: base64 + +0M8R4KGxGuEAAAAAAAAAAAAAAAAAAAAAPgADAP7/CQAGAAAAAAAAAAAAAAACAAAAwgAAAAAA +AAAAEAAA/v///wAAAAD+////AAAAAMAAAADBAAAA//////////////////////////////// diff --git a/tests/messages/bcc.eml b/tests/messages/bcc.eml new file mode 100644 index 00000000..1a6f16a8 --- /dev/null +++ b/tests/messages/bcc.eml @@ -0,0 +1,12 @@ +Return-Path: +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +From: from@there.com +To: to@here.com +Bcc: =?UTF-8?B?QV/igqxAe8OoX1o=?= +Reply-To: reply-to@here.com +Sender: sender@here.com + +Hi! diff --git a/tests/messages/boolean_decoded_content.eml b/tests/messages/boolean_decoded_content.eml new file mode 100644 index 00000000..91818038 --- /dev/null +++ b/tests/messages/boolean_decoded_content.eml @@ -0,0 +1,31 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: multipart/mixed; boundary="=-vyqYb0SSRwuGFKv/Trdf" + +--=-vyqYb0SSRwuGFKv/Trdf +Content-Type: multipart/alternative; boundary="=-ewUvwipK68Y6itClYNpy" + +--=-ewUvwipK68Y6itClYNpy +Content-Type: text/plain; charset="us-ascii" + +Here is the problem mail + +Body text +--=-ewUvwipK68Y6itClYNpy +Content-Type: text/html; charset="us-ascii" + +Here is the problem mail + +Body text +--=-ewUvwipK68Y6itClYNpy-- + +--=-vyqYb0SSRwuGFKv/Trdf +Content-Type: application/pdf; name="Example Domain.pdf" +Content-Disposition: attachment; filename="Example Domain.pdf" +Content-Transfer-Encoding: base64 + +nnDusSNdG92w6Fuw61fMjAxOF8wMy0xMzMyNTMzMTkzLnBkZg==?= + +--=-vyqYb0SSRwuGFKv/Trdf-- diff --git a/tests/messages/date-template.eml b/tests/messages/date-template.eml new file mode 100644 index 00000000..9354fd10 --- /dev/null +++ b/tests/messages/date-template.eml @@ -0,0 +1,8 @@ +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: %date_raw_header% +From: from@there.com +To: to@here.com + +Hi! diff --git a/tests/messages/email_address.eml b/tests/messages/email_address.eml new file mode 100644 index 00000000..62b76d70 --- /dev/null +++ b/tests/messages/email_address.eml @@ -0,0 +1,9 @@ +Message-ID: <123@example.com> +From: no_host +Cc: "This one: is \"right\"" , No-address +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi +How are you? diff --git a/tests/messages/embedded_email.eml b/tests/messages/embedded_email.eml new file mode 100644 index 00000000..293bc28e --- /dev/null +++ b/tests/messages/embedded_email.eml @@ -0,0 +1,63 @@ +Return-Path: demo@cerstor.cz +Received: from webmail.my-office.cz (localhost [127.0.0.1]) + by keira.cofis.cz + ; Fri, 29 Jan 2016 14:25:40 +0100 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="=_5d4b2de51e6aad0435b4bfbd7a23594a" +Date: Fri, 29 Jan 2016 14:25:40 +0100 +From: demo@cerstor.cz +To: demo@cerstor.cz +Subject: embedded message +Message-ID: <7e5798da5747415e5b82fdce042ab2a6@cerstor.cz> +X-Sender: demo@cerstor.cz +User-Agent: Roundcube Webmail/1.0.0 + +--=_5d4b2de51e6aad0435b4bfbd7a23594a +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; charset=US-ASCII; + format=flowed + +email that contains embedded message +--=_5d4b2de51e6aad0435b4bfbd7a23594a +Content-Transfer-Encoding: 8bit +Content-Type: message/rfc822; + name=demo.eml +Content-Disposition: attachment; + filename=demo.eml; + size=889 + +Return-Path: demo@cerstor.cz +Received: from webmail.my-office.cz (localhost [127.0.0.1]) + by keira.cofis.cz + ; Fri, 29 Jan 2016 14:22:13 +0100 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="=_995890bdbf8bd158f2cbae0e8d966000" +Date: Fri, 29 Jan 2016 14:22:13 +0100 +From: demo-from@cerstor.cz +To: demo-to@cerstor.cz +Subject: demo +Message-ID: <4cbaf57cb00891c53b32e1d63367740c@cerstor.cz> +X-Sender: demo@cerstor.cz +User-Agent: Roundcube Webmail/1.0.0 + +--=_995890bdbf8bd158f2cbae0e8d966000 +Content-Transfer-Encoding: 7bit +Content-Type: text/plain; charset=US-ASCII; + format=flowed + +demo text +--=_995890bdbf8bd158f2cbae0e8d966000 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; + name=testfile.txt +Content-Disposition: attachment; + filename=testfile.txt; + size=29 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= +--=_995890bdbf8bd158f2cbae0e8d966000-- + + +--=_5d4b2de51e6aad0435b4bfbd7a23594a-- diff --git a/tests/messages/embedded_email_without_content_disposition-embedded.eml b/tests/messages/embedded_email_without_content_disposition-embedded.eml new file mode 100644 index 00000000..df5fa40b --- /dev/null +++ b/tests/messages/embedded_email_without_content_disposition-embedded.eml @@ -0,0 +1,61 @@ +Received: from webmail.my-office.cz (localhost [127.0.0.1]) + by keira.cofis.cz + ; Fri, 29 Jan 2016 14:25:40 +0100 +From: demo@cerstor.cz +To: demo@cerstor.cz +Date: Fri, 5 Apr 2019 12:10:49 +0200 +Subject: embedded_message_subject +Message-ID: +Accept-Language: pl-PL, nl-NL +Content-Language: pl-PL +Content-Type: multipart/mixed; + boundary="_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_" +MIME-Version: 1.0 + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: multipart/alternative; + boundary="_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_" + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: text/plain; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +some txt + + + + + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: text/html; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + + +

some txt

+ + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_-- + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="file1.xlsx" +Content-Description: file1.xlsx +Content-Disposition: attachment; filename="file1.xlsx"; size=29; + creation-date="Fri, 05 Apr 2019 10:06:01 GMT"; + modification-date="Fri, 05 Apr 2019 10:10:49 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="file2.xlsx" +Content-Description: file2 +Content-Disposition: attachment; filename="file2.xlsx"; size=29; + creation-date="Fri, 05 Apr 2019 10:10:19 GMT"; + modification-date="Wed, 03 Apr 2019 11:04:32 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_-- diff --git a/tests/messages/embedded_email_without_content_disposition.eml b/tests/messages/embedded_email_without_content_disposition.eml new file mode 100644 index 00000000..c5c8cffe --- /dev/null +++ b/tests/messages/embedded_email_without_content_disposition.eml @@ -0,0 +1,141 @@ +Return-Path: demo@cerstor.cz +Received: from webmail.my-office.cz (localhost [127.0.0.1]) + by keira.cofis.cz + ; Fri, 29 Jan 2016 14:25:40 +0100 +From: demo@cerstor.cz +To: demo@cerstor.cz +Date: Fri, 5 Apr 2019 13:48:50 +0200 +Subject: Subject +Message-ID: +Accept-Language: pl-PL, nl-NL +Content-Type: multipart/mixed; + boundary="_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_" +MIME-Version: 1.0 + +--_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: multipart/related; + boundary="_007_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_"; + type="multipart/alternative" + +--_007_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: multipart/alternative; + boundary="_000_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_" + +--_000_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: text/plain; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +TexT + +[cid:file.jpg] + +--_000_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: text/html; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +

TexT

= + +--_000_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_-- + +--_007_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: image/jpeg; name= + "file.jpg" +Content-Description: file.jpg +Content-Disposition: inline; filename= + "file.jpg"; + size=54558; creation-date="Fri, 05 Apr 2019 11:48:58 GMT"; + modification-date="Fri, 05 Apr 2019 11:48:58 GMT" +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== + +--_007_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_-- + +--_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: message/rfc822 + +Received: from webmail.my-office.cz (localhost [127.0.0.1]) + by keira.cofis.cz + ; Fri, 29 Jan 2016 14:25:40 +0100 +From: demo@cerstor.cz +To: demo@cerstor.cz +Date: Fri, 5 Apr 2019 12:10:49 +0200 +Subject: embedded_message_subject +Message-ID: +Accept-Language: pl-PL, nl-NL +Content-Language: pl-PL +Content-Type: multipart/mixed; + boundary="_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_" +MIME-Version: 1.0 + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: multipart/alternative; + boundary="_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_" + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: text/plain; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + +some txt + + + + + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: text/html; charset="iso-8859-2" +Content-Transfer-Encoding: quoted-printable + + +

some txt

+ + +--_000_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_-- + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="file1.xlsx" +Content-Description: file1.xlsx +Content-Disposition: attachment; filename="file1.xlsx"; size=29; + creation-date="Fri, 05 Apr 2019 10:06:01 GMT"; + modification-date="Fri, 05 Apr 2019 10:10:49 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_ +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="file2.xlsx" +Content-Description: file2 +Content-Disposition: attachment; filename="file2.xlsx"; size=29; + creation-date="Fri, 05 Apr 2019 10:10:19 GMT"; + modification-date="Wed, 03 Apr 2019 11:04:32 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_005_AC39946EBF5C034B87BABD5343E96979012671D40E38VM002emonsn_-- + +--_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="file3.xlsx" +Content-Description: file3.xlsx +Content-Disposition: attachment; filename="file3.xlsx"; + size=90672; creation-date="Fri, 05 Apr 2019 11:46:30 GMT"; + modification-date="Thu, 28 Mar 2019 08:07:58 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_ +Content-Type: application/x-zip-compressed; name="file4.zip" +Content-Description: file4.zip +Content-Disposition: attachment; filename="file4.zip"; size=29; + creation-date="Fri, 05 Apr 2019 11:48:45 GMT"; + modification-date="Fri, 05 Apr 2019 11:35:24 GMT" +Content-Transfer-Encoding: base64 + +IHRoaXMgaXMgY29udGVudCBvZiB0ZXN0IGZpbGU= + +--_008_AC39946EBF5C034B87BABD5343E96979012671D9F7E4VM002emonsn_-- diff --git a/tests/messages/example_attachment.eml b/tests/messages/example_attachment.eml new file mode 100644 index 00000000..3e929a34 --- /dev/null +++ b/tests/messages/example_attachment.eml @@ -0,0 +1,56 @@ +Return-Path: +Delivered-To: +Received: from mx.domain.tld + by localhost (Dovecot) with LMTP id T7mwLn3ddlvKWwAA0J78UA + for ; Fri, 17 Aug 2018 16:36:45 +0200 +Received: from localhost (localhost [127.0.0.1]) + by mx.domain.tld (Postfix) with ESMTP id B642913BA0BE2 + for ; Fri, 17 Aug 2018 16:36:45 +0200 (CEST) +X-Virus-Scanned: Debian amavisd-new at mx.domain.tld +X-Spam-Flag: NO +X-Spam-Score: 1.103 +X-Spam-Level: * +X-Spam-Status: No, score=1.103 required=6.31 tests=[ALL_TRUSTED=-1, + OBFU_TEXT_ATTACH=1, TRACKER_ID=1.102, TVD_SPACE_RATIO=0.001] + autolearn=no autolearn_force=no +Received: from mx.domain.tld ([127.0.0.1]) + by localhost (mx.domain.tld [127.0.0.1]) (amavisd-new, port 10024) + with ESMTP id L8E9vyX80d44 for ; + Fri, 17 Aug 2018 16:36:39 +0200 (CEST) +Received: from [127.0.0.1] (ip.dynamic.domain.tld [192.168.0.24]) + (using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) + (No client certificate requested) + by mx.domain.tld (Postfix) with ESMTPSA id EF01E13BA0BD7 + for ; Fri, 17 Aug 2018 16:36:38 +0200 (CEST) +Sender: testsender +Message-ID: +Date: Fri, 17 Aug 2018 14:36:24 +0000 +Subject: ogqMVHhz7swLaq2PfSWsZj0k99w8wtMbrb4RuHdNg53i76B7icIIM0zIWpwGFtnk +From: testfrom +Reply-To: testreply_to +To: testnameto +Cc: testnamecc +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="_=_swift_v4_1534516584_32c032a3715d2dfd5cd84c26f84dba8d_=_" +X-Priority: 1 (Highest) + + + + +--_=_swift_v4_1534516584_32c032a3715d2dfd5cd84c26f84dba8d_=_ +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable + +n1IaXFkbeqKyg4lYToaJ3u1Ond2EDrN3UWuiLFNjOLJEAabSYagYQaOHtV5QDlZE + +--_=_swift_v4_1534516584_32c032a3715d2dfd5cd84c26f84dba8d_=_ +Content-Type: application/octet-stream; name=6mfFxiU5Yhv9WYJx.txt +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename=6mfFxiU5Yhv9WYJx.txt + +em5rNTUxTVAzVFAzV1BwOUtsMWduTEVycldFZ2tKRkF0dmFLcWtUZ3JrM2RLSThkWDM4WVQ4QmFW +eFJjT0VSTg== + +--_=_swift_v4_1534516584_32c032a3715d2dfd5cd84c26f84dba8d_=_-- + diff --git a/tests/messages/example_bounce.eml b/tests/messages/example_bounce.eml new file mode 100644 index 00000000..a1f5683f --- /dev/null +++ b/tests/messages/example_bounce.eml @@ -0,0 +1,96 @@ +Return-Path: <> +Received: from somewhere.your-server.de + by somewhere.your-server.de with LMTP + id 3TP8LrElAGSOaAAAmBr1xw + (envelope-from <>); Thu, 02 Mar 2023 05:27:29 +0100 +Envelope-to: demo@foo.de +Delivery-date: Thu, 02 Mar 2023 05:27:29 +0100 +Authentication-Results: somewhere.your-server.de; + iprev=pass (somewhere06.your-server.de) smtp.remote-ip=1b21:2f8:e0a:50e4::2; + spf=none smtp.mailfrom=<>; + dmarc=skipped +Received: from somewhere06.your-server.de ([1b21:2f8:e0a:50e4::2]) + by somewhere.your-server.de with esmtps (TLS1.3) tls TLS_AES_256_GCM_SHA384 + (Exim 4.94.2) + id 1pXaXR-0006xQ-BN + for demo@foo.de; Thu, 02 Mar 2023 05:27:29 +0100 +Received: from [192.168.0.10] (helo=sslproxy01.your-server.de) + by somewhere06.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) + (Exim 4.92) + id 1pXaXO-000LYP-9R + for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100 +Received: from localhost ([127.0.0.1] helo=sslproxy01.your-server.de) + by sslproxy01.your-server.de with esmtps (TLSv1.3:TLS_AES_256_GCM_SHA384:256) + (Exim 4.92) + id 1pXaXO-0008gy-7x + for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100 +Received: from Debian-exim by sslproxy01.your-server.de with local (Exim 4.92) + id 1pXaXO-0008gb-6g + for demo@foo.de; Thu, 02 Mar 2023 05:27:26 +0100 +X-Failed-Recipients: ding@ding.de +Auto-Submitted: auto-replied +From: Mail Delivery System +To: demo@foo.de +Content-Type: multipart/report; report-type=delivery-status; boundary=1677731246-eximdsn-678287796 +MIME-Version: 1.0 +Subject: Mail delivery failed +Message-Id: +Date: Thu, 02 Mar 2023 05:27:26 +0100 +X-Virus-Scanned: Clear (ClamAV 0.103.8/26827/Wed Mar 1 09:28:49 2023) +X-Spam-Score: 0.0 (/) +Delivered-To: bar-demo@foo.de + +--1677731246-eximdsn-678287796 +Content-type: text/plain; charset=us-ascii + +This message was created automatically by mail delivery software. + +A message sent by + + + +could not be delivered to one or more of its recipients. The following +address(es) failed: + + ding@ding.de + host 36.143.65.153 [36.143.65.153] + SMTP error from remote mail server after pipelined end of data: + 550-Verification failed for + 550-Unrouteable address + 550 Sender verify failed + +--1677731246-eximdsn-678287796 +Content-type: message/delivery-status + +Reporting-MTA: dns; sslproxy01.your-server.de + +Action: failed +Final-Recipient: rfc822;ding@ding.de +Status: 5.0.0 +Remote-MTA: dns; 36.143.65.153 +Diagnostic-Code: smtp; 550-Verification failed for + 550-Unrouteable address + 550 Sender verify failed + +--1677731246-eximdsn-678287796 +Content-type: message/rfc822 + +Return-path: +Received: from [31.18.107.47] (helo=127.0.0.1) + by sslproxy01.your-server.de with esmtpsa (TLSv1.3:TLS_AES_256_GCM_SHA384:256) + (Exim 4.92) + (envelope-from ) + id 1pXaXO-0008eK-11 + for ding@ding.de; Thu, 02 Mar 2023 05:27:26 +0100 +Date: Thu, 2 Mar 2023 05:27:25 +0100 +To: ding +From: =?iso-8859-1?Q?S=C3=BCderbar_Foo_=28SI=29_GmbH?= +Subject: Test +Message-ID: +X-Mailer: PHPMailer 6.7.1 (https://github.com/PHPMailer/PHPMailer) +Return-Path: bounce@foo.de +MIME-Version: 1.0 +Content-Type: text/html; charset=iso-8859-1 +X-Authenticated-Sender: demo@foo.de + +
Hallo, dies ist ein Beispiel-Text.
\ No newline at end of file diff --git a/tests/messages/four_nested_emails.eml b/tests/messages/four_nested_emails.eml new file mode 100644 index 00000000..0b34e986 --- /dev/null +++ b/tests/messages/four_nested_emails.eml @@ -0,0 +1,69 @@ +To: test@example.com +From: test@example.com +Subject: 3-third-subject +Content-Type: multipart/mixed; + boundary="------------2E5D78A17C812FEFF825F7D5" + +This is a multi-part message in MIME format. +--------------2E5D78A17C812FEFF825F7D5 +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +3-third-content +--------------2E5D78A17C812FEFF825F7D5 +Content-Type: message/rfc822; + name="2-second-email.eml" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="2-second-email.eml" + +To: test@example.com +From: test@example.com +Subject: 2-second-subject +Content-Type: multipart/mixed; + boundary="------------9919377E37A03209B057D47F" + +This is a multi-part message in MIME format. +--------------9919377E37A03209B057D47F +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +2-second-content +--------------9919377E37A03209B057D47F +Content-Type: message/rfc822; + name="1-first-email.eml" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="1-first-email.eml" + +To: test@example.com +From: test@example.com +Subject: 1-first-subject +Content-Type: multipart/mixed; + boundary="------------0919377E37A03209B057D47A" + +This is a multi-part message in MIME format. +--------------0919377E37A03209B057D47A +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +1-first-content +--------------0919377E37A03209B057D47A +Content-Type: message/rfc822; + name="0-zero-email.eml" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="0-zero-email.eml" + +To: test@example.com +From: test@example.com +Subject: 0-zero-subject +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +0-zero-content +--------------0919377E37A03209B057D47A-- + +--------------9919377E37A03209B057D47F-- + +--------------2E5D78A17C812FEFF825F7D5-- diff --git a/tests/messages/gbk_charset.eml b/tests/messages/gbk_charset.eml new file mode 100644 index 00000000..b40936bd --- /dev/null +++ b/tests/messages/gbk_charset.eml @@ -0,0 +1,9 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="X-GBK" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/html_only.eml b/tests/messages/html_only.eml new file mode 100644 index 00000000..ce3ee17c --- /dev/null +++ b/tests/messages/html_only.eml @@ -0,0 +1,9 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/html; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi \ No newline at end of file diff --git a/tests/messages/imap_mime_header_decode_returns_false.eml b/tests/messages/imap_mime_header_decode_returns_false.eml new file mode 100644 index 00000000..af3a7f36 --- /dev/null +++ b/tests/messages/imap_mime_header_decode_returns_false.eml @@ -0,0 +1,9 @@ +From: from@there.com +To: to@here.com +Subject: =?UTF-8?B?nnDusSNdG92w6Fuw61fMjAxOF8wMy0xMzMyNTMzMTkzLnBkZg==?= +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/inline_attachment.eml b/tests/messages/inline_attachment.eml new file mode 100644 index 00000000..be60f598 --- /dev/null +++ b/tests/messages/inline_attachment.eml @@ -0,0 +1,41 @@ +Content-Type: multipart/mixed; + boundary="----=_Part_1114403_1160068121.1505882828080" + +------=_Part_1114403_1160068121.1505882828080 +Content-Type: multipart/related; + boundary="----=_Part_1114404_576719783.1505882828080" + +------=_Part_1114404_576719783.1505882828080 +Content-Type: text/html;charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + + +------=_Part_1114404_576719783.1505882828080 +Content-Type: image/png +Content-Disposition: inline +Content-Transfer-Encoding: base64 +Content-ID: + +iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAB+FBMVEUAAAA/mUPidDHiLi5Cn0Xk +NTPmeUrkdUg/m0Q0pEfcpSbwaVdKskg+lUP4zA/iLi3msSHkOjVAmETdJSjtYFE/lkPnRj3sWUs8 +kkLeqCVIq0fxvhXqUkbVmSjwa1n1yBLepyX1xxP0xRXqUkboST9KukpHpUbuvRrzrhF/ljbwalju +ZFM4jELaoSdLtElJrUj1xxP6zwzfqSU4i0HYnydMtUlIqUfywxb60AxZqEXaoifgMCXptR9MtklH +pEY2iUHWnSjvvRr70QujkC+pUC/90glMuEnlOjVMt0j70QriLS1LtEnnRj3qUUXfIidOjsxAhcZF +o0bjNDH0xxNLr0dIrUdmntVTkMoyfL8jcLBRuErhJyrgKyb4zA/5zg3tYFBBmUTmQTnhMinruBzv +vhnxwxZ/st+Ktt5zp9hqota2vtK6y9FemNBblc9HiMiTtMbFtsM6gcPV2r6dwroseLrMrbQrdLGd +yKoobKbo3Zh+ynrgVllZulTsXE3rV0pIqUf42UVUo0JyjEHoS0HmsiHRGR/lmRz/1hjqnxjvpRWf +wtOhusaz0LRGf7FEfbDVmqHXlJeW0pbXq5bec3fX0nTnzmuJuWvhoFFhm0FtrziBsjaAaDCYWC+u +Si6jQS3FsSfLJiTirCOkuCG1KiG+wSC+GBvgyhTszQ64Z77KAAAARXRSTlMAIQRDLyUgCwsE6ebm +5ubg2dLR0byXl4FDQzU1NDEuLSUgC+vr6urq6ubb29vb2tra2tG8vLu7u7uXl5eXgYGBgYGBLiUA +LabIAAABsElEQVQoz12S9VPjQBxHt8VaOA6HE+AOzv1wd7pJk5I2adpCC7RUcHd3d3fXf5PvLkxh +eD++z+yb7GSRlwD/+Hj/APQCZWxM5M+goF+RMbHK594v+tPoiN1uHxkt+xzt9+R9wnRTZZQpXQ0T +5uP1IQxToyOAZiQu5HEpjeA4SWIoksRxNiGC1tRZJ4LNxgHgnU5nJZBDvuDdl8lzQRBsQ+s9PZt7 +s7Pz8wsL39/DkIfZ4xlB2Gqsq62ta9oxVlVrNZpihFRpGO9fzQw1ms0NDWZz07iGkJmIFH8xxkc3 +a/WWlubmFkv9AB2SEpDvKxbjidN2faseaNV3zoHXvv7wMODJdkOHAegweAfFPx4G67KluxzottCU +9n8CUqXzcIQdXOytAHqXxomvykhEKN9EFutG22p//0rbNvHVxiJywa8yS2KDfV1dfbu31H8jF1RH +iTKtWYeHxUvq3bn0pyjCRaiRU6aDO+gb3aEfEeVNsDgm8zzLy9egPa7Qt8TSJdwhjplk06HH43ZN +J3s91KKCHQ5x4sw1fRGYDZ0n1L4FKb9/BP5JLYxToheoFCVxz57PPS8UhhEpLBVeAAAAAElFTkSu +QmCC +------=_Part_1114404_576719783.1505882828080-- + +------=_Part_1114403_1160068121.1505882828080-- diff --git a/tests/messages/issue-275-2.eml b/tests/messages/issue-275-2.eml new file mode 100644 index 00000000..d80e66ed --- /dev/null +++ b/tests/messages/issue-275-2.eml @@ -0,0 +1,123 @@ +Received: from AS4PR02MB8234.eurprd02.prod.outlook.com (2603:10a6:20b:4f1::17) + by PA4PR02MB7071.eurprd02.prod.outlook.com with HTTPS; Wed, 18 Jan 2023 + 09:17:10 +0000 +Received: from AS1PR02MB7917.eurprd02.prod.outlook.com (2603:10a6:20b:4a5::5) + by AS4PR02MB8234.eurprd02.prod.outlook.com (2603:10a6:20b:4f1::17) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5986.19; Wed, 18 Jan + 2023 09:17:09 +0000 +Received: from AS1PR02MB7917.eurprd02.prod.outlook.com + ([fe80::4871:bdde:a499:c0d9]) by AS1PR02MB7917.eurprd02.prod.outlook.com + ([fe80::4871:bdde:a499:c0d9%9]) with mapi id 15.20.5986.023; Wed, 18 Jan 2023 + 09:17:09 +0000 +From: =?iso-8859-1?Q?Martin_B=FClow_Larsen?= +To: Cofman Drift +Subject: Test 1017 +Thread-Topic: Test 1017 +Thread-Index: AdkrHaKsy+bUiL/eTIS5W3AP+zzLjQ== +Date: Wed, 18 Jan 2023 09:17:09 +0000 +Message-ID: + +Accept-Language: da-DK, en-US +Content-Language: en-US +X-MS-Exchange-Organization-AuthAs: Internal +X-MS-Exchange-Organization-AuthMechanism: 04 +X-MS-Exchange-Organization-AuthSource: AS1PR02MB7917.eurprd02.prod.outlook.com +X-MS-Has-Attach: +X-MS-Exchange-Organization-Network-Message-Id: + 98cea1c9-a497-454a-6606-08daf934c6c4 +X-MS-Exchange-Organization-SCL: -1 +X-MS-TNEF-Correlator: +X-MS-Exchange-Organization-RecordReviewCfmType: 0 +x-ms-publictraffictype: Email +X-Microsoft-Antispam-Mailbox-Delivery: + ucf:0;jmr:0;auth:0;dest:I;ENG:(910001)(944506478)(944626604)(920097)(425001)(930097); +X-Microsoft-Antispam-Message-Info: + BzqL6hvPyQW0lSkWGop6vQlsIZK48umY74vuKlNgF0pb/H659W+0fuTB+6guqGM0oof00mlzu3gn1pu1R5pUOE2Fb58OqnBEFkB30vVrG6TNsG/6KBtecXkP3FptqO/WRmsxCQx7bK7J2VyS2SbOibqX8mDZhkTrwP1+IA0R9eD0/NvoMqX9GssewUDxSAbaaKdADCuU1dG7qpF8M9tfLDJz+dUL5qZoO+oGINGLLdo2y6Z/F+h3UWv7BXiS4BJKc+jwAng26BUMKmg2VVRdMvc+LbZTovUr9hyEq1orS9fOg1iIV6sPcyIVl3NIEy5bHMYh1d6sUCqvTO1UPSdf7lIvKxSszyKptIPNgioOvFpF9tTHDyKU5p1IiLm5FxW/+kGdPq6ZoTIZVriJoyjx6gWKpPY3vHN6bHUK9mA+LspIYAeuDFFvwkZx2b2Rtw3S99S7zz3eBqv3xlGqJixx/apl4Af7ZaxKFpMj9XZXAQVIv9BA0tIA+1nLByt4dPW4Xzoj3KcBbX5K/HkuR/30Lrq7gRQQDyNYgf5S/MO2MLJqcvnVFPXgVubK6XFu5quDibsZjPjxOUfBTJkJ/n4gB8Z8/TOM0oKD76hszXXoaWd9leUeQ1x88oy+QuXPRxzuLzVec3GiPNMYA42QvvTiWmrrhdceRzhV0J7pJBbi10ik+hXqSeNkldgktd5cWPss5F74yxAaEaPJO9I7MOUpE0XzlRfljPptykrIQt8SARMllykzJNrDt8VAl37ArEZbWxFLm3RuypOI0zgCZMRLf5JeElpCv1ay4wilz4vsYGr4fs3KUQzI1YY43uaDxNMz8k7dH/UkC9Dfg1oyHlNs+w== +Content-Type: multipart/alternative; + boundary="_000_AS1PR02MB791721260DE0273A15AFEC3EB5C79AS1PR02MB7917eurp_" +MIME-Version: 1.0 + +--_000_AS1PR02MB791721260DE0273A15AFEC3EB5C79AS1PR02MB7917eurp_ +Content-Type: text/plain; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +Test + +Med venlig hilsen +Martin Larsen +Feline Holidays A/S +Tlf 78 77 04 12 + + + +--_000_AS1PR02MB791721260DE0273A15AFEC3EB5C79AS1PR02MB7917eurp_ +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

Test

+

 

+

= +Med venlig hilsen

+

Martin Larsen

+

Feline Holidays A/S

+

Tlf 78 77 04 12

+

 

+

 

+
+ + + +--_000_AS1PR02MB791721260DE0273A15AFEC3EB5C79AS1PR02MB7917eurp_-- diff --git a/tests/messages/issue-275.eml b/tests/messages/issue-275.eml new file mode 100644 index 00000000..642d6a42 --- /dev/null +++ b/tests/messages/issue-275.eml @@ -0,0 +1,208 @@ +Received: from FR0P281MB2649.DEUP281.PROD.OUTLOOK.COM (2603:10a6:d10:50::12) + by BEZP281MB2374.DEUP281.PROD.OUTLOOK.COM with HTTPS; Tue, 17 Jan 2023 + 09:25:19 +0000 +Received: from DB6P191CA0002.EURP191.PROD.OUTLOOK.COM (2603:10a6:6:28::12) by + FR0P281MB2649.DEUP281.PROD.OUTLOOK.COM (2603:10a6:d10:50::12) with Microsoft + SMTP Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id + 15.20.5986.23; Tue, 17 Jan 2023 09:25:18 +0000 +Received: from DB5EUR02FT011.eop-EUR02.prod.protection.outlook.com + (2603:10a6:6:28:cafe::3b) by DB6P191CA0002.outlook.office365.com + (2603:10a6:6:28::12) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6002.19 via Frontend + Transport; Tue, 17 Jan 2023 09:25:18 +0000 +Authentication-Results: spf=pass (sender IP is 80.241.56.171) + smtp.mailfrom=*****; dkim=pass (signature was verified) + header.d=jankoppe.de;dmarc=pass action=none + header.from=jankoppe.de;compauth=pass reason=100 +Received-SPF: Pass (protection.outlook.com: domain of **** designates + 80.241.56.171 as permitted sender) receiver=protection.outlook.com; + client-ip=80.241.56.171; helo=mout-p-201.mailbox.org; pr=C +Received: from **** + DB5EUR02FT011.mail.protection.outlook.com (10.13.58.70) with Microsoft SMTP + Server (version=TLS1_2, cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384) id + 15.20.6002.13 via Frontend Transport; Tue, 17 Jan 2023 09:25:18 +0000 +Received: from **** + (***) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P384) id 15.1.2375.34; Tue, 17 + Jan 2023 10:25:18 +0100 +Received: from ***** + (***) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.1.2375.34; Tue, 17 Jan + 2023 10:25:16 +0100 +Received: from **** + (***) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384) id 15.1.2375.34 via Frontend + Transport; Tue, 17 Jan 2023 10:25:16 +0100 +Received: from ***** + (***) with Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384_P521) id 15.1.2375.34; Tue, 17 + Jan 2023 10:25:18 +0100 +IronPort-SDR: qF6UeVYj8pb73gXrskSNOtfmUEgr2JLTtqbIK+/6ymuIu+hw8DzzyKrOGqm7zNPwG/4zr+ma5y + XERFv6Zyf/cxrWoVjWXswWrkCVjqQuHglLaONZt1Mg4okFzEByeEZsyg3/3n6kI4O+kgViBgLW + nNMzGlNLSqYBX+EDhOfE1GEWB/4iRbwDY32SnTr5BYqR0HwgHf+0M0E3b23/NqV6iYrF3KETah + cLtLRt/b5JY6f5KvmKoz0Y395r7MwryWR1eLKAU2j3w+ioNYBUw36K/hsIRojcquvQk9w3Qtfu + Yz1BUNwOEOwfxr0VjQScHirO +X-IRON-External-Mail-Tag: Extern +X-IronPort-MID: 60840111 +X-IPAS-Result: =?us-ascii?q?A0G0CQCSaMZjmKs48VBaglqEBz5Fk0mfZoFqEw8BAQEBA?= + =?us-ascii?q?QEBAQEJRAQBAQQDihcCJToEDQECBAEBAQEDAgMBAQEBBQEBAQQBAQECAQEGA?= + =?us-ascii?q?wEBAQIQAQEBAQEBAQEVCRkFDhAFLw1XDV0LgUQLgXQLAzENgjgiggQsgXYnA?= + =?us-ascii?q?QE4hE6DIwetVIEBgggBAQaCY5xECYFBi12ESYEhHD8BgU2EP4VPhXKaXIE7f?= + =?us-ascii?q?IEnDoFIgSk3A0QdQAMLbQpANRZKKxobB4EJKigVAwQEAwIGEwMiAg0oMRQEK?= + =?us-ascii?q?RMNJyZpCQIDImYDAwQoLQkgHwcmJDwHVj0CDx83BgMJAwIhToEgJAUDCxUqR?= + =?us-ascii?q?wQINgUGUhICCA8SDyxDDkI3NBMGgQYLDhEDUIFOBIENfwpXxUKgLYIqgVChG?= + =?us-ascii?q?oNmAZMikiGXS6gMgXwKFoFcTSQUgyJPAQIBAQENAQIBAQMBAgEBAQkBAQEBj?= + =?us-ascii?q?jaEDIosQzE7AgcLAQEDCYwjAQE?= +IronPort-PHdr: A9a23:3aIm3RBfYL78XRTcz0LxUyQUT0MY04WdBeb1wqQuh78GSKm/5ZOqZ + BWZua8wygaRAM6CsKgMotGVmp6jcFRI2YyGvnEGfc4EfD4+ouJSoTYdBtWYA1bwNv/gYn9yN + s1DUFh44yPzahANS47xaFLIv3K98yMZFAnhOgppPOT1HZPZg9iq2+yo9JDffQVFiCCgbb9uL + Bi6ohjdu8cIjYB/Nqs/1xzFr2dHdOhR2W5mP0+YkQzm5se38p5j8iBQtOwk+sVdT6j0fLk2Q + KJBAjg+PG87+MPktR/YTQuS/XQcSXkZkgBJAwfe8h73WIr6vzbguep83CmaOtD2TawxVD+/4 + apnVAPkhSEaPDM/7WrZiNF/jLhDrRyiuhJxw5Dab52aOvRwcazQZs8aSGhbU8pNSyBNHp+wY + o0SBOQBJ+ZYqIz9qkMKoxSkAwmnGebhyjhQhn/uw6IxzuMsERnB3Aw7A9IDq3bUo8/zNKcRV + uC11LHIwivZY/xLxzjw8Y7FeQ0urv+QR7x/a9bRyVUxGAPfiFWdsZDpMjKV2OkTvWWV7+RtW + OOhhmMmqAx8oDuiy8Uih4fGh48Y1FPJ+Th4zYs6J9C0VFJ3bN64HZZftiyXKoR7Tt4kTmp1u + yg60qULtJ2ncCQQ1pgqyAPTZ+aHfoWJ+B7vSeScLSp+iXl4YrywnQyy/lKlyuDkVsm7zlJKr + i1dn9nJsXANygDT5tGfSvdk4EutxSuD2xrW6u5eIEA0kbHUK5kuw7IqkZoTq0vDEjf3mEXwk + qCWal0p9+u05+j9fLnrqYKQO5V0hwz/KKgih86yDfkgPggLRWeb+OC81LP5/U3+RbVHluU2k + q7CsJDGPskbpLS2AwlW0oYk8xa/Fymp3M4FknYZNF5FfgmIgJDzO17SOPD4Eeu/g1O0nTt23 + /zGJKHuAo3RLnjfl7fsZbJ960lTyAoyy9BT/olUCqwZIPLrXU/xrsDYAwQiPAyp2eboFc9y2 + poQWWKIGK+YPrndsUWV6e41PuaDetxdhDGoL/8q5virlmIhgVgHYYGjwIEbYTW2Ge55Kl+VJ + 3bh0fkbFmJfnAM4BM/tkEWPGWpLYG2ud6A14DI8EJqrS4vOENP+yIed1Tu2S8UFLltNDUqBR + C+ASg== +IronPort-Data: A9a23:VhC+4aKGZF33Q9F0FE+RvpMlxSXFcZb7ZxGr2PjKsXjdYENS1zBVm + zYfDDuGOvjcMGT0etAkYN6x8kwD7MLQm9Q3G1ForCE8RH9jl5HIVI+TRqvSF3rJd5WcFiqLz + Cm/hv3odp1coqr0/E/F3oAMLhCQ7InQLlbGILes1htZGEk1F0/NtTo5w7Ri2tcx3oDga++wk + YqaT/P3aQfNNwFcbzp8B5Kr8HuDa9yr5Vv0FnRnDRx6lAe2e0s9VfrzFonoR5fMebS4K8bhL + wr15ODjpz2Bp3/BPfv++lrzWhVirrc/pmFigFIOM0SpqkAqSiDfTs/XnRfTAKtao2zhojx/9 + DlCnZWXSl0jF72Qo9YUcCEfFTpSP4Rt/KCSdBBTseTLp6HHW37r3ukrFARsZdRe/+92BWtJ5 + bofMj9lghKr17rwmu7iDLQywJ18daEHP6tH0p1k5SneFuoOQ5nFQKLS/dIe0DpYasVmQ66OO + 5JAMGMHgBLoWwRwMVsFObICo/qFn1bzdyRihGDOnP9ii4TU5Fcojeaxb4O9lsaxbcFSkUee4 + 3nb53z+GA0yPsGFxTPA/HW2mebVkWX3Veov+KaQ8/l3nBiLgzZLUVsTXFq/q/6pzEmkVLqzN + nD45AIniqto/mW7EuLPVj6A53ifkhw1cN5PRrhSBB629oLY5AOQB24hRzFHacA7uMJeeQHGx + mNljPu0X2E06uT9pWa1r+zI9WroUcQABTVaDRLoWzfp9PHPjenfZDrlQ8xnEajdYjbdQGmpm + mHiQMQWo7wVgYYh2r+//Favvt5Bjp3OUxJw/kCNBjvj6wp4YISid8qv81ezARd8wGSxEQXpU + JsswZL2AAUy4XelyHzlrAIlQejB2hp9GGeA6WOD5rF4n9hXx1atfJpL/BZ1L1pzP8APdFfBO + RGM4lMAv88JYiL6Ncebhr5d7ex0ncAM8vy6DpjpgiZmO/CdiSfcrH02DaJu9zmyziDAbp3Ty + b/AKJvyUSlDYUiW5Dq7RuEZ3L4tgzsj3XvUX4yzwBGt0dKjiI29Ft843K+1Rrlhtsus+VyNm + /4Gbpfi40gBDIXWP3eGmaZNdgpiBSZgWvjLRzl/K7TrzvxOQj9xUpc8ANoJJuRYokiivryZo + S/lAxEGmAGXaL+uAVziV02PoYjHBf5XxU/X9wR1Vbpx83R8M4up8okFcJ47Iesu+OB5lKcmT + fADeMKYGvkJRjmeo2YRapz0rYpDchW3hFvQYHP+PWViJsBtF17T59vpXgrz7y1SXCC5gs1v8 + bSv2zTSTYcHWwk/Xt3db+iizg3tsHVEwLByUkLEL8N9YkLp9IQ2eSX9guVuepMOIBPAwSOC2 + kCaDE5A9+XKpoY09vjPhLyF9tn2SrAjQxcDQWSCtOS4LyjX+Gan0LRsaufQcGCPTn7w9YWje + f5Rk6P2PsoBzQRDvIdLGrp2yb4zuon0rLhAwwU6QHjGYgj5Cr5kJXXaj8BDurcXnO1cvhaqH + 1rKoIEDf7CAOcfvF05XIxAqN7zR2fYRkzjUzPI0PESjunAup+faDBwMMknekjFZIZt0LJghn + bUrtvkQul62hRcdO9qbijxZqjaXJXsaXqR56pwXXN3xhgwwxg0QaJDQEHWsspSIdskJKgxwe + mbSgaPDg75b1gzFaXVqTSrB2u9UhJIvvhFWzQZceA3Sx4eY36E6jE9L7DA6bgVJ1REbgeh9D + W46ZUR6KJKH8ypsmMUeDXunHBtMBUPF90H8o7fTeLY1k6V1uqfxwKHR9ApDEI31M46RQ9SDw + Iyl9Q== +IronPort-HdrOrdr: A9a23:8y8NqqHssZLZ5JoIpLqE88eALOsnbusQ8zAXPidKKSC9E/b4qy + nKpp4mPHDP+VUssR0b9uxoW5PwI080l6QZ3WB5B97LNzUO01HFEGgN1+Xf/wE= +X-IronPort-Anti-Spam-Filtered: true +X-IronPort-AV: E=Sophos;i="5.97,222,1669071600"; + d="scan'208";a="60840111" +X-Amp-Result: SKIPPED(no attachment in message) +Received: from mout-p-201.mailbox.org ([80.241.56.171]) + by maila.burda.com with ESMTP/TLS/ECDHE-RSA-AES256-GCM-SHA384; 17 Jan 2023 10:25:16 +0100 +Received: from smtp1.mailbox.org (smtp1.mailbox.org [10.196.197.1]) + (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) + key-exchange ECDHE (P-384) server-signature RSA-PSS (4096 bits) server-digest SHA256) + (No client certificate requested) + by mout-p-201.mailbox.org (Postfix) with ESMTPS id 4Nx3QJ6GdJz9sQp + for <***>; Tue, 17 Jan 2023 10:25:12 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=****; s=MBO0001; + t=1673947512; + h=from:from:reply-to:subject:subject:date:date:message-id:message-id: + to:to:cc:mime-version:mime-version:content-type:content-type: + content-transfer-encoding:content-transfer-encoding; + bh=OMh+Pfp7SvIiDJjyXg53DevMPfaJRBhjQUkokwQIgDY=; + b=m3nM2KPbJdO7D+Vq/fMLLCXpkeDgvLs/JRzGTzWO4OoQhSaXLp76/vkBGPCU7BSGxir2Fu + g6e9Ggnrf0l8vL7rpvTgttta64wImaP9wOmJ0fOjzHcg/PX7sYjlUjyVyfThqZ7M5qg6/P + E9wItt4lQoUeQRVc6EoUlUaL7S+2R4P2WsQ6HCjulmpC3fmZdPOTXph/a1YfGvSfSj0pjp + LauH2n6EURKFmtMv8MbDcvTVKcq7o1bLGnK/RAjkLmRAORt+eC08IEAb5stVE6T6UPZ14Q + GUqrK2HEm5THS9vlH/11LwxwmnAdqUm8nl+Ymo3n1UNF9r8wkh8BUndGQFOQqQ== +Message-ID: <****> +Subject: Testing 123 +From: **** +To: *** +Content-Type: text/plain +Content-Transfer-Encoding: 7bit +Date: Tue, 17 Jan 2023 10:25:11 +0100 +Return-Path: *** +X-OrganizationHeadersPreserved: *** +X-MS-Exchange-Organization-ExpirationStartTime: 17 Jan 2023 09:25:18.2389 + (UTC) +X-MS-Exchange-Organization-ExpirationStartTimeReason: OriginalSubmit +X-MS-Exchange-Organization-ExpirationInterval: 1:00:00:00.0000000 +X-MS-Exchange-Organization-ExpirationIntervalReason: OriginalSubmit +X-MS-Exchange-Organization-Network-Message-Id: + ca3e116b-7c15-4efe-897e-08daf86cbfae +X-EOPAttributedMessage: 0 +X-MS-Exchange-Organization-MessageDirectionality: Originating +X-MS-Exchange-SkipListedInternetSender: + ip=[80.241.56.171];domain=mout-p-201.mailbox.org +X-MS-Exchange-ExternalOriginalInternetSender: + ip=[80.241.56.171];domain=mout-p-201.mailbox.org +X-CrossPremisesHeadersPromoted: + DB5EUR02FT011.eop-EUR02.prod.protection.outlook.com +X-CrossPremisesHeadersFiltered: + DB5EUR02FT011.eop-EUR02.prod.protection.outlook.com +X-MS-PublicTrafficType: Email +X-MS-TrafficTypeDiagnostic: DB5EUR02FT011:EE_|FR0P281MB2649:EE_ +X-MS-Exchange-Organization-AuthSource: **** +X-MS-Exchange-Organization-AuthAs: Anonymous +X-OriginatorOrg: *** +X-MS-Office365-Filtering-Correlation-Id: ca3e116b-7c15-4efe-897e-08daf86cbfae +X-MS-Exchange-Organization-SCL: 1 +X-Microsoft-Antispam: BCL:0; +X-Forefront-Antispam-Report: + CIP:193.26.100.144;CTRY:DE;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:mout-p-201.mailbox.org;PTR:mout-p-201.mailbox.org;CAT:NONE;SFS:(13230022)(4636009)(451199015)(6916009)(82310400005)(558084003)(2616005)(7116003)(6266002)(36005)(8676002)(86362001)(36756003)(5660300002)(1096003)(336012)(8936002)(156005)(7636003)(7596003);DIR:INB; +X-MS-Exchange-CrossTenant-OriginalArrivalTime: 17 Jan 2023 09:25:18.1452 + (UTC) +X-MS-Exchange-CrossTenant-Network-Message-Id: ca3e116b-7c15-4efe-897e-08daf86cbfae +X-MS-Exchange-CrossTenant-Id: 0a3106bb-aab1-42df-9639-39f349ecd2a0 +X-MS-Exchange-CrossTenant-OriginalAttributedTenantConnectingIp: TenantId=0a3106bb-aab1-42df-9639-39f349ecd2a0**** +X-MS-Exchange-CrossTenant-AuthSource: *** +X-MS-Exchange-CrossTenant-AuthAs: Anonymous +X-MS-Exchange-CrossTenant-FromEntityHeader: HybridOnPrem +X-MS-Exchange-Transport-CrossTenantHeadersStamped: FR0P281MB2649 +X-MS-Exchange-Transport-EndToEndLatency: 00:00:01.4839051 +X-MS-Exchange-Processed-By-BccFoldering: 15.20.6002.012 +X-Microsoft-Antispam-Mailbox-Delivery: + ucf:0;jmr:0;auth:0;dest:I;ENG:(910001)(944506478)(944626604)(920097)(930097); +X-Microsoft-Antispam-Message-Info: + =?iso-8859-1?Q?DN6oyY29jzFhRK1BGCOfg1yFnX4ACBaHBh9nbhnIoNZ4DGdi6zN+tIXnUR?= + =?iso-8859-1?Q?AY3F6SV1doOncexZBgqeE70YqvkB0xmo97NgnkQFb48xPvmZwoiAqkmIrS?= + =?iso-8859-1?Q?EbPesMMRL/pVX22Pde2C0KNThL0e02Li5ZCNvDxq+mEt0T9cww7HhoY6SS?= + =?iso-8859-1?Q?vMbDIBDA4c6Gyvb+34A6rYNFyHtZFVMas8S1KP4bV7QtI2WooRBzL1tOgM?= + =?iso-8859-1?Q?gyRVbzR69Mk++hTLhqTK7kBIkGMFhUC1McOMGMTOuUh9Ay7vMeY/oNGwOG?= + =?iso-8859-1?Q?WEFVsQjOz6lY4FIc3A+U5t3LMxkHtxRCql//ZhfKfjy78hPR7FCzYk70+T?= + =?iso-8859-1?Q?iCLlDZNF73KjvxH82FFKOersAl26L1kb2x5F/d1XJxAJe7BTG20/LXuMmx?= + =?iso-8859-1?Q?wGJs7s+C69LYa4cliwtGTMEuS6Rd7bOLihXnIy6hn8UhjVhNBv0+yGjint?= + =?iso-8859-1?Q?hbwBnQJ4Ny2jeS42bJYmKw+7Dnol+pphbvhjhtsDvKifo4Xx7xM45eV2s3?= + =?iso-8859-1?Q?wfKtSfeNGIR3Oi0VwreHTtsCOfvY8jbP+T2Z6KOFtQRvlUyutOFnOB2x1J?= + =?iso-8859-1?Q?BVgSblJzekltOB7gCAmZiCQnZuUMOtVVqRfURwUhOuQ47I/2LDJzi/NQnf?= + =?iso-8859-1?Q?XiqeHojof9SfngGas5TNx9tuQxmFqw4AWJE7iy4hxJo2ehSt4ReEvPzyt9?= + =?iso-8859-1?Q?1DTwLNYbyZ8Ub7Uq3EtUDE5vn3jY2thuVBo8eemdrjOvsx+hdH9RxcFwYz?= + =?iso-8859-1?Q?qU8NIflZ0TMRHTT2M2aBdzB9zsR3Kda+I8tNi7T6DCWve+MEEmLeRa4lU6?= + =?iso-8859-1?Q?XAFgPATiTR9BGrBBrwqKLye2Pnn6Bx8Hs+R7sFsv6HnQkS8B585+mjXV0A?= + =?iso-8859-1?Q?jXMm+5KDT/IUTiTAeJPwBTNEAG6Yo8gDg+6/9EMuZtQYAYZnM4tMefCwBm?= + =?iso-8859-1?Q?oV/M1vLbCGr7m1BDeRerH34C/2SWa0XnU9zOzqvm6/ery2Cv81aakjQr+S?= + =?iso-8859-1?Q?FdFwyYhZi96v0+HHAeT1Ncyrb84GZVp0kb1VYOBI0JH7TTZy88PMtFfbXQ?= + =?iso-8859-1?Q?nrSZ/VgrVa7hCvBOf2VQnj6iMnNqaJlmnI+E/yXvDNQJn2JDhor7QrClSA?= + =?iso-8859-1?Q?rrCXMDZyWWZhhDrf+VkN7ePKkju46/dqh0NRjqz6571xFdftnXx/b9dli1?= + =?iso-8859-1?Q?+XoZOiqbV8FyrJNCWDLuZRc1gIr0KVbbxN9nggMGa5c8tslnJvW/OTImm7?= + =?iso-8859-1?Q?SEKOX/bZx/aOe9bLlgzQhqdtwBkOJurGSWeDajB/ko2pFUcIYWyMaB6dFW?= + =?iso-8859-1?Q?RcQgdjWDgNNqiUnyvY585ZCFJCfiMaWj8hglJWADRQdLHNSqFAtuZdVCc1?= + =?iso-8859-1?Q?HPXeZDkLqPP+0VfD8EO3A1Fmp+E3kIrykjPJtNslbzwmtL3ATsC8e2mcob?= + =?iso-8859-1?Q?NGBKDmktRjDYG3mhya2XCCiFWaYiig6J/s1ePtuRp3TbBojEAUg2E5xItx?= + =?iso-8859-1?Q?gRGc6bNfg9Rihie4+BZSN+l+ynuYJQ/FGK3CvQYA9gXp00ZtfPwlHx7mXA?= + =?iso-8859-1?Q?gaDydNv1CyuYCOgUeTxo4Bi7N4Rrz72tn9pYxoZ7okujjzLWuiK7etRoRz?= + =?iso-8859-1?Q?JwOHGQklwT4VgMwwLQdXxNR+WMLbOyXjt6bfrKSUHq34oLx6ZVk2F6r9HI?= + =?iso-8859-1?Q?5uaCMLO8dLt3O9rb8FdV3EfLGg+zThKobHlrVJ2WFZLm0xtSsbzC04WLdR?= + =?iso-8859-1?Q?Oqf0F2GVBbFjulBFs9mvdhDXD33oUn5atsgLnkAitzWZH8qgeTOKgCbZD4?= + =?iso-8859-1?Q?WIXnz+DQx0g6sgmqGwa5fkPzRn+NfdGnwNwRyjXcAw7jW5DVkAnzd2OkEc?= + =?iso-8859-1?Q?WrVWN1eIbPedT77yCWDwLAjGBWSrpmI1bZkqI=3D?= +MIME-Version: 1.0 + +Asdf testing123 this is a body \ No newline at end of file diff --git a/tests/messages/issue-348.eml b/tests/messages/issue-348.eml new file mode 100644 index 00000000..6c9d385d --- /dev/null +++ b/tests/messages/issue-348.eml @@ -0,0 +1,1261 @@ +MIME-Version: 1.0 +Date: Fri, 18 Nov 2022 16:52:06 +0100 +From: sender@testaddres.com +To: receiver@testaddres.com +Subject: 2 - test with a strange attachement +In-Reply-To: <30fdd5117dd9408487d74e9b50dedc27@testaddres.com> +References: + <30fdd5117dd9408487d74e9b50dedc27@testaddres.com> +User-Agent: REDACTED +Message-ID: <0a5495c56acb49d56545a2017dd1f931@testaddres.com> +X-Sender: sender@testaddres.com +Content-Type: multipart/mixed; + boundary="=_be6bc52b94449e229e651d02e56a25f1" + +--=_be6bc52b94449e229e651d02e56a25f1 +Content-Transfer-Encoding: 8bit +Content-Type: text/plain; charset=UTF-8; + format=flowed + + + +-------- Original Message -------- +Subject: 2 - test with a strange attachement +Date: 2022-11-09 08:36 + From: anotherSender@testaddres.com> +To: "receiver@testaddres.com" + +--- First Text as body test 80 column new line linux style 0x0A --- + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu +fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in +culpa qui officia deserunt mollit anim id est laborum. + +--------------------------------------------------------- + + +--- Second Text as body test no column limit (865 characters) one single line ending with 0x0A --- + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur + + +--------------------------------------------------------- + +Da: From Test sender in another language (IT) +Inviato: mercoledì 9 novembre 2022 03:17 +A: To in another language (IT) +Oggetto: Subject in another language + +--- Third Text as body test 80 column new line linux style 0x0A --- + +At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis +praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias +excepturi sint occaecati +cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia +animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et +expedita distinctio. Nam +libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo +minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, +omnis dolor repellendus. +Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus +saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. +Itaque earum rerum hic tenetur a +sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut +perferendis doloribus asperiores repellat. +--=_be6bc52b94449e229e651d02e56a25f1 +Content-Transfer-Encoding: base64 +Content-Type: application/pdf; + name="Kelvinsong—Font_test_page_bold.pdf" +Content-Disposition: attachment; + filename="Kelvinsong—Font_test_page_bold.pdf"; + size=35648 + +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 +YmoKMjY0NgplbmRvYmoKNCAwIG9iago8PC9UeXBlL1BhZ2UvTWVkaWFCb3ggWzAgMCA1OTUgODQy +XQovUm90YXRlIDkwL1BhcmVudCAzIDAgUgovUmVzb3VyY2VzPDwvUHJvY1NldFsvUERGIC9JbWFn +ZUMgL1RleHRdCi9FeHRHU3RhdGUgMTYgMCBSCi9YT2JqZWN0IDE3IDAgUgovRm9udCAxOCAwIFIK +Pj4KL0Fubm90c1sxMyAwIFJdL0NvbnRlbnRzIDUgMCBSCj4+CmVuZG9iagozIDAgb2JqCjw8IC9U +eXBlIC9QYWdlcyAvS2lkcyBbCjQgMCBSCl0gL0NvdW50IDEKPj4KZW5kb2JqCjEgMCBvYmoKPDwv +VHlwZSAvQ2F0YWxvZyAvUGFnZXMgMyAwIFIKL01ldGFkYXRhIDI1IDAgUgo+PgplbmRvYmoKNyAw +IG9iago8PC9UeXBlL0V4dEdTdGF0ZQovT1BNIDE+PmVuZG9iagoxMyAwIG9iago8PC9UeXBlL0Fu +bm90Ci9SZWN0IFswIDAgNTk0Ljk2IDE1XQovQm9yZGVyIFswIDAgMl0KL0MgWzEgMCAwXQovQTw8 +L1VSSShodHRwOi8vd3d3LmJ1bGx6aXAuY29tL2Rpc3BhdGNoLz9hY3Rpb249VHJpYWwmcHJvZHVj +dGlkPXBkZiZ2PTExLjExLjI4MDQmZj1Qcm9mZXNzaW9uYWwlMjBmZWF0dXJlczolMjBVc2VyJTIw +aXMlMjBub3QlMjBpbnRlcmFjdGl2ZSZyZD10cmlhbCZmbD0mcmZsPVBSTykKL1MvVVJJPj4KL1N1 +YnR5cGUvTGluaz4+ZW5kb2JqCjE2IDAgb2JqCjw8L1I3CjcgMCBSPj4KZW5kb2JqCjE3IDAgb2Jq +Cjw8L1IxMgoxMiAwIFI+PgplbmRvYmoKMTIgMCBvYmoKPDwvU3VidHlwZS9JbWFnZQovQ29sb3JT +cGFjZS9EZXZpY2VSR0IKL1dpZHRoIDQ4NQovSGVpZ2h0IDE3OAovQml0c1BlckNvbXBvbmVudCA4 +Ci9GaWx0ZXIvRmxhdGVEZWNvZGUKL0RlY29kZVBhcm1zPDwvUHJlZGljdG9yIDE1Ci9Db2x1bW5z +IDQ4NQovQ29sb3JzIDM+Pi9MZW5ndGggODU5Nj4+c3RyZWFtCnic7Z1PrF/Fdcfv29kKErY3cfAi +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 +mC/DQfYSeCdasGbx9iyEahLa0CpYc3zPF1qw5uBl7JiqhJuJkMGa43vWZrDm4GWwGaw5eHUWrFm8 +vXoGa475ahWsOXjZBgKTcJZX4d9FQlc8CywJq0TAVyyldGHOKC7hKnaFSCRsZhoYspRS02awFvE2 +toFeJBRyvSyfTwgfGb5Wn49TN38cR9vuetL0ZPGpum7t36u333ae6qDTH+WnOLQKZW5kc3RyZWFt +CmVuZG9iago4IDAgb2JqCjw8L0Jhc2VGb250L0dBVkNCTytBcmlhbC9Gb250RGVzY3JpcHRvciA5 +IDAgUi9Ub1VuaWNvZGUgMjIgMCBSL1R5cGUvRm9udAovRmlyc3RDaGFyIDEvTGFzdENoYXIgNzIv +V2lkdGhzWyA3MjIgNTU2IDIyMiAyNzggNTU2IDUwMCAyNzggNjY3IDU1NiA2NjcgMzMzIDUwMCA1 +NTYgNjY3IDMzMwo1NTYgMjc4IDIyMiA3MjIgNTU2IDI3OCA1NTYgNTU2IDI3OCA1NTYgNTU2IDU1 +NiA1NTYgNzc4IDc3OCAzMzMKNzIyIDMzMyAyNzggNTAwIDYxMSA2MTEgNzIyIDU1NiA1NTYgNTU2 +IDUwMCAxMDE1IDgzMyA3MjIgNTU2IDU1Ngo1NTYgNTU2IDY2NyA2NjcgNjExIDY2NyA1MDAgNTg0 +IDUwMCA4MzMgNjY3IDcyMiA1NTYgOTQ0IDcyMiAyNzgKNTU2IDE5MSAyNzggNDAwIDI3OCA1NTYg +NTU2IDU1NiAzNTVdCi9TdWJ0eXBlL1RydWVUeXBlPj4KZW5kb2JqCjIzIDAgb2JqCjw8L0ZpbHRl +ci9GbGF0ZURlY29kZS9MZW5ndGggNTAyPj5zdHJlYW0KeJxd1E1u2zAQBeC9T6EbmDP8kQME3KSb +LFoUbS8gU1SgRWRBcRa9fd+8qbvo4gV4EWnyo0yfX16/vG7rfTh/P27tZ78Py7rNR/+4fR6tD9f+ +tm4n0WFe2/1v49/2Pu2n88vXaf/1e+8DBvTF+7fpvZ9/aOR/xOe029w/9qn1Y9re+uk5hPq8LPXU +t/m/R/niM67LY6hUT8ihomr1hLJYjdUTRrWaqieMyWqunqCcW6onKOeO1RMy516qJ5Rs9al6whit +TtUTili9Vk9I/KhWPSFx3bl6Qnqy2qsnpG51qZ6QbCHBWVhQ7ZMFVqE32boCq9Cb+RRWoTdzLqxC +b+ZgWIXebNsQWIXeYusKrEKvssIq9EbjC6xCr85WYRV6IxeCVeiN3AasQm8sVmEVeiO3AavQW4wv +sAq9xQbjVTCotpDCqvQWm6uwKr3jZBVWpbeYSGFV945WYVV608UqrOreZhVWpTdxXVjVvRwMq9Ib +7buhsKp7uRCsSm/hQrCqv1/uGValV/kUVnWvnSS+XJ4w2Tbw8QwG20ni/BgAbV2cPYNqB4vzY1A5 +GNboXiPgVTB4agScPYM921lFWKO/X64La6Q38ymskd5ke8YMBrXxHj4unF1Ju9uPqzy0z+Po250/ +ALzgdrHXrf/7jdhvu80akNMfVk8LMQplbmRzdHJlYW0KZW5kb2JqCjEwIDAgb2JqCjw8L0Jhc2VG +b250L1FaUEVYSitBcmlhbCxCb2xkL0ZvbnREZXNjcmlwdG9yIDExIDAgUi9Ub1VuaWNvZGUgMjMg +MCBSL1R5cGUvRm9udAovRmlyc3RDaGFyIDEvTGFzdENoYXIgNTgvV2lkdGhzWyA2NjcgNjExIDM4 +OSAzMzMgMjc4IDI3OCA3MjIgNTU2IDU1NiA1NTYgNzc4IDcyMiAyNzggNzIyIDY2Nwo3MjIgNzIy +IDc3OCA3MjIgNjY3IDYxMSA2MTEgMjc4IDU1NiAzMzMgNTU2IDU1NiA1NTYgNTU2IDI3OCAzMzMK +ODg5IDU1NiA1MDAgNjExIDk0NCA3MjIgMjc4IDYxMSA1NTYgNTU2IDU1NiA2MTEgODMzIDIzOCA2 +MTEgNTU2Cjg4OSA2MTEgNjExIDYxMSA2NjcgNTU2IDMzMyAyNzggNjExIDc3OCA2MTFdCi9TdWJ0 +eXBlL1RydWVUeXBlPj4KZW5kb2JqCjI0IDAgb2JqCjw8L0ZpbHRlci9GbGF0ZURlY29kZS9MZW5n +dGggMzEyPj5zdHJlYW0KeJxdksFugzAMhu88Rd6AkBLTSpUv7aWHTdO2F4BgKg4NiNLD3n6/TbvD +Dr+lDxL5s5zydDlf8ri68mOZ0pesbhhzv8h9eixJXCfXMRdVcP2Y1idZTbd2LsrTWzt//8zicECG +jd/bm5SfobYv1XYnTb3c5zbJ0uarFEfv+TgMXEju//0KcbvRDc+jAUc13qMCe94SqgCsK7Z4jwrc +sQW4U6zZAqwVI1uAUfHAFuBBMbEFmBTRRAPsFQe2AOF9jIEt3kfViGgSrVHURgQjMitSK4IRmRWp +FUGBTINUg4gtQFLcswW4V4QgmSSpJHVsAXaK8CVzJnUm+JI5kzqTsAUoitAnG4F0hAb6jY3Q6AgN +9BsbAVUX89qA7kiX/dqtS49lkbzai7CN66bHLH+PZp5mveWQ4heSYaHqCmVuZHN0cmVhbQplbmRv +YmoKMTQgMCBvYmoKPDwvQmFzZUZvbnQvRk1ZRExOK0M6XFdpbmRvd3NcRm9udHNcYXJpYWwudHRm +L0ZvbnREZXNjcmlwdG9yIDE1IDAgUi9Ub1VuaWNvZGUgMjQgMCBSL1R5cGUvRm9udAovRmlyc3RD +aGFyIDMyL0xhc3RDaGFyIDExNi9XaWR0aHNbCjI3OCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAz +MzMgMCAwCjAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAKMCA2NjcgMCA3MjIgNzIyIDY2 +NyAwIDAgMCAyNzggMCAwIDU1NiA4MzMgMCA3NzgKMCAwIDcyMiAwIDYxMSAwIDAgMCAwIDAgMCAw +IDAgMCAwIDAKMCA1NTYgMCA1MDAgMCA1NTYgMjc4IDAgNTU2IDIyMiAwIDUwMCAyMjIgODMzIDU1 +NiA1NTYKMCAwIDMzMyAwIDI3OF0KL1N1YnR5cGUvVHJ1ZVR5cGU+PgplbmRvYmoKOSAwIG9iago8 +PC9UeXBlL0ZvbnREZXNjcmlwdG9yL0ZvbnROYW1lL0dBVkNCTytBcmlhbC9Gb250QkJveFswIC0y +MTAgOTc5IDcyOV0vRmxhZ3MgNAovQXNjZW50IDcyOQovQ2FwSGVpZ2h0IDcyOQovRGVzY2VudCAt +MjEwCi9JdGFsaWNBbmdsZSAwCi9TdGVtViAxNDYKL01pc3NpbmdXaWR0aCA3NTAKL0ZvbnRGaWxl +MiAxOSAwIFI+PgplbmRvYmoKMTkgMCBvYmoKPDwvRmlsdGVyL0ZsYXRlRGVjb2RlCi9MZW5ndGgx +IDYzMDI4L0xlbmd0aCAyODM2OT4+c3RyZWFtCnicpL0JfBTl/TD+PM+cuzu7O7vJHtnd7M5mkw3J +AoEkEAKRDLeISBAICRIJcgooBPA+CCqHEQVtS9VaxaviQd0cQDhaqPLTqqXaam21VanF89cotUg9 +yO77fZ7ZWTbV/t++n/9Onuf5zjPPzHN97+eZCcIIITtqRxxqmDGrohKx3+8uhKhx0RUL1xjnry5H +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 +MB2tKB3derqHzHPIOutx1hkhpdJQt0tDhyx0nG9GGMfugg4S0NO3PlkeZfKDGU/eA71Z6hnP+y4Z +JTGPXQyNfiOgSyqSEQoUclMKz8pF2eEiFVJR7sdJZ5ybXiC3TKWyEhkqNqy9m/XhWVfp7wf9bPCh +MT8kXXTipVnlA3wd8r9FMoS/eTZNMUrkj478mgGTk4rnxcPNmU4TItQfyfP1183zx8PoRxQSVhj/ +voBdhTv3EruwxiSWdGIcXn15CJpjXkTpUpPuoi174oibrmkj9QZ38U8oDtUZXfhF+/Kg/qNo+Ghj +jY9GB3M//Pyg54+Fs00j+Sz+xC9bRgqkJamOSXK4iW8RcU5AHBfhOOERK0zqkg45K5pgIWladhs3 +0lIkk7W0o3xSpvJSg1H1V+emtaCNfoelD7EqpBuK0kPKkQckN2RxI/RU3+zYuLEUUNLZGgFr4BAS +ZMrApa2scqfEm8Q6HFNVcVJjh+WvFN85zz7M/gOSUtWRCmVuZHN0cmVhbQplbmRvYmoKNCAwIG9i +agogICAzMDk0CmVuZG9iagoyIDAgb2JqCjw8CiAgIC9FeHRHU3RhdGUgPDwKICAgICAgL2EwIDw8 +IC9DQSAxIC9jYSAxID4+CiAgID4+CiAgIC9Gb250IDw8CiAgICAgIC9mLTAtMCA1IDAgUgogICAg +ICAvZi0wLTEgNiAwIFIKICAgICAgL2YtMS0wIDcgMCBSCiAgICAgIC9mLTEtMSA4IDAgUgogICA+ +Pgo+PgplbmRvYmoKOSAwIG9iago8PCAvVHlwZSAvUGFnZQogICAvUGFyZW50IDEgMCBSCiAgIC9N +ZWRpYUJveCBbIDAgMCA2MTIgNzkyIF0KICAgL0NvbnRlbnRzIDMgMCBSCiAgIC9Hcm91cCA8PAog +ICAgICAvVHlwZSAvR3JvdXAKICAgICAgL1MgL1RyYW5zcGFyZW5jeQogICAgICAvSSB0cnVlCiAg +ICAgIC9DUyAvRGV2aWNlUkdCCiAgID4+CiAgIC9SZXNvdXJjZXMgMiAwIFIKPj4KZW5kb2JqCjEw +IDAgb2JqCjw8IC9MZW5ndGggMTEgMCBSCiAgIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlCiAgIC9TdWJ0 +eXBlIC9UeXBlMUMKPj4Kc3RyZWFtCnicnXoHQBTH9/9QllvuuLOuXQ5UxK7Yuyg2bBgRRYqgFKkK +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 +4jf4TfwGv4nf4DfxD/iH+Af8Q/wD/iH+Af8Q/4B/iH/AP8Q/4B/iH/AP8Q/4h/gH/EP8A/4h/gH/ +EP+Af4h/wD/EP+Af4h/w6+503Hv+zk/xwz0W5971d36eHO6x+jp47c7fPHsj9rfoPZb+3pmD9v93 +J1n44UpGkm+Uw86P5Z2L3l/HauwwE3tOJNZk+3vsk3G186vLnmuVvw72iViG7g8e++tmX4ll4uEe +l+9BdZ3UvffbPbV9vV7XK6pfjv1uqlvp6Wzf7s+X14uq/O9f/gio4gplbmRzdHJlYW0KZW5kb2Jq +CjEzIDAgb2JqCiAgIDExNjkKZW5kb2JqCjE0IDAgb2JqCjw8IC9UeXBlIC9Gb250RGVzY3JpcHRv +cgogICAvRm9udE5hbWUgL1JLSUFUTStTV0lGVERBWTNCb2xkCiAgIC9Gb250RmFtaWx5IChTV0lG +VERBWTMpCiAgIC9GbGFncyA0CiAgIC9Gb250QkJveCBbIC0zOTQgLTExODMgMTYzNSA5MzggXQog +ICAvSXRhbGljQW5nbGUgMAogICAvQXNjZW50IDc3MAogICAvRGVzY2VudCAtNDMwCiAgIC9DYXBI +ZWlnaHQgOTM4CiAgIC9TdGVtViA4MAogICAvU3RlbUggODAKICAgL0ZvbnRGaWxlMyAxMCAwIFIK +Pj4KZW5kb2JqCjUgMCBvYmoKPDwgL1R5cGUgL0ZvbnQKICAgL1N1YnR5cGUgL1R5cGUxCiAgIC9C +YXNlRm9udCAvUktJQVRNK1NXSUZUREFZM0JvbGQKICAgL0ZpcnN0Q2hhciAzMgogICAvTGFzdENo +YXIgMjU1CiAgIC9Gb250RGVzY3JpcHRvciAxNCAwIFIKICAgL0VuY29kaW5nIC9XaW5BbnNpRW5j +b2RpbmcKICAgL1dpZHRocyBbIDI2MCAyODYgMzI2IDQzNyA0NzUgOTE3IDcwMCAxNzIgMzEwIDMx +MCA0NDMgNDYwIDI1OSAzMDAgMjU5IDI4NiA1NzYgNDA2IDUwMCA0NDYgNTcwIDQzOSA1MTYgNDAw +IDUxOCA0OTYgMjU5IDI1OSAzMjAgNDYwIDMyMCA0MzAgNzQwIDY3MiA2NDEgNjcxIDc0MCA1ODkg +NTE0IDcwNSA3MzYgMzgwIDM2OCA2NjkgNTYyIDkxMCA3MDMgNzQzIDU1OSA3NDMgNjMwIDUxMCA1 +NTIgNjg4IDY0NiA5NTIgNjM5IDU5MyA1NzAgMzMwIDI4NiAzMzAgNDgwIDUwMCA0MDAgNDg0IDU1 +NiA0MzMgNTQwIDQ0OCAzNTMgNDkyIDYwNSAzMTQgMzE4IDU1NSAzMjAgODYzIDU5MSA1MTggNTY1 +IDU2NSA0MTkgNDMwIDM4NCA1NzAgNDg2IDc1MCA0OTAgNDYxIDQzNSAzMTAgMjA0IDMxMCA0NjAg +MCA2MjMgMCAxODAgMCA0MzAgMTAwMCA0NjAgNDYwIDQwMCAxMzAwIDUxMCAyOTIgODcwIDAgNTcw +IDAgMCAxODAgMTgwIDQzMCA0MzAgMCA1MDAgOTQwIDQwMCAwIDQzMCAyOTIgNzU0IDAgNDM1IDU5 +MyAwIDI4NiA0NTMgNDU0IDAgNTg4IDIwNCA0NzQgNDAwIDcyMSAzODQgNDgwIDQ5NCAwIDcyMSA0 +NDggMjgyIDQ2MCA0MTUgMzgzIDQwMCA1NzEgNTIzIDIzMCA0MDAgMzUyIDM5MyA0ODAgODQwIDg0 +MCA4NDAgNDMwIDY3MiA2NzIgNjcyIDY3MiA2NzIgNjcyIDg1NSA2NzEgNTg5IDU4OSA1ODkgNTg5 +IDM4MCAzODAgMzgwIDM4MCA3NDAgNzAzIDc0MyA3NDMgNzQzIDc0MyA3NDMgNDYwIDc0MyA2ODgg +Njg4IDY4OCA2ODggNTkzIDU2OCA1OTAgNDg0IDQ4NCA0ODQgNDg0IDQ4NCA0ODQgNjg0IDQzMyA0 +NDggNDQ4IDQ0OCA0NDggMzE4IDMxOCAzMTggMzE4IDU0MSA1OTEgNTE4IDUxOCA1MTggNTE4IDUx +OCA0NjAgNTE4IDU3MCA1NzAgNTcwIDU3MCA0NjEgNTY1IDQ2MSBdCiAgICAvVG9Vbmljb2RlIDEy +IDAgUgo+PgplbmRvYmoKMTUgMCBvYmoKPDwgL0xlbmd0aCAxNiAwIFIKICAgL0ZpbHRlciAvRmxh +dGVEZWNvZGUKICAgL1N1YnR5cGUgL0NJREZvbnRUeXBlMEMKPj4Kc3RyZWFtCnicnXoHXFRH1/dS +7u7lLrsKchWNsmpEjb1hizHYsCsBCyAgKigoXTqogKjoSRBRilJFLChYsGAXe+wt0WhMjKlvuqZ4 +7jqL+51715Z8yfM+38fvt7sz8z9n2qkzg5XK1lZlZWWl954+1mPKyGG+fYdHhQerrGxVKpUVfdyl +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 +oQxVKaEaZalOOWql8Bl8gk/PtOAz+ASfwSf4DD7BZ/AJPoNP8Bl8gs/gE3wGn+DTa9DiM/gsPoPP +4jP4LD6Dz+Iz+Cw+wWfxCT6LT/BZfILP4hN8Fp/gs/gEn8Un+Cw+wWfxCT6LT/A5fILP4RN8Dp/g +c/gEn8Mn+Bw+vcccPr03HT69Gx0+vf8cPr3/HD69/xw+vf8cPr3/HD69/xw+vfsdPovP47P4PD6L +z+Oz+Dw+i8/js/g8PofP43P4PD6Hz+Nz+Dw+h8/jc/g8PofP43P4PD6Hz+Nz+Dw+hy/gc/gCPocv +4HP4Aj6HL+BzPJn1Cfw/T+SA3CMPyD3ygNyjC8g9uoDcowvIPbqA3KMLyD26gNyrDrlXHXKPLiL3 +6CJyjy4i95y9iDxwhiLywBmK+AKGiC9giPgChogvYIj4AoaIL2CI+AKGiC9giPiCGvAFNeALGBK+ +gCHh0zUm4Yuch4Qv4kv4Ir6ET1ejhE9Xo4RPV6OET1ejhE/Xn4QvIkr4IqKELyJK+KIe9fCJ3rVp ++KSw3TJTbLcMn1S+uQjF3hdLsffFUTrK8EnjPCyB0u0ipdslirlehs9m5nPJFHO2DJ8tOubw2aqf +DZ9tzODCyq/r1sLKr+tWZuXXdSuz8us6kln5dXXIrPz67M6s/Pr0zJ7SUQLFFZLjKH265LtPzMKx +5IXS7TKl3yyjCjOR6yj9lZUbpd/sFFdIXikMZaY4f2X4TGOUIhR7L/xy018axVHYy/CJrj9l+ETn +rESKuS6J0m8ulO49U8xLKYypx1IpZrc0inNbOsX1UlaK66UOn+haUfllqs/uKhRjVnz6tK749Gld +8QWOpeLTp03Fp0+biq/3b5/I4r5/IFfgnemrwFcdHvjKpVMH3C5MWG2UcjrFJVBXiulrM8Ue2oD/ +e6M0odhDsxSnqzmKG6V5CmoLFNQWKS6Iligmsy0Uk9kyxWS2QnGCGr6Mr+HL+Bq+jK8NX+yM2Wdq ++Xai768n93epr+8+9X3b9tee8cI13nfubzqnc//6Tna9XO9bjb//AJ+bDlkKZW5kc3RyZWFtCmVu +ZG9iagoxOCAwIG9iagogICAxMTM1CmVuZG9iagoxOSAwIG9iago8PCAvVHlwZSAvRm9udERlc2Ny +aXB0b3IKICAgL0ZvbnROYW1lIC9QR0RSSVkrU1dJRlREQVkzQm9sZAogICAvRm9udEZhbWlseSAo +U1dJRlREQVkzKQogICAvRmxhZ3MgNAogICAvRm9udEJCb3ggWyAtMzk0IC0xMTgzIDE2MzUgOTM4 +IF0KICAgL0l0YWxpY0FuZ2xlIDAKICAgL0FzY2VudCA3NzAKICAgL0Rlc2NlbnQgLTQzMAogICAv +Q2FwSGVpZ2h0IDkzOAogICAvU3RlbVYgODAKICAgL1N0ZW1IIDgwCiAgIC9Gb250RmlsZTMgMTUg +MCBSCj4+CmVuZG9iagoyMCAwIG9iago8PCAvVHlwZSAvRm9udAogICAvU3VidHlwZSAvQ0lERm9u +dFR5cGUwCiAgIC9CYXNlRm9udCAvUEdEUklZK1NXSUZUREFZM0JvbGQKICAgL0NJRFN5c3RlbUlu +Zm8KICAgPDwgL1JlZ2lzdHJ5IChBZG9iZSkKICAgICAgL09yZGVyaW5nIChJZGVudGl0eSkKICAg +ICAgL1N1cHBsZW1lbnQgMAogICA+PgogICAvRm9udERlc2NyaXB0b3IgMTkgMCBSCiAgIC9XIFsw +IFsgNTAwIDU4MCA1NzYgNzUwIDM5NiA0OTYgNDQ1IDUwMCA0NDUgNTQyIDQ0MSA1MTYgMzgwIDUx +OCA0NDggMzkwIDM0MiAzODUgMzA1IDM5OSAzODUgNjcyIDQ4NCA2NzIgNDg0IDY3MSA0ODQgNjcx +IDQzMyA2NzEgNDMzIDY3MSA0MzMgNjcxIDQzMyA3NDAgNzAwIDc0MCA1NDAgNTg5IDQ0OCA1ODkg +NDQ4IDU4OSA0NDggNTg5IDQ0OCA1ODkgNDQ4IDcwNSA0OTIgNzA1IDQ5MiA3MDUgNDkyIDcwNSA0 +OTIgNzM2IDYwNSA3MzYgNjA1IDM4MCAzMTggMzgwIDMxOCAzODAgMzE4IDM3OSAzMTQgMzgwIDMx +OCA3MTEgNjMyIDM2OCAzMTggNjY5IDU1NSA1NDkgNTYyIDMyMCA1NjIgMzIwIDU2MiA0NjAgNTYy +IDQzNiA1NjIgMzU2IDcwMyA1OTEgNzAzIDU5MSA3MDMgNTkxIDYxOCA3MDMgNTg5IDc0MyA1MTgg +NzQzIDUxOCA3NDMgNTE4IDYzMCA0MTkgNjMwIDQxOSA2MzAgNDE5IDUxMCA0MzAgNTEwIDQzMCA1 +MTAgNDMwIDU1MiAzODQgNTUyIDM4NCA1NTIgMzg0IDY4OCA1NzAgNjg4IDU3MCA2ODggNTcwIDY4 +OCA1NzAgNjg4IDU3MCA2ODggNTcxIDk1MiA3NTAgNTkzIDQ2MSA1NzAgNDM1IDU3MCA0MzUgMzQz +IDMxOCA0MDAgNDAwIDQwMCA0MDAgNDAwIDQwMCA0MDAgNDAwIDQwMCA0MDAgNzE2IDU3OSA1NzYg +NTUwIDUzOCAzMDAgMzAwIDUwMCAxODAgNDMwIDIzMCAxNzAyIDIyMCA1MDAgMCA2MDAgNjAwIDc2 +MCA2MDAgNjAwIDYwMCA2MDAgNjAwIDYwMCA2MDAgNjAwIDY4MCA4MDAgMzYwIDQ2MCAyODYgNTgw +IDU4MCA3MTggNDgwIDQ2MCA0NjAgNDM2IDQzNiA0NjAgNDYwIDQ2MCA0NjAgNDYwIDQ2MCAzMzAg +MzMwIDEwMDAgMTAwMCAxMDAwIDEwMDAgMzkwIDM5MCAzOTAgMzkwIDM5MCAzOTAgMzEwIDMxMCAz +MTAgMzEwIDMxMCAzMTAgNDA0IDQwNCA0MDQgNDA0IDQwNCA0MDQgNDA0IDI4MCAyODAgXV0KPj4K +ZW5kb2JqCjYgMCBvYmoKPDwgL1R5cGUgL0ZvbnQKICAgL1N1YnR5cGUgL1R5cGUwCiAgIC9CYXNl +Rm9udCAvUEdEUklZK1NXSUZUREFZM0JvbGQKICAgL0VuY29kaW5nIC9JZGVudGl0eS1ICiAgIC9E +ZXNjZW5kYW50Rm9udHMgWyAyMCAwIFJdCiAgIC9Ub1VuaWNvZGUgMTcgMCBSCj4+CmVuZG9iagoy +MSAwIG9iago8PCAvTGVuZ3RoIDIyIDAgUgogICAvRmlsdGVyIC9GbGF0ZURlY29kZQogICAvU3Vi +dHlwZSAvVHlwZTFDCj4+CnN0cmVhbQp4nH1WCVhU1R4/d4bjud6xMYWrqeCgLW5UEpoKZbIKiKzK +GigOiyP7AOOwyKKC2onYF0GQfZMQEc20cd/KNCJ7fWal9arXy77K972+c+nM93pnBkNt8c439zv3 +v/7+5/yXwwELC8BxnFVQiJfHOjfnMAeXlMQYr4zoRI0acBYAAI79naTJQJrFSdYyyUYuWVkYeWsa +bT3B7deV0Iax351senc8zt6ccoppPYe9LEamFk6SATnHzXZ4yTUlNUurid+cYTtfvcD2hcX2S+1s +E2ITdZpkOzu7cd+2Jue2Y97Hiczc3Gh1ckis7aZ4TUqmNj0jy3drWMJz6zYn6mI8kyri9KlpztkA +uAA34AeWAw8G2RV4Am/gD4JBEFgH1oNw8DwIBY7AB0wGS4EXWAtCgDtYAHwBD1aDMBAAAsFcEAGe +lDHluWAxWMHM+TDFUFACGsAB8DY4C66Aj8E34EcgcTJuCjeHW8St4Fw4H66Ma+beki2RrZT5yi7L +eflGeZo8GxskewNnMJDTBrlhmvSUVG18aoLBqBYle3LaaI+U3dJ0yUv0FFwiRC9BuYz8xr7WCMpT +pJctwgSltFD6RfpedBWUgeQrRvIVlLRD2UPeG/UQqW9GSfqFYuy/wHgd9hW8t6sb8yTqLnEhdneD +m2NUftjZbR0VeDLrP6K3oGyUbJmBAJOrQGYlgKSNLhFtBQPNF9cJyiLyLeOGCzERYoSgPEta2ddG +QbmYdLPFq0x+OrnAVpFs5UT2SQNilDBM54obBGULqWaMTYKSLCO2o1NFJ7baQGoYLVpQ/kO6xeC7 +CcU0XHQXGPCPGPBggZSTO2IIs5VOniEzGArlLfK19KO4SiBzpzkLynPkOpMLYgLh5BJbhQrKLLNJ +6spiPl9c6p9Ha+Fuz4H8g5jIMfEg9ZhMx43F7flVvK50YRSs0VdE4xT8pCNdqMFZeEd1yQCvPCQZ +mDEfgVoSg7iW4UwfXSN9IzoI9IB0Ulwi9JJWcSkjnzBvP11r9oX9tXRRDHUrLyCgpf4WvEhcDd8d +xF24Vr9TzWdQq7/i1el3qPl4GphHZaX5xLV1T1tAGb54mKTC+tvhPZF4OY4L8qMqvv7mPcKLOMbP +l87jlfQCCZfeE/NZ7E9KS0ZdRT9hEz0t+gvKRNJoQrUqpST9DO7ccpK+cpQGEpeuPW3rcWrferIG +ukeIaynISU/CmTi7TFfO5+3MWA5rt9fvbsb9uLe9ubutr7JpmEzkySqiEzfjNK0uOSMxX+s/j++b +cO6H+pZu3IJri5uL+LqK1i9gflXuGzrMKw+Sjgf3I4xeDaa55pgHSBiseq0SV5hS76cHUm/VWOpZ +m1OvyZx6VE3l1IXaPX1aN6i6hEc+OEsE3t1ozTbIn22QF9ugK5J8iLjALTWBpcmYZ5najrsK6gv4 +HbEwhDqvckzAfEZ+1SkytY9Y/qxSEhsSSRLFRQLxleaIdoLyeTKZOVILytx6yQuTyNqBNybQbIzM +3chS2m0lFYr7JgmvT1KCIlORWwIrIIJpYDp4AswAM8EsYA1swDzWEfxY3zD1A1PnSAGvgSpQDWpA +LagDe8H74Cq4Bj4Aw+Cf4H/gN86R28ClcxlcJtfJnee+kqlkobKtsnfkU+U28kQ5ll+X/yy/azHR +wsUiyqLL4pTFR3AS3ARfg9Xw1oSQCQY0ASHEo4lIQAo0CT2GlGgyehxNQVORJbJCIpqGpqMn0Aw0 +E81C1sgGzUYqZIvmIFfkhtyRB1qNPJEX8kZrkA9ai3yRH/JHASgQBaF1aD0KRiEoFIWhcBSBXkWR +KAptQBtRNNqE1CgGxaI4FI82xyQlxcT0JA4O9vYODib2xKgU9x8yjMsH6DCGCnrNOOqdjbeSQIz3 +uzeSbCn+a2Oct55RihglqIFaSb/Ce2o32vF+WoHx1it6B+NJFy0TYl/7PZqg4vrvrGET6+XfWWsY +i/Tj8qO0H9+zgnXv618wnnPKwjoSjXHzK42fSbuI3njoJr4j/fT+PtxMAzHEuk+y3Y0+NFLSjuk5 +GM+YrdaaHX4tXYKKiqePlewlc5JxWDGlOLwsCSfSOW/vGbjLNPwwDTF6+Ese55qYwRyMdRdyvjGO +vsMsk2gp/HXVNWOEAbNWSEYamYA/E7isp2uNblBxxaTxIiMc30odaN1LZO+QiZLCKGdyyGNGcImF +MkQqKcRqWuJqAlXKQJlgUvXT5nI4YS6Hq+ZySKQrS/KI971+0U6eOXK/HBQPFYPX/WJoMxWDorW2 +MIROjaGWjqoErKnNreHL++Ep4nL1iyNMl1rTBdSO/eD5hE9V1/CHp89f4nUtMF4TqYnGfOSWti8J +aPr3HdWbuFPbso2vK+u/AbMbdNUmx+OTJoPOg9vfKCzJwzxdSR+nnvSZe9V8/cMx+Kvuw38HMsSd +efU5fHGyCfHLy8cRdxJLAlQKOpHOpPOoPYXvJnzMQN0YB7VeE4L5+KSOm2R2+5cj++oLtCocWhBM +J+XzSYjMrP/hLP6UP775UOhsxWas3RaXFpXg7J9FZfwKYwnMcyzQJ+FUrKnX1vB5xanLYUNxx64W +1gLb9w50HDvw8cVGIuM/l0pg3Wc1TT24E7+Z217AQu78AuaUpZVmspArDtEN2Ddzdw7LejW1+FPf +2kQLYe6SjO1BOAqv3hfTw2/bnr4Sdmwdyj+Mz+PmskNNx9sId6qV2PBDpOhvT/GVh06RyFX399rb +OP/hyXLSPFkaHjFZQsyDRG0eJAq65l67jqWah4VZ8o178TLOjxvz0lx7G7LNTE3PSMpMLsp1ms8n +JfxCk4gDGzDhOK5vIfGCFz+tqe5kI6KxiI2IytKO23B7pa5c/2CKvGyM+6O3vx+kYyHtfURIYQ+G +5ECdFlMNhSMJH35FXvqWaAh0PeChUgx1bVxKxbhnV0enHvmSiAe/H1Y95NQc4S/N9bdhGwEjZNYZ +vB/X7Wko5KvLev8Fd1Rsq8zE3jjCJ4jO5BWL6cQAexWO7Is/G3cutjELJ+Kk3J1x7Jw9hzIb+Kzi +nOWwqcCQcwLfwv+9gD/HF7YcDBjc2K3Zj7txV2tVOz6Gj6e35PCNZS2fQ31VWpnp1BdluhjtYfaC +Lv0ZfBJf6+vv4wuboD4voSALb8E791ef40ek+dCUd1ocO5Z33mMROMfTGJj7oiHpbXwbH750mcxj +mxX0UJuYP0jcimooyMxdBv2pa+hz8ezuk9dUcZh/kwQyS3++NZ3Hl68cucPvcnzgFuX8t7co57Fb +VKHpFlVHgmFZS/lR3MHSYEzSblzy2WV0STLOG5P8YwRnzRH0myOIpDWsfrLN9eP8QP0cN9dPX8nB +luPd351oJdasfIoHifsjolPTGS27YNvhzsqj2/mbuXQF1S61b0yHR3s6KnuLeOKYcAy/RWZklsJ0 +ddbO6CqeTukgTkT7yY2sNrgxJac4vYynC7qg4hEPjtYkRKXxtTr40eGrJ/FNfiilL06tzdDMxvHN +ukOF/KOU7z//B39W2BAKZW5kc3RyZWFtCmVuZG9iagoyMiAwIG9iagogICAyNjEzCmVuZG9iagoy +MyAwIG9iago8PCAvTGVuZ3RoIDI0IDAgUgogICAvRmlsdGVyIC9GbGF0ZURlY29kZQo+PgpzdHJl +YW0KeJxdks9ugzAMxu88RY7doYLSgFcJVZq6Sw/7o3V7AEhMh7QGlNJD3352vqqTdoD8cL7PNonz +3f55H4bZ5O9xdAeeTT8EH/k8XqJj0/FxCNmqNH5w8+0rvd2pnbJczIfreebTPvRj1jQm/5DN8xyv +ZvHkx44fMmNM/hY9xyEczeJrd0DocJmmHz5xmE2RbbfGcy/pXtrptT2xyZN5ufeyP8zXpdj+FJ/X +iU2ZvldoyY2ez1PrOLbhyFlTFFvT9P024+D/7a0tLF3vvtuYNfVKpEUhi/AavFZmMAtXlFgWiVeI +V8JlkVgWiZeIl8rQ10m/AW+Ue7A01hDykOYheEm9hB5IeyALtsrIQ5rHojervRFqkdaqoKlSrQ61 +Ou0T+jL9C3JWmrN+hOZR2YGd5qyRs9Y49LXqLfQ26T3iXnij/ZfFKtWFt1YvQU+qJ5wV6VkRzpz0 +zC3YKlOLeJsu7nZDeoU6a/fZcJcYZSzSQKZ50EkYAt9ndhondaXnF1p7vskKZW5kc3RyZWFtCmVu +ZG9iagoyNCAwIG9iagogICAzODMKZW5kb2JqCjI1IDAgb2JqCjw8IC9UeXBlIC9Gb250RGVzY3Jp +cHRvcgogICAvRm9udE5hbWUgL0hRUUtGUCtTV0lGVERBWTNCb2xkSXRhbGljCiAgIC9Gb250RmFt +aWx5IChTV0lGVERBWTMpCiAgIC9GbGFncyA0CiAgIC9Gb250QkJveCBbIC0zNzIgLTExODMgMTYw +NCA5MzggXQogICAvSXRhbGljQW5nbGUgMAogICAvQXNjZW50IDc3MAogICAvRGVzY2VudCAtNDMw +CiAgIC9DYXBIZWlnaHQgOTM4CiAgIC9TdGVtViA4MAogICAvU3RlbUggODAKICAgL0ZvbnRGaWxl +MyAyMSAwIFIKPj4KZW5kb2JqCjcgMCBvYmoKPDwgL1R5cGUgL0ZvbnQKICAgL1N1YnR5cGUgL1R5 +cGUxCiAgIC9CYXNlRm9udCAvSFFRS0ZQK1NXSUZUREFZM0JvbGRJdGFsaWMKICAgL0ZpcnN0Q2hh +ciAzMgogICAvTGFzdENoYXIgMTQ2CiAgIC9Gb250RGVzY3JpcHRvciAyNSAwIFIKICAgL0VuY29k +aW5nIC9XaW5BbnNpRW5jb2RpbmcKICAgL1dpZHRocyBbIDIyMCAwIDAgMCAwIDAgMCAwIDAgMCAw +IDAgMCAwIDI3MCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCA2MzAgMCAwIDAg +MCAwIDAgNzE2IDAgMCAwIDAgMCA2NTYgMCAwIDAgMCAwIDU3NyAwIDAgODk5IDAgNTY4IDAgMCAw +IDAgMCAwIDAgNTIyIDUyMyA0MDkgNTMxIDQzNiAzNTAgNTExIDU0OSAzMDQgMCA1MjMgMjk1IDc4 +OCA1NDUgNDg3IDUyOSA1MTIgNDAyIDM2MiAzMjYgNTQ4IDQ3NSA2OTYgNDYyIDUxNSA0MDEgMCAw +IDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDE4OCBdCiAgICAvVG9V +bmljb2RlIDIzIDAgUgo+PgplbmRvYmoKMjYgMCBvYmoKPDwgL0xlbmd0aCAyNyAwIFIKICAgL0Zp +bHRlciAvRmxhdGVEZWNvZGUKICAgL1N1YnR5cGUgL0NJREZvbnRUeXBlMEMKPj4Kc3RyZWFtCnic +Y2RgYWFgZGQUCQ73dAtxcYw0dsrPSfEsSczJTGZgZGFgYGAEYqcf0j9kunnkfvAz/JBl/CHH9EOe ++YcIyx8Omd+JMmwuv+xY+3iUZIFqu3hUgBTDAR5VELWbH0SeEGzkYWJgZWRk49N28HRMyU9K9UxJ +zSvJLKl0zi+oLMpMzyhR0EjWVDAyMDTVUchOzSnLzNPR0YG7SAHkJAWIm+CCDAxMQKcxMoPdx8DM +wMzIqJS3p3vvD8O9jHv3fj+wl3mv2A+VH1P/qLDt/ZMs+sPw+4E/hux8u783f38u6l3aXf19Xvek +jaxm3yeJHp3j8Vvod0zAb2vDwIbz34W+xxz4bvVUftEO0d8Wv/l/x/4u+6263va7+HeL7/zfY7+X +fVdNv/lbXJ5v2fc7P5xFq/JY97eviOk25fitHGFo65Gy/aFc95tZt27s46hbwPpbPCpAqVuR4zfr +lahP3wW/G7z5HvKdwX6nhtys7/2iv03+yW1s/W7yS46VDxHaPKBQUwQF5wKhmlk/PLu/x07f0Mf2 +u6qbHSwj/KND5Eej6Bwerh4ePoZWRkYmZhZWNnYOTi5uHl4+fgFBIWERUTFxCUkpaRlZOXkFRSVl +FVU1dQ1NLW0dXT19A0MjYxNTM3MLSytrG1s7ewdHJ2cXVzd3D08vbx9fP/+AwKDgkNCw8IjIqOiY +2Lj4hMSk5JTUtPSMzKzsnNy8/ILCouKS0rLyisqq6prauvqGxqbmltY27sEAAJhry3MKZW5kc3Ry +ZWFtCmVuZG9iagoyNyAwIG9iagogICA1NjUKZW5kb2JqCjI4IDAgb2JqCjw8IC9MZW5ndGggMjkg +MCBSCiAgIC9GaWx0ZXIgL0ZsYXRlRGVjb2RlCj4+CnN0cmVhbQp4nF2QwWrDMAyG734KHbtDcZJS +2CEYRnfJYetotgdwbDkzLLZRnEPefo5SOpjAht/6P/Fb8tK9dsFnkB8UTY8ZnA+WcI4LGYQBRx9E +3YD1Jt8V32bSScgC9+ucceqCi6JtQd5Kc860wuHFxgGfBADIK1kkH0Y4fF36/alfUvrBCUOGSigF +Fl0Z96bTu54QJMPHzpa+z+uxYH+OzzUhNKzrPZKJFuekDZIOI4q2KqWgdaWUwGD/9ZudGpz51sTu +urir0/CsWDWs7JnZu2ubsn35EdEsRCUd74VjbYF8wMfqUkwbxecX6UZzeQplbmRzdHJlYW0KZW5k +b2JqCjI5IDAgb2JqCiAgIDIzMwplbmRvYmoKMzAgMCBvYmoKPDwgL1R5cGUgL0ZvbnREZXNjcmlw +dG9yCiAgIC9Gb250TmFtZSAvUFdDREVaK1NXSUZUREFZM0JvbGRJdGFsaWMKICAgL0ZvbnRGYW1p +bHkgKFNXSUZUREFZMykKICAgL0ZsYWdzIDQKICAgL0ZvbnRCQm94IFsgLTM3MiAtMTE4MyAxNjA0 +IDkzOCBdCiAgIC9JdGFsaWNBbmdsZSAwCiAgIC9Bc2NlbnQgNzcwCiAgIC9EZXNjZW50IC00MzAK +ICAgL0NhcEhlaWdodCA5MzgKICAgL1N0ZW1WIDgwCiAgIC9TdGVtSCA4MAogICAvRm9udEZpbGUz +IDI2IDAgUgo+PgplbmRvYmoKMzEgMCBvYmoKPDwgL1R5cGUgL0ZvbnQKICAgL1N1YnR5cGUgL0NJ +REZvbnRUeXBlMAogICAvQmFzZUZvbnQgL1BXQ0RFWitTV0lGVERBWTNCb2xkSXRhbGljCiAgIC9D +SURTeXN0ZW1JbmZvCiAgIDw8IC9SZWdpc3RyeSAoQWRvYmUpCiAgICAgIC9PcmRlcmluZyAoSWRl +bnRpdHkpCiAgICAgIC9TdXBwbGVtZW50IDAKICAgPj4KICAgL0ZvbnREZXNjcmlwdG9yIDMwIDAg +UgogICAvVyBbMCBbIDUwMCA1NDEgNTIwIF1dCj4+CmVuZG9iago4IDAgb2JqCjw8IC9UeXBlIC9G +b250CiAgIC9TdWJ0eXBlIC9UeXBlMAogICAvQmFzZUZvbnQgL1BXQ0RFWitTV0lGVERBWTNCb2xk +SXRhbGljCiAgIC9FbmNvZGluZyAvSWRlbnRpdHktSAogICAvRGVzY2VuZGFudEZvbnRzIFsgMzEg +MCBSXQogICAvVG9Vbmljb2RlIDI4IDAgUgo+PgplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFn +ZXMKICAgL0tpZHMgWyA5IDAgUiBdCiAgIC9Db3VudCAxCj4+CmVuZG9iagozMiAwIG9iago8PCAv +Q3JlYXRvciAoY2Fpcm8gMS4xMy4xIChodHRwOi8vY2Fpcm9ncmFwaGljcy5vcmcpKQogICAvUHJv +ZHVjZXIgKGNhaXJvIDEuMTMuMSAoaHR0cDovL2NhaXJvZ3JhcGhpY3Mub3JnKSkKPj4KZW5kb2Jq +CjMzIDAgb2JqCjw8IC9UeXBlIC9DYXRhbG9nCiAgIC9QYWdlcyAxIDAgUgo+PgplbmRvYmoKeHJl +ZgowIDM0CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAzNDYzNSAwMDAwMCBuIAowMDAwMDAzMjA5 +IDAwMDAwIG4gCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMzE4NiAwMDAwMCBuIAowMDAwMDE2 +MDg0IDAwMDAwIG4gCjAwMDAwMjg3MzUgMDAwMDAgbiAKMDAwMDAzMjM5NSAwMDAwMCBuIAowMDAw +MDM0NDY2IDAwMDAwIG4gCjAwMDAwMDMzNzUgMDAwMDAgbiAKMDAwMDAwMzU4OSAwMDAwMCBuIAow +MDAwMDE0NTE3IDAwMDAwIG4gCjAwMDAwMTQ1NDIgMDAwMDAgbiAKMDAwMDAxNTc5MCAwMDAwMCBu +IAowMDAwMDE1ODE0IDAwMDAwIG4gCjAwMDAwMTcxNzIgMDAwMDAgbiAKMDAwMDAyNjA3MCAwMDAw +MCBuIAowMDAwMDI2MDk0IDAwMDAwIG4gCjAwMDAwMjczMDggMDAwMDAgbiAKMDAwMDAyNzMzMiAw +MDAwMCBuIAowMDAwMDI3NjAyIDAwMDAwIG4gCjAwMDAwMjg4OTggMDAwMDAgbiAKMDAwMDAzMTYx +MCAwMDAwMCBuIAowMDAwMDMxNjM0IDAwMDAwIG4gCjAwMDAwMzIwOTYgMDAwMDAgbiAKMDAwMDAz +MjExOSAwMDAwMCBuIAowMDAwMDMyOTEzIDAwMDAwIG4gCjAwMDAwMzM1ODQgMDAwMDAgbiAKMDAw +MDAzMzYwNyAwMDAwMCBuIAowMDAwMDMzOTE5IDAwMDAwIG4gCjAwMDAwMzM5NDIgMDAwMDAgbiAK +MDAwMDAzNDIxOCAwMDAwMCBuIAowMDAwMDM0NzAwIDAwMDAwIG4gCjAwMDAwMzQ4MjggMDAwMDAg +biAKdHJhaWxlcgo8PCAvU2l6ZSAzNAogICAvUm9vdCAzMyAwIFIKICAgL0luZm8gMzIgMCBSCj4+ +CnN0YXJ0eHJlZgozNDg4MQolJUVPRgo= +--=_be6bc52b94449e229e651d02e56a25f1-- diff --git a/tests/messages/issue-382.eml b/tests/messages/issue-382.eml new file mode 100644 index 00000000..f34b89fc --- /dev/null +++ b/tests/messages/issue-382.eml @@ -0,0 +1,9 @@ +From: MAILER-DAEMON@mta-09.someserver.com (Mail Delivery System) +To: to@here.com +Subject: Test +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi \ No newline at end of file diff --git a/tests/messages/issue-40.eml b/tests/messages/issue-40.eml new file mode 100644 index 00000000..52e7ff9a --- /dev/null +++ b/tests/messages/issue-40.eml @@ -0,0 +1,39 @@ +Return-Path: +Delivered-To: receipent@receipent_domain.pl +Received: from h2.server.pl +\tby h2.server.pl with LMTP +\tid 4IDTIEUkm18ZSSkA87l24w +\t(envelope-from ) +\tfor ; Thu, 29 Oct 2020 21:21:25 +0100 +Return-path: +Envelope-to: receipent@receipent_domain.pl +Delivery-date: Thu, 29 Oct 2020 21:21:25 +0100 +Received: from sender_domain.pl ([server ip]) +\tby h2.server.pl with esmtps (TLS1.2) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +\t(Exim 4.94) +\t(envelope-from ) +\tid 1kYEQG-00BPgD-S0 +\tfor receipent@receipent_domain.pl; Thu, 29 Oct 2020 21:21:25 +0100 +Received: by sender_domain.pl (Postfix, from userid 1000) +\tid 57DADAB; Thu, 29 Oct 2020 21:21:23 +0100 (CET) +DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=sender_domain.pl; s=default; +\tt=1604002883; bh=CsZufJouWdjY/W12No6MSSMwbp0VaS8EOMGg9WptEaI=; +\th=From:To:Subject:Date; +\tb=v0NAncnNT/w+gInANxAkMt20ktM4LZquuwlokUmLpPyO3++8dy112olu63Dkn9L2E +\t GwfHGqW+8f7g494UK6asUKqTx8fHxlEJbHqAiEV5QrlynSeZDFXsKvGDW8XNMFBKop +\t sAjvp8NTUiNcA4MTbFaZ7RX15A/9d9QVEynU8MaNP2ZYKnq9J/JXgUjjMnx+FiULqf +\t xJN/5rjwHRx7f6JQoXXUxuck6Zh4tSDiLLnDFasrSxed6sTNfnZMAggCyb1++estNk +\t q6HNBwp85Az3ELo10RbBF/WM2FhxxFz1khncRtCyLXLUZ2lzhjan765KXpeYg7FUa9 +\t zItPWVTaTzTEg== +From: faked_sender@sender_domain.pl +To: receipent@receipent_domain.pl +Subject: Zly from +Message-Id: <20201029202123.57DADAB@sender_domain.pl> +Date: Thu, 29 Oct 2020 21:21:01 +0100 (CET) +Forward-Confirmed-ReverseDNS: Reverse and forward lookup success on server ip, -10 Spam score +SPFCheck: Server passes SPF test, -30 Spam score +X-DKIM: signer='sender_domain.pl' status='pass' reason='' +DKIMCheck: Server passes DKIM test, -20 Spam score +X-Spam-Score: -0.2 (/) + +Test message diff --git a/tests/messages/issue-401.eml b/tests/messages/issue-401.eml new file mode 100644 index 00000000..386fdad8 --- /dev/null +++ b/tests/messages/issue-401.eml @@ -0,0 +1,9 @@ +From: from@there.com +To: to@here.com +Subject: 1;00pm Client running few minutes late +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi \ No newline at end of file diff --git a/tests/messages/issue-410.eml b/tests/messages/issue-410.eml new file mode 100644 index 00000000..e5fe0c62 --- /dev/null +++ b/tests/messages/issue-410.eml @@ -0,0 +1,16 @@ +From: from@there.com +To: to@here.com +Subject: =?ISO-2022-JP?B?GyRCIXlCaBsoQjEzMhskQjlmISEhViUsITwlRyVzGyhCJhskQiUoJS8lOSVGJWolIiFXQGxMZ0U5JE4kPyRhJE4jURsoQiYbJEIjQSU1JW0lcyEhIVo3bjQpJSglLyU5JUYlaiUiISYlbyE8JS8hWxsoQg==?= +Date: Wed, 13 Sep 2017 13:05:45 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------B832AF745285AEEC6D5AEE42" + +Hi +--------------B832AF745285AEEC6D5AEE42 +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="=?ISO-2022-JP?B?GyRCIXlCaBsoQjEzMhskQjlmISEhViUsITwlRyVzGyhCJhskQiUoJS8lOSVGJWolIiFXQGxMZ0U5JE4kPyRhJE4jURsoQiYbJEIjQSU1JW0lcyEhIVo3bjQpJSglLyU5JUYlaiUiISYlbyE8JS8hWxsoQg==?=" + +SGkh +--------------B832AF745285AEEC6D5AEE42-- \ No newline at end of file diff --git a/tests/messages/issue-410b.eml b/tests/messages/issue-410b.eml new file mode 100644 index 00000000..b260a1e0 --- /dev/null +++ b/tests/messages/issue-410b.eml @@ -0,0 +1,22 @@ +From: from@there.com +To: to@here.com +Subject: =?iso-8859-1?Q?386_-_400021804_-_19.,_Heiligenst=E4dter_Stra=DFe_80_-_081?= + =?iso-8859-1?Q?9306_-_Anfrage_Vergabevorschlag?= +Date: Wed, 13 Sep 2017 13:05:45 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------B832AF745285AEEC6D5AEE42" + +Hi +--------------B832AF745285AEEC6D5AEE42 +Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; + name="=?iso-8859-1?Q?2021=5FM=E4ngelliste=5F0819306.xlsx?=" +Content-Description: =?iso-8859-1?Q?2021=5FM=E4ngelliste=5F0819306.xlsx?= +Content-Disposition: attachment; + filename="=?iso-8859-1?Q?2021=5FM=E4ngelliste=5F0819306.xlsx?="; size=11641; + creation-date="Mon, 10 Jan 2022 09:01:00 GMT"; + modification-date="Mon, 10 Jan 2022 09:01:00 GMT" +Content-Transfer-Encoding: base64 + +SGkh +--------------B832AF745285AEEC6D5AEE42-- \ No newline at end of file diff --git a/tests/messages/issue-410symbols.eml b/tests/messages/issue-410symbols.eml new file mode 100644 index 00000000..b83a2f26 --- /dev/null +++ b/tests/messages/issue-410symbols.eml @@ -0,0 +1,22 @@ +From: from@there.com +To: to@here.com +Subject: =?iso-8859-1?Q?386_-_400021804_-_19.,_Heiligenst=E4dter_Stra=DFe_80_-_081?= + =?iso-8859-1?Q?9306_-_Anfrage_Vergabevorschlag?= +Date: Wed, 13 Sep 2017 13:05:45 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------B832AF745285AEEC6D5AEE42" + +Hi +--------------B832AF745285AEEC6D5AEE42 +Content-Type: application/pdf; name="Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf" +Content-Description: Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf +Content-Disposition: attachment; + filename="Checkliste 10.,DAVIDGASSE 76-80;2;2.pdf"; size=3439313; + creation-date="Tue, 12 Sep 2023 06:53:03 GMT"; + modification-date="Tue, 12 Sep 2023 08:18:16 GMT" +Content-ID: <34A0EDD24A954140A472605B7526F190@there.com> +Content-Transfer-Encoding: base64 + +SGkh +--------------B832AF745285AEEC6D5AEE42-- \ No newline at end of file diff --git a/tests/messages/issue-412.eml b/tests/messages/issue-412.eml new file mode 100644 index 00000000..f28b81a7 --- /dev/null +++ b/tests/messages/issue-412.eml @@ -0,0 +1,216 @@ +Return-Path: +Delivered-To: gmx@tonymarston.co.uk +Received: from ion.dnsprotect.com + by ion.dnsprotect.com with LMTP + id YFDRGeZ6d2SlCRUAzEkvSQ + (envelope-from ) + for ; Wed, 31 May 2023 12:50:46 -0400 +Return-path: +Envelope-to: gmx@tonymarston.co.uk +Delivery-date: Wed, 31 May 2023 12:50:46 -0400 +Received: from mail-vi1eur04olkn2050.outbound.protection.outlook.com ([40.92.75.50]:23637 helo=EUR04-VI1-obe.outbound.protection.outlook.com) + by ion.dnsprotect.com with esmtps (TLS1.2) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + (Exim 4.96) + (envelope-from ) + id 1q4P28-005pTm-0E + for gmx@tonymarston.co.uk; + Wed, 31 May 2023 12:50:46 -0400 +ARC-Seal: i=1; a=rsa-sha256; s=arcselector9901; d=microsoft.com; cv=none; + b=SYBG6BEOiWsmVfcgXUQJ0moS1SuG/IdTK6DIT4H3g7CQ+hbWIbItTxOhFzJiHP+q0uz+XzR1FzX2Daso+4iKotX7x2ViHIA0Hs65xUZVFtvflMsUrB+5RLlf3Pr7DiNKguQQtC+R2IBLvedc+IqElnMrTHcxLVS2gWl89MZx5Q0bXGWW/9wBVq6yvc6C69ynppcEdD0QZsoUQlp2DgzDpg8iG3y6dYGxFiTvLzw08nTQiCuqi8qQ+nmHyeeItIiAmyKynvyiL+kh4frcSDS67r6PU/CBxvto/nP3RCAxHuzJEGOpS7LJPqoJAlRSrUp2zpeEMpmDFJUE/Jo0K+EgcQ== +ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com; + s=arcselector9901; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1; + bh=ISg5C/1AgVASEPkGp/cgowfc/y9qywGvXMTv5yjJffs=; + b=eVYGErUWMxOeFGLg2nPuB0E/ngKet0hEVcN8Ay4ujGFY4k7i+1hrmnOMD6XiaIk3gVrqPalsmDjmEHpS0zV3+fPPTSktlSvkLrUr5ViVI1kMVBbBQsowLD5x3FpX7fnP2q17WPQ2P6R8Ibudkdnei8uq7gZhc3CSDLv4PfNma45H0FmdaB40mF2dCYzj5hEzr6jmMliANHJjznuDEFEUH3CfS1/iIA9rzhBKPKtahipTNeYiLqvZpKo1fO/XkZ57T44fqHkocfCyEK3Y1rehWudmkU8a9eEZlU5nBC6xoGO3P5Q1XIUNEHFmx2HH7eO8IgGzq/vbLMhcvbc3Ysdb2A== +ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none; + dkim=none; arc=none +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=hotmail.com; + s=selector1; + h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck; + bh=ISg5C/1AgVASEPkGp/cgowfc/y9qywGvXMTv5yjJffs=; + b=Qv71Bx+LsM9Lo0uen0vxQzWI3ju4Q095ZMkKPXDaKsd5Y8dA3QteMpAPhy3/mAdP1+EV8NviDBhamtTG4qMO+zEqu/pyRpBGZOtjyiJGKh7aFh2bbodOkJkiGqH3jPwYBnE7T1XAqDVFmvRpuqIkqBe9FXeCZKRrF/Na5Y+zuklH7ebuGQVzIK+xol6q7BDgb/oLul7Wa3r3Lw40cPW5leUgwxngRFMucUVVO5aJ4MWlk76CmcN8XqgwVcFaACY80HLWRqRZfM8n24/KzV9nKSZIQFCgJi2CiqnEWVRSZZtZ9SudJJv4S3C/gU4OYoiFKr7GkEQibyqE2QkGHCBA1g== +Received: from PAXP193MB2364.EURP193.PROD.OUTLOOK.COM (2603:10a6:102:22b::9) + by DB8P193MB0773.EURP193.PROD.OUTLOOK.COM (2603:10a6:10:15a::10) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6455.22; Wed, 31 May + 2023 16:50:03 +0000 +Received: from PAXP193MB2364.EURP193.PROD.OUTLOOK.COM + ([fe80::b962:59b:2d33:85c2]) by PAXP193MB2364.EURP193.PROD.OUTLOOK.COM + ([fe80::b962:59b:2d33:85c2%5]) with mapi id 15.20.6455.020; Wed, 31 May 2023 + 16:50:03 +0000 +From: Tony Marston +To: "gmx@tonymarston.co.uk" +Subject: RE: TEST MESSAGE +Thread-Topic: TEST MESSAGE +Thread-Index: AQHZjysmbQn8p2cYOEawNV6ywnFmN690oIyAgAAA5rw= +Date: Wed, 31 May 2023 16:50:03 +0000 +Message-ID: + +References: + + <944a7d16fcb607628f358cdd0f5ba8c0@tonymarston.co.uk> +In-Reply-To: <944a7d16fcb607628f358cdd0f5ba8c0@tonymarston.co.uk> +Accept-Language: en-GB, en-US +Content-Language: en-GB +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-ms-exchange-messagesentrepresentingtype: 1 +x-tmn: [ABOzTq1ytyWYuvBD5A4I78Eqna1hBLeM] +x-ms-publictraffictype: Email +x-ms-traffictypediagnostic: PAXP193MB2364:EE_|DB8P193MB0773:EE_ +x-ms-office365-filtering-correlation-id: 7fbd6771-9a0d-495d-c4d3-08db61f714a6 +x-microsoft-antispam: BCL:0; +x-microsoft-antispam-message-info: + fwWKs8Qs/JyQ+pSnCNC804PQ86JYn/R63U7P6WCeXLC/fVSGqcAnslOc2vbxC2cUeTPPfow1WAz3f73/7Uk+byKxiE6L48WkgL6BFKVkYCwPKcQ3ps6KyEdGL6uUvPqYKwf5gFgUiTt+fKDiR/GJljj6qmN51jd5lUBvLf1g59q3LryUC+lEy6iLQ8cjCivzmBcR+0C2+uaa/xsjJxokbIEQoMicjjUiVWFkxRFBDr6FO8kEQyzzs70pNivK6mcXpVeJSOiLQfYa/2Q5QDYhE8kznG1EYUzHBQD/sLp/maUgmpKj1b8ObeI13QXed1qih+CtdLYAmPs5GPaoz8aY7pmaxroKLjBAqfOAC2IeQ3grxdQ8eRXlrnZ29cQnvD9tDryUvE9nyQLinaM2Dft4MueHvBTL5+WOTNnVQB98zVjnop8iVkwNrBKzPYiox9ufs9XFXNl0+2fFn66647ET7y/DkBKcszTKYF5RRp5o59QAh8rUTsaKeGJkPkyowZd+i6R12NIavL+eOOgnKvziTTbNl1lGgP+3zTKbgbb6K+DxfbKNl1zaTNvonOHwzI3jfdkDRtg5BvZVKstyl9AfZqo6OZ7ii3JKgVquRVWAtEQ6J6tx1Va/nTVrn1478+Dj +x-ms-exchange-antispam-messagedata-chunkcount: 1 +x-ms-exchange-antispam-messagedata-0: + =?us-ascii?Q?EMaSeqWhMBslXR7KptI34/opub+lBzTReVt3ACHDu/w7NvVUPpdp4YhwogpL?= + =?us-ascii?Q?8V9o/Zj+8vv6zZ7YL0n2NpODi8fzrPScgRiZGtJmfS58OqfgW2yygp1BmawZ?= + =?us-ascii?Q?y99Omv39THENDmCAba4d8zYLikOc+HJUfhpWeb/L9yqut7T2QkPFJYoeN5vU?= + =?us-ascii?Q?LeJ1hoPlguhNnDwMtE3HTgAl0Hbesms0Rb7wGaTDsgAc7XOO+8XpqLamnU0m?= + =?us-ascii?Q?zPpkQXV8+V5dFU2HxgIcYPYSaTX1CLPCdNcCAr1uHPnN81Ntm9Jb7fpYs1oA?= + =?us-ascii?Q?tgzPMwt37Ks9eQucJWZ5LQnNmZl/kYtVnvkylqYYMWiAg3y2XtjVkW/Ut/V2?= + =?us-ascii?Q?6vMfyCB5fEGJrn/uCp+KwL1s7s/4B332Bn4zvwpYd5TioMSGf9Rdp157eAfg?= + =?us-ascii?Q?LiNlLjDFdRc5SSEkUEl1TeX0FOQLsaCsQQqC/hzb10boa49GuxpoKwRmwhAO?= + =?us-ascii?Q?LZ2+veS5Jr1qWngBGo4MTkEq7nD6vBRIXQmKiLMpJc+Gk3/PCADL+H0IRH0+?= + =?us-ascii?Q?4uHnvcVr6CrDDZ2BEwcaWOa/ct8yUI5G9SC5gFP53TaS2llCnYwHAX1PkMAo?= + =?us-ascii?Q?w2LHFHE5I5dtQQJHaWNGMJGYdPmb9dDrksggLWXN+IxsxvFcFNSK2GPaqOMJ?= + =?us-ascii?Q?H4ht1rpqHTlU0uzgjb19gKCCcBdZfIzv118RTjFYG+EX/rsHlNRei/OWdTBF?= + =?us-ascii?Q?xf5cdnap836rQmre5ZoubNsSw2qKSRJhmZH1pCHoCFLtreM1fk7kkVJfkUz5?= + =?us-ascii?Q?ApMa03dEfFvzVv5wvPdWBLiCqzI6z6z8fUmwg2XfvK9Nyxb1AOZoT7JUXnp2?= + =?us-ascii?Q?Me6dTKqGKsBx87Dtny+fANHqgOm+Eo/pBZqyXwN93udbltxPmtNJ84MJJjyx?= + =?us-ascii?Q?vbEiDnMVb4knBO+sBqlKAVUv1F9ZJA2oUTrOD7t1xx6nBmQTgoYN6zsi+dgw?= + =?us-ascii?Q?nPebsl1/6fUy73FWLUKkeA64PeSa00Zi/q53ylXmUZV4Pc+11blKdL8o+p32?= + =?us-ascii?Q?dTD0ndul0WvvpQf8RdYNtGJ/BMurqNfvHq9wJo7Iu4fgTElR50ngwEsr28Bc?= + =?us-ascii?Q?G81pAb2fNhID6ewyOGfj87kqybxUhv1E+4pquh770UagjD1J3rKAUiw1sxWp?= + =?us-ascii?Q?u+FSmd7HKgd2cKJsmMErnQelF3DNozw5d0qdNELXZNO03xlMiADVvhqEJuqc?= + =?us-ascii?Q?uArdsSo2hyApUaiB+dM4Fp+oeiGienEQl64NJ7QFxRb/h96J0iTL3Vp8+8Y?= + =?us-ascii?Q?=3D?= +Content-Type: multipart/alternative; + boundary="_000_PAXP193MB2364F1160B9E7C559D7D897ABB489PAXP193MB2364EURP_" +MIME-Version: 1.0 +X-OriginatorOrg: sct-15-20-4755-11-msonline-outlook-80ceb.templateTenant +X-MS-Exchange-CrossTenant-AuthAs: Internal +X-MS-Exchange-CrossTenant-AuthSource: PAXP193MB2364.EURP193.PROD.OUTLOOK.COM +X-MS-Exchange-CrossTenant-RMS-PersistedConsumerOrg: 00000000-0000-0000-0000-000000000000 +X-MS-Exchange-CrossTenant-Network-Message-Id: 7fbd6771-9a0d-495d-c4d3-08db61f714a6 +X-MS-Exchange-CrossTenant-originalarrivaltime: 31 May 2023 16:50:03.3982 + (UTC) +X-MS-Exchange-CrossTenant-fromentityheader: Hosted +X-MS-Exchange-CrossTenant-id: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa +X-MS-Exchange-CrossTenant-rms-persistedconsumerorg: 00000000-0000-0000-0000-000000000000 +X-MS-Exchange-Transport-CrossTenantHeadersStamped: DB8P193MB0773 +X-Spam-Status: No, score=-1.6 +X-Spam-Score: -15 +X-Spam-Bar: - +X-Ham-Report: Spam detection software, running on the system "ion.dnsprotect.com", + has NOT identified this incoming email as spam. The original + message has been attached to this so you can view it or label + similar future email. If you have any questions, see + root\@localhost for details. + Content preview: Here is my reply to your reply. Tony Marston From: gmx@tonymarston.co.uk + Sent: 31 May 2023 17:46 To: Tony Marston + Subject: Re: TEST MESSAGE + Content analysis details: (-1.6 points, 8.0 required) + pts rule name description + ---- ---------------------- -------------------------------------------------- + -1.9 BAYES_00 BODY: Bayes spam probability is 0 to 1% + [score: 0.0005] + 0.0 FREEMAIL_FROM Sender email is commonly abused enduser mail + provider + [tonymarston[at]hotmail.com] + -0.0 SPF_PASS SPF: sender matches SPF record + 0.5 SUBJ_ALL_CAPS Subject is all capitals + -0.0 SPF_HELO_PASS SPF: HELO matches SPF record + 0.0 HTML_MESSAGE BODY: HTML included in message + -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from + author's domain + 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily + valid + -0.1 DKIM_VALID_EF Message has a valid DKIM or DK signature from + envelope-from domain + -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature + -0.0 T_SCC_BODY_TEXT_LINE No description available. +X-Spam-Flag: NO +X-From-Rewrite: unmodified, no actual sender determined from check mail permissions + +--_000_PAXP193MB2364F1160B9E7C559D7D897ABB489PAXP193MB2364EURP_ +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Here is my reply to your reply. + +Tony Marston + +From: gmx@tonymarston.co.uk +Sent: 31 May 2023 17:46 +To: Tony Marston +Subject: Re: TEST MESSAGE + +On 2023-05-25 13:06, Tony Marston wrote: +Here is my reply to your message +> Tony Marston + + +--_000_PAXP193MB2364F1160B9E7C559D7D897ABB489PAXP193MB2364EURP_ +Content-Type: text/html; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

Here is my reply to your reply.

+

 

+

Tony Marston

+

 

+
+

From: gmx@tonymarston.co.uk
+Sent: 31 May 2023 17:46
+To: Tony Marston
+Subject: Re: TEST MESSAGE

+
+

 

+

On 2023-05-25 13:06, Tony Marston wrote:
+Here is my reply to your message
+> Tony Marston

+

 

+
+ + + +--_000_PAXP193MB2364F1160B9E7C559D7D897ABB489PAXP193MB2364EURP_-- \ No newline at end of file diff --git a/tests/messages/issue-413.eml b/tests/messages/issue-413.eml new file mode 100644 index 00000000..9f0cbfa2 --- /dev/null +++ b/tests/messages/issue-413.eml @@ -0,0 +1,32 @@ +Return-Path: +Delivered-To: gmx@tonymarston.co.uk +Received: from ion.dnsprotect.com + by ion.dnsprotect.com with LMTP + id oPy8IzIke2Rr4gIAzEkvSQ + (envelope-from ) + for ; Sat, 03 Jun 2023 07:29:54 -0400 +Return-path: +Envelope-to: gmx@tonymarston.net +Delivery-date: Sat, 03 Jun 2023 07:29:54 -0400 +Received: from [::1] (port=48740 helo=ion.dnsprotect.com) + by ion.dnsprotect.com with esmtpa (Exim 4.96) + (envelope-from ) + id 1q5PSF-000nPQ-1F + for gmx@tonymarston.net; + Sat, 03 Jun 2023 07:29:54 -0400 +MIME-Version: 1.0 +Date: Sat, 03 Jun 2023 07:29:54 -0400 +From: radicore +To: gmx@tonymarston.net +Subject: Test Message +User-Agent: Roundcube Webmail/1.6.0 +Message-ID: +X-Sender: radicore@radicore.org +Content-Type: text/plain; charset=US-ASCII; + format=flowed +Content-Transfer-Encoding: 7bit +X-From-Rewrite: unmodified, already matched + +This is just a test, so ignore it (if you can!) + +Tony Marston diff --git a/tests/messages/issue-414.eml b/tests/messages/issue-414.eml new file mode 100644 index 00000000..302e4492 --- /dev/null +++ b/tests/messages/issue-414.eml @@ -0,0 +1,27 @@ +From: from@there.com +To: to@here.com +Subject: Test +Date: Fri, 29 Sep 2017 10:55:23 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------5B1F217006A67C28E756A62E" + +This is a multi-part message in MIME format. + +--------------5B1F217006A67C28E756A62E +Content-Type: text/plain; charset=UTF-8; + name="../../example/MyFile.txt" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="../../example/MyFile.txt" + +TXlGaWxlQ29udGVudA== +--------------5B1F217006A67C28E756A62E +Content-Type: text/plain; charset=UTF-8; + name="php://foo" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="php://foo" + +TXlGaWxlQ29udGVudA== +--------------5B1F217006A67C28E756A62E-- diff --git a/tests/messages/issue-462.eml b/tests/messages/issue-462.eml new file mode 100644 index 00000000..d13b3cf1 --- /dev/null +++ b/tests/messages/issue-462.eml @@ -0,0 +1,5 @@ +From: "postmaster@" +To: receipent@receipent_domain.tld +Subject: Undeliverable: Some subject + +Test message diff --git a/tests/messages/issue-511.eml b/tests/messages/issue-511.eml new file mode 100644 index 00000000..7cd61faa --- /dev/null +++ b/tests/messages/issue-511.eml @@ -0,0 +1,5 @@ +From: COMPANYNAME | =?iso-8859-2?q?us=B3ugi?= +To: receipent@receipent_domain.tld +Subject: =?utf-8?B?UkU6IFtFWFRFUk5BTF0gUmU6IExvcmVtIElwc3VtIC8=?= =?utf-8?Q?40_one?= + +Test message diff --git a/tests/messages/issue-544.eml b/tests/messages/issue-544.eml new file mode 100644 index 00000000..96e2c48c --- /dev/null +++ b/tests/messages/issue-544.eml @@ -0,0 +1,220 @@ +Sender: "test@mail.com" +From: "test@mail.com" +Subject: Test bad boundary +To: "test_1@mail.com" +Cc: +Bcc: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="-" + +This is a multi-part message in MIME format. + +--- +Content-Type: text/plain + +This message may contain an attachment in a PDF format. + +Special Comments +- +See attached document + +--- +Content-Type: application/pdf; name="file.pdf" +Content-Transfer-Encoding: base64 +Content-Disposition: inline; filename="file.pdf" +Content-MD5: MLGn6wT7mIo/SUBWQ/mmng== + +JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PC9UaXRsZSAoZmlsZS0xLnBkZikKL1Byb2R1Y2VyIChT +a2lhL1BERiBtMTE5IEdvb2dsZSBEb2NzIFJlbmRlcmVyKT4+CmVuZG9iagozIDAgb2JqCjw8L2Nh +IDEKL0JNIC9Ob3JtYWw+PgplbmRvYmoKNSAwIG9iago8PC9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9M +ZW5ndGggMTkzPj4gc3RyZWFtCnicdZBNDgIhDIX3PUUvILZDoZAYFxrHtYYb+JeYuHC8fyIwo2NM +oASa99FHgZFyLDgvGjs8PeAJRl1VP3sWGUsc9zgmww2We4u3FxQe2COT8zhc4AqHPwftysweVKXs +MSazxybBshdkMb4MxXQFnvsyojGw2oipmCzYmk7Vig2YzrgikrjGdAcxpBzVaa6ZwLaCfN55lmhn +0LdAqCAYdsyk4QuYpjtCIC/uB0irghtdOduoEG2B8YG7lD/3DS1VWPgKZW5kc3RyZWFtCmVuZG9i +agoyIDAgb2JqCjw8L1R5cGUgL1BhZ2UKL1Jlc291cmNlcyA8PC9Qcm9jU2V0IFsvUERGIC9UZXh0 +IC9JbWFnZUIgL0ltYWdlQyAvSW1hZ2VJXQovRXh0R1N0YXRlIDw8L0czIDMgMCBSPj4KL0ZvbnQg +PDwvRjQgNCAwIFI+Pj4+Ci9NZWRpYUJveCBbMCAwIDYxMiA3OTJdCi9Db250ZW50cyA1IDAgUgov +U3RydWN0UGFyZW50cyAwCi9QYXJlbnQgNiAwIFI+PgplbmRvYmoKNiAwIG9iago8PC9UeXBlIC9Q +YWdlcwovQ291bnQgMQovS2lkcyBbMiAwIFJdPj4KZW5kb2JqCjcgMCBvYmoKPDwvVHlwZSAvQ2F0 +YWxvZwovUGFnZXMgNiAwIFI+PgplbmRvYmoKOCAwIG9iago8PC9MZW5ndGgxIDE3ODUyCi9GaWx0 +ZXIgL0ZsYXRlRGVjb2RlCi9MZW5ndGggODc4OD4+IHN0cmVhbQp4nO16C3wURdbvqerueSWTzOT9 +mGQ6DAmSAQJ5kBAimZAHaowECJggSAJEAogEAoiPlbgughGF9VNUdAXf+GQSIgbUJauuu6IIq6gr +PoiIiroI+ikqSPr7V80EQtR75d7vPvz9tpvzr1NVp05Vn3PqdE0HYkQUCVBp6JiS0jI2n80g4nFo +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 ++hPQT/cAW4F7qY3WAzfTBmA73We8Q0/R/cY/aYvEp+kBYAc9CNxKDwG3SXyGNgKfpUeMt+k5ehT4 +Z4nb6TFgJz0O/As9AXyengS+QJuMt+hF8gP/Sq3Gm/SSxL9RG/DvtNnYQy9TO3AHPQV8hbYAX6Wn +gTvx/ttDr9FW4C6Ju2kb8B/0LPB1es54g94Avk576M/AN2k78C3qNP5Bb0v8Jz0PfIdeAO6lF4Hv +SnyP/gp8n14CfkB/M3bTPold9LKxiz6kHcD99ArwI4kH6FXgx7QT+Am9BvyUdhuv0UGJn9E/gJ/T +68ZO+oLeAP5L4iHaA/yS3jJepcP0NvCIxK/on8Cv6R3gf9Je4DcSv6X3jFfoKL0P/I4+AH4P3EE/ +0D7gMeoCHqcPgT9KPEEfGS9TNx0AGvQx8N85/f98Tv/qN57Tv/jVOf2zX8jpn/0kpx/8hZz+6U9y ++ie/IqcfOJnTF56W0z/6hZz+kczpH/0kp++XOX1/r5y+X+b0/TKn7++V0z/8SU7vkjm9S+b0rt9g +Tn/n/1FO3/PvnP7vnP6by+m/9XP6bzen/9I5/d85/d85/edz+t9/+zn9vwCXmHoeCmVuZHN0cmVh +bQplbmRvYmoKOSAwIG9iago8PC9UeXBlIC9Gb250RGVzY3JpcHRvcgovRm9udE5hbWUgL0FBQUFB +QStBcmlhbE1UCi9GbGFncyA0Ci9Bc2NlbnQgOTA1LjI3MzQ0Ci9EZXNjZW50IC0yMTEuOTE0MDYK +L1N0ZW1WIDQ1Ljg5ODQzOAovQ2FwSGVpZ2h0IDcxNS44MjAzMQovSXRhbGljQW5nbGUgMAovRm9u +dEJCb3ggWy02NjQuNTUwNzggLTMyNC43MDcwMyAyMDAwIDEwMDUuODU5MzhdCi9Gb250RmlsZTIg +OCAwIFI+PgplbmRvYmoKMTAgMCBvYmoKPDwvVHlwZSAvRm9udAovRm9udERlc2NyaXB0b3IgOSAw +IFIKL0Jhc2VGb250IC9BQUFBQUErQXJpYWxNVAovU3VidHlwZSAvQ0lERm9udFR5cGUyCi9DSURU +b0dJRE1hcCAvSWRlbnRpdHkKL0NJRFN5c3RlbUluZm8gPDwvUmVnaXN0cnkgKEFkb2JlKQovT3Jk +ZXJpbmcgKElkZW50aXR5KQovU3VwcGxlbWVudCAwPj4KL1cgWzE2IFszMzMuMDA3ODEgMjc3Ljgz +MjAzXSAyMCA3MiA1NTYuMTUyMzQgNzMgWzI3Ny44MzIwM10gNzYgNzkgMjIyLjE2Nzk3IDgzIFs1 +NTYuMTUyMzRdXQovRFcgNzUwPj4KZW5kb2JqCjExIDAgb2JqCjw8L0ZpbHRlciAvRmxhdGVEZWNv +ZGUKL0xlbmd0aCAyNzE+PiBzdHJlYW0KeJxdkdtqxCAQhu99irncXizRbNplIQSWLAu56IGmfQCj +k1RojBhzkbevhzSFCiofM//vzJjVza3RykH2ZifRooNeaWlxnhYrEDoclCYsB6mE2yieYuSGZF7c +rrPDsdH9RMoSIHv30dnZFQ5XOXX4QLJXK9EqPcDhs249t4sx3ziidkBJVYHE3js9c/PCR4Qsyo6N +9HHl1qPX/GV8rAYhj8xSNWKSOBsu0HI9ICmpXxWUd78qglr+ixdJ1fXii9uQzQqfTemJVYGKOtLT +JdE9UR3p8RTpTKPv5pD/+u3PMxrTGItXfktO50jFJRkWm0UShSrDNPcRiMVa330ceWw7NKw07r9i +JhNUYf8AtiGI+QplbmRzdHJlYW0KZW5kb2JqCjQgMCBvYmoKPDwvVHlwZSAvRm9udAovU3VidHlw +ZSAvVHlwZTAKL0Jhc2VGb250IC9BQUFBQUErQXJpYWxNVAovRW5jb2RpbmcgL0lkZW50aXR5LUgK +L0Rlc2NlbmRhbnRGb250cyBbMTAgMCBSXQovVG9Vbmljb2RlIDExIDAgUj4+CmVuZG9iagp4cmVm +CjAgMTIKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDE1IDAwMDAwIG4gCjAwMDAwMDA0MDEg +MDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDEwNDU5IDAwMDAwIG4gCjAwMDAwMDAx +MzggMDAwMDAgbiAKMDAwMDAwMDYwOSAwMDAwMCBuIAowMDAwMDAwNjY0IDAwMDAwIG4gCjAwMDAw +MDA3MTEgMDAwMDAgbiAKMDAwMDAwOTU4NSAwMDAwMCBuIAowMDAwMDA5ODE5IDAwMDAwIG4gCjAw +MDAwMTAxMTcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDEyCi9Sb290IDcgMCBSCi9JbmZvIDEg +MCBSPj4Kc3RhcnR4cmVmCjEwNTk4CiUlRU9GCg== + +----- diff --git a/tests/messages/ks_c_5601-1987_headers.eml b/tests/messages/ks_c_5601-1987_headers.eml new file mode 100644 index 00000000..a8de3d08 --- /dev/null +++ b/tests/messages/ks_c_5601-1987_headers.eml @@ -0,0 +1,10 @@ +Subject: =?ks_c_5601-1987?B?UkU6IMi4v/i01LKyIEVyc2m01MDMILjevcPB9rimILq4s8K9wLTP?= + =?ks_c_5601-1987?B?tNku?= +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +Thread-Topic: =?ks_c_5601-1987?B?yLi/+LTUsrIgRXJzabTUwMwguN69w8H2uKYgurizwr3AtM+02S4=?= +From: =?ks_c_5601-1987?B?seggx/bB+A==?= +To: to@here.com + +Content diff --git a/tests/messages/mail_that_is_attachment.eml b/tests/messages/mail_that_is_attachment.eml new file mode 100644 index 00000000..89215cf8 --- /dev/null +++ b/tests/messages/mail_that_is_attachment.eml @@ -0,0 +1,28 @@ +Sender: xxx@yyy.cz +MIME-Version: 1.0 +Message-ID: <2244696771454641389@google.com> +Date: Sun, 15 Feb 2015 10:21:51 +0000 +Subject: Report domain: yyy.cz Submitter: google.com Report-ID: 2244696771454641389 +From: noreply-dmarc-support via xxx +To: xxx@yyy.cz +Content-Type: application/zip; + name="google.com!yyy.cz!1423872000!1423958399.zip" +Content-Disposition: attachment; + filename="google.com!yyy.cz!1423872000!1423958399.zip" +Content-Transfer-Encoding: base64 +Reply-To: noreply-dmarc-support@google.com + +UEsDBAoAAAAIABRPT0bdJB+DSwIAALgKAAAuAAAAZ29vZ2xlLmNvbSFzdW5mb3guY3ohMTQyMzg3 +MjAwMCExNDIzOTU4Mzk5LnhtbO1WwY6bMBC971dEuQcDIQSQQ3rqF7RnZIwh7oJt2WY32a+viQ2h +u9moqnarqOop8Gbmjd/4OQbuj127eCJSUc52y8DzlwvCMK8oa3bL79++rpLlYp8/wJqQqkT4MX9Y +LKAkgktddESjCmk0YAblsikY6kjecN60xMO8g2ACbQ7pEG1zxg1De1pVHZJ4pXox0H2Zl9k8V3PU +EhWYM42wLiireX7QWmQAuErvUgkQKCkDiKlnIj1x2tunXRjF8SbxDfFbMtvFaaJVHoZRFKfxdhtE +myiOgnWSQnAJ23SjmxQSscYpM1BJGsryIArXyTb0fdPMImOcsOocTTfJOjWUw7slA7+yTd3mA4aC +txSfCtGXLVUHMi2Em1GxXPVGytHDL4bMIjaMqkfa5RIC++BAJeozNvxaSOSS/CBYQyAcoi6QGjGB +dR4MyoaH80qvrcrMEnM5LlDy52kEivcSk4KKPIz9bVYnpZ9Fvr/OsB9kWbgOTa8pZSzCvGemLQT2 +YYRdZ/KE2t6MrxoDw0yoElxRbTxtvMaImckMmeUNIxFIKZMwTceJr11gGtFM7aueZr9GjZBWhGla +U3OiprIDQRWRRS15N9+nOex43lRD1OtDIYnqW30hfLXY2xZw7h4YnCT3Mqma08GZ3g+gvhgMvFYy +JI82+R3HpL4XbDdesIm84SB/tE9Gr99wSm3+k646xQbu0Sl/uptW0Sfu5tXzH6b3dP7vd1f/+vl/ +KU83eRnpzbX6uY5JzMeJZ25PLwji920S/r8m/tVrAoLLR+hPUEsBAgoACgAAAAgAFE9PRt0kH4NL +AgAAuAoAAC4AAAAAAAAAAAAAAAAAAAAAAGdvb2dsZS5jb20hc3VuZm94LmN6ITE0MjM4NzIwMDAh +MTQyMzk1ODM5OS54bWxQSwUGAAAAAAEAAQBcAAAAlwIAAAAA \ No newline at end of file diff --git a/tests/messages/missing_date.eml b/tests/messages/missing_date.eml new file mode 100644 index 00000000..5f8a6032 --- /dev/null +++ b/tests/messages/missing_date.eml @@ -0,0 +1,7 @@ +From: from@here.com +To: to@here.com +Subject: Nuu +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/missing_from.eml b/tests/messages/missing_from.eml new file mode 100644 index 00000000..e09dd9d9 --- /dev/null +++ b/tests/messages/missing_from.eml @@ -0,0 +1,7 @@ +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/mixed_filename.eml b/tests/messages/mixed_filename.eml new file mode 100644 index 00000000..4a353da3 --- /dev/null +++ b/tests/messages/mixed_filename.eml @@ -0,0 +1,25 @@ +Date: Fri, 02 Feb 2018 22:23:06 +0300 +To: foo@bar.com +Subject: =?windows-1251?B?0eLl5ujpIO/w4OnxLevo8fI=?= +From: =?windows-1251?B?z/Dg6fH7IHx8IM/g8PLK7uw=?= +Content-Transfer-Encoding: quoted-printable +Content-Type: multipart/mixed; + boundary="=_743251f7a933f6b30c004fcb14eabb57" +Content-Disposition: inline + +This is a message in Mime Format. If you see this, your mail reader does not support this format. + +--=_743251f7a933f6b30c004fcb14eabb57 +Content-Type: text/html; charset=windows-1251 +Content-Transfer-Encoding: quoted-printable +Content-Disposition: inline + + +--=_743251f7a933f6b30c004fcb14eabb57 +Content-Type: application/octet-stream +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Price4VladDaKar.xlsx" + +UEsDBBQAAgAIAHOyQkwNlxQhWwEAAAYFAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK2UTU7D +CwALANECAAAT1E4AAAA= +--=_743251f7a933f6b30c004fcb14eabb57-- diff --git a/tests/messages/multipart_without_body.eml b/tests/messages/multipart_without_body.eml new file mode 100644 index 00000000..8f86799f --- /dev/null +++ b/tests/messages/multipart_without_body.eml @@ -0,0 +1,110 @@ +Received: from AS8PR02MB6805.eurprd02.prod.outlook.com (2603:10a6:20b:252::8) + by PA4PR02MB7071.eurprd02.prod.outlook.com with HTTPS; Sat, 11 Mar 2023 + 08:24:33 +0000 +Received: from omef0ahNgeoJu.eurprd02.prod.outlook.com (2603:10a6:10:33c::12) + by AS8PR02MB6805.eurprd02.prod.outlook.com (2603:10a6:20b:252::8) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.6178.19; Sat, 11 Mar + 2023 08:24:31 +0000 +Received: from omef0ahNgeoJu.eurprd02.prod.outlook.com + ([fe80::38c0:9c40:7fc6:93a7]) by omef0ahNgeoJu.eurprd02.prod.outlook.com + ([fe80::38c0:9c40:7fc6:93a7%7]) with mapi id 15.20.6178.019; Sat, 11 Mar 2023 + 08:24:31 +0000 +From: =?iso-8859-1?Q?Foo_B=FClow_Bar?= +To: some one +Subject: This mail will not contain a body +Thread-Topic: This mail will not contain a body +Thread-Index: AdlT8uVmpHPvImbCRM6E9LODIvAcQA== +Date: Sat, 11 Mar 2023 08:24:31 +0000 +Message-ID: + +Accept-Language: da-DK, en-US +Content-Language: en-US +X-MS-Exchange-Organization-AuthAs: Internal +X-MS-Exchange-Organization-AuthMechanism: 04 +X-MS-Exchange-Organization-AuthSource: omef0ahNgeoJu.eurprd02.prod.outlook.com +X-MS-Has-Attach: +X-MS-Exchange-Organization-Network-Message-Id: + aa546a02-2b7a-4fb1-7fd4-08db220a09f1 +X-MS-Exchange-Organization-SCL: -1 +X-MS-TNEF-Correlator: +X-MS-Exchange-Organization-RecordReviewCfmType: 0 +x-ms-publictraffictype: Email +X-Microsoft-Antispam-Mailbox-Delivery: + ucf:0;jmr:0;auth:0;dest:I;ENG:(910001)(944506478)(944626604)(920097)(425001)(930097); +X-Microsoft-Antispam-Message-Info: + PeifKDwBVlQGeDu/c7oB3MKTffBwvlRIg5GJo1AiA4LroGAjwgwlg+oPfLetX9CgtbtKZy4gZnbjLCn3jJnod5ehn3Sv9gQaoH9PWkH/PIj6izaJzwlcEk81/MdprZFrORMwR7TGNqP/7ELAHw8rOH2Dmz9vGCE4cv0EwyYS3ShUhXABj4eJ17GNu1B4o53T2m9CzTgm647FRR5jpvk5xNIjQOwrhonVXMkCf2XKwF21sd/k4XLS/jX08tFJXBNyBALhsB4cG9gx2rxZYDn11SBejGFDw4eaqIQw6fYsmsyZvEoCnBkO+vK9lGIrQhRzGj7nJ41+uHVBcUYThce7P/ORpxl3GgThHyQpXQDV00JbP1aCBzm+4w8TyFiL0aOHXDhU9UKRHg3A01F+oH8IKAIaYazLCoOzbVcijSw0icNjBQsNLWa0FvfRT8y/rIvGkOB3rZpKf7ZR0g7cSlDAB3Ml2AaTbIB4ZL6QMukP/waDIObMZFmlVaAvmJzdTEdhGSLtUBFk4CNJhd6szcwaaPZxROumOGtz0sDke2iap8wRZqpdMWHVYi/trA+IESlF2G8TPzZiXs1lRDvYjNlam5r+1Ay1zlSmKTMnGbfNvsJgHkTlcgswKTlip4jGFPh6INTSDZtx9dQuDi4vbyNQiN1qVxoOPScTXzxUKVZ2PJ+8ipL2dqudLb3R2GSDHL10uQTuoIftA/Wjf67QZ629qQ== +Content-Type: multipart/alternative; + boundary="_000_omef0ahNgeoJuEB51C568ED2227A2DAABB5BB9omef0ahNgeoJueurp_" +MIME-Version: 1.0 + +--_000_omef0ahNgeoJuEB51C568ED2227A2DAABB5BB9omef0ahNgeoJueurp_ +Content-Type: text/plain; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +This mail will not contain a body + + + +--_000_omef0ahNgeoJuEB51C568ED2227A2DAABB5BB9omef0ahNgeoJueurp_ +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

This mail will not contain a bo= +dy

+

 

+
+ + + +--_000_omef0ahNgeoJuEB51C568ED2227A2DAABB5BB9omef0ahNgeoJueurp_-- diff --git a/tests/messages/multiple_html_parts_and_attachments.eml b/tests/messages/multiple_html_parts_and_attachments.eml new file mode 100644 index 00000000..5ccbd2c3 --- /dev/null +++ b/tests/messages/multiple_html_parts_and_attachments.eml @@ -0,0 +1,203 @@ +Return-Path: +Delivered-To: to@there.com +Return-path: +Envelope-to: to@there.com +Delivery-date: Thu, 16 Feb 2023 09:19:23 +0000 +From: FromName +Content-Type: multipart/alternative; + boundary="Apple-Mail=_5382A451-FE2F-4504-9E3F-8FEB0B716E43" +Mime-Version: 1.0 (Mac OS X Mail 16.0 \(3731.400.51.1.1\)) +Subject: multiple_html_parts_and_attachments +Date: Thu, 16 Feb 2023 10:19:02 +0100 +To: to@there.com + + + +--Apple-Mail=_5382A451-FE2F-4504-9E3F-8FEB0B716E43 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +This is the first html part + +=EF=BF=BC + +This is the second html part + +=EF=BF=BC + +This is the last html part +https://www.there.com + + +--Apple-Mail=_5382A451-FE2F-4504-9E3F-8FEB0B716E43 +Content-Type: multipart/mixed; + boundary="Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE" + + +--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +This is the first html part

+--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE +Content-Disposition: inline; + filename=attachment1.pdf +Content-Type: application/pdf; + x-unix-mode=0666; + name="attachment1.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjMKJcTl8uXrp/Og0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xl +bmd0aCA5MyA+PgpzdHJlYW0KeAEdjDsOgCAQBXtP8U7AzwWW3sZOKmtDKExEhej9JWbamamIqFAd +G6ww3jowacEcGC1jxQm55Jby/bzbgbZ3W2v6CzPCkBJu9AxSQRBRGFKBnIvGdPVz/ABGZhatCmVu +ZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNv +dXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDU5NS4yNzU2IDg0MS44 +ODk4XQo+PgplbmRvYmoKNCAwIG9iago8PCAvUHJvY1NldCBbIC9QREYgL0ltYWdlQiAvSW1hZ2VD +IC9JbWFnZUkgXSAvWE9iamVjdCA8PCAvSW0xIDUgMCBSID4+ID4+CmVuZG9iago1IDAgb2JqCjw8 +IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTE0IC9IZWlnaHQgMjMgL0lu +dGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNiAwIFIgL0ludGVudCAvUGVyY2VwdHVhbCAvQml0 +c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTUzNCAvRmlsdGVyCi9GbGF0ZURlY29kZSA+PgpzdHJl +YW0KeAHtmAlTllUUxz9CZTVN+75rmkJmZqMpVgoCoogikArhgmyhKYoKCqK4gQQoCgIBKiiEGCIg +u4CoKU1Mi/uu7fUB7CdnunPnPmLvy4Bj08s888w592z3/J9zz7kvN286/voWgb9u9Hc8PUagu2/T +Y4cOQxBwoNoXZXBvonrj0uCfLg3ui3zvjk9bUD16/MP7xh3j2Vo0z9jVhTNvd3476rerA2XdYA1l +29nHvZoGzyq3Xb+PNG1M58p556CETV9W+6pt2ILqgpR4QXVsRL4yFCIwIRnRkWMf3ZY1lG1n7xFU +jey6239i9iJASMharBT+FVXq8Hmf2icmNT47pe7+8Uc7O0cqWwgjrsHqmnbR/wlUvz7psmJLzISF +O4DFXlSLK/0xmZe0Tip25bYlCp+AuNQXptUgHRW6i3WDFbWiioDgxA39Aw4MDS6dv27tnko/ZQ6x +r2bqB5H5T01u4MNNWrJN1bygWn7IZ+qyDEKMmFesV8KUmK3sp6Zpol9s2qt+B31XpFc1Tjrz4/CZ +8SkSKDot9o/rA1QgehSiIbP20VWCEze2HR2nRLgir6Y2N++lma9Mrxoxtzguc8mfXba3TUcZQmSV +zAZPgdReVAmHSd1hD0JDDJq5X3n2XJz1tHc9i06B+xanxhksahm7Q5DyAPvI+bsfnNAGXbh/pnjI +3DOXLfVzO/J+6K7nfGoR8T79w7tIQfUB13b0H3ZvHfBxhThZnxclhkgf6hI96nlYNsDK6/6VqKGM +QwjAEeWGFvcnJzewwtcZPmdvP9f2xyY2872UK2wf8WhByg4Jh+aa7M+QWtMRE+t7c2EYVvp3v3MH +OHPqHTapkITAvL7FXXk2jrzBukQUAE7rP7UBjJhTNphfPe9MS8E5JQdLn6HhI92Q9yksmYrmxbND +qZyc0k9gOWsSV6ToXz731vWLQ1hHyhdpaR+PQmX9ZFiOADS2lB9sckGEjNSSqukA6xRUprui2q9d +cGKFs4Oy28IckRrpyKL1bS+q63KjiLJqW7S4Wr5lGSwHWXk24hpsfvksVZmYkBHmHDpoRNAccOWq +45vRiJLzI1gBN8oY0ET6+7U3qKKXfKuFFSmTV9jVWbeGBadeWJBEgZYC29g6AdHAGV+JSN5jwgpZ +PH5iLCyanAi+nYjoG9StCmSkozvRaXtRdQ4qYwPTlmdEJcfzSDfg0P16ZZC4NeIaLDrNR1zpgaPD +drJVaUGCKucFz3qX1vdJshxJfQVzJqasIH1Rk6YUhOMqbVeo0n/Gu45Nwu4oDUYEwtSzevDMYkWd +Nwq4op0qQ4jX/A6qQNZ0dE1F24WqNFI2YH2YQeLTiKuz1AwAYksTYNzMWLU5NnMprKAauWk1tPUC +LG5J1rivGqjqUkFVdwWqTEBc0U+IAoz0IuM51OyJgjUQqNKHb5udLFrfdqEakpTElpJyFjBe1SM9 +QZDBvw6jwdIwMWeHp7oGEFKGLytiu/GLSOg5a9erTX7/3XvhGxNpoaxYk+0ZqsUHbl1gmDsqCgSB +aPW/dB03a6A+rdWfL78JIPQcfl/oWzrL/HJtZwTLuqCq5pHO5pYFkZHeORelrlSoMvKgGdnqd1n4 +hjWsgHYvosrtmt3SKhlqkgW/hflAdGlpy7agqrLTcdBp22s1ryyQHLlD6uZCj4/KRZS6Mwx2afoK +aK/o7Uwig2Uc0EjJiIvK9r2zgVf6KvO39rAHyh6LsrFl4G4pCvGP/Zz7AMdWCtuabM9qlSjysbg1 +cQqyS4K5XBGUGYGIxxpIr1UjOzGxvm1HVVJmUludpO+ez8ZcwgsR8R2ZsLByrg2WwmOsIOWhuZVW ++TL4oOXqQrXI+BMFrm1c5iUc84JLux76Zd9qaZUsGlJJKrN4rtLnlkVrFZb/0oSuXysheHPFjUlf +rqat4QoTfkeoQEY6yr9BUGB45nerWr/zfVWp3ZngoKkjhqbOcikCqxMdLsoDNNdgxZ4/Pay60etk +xxg01WKvE0TkLrq/dsq508Psda6nY6Ntr6BqY6z/j5oD1b741g5U7yaq3aHtWLcXgb8Bi7W2lwpl +bmRzdHJlYW0KZW5kb2JqCjcgMCBvYmoKPDwgL04gMyAvQWx0ZXJuYXRlIC9EZXZpY2VSR0IgL0xl +bmd0aCAzNDMgL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4Kc3RyZWFtCngBdZA7SwNBFIXPxsiCpkgh +Vim2sRBWiZsgdpKoiJBiiYqPbrKJiZDHsFkRG7HzD4iVYCdprWKhIP4CQVCxshC1FwI+wnomUTaF +3uHOfHPm3N3LBUIRIWU5DKBS9dzsfNpYXVs39BeEEIWOJEzh1GXKtjO04PdUHETrFpq63Yypb8VO +P7Kv+eP9+7Q5rYvGYuD7kwbyhbrDly+m5UjXA7Q42d72pOI98pDLpsgHiotdbijOdfm841nKztBz +TY46JZEnP5LNXI9e7OFKeUv9V4XqPlKoLqteh5kxzGIOGS4DNixOYQJT1PBPTbJTM4MaJHbgYhNF +lOCxOkVFoowCeQFVOBiHSbYQZybUrFnLCGYYaLUIMPnMx6tAE0/A2e7PSAxVCYzQEz0CLt6lcEVX +4661wvWNhNW5a4NNoP/Q999WAH0UaN/5/mfT99snQN8DcNn6BsE0ZMMKZW5kc3RyZWFtCmVuZG9i +ago2IDAgb2JqClsgL0lDQ0Jhc2VkIDcgMCBSIF0KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1Bh +Z2VzIC9NZWRpYUJveCBbMCAwIDU5NS4yNzU2IDg0MS44ODk4XSAvQ291bnQgMSAvS2lkcyBbIDEg +MCBSIF0KPj4KZW5kb2JqCjggMCBvYmoKPDwgL1R5cGUgL0NhdGFsb2cgL1BhZ2VzIDIgMCBSID4+ +CmVuZG9iago5IDAgb2JqCjw8IC9UaXRsZSAo/v9cMDAwU1wwMDBjXDAwMGhcMDAwZVwwMDByXDAw +MG1cMDAwrVwwMDBhXDAwMGZcMDAwYlwwMDBlXDAwMGVcMDAwbFwwMDBkXDAwMGlcMDAwblwwMDBn +XDAwMCBcMDAwMlwwMDAwXDAwMDJcMDAwM1wwMDAtXDAwMDBcMDAwMlwwMDAtXDAwMDFcMDAwNlww +MDAgXDAwMG9cMDAwbVwwMDAgXDAwMDFcMDAwMFwwMDAuXDAwMDFcMDAwMVwwMDAuXDAwMDBcMDAw +M1wwMDAuXDAwMHBcMDAwblwwMDBnKQovUHJvZHVjZXIgKG1hY09TIFZlcnNpZSAxMy4yIFwoYnVp +bGQgMjJENDlcKSBRdWFydHogUERGQ29udGV4dCkgL0NyZWF0b3IgKFZvb3J2ZXJ0b25pbmcpCi9D +cmVhdGlvbkRhdGUgKEQ6MjAyMzAyMTYwOTExNDdaMDAnMDAnKSAvTW9kRGF0ZSAoRDoyMDIzMDIx +NjA5MTE0N1owMCcwMCcpCj4+CmVuZG9iagp4cmVmCjAgMTAKMDAwMDAwMDAwMCA2NTUzNSBmIAow +MDAwMDAwMTg2IDAwMDAwIG4gCjAwMDAwMDI2MDIgMDAwMDAgbiAKMDAwMDAwMDAyMiAwMDAwMCBu +IAowMDAwMDAwMzAwIDAwMDAwIG4gCjAwMDAwMDAzODkgMDAwMDAgbiAKMDAwMDAwMjU2NyAwMDAw +MCBuIAowMDAwMDAyMTI1IDAwMDAwIG4gCjAwMDAwMDI2OTUgMDAwMDAgbiAKMDAwMDAwMjc0NCAw +MDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDEwIC9Sb290IDggMCBSIC9JbmZvIDkgMCBSIC9JRCBb +IDxlNzk0OWE2YzQ2MWM1MDYxNTk0ODU5YjkxZjczYzAzMz4KPGU3OTQ5YTZjNDYxYzUwNjE1OTQ4 +NTliOTFmNzNjMDMzPiBdID4+CnN0YXJ0eHJlZgozMTYxCiUlRU9GCg== +--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +

This is the second html part

+--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE +Content-Disposition: inline; + filename=attachment2.pdf +Content-Type: application/pdf; + x-unix-mode=0666; + name="attachment2.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjMKJcTl8uXrp/Og0MTGCjMgMCBvYmoKPDwgL0ZpbHRlciAvRmxhdGVEZWNvZGUgL0xl +bmd0aCA5MyA+PgpzdHJlYW0KeAEdjDsOgCAQBXtP8U4ALB9Zehs7qawNoTARFaL3l5hpZ6YiokJ1 +XHBCezeCLQnmwGgZK07IJbeU7+fdDrS926TpL4yCNl6Q8QyrnAjWhiEVyLkQpquf4wdGBBarCmVu +ZHN0cmVhbQplbmRvYmoKMSAwIG9iago8PCAvVHlwZSAvUGFnZSAvUGFyZW50IDIgMCBSIC9SZXNv +dXJjZXMgNCAwIFIgL0NvbnRlbnRzIDMgMCBSIC9NZWRpYUJveCBbMCAwIDU5NS4yNzU2IDg0MS44 +ODk4XQo+PgplbmRvYmoKNCAwIG9iago8PCAvUHJvY1NldCBbIC9QREYgL0ltYWdlQiAvSW1hZ2VD +IC9JbWFnZUkgXSAvWE9iamVjdCA8PCAvSW0xIDUgMCBSID4+ID4+CmVuZG9iago1IDAgb2JqCjw8 +IC9UeXBlIC9YT2JqZWN0IC9TdWJ0eXBlIC9JbWFnZSAvV2lkdGggMTIxIC9IZWlnaHQgMzAgL0lu +dGVycG9sYXRlIHRydWUKL0NvbG9yU3BhY2UgNiAwIFIgL0ludGVudCAvUGVyY2VwdHVhbCAvQml0 +c1BlckNvbXBvbmVudCA4IC9MZW5ndGggMTczNSAvRmlsdGVyCi9GbGF0ZURlY29kZSA+PgpzdHJl +YW0KeAHtl4lT1kUYx/+Eymq677s0TTHzajSPPBFE8UA0T1RuxRMVUfEAEVECFQVREQ9UxiNFVFAU +FJAxGrDM+9bu+gPsI8+0s+27v3qFmnLm987OO8+9u9999nn2d++e+3MRcBFwEXARcBFwEXBE4Le7 +Td3RYAQcYbUpGjyL6wgCNkQdZS5ijUHAEVabojETNd737vWW311v2fg4/1UEG6KOMn2RVdU9Hul1 +mrFme6guh7568aO62s4/3WoucoM1jL1nnws43nL0Xu/t/yXLv93O+XMd0rZEjluUMjRu1ayMuZXV +PWQljrDaFPrip6xIEKi7R+fqcugxC1NRVZzuKXKDNYy9Z/8nUP/1dsjAt4IOsf0n+51kQDzue2rd +zvFs04aoo0zBQsa+Nrj4+QGlrwwqebR3VV1dJ6WCMBZjsLrlA9EPBdQ9J28C3qUbYoDo1lWflXmR +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 +99snQN8DcNn6BsE0ZMMKZW5kc3RyZWFtCmVuZG9iago2IDAgb2JqClsgL0lDQ0Jhc2VkIDcgMCBS +IF0KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9NZWRpYUJveCBbMCAwIDU5NS4yNzU2 +IDg0MS44ODk4XSAvQ291bnQgMSAvS2lkcyBbIDEgMCBSIF0KPj4KZW5kb2JqCjggMCBvYmoKPDwg +L1R5cGUgL0NhdGFsb2cgL1BhZ2VzIDIgMCBSID4+CmVuZG9iago5IDAgb2JqCjw8IC9UaXRsZSAo +/v9cMDAwU1wwMDBjXDAwMGhcMDAwZVwwMDByXDAwMG1cMDAwrVwwMDBhXDAwMGZcMDAwYlwwMDBl +XDAwMGVcMDAwbFwwMDBkXDAwMGlcMDAwblwwMDBnXDAwMCBcMDAwMlwwMDAwXDAwMDJcMDAwM1ww +MDAtXDAwMDBcMDAwMlwwMDAtXDAwMDFcMDAwNlwwMDAgXDAwMG9cMDAwbVwwMDAgXDAwMDFcMDAw +MFwwMDAuXDAwMDFcMDAwMVwwMDAuXDAwMDFcMDAwMFwwMDAuXDAwMHBcMDAwblwwMDBnKQovUHJv +ZHVjZXIgKG1hY09TIFZlcnNpZSAxMy4yIFwoYnVpbGQgMjJENDlcKSBRdWFydHogUERGQ29udGV4 +dCkgL0NyZWF0b3IgKFZvb3J2ZXJ0b25pbmcpCi9DcmVhdGlvbkRhdGUgKEQ6MjAyMzAyMTYwOTEx +MzhaMDAnMDAnKSAvTW9kRGF0ZSAoRDoyMDIzMDIxNjA5MTEzOFowMCcwMCcpCj4+CmVuZG9iagp4 +cmVmCjAgMTAKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMTg2IDAwMDAwIG4gCjAwMDAwMDI4 +MDMgMDAwMDAgbiAKMDAwMDAwMDAyMiAwMDAwMCBuIAowMDAwMDAwMzAwIDAwMDAwIG4gCjAwMDAw +MDAzODkgMDAwMDAgbiAKMDAwMDAwMjc2OCAwMDAwMCBuIAowMDAwMDAyMzI2IDAwMDAwIG4gCjAw +MDAwMDI4OTYgMDAwMDAgbiAKMDAwMDAwMjk0NSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDEw +IC9Sb290IDggMCBSIC9JbmZvIDkgMCBSIC9JRCBbIDxjOWI4YzBlZGQ5Mjk1Y2U3ZmQ2YzFkOWJj +ZTJhZTRiOD4KPGM5YjhjMGVkZDkyOTVjZTdmZDZjMWQ5YmNlMmFlNGI4PiBdID4+CnN0YXJ0eHJl +ZgozMzYyCiUlRU9GCg== +--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE +Content-Transfer-Encoding: 7bit +Content-Type: text/html; + charset=us-ascii + +

This is the last html part
https://www.there.com



+
+--Apple-Mail=_7C42F551-D023-4101-858E-D004D27871BE-- + +--Apple-Mail=_5382A451-FE2F-4504-9E3F-8FEB0B716E43-- diff --git a/tests/messages/multiple_nested_attachments.eml b/tests/messages/multiple_nested_attachments.eml new file mode 100644 index 00000000..ad48d47b --- /dev/null +++ b/tests/messages/multiple_nested_attachments.eml @@ -0,0 +1,84 @@ +Date: Mon, 15 Jan 2018 10:54:09 +0100 +User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:52.0) Gecko/20100101 + Thunderbird/52.5.0 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------85793CE1578A77318D5A90A6" +Content-Language: en-US + +This is a multi-part message in MIME format. +--------------85793CE1578A77318D5A90A6 +Content-Type: multipart/alternative; + boundary="------------32D598A7FC3F8A8E228998B0" + + +--------------32D598A7FC3F8A8E228998B0 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 7bit + + +------------------------------------------------------------------------ + + + + +--------------32D598A7FC3F8A8E228998B0 +Content-Type: multipart/related; + boundary="------------261DC39B47CFCDB73BCE3C18" + + +--------------261DC39B47CFCDB73BCE3C18 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 8bit + + + + + + + +


+

+
+ + + Â +
+ + + + + + + +

+

+
+
+ + + +--------------261DC39B47CFCDB73BCE3C18 +Content-Type: image/png; + name="mleokdgdlgkkecep.png" +Content-Transfer-Encoding: base64 +Content-ID: +Content-Disposition: inline; + filename="mleokdgdlgkkecep.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAACklE +QVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg== +--------------261DC39B47CFCDB73BCE3C18-- + +--------------32D598A7FC3F8A8E228998B0-- + +--------------85793CE1578A77318D5A90A6 +Content-Type: image/png; + name="FF4D00-1.png" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="FF4D00-1.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAACklE +QVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg== +--------------85793CE1578A77318D5A90A6-- \ No newline at end of file diff --git a/tests/messages/nestes_embedded_with_attachment.eml b/tests/messages/nestes_embedded_with_attachment.eml new file mode 100644 index 00000000..44498103 --- /dev/null +++ b/tests/messages/nestes_embedded_with_attachment.eml @@ -0,0 +1,145 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_NextPart_000_000_000" + +This is a multi-part message in MIME format. + +------=_NextPart_000_000_000 +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_111_000" + + +------=_NextPart_000_111_000 +Content-Type: text/plain; + charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +Dear Sarah + +------=_NextPart_000_111_000 +Content-Type: text/html; + charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + +
Dear Sarah,
+ + +------=_NextPart_000_111_000-- + +------=_NextPart_000_000_000 +Content-Type: message/rfc822; + name="first.eml" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="first.eml" + +From: from@there.com +To: to@here.com +Subject: FIRST +Date: Sat, 28 Apr 2018 14:37:16 -0400 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_NextPart_000_222_000" + +This is a multi-part message in MIME format. + +------=_NextPart_000_222_000 +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_222_111" + + +------=_NextPart_000_222_111 +Content-Type: text/plain; + charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Please respond directly to this email to update your RMA + + +2018-04-17T11:04:03-04:00 +------=_NextPart_000_222_111 +Content-Type: text/html; + charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + + +
Please respond directly to this = +email to=20 +update your RMA
+ +------=_NextPart_000_222_111-- + +------=_NextPart_000_222_000 +Content-Type: image/png; + name="chrome.png" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="chrome.png" + +iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAB+FBMVEUAAAA/mUPidDHiLi5Cn0Xk +NTPmeUrkdUg/m0Q0pEfcpSbwaVdKskg+lUP4zA/iLi3msSHkOjVAmETdJSjtYFE/lkPnRj3sWUs8 +kkLeqCVIq0fxvhXqUkbVmSjwa1n1yBLepyX1xxP0xRXqUkboST9KukpHpUbuvRrzrhF/ljbwalju +ZFM4jELaoSdLtElJrUj1xxP6zwzfqSU4i0HYnydMtUlIqUfywxb60AxZqEXaoifgMCXptR9MtklH +pEY2iUHWnSjvvRr70QujkC+pUC/90glMuEnlOjVMt0j70QriLS1LtEnnRj3qUUXfIidOjsxAhcZF +o0bjNDH0xxNLr0dIrUdmntVTkMoyfL8jcLBRuErhJyrgKyb4zA/5zg3tYFBBmUTmQTnhMinruBzv +vhnxwxZ/st+Ktt5zp9hqota2vtK6y9FemNBblc9HiMiTtMbFtsM6gcPV2r6dwroseLrMrbQrdLGd +yKoobKbo3Zh+ynrgVllZulTsXE3rV0pIqUf42UVUo0JyjEHoS0HmsiHRGR/lmRz/1hjqnxjvpRWf +wtOhusaz0LRGf7FEfbDVmqHXlJeW0pbXq5bec3fX0nTnzmuJuWvhoFFhm0FtrziBsjaAaDCYWC+u +Si6jQS3FsSfLJiTirCOkuCG1KiG+wSC+GBvgyhTszQ64Z77KAAAARXRSTlMAIQRDLyUgCwsE6ebm +5ubg2dLR0byXl4FDQzU1NDEuLSUgC+vr6urq6ubb29vb2tra2tG8vLu7u7uXl5eXgYGBgYGBLiUA +LabIAAABsElEQVQoz12S9VPjQBxHt8VaOA6HE+AOzv1wd7pJk5I2adpCC7RUcHd3d3fXf5PvLkxh +eD++z+yb7GSRlwD/+Hj/APQCZWxM5M+goF+RMbHK594v+tPoiN1uHxkt+xzt9+R9wnRTZZQpXQ0T +5uP1IQxToyOAZiQu5HEpjeA4SWIoksRxNiGC1tRZJ4LNxgHgnU5nJZBDvuDdl8lzQRBsQ+s9PZt7 +s7Pz8wsL39/DkIfZ4xlB2Gqsq62ta9oxVlVrNZpihFRpGO9fzQw1ms0NDWZz07iGkJmIFH8xxkc3 +a/WWlubmFkv9AB2SEpDvKxbjidN2faseaNV3zoHXvv7wMODJdkOHAegweAfFPx4G67KluxzottCU +9n8CUqXzcIQdXOytAHqXxomvykhEKN9EFutG22p//0rbNvHVxiJywa8yS2KDfV1dfbu31H8jF1RH +iTKtWYeHxUvq3bn0pyjCRaiRU6aDO+gb3aEfEeVNsDgm8zzLy9egPa7Qt8TSJdwhjplk06HH43ZN +J3s91KKCHQ5x4sw1fRGYDZ0n1L4FKb9/BP5JLYxToheoFCVxz57PPS8UhhEpLBVeAAAAAElFTkSu +QmCC + +------=_NextPart_000_222_000-- + +------=_NextPart_000_000_000 +Content-Type: message/rfc822; + name="second.eml" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="second.eml" + +From: from@there.com +To: to@here.com +Subject: SECOND +Date: Sat, 28 Apr 2018 13:37:30 -0400 +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_333_000" + +This is a multi-part message in MIME format. + +------=_NextPart_000_333_000 +Content-Type: text/plain; + charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +T whom it may concern: +------=_NextPart_000_333_000 +Content-Type: text/html; + charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + + +
T whom it may concern:
+ + +------=_NextPart_000_333_000-- + +------=_NextPart_000_000_000-- + diff --git a/tests/messages/null_content_charset.eml b/tests/messages/null_content_charset.eml new file mode 100644 index 00000000..0f19eb44 --- /dev/null +++ b/tests/messages/null_content_charset.eml @@ -0,0 +1,8 @@ +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +From: from@there.com +To: to@here.com + +Hi! \ No newline at end of file diff --git a/tests/messages/pec.eml b/tests/messages/pec.eml new file mode 100644 index 00000000..15dfacca --- /dev/null +++ b/tests/messages/pec.eml @@ -0,0 +1,65 @@ +To: test@example.com +From: test@example.com +Subject: Certified +Date: Mon, 2 Oct 2017 12:13:43 +0200 +Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha1"; boundary="----258A05BDE519DE69AAE8D59024C32F5E" + +This is an S/MIME signed message + +------258A05BDE519DE69AAE8D59024C32F5E +Content-Type: multipart/mixed; boundary="----------=_1506939223-24530-42" +Content-Transfer-Encoding: binary + +------------=_1506939223-24530-42 +Content-Type: multipart/alternative; + boundary="----------=_1506939223-24530-43" +Content-Transfer-Encoding: binary + +------------=_1506939223-24530-43 +Content-Type: text/plain; charset="iso-8859-1" +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable + +Signed + +------------=_1506939223-24530-43 +Content-Type: text/html; charset="iso-8859-1" +Content-Disposition: inline +Content-Transfer-Encoding: quoted-printable + +Signed + +------------=_1506939223-24530-43-- + +------------=_1506939223-24530-42 +Content-Type: application/xml; name="data.xml" +Content-Disposition: inline; filename="data.xml" +Content-Transfer-Encoding: base64 + +PHhtbC8+ + +------------=_1506939223-24530-42 +Content-Type: message/rfc822; name="postacert.eml" +Content-Disposition: inline; filename="postacert.eml" +Content-Transfer-Encoding: 7bit + +To: test@example.com +From: test@example.com +Subject: test-subject +Date: Mon, 2 Oct 2017 12:13:50 +0200 +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +test-content + +------------=_1506939223-24530-42-- + +------258A05BDE519DE69AAE8D59024C32F5E +Content-Type: application/x-pkcs7-signature; name="smime.p7s" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="smime.p7s" + +MQ== + +------258A05BDE519DE69AAE8D59024C32F5E-- + diff --git a/tests/messages/plain.eml b/tests/messages/plain.eml new file mode 100644 index 00000000..4d3d22b1 --- /dev/null +++ b/tests/messages/plain.eml @@ -0,0 +1,9 @@ +From: from@someone.com +To: to@someone-else.com +Subject: Example +Date: Mon, 21 Jan 2023 19:36:45 +0200 +Content-Type: text/plain; + charset=\"us-ascii\" +Content-Transfer-Encoding: quoted-printable + +Hi there! \ No newline at end of file diff --git a/tests/messages/plain_only.eml b/tests/messages/plain_only.eml new file mode 100644 index 00000000..bbf9f3b3 --- /dev/null +++ b/tests/messages/plain_only.eml @@ -0,0 +1,9 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi \ No newline at end of file diff --git a/tests/messages/plain_text_attachment.eml b/tests/messages/plain_text_attachment.eml new file mode 100644 index 00000000..6ec6a6c5 --- /dev/null +++ b/tests/messages/plain_text_attachment.eml @@ -0,0 +1,21 @@ +To: to@here.com +From: from@there.com +Subject: Plain text attachment +Date: Tue, 21 Aug 2018 09:05:14 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------B832AF745285AEEC6D5AEE42" + +This is a multi-part message in MIME format. +--------------B832AF745285AEEC6D5AEE42 +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +Test +--------------B832AF745285AEEC6D5AEE42 +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="a.txt" + +SGkh +--------------B832AF745285AEEC6D5AEE42-- diff --git a/tests/messages/references.eml b/tests/messages/references.eml new file mode 100644 index 00000000..478383cf --- /dev/null +++ b/tests/messages/references.eml @@ -0,0 +1,11 @@ +Message-ID: <123@example.com> +From: no_host +Cc: "This one: is \"right\"" , No-address +In-Reply-To: +References: <231d9ac57aec7d8c1a0eacfeab8af6f3@example.com> <08F04024-A5B3-4FDE-BF2C-6710DE97D8D9@example.com> +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi +How are you? diff --git a/tests/messages/simple_multipart.eml b/tests/messages/simple_multipart.eml new file mode 100644 index 00000000..60a6521c --- /dev/null +++ b/tests/messages/simple_multipart.eml @@ -0,0 +1,23 @@ +From: from@there.com +To: to@here.com +Date: Wed, 27 Sep 2017 12:48:51 +0200 +Subject: test +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_0081_01D32C91.033E7020" + +This is a multipart message in MIME format. + +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: 7bit + +MyPlain +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/html; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +MyHtml +------=_NextPart_000_0081_01D32C91.033E7020-- + diff --git a/tests/messages/structured_with_attachment.eml b/tests/messages/structured_with_attachment.eml new file mode 100644 index 00000000..f795ec3a --- /dev/null +++ b/tests/messages/structured_with_attachment.eml @@ -0,0 +1,24 @@ +From: from@there.com +To: to@here.com +Subject: Test +Date: Fri, 29 Sep 2017 10:55:23 +0200 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------5B1F217006A67C28E756A62E" + +This is a multi-part message in MIME format. +--------------5B1F217006A67C28E756A62E +Content-Type: text/plain; charset=iso-8859-15; format=flowed +Content-Transfer-Encoding: 7bit + +Test + +--------------5B1F217006A67C28E756A62E +Content-Type: text/plain; charset=UTF-8; + name="MyFile.txt" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="MyFile.txt" + +TXlGaWxlQ29udGVudA== +--------------5B1F217006A67C28E756A62E-- diff --git a/tests/messages/thread_my_topic.eml b/tests/messages/thread_my_topic.eml new file mode 100644 index 00000000..9a196351 --- /dev/null +++ b/tests/messages/thread_my_topic.eml @@ -0,0 +1,10 @@ +Message-ID: <123@example.com> +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/thread_re_my_topic.eml b/tests/messages/thread_re_my_topic.eml new file mode 100644 index 00000000..db4fdf41 --- /dev/null +++ b/tests/messages/thread_re_my_topic.eml @@ -0,0 +1,11 @@ +Message-ID: <456@example.com> +In-Reply-To: <123@example.com> +From: from@there.com +To: to@here.com +Subject: Re: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/thread_unrelated.eml b/tests/messages/thread_unrelated.eml new file mode 100644 index 00000000..e47faa81 --- /dev/null +++ b/tests/messages/thread_unrelated.eml @@ -0,0 +1,10 @@ +Message-ID: <999@example.com> +From: from@there.com +To: to@here.com +Subject: Wut +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; + charset="us-ascii" +Content-Transfer-Encoding: quoted-printable + +Hi diff --git a/tests/messages/undefined_charset_header.eml b/tests/messages/undefined_charset_header.eml new file mode 100644 index 00000000..117a33e4 --- /dev/null +++ b/tests/messages/undefined_charset_header.eml @@ -0,0 +1,20 @@ +X-Real-To: +X-Stored-In: BlaBla +Return-Path: +Received: from + by bla.bla (CommuniGate Pro RULE 6.1.13) + with RULE id 14057804; Mon, 27 Feb 2017 13:21:44 +0930 +X-Autogenerated: Mirror +Resent-From: +Resent-Date: Mon, 27 Feb 2017 13:21:44 +0930 +Message-Id: <201702270351.BGF77614@bla.bla> +From: =?X-IAS-German?B?bXlHb3Y=?= +To: sales@bla.bla +Subject: =?X-IAS-German?B?U3VibWl0IHlvdXIgdGF4IHJlZnVuZCB8IEF1c3RyYWxpYW4gVGF4YXRpb24gT2ZmaWNlLg==?= +Date: 27 Feb 2017 04:51:29 +0100 +MIME-Version: 1.0 +Content-Type: text/html; + charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +) \ No newline at end of file diff --git a/tests/messages/undisclosed_recipients.eml b/tests/messages/undisclosed_recipients.eml new file mode 100644 index 00000000..5d33ecff --- /dev/null +++ b/tests/messages/undisclosed_recipients.eml @@ -0,0 +1,8 @@ +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +From: from@there.com +To: "Undisclosed Recipients" <> + +Hi! diff --git a/tests/messages/undisclosed_recipients_minus.eml b/tests/messages/undisclosed_recipients_minus.eml new file mode 100644 index 00000000..73f7b83f --- /dev/null +++ b/tests/messages/undisclosed_recipients_minus.eml @@ -0,0 +1,8 @@ +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +From: from@there.com +To: undisclosed-recipients:; + +Hi! diff --git a/tests/messages/undisclosed_recipients_space.eml b/tests/messages/undisclosed_recipients_space.eml new file mode 100644 index 00000000..6c06cbd4 --- /dev/null +++ b/tests/messages/undisclosed_recipients_space.eml @@ -0,0 +1,8 @@ +Subject: test +MIME-Version: 1.0 +Content-Type: text/plain +Date: Wed, 27 Sep 2017 12:48:51 +0200 +From: from@there.com +To: Undisclosed recipients:; + +Hi! diff --git a/tests/messages/unknown_encoding.eml b/tests/messages/unknown_encoding.eml new file mode 100644 index 00000000..271bc4e2 --- /dev/null +++ b/tests/messages/unknown_encoding.eml @@ -0,0 +1,24 @@ +From: from@there.com +To: to@here.com +Date: Wed, 27 Sep 2017 12:48:51 +0200 +Subject: test +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_0081_01D32C91.033E7020" + +This is a multipart message in MIME format. + +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/plain; + charset= +Content-Transfer-Encoding: foobar + +MyPlain + +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/html; + charset="" +Content-Transfer-Encoding: quoted-printable + +MyHtml +------=_NextPart_000_0081_01D32C91.033E7020-- + diff --git a/tests/messages/without_charset_plain_only.eml b/tests/messages/without_charset_plain_only.eml new file mode 100644 index 00000000..bd4fe5de --- /dev/null +++ b/tests/messages/without_charset_plain_only.eml @@ -0,0 +1,8 @@ +From: from@there.com +To: to@here.com +Subject: Nuu +Date: Wed, 13 Sep 2017 13:05:45 +0200 +Content-Type: text/plain; charset= +Content-Transfer-Encoding: quoted-printable + +Hi \ No newline at end of file diff --git a/tests/messages/without_charset_simple_multipart.eml b/tests/messages/without_charset_simple_multipart.eml new file mode 100644 index 00000000..d1a092d2 --- /dev/null +++ b/tests/messages/without_charset_simple_multipart.eml @@ -0,0 +1,23 @@ +From: from@there.com +To: to@here.com +Date: Wed, 27 Sep 2017 12:48:51 +0200 +Subject: test +Content-Type: multipart/alternative; + boundary="----=_NextPart_000_0081_01D32C91.033E7020" + +This is a multipart message in MIME format. + +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/plain; + charset= +Content-Transfer-Encoding: 7bit + +MyPlain +------=_NextPart_000_0081_01D32C91.033E7020 +Content-Type: text/html; + charset="" +Content-Transfer-Encoding: quoted-printable + +MyHtml +------=_NextPart_000_0081_01D32C91.033E7020-- +