|
3 | 3 | namespace DirectoryTree\ImapEngine\Connection; |
4 | 4 |
|
5 | 5 | use DirectoryTree\ImapEngine\Collections\ResponseCollection; |
| 6 | +use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface; |
6 | 7 | use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse; |
7 | 8 | use DirectoryTree\ImapEngine\Connection\Responses\Response; |
8 | 9 | use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse; |
9 | 10 | use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; |
| 11 | +use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface; |
10 | 12 | use DirectoryTree\ImapEngine\Exceptions\CommandFailedException; |
| 13 | +use DirectoryTree\ImapEngine\Exceptions\ConnectionClosedException; |
11 | 14 | use DirectoryTree\ImapEngine\Exceptions\ConnectionFailedException; |
| 15 | +use DirectoryTree\ImapEngine\Exceptions\ConnectionTimedOutException; |
12 | 16 | use DirectoryTree\ImapEngine\Exceptions\Exception; |
| 17 | +use DirectoryTree\ImapEngine\Exceptions\RuntimeException; |
13 | 18 | use DirectoryTree\ImapEngine\Support\Str; |
14 | 19 |
|
15 | | -class ImapConnection extends Connection |
| 20 | +class ImapConnection implements ConnectionInterface |
16 | 21 | { |
| 22 | + use ParsesResponses; |
| 23 | + |
17 | 24 | /** |
18 | | - * The current request sequence. |
| 25 | + * Sequence number used to generate unique command tags. |
19 | 26 | */ |
20 | 27 | protected int $sequence = 0; |
21 | 28 |
|
| 29 | + /** |
| 30 | + * Constructor. |
| 31 | + */ |
| 32 | + public function __construct( |
| 33 | + protected StreamInterface $stream, |
| 34 | + protected ?LoggerInterface $logger = null, |
| 35 | + ) {} |
| 36 | + |
| 37 | + /** |
| 38 | + * Tear down the connection. |
| 39 | + */ |
| 40 | + public function __destruct() |
| 41 | + { |
| 42 | + $this->logout(); |
| 43 | + } |
| 44 | + |
22 | 45 | /** |
23 | 46 | * {@inheritDoc} |
24 | 47 | */ |
25 | | - public function login(string $user, string $password): TaggedResponse |
| 48 | + public function connect(string $host, ?int $port = null, array $options = []): void |
26 | 49 | { |
27 | | - $this->send('LOGIN', Str::literal([$user, $password]), $tag); |
| 50 | + $transport = strtolower($options['encryption'] ?? 'tcp'); |
28 | 51 |
|
29 | | - return $this->assertTaggedResponse($tag); |
| 52 | + if (in_array($transport, ['ssl', 'tls'])) { |
| 53 | + $port ??= 993; |
| 54 | + } else { |
| 55 | + $port ??= 143; |
| 56 | + } |
| 57 | + |
| 58 | + $this->setParser( |
| 59 | + $this->newParser($this->stream) |
| 60 | + ); |
| 61 | + |
| 62 | + $this->stream->open( |
| 63 | + $transport, |
| 64 | + $host, |
| 65 | + $port, |
| 66 | + $options['timeout'] ?? 30, |
| 67 | + $this->getDefaultSocketOptions( |
| 68 | + $transport, |
| 69 | + $options['proxy'] ?? [], |
| 70 | + $options['validate_cert'] ?? true |
| 71 | + ) |
| 72 | + ); |
| 73 | + |
| 74 | + $this->assertNextResponse( |
| 75 | + fn (Response $response) => $response instanceof UntaggedResponse, |
| 76 | + fn (UntaggedResponse $response) => $response->type()->is('OK'), |
| 77 | + fn () => new ConnectionFailedException("Connection to $host:$port failed") |
| 78 | + ); |
| 79 | + |
| 80 | + if ($transport === 'starttls') { |
| 81 | + $this->startTls(); |
| 82 | + } |
30 | 83 | } |
31 | 84 |
|
32 | 85 | /** |
33 | | - * {@inheritDoc} |
| 86 | + * Get the default socket options for the given transport. |
34 | 87 | */ |
35 | | - public function authenticate(string $user, string $token): TaggedResponse |
| 88 | + protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array |
36 | 89 | { |
37 | | - $credentials = base64_encode("user=$user\1auth=Bearer $token\1\1"); |
| 90 | + $options = []; |
38 | 91 |
|
39 | | - $this->send('AUTHENTICATE', ['XOAUTH2', $credentials], $tag); |
| 92 | + if (in_array($transport, ['ssl', 'tls'])) { |
| 93 | + $options['ssl'] = [ |
| 94 | + 'verify_peer' => $validateCert, |
| 95 | + 'verify_peer_name' => $validateCert, |
| 96 | + ]; |
| 97 | + } |
40 | 98 |
|
41 | | - return $this->assertTaggedResponse($tag); |
| 99 | + if (! isset($proxy['socket'])) { |
| 100 | + return $options; |
| 101 | + } |
| 102 | + |
| 103 | + $options[$transport]['proxy'] = $proxy['socket']; |
| 104 | + $options[$transport]['request_fulluri'] = $proxy['request_fulluri'] ?? false; |
| 105 | + |
| 106 | + if (isset($proxy['username'])) { |
| 107 | + $auth = base64_encode($proxy['username'].':'.$proxy['password']); |
| 108 | + |
| 109 | + $options[$transport]['header'] = ["Proxy-Authorization: Basic $auth"]; |
| 110 | + } |
| 111 | + |
| 112 | + return $options; |
42 | 113 | } |
43 | 114 |
|
44 | 115 | /** |
45 | 116 | * {@inheritDoc} |
46 | 117 | */ |
47 | | - public function startTls(): void |
| 118 | + public function disconnect(): void |
48 | 119 | { |
49 | | - $this->send('STARTTLS', tag: $tag); |
50 | | - |
51 | | - $this->assertTaggedResponse($tag, fn () => ( |
52 | | - new ConnectionFailedException('Failed to enable STARTTLS') |
53 | | - )); |
| 120 | + $this->stream->close(); |
| 121 | + } |
54 | 122 |
|
55 | | - $this->stream->setSocketSetCrypto(true, $this->getCryptoMethod()); |
| 123 | + /** |
| 124 | + * Check if the current stream is open. |
| 125 | + */ |
| 126 | + public function connected(): bool |
| 127 | + { |
| 128 | + return $this->stream->isOpen(); |
56 | 129 | } |
57 | 130 |
|
58 | 131 | /** |
59 | 132 | * {@inheritDoc} |
60 | 133 | */ |
61 | | - public function logout(): ?TaggedResponse |
| 134 | + public function login(string $user, string $password): TaggedResponse |
62 | 135 | { |
63 | | - if (! $this->stream->isOpen() || ($this->meta()['timed_out'] ?? false)) { |
64 | | - $this->close(); |
| 136 | + $this->send('LOGIN', Str::literal([$user, $password]), $tag); |
65 | 137 |
|
66 | | - return null; |
67 | | - } |
| 138 | + return $this->assertTaggedResponse($tag); |
| 139 | + } |
68 | 140 |
|
| 141 | + /** |
| 142 | + * {@inheritDoc} |
| 143 | + */ |
| 144 | + public function logout(): void |
| 145 | + { |
69 | 146 | try { |
| 147 | + // It's generally acceptable to send a logout command to an IMAP server |
| 148 | + // and not wait for a response. If the server encounters an error |
| 149 | + // processing the request, we will have to reconnect anyway. |
70 | 150 | $this->send('LOGOUT', tag: $tag); |
71 | 151 | } catch (Exception) { |
72 | 152 | // Do nothing. |
73 | 153 | } |
| 154 | + } |
74 | 155 |
|
75 | | - $this->close(); |
| 156 | + /** |
| 157 | + * {@inheritDoc} |
| 158 | + */ |
| 159 | + public function authenticate(string $user, string $token): TaggedResponse |
| 160 | + { |
| 161 | + $credentials = base64_encode("user=$user\1auth=Bearer $token\1\1"); |
| 162 | + |
| 163 | + $this->send('AUTHENTICATE', ['XOAUTH2', $credentials], $tag); |
76 | 164 |
|
77 | | - return null; |
| 165 | + return $this->assertTaggedResponse($tag); |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * {@inheritDoc} |
| 170 | + */ |
| 171 | + public function startTls(): void |
| 172 | + { |
| 173 | + $this->send('STARTTLS', tag: $tag); |
| 174 | + |
| 175 | + $this->assertTaggedResponse($tag, fn () => ( |
| 176 | + new ConnectionFailedException('Failed to enable STARTTLS') |
| 177 | + )); |
| 178 | + |
| 179 | + $this->stream->setSocketSetCrypto(true, match (true) { |
| 180 | + defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT') => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, |
| 181 | + defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT') => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT, |
| 182 | + default => STREAM_CRYPTO_METHOD_TLS_CLIENT, |
| 183 | + }); |
78 | 184 | } |
79 | 185 |
|
80 | 186 | /** |
@@ -409,4 +515,108 @@ public function done(): void |
409 | 515 | fn (TaggedResponse $response) => CommandFailedException::make(new ImapCommand('', 'DONE'), $response), |
410 | 516 | ); |
411 | 517 | } |
| 518 | + |
| 519 | + /** |
| 520 | + * Set the stream timeout. |
| 521 | + */ |
| 522 | + public function setStreamTimeout(int $streamTimeout): Connection |
| 523 | + { |
| 524 | + if (! $this->stream->setTimeout($streamTimeout)) { |
| 525 | + throw new ConnectionFailedException('Failed to set stream timeout'); |
| 526 | + } |
| 527 | + |
| 528 | + return $this; |
| 529 | + } |
| 530 | + |
| 531 | + /** |
| 532 | + * Read the next reply from the stream. |
| 533 | + */ |
| 534 | + public function nextReply(): Response |
| 535 | + { |
| 536 | + if (! $this->parser) { |
| 537 | + throw new RuntimeException('Connection must be opened before reading replies.'); |
| 538 | + } |
| 539 | + |
| 540 | + if (! $reply = $this->parser->next()) { |
| 541 | + $meta = $this->stream->meta(); |
| 542 | + |
| 543 | + throw match (true) { |
| 544 | + $meta['timed_out'] ?? false => new ConnectionTimedOutException('Stream timed out, no response'), |
| 545 | + $meta['eof'] ?? false => new ConnectionClosedException('Server closed the connection (EOF)'), |
| 546 | + default => new RuntimeException('Unknown read error, no response: '.json_encode($meta)), |
| 547 | + }; |
| 548 | + } |
| 549 | + |
| 550 | + $this->logger?->received($reply); |
| 551 | + |
| 552 | + return $reply; |
| 553 | + } |
| 554 | + |
| 555 | + /** |
| 556 | + * Send an IMAP command. |
| 557 | + */ |
| 558 | + public function send(string $name, array $tokens = [], ?string &$tag = null): void |
| 559 | + { |
| 560 | + if (! $tag) { |
| 561 | + $this->sequence++; |
| 562 | + $tag = 'TAG'.$this->sequence; |
| 563 | + } |
| 564 | + |
| 565 | + $command = new ImapCommand($tag, $name, $tokens); |
| 566 | + |
| 567 | + // After every command, we'll overwrite any previous result |
| 568 | + // with the new command and its responses, so that we can |
| 569 | + // easily access the commands responses for assertion. |
| 570 | + $this->setResult(new Result($command)); |
| 571 | + |
| 572 | + foreach ($command->compile() as $line) { |
| 573 | + $this->write($line); |
| 574 | + } |
| 575 | + } |
| 576 | + |
| 577 | + /** |
| 578 | + * Write data to the connected stream. |
| 579 | + */ |
| 580 | + protected function write(string $data): void |
| 581 | + { |
| 582 | + $command = $data."\r\n"; |
| 583 | + |
| 584 | + $this->logger?->sent($command); |
| 585 | + |
| 586 | + if ($this->stream->fwrite($command) === false) { |
| 587 | + throw new RuntimeException('Failed to write data to stream.'); |
| 588 | + } |
| 589 | + } |
| 590 | + |
| 591 | + /** |
| 592 | + * Fetch one or more items for one or more messages. |
| 593 | + */ |
| 594 | + public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection |
| 595 | + { |
| 596 | + $prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : ''; |
| 597 | + |
| 598 | + $this->send(trim($prefix.' FETCH'), [ |
| 599 | + Str::set($from, $to), |
| 600 | + Str::list((array) $items), |
| 601 | + ], $tag); |
| 602 | + |
| 603 | + $this->assertTaggedResponse($tag); |
| 604 | + |
| 605 | + // Some IMAP servers can send unsolicited untagged responses along with fetch |
| 606 | + // requests. We'll need to filter these out so that we can return only the |
| 607 | + // responses that are relevant to the fetch command. For example: |
| 608 | + // TAG123 FETCH (UID 456 BODY[TEXT]) |
| 609 | + // * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!) |
| 610 | + // * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response |
| 611 | + return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) { |
| 612 | + // The third token will always be a list of data items. |
| 613 | + return match ($identifier) { |
| 614 | + // If we're fetching UIDs, we can check if a UID token is contained in the list. |
| 615 | + ImapFetchIdentifier::Uid => $response->tokenAt(3)->contains('UID'), |
| 616 | + |
| 617 | + // If we're fetching message numbers, we can check if the requested items are all contained in the list. |
| 618 | + ImapFetchIdentifier::MessageNumber => $response->tokenAt(3)->contains($items), |
| 619 | + }; |
| 620 | + }); |
| 621 | + } |
412 | 622 | } |
0 commit comments