├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json └── src ├── Connection.php ├── ConnectionInterface.php ├── Exception ├── AbstractException.php ├── AuthenticationFailedException.php ├── CreateMailboxException.php ├── DeleteMailboxException.php ├── ImapFetchbodyException.php ├── ImapFetchheaderException.php ├── ImapGetmailboxesException.php ├── ImapMsgnoException.php ├── ImapNumMsgException.php ├── ImapQuotaException.php ├── ImapStatusException.php ├── InvalidDateHeaderException.php ├── InvalidHeadersException.php ├── InvalidSearchCriteriaException.php ├── MailboxDoesNotExistException.php ├── MessageCopyException.php ├── MessageDoesNotExistException.php ├── MessageMoveException.php ├── MessageStructureException.php ├── NotEmbeddedMessageException.php ├── OutOfBoundsException.php ├── RenameMailboxException.php ├── ReopenMailboxException.php ├── ResourceCheckFailureException.php ├── SubscribeMailboxException.php ├── UnexpectedEncodingException.php └── UnsupportedCharsetException.php ├── ImapResource.php ├── ImapResourceInterface.php ├── Mailbox.php ├── MailboxInterface.php ├── Message.php ├── Message ├── AbstractMessage.php ├── AbstractPart.php ├── Attachment.php ├── AttachmentInterface.php ├── BasicMessageInterface.php ├── EmailAddress.php ├── EmbeddedMessage.php ├── EmbeddedMessageInterface.php ├── Headers.php ├── Parameters.php ├── PartInterface.php ├── SimplePart.php └── Transcoder.php ├── MessageInterface.php ├── MessageIterator.php ├── MessageIteratorInterface.php ├── Search ├── AbstractDate.php ├── AbstractText.php ├── ConditionInterface.php ├── Date │ ├── Before.php │ ├── On.php │ └── Since.php ├── Email │ ├── Bcc.php │ ├── Cc.php │ ├── From.php │ └── To.php ├── Flag │ ├── Answered.php │ ├── Flagged.php │ ├── Recent.php │ ├── Seen.php │ ├── Unanswered.php │ ├── Unflagged.php │ └── Unseen.php ├── LogicalOperator │ ├── All.php │ └── OrConditions.php ├── RawExpression.php ├── State │ ├── Deleted.php │ ├── NewMessage.php │ ├── Old.php │ └── Undeleted.php └── Text │ ├── Body.php │ ├── Keyword.php │ ├── Subject.php │ ├── Text.php │ └── Unkeyword.php ├── SearchExpression.php ├── Server.php ├── ServerInterface.php └── Test └── RawMessageIterator.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 7 | ->setRules([ 8 | '@DoctrineAnnotation' => true, 9 | '@Symfony' => true, 10 | '@Symfony:risky' => true, 11 | '@PHPUnit75Migration:risky' => true, 12 | '@PHP71Migration' => true, 13 | '@PHP70Migration:risky' => true, // @TODO with next major version 14 | 'align_multiline_comment' => ['comment_type' => 'all_multiline'], 15 | 'array_indentation' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'binary_operator_spaces' => ['default' => 'align_single_space'], 18 | 'blank_line_before_statement' => true, 19 | 'class_definition' => ['single_item_single_line' => true], 20 | 'compact_nullable_type_declaration' => true, 21 | 'concat_space' => ['spacing' => 'one'], 22 | 'echo_tag_syntax' => ['format' => 'long'], 23 | 'error_suppression' => false, 24 | 'explicit_indirect_variable' => true, 25 | 'explicit_string_variable' => true, 26 | 'fully_qualified_strict_types' => true, 27 | 'heredoc_to_nowdoc' => true, 28 | 'list_syntax' => ['syntax' => 'long'], 29 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 30 | 'method_chaining_indentation' => true, 31 | 'multiline_comment_opening_closing' => true, 32 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 33 | 'native_constant_invocation' => true, 34 | 'native_function_invocation' => ['include' => ['@internal']], 35 | 'no_alternative_syntax' => true, 36 | 'no_break_comment' => true, 37 | 'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block']], 38 | 'no_null_property_initialization' => true, 39 | 'no_php4_constructor' => true, 40 | 'no_superfluous_elseif' => true, 41 | 'no_unneeded_braces' => true, 42 | 'no_unneeded_final_method' => true, 43 | 'no_unreachable_default_argument_value' => true, 44 | 'no_useless_else' => true, 45 | 'no_useless_return' => true, 46 | 'ordered_imports' => true, 47 | 'php_unit_method_casing' => true, 48 | 'php_unit_set_up_tear_down_visibility' => true, 49 | 'php_unit_strict' => true, 50 | 'php_unit_test_annotation' => true, 51 | 'php_unit_test_case_static_method_calls' => false, 52 | 'php_unit_test_class_requires_covers' => false, 53 | 'phpdoc_add_missing_param_annotation' => true, 54 | 'phpdoc_order' => true, 55 | 'phpdoc_order_by_value' => true, 56 | 'phpdoc_types_order' => true, 57 | 'random_api_migration' => true, 58 | 'semicolon_after_instruction' => true, 59 | 'simplified_null_return' => true, 60 | 'single_line_comment_style' => true, 61 | 'single_line_throw' => false, 62 | 'space_after_semicolon' => true, 63 | 'static_lambda' => true, 64 | 'strict_comparison' => true, 65 | 'string_implicit_backslashes' => true, 66 | 'string_line_ending' => true, 67 | ]) 68 | ->setFinder( 69 | PhpCsFixer\Finder::create() 70 | ->in(__DIR__ . '/src') 71 | ->in(__DIR__ . '/tests') 72 | ) 73 | ; 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 David de Boer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP IMAP library 2 | 3 | [![Latest Stable Version](https://img.shields.io/packagist/v/ddeboer/imap.svg)](https://packagist.org/packages/ddeboer/imap) 4 | [![Downloads](https://img.shields.io/packagist/dt/ddeboer/imap.svg)](https://packagist.org/packages/ddeboer/imap) 5 | [![Integrate](https://github.com/ddeboer/imap/workflows/CI/badge.svg)](https://github.com/ddeboer/imap/actions) 6 | [![Code Coverage](https://codecov.io/gh/ddeboer/imap/coverage.svg?branch=master)](https://codecov.io/gh/ddeboer/imap?branch=master) 7 | 8 | A PHP IMAP library to read and process e-mails over IMAP protocol, built with robust Object-Oriented architecture. 9 | 10 | This library requires PHP >= 8.3 with [IMAP](https://www.php.net/manual/en/book.imap.php), 11 | [iconv](https://www.php.net/manual/en/book.iconv.php) and 12 | [Multibyte String](https://www.php.net/manual/en/book.mbstring.php) extensions installed. 13 | 14 | ## Installation 15 | 16 | The recommended way to install the IMAP library is through [Composer](https://getcomposer.org): 17 | 18 | ```bash 19 | $ composer require ddeboer/imap 20 | ``` 21 | 22 | This command requires you to have Composer installed globally, as explained 23 | in the [installation chapter](https://getcomposer.org/doc/00-intro.md) 24 | of the Composer documentation. 25 | 26 | ## Usage 27 | 28 | ### Connect and Authenticate 29 | 30 | ```php 31 | use Ddeboer\Imap\Server; 32 | 33 | $server = new Server('imap.gmail.com'); 34 | 35 | // $connection is instance of \Ddeboer\Imap\Connection 36 | $connection = $server->authenticate('my_username', 'my_password'); 37 | ``` 38 | 39 | You can specify port, [flags and parameters](https://secure.php.net/manual/en/function.imap-open.php) 40 | to the server: 41 | 42 | ```php 43 | $server = new Server( 44 | $hostname, // required 45 | $port, // defaults to '993' 46 | $flags, // defaults to '/imap/ssl/validate-cert' 47 | $parameters 48 | ); 49 | ``` 50 | 51 | ### Mailboxes 52 | 53 | Retrieve mailboxes (also known as mail folders) from the mail server and iterate 54 | over them: 55 | 56 | ```php 57 | $mailboxes = $connection->getMailboxes(); 58 | 59 | foreach ($mailboxes as $mailbox) { 60 | // Skip container-only mailboxes 61 | // @see https://secure.php.net/manual/en/function.imap-getmailboxes.php 62 | if ($mailbox->getAttributes() & \LATT_NOSELECT) { 63 | continue; 64 | } 65 | 66 | // $mailbox is instance of \Ddeboer\Imap\Mailbox 67 | printf('Mailbox "%s" has %s messages', $mailbox->getName(), $mailbox->count()); 68 | } 69 | ``` 70 | 71 | Or retrieve a specific mailbox: 72 | 73 | ```php 74 | $mailbox = $connection->getMailbox('INBOX'); 75 | ``` 76 | 77 | Delete a mailbox: 78 | 79 | ```php 80 | $connection->deleteMailbox($mailbox); 81 | ``` 82 | 83 | You can bulk set, or clear, any [flag](https://secure.php.net/manual/en/function.imap-setflag-full.php) of mailbox messages (by UIDs): 84 | 85 | ```php 86 | $mailbox->setFlag('\\Seen \\Flagged', ['1:5', '7', '9']); 87 | $mailbox->setFlag('\\Seen', '1,3,5,6:8'); 88 | 89 | $mailbox->clearFlag('\\Flagged', '1,3'); 90 | ``` 91 | 92 | **WARNING** You must retrieve new Message instances in case of bulk modify flags to refresh the single Messages flags. 93 | 94 | ### Messages 95 | 96 | Retrieve messages (e-mails) from a mailbox and iterate over them: 97 | 98 | ```php 99 | $messages = $mailbox->getMessages(); 100 | 101 | foreach ($messages as $message) { 102 | // $message is instance of \Ddeboer\Imap\Message 103 | } 104 | ``` 105 | 106 | To insert a new message (that just has been sent) into the Sent mailbox and flag it as seen: 107 | 108 | ```php 109 | $mailbox = $connection->getMailbox('Sent'); 110 | $mailbox->addMessage($messageMIME, '\\Seen'); 111 | ``` 112 | 113 | Note that the message should be a string at MIME format (as described in the [RFC2045](https://tools.ietf.org/html/rfc2045)). 114 | 115 | #### Searching for Messages 116 | 117 | ```php 118 | use Ddeboer\Imap\SearchExpression; 119 | use Ddeboer\Imap\Search\Email\To; 120 | use Ddeboer\Imap\Search\Text\Body; 121 | 122 | $search = new SearchExpression(); 123 | $search->addCondition(new To('me@here.com')); 124 | $search->addCondition(new Body('contents')); 125 | 126 | $messages = $mailbox->getMessages($search); 127 | ``` 128 | 129 | **WARNING** We are currently unable to have both spaces _and_ double-quotes 130 | escaped together. Only spaces are currently escaped correctly. 131 | You can use `Ddeboer\Imap\Search\RawExpression` to write the complete search 132 | condition by yourself. 133 | 134 | Messages can also be retrieved sorted as per [imap_sort](https://secure.php.net/manual/en/function.imap-sort.php) 135 | function: 136 | 137 | ```php 138 | $today = new DateTimeImmutable(); 139 | $thirtyDaysAgo = $today->sub(new DateInterval('P30D')); 140 | 141 | $messages = $mailbox->getMessages( 142 | new Ddeboer\Imap\Search\Date\Since($thirtyDaysAgo), 143 | \SORTDATE, // Sort criteria 144 | true // Descending order 145 | ); 146 | ``` 147 | 148 | #### Unknown search criterion: OR 149 | 150 | Note that PHP imap library relies on the `c-client` library available at https://www.washington.edu/imap/ 151 | which doesn't fully support some IMAP4 search criteria like `OR`. If you want those unsupported criteria, 152 | you need to manually patch the latest version (`imap-2007f` of 23-Jul-2011 at the time of this commit) 153 | and recompile PHP onto your patched `c-client` library. 154 | 155 | By the way most of the common search criteria are available and functioning, browse them in `./src/Search`. 156 | 157 | References: 158 | 159 | 1. https://stackoverflow.com/questions/36356715/imap-search-unknown-search-criterion-or 160 | 2. imap-2007f.tar.gz: `./src/c-client/mail.c` and `./docs/internal.txt` 161 | 162 | #### Message Properties and Operations 163 | 164 | Get message number and unique [message id](https://en.wikipedia.org/wiki/Message-ID) 165 | in the form <...>: 166 | 167 | ```php 168 | $message->getNumber(); 169 | $message->getId(); 170 | ``` 171 | 172 | Get other message properties: 173 | 174 | ```php 175 | $message->getSubject(); 176 | $message->getFrom(); // Message\EmailAddress 177 | $message->getTo(); // array of Message\EmailAddress 178 | $message->getDate(); // DateTimeImmutable 179 | $message->isAnswered(); 180 | $message->isDeleted(); 181 | $message->isDraft(); 182 | $message->isSeen(); 183 | ``` 184 | 185 | Get message headers as a [\Ddeboer\Imap\Message\Headers](/src/Message/Headers.php) object: 186 | 187 | ```php 188 | $message->getHeaders(); 189 | ``` 190 | 191 | Get message body as HTML or plain text (only first part): 192 | 193 | ```php 194 | $message->getBodyHtml(); // Content of text/html part, if present 195 | $message->getBodyText(); // Content of text/plain part, if present 196 | ``` 197 | 198 | 199 | Get complete body (all parts): 200 | 201 | ```php 202 | $body = $message->getCompleteBodyHtml(); // Content of text/html part, if present 203 | if ($body === null) { // If body is null, there are no HTML parts, so let's try getting the text body 204 | $body = $message->getCompleteBodyText(); // Content of text/plain part, if present 205 | } 206 | ``` 207 | 208 | Reading the message body keeps the message as unseen. 209 | If you want to mark the message as seen: 210 | 211 | ```php 212 | $message->markAsSeen(); 213 | ``` 214 | 215 | Or you can set, or clear, any [flag](https://secure.php.net/manual/en/function.imap-setflag-full.php): 216 | 217 | ```php 218 | $message->setFlag('\\Seen \\Flagged'); 219 | $message->clearFlag('\\Flagged'); 220 | ``` 221 | 222 | Move a message to another mailbox: 223 | 224 | ```php 225 | $mailbox = $connection->getMailbox('another-mailbox'); 226 | $message->move($mailbox); 227 | ``` 228 | 229 | Deleting messages: 230 | 231 | ```php 232 | $mailbox->getMessage(1)->delete(); 233 | $mailbox->getMessage(2)->delete(); 234 | $connection->expunge(); 235 | ``` 236 | 237 | ### Message Attachments 238 | 239 | Get message attachments (both inline and attached) and iterate over them: 240 | 241 | ```php 242 | $attachments = $message->getAttachments(); 243 | 244 | foreach ($attachments as $attachment) { 245 | // $attachment is instance of \Ddeboer\Imap\Message\Attachment 246 | } 247 | ``` 248 | 249 | Download a message attachment to a local file: 250 | 251 | ```php 252 | // getDecodedContent() decodes the attachment’s contents automatically: 253 | file_put_contents( 254 | '/my/local/dir/' . $attachment->getFilename(), 255 | $attachment->getDecodedContent() 256 | ); 257 | ``` 258 | 259 | ### Embedded Messages 260 | 261 | Check if attachment is embedded message and get it: 262 | 263 | ```php 264 | $attachments = $message->getAttachments(); 265 | 266 | foreach ($attachments as $attachment) { 267 | if ($attachment->isEmbeddedMessage()) { 268 | $embeddedMessage = $attachment->getEmbeddedMessage(); 269 | // $embeddedMessage is instance of \Ddeboer\Imap\Message\EmbeddedMessage 270 | } 271 | } 272 | ``` 273 | 274 | An EmbeddedMessage has the same API as a normal Message, apart from flags 275 | and operations like copy, move or delete. 276 | 277 | ### Timeouts 278 | 279 | The IMAP extension provides the [imap_timeout](https://secure.php.net/manual/en/function.imap-timeout.php) 280 | function to adjust the timeout seconds for various operations. 281 | 282 | However the extension's implementation doesn't link the functionality to a 283 | specific context or connection, instead they are global. So in order to not 284 | affect functionalities outside this library, we had to choose whether wrap 285 | every `imap_*` call around an optional user-provided timeout or leave this 286 | task to the user. 287 | 288 | Because of the heterogeneous world of IMAP servers and the high complexity 289 | burden cost for such a little gain of the former, we chose the latter. 290 | 291 | ## Mock the library 292 | 293 | Mockability is granted by interfaces present for each API. 294 | Dig into [MockabilityTest](tests/MockabilityTest.php) for an example of a 295 | mocked workflow. 296 | 297 | ## Contributing: run the build locally 298 | 299 | Docker is needed to run the build on your computer. 300 | 301 | First command you need to run is `make start-imap-server`, which starts an IMAP server locally. 302 | 303 | Then the local build can be triggered with a bare `make`. 304 | 305 | When you finish the development, stop the local IMAP server with `make stop-imap-server`. 306 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ddeboer/imap", 3 | "description": "Object-oriented IMAP for PHP", 4 | "license": "MIT", 5 | "keywords": [ 6 | "email", 7 | "mail", 8 | "imap" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "David de Boer", 13 | "email": "david@ddeboer.nl" 14 | }, 15 | { 16 | "name": "Filippo Tessarotto", 17 | "email": "zoeslam@gmail.com" 18 | }, 19 | { 20 | "name": "Community contributors", 21 | "homepage": "https://github.com/ddeboer/imap/graphs/contributors" 22 | } 23 | ], 24 | "require": { 25 | "php": "~8.3.0 || ~8.4.0", 26 | "ext-dom": "*", 27 | "ext-iconv": "*", 28 | "ext-imap": "*", 29 | "ext-libxml": "*", 30 | "ext-mbstring": "*" 31 | }, 32 | "require-dev": { 33 | "friendsofphp/php-cs-fixer": "^3.64.0", 34 | "laminas/laminas-mail": "^2.25.1", 35 | "phpstan/phpstan": "^1.12.4", 36 | "phpstan/phpstan-phpunit": "^1.4.0", 37 | "phpstan/phpstan-strict-rules": "^1.6.1", 38 | "phpunit/phpunit": "^12.0.2" 39 | }, 40 | "autoload": { 41 | "psr-4": { 42 | "Ddeboer\\Imap\\": "src/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Ddeboer\\Imap\\Tests\\": "tests/" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | private ?array $mailboxNames = null; 30 | 31 | /** 32 | * Constructor. 33 | * 34 | * @throws \InvalidArgumentException 35 | */ 36 | public function __construct(ImapResourceInterface $resource, string $server) 37 | { 38 | $this->resource = $resource; 39 | $this->server = $server; 40 | } 41 | 42 | public function getResource(): ImapResourceInterface 43 | { 44 | return $this->resource; 45 | } 46 | 47 | public function expunge(): bool 48 | { 49 | return \imap_expunge($this->resource->getStream()); 50 | } 51 | 52 | public function close(int $flag = 0): bool 53 | { 54 | $stream = $this->resource->getStream(); 55 | 56 | $this->resource->clearLastMailboxUsedCache(); 57 | 58 | return \imap_close($stream, $flag); 59 | } 60 | 61 | public function getQuota(string $root = 'INBOX'): array 62 | { 63 | $errorMessage = null; 64 | $errorNumber = 0; 65 | \set_error_handler(static function ($nr, $message) use (&$errorMessage, &$errorNumber): bool { 66 | $errorMessage = $message; 67 | $errorNumber = $nr; 68 | 69 | return true; 70 | }); 71 | 72 | $return = \imap_get_quotaroot($this->resource->getStream(), $root); 73 | 74 | \restore_error_handler(); 75 | 76 | if (false === $return || null !== $errorMessage) { 77 | throw new ImapQuotaException( 78 | \sprintf( 79 | 'IMAP Quota request failed for "%s"%s', 80 | $root, 81 | null !== $errorMessage ? ': ' . $errorMessage : '' 82 | ), 83 | $errorNumber 84 | ); 85 | } 86 | 87 | return $return; 88 | } 89 | 90 | public function getMailboxes(): array 91 | { 92 | $this->initMailboxNames(); 93 | \assert(null !== $this->mailboxNames); 94 | 95 | if (null === $this->mailboxes) { 96 | $this->mailboxes = []; 97 | foreach ($this->mailboxNames as $mailboxName => $mailboxInfo) { 98 | $this->mailboxes[(string) $mailboxName] = $this->getMailbox((string) $mailboxName); 99 | } 100 | } 101 | 102 | return $this->mailboxes; 103 | } 104 | 105 | public function hasMailbox(string $name): bool 106 | { 107 | $this->initMailboxNames(); 108 | \assert(null !== $this->mailboxNames); 109 | 110 | return isset($this->mailboxNames[$name]); 111 | } 112 | 113 | public function getMailbox(string $name): MailboxInterface 114 | { 115 | if (false === $this->hasMailbox($name)) { 116 | throw new MailboxDoesNotExistException(\sprintf('Mailbox name "%s" does not exist', $name)); 117 | } 118 | \assert(isset($this->mailboxNames[$name])); 119 | 120 | return new Mailbox($this->resource, $name, $this->mailboxNames[$name]); 121 | } 122 | 123 | #[\ReturnTypeWillChange] 124 | public function count() 125 | { 126 | $return = \imap_num_msg($this->resource->getStream()); 127 | 128 | if (false === $return) { 129 | throw new ImapNumMsgException('imap_num_msg failed'); 130 | } 131 | 132 | return $return; 133 | } 134 | 135 | public function ping(): bool 136 | { 137 | return \imap_ping($this->resource->getStream()); 138 | } 139 | 140 | public function createMailbox(string $name): MailboxInterface 141 | { 142 | if (false === \imap_createmailbox($this->resource->getStream(), $this->server . \mb_convert_encoding($name, 'UTF7-IMAP', 'UTF-8'))) { 143 | throw new CreateMailboxException(\sprintf('Can not create "%s" mailbox at "%s"', $name, $this->server)); 144 | } 145 | 146 | $this->mailboxNames = $this->mailboxes = null; 147 | $this->resource->clearLastMailboxUsedCache(); 148 | 149 | return $this->getMailbox($name); 150 | } 151 | 152 | public function deleteMailbox(MailboxInterface $mailbox): void 153 | { 154 | if (false === \imap_deletemailbox($this->resource->getStream(), $mailbox->getFullEncodedName())) { 155 | throw new DeleteMailboxException(\sprintf('Mailbox "%s" could not be deleted', $mailbox->getName())); 156 | } 157 | 158 | $this->mailboxes = $this->mailboxNames = null; 159 | $this->resource->clearLastMailboxUsedCache(); 160 | } 161 | 162 | private function initMailboxNames(): void 163 | { 164 | if (null !== $this->mailboxNames) { 165 | return; 166 | } 167 | 168 | $this->mailboxNames = []; 169 | $mailboxesInfo = \imap_getmailboxes($this->resource->getStream(), $this->server, '*'); 170 | if (!\is_array($mailboxesInfo)) { 171 | throw new ImapGetmailboxesException('imap_getmailboxes failed'); 172 | } 173 | 174 | foreach ($mailboxesInfo as $mailboxInfo) { 175 | $name = \mb_convert_encoding(\str_replace($this->server, '', $mailboxInfo->name), 'UTF-8', 'UTF7-IMAP'); 176 | \assert(\is_string($name)); 177 | 178 | $this->mailboxNames[$name] = $mailboxInfo; 179 | } 180 | } 181 | 182 | public function subscribeMailbox(string $name): void 183 | { 184 | if (false === \imap_subscribe($this->resource->getStream(), $this->server . \mb_convert_encoding($name, 'UTF7-IMAP', 'UTF-8'))) { 185 | throw new SubscribeMailboxException(\sprintf('Can not subscribe to "%s" mailbox at "%s"', $name, $this->server)); 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/ConnectionInterface.php: -------------------------------------------------------------------------------- 1 | 41 | */ 42 | public function getQuota(string $root = 'INBOX'): array; 43 | 44 | /** 45 | * Get a list of mailboxes (also known as folders). 46 | * 47 | * @return MailboxInterface[] 48 | */ 49 | public function getMailboxes(): array; 50 | 51 | /** 52 | * Check that a mailbox with the given name exists. 53 | * 54 | * @param string $name Mailbox name 55 | */ 56 | public function hasMailbox(string $name): bool; 57 | 58 | /** 59 | * Get a mailbox by its name. 60 | * 61 | * @param string $name Mailbox name 62 | * 63 | * @throws MailboxDoesNotExistException If mailbox does not exist 64 | */ 65 | public function getMailbox(string $name): MailboxInterface; 66 | 67 | /** 68 | * Create mailbox. 69 | * 70 | * @throws CreateMailboxException 71 | */ 72 | public function createMailbox(string $name): MailboxInterface; 73 | 74 | /** 75 | * Delete mailbox. 76 | * 77 | * @throws DeleteMailboxException 78 | */ 79 | public function deleteMailbox(MailboxInterface $mailbox): void; 80 | 81 | /** 82 | * Subscribe to mailbox. 83 | * 84 | * @throws SubscribeMailboxException 85 | */ 86 | public function subscribeMailbox(string $name): void; 87 | } 88 | -------------------------------------------------------------------------------- /src/Exception/AbstractException.php: -------------------------------------------------------------------------------- 1 | 'E_ERROR', 11 | \E_WARNING => 'E_WARNING', 12 | \E_PARSE => 'E_PARSE', 13 | \E_NOTICE => 'E_NOTICE', 14 | \E_CORE_ERROR => 'E_CORE_ERROR', 15 | \E_CORE_WARNING => 'E_CORE_WARNING', 16 | \E_COMPILE_ERROR => 'E_COMPILE_ERROR', 17 | \E_COMPILE_WARNING => 'E_COMPILE_WARNING', 18 | \E_USER_ERROR => 'E_USER_ERROR', 19 | \E_USER_WARNING => 'E_USER_WARNING', 20 | \E_USER_NOTICE => 'E_USER_NOTICE', 21 | \E_STRICT => 'E_STRICT', 22 | \E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', 23 | \E_DEPRECATED => 'E_DEPRECATED', 24 | \E_USER_DEPRECATED => 'E_USER_DEPRECATED', 25 | ]; 26 | 27 | final public function __construct(string $message, int $code = 0, ?\Throwable $previous = null) 28 | { 29 | $errorType = ''; 30 | if (isset(self::ERROR_LABELS[$code])) { 31 | $errorType = \sprintf('[%s] ', self::ERROR_LABELS[$code]); 32 | } 33 | 34 | $joinString = "\n- "; 35 | $alerts = \imap_alerts(); 36 | $errors = \imap_errors(); 37 | $completeMessage = \sprintf( 38 | "%s%s\nimap_alerts (%s):%s\nimap_errors (%s):%s", 39 | $errorType, 40 | $message, 41 | false !== $alerts ? \count($alerts) : 0, 42 | false !== $alerts ? $joinString . \implode($joinString, $alerts) : '', 43 | false !== $errors ? \count($errors) : 0, 44 | false !== $errors ? $joinString . \implode($joinString, $errors) : '' 45 | ); 46 | 47 | parent::__construct($completeMessage, $code, $previous); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Exception/AuthenticationFailedException.php: -------------------------------------------------------------------------------- 1 | resource = $resource; 25 | $this->mailbox = $mailbox; 26 | } 27 | 28 | public function getStream(): Connection 29 | { 30 | $this->initMailbox(); 31 | 32 | return $this->resource; 33 | } 34 | 35 | public function clearLastMailboxUsedCache(): void 36 | { 37 | self::$lastMailboxUsedCache = null; 38 | } 39 | 40 | /** 41 | * If connection is not currently in this mailbox, switch it to this mailbox. 42 | */ 43 | private function initMailbox(): void 44 | { 45 | if (null === $this->mailbox || self::isMailboxOpen($this->mailbox, $this->resource)) { 46 | return; 47 | } 48 | 49 | \set_error_handler(static function (): bool { 50 | return true; 51 | }); 52 | 53 | \imap_reopen($this->resource, $this->mailbox->getFullEncodedName()); 54 | 55 | \restore_error_handler(); 56 | 57 | if (self::isMailboxOpen($this->mailbox, $this->resource)) { 58 | return; 59 | } 60 | 61 | throw new ReopenMailboxException(\sprintf('Cannot reopen mailbox "%s"', $this->mailbox->getName())); 62 | } 63 | 64 | /** 65 | * Check whether the current mailbox is open. 66 | */ 67 | private static function isMailboxOpen(MailboxInterface $mailbox, Connection $resource): bool 68 | { 69 | $currentMailboxName = $mailbox->getFullEncodedName(); 70 | if ($currentMailboxName === self::$lastMailboxUsedCache) { 71 | return true; 72 | } 73 | 74 | self::$lastMailboxUsedCache = null; 75 | $check = \imap_check($resource); 76 | $return = false !== $check && $check->Mailbox === $currentMailboxName; 77 | 78 | if (true === $return) { 79 | self::$lastMailboxUsedCache = $currentMailboxName; 80 | } 81 | 82 | return $return; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ImapResourceInterface.php: -------------------------------------------------------------------------------- 1 | resource = new ImapResource($resource->getStream(), $this); 35 | $this->name = $name; 36 | $this->info = $info; 37 | } 38 | 39 | public function getName(): string 40 | { 41 | return $this->name; 42 | } 43 | 44 | public function renameTo(string $name): bool 45 | { 46 | $encodedName = \mb_convert_encoding($name, 'UTF7-IMAP', 'UTF-8'); 47 | $oldFullName = $this->getFullEncodedName(); 48 | $newFullName = \preg_replace('/' . \preg_quote(\mb_convert_encoding($this->name, 'UTF7-IMAP', 'UTF-8')) . '$/', $encodedName, $oldFullName); 49 | \assert(null !== $newFullName); 50 | 51 | $return = \imap_renamemailbox($this->resource->getStream(), $oldFullName, $newFullName); 52 | if (false === $return) { 53 | throw new RenameMailboxException('Could not rename mailbox'); 54 | } 55 | $this->name = $name; 56 | $this->info->name = $newFullName; 57 | 58 | return true; 59 | } 60 | 61 | public function getEncodedName(): string 62 | { 63 | /** @var string $name */ 64 | $name = $this->info->name; 65 | 66 | return (string) \preg_replace('/^{.+}/', '', $name); 67 | } 68 | 69 | public function getFullEncodedName(): string 70 | { 71 | return $this->info->name; 72 | } 73 | 74 | public function getAttributes(): int 75 | { 76 | return $this->info->attributes; 77 | } 78 | 79 | public function getDelimiter(): string 80 | { 81 | return $this->info->delimiter; 82 | } 83 | 84 | #[\ReturnTypeWillChange] 85 | public function count() 86 | { 87 | $return = \imap_num_msg($this->resource->getStream()); 88 | 89 | if (false === $return) { 90 | throw new ImapNumMsgException('imap_num_msg failed'); 91 | } 92 | 93 | return $return; 94 | } 95 | 96 | public function getStatus(?int $flags = null): \stdClass 97 | { 98 | $return = \imap_status($this->resource->getStream(), $this->getFullEncodedName(), $flags ?? \SA_ALL); 99 | 100 | if (false === $return) { 101 | throw new ImapStatusException('imap_status failed'); 102 | } 103 | 104 | return $return; 105 | } 106 | 107 | public function setFlag(string $flag, $numbers): bool 108 | { 109 | return \imap_setflag_full($this->resource->getStream(), $this->prepareMessageIds($numbers), $flag, \ST_UID); 110 | } 111 | 112 | public function clearFlag(string $flag, $numbers): bool 113 | { 114 | return \imap_clearflag_full($this->resource->getStream(), $this->prepareMessageIds($numbers), $flag, \ST_UID); 115 | } 116 | 117 | public function getMessages(?ConditionInterface $search = null, ?int $sortCriteria = null, bool $descending = false, ?string $charset = null): MessageIteratorInterface 118 | { 119 | if (null === $search) { 120 | $search = new All(); 121 | } 122 | $query = $search->toString(); 123 | 124 | // We need to clear the stack to know whether imap_last_error() 125 | // is related to this imap_search 126 | \imap_errors(); 127 | 128 | if (null !== $sortCriteria) { 129 | $params = [ 130 | $this->resource->getStream(), 131 | $sortCriteria, 132 | $descending, 133 | \SE_UID, 134 | $query, 135 | ]; 136 | if (null !== $charset) { 137 | $params[] = $charset; 138 | } 139 | $messageNumbers = \imap_sort(...$params); 140 | } else { 141 | $params = [ 142 | $this->resource->getStream(), 143 | $query, 144 | \SE_UID, 145 | ]; 146 | if (null !== $charset) { 147 | $params[] = $charset; 148 | } 149 | $messageNumbers = \imap_search(...$params); 150 | } 151 | if (false !== \imap_last_error()) { 152 | // this way all errors occurred during search will be reported 153 | throw new InvalidSearchCriteriaException( 154 | \sprintf('Invalid search criteria [%s]', $query) 155 | ); 156 | } 157 | if (false === $messageNumbers) { 158 | // imap_search can also return false 159 | $messageNumbers = []; 160 | } 161 | 162 | return new MessageIterator($this->resource, $messageNumbers); 163 | } 164 | 165 | public function getMessageSequence(string $sequence): MessageIteratorInterface 166 | { 167 | \imap_errors(); 168 | 169 | $overview = \imap_fetch_overview($this->resource->getStream(), $sequence, \FT_UID); 170 | if (false !== \imap_last_error()) { 171 | throw new InvalidSearchCriteriaException( 172 | \sprintf('Invalid sequence [%s]', $sequence) 173 | ); 174 | } 175 | if (\is_array($overview) && [] !== $overview) { 176 | $messageNumbers = \array_column($overview, 'uid'); 177 | } else { 178 | $messageNumbers = []; 179 | } 180 | 181 | return new MessageIterator($this->resource, $messageNumbers); 182 | } 183 | 184 | public function getMessage(int $number): MessageInterface 185 | { 186 | return new Message($this->resource, $number); 187 | } 188 | 189 | public function getIterator(): MessageIteratorInterface 190 | { 191 | return $this->getMessages(); 192 | } 193 | 194 | public function addMessage(string $message, ?string $options = null, ?\DateTimeInterface $internalDate = null): bool 195 | { 196 | $arguments = [ 197 | $this->resource->getStream(), 198 | $this->getFullEncodedName(), 199 | $message, 200 | $options ?? '', 201 | ]; 202 | if (null !== $internalDate) { 203 | $arguments[] = $internalDate->format('d-M-Y H:i:s O'); 204 | } 205 | 206 | return \imap_append(...$arguments); 207 | } 208 | 209 | public function getThread(): array 210 | { 211 | \set_error_handler(static function (): bool { 212 | return true; 213 | }); 214 | 215 | /** @var array|false $tree */ 216 | $tree = \imap_thread($this->resource->getStream(), \SE_UID); 217 | 218 | \restore_error_handler(); 219 | 220 | return false !== $tree ? $tree : []; 221 | } 222 | 223 | public function move($numbers, MailboxInterface $mailbox): void 224 | { 225 | if (!\imap_mail_copy($this->resource->getStream(), $this->prepareMessageIds($numbers), $mailbox->getEncodedName(), \CP_UID | \CP_MOVE)) { 226 | throw new MessageMoveException(\sprintf('Messages cannot be moved to "%s"', $mailbox->getName())); 227 | } 228 | } 229 | 230 | public function copy($numbers, MailboxInterface $mailbox): void 231 | { 232 | if (!\imap_mail_copy($this->resource->getStream(), $this->prepareMessageIds($numbers), $mailbox->getEncodedName(), \CP_UID)) { 233 | throw new MessageCopyException(\sprintf('Messages cannot be copied to "%s"', $mailbox->getName())); 234 | } 235 | } 236 | 237 | /** 238 | * Prepare message ids for the use with bulk functions. 239 | * 240 | * @param array|MessageIterator|string $messageIds Message numbers 241 | */ 242 | private function prepareMessageIds($messageIds): string 243 | { 244 | if ($messageIds instanceof MessageIterator) { 245 | $messageIds = $messageIds->getArrayCopy(); 246 | } 247 | 248 | if (\is_array($messageIds)) { 249 | $messageIds = \implode(',', $messageIds); 250 | } 251 | 252 | return $messageIds; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/MailboxInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | interface MailboxInterface extends \Countable, \IteratorAggregate 16 | { 17 | /** 18 | * Get mailbox decoded name. 19 | */ 20 | public function getName(): string; 21 | 22 | /** 23 | * Set new mailbox name. 24 | */ 25 | public function renameTo(string $name): bool; 26 | 27 | /** 28 | * Get mailbox encoded path. 29 | */ 30 | public function getEncodedName(): string; 31 | 32 | /** 33 | * Get mailbox encoded full name. 34 | */ 35 | public function getFullEncodedName(): string; 36 | 37 | /** 38 | * Get mailbox attributes. 39 | */ 40 | public function getAttributes(): int; 41 | 42 | /** 43 | * Get mailbox delimiter. 44 | */ 45 | public function getDelimiter(): string; 46 | 47 | /** 48 | * Get Mailbox status. 49 | */ 50 | public function getStatus(?int $flags = null): \stdClass; 51 | 52 | /** 53 | * Bulk Set Flag for Messages. 54 | * 55 | * @param string $flag \Seen, \Answered, \Flagged, \Deleted, and \Draft 56 | * @param array|MessageIterator|string $numbers Message numbers 57 | */ 58 | public function setFlag(string $flag, $numbers): bool; 59 | 60 | /** 61 | * Bulk Clear Flag for Messages. 62 | * 63 | * @param string $flag \Seen, \Answered, \Flagged, \Deleted, and \Draft 64 | * @param array|MessageIterator|string $numbers Message numbers 65 | */ 66 | public function clearFlag(string $flag, $numbers): bool; 67 | 68 | /** 69 | * Get message ids. 70 | * 71 | * @param ConditionInterface $search Search expression (optional) 72 | */ 73 | public function getMessages(?ConditionInterface $search = null, ?int $sortCriteria = null, bool $descending = false, ?string $charset = null): MessageIteratorInterface; 74 | 75 | /** 76 | * Get message iterator for a sequence. 77 | * 78 | * @param string $sequence Message numbers 79 | */ 80 | public function getMessageSequence(string $sequence): MessageIteratorInterface; 81 | 82 | /** 83 | * Get a message by message number. 84 | * 85 | * @param int $number Message number 86 | * 87 | * @return MessageInterface 88 | */ 89 | public function getMessage(int $number): MessageInterface; 90 | 91 | /** 92 | * Get messages in this mailbox. 93 | */ 94 | public function getIterator(): MessageIteratorInterface; 95 | 96 | /** 97 | * Add a message to the mailbox. 98 | */ 99 | public function addMessage(string $message, ?string $options = null, ?\DateTimeInterface $internalDate = null): bool; 100 | 101 | /** 102 | * Returns a tree of threaded message for the current Mailbox. 103 | * 104 | * @return array 105 | */ 106 | public function getThread(): array; 107 | 108 | /** 109 | * Bulk move messages. 110 | * 111 | * @param array|MessageIterator|string $numbers Message numbers 112 | * @param MailboxInterface $mailbox Destination Mailbox to move the messages to 113 | * 114 | * @throws Exception\MessageMoveException 115 | */ 116 | public function move($numbers, self $mailbox): void; 117 | 118 | /** 119 | * Bulk copy messages. 120 | * 121 | * @param array|MessageIterator|string $numbers Message numbers 122 | * @param MailboxInterface $mailbox Destination Mailbox to copy the messages to 123 | * 124 | * @throws Exception\MessageCopyException 125 | */ 126 | public function copy($numbers, self $mailbox): void; 127 | } 128 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | structureLoaded) { 40 | return; 41 | } 42 | $this->structureLoaded = true; 43 | 44 | $messageNumber = $this->getNumber(); 45 | 46 | $errorMessage = null; 47 | $errorNumber = 0; 48 | \set_error_handler(static function ($nr, $message) use (&$errorMessage, &$errorNumber): bool { 49 | $errorMessage = $message; 50 | $errorNumber = $nr; 51 | 52 | return true; 53 | }); 54 | 55 | $structure = \imap_fetchstructure( 56 | $this->resource->getStream(), 57 | $messageNumber, 58 | \FT_UID 59 | ); 60 | 61 | \restore_error_handler(); 62 | 63 | if (!$structure instanceof \stdClass) { 64 | throw new MessageStructureException(\sprintf( 65 | 'Message "%s" structure is empty: %s', 66 | $messageNumber, 67 | $errorMessage 68 | ), $errorNumber); 69 | } 70 | 71 | $this->setStructure($structure); 72 | } 73 | 74 | protected function assertMessageExists(int $messageNumber): void 75 | { 76 | if (true === $this->messageNumberVerified) { 77 | return; 78 | } 79 | $this->messageNumberVerified = true; 80 | 81 | \set_error_handler(static function (): bool { 82 | return true; 83 | }); 84 | 85 | $msgno = \imap_msgno($this->resource->getStream(), $messageNumber); 86 | 87 | \restore_error_handler(); 88 | 89 | if ($msgno > 0) { 90 | $this->imapMsgNo = $msgno; 91 | 92 | return; 93 | } 94 | 95 | throw new MessageDoesNotExistException(\sprintf( 96 | 'Message "%s" does not exist', 97 | $messageNumber 98 | )); 99 | } 100 | 101 | private function getMsgNo(): int 102 | { 103 | // Triggers assertMessageExists() 104 | $this->getNumber(); 105 | 106 | return $this->imapMsgNo; 107 | } 108 | 109 | public function getRawHeaders(): string 110 | { 111 | if (null === $this->rawHeaders) { 112 | $rawHeaders = \imap_fetchheader($this->resource->getStream(), $this->getNumber(), \FT_UID); 113 | 114 | if (false === $rawHeaders) { 115 | throw new ImapFetchheaderException('imap_fetchheader failed'); 116 | } 117 | 118 | $this->rawHeaders = $rawHeaders; 119 | } 120 | 121 | return $this->rawHeaders; 122 | } 123 | 124 | public function getRawMessage(): string 125 | { 126 | if (null === $this->rawMessage) { 127 | $this->rawMessage = $this->doGetContent(''); 128 | } 129 | 130 | return $this->rawMessage; 131 | } 132 | 133 | /** 134 | * @param resource|string $file the path to the saved file as a string, or a valid file descriptor 135 | */ 136 | public function saveRawMessage($file): void 137 | { 138 | $this->doSaveContent($file, ''); 139 | } 140 | 141 | public function getHeaders(): Message\Headers 142 | { 143 | if (null === $this->headers) { 144 | // imap_headerinfo is much faster than imap_fetchheader 145 | // imap_headerinfo returns only a subset of all mail headers, 146 | // but it does include the message flags. 147 | $headers = \imap_headerinfo($this->resource->getStream(), $this->getMsgNo()); 148 | if (false === $headers) { 149 | // @see https://github.com/ddeboer/imap/issues/358 150 | throw new InvalidHeadersException(\sprintf('Message "%s" has invalid headers', $this->getNumber())); 151 | } 152 | $this->headers = new Message\Headers($headers); 153 | } 154 | 155 | return $this->headers; 156 | } 157 | 158 | /** 159 | * Clearmessage headers. 160 | */ 161 | private function clearHeaders(): void 162 | { 163 | $this->headers = null; 164 | } 165 | 166 | public function isRecent(): ?string 167 | { 168 | $recent = $this->getHeaders()->get('recent'); 169 | \assert(null === $recent || \is_string($recent)); 170 | 171 | return $recent; 172 | } 173 | 174 | public function isUnseen(): bool 175 | { 176 | return 'U' === $this->getHeaders()->get('unseen'); 177 | } 178 | 179 | public function isFlagged(): bool 180 | { 181 | return 'F' === $this->getHeaders()->get('flagged'); 182 | } 183 | 184 | public function isAnswered(): bool 185 | { 186 | return 'A' === $this->getHeaders()->get('answered'); 187 | } 188 | 189 | public function isDeleted(): bool 190 | { 191 | return 'D' === $this->getHeaders()->get('deleted'); 192 | } 193 | 194 | public function isDraft(): bool 195 | { 196 | return 'X' === $this->getHeaders()->get('draft'); 197 | } 198 | 199 | public function isSeen(): bool 200 | { 201 | return 'N' !== $this->getHeaders()->get('recent') && 'U' !== $this->getHeaders()->get('unseen'); 202 | } 203 | 204 | public function maskAsSeen(): bool 205 | { 206 | return $this->markAsSeen(); 207 | } 208 | 209 | public function markAsSeen(): bool 210 | { 211 | return $this->setFlag('\Seen'); 212 | } 213 | 214 | public function copy(MailboxInterface $mailbox): void 215 | { 216 | // 'deleted' header changed, force to reload headers, would be better to set deleted flag to true on header 217 | $this->clearHeaders(); 218 | 219 | if (!\imap_mail_copy($this->resource->getStream(), (string) $this->getNumber(), $mailbox->getEncodedName(), \CP_UID)) { 220 | throw new MessageCopyException(\sprintf('Message "%s" cannot be copied to "%s"', $this->getNumber(), $mailbox->getName())); 221 | } 222 | } 223 | 224 | public function move(MailboxInterface $mailbox): void 225 | { 226 | // 'deleted' header changed, force to reload headers, would be better to set deleted flag to true on header 227 | $this->clearHeaders(); 228 | 229 | if (!\imap_mail_move($this->resource->getStream(), (string) $this->getNumber(), $mailbox->getEncodedName(), \CP_UID)) { 230 | throw new MessageMoveException(\sprintf('Message "%s" cannot be moved to "%s"', $this->getNumber(), $mailbox->getName())); 231 | } 232 | } 233 | 234 | public function delete(): void 235 | { 236 | // 'deleted' header changed, force to reload headers, would be better to set deleted flag to true on header 237 | $this->clearHeaders(); 238 | 239 | \imap_delete($this->resource->getStream(), (string) $this->getNumber(), \FT_UID); 240 | } 241 | 242 | public function undelete(): void 243 | { 244 | // 'deleted' header changed, force to reload headers, would be better to set deleted flag to false on header 245 | $this->clearHeaders(); 246 | 247 | \imap_undelete($this->resource->getStream(), (string) $this->getNumber(), \FT_UID); 248 | } 249 | 250 | public function setFlag(string $flag): bool 251 | { 252 | $result = \imap_setflag_full($this->resource->getStream(), (string) $this->getNumber(), $flag, \ST_UID); 253 | 254 | $this->clearHeaders(); 255 | 256 | return $result; 257 | } 258 | 259 | public function clearFlag(string $flag): bool 260 | { 261 | $result = \imap_clearflag_full($this->resource->getStream(), (string) $this->getNumber(), $flag, \ST_UID); 262 | 263 | $this->clearHeaders(); 264 | 265 | return $result; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Message/AbstractMessage.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | final public function getId(): ?string 27 | { 28 | $messageId = $this->getHeaders()->get('message_id'); 29 | \assert(null === $messageId || \is_string($messageId)); 30 | 31 | return $messageId; 32 | } 33 | 34 | /** 35 | * Get message sender (from headers). 36 | */ 37 | final public function getFrom(): ?EmailAddress 38 | { 39 | $from = $this->getHeaders()->get('from'); 40 | \assert(null === $from || \is_array($from)); 41 | 42 | return null !== $from ? $this->decodeEmailAddress($from[0]) : null; 43 | } 44 | 45 | /** 46 | * Get To recipients. 47 | * 48 | * @return EmailAddress[] Empty array in case message has no To: recipients 49 | */ 50 | final public function getTo(): array 51 | { 52 | $emails = $this->getHeaders()->get('to'); 53 | \assert(null === $emails || \is_array($emails)); 54 | 55 | return $this->decodeEmailAddresses($emails ?? []); 56 | } 57 | 58 | /** 59 | * Get Cc recipients. 60 | * 61 | * @return EmailAddress[] Empty array in case message has no CC: recipients 62 | */ 63 | final public function getCc(): array 64 | { 65 | $emails = $this->getHeaders()->get('cc'); 66 | \assert(null === $emails || \is_array($emails)); 67 | 68 | return $this->decodeEmailAddresses($emails ?? []); 69 | } 70 | 71 | /** 72 | * Get Bcc recipients. 73 | * 74 | * @return EmailAddress[] Empty array in case message has no BCC: recipients 75 | */ 76 | final public function getBcc(): array 77 | { 78 | $emails = $this->getHeaders()->get('bcc'); 79 | \assert(null === $emails || \is_array($emails)); 80 | 81 | return $this->decodeEmailAddresses($emails ?? []); 82 | } 83 | 84 | /** 85 | * Get Reply-To recipients. 86 | * 87 | * @return EmailAddress[] Empty array in case message has no Reply-To: recipients 88 | */ 89 | final public function getReplyTo(): array 90 | { 91 | $emails = $this->getHeaders()->get('reply_to'); 92 | \assert(null === $emails || \is_array($emails)); 93 | 94 | return $this->decodeEmailAddresses($emails ?? []); 95 | } 96 | 97 | /** 98 | * Get Sender. 99 | * 100 | * @return EmailAddress[] Empty array in case message has no Sender: recipients 101 | */ 102 | final public function getSender(): array 103 | { 104 | $emails = $this->getHeaders()->get('sender'); 105 | \assert(null === $emails || \is_array($emails)); 106 | 107 | return $this->decodeEmailAddresses($emails ?? []); 108 | } 109 | 110 | /** 111 | * Get Return-Path. 112 | * 113 | * @return EmailAddress[] Empty array in case message has no Return-Path: recipients 114 | */ 115 | final public function getReturnPath(): array 116 | { 117 | $emails = $this->getHeaders()->get('return_path'); 118 | \assert(null === $emails || \is_array($emails)); 119 | 120 | return $this->decodeEmailAddresses($emails ?? []); 121 | } 122 | 123 | /** 124 | * Get date (from headers). 125 | */ 126 | final public function getDate(): ?\DateTimeImmutable 127 | { 128 | /** @var null|string $dateHeader */ 129 | $dateHeader = $this->getHeaders()->get('date'); 130 | if (null === $dateHeader) { 131 | return null; 132 | } 133 | 134 | $alteredValue = $dateHeader; 135 | $alteredValue = \str_replace(',', '', $alteredValue); 136 | $alteredValue = (string) \preg_replace('/^[a-zA-Z]+ ?/', '', $alteredValue); 137 | $alteredValue = (string) \preg_replace('/\(.*\)/', '', $alteredValue); 138 | $alteredValue = (string) \preg_replace('/\<.*\>/', '', $alteredValue); 139 | $alteredValue = (string) \preg_replace('/\bUT\b/', 'UTC', $alteredValue); 140 | if (0 === \preg_match('/\d\d:\d\d:\d\d.* [\+\-]\d\d:?\d\d/', $alteredValue)) { 141 | $alteredValue .= ' +0000'; 142 | } 143 | // Handle numeric months 144 | $alteredValue = (string) \preg_replace('/^(\d\d) (\d\d) (\d\d(?:\d\d)?) /', '$3-$2-$1 ', $alteredValue); 145 | 146 | try { 147 | $date = new \DateTimeImmutable($alteredValue); 148 | } catch (\Throwable $ex) { 149 | throw new InvalidDateHeaderException(\sprintf('Invalid Date header found: "%s"', $dateHeader), 0, $ex); 150 | } 151 | 152 | return $date; 153 | } 154 | 155 | /** 156 | * Get message size (from headers). 157 | * 158 | * @return null|int|string 159 | */ 160 | final public function getSize() 161 | { 162 | $size = $this->getHeaders()->get('size'); 163 | \assert(null === $size || \is_int($size) || \is_string($size)); 164 | 165 | return $size; 166 | } 167 | 168 | /** 169 | * Get message subject (from headers). 170 | */ 171 | final public function getSubject(): ?string 172 | { 173 | $subject = $this->getHeaders()->get('subject'); 174 | \assert(null === $subject || \is_string($subject)); 175 | 176 | return $subject; 177 | } 178 | 179 | /** 180 | * Get message In-Reply-To (from headers). 181 | * 182 | * @return string[] 183 | */ 184 | final public function getInReplyTo(): array 185 | { 186 | $inReplyTo = $this->getHeaders()->get('in_reply_to'); 187 | \assert(null === $inReplyTo || \is_string($inReplyTo)); 188 | 189 | return null !== $inReplyTo ? \explode(' ', $inReplyTo) : []; 190 | } 191 | 192 | /** 193 | * Get message References (from headers). 194 | * 195 | * @return string[] 196 | */ 197 | final public function getReferences(): array 198 | { 199 | $references = $this->getHeaders()->get('references'); 200 | \assert(null === $references || \is_string($references)); 201 | 202 | return null !== $references ? \explode(' ', $references) : []; 203 | } 204 | 205 | /** 206 | * Get first body HTML part. 207 | */ 208 | final public function getBodyHtml(): ?string 209 | { 210 | $htmlParts = $this->getAllContentsBySubtype(self::SUBTYPE_HTML); 211 | 212 | return $htmlParts[0] ?? null; 213 | } 214 | 215 | /** 216 | * Get all contents parts of specific subtype (self::SUBTYPE_HTML or self::SUBTYPE_PLAIN). 217 | * 218 | * @return string[] 219 | */ 220 | final public function getAllContentsBySubtype(string $subtype): array 221 | { 222 | $iterator = new \RecursiveIteratorIterator($this, \RecursiveIteratorIterator::SELF_FIRST); 223 | $parts = []; 224 | /** @var PartInterface $part */ 225 | foreach ($iterator as $part) { 226 | if ($subtype === $part->getSubtype()) { 227 | $parts[] = $part->getDecodedContent(); 228 | } 229 | } 230 | if (\count($parts) > 0) { 231 | return $parts; 232 | } 233 | 234 | // If message has no parts and is of right type, return content of message. 235 | if ($subtype === $this->getSubtype()) { 236 | return [$this->getDecodedContent()]; 237 | } 238 | 239 | return []; 240 | } 241 | 242 | /** 243 | * Get body HTML parts. 244 | * 245 | * @return string[] 246 | */ 247 | final public function getBodyHtmlParts(): array 248 | { 249 | return $this->getAllContentsBySubtype(self::SUBTYPE_HTML); 250 | } 251 | 252 | /** 253 | * Get all body HTML parts merged into 1 html. 254 | */ 255 | final public function getCompleteBodyHtml(): ?string 256 | { 257 | $htmlParts = $this->getAllContentsBySubtype(self::SUBTYPE_HTML); 258 | 259 | if (1 === \count($htmlParts)) { 260 | return $htmlParts[0]; 261 | } 262 | if (0 === \count($htmlParts)) { 263 | return null; 264 | } 265 | \libxml_use_internal_errors(true); // Suppress parse errors, get errors with libxml_get_errors(); 266 | 267 | $newDom = new \DOMDocument(); 268 | 269 | $newBody = ''; 270 | $newDom->loadHTML(\implode('', $htmlParts)); 271 | 272 | $bodyTags = $newDom->getElementsByTagName('body'); 273 | 274 | foreach ($bodyTags as $body) { 275 | foreach ($body->childNodes as $node) { 276 | $newBody .= $newDom->saveHTML($node); 277 | } 278 | } 279 | 280 | $newDom = new \DOMDocument(); 281 | $newDom->loadHTML($newBody); 282 | 283 | $completeHtml = $newDom->saveHTML(); 284 | 285 | return false === $completeHtml ? null : $completeHtml; 286 | } 287 | 288 | /** 289 | * Get body text. 290 | */ 291 | final public function getBodyText(): ?string 292 | { 293 | $plainParts = $this->getAllContentsBySubtype(self::SUBTYPE_PLAIN); 294 | 295 | return $plainParts[0] ?? null; 296 | } 297 | 298 | /** 299 | * Get all body PLAIN parts merged into 1 string. 300 | * 301 | * @return null|string Null if message has no PLAIN message parts 302 | */ 303 | final public function getCompleteBodyText(): ?string 304 | { 305 | $plainParts = $this->getAllContentsBySubtype(self::SUBTYPE_PLAIN); 306 | 307 | if (1 === \count($plainParts)) { 308 | return $plainParts[0]; 309 | } 310 | if (0 === \count($plainParts)) { 311 | return null; 312 | } 313 | 314 | return \implode("\n", $plainParts); 315 | } 316 | 317 | /** 318 | * Get attachments (if any) linked to this e-mail. 319 | * 320 | * @return AttachmentInterface[] 321 | */ 322 | final public function getAttachments(): array 323 | { 324 | if (null === $this->attachments) { 325 | $this->attachments = self::gatherAttachments($this); 326 | } 327 | 328 | return $this->attachments; 329 | } 330 | 331 | /** 332 | * @param PartInterface $part 333 | * 334 | * @return Attachment[] 335 | */ 336 | private static function gatherAttachments(PartInterface $part): array 337 | { 338 | $attachments = []; 339 | foreach ($part->getParts() as $childPart) { 340 | if ($childPart instanceof Attachment) { 341 | $attachments[] = $childPart; 342 | } 343 | if ($childPart->hasChildren()) { 344 | $attachments = [...$attachments, ...self::gatherAttachments($childPart)]; 345 | } 346 | } 347 | 348 | return $attachments; 349 | } 350 | 351 | /** 352 | * Does this message have attachments? 353 | */ 354 | final public function hasAttachments(): bool 355 | { 356 | return \count($this->getAttachments()) > 0; 357 | } 358 | 359 | /** 360 | * @param \stdClass[] $addresses 361 | * 362 | * @return EmailAddress[] 363 | */ 364 | private function decodeEmailAddresses(array $addresses): array 365 | { 366 | $return = []; 367 | foreach ($addresses as $address) { 368 | if (isset($address->mailbox)) { 369 | $return[] = $this->decodeEmailAddress($address); 370 | } 371 | } 372 | 373 | return $return; 374 | } 375 | 376 | private function decodeEmailAddress(\stdClass $value): EmailAddress 377 | { 378 | return new EmailAddress($value->mailbox, $value->host, $value->personal); 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /src/Message/AbstractPart.php: -------------------------------------------------------------------------------- 1 | self::TYPE_TEXT, 19 | \TYPEMULTIPART => self::TYPE_MULTIPART, 20 | \TYPEMESSAGE => self::TYPE_MESSAGE, 21 | \TYPEAPPLICATION => self::TYPE_APPLICATION, 22 | \TYPEAUDIO => self::TYPE_AUDIO, 23 | \TYPEIMAGE => self::TYPE_IMAGE, 24 | \TYPEVIDEO => self::TYPE_VIDEO, 25 | \TYPEMODEL => self::TYPE_MODEL, 26 | \TYPEOTHER => self::TYPE_OTHER, 27 | ]; 28 | 29 | private const ENCODINGS_MAP = [ 30 | \ENC7BIT => self::ENCODING_7BIT, 31 | \ENC8BIT => self::ENCODING_8BIT, 32 | \ENCBINARY => self::ENCODING_BINARY, 33 | \ENCBASE64 => self::ENCODING_BASE64, 34 | \ENCQUOTEDPRINTABLE => self::ENCODING_QUOTED_PRINTABLE, 35 | ]; 36 | 37 | private const ATTACHMENT_KEYS = [ 38 | 'name' => true, 39 | 'filename' => true, 40 | 'name*' => true, 41 | 'filename*' => true, 42 | ]; 43 | 44 | protected ImapResourceInterface $resource; 45 | private bool $structureParsed = false; 46 | /** 47 | * @var AbstractPart[] 48 | */ 49 | private array $parts = []; 50 | private string $partNumber; 51 | private int $messageNumber; 52 | private \stdClass $structure; 53 | private Parameters $parameters; 54 | private ?string $type = null; 55 | private ?string $subtype = null; 56 | private ?string $encoding = null; 57 | private ?string $disposition = null; 58 | private ?string $description = null; 59 | private string|int|null $bytes; 60 | private ?string $lines = null; 61 | private ?string $content = null; 62 | private ?string $decodedContent = null; 63 | private int $key = 0; 64 | 65 | /** 66 | * Constructor. 67 | * 68 | * @param ImapResourceInterface $resource IMAP resource 69 | * @param int $messageNumber Message number 70 | * @param string $partNumber Part number 71 | * @param \stdClass $structure Part structure 72 | */ 73 | public function __construct( 74 | ImapResourceInterface $resource, 75 | int $messageNumber, 76 | string $partNumber, 77 | \stdClass $structure, 78 | ) { 79 | $this->resource = $resource; 80 | $this->messageNumber = $messageNumber; 81 | $this->partNumber = $partNumber; 82 | $this->setStructure($structure); 83 | } 84 | 85 | final public function getNumber(): int 86 | { 87 | $this->assertMessageExists($this->messageNumber); 88 | 89 | return $this->messageNumber; 90 | } 91 | 92 | /** 93 | * Ensure message exists. 94 | */ 95 | protected function assertMessageExists(int $messageNumber): void 96 | { 97 | } 98 | 99 | /** 100 | * @param \stdClass $structure Part structure 101 | */ 102 | final protected function setStructure(\stdClass $structure): void 103 | { 104 | $this->structure = $structure; 105 | } 106 | 107 | final public function getStructure(): \stdClass 108 | { 109 | $this->lazyLoadStructure(); 110 | 111 | return $this->structure; 112 | } 113 | 114 | /** 115 | * Lazy load structure. 116 | */ 117 | protected function lazyLoadStructure(): void 118 | { 119 | } 120 | 121 | final public function getParameters(): Parameters 122 | { 123 | $this->lazyParseStructure(); 124 | 125 | return $this->parameters; 126 | } 127 | 128 | final public function getCharset(): ?string 129 | { 130 | $this->lazyParseStructure(); 131 | 132 | $charset = $this->parameters->get('charset'); 133 | \assert(null === $charset || \is_string($charset)); 134 | 135 | return '' !== $charset ? $charset : null; 136 | } 137 | 138 | final public function getType(): ?string 139 | { 140 | $this->lazyParseStructure(); 141 | 142 | return $this->type; 143 | } 144 | 145 | final public function getSubtype(): ?string 146 | { 147 | $this->lazyParseStructure(); 148 | 149 | return $this->subtype; 150 | } 151 | 152 | final public function getEncoding(): ?string 153 | { 154 | $this->lazyParseStructure(); 155 | 156 | return $this->encoding; 157 | } 158 | 159 | final public function getDisposition(): ?string 160 | { 161 | $this->lazyParseStructure(); 162 | 163 | return $this->disposition; 164 | } 165 | 166 | final public function getDescription(): ?string 167 | { 168 | $this->lazyParseStructure(); 169 | 170 | return $this->description; 171 | } 172 | 173 | final public function getBytes() 174 | { 175 | $this->lazyParseStructure(); 176 | 177 | return $this->bytes; 178 | } 179 | 180 | final public function getLines(): ?string 181 | { 182 | $this->lazyParseStructure(); 183 | 184 | return $this->lines; 185 | } 186 | 187 | final public function getContent(): string 188 | { 189 | if (null === $this->content) { 190 | $this->content = $this->doGetContent($this->getContentPartNumber()); 191 | } 192 | 193 | return $this->content; 194 | } 195 | 196 | /** 197 | * Get content part number. 198 | */ 199 | protected function getContentPartNumber(): string 200 | { 201 | return $this->partNumber; 202 | } 203 | 204 | final public function getPartNumber(): string 205 | { 206 | return $this->partNumber; 207 | } 208 | 209 | final public function getDecodedContent(): string 210 | { 211 | if (null === $this->decodedContent) { 212 | if (self::ENCODING_UNKNOWN === $this->getEncoding()) { 213 | throw new UnexpectedEncodingException('Cannot decode a content with an uknown encoding'); 214 | } 215 | 216 | $content = $this->getContent(); 217 | if (self::ENCODING_BASE64 === $this->getEncoding()) { 218 | $content = \base64_decode($content, false); 219 | } elseif (self::ENCODING_QUOTED_PRINTABLE === $this->getEncoding()) { 220 | $content = \quoted_printable_decode($content); 221 | } 222 | 223 | if (false === $content) { 224 | throw new UnexpectedEncodingException('Cannot decode content'); 225 | } 226 | 227 | // If this part is a text part, convert its charset to UTF-8. 228 | // We don't want to decode an attachment's charset. 229 | if (!$this instanceof Attachment && null !== $this->getCharset() && self::TYPE_TEXT === $this->getType()) { 230 | $content = Transcoder::decode($content, $this->getCharset()); 231 | } 232 | 233 | $this->decodedContent = $content; 234 | } 235 | 236 | return $this->decodedContent; 237 | } 238 | 239 | /** 240 | * Get raw message content. 241 | */ 242 | final protected function doGetContent(string $partNumber): string 243 | { 244 | $return = \imap_fetchbody( 245 | $this->resource->getStream(), 246 | $this->getNumber(), 247 | $partNumber, 248 | \FT_UID | \FT_PEEK 249 | ); 250 | 251 | if (false === $return) { 252 | throw new ImapFetchbodyException('imap_fetchbody failed'); 253 | } 254 | 255 | return $return; 256 | } 257 | 258 | /** 259 | * Save raw message content to file. 260 | * 261 | * @param resource|string $file the path to the saved file as a string, or a valid file descriptor 262 | */ 263 | final protected function doSaveContent($file, string $partNumber): void 264 | { 265 | $return = \imap_savebody( 266 | $this->resource->getStream(), 267 | $file, 268 | $this->getNumber(), 269 | $partNumber, 270 | \FT_UID | \FT_PEEK 271 | ); 272 | 273 | if (false === $return) { 274 | throw new ImapFetchbodyException('imap_savebody failed'); 275 | } 276 | } 277 | 278 | final public function getParts(): array 279 | { 280 | $this->lazyParseStructure(); 281 | 282 | return $this->parts; 283 | } 284 | 285 | /** 286 | * Get current child part. 287 | */ 288 | #[\ReturnTypeWillChange] 289 | final public function current() 290 | { 291 | $this->lazyParseStructure(); 292 | 293 | return $this->parts[$this->key]; 294 | } 295 | 296 | #[\ReturnTypeWillChange] 297 | final public function getChildren() 298 | { 299 | return $this->current(); 300 | } 301 | 302 | #[\ReturnTypeWillChange] 303 | final public function hasChildren() 304 | { 305 | $this->lazyParseStructure(); 306 | 307 | return \count($this->parts) > 0; 308 | } 309 | 310 | /** 311 | * @return int 312 | */ 313 | #[\ReturnTypeWillChange] 314 | final public function key() 315 | { 316 | return $this->key; 317 | } 318 | 319 | #[\ReturnTypeWillChange] 320 | final public function next() 321 | { 322 | ++$this->key; 323 | } 324 | 325 | #[\ReturnTypeWillChange] 326 | final public function rewind() 327 | { 328 | $this->key = 0; 329 | } 330 | 331 | #[\ReturnTypeWillChange] 332 | final public function valid() 333 | { 334 | $this->lazyParseStructure(); 335 | 336 | return isset($this->parts[$this->key]); 337 | } 338 | 339 | /** 340 | * Parse part structure. 341 | */ 342 | private function lazyParseStructure(): void 343 | { 344 | if (true === $this->structureParsed) { 345 | return; 346 | } 347 | $this->structureParsed = true; 348 | 349 | $this->lazyLoadStructure(); 350 | 351 | $this->type = self::TYPES_MAP[$this->structure->type] ?? self::TYPE_UNKNOWN; 352 | 353 | // In our context, \ENCOTHER is as useful as an unknown encoding 354 | $this->encoding = self::ENCODINGS_MAP[$this->structure->encoding] ?? self::ENCODING_UNKNOWN; 355 | if (isset($this->structure->subtype)) { 356 | $this->subtype = $this->structure->subtype; 357 | } 358 | 359 | if (isset($this->structure->bytes)) { 360 | $this->bytes = $this->structure->bytes; 361 | } 362 | if ($this->structure->ifdisposition) { 363 | $this->disposition = $this->structure->disposition; 364 | } 365 | if ($this->structure->ifdescription) { 366 | $this->description = $this->structure->description; 367 | } 368 | 369 | $this->parameters = new Parameters(); 370 | if ($this->structure->ifparameters) { 371 | $this->parameters->add($this->structure->parameters); 372 | } 373 | 374 | if ($this->structure->ifdparameters) { 375 | $this->parameters->add($this->structure->dparameters); 376 | } 377 | 378 | // When the message is not multipart and the body is the attachment content 379 | // Prevents infinite recursion 380 | if (!$this instanceof Attachment && self::isAttachment($this->structure)) { 381 | $this->parts[] = new Attachment($this->resource, $this->getNumber(), '1', $this->structure); 382 | } 383 | 384 | if (isset($this->structure->parts)) { 385 | $parts = $this->structure->parts; 386 | // https://secure.php.net/manual/en/function.imap-fetchbody.php#89002 387 | if ($this instanceof Attachment && $this->isEmbeddedMessage() && 1 === \count($parts) && \TYPEMULTIPART === $parts[0]->type) { 388 | $parts = $parts[0]->parts; 389 | } 390 | foreach ($parts as $key => $partStructure) { 391 | $partNumber = (!$this instanceof Message) ? $this->partNumber . '.' : ''; 392 | $partNumber .= $key + 1; 393 | 394 | $newPartClass = self::isAttachment($partStructure) 395 | ? Attachment::class 396 | : SimplePart::class; 397 | 398 | $this->parts[] = new $newPartClass($this->resource, $this->getNumber(), $partNumber, $partStructure); 399 | } 400 | } 401 | } 402 | 403 | /** 404 | * Check if the given part is an attachment. 405 | */ 406 | private static function isAttachment(\stdClass $part): bool 407 | { 408 | if (isset(self::TYPES_MAP[$part->type]) && self::TYPE_MULTIPART === self::TYPES_MAP[$part->type]) { 409 | return false; 410 | } 411 | 412 | // Attachment with correct Content-Disposition header 413 | if ($part->ifdisposition) { 414 | if ('attachment' === \strtolower($part->disposition)) { 415 | return true; 416 | } 417 | 418 | if ( 419 | 'inline' === \strtolower($part->disposition) 420 | && self::SUBTYPE_PLAIN !== \strtoupper($part->subtype) 421 | && self::SUBTYPE_HTML !== \strtoupper($part->subtype) 422 | ) { 423 | return true; 424 | } 425 | } 426 | 427 | // Attachment without Content-Disposition header 428 | if ($part->ifparameters) { 429 | foreach ($part->parameters as $parameter) { 430 | if (isset(self::ATTACHMENT_KEYS[\strtolower($parameter->attribute)])) { 431 | return true; 432 | } 433 | } 434 | } 435 | 436 | /* 437 | if ($part->ifdparameters) { 438 | foreach ($part->dparameters as $parameter) { 439 | if (isset(self::$attachmentKeys[\strtolower($parameter->attribute)])) { 440 | return true; 441 | } 442 | } 443 | } 444 | */ 445 | 446 | return self::SUBTYPE_RFC822 === \strtoupper($part->subtype); 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /src/Message/Attachment.php: -------------------------------------------------------------------------------- 1 | getParameters()->get('filename'); 17 | if (null === $filename || '' === $filename) { 18 | $filename = $this->getParameters()->get('name'); 19 | } 20 | \assert(null === $filename || \is_string($filename)); 21 | 22 | return $filename; 23 | } 24 | 25 | public function getSize() 26 | { 27 | $size = $this->getParameters()->get('size'); 28 | if (\is_numeric($size)) { 29 | $size = (int) $size; 30 | } 31 | \assert(null === $size || \is_int($size)); 32 | 33 | return $size; 34 | } 35 | 36 | public function isEmbeddedMessage(): bool 37 | { 38 | return self::TYPE_MESSAGE === $this->getType(); 39 | } 40 | 41 | public function getEmbeddedMessage(): EmbeddedMessageInterface 42 | { 43 | if (!$this->isEmbeddedMessage()) { 44 | throw new NotEmbeddedMessageException(\sprintf( 45 | 'Attachment "%s" in message "%s" is not embedded message', 46 | $this->getPartNumber(), 47 | $this->getNumber() 48 | )); 49 | } 50 | 51 | return new EmbeddedMessage($this->resource, $this->getNumber(), $this->getPartNumber(), $this->getStructure()->parts[0]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Message/AttachmentInterface.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function getEmbeddedMessage(): EmbeddedMessageInterface; 39 | } 40 | -------------------------------------------------------------------------------- /src/Message/BasicMessageInterface.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function getId(): ?string; 39 | 40 | /** 41 | * Get message sender (from headers). 42 | */ 43 | public function getFrom(): ?EmailAddress; 44 | 45 | /** 46 | * Get To recipients. 47 | * 48 | * @return EmailAddress[] Empty array in case message has no To: recipients 49 | */ 50 | public function getTo(): array; 51 | 52 | /** 53 | * Get Cc recipients. 54 | * 55 | * @return EmailAddress[] Empty array in case message has no CC: recipients 56 | */ 57 | public function getCc(): array; 58 | 59 | /** 60 | * Get Bcc recipients. 61 | * 62 | * @return EmailAddress[] Empty array in case message has no BCC: recipients 63 | */ 64 | public function getBcc(): array; 65 | 66 | /** 67 | * Get Reply-To recipients. 68 | * 69 | * @return EmailAddress[] Empty array in case message has no Reply-To: recipients 70 | */ 71 | public function getReplyTo(): array; 72 | 73 | /** 74 | * Get Sender. 75 | * 76 | * @return EmailAddress[] Empty array in case message has no Sender: recipients 77 | */ 78 | public function getSender(): array; 79 | 80 | /** 81 | * Get Return-Path. 82 | * 83 | * @return EmailAddress[] Empty array in case message has no Return-Path: recipients 84 | */ 85 | public function getReturnPath(): array; 86 | 87 | /** 88 | * Get date (from headers). 89 | */ 90 | public function getDate(): ?\DateTimeImmutable; 91 | 92 | /** 93 | * Get message size (from headers). 94 | * 95 | * @return null|int|string 96 | */ 97 | public function getSize(); 98 | 99 | /** 100 | * Get message subject (from headers). 101 | */ 102 | public function getSubject(): ?string; 103 | 104 | /** 105 | * Get message In-Reply-To (from headers). 106 | * 107 | * @return string[] 108 | */ 109 | public function getInReplyTo(): array; 110 | 111 | /** 112 | * Get message References (from headers). 113 | * 114 | * @return string[] 115 | */ 116 | public function getReferences(): array; 117 | 118 | /** 119 | * Get message parts by type. 120 | * 121 | * @return string[] 122 | */ 123 | public function getAllContentsBySubtype(string $subtype): array; 124 | 125 | /** 126 | * Get first body HTML part. 127 | * 128 | * @return null|string Null if message has no HTML message part 129 | */ 130 | public function getBodyHtml(): ?string; 131 | 132 | /** 133 | * Get all body HTML parts as array. 134 | * 135 | * @return string[] 136 | */ 137 | public function getBodyHtmlParts(): array; 138 | 139 | /** 140 | * Get all body HTML parts merged into 1 html. 141 | * 142 | * @return null|string Null if message has no HTML message parts 143 | */ 144 | public function getCompleteBodyHtml(): ?string; 145 | 146 | /** 147 | * Get body text. 148 | */ 149 | public function getBodyText(): ?string; 150 | 151 | /** 152 | * Get all body PLAIN parts merged into 1 string. 153 | * 154 | * @return null|string Null if message has no PLAIN message parts 155 | */ 156 | public function getCompleteBodyText(): ?string; 157 | 158 | /** 159 | * Get attachments (if any) linked to this e-mail. 160 | * 161 | * @return AttachmentInterface[] 162 | */ 163 | public function getAttachments(): array; 164 | 165 | /** 166 | * Does this message have attachments? 167 | */ 168 | public function hasAttachments(): bool; 169 | } 170 | -------------------------------------------------------------------------------- /src/Message/EmailAddress.php: -------------------------------------------------------------------------------- 1 | mailbox = $mailbox; 20 | $this->hostname = $hostname; 21 | $this->name = $name; 22 | $this->address = null; 23 | 24 | if (null !== $hostname) { 25 | $this->address = $mailbox . '@' . $hostname; 26 | } 27 | } 28 | 29 | /** 30 | * @return null|string 31 | */ 32 | public function getAddress() 33 | { 34 | return $this->address; 35 | } 36 | 37 | /** 38 | * Returns address with person name. 39 | */ 40 | public function getFullAddress(): string 41 | { 42 | $address = \sprintf('%s@%s', $this->mailbox, $this->hostname); 43 | if (null !== $this->name) { 44 | $address = \sprintf('"%s" <%s>', \addcslashes($this->name, '"'), $address); 45 | } 46 | 47 | return $address; 48 | } 49 | 50 | public function getMailbox(): string 51 | { 52 | return $this->mailbox; 53 | } 54 | 55 | /** 56 | * @return null|string 57 | */ 58 | public function getHostname() 59 | { 60 | return $this->hostname; 61 | } 62 | 63 | /** 64 | * @return null|string 65 | */ 66 | public function getName() 67 | { 68 | return $this->name; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Message/EmbeddedMessage.php: -------------------------------------------------------------------------------- 1 | headers) { 16 | $this->headers = new Headers(\imap_rfc822_parse_headers($this->getRawHeaders())); 17 | } 18 | 19 | return $this->headers; 20 | } 21 | 22 | public function getRawHeaders(): string 23 | { 24 | if (null === $this->rawHeaders) { 25 | $rawHeaders = \explode("\r\n\r\n", $this->getRawMessage(), 2); 26 | $this->rawHeaders = \current($rawHeaders); 27 | } 28 | 29 | return $this->rawHeaders; 30 | } 31 | 32 | public function getRawMessage(): string 33 | { 34 | if (null === $this->rawMessage) { 35 | $this->rawMessage = $this->doGetContent($this->getPartNumber()); 36 | } 37 | 38 | return $this->rawMessage; 39 | } 40 | 41 | /** 42 | * @param resource|string $file the path to the saved file as a string, or a valid file descriptor 43 | */ 44 | public function saveRawMessage($file): void 45 | { 46 | $this->doSaveContent($file, $this->getPartNumber()); 47 | } 48 | 49 | /** 50 | * Get content part number. 51 | */ 52 | protected function getContentPartNumber(): string 53 | { 54 | $partNumber = $this->getPartNumber(); 55 | if (0 === \count($this->getParts())) { 56 | $partNumber .= '.1'; 57 | } 58 | 59 | return $partNumber; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Message/EmbeddedMessageInterface.php: -------------------------------------------------------------------------------- 1 | $value) { 19 | try { 20 | $this[$key] = $this->parseHeader($key, $value); 21 | } catch (UnsupportedCharsetException) { 22 | // safely skip header with unsupported charset 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Get header. 29 | * 30 | * @return null|int|\stdClass[]|string 31 | */ 32 | public function get(string $key) 33 | { 34 | return parent::get(\strtolower($key)); 35 | } 36 | 37 | /** 38 | * Parse header. 39 | * 40 | * @param int|\stdClass[]|string $value 41 | * 42 | * @return int|\stdClass[]|string 43 | */ 44 | private function parseHeader(string $key, $value) 45 | { 46 | switch ($key) { 47 | case 'msgno': 48 | \assert(\is_string($value)); 49 | 50 | return (int) $value; 51 | case 'from': 52 | case 'to': 53 | case 'cc': 54 | case 'bcc': 55 | case 'reply_to': 56 | case 'sender': 57 | case 'return_path': 58 | \assert(\is_array($value)); 59 | foreach ($value as $address) { 60 | if (isset($address->mailbox)) { 61 | $address->host = $address->host ?? null; 62 | $address->personal = isset($address->personal) ? $this->decode($address->personal) : null; 63 | } 64 | } 65 | 66 | return $value; 67 | case 'date': 68 | case 'subject': 69 | \assert(\is_string($value)); 70 | 71 | return $this->decode($value); 72 | } 73 | 74 | return $value; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Message/Parameters.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class Parameters extends \ArrayIterator 11 | { 12 | /** 13 | * @var array 14 | */ 15 | private static array $attachmentCustomKeys = [ 16 | 'name*' => 'name', 17 | 'filename*' => 'filename', 18 | ]; 19 | 20 | /** 21 | * @param \stdClass[] $parameters 22 | */ 23 | public function add(array $parameters = []): void 24 | { 25 | foreach ($parameters as $parameter) { 26 | $key = \strtolower($parameter->attribute); 27 | if (isset(self::$attachmentCustomKeys[$key])) { 28 | $key = self::$attachmentCustomKeys[$key]; 29 | } 30 | $value = $this->decode($parameter->value); 31 | $this[$key] = $value; 32 | } 33 | } 34 | 35 | /** 36 | * @return null|int|\stdClass[]|string 37 | */ 38 | public function get(string $key) 39 | { 40 | return $this[$key] ?? null; 41 | } 42 | 43 | final protected function decode(string $value): string 44 | { 45 | $parts = \imap_mime_header_decode($value); 46 | if (!\is_array($parts)) { 47 | return $value; 48 | } 49 | 50 | $decoded = ''; 51 | foreach ($parts as $part) { 52 | $text = $part->text; 53 | if ('default' !== $part->charset) { 54 | $text = Transcoder::decode($text, $part->charset); 55 | } 56 | // RFC2231 57 | if (1 === \preg_match('/^(?[^\']+)\'[^\']*?\'(?.+)$/', $text, $matches)) { 58 | $hasInvalidChars = 1 === \preg_match('#[^%a-zA-Z0-9\-_\.\+]#', $matches['urltext']); 59 | $hasEscapedChars = 1 === \preg_match('#%[a-zA-Z0-9]{2}#', $matches['urltext']); 60 | if (!$hasInvalidChars && $hasEscapedChars) { 61 | $text = Transcoder::decode(\urldecode($matches['urltext']), $matches['encoding']); 62 | } 63 | } 64 | 65 | $decoded .= $text; 66 | } 67 | 68 | return $decoded; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Message/PartInterface.php: -------------------------------------------------------------------------------- 1 | 'Shift_JIS', 19 | '129' => 'EUC-KR', 20 | '134' => 'GB2312', 21 | '136' => 'Big5', 22 | '161' => 'windows-1253', 23 | '162' => 'windows-1254', 24 | '177' => 'windows-1255', 25 | '178' => 'windows-1256', 26 | '186' => 'windows-1257', 27 | '204' => 'windows-1251', 28 | '222' => 'windows-874', 29 | '238' => 'windows-1250', 30 | '5601' => 'EUC-KR', 31 | '646' => 'us-ascii', 32 | '850' => 'IBM850', 33 | '852' => 'IBM852', 34 | '855' => 'IBM855', 35 | '857' => 'IBM857', 36 | '862' => 'IBM862', 37 | '864' => 'IBM864', 38 | '864i' => 'IBM864i', 39 | '866' => 'IBM866', 40 | 'ansi-1251' => 'windows-1251', 41 | 'ansi_x3.4-1968' => 'us-ascii', 42 | 'arabic' => 'ISO-8859-6', 43 | 'ascii' => 'us-ascii', 44 | 'asmo-708' => 'ISO-8859-6', 45 | 'big5-hkscs' => 'Big5', 46 | 'chinese' => 'GB2312', 47 | 'cn-big5' => 'Big5', 48 | 'cns11643' => 'x-euc-tw', 49 | 'cp-866' => 'IBM866', 50 | 'cp1250' => 'windows-1250', 51 | 'cp1251' => 'windows-1251', 52 | 'cp1252' => 'windows-1252', 53 | 'cp1253' => 'windows-1253', 54 | 'cp1254' => 'windows-1254', 55 | 'cp1255' => 'windows-1255', 56 | 'cp1256' => 'windows-1256', 57 | 'cp1257' => 'windows-1257', 58 | 'cp1258' => 'windows-1258', 59 | 'cp819' => 'ISO-8859-1', 60 | 'cp850' => 'IBM850', 61 | 'cp852' => 'IBM852', 62 | 'cp855' => 'IBM855', 63 | 'cp857' => 'IBM857', 64 | 'cp862' => 'IBM862', 65 | 'cp864' => 'IBM864', 66 | 'cp864i' => 'IBM864i', 67 | 'cp866' => 'IBM866', 68 | 'cp932' => 'Shift_JIS', 69 | 'csbig5' => 'Big5', 70 | 'cseucjpkdfmtjapanese' => 'EUC-JP', 71 | 'cseuckr' => 'EUC-KR', 72 | 'cseucpkdfmtjapanese' => 'EUC-JP', 73 | 'csgb2312' => 'GB2312', 74 | 'csibm850' => 'IBM850', 75 | 'csibm852' => 'IBM852', 76 | 'csibm855' => 'IBM855', 77 | 'csibm857' => 'IBM857', 78 | 'csibm862' => 'IBM862', 79 | 'csibm864' => 'IBM864', 80 | 'csibm864i' => 'IBM864i', 81 | 'csibm866' => 'IBM866', 82 | 'csiso103t618bit' => 'T.61-8bit', 83 | 'csiso111ecmacyrillic' => 'ISO-IR-111', 84 | 'csiso2022jp' => 'ISO-2022-JP', 85 | 'csiso2022jp2' => 'ISO-2022-JP', 86 | 'csiso2022kr' => 'ISO-2022-KR', 87 | 'csiso58gb231280' => 'GB2312', 88 | 'csiso88596e' => 'ISO-8859-6-E', 89 | 'csiso88596i' => 'ISO-8859-6-I', 90 | 'csiso88598e' => 'ISO-8859-8-E', 91 | 'csiso88598i' => 'ISO-8859-8-I', 92 | 'csisolatin1' => 'ISO-8859-1', 93 | 'csisolatin2' => 'ISO-8859-2', 94 | 'csisolatin3' => 'ISO-8859-3', 95 | 'csisolatin4' => 'ISO-8859-4', 96 | 'csisolatin5' => 'ISO-8859-9', 97 | 'csisolatin6' => 'ISO-8859-10', 98 | 'csisolatin9' => 'ISO-8859-15', 99 | 'csisolatinarabic' => 'ISO-8859-6', 100 | 'csisolatincyrillic' => 'ISO-8859-5', 101 | 'csisolatingreek' => 'ISO-8859-7', 102 | 'csisolatinhebrew' => 'ISO-8859-8', 103 | 'cskoi8r' => 'KOI8-R', 104 | 'csksc56011987' => 'EUC-KR', 105 | 'csmacintosh' => 'x-mac-roman', 106 | 'csshiftjis' => 'Shift_JIS', 107 | 'csueckr' => 'EUC-KR', 108 | 'csunicode' => 'UTF-16BE', 109 | 'csunicode11' => 'UTF-16BE', 110 | 'csunicode11utf7' => 'UTF-7', 111 | 'csunicodeascii' => 'UTF-16BE', 112 | 'csunicodelatin1' => 'UTF-16BE', 113 | 'csviqr' => 'VIQR', 114 | 'csviscii' => 'VISCII', 115 | 'cyrillic' => 'ISO-8859-5', 116 | 'dos-874' => 'windows-874', 117 | 'ecma-114' => 'ISO-8859-6', 118 | 'ecma-118' => 'ISO-8859-7', 119 | 'ecma-cyrillic' => 'ISO-IR-111', 120 | 'elot_928' => 'ISO-8859-7', 121 | 'gb_2312' => 'GB2312', 122 | 'gb_2312-80' => 'GB2312', 123 | 'greek' => 'ISO-8859-7', 124 | 'greek8' => 'ISO-8859-7', 125 | 'hebrew' => 'ISO-8859-8', 126 | 'ibm-864' => 'IBM864', 127 | 'ibm-864i' => 'IBM864i', 128 | 'ibm819' => 'ISO-8859-1', 129 | 'ibm874' => 'windows-874', 130 | 'iso-10646' => 'UTF-16BE', 131 | 'iso-10646-j-1' => 'UTF-16BE', 132 | 'iso-10646-ucs-2' => 'UTF-16BE', 133 | 'iso-10646-ucs-4' => 'UTF-32BE', 134 | 'iso-10646-ucs-basic' => 'UTF-16BE', 135 | 'iso-10646-unicode-latin1' => 'UTF-16BE', 136 | 'iso-2022-cn-ext' => 'ISO-2022-CN', 137 | 'iso-2022-jp-2' => 'ISO-2022-JP', 138 | 'iso-8859-8i' => 'ISO-8859-8-I', 139 | 'iso-ir-100' => 'ISO-8859-1', 140 | 'iso-ir-101' => 'ISO-8859-2', 141 | 'iso-ir-103' => 'T.61-8bit', 142 | 'iso-ir-109' => 'ISO-8859-3', 143 | 'iso-ir-110' => 'ISO-8859-4', 144 | 'iso-ir-126' => 'ISO-8859-7', 145 | 'iso-ir-127' => 'ISO-8859-6', 146 | 'iso-ir-138' => 'ISO-8859-8', 147 | 'iso-ir-144' => 'ISO-8859-5', 148 | 'iso-ir-148' => 'ISO-8859-9', 149 | 'iso-ir-149' => 'EUC-KR', 150 | 'iso-ir-157' => 'ISO-8859-10', 151 | 'iso-ir-58' => 'GB2312', 152 | 'iso8859-1' => 'ISO-8859-1', 153 | 'iso8859-10' => 'ISO-8859-10', 154 | 'iso8859-11' => 'ISO-8859-11', 155 | 'iso8859-13' => 'ISO-8859-13', 156 | 'iso8859-14' => 'ISO-8859-14', 157 | 'iso8859-15' => 'ISO-8859-15', 158 | 'iso8859-2' => 'ISO-8859-2', 159 | 'iso8859-3' => 'ISO-8859-3', 160 | 'iso8859-4' => 'ISO-8859-4', 161 | 'iso8859-5' => 'ISO-8859-5', 162 | 'iso8859-6' => 'ISO-8859-6', 163 | 'iso8859-7' => 'ISO-8859-7', 164 | 'iso8859-8' => 'ISO-8859-8', 165 | 'iso8859-9' => 'ISO-8859-9', 166 | 'iso88591' => 'ISO-8859-1', 167 | 'iso885910' => 'ISO-8859-10', 168 | 'iso885911' => 'ISO-8859-11', 169 | 'iso885912' => 'ISO-8859-12', 170 | 'iso885913' => 'ISO-8859-13', 171 | 'iso885914' => 'ISO-8859-14', 172 | 'iso885915' => 'ISO-8859-15', 173 | 'iso88592' => 'ISO-8859-2', 174 | 'iso88593' => 'ISO-8859-3', 175 | 'iso88594' => 'ISO-8859-4', 176 | 'iso88595' => 'ISO-8859-5', 177 | 'iso88596' => 'ISO-8859-6', 178 | 'iso88597' => 'ISO-8859-7', 179 | 'iso88598' => 'ISO-8859-8', 180 | 'iso88599' => 'ISO-8859-9', 181 | 'iso_8859-1' => 'ISO-8859-1', 182 | 'iso_8859-15' => 'ISO-8859-15', 183 | 'iso_8859-1:1987' => 'ISO-8859-1', 184 | 'iso_8859-2' => 'ISO-8859-2', 185 | 'iso_8859-2:1987' => 'ISO-8859-2', 186 | 'iso_8859-3' => 'ISO-8859-3', 187 | 'iso_8859-3:1988' => 'ISO-8859-3', 188 | 'iso_8859-4' => 'ISO-8859-4', 189 | 'iso_8859-4:1988' => 'ISO-8859-4', 190 | 'iso_8859-5' => 'ISO-8859-5', 191 | 'iso_8859-5:1988' => 'ISO-8859-5', 192 | 'iso_8859-6' => 'ISO-8859-6', 193 | 'iso_8859-6:1987' => 'ISO-8859-6', 194 | 'iso_8859-7' => 'ISO-8859-7', 195 | 'iso_8859-7:1987' => 'ISO-8859-7', 196 | 'iso_8859-8' => 'ISO-8859-8', 197 | 'iso_8859-8:1988' => 'ISO-8859-8', 198 | 'iso_8859-9' => 'ISO-8859-9', 199 | 'iso_8859-9:1989' => 'ISO-8859-9', 200 | 'koi' => 'KOI8-R', 201 | 'koi8' => 'KOI8-R', 202 | 'koi8-ru' => 'KOI8-U', 203 | 'koi8_r' => 'KOI8-R', 204 | 'korean' => 'EUC-KR', 205 | 'ks_c_5601-1987' => 'EUC-KR', 206 | 'ks_c_5601-1989' => 'EUC-KR', 207 | 'ksc5601' => 'EUC-KR', 208 | 'ksc_5601' => 'EUC-KR', 209 | 'l1' => 'ISO-8859-1', 210 | 'l2' => 'ISO-8859-2', 211 | 'l3' => 'ISO-8859-3', 212 | 'l4' => 'ISO-8859-4', 213 | 'l5' => 'ISO-8859-9', 214 | 'l6' => 'ISO-8859-10', 215 | 'l9' => 'ISO-8859-15', 216 | 'latin1' => 'ISO-8859-1', 217 | 'latin2' => 'ISO-8859-2', 218 | 'latin3' => 'ISO-8859-3', 219 | 'latin4' => 'ISO-8859-4', 220 | 'latin5' => 'ISO-8859-9', 221 | 'latin6' => 'ISO-8859-10', 222 | 'logical' => 'ISO-8859-8-I', 223 | 'mac' => 'x-mac-roman', 224 | 'macintosh' => 'x-mac-roman', 225 | 'ms932' => 'Shift_JIS', 226 | 'ms_kanji' => 'Shift_JIS', 227 | 'shift-jis' => 'Shift_JIS', 228 | 'sjis' => 'Shift_JIS', 229 | 'sun_eu_greek' => 'ISO-8859-7', 230 | 't.61' => 'T.61-8bit', 231 | 'tis620' => 'TIS-620', 232 | 'unicode-1-1-utf-7' => 'UTF-7', 233 | 'unicode-1-1-utf-8' => 'UTF-8', 234 | 'unicode-2-0-utf-7' => 'UTF-7', 235 | 'visual' => 'ISO-8859-8', 236 | 'windows-31j' => 'Shift_JIS', 237 | 'windows-949' => 'EUC-KR', 238 | 'x-cp1250' => 'windows-1250', 239 | 'x-cp1251' => 'windows-1251', 240 | 'x-cp1252' => 'windows-1252', 241 | 'x-cp1253' => 'windows-1253', 242 | 'x-cp1254' => 'windows-1254', 243 | 'x-cp1255' => 'windows-1255', 244 | 'x-cp1256' => 'windows-1256', 245 | 'x-cp1257' => 'windows-1257', 246 | 'x-cp1258' => 'windows-1258', 247 | 'x-euc-jp' => 'EUC-JP', 248 | 'x-gbk' => 'gbk', 249 | 'x-iso-10646-ucs-2-be' => 'UTF-16BE', 250 | 'x-iso-10646-ucs-2-le' => 'UTF-16LE', 251 | 'x-iso-10646-ucs-4-be' => 'UTF-32BE', 252 | 'x-iso-10646-ucs-4-le' => 'UTF-32LE', 253 | 'x-mac-ce' => 'windows-1250', 254 | 'x-sjis' => 'Shift_JIS', 255 | 'x-unicode-2-0-utf-7' => 'UTF-7', 256 | 'x-x-big5' => 'Big5', 257 | 'zh_cn.euc' => 'GB2312', 258 | 'zh_tw-big5' => 'Big5', 259 | 'zh_tw-euc' => 'x-euc-tw', 260 | ]; 261 | 262 | /** 263 | * Decode text to UTF-8. 264 | * 265 | * @param string $text Text to decode 266 | * @param string $fromCharset Original charset 267 | */ 268 | public static function decode(string $text, string $fromCharset): string 269 | { 270 | static $utf8Aliases = [ 271 | 'unicode-1-1-utf-8' => true, 272 | 'utf8' => true, 273 | 'utf-8' => true, 274 | 'UTF8' => true, 275 | 'UTF-8' => true, 276 | ]; 277 | 278 | if (isset($utf8Aliases[$fromCharset])) { 279 | return $text; 280 | } 281 | 282 | $originalFromCharset = $fromCharset; 283 | $lowercaseFromCharset = \strtolower($fromCharset); 284 | if (isset(self::CHARSET_ALIASES[$lowercaseFromCharset])) { 285 | $fromCharset = self::CHARSET_ALIASES[$lowercaseFromCharset]; 286 | } 287 | 288 | \set_error_handler(static function (): bool { 289 | return true; 290 | }); 291 | 292 | $iconvDecodedText = \iconv($fromCharset, 'UTF-8', $text); 293 | if (false === $iconvDecodedText) { 294 | $iconvDecodedText = \iconv($originalFromCharset, 'UTF-8', $text); 295 | } 296 | 297 | \restore_error_handler(); 298 | 299 | if (false !== $iconvDecodedText) { 300 | return $iconvDecodedText; 301 | } 302 | 303 | $errorMessage = null; 304 | $errorNumber = 0; 305 | \set_error_handler(static function ($nr, $message) use (&$errorMessage, &$errorNumber): bool { 306 | $errorMessage = $message; 307 | $errorNumber = $nr; 308 | 309 | return true; 310 | }); 311 | 312 | $decodedText = ''; 313 | 314 | try { 315 | $decodedText = \mb_convert_encoding($text, 'UTF-8', $fromCharset); 316 | } catch (\Error $error) { 317 | $errorMessage = $error->getMessage(); 318 | } 319 | 320 | \restore_error_handler(); 321 | 322 | if (null !== $errorMessage) { 323 | throw new UnsupportedCharsetException(\sprintf( 324 | 'Unsupported charset "%s"%s: %s', 325 | $originalFromCharset, 326 | ($fromCharset !== $originalFromCharset) ? \sprintf(' (alias found: "%s")', $fromCharset) : '', 327 | $errorMessage 328 | ), $errorNumber); 329 | } 330 | 331 | return $decodedText; 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/MessageInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class MessageIterator extends \ArrayIterator implements MessageIteratorInterface 13 | { 14 | private ImapResourceInterface $resource; 15 | 16 | /** 17 | * Constructor. 18 | * 19 | * @param ImapResourceInterface $resource IMAP resource 20 | * @param int[] $messageNumbers Array of message numbers 21 | */ 22 | public function __construct(ImapResourceInterface $resource, array $messageNumbers) 23 | { 24 | $this->resource = $resource; 25 | 26 | parent::__construct($messageNumbers); 27 | } 28 | 29 | /** 30 | * Get current message. 31 | * 32 | * @return MessageInterface 33 | */ 34 | public function current(): MessageInterface 35 | { 36 | $current = parent::current(); 37 | if (!\is_int($current)) { 38 | throw new Exception\OutOfBoundsException(\sprintf( 39 | 'The current value "%s" isn\'t an integer and doesn\'t represent a message;' 40 | . ' try to cycle this "%s" with a native php function like foreach or with the method getArrayCopy(),' 41 | . ' or check it by calling the methods valid().', 42 | \get_debug_type($current), 43 | self::class 44 | )); 45 | } 46 | 47 | return new Message($this->resource, $current); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/MessageIteratorInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface MessageIteratorInterface extends \Iterator, \Countable 13 | { 14 | /** 15 | * Get current message. 16 | * 17 | * @return MessageInterface 18 | */ 19 | public function current(): MessageInterface; 20 | } 21 | -------------------------------------------------------------------------------- /src/Search/AbstractDate.php: -------------------------------------------------------------------------------- 1 | date = $date; 30 | $this->dateFormat = $dateFormat; 31 | } 32 | 33 | /** 34 | * Converts the condition to a string that can be sent to the IMAP server. 35 | */ 36 | final public function toString(): string 37 | { 38 | return \sprintf('%s "%s"', $this->getKeyword(), $this->date->format($this->dateFormat)); 39 | } 40 | 41 | /** 42 | * Returns the keyword that the condition represents. 43 | */ 44 | abstract protected function getKeyword(): string; 45 | } 46 | -------------------------------------------------------------------------------- /src/Search/AbstractText.php: -------------------------------------------------------------------------------- 1 | text = $text; 26 | } 27 | 28 | /** 29 | * Converts the condition to a string that can be sent to the IMAP server. 30 | */ 31 | final public function toString(): string 32 | { 33 | return \sprintf('%s "%s"', $this->getKeyword(), $this->text); 34 | } 35 | 36 | /** 37 | * Returns the keyword that the condition represents. 38 | */ 39 | abstract protected function getKeyword(): string; 40 | } 41 | -------------------------------------------------------------------------------- /src/Search/ConditionInterface.php: -------------------------------------------------------------------------------- 1 | addCondition($condition); 29 | } 30 | } 31 | 32 | /** 33 | * Adds a new condition to the expression. 34 | * 35 | * @param ConditionInterface $condition the condition to be added 36 | * 37 | * @return void 38 | */ 39 | private function addCondition(ConditionInterface $condition) 40 | { 41 | $this->conditions[] = $condition; 42 | } 43 | 44 | /** 45 | * Returns the keyword that the condition represents. 46 | */ 47 | public function toString(): string 48 | { 49 | $conditions = \array_map(static function (ConditionInterface $condition): string { 50 | return $condition->toString(); 51 | }, $this->conditions); 52 | 53 | return \sprintf('( %s )', \implode(' OR ', $conditions)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Search/RawExpression.php: -------------------------------------------------------------------------------- 1 | expression = $expression; 23 | } 24 | 25 | public function toString(): string 26 | { 27 | return $this->expression; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Search/State/Deleted.php: -------------------------------------------------------------------------------- 1 | conditions[] = $condition; 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Converts the expression to a string that can be sent to the IMAP server. 35 | */ 36 | public function toString(): string 37 | { 38 | $conditions = \array_map(static function (ConditionInterface $condition): string { 39 | return $condition->toString(); 40 | }, $this->conditions); 41 | 42 | return \implode(' ', $conditions); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | hostname = $hostname; 49 | $this->port = $port; 50 | $this->flags = '' !== $flags ? '/' . \ltrim($flags, '/') : ''; 51 | $this->parameters = $parameters; 52 | $this->options = $options; 53 | $this->retries = $retries; 54 | } 55 | 56 | /** 57 | * Authenticate connection. 58 | * 59 | * @param string $username Username 60 | * @param string $password Password 61 | * 62 | * @throws AuthenticationFailedException 63 | */ 64 | public function authenticate(string $username, string $password): ConnectionInterface 65 | { 66 | $errorMessage = null; 67 | $errorNumber = 0; 68 | \set_error_handler(static function ($nr, $message) use (&$errorMessage, &$errorNumber): bool { 69 | $errorMessage = $message; 70 | $errorNumber = $nr; 71 | 72 | return true; 73 | }); 74 | 75 | $resource = \imap_open( 76 | $this->getServerString(), 77 | $username, 78 | $password, 79 | $this->options, 80 | $this->retries, 81 | $this->parameters 82 | ); 83 | 84 | \restore_error_handler(); 85 | 86 | if (false === $resource || null !== $errorMessage) { 87 | throw new AuthenticationFailedException(\sprintf( 88 | 'Authentication failed for user "%s"%s', 89 | $username, 90 | null !== $errorMessage ? ': ' . $errorMessage : '' 91 | ), $errorNumber); 92 | } 93 | 94 | $check = \imap_check($resource); 95 | 96 | if (false === $check) { 97 | throw new ResourceCheckFailureException('Resource check failure'); 98 | } 99 | 100 | $mailbox = $check->Mailbox; 101 | $connection = $mailbox; 102 | $curlyPosition = \strpos($mailbox, '}'); 103 | if (false !== $curlyPosition) { 104 | $connection = \substr($mailbox, 0, $curlyPosition + 1); 105 | } 106 | 107 | // These are necessary to get rid of PHP throwing IMAP errors 108 | \imap_errors(); 109 | \imap_alerts(); 110 | 111 | return new Connection(new ImapResource($resource), $connection); 112 | } 113 | 114 | /** 115 | * Glues hostname, port and flags and returns result. 116 | */ 117 | private function getServerString(): string 118 | { 119 | return \sprintf( 120 | '{%s%s%s}', 121 | $this->hostname, 122 | '' !== $this->port ? ':' . $this->port : '', 123 | $this->flags 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/ServerInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class RawMessageIterator extends \ArrayIterator implements MessageIteratorInterface 17 | { 18 | /** 19 | * @return MessageInterface 20 | */ 21 | public function current(): MessageInterface 22 | { 23 | return parent::current(); 24 | } 25 | } 26 | --------------------------------------------------------------------------------