open(); $stream->feed($responses); return new static($stream); } /** * Tear down the connection. */ public function __destruct() { if (! $this->connected()) { return; } try { @$this->logout(); } catch (Exception $e) { // Do nothing. } } /** * {@inheritDoc} */ public function connect(string $host, ?int $port = null, array $options = []): void { $transport = strtolower($options['encryption'] ?? '') ?: 'tcp'; if (in_array($transport, ['ssl', 'tls'])) { $port ??= 993; } else { $port ??= 143; } $this->setParser( $this->newParser($this->stream) ); $this->stream->open( $transport === 'starttls' ? 'tcp' : $transport, $host, $port, $options['timeout'] ?? 30, $this->getDefaultSocketOptions( $transport, $options['proxy'] ?? [], $options['validate_cert'] ?? true ) ); $this->assertNextResponse( fn (Response $response) => $response instanceof UntaggedResponse, fn (UntaggedResponse $response) => $response->type()->is('OK'), fn () => new ImapConnectionFailedException("Connection to $host:$port failed") ); if ($transport === 'starttls') { $this->startTls(); } } /** * Get the default socket options for the given transport. * * @param 'ssl'|'tls'|'starttls'|'tcp' $transport */ protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array { $options = []; $key = match ($transport) { 'ssl', 'tls' => 'ssl', 'starttls', 'tcp' => 'tcp', }; if (in_array($transport, ['ssl', 'tls'])) { $options[$key] = [ 'verify_peer' => $validateCert, 'verify_peer_name' => $validateCert, ]; } if (! isset($proxy['socket'])) { return $options; } $options[$key]['proxy'] = $proxy['socket']; $options[$key]['request_fulluri'] = $proxy['request_fulluri'] ?? false; if (isset($proxy['username'])) { $auth = base64_encode($proxy['username'].':'.$proxy['password']); $options[$key]['header'] = ["Proxy-Authorization: Basic $auth"]; } return $options; } /** * {@inheritDoc} */ public function disconnect(): void { $this->stream->close(); } /** * {@inheritDoc} */ public function connected(): bool { return $this->stream->opened(); } /** * {@inheritDoc} */ public function login(string $user, string $password): TaggedResponse { $this->send('LOGIN', Str::literal([$user, $password]), $tag); return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => ( ImapCommandException::make($this->result->command()->redacted(), $response) )); } /** * {@inheritDoc} */ public function logout(): void { $this->send('LOGOUT', tag: $tag); } /** * {@inheritDoc} */ public function authenticate(string $user, string $token): TaggedResponse { $this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag); return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => ( ImapCommandException::make($this->result->command()->redacted(), $response) )); } /** * {@inheritDoc} */ public function startTls(): void { $this->send('STARTTLS', tag: $tag); $this->assertTaggedResponse($tag); $this->stream->setSocketSetCrypto(true, STREAM_CRYPTO_METHOD_TLS_CLIENT); } /** * {@inheritDoc} */ public function select(string $folder = 'INBOX'): ResponseCollection { return $this->examineOrSelect('SELECT', $folder); } /** * {@inheritDoc} */ public function examine(string $folder = 'INBOX'): ResponseCollection { return $this->examineOrSelect('EXAMINE', $folder); } /** * Examine and select have the same response. */ protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): ResponseCollection { $this->send($command, [Str::literal($folder)], $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged(); } /** * {@inheritDoc} */ public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse { $this->send('STATUS', [ Str::literal($folder), Str::list($arguments), ], $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstWhere( fn (UntaggedResponse $response) => $response->type()->is('STATUS') ); } /** * {@inheritDoc} */ public function create(string $folder): ResponseCollection { $this->send('CREATE', [Str::literal($folder)], $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->filter( fn (UntaggedResponse $response) => $response->type()->is('LIST') ); } /** * {@inheritDoc} */ public function delete(string $folder): TaggedResponse { $this->send('DELETE', [Str::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function rename(string $oldPath, string $newPath): TaggedResponse { $this->send('RENAME', Str::literal([$oldPath, $newPath]), tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function subscribe(string $folder): TaggedResponse { $this->send('SUBSCRIBE', [Str::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function unsubscribe(string $folder): TaggedResponse { $this->send('UNSUBSCRIBE', [Str::literal($folder)], tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function quota(string $root): UntaggedResponse { $this->send('GETQUOTA', [Str::literal($root)], tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstOrFail( fn (UntaggedResponse $response) => $response->type()->is('QUOTA') ); } /** * {@inheritDoc} */ public function quotaRoot(string $mailbox): ResponseCollection { $this->send('GETQUOTAROOT', [Str::literal($mailbox)], tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->filter( fn (UntaggedResponse $response) => $response->type()->is('QUOTA') ); } /** * {@inheritDoc} */ public function list(string $reference = '', string $folder = '*'): ResponseCollection { $this->send('LIST', Str::literal([$reference, $folder]), $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->filter( fn (UntaggedResponse $response) => $response->type()->is('LIST') ); } /** * {@inheritDoc} */ public function append(string $folder, string $message, ?array $flags = null): TaggedResponse { $tokens = []; $tokens[] = Str::literal($folder); if ($flags) { $tokens[] = Str::list($flags); } $tokens[] = Str::literal($message); $this->send('APPEND', $tokens, tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse { $this->send('UID COPY', [ Str::set($from, $to), Str::literal($folder), ], $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse { $this->send('UID MOVE', [ Str::set($from, $to), Str::literal($folder), ], $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection { $set = Str::set($from, $to); $flags = Str::list((array) $flags); $item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : ''); $this->send('UID STORE', [$set, $item, $flags], tag: $tag); $this->assertTaggedResponse($tag); return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter( fn (UntaggedResponse $response) => $response->type()->is('FETCH') ); } /** * {@inheritDoc} */ public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection { return $this->fetch(['UID'], (array) $ids, null, $identifier); } /** * {@inheritDoc} */ public function bodyText(int|array $ids, bool $peek = true): ResponseCollection { return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids); } /** * {@inheritDoc} */ public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection { return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids); } /** * Fetch the BODYSTRUCTURE for the given message(s). */ public function bodyStructure(int|array $ids): ResponseCollection { return $this->fetch(['BODYSTRUCTURE'], (array) $ids); } /** * Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc. */ public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection { $part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]"; return $this->fetch([$part], (array) $ids); } /** * {@inheritDoc} */ public function flags(int|array $ids): ResponseCollection { return $this->fetch(['FLAGS'], (array) $ids); } /** * {@inheritDoc} */ public function size(int|array $ids): ResponseCollection { return $this->fetch(['RFC822.SIZE'], (array) $ids); } /** * {@inheritDoc} */ public function search(array $params): UntaggedResponse { $this->send('UID SEARCH', $params, tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstOrFail( fn (UntaggedResponse $response) => $response->type()->is('SEARCH') ); } /** * {@inheritDoc} */ public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse { $sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value; $this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstOrFail( fn (UntaggedResponse $response) => $response->type()->is('SORT') ); } /** * {@inheritDoc} */ public function capability(): UntaggedResponse { $this->send('CAPABILITY', tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstOrFail( fn (UntaggedResponse $response) => $response->type()->is('CAPABILITY') ); } /** * {@inheritDoc} */ public function id(?array $ids = null): UntaggedResponse { $token = 'NIL'; if (is_array($ids) && ! empty($ids)) { $token = '('; foreach ($ids as $id) { $token .= '"'.Str::escape($id).'" '; } $token = rtrim($token).')'; } $this->send('ID', [$token], tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged()->firstOrFail( fn (UntaggedResponse $response) => $response->type()->is('ID') ); } /** * {@inheritDoc} */ public function expunge(): ResponseCollection { $this->send('EXPUNGE', tag: $tag); $this->assertTaggedResponse($tag); return $this->result->responses()->untagged(); } /** * {@inheritDoc} */ public function noop(): TaggedResponse { $this->send('NOOP', tag: $tag); return $this->assertTaggedResponse($tag); } /** * {@inheritDoc} */ public function idle(int $timeout): Generator { $this->stream->setTimeout($timeout); $this->send('IDLE', tag: $tag); $this->assertNextResponse( fn (Response $response) => $response instanceof ContinuationResponse, fn (ContinuationResponse $response) => true, fn (ContinuationResponse $response) => ImapCommandException::make(new ImapCommand('', 'IDLE'), $response), ); while ($response = $this->nextReply()) { yield $response; } } /** * {@inheritDoc} */ public function done(): void { $this->write('DONE'); // After issuing a "DONE" command, the server must eventually respond with a // tagged response to indicate that the IDLE command has been successfully // terminated and the server is ready to accept further commands. $this->assertNextResponse( fn (Response $response) => $response instanceof TaggedResponse, fn (TaggedResponse $response) => $response->successful(), fn (TaggedResponse $response) => ImapCommandException::make(new ImapCommand('', 'DONE'), $response), ); } /** * Send an IMAP command. * * @param-out string $tag */ public function send(string $name, array $tokens = [], ?string &$tag = null): void { if (! $tag) { $this->sequence++; $tag = 'TAG'.$this->sequence; } $command = new ImapCommand($tag, $name, $tokens); // After every command, we'll overwrite any previous result // with the new command and its responses, so that we can // easily access the commands responses for assertion. $this->setResult(new Result($command)); foreach ($command->compile() as $line) { $this->write($line); } } /** * Write data to the connected stream. */ protected function write(string $data): void { if ($this->stream->fwrite($data."\r\n") === false) { throw new ImapStreamException('Failed to write data to stream'); } $this->logger?->sent($data); } /** * Fetch one or more items for one or more messages. */ public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection { $prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : ''; $this->send(trim($prefix.' FETCH'), [ Str::set($from, $to), Str::list((array) $items), ], $tag); $this->assertTaggedResponse($tag); // Some IMAP servers can send unsolicited untagged responses along with fetch // requests. We'll need to filter these out so that we can return only the // responses that are relevant to the fetch command. For example: // >> TAG123 FETCH (UID 456 BODY[TEXT]) // << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!) // << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) { // Skip over any untagged responses that are not FETCH responses. // The third token should always be the list of data items. if (! ($data = $response->tokenAt(3)) instanceof ListData) { return false; } return match ($identifier) { // If we're fetching UIDs, we can check if a UID token is contained in the list. ImapFetchIdentifier::Uid => $data->contains('UID'), // If we're fetching message numbers, we can check if the requested items are all contained in the list. ImapFetchIdentifier::MessageNumber => $data->contains($items), }; }); } /** * Set the current result instance. */ protected function setResult(Result $result): void { $this->result = $result; } /** * Set the current parser instance. */ protected function setParser(ImapParser $parser): void { $this->parser = $parser; } /** * Create a new parser instance. */ protected function newParser(StreamInterface $stream): ImapParser { return new ImapParser($this->newTokenizer($stream)); } /** * Create a new tokenizer instance. */ protected function newTokenizer(StreamInterface $stream): ImapTokenizer { return new ImapTokenizer($stream); } /** * Assert the next response is a successful tagged response. */ protected function assertTaggedResponse(string $tag, ?callable $exception = null): TaggedResponse { /** @var TaggedResponse $response */ $response = $this->assertNextResponse( fn (Response $response) => ( $response instanceof TaggedResponse && $response->tag()->is($tag) ), fn (TaggedResponse $response) => ( $response->successful() ), $exception ?? fn (TaggedResponse $response) => ( ImapCommandException::make($this->result->command(), $response) ), ); return $response; } /** * Assert the next response matches the given filter and assertion. * * @template T of Response * * @param callable(Response): bool $filter * @param callable(T): bool $assertion * @param callable(T): Throwable $exception * @return T * * @throws ImapResponseException */ protected function assertNextResponse(callable $filter, callable $assertion, callable $exception): Response { while ($response = $this->nextResponse($filter)) { if ($assertion($response)) { return $response; } throw $exception($response); } throw new ImapResponseException('No matching response found'); } /** * Returns the next response matching the given filter. * * @template T of Response * * @param callable(T): bool $filter * @return T|null */ protected function nextResponse(callable $filter): ?Response { if (! $this->parser) { throw new LogicException('No parser instance set'); } while ($response = $this->nextReply()) { if (! $response instanceof Response) { continue; } $this->result?->addResponse($response); if ($filter($response)) { return $response; } } return null; } /** * Read the next reply from the stream. */ protected function nextReply(): Data|Token|Response|null { if (! $reply = $this->parser->next()) { $meta = $this->stream->meta(); throw match (true) { $meta['timed_out'] ?? false => new ImapConnectionTimedOutException('Stream timed out, no response'), $meta['eof'] ?? false => new ImapConnectionClosedException('Server closed the connection (EOF)'), default => new ImapConnectionFailedException('Unknown stream error. Metadata: '.json_encode($meta)), }; } $this->logger?->received($reply); return $reply; } }