<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Support\Str;

class FolderRepository implements FolderRepositoryInterface
{
    public function __construct(
        protected Mailbox $mailbox
    ) {}
    public function find(string $path): ?FolderInterface
    {
        return $this->get($path)->first();
    }
    public function findOrFail(string $path): FolderInterface
    {
        return $this->get($path)->firstOrFail();
    }
    public function create(string $path): FolderInterface
    {
        $this->mailbox->connection()->create(
            Str::toImapUtf7($path)
        );
        return $this->find($path);
    }
    public function firstOrCreate(string $path): FolderInterface
    {
        return $this->find($path) ?? $this->create($path);
    }
    public function get(?string $match = '*', ?string $reference = ''): FolderCollection
    {
        return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map(
            fn (UntaggedResponse $response) => new Folder(
                mailbox: $this->mailbox,
                path: $response->tokenAt(4)->value,
                flags: $response->tokenAt(2)->values(),
                delimiter: $response->tokenAt(3)->value,
            )
        )->pipeInto(FolderCollection::class);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Testing;

use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\ItemNotFoundException;

class FakeFolderRepository implements FolderRepositoryInterface
{
    /**
     * Constructor.
     */
    public function __construct(
        protected MailboxInterface $mailbox,
        /** @var FolderInterface[] */
        protected array $folders = []
    ) {}

    /**
     * {@inheritDoc}
     */
    public function find(string $path): ?FolderInterface
    {
        try {
            return $this->findOrFail($path);
        } catch (ItemNotFoundException) {
            return null;
        }
    }

    /**
     * {@inheritDoc}
     */
    public function findOrFail(string $path): FolderInterface
    {
        return $this->get()->firstOrFail(
            fn (FolderInterface $folder) => strtolower($folder->path()) === strtolower($path)
        );
    }

    /**
     * {@inheritDoc}
     */
    public function create(string $path): FolderInterface
    {
        return $this->folders[] = new FakeFolder($path, mailbox: $this->mailbox);
    }

    /**
     * {@inheritDoc}
     */
    public function firstOrCreate(string $path): FolderInterface
    {
        return $this->find($path) ?? $this->create($path);
    }

    /**
     * {@inheritDoc}
     */
    public function get(?string $match = '*', ?string $reference = ''): FolderCollection
    {
        $folders = FolderCollection::make($this->folders);

        // If we're not matching all, filter the folders by the match pattern.
        if (! in_array($match, ['*', null])) {
            return $folders->filter(
                fn (FolderInterface $folder) => Str::is($match, $folder->path())
            );
        }

        return $folders;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\ItemNotFoundException;
use JsonSerializable;

class Folder implements Arrayable, FolderInterface, JsonSerializable
{
    use ComparesFolders;

    /**
     * Constructor.
     */
    public function __construct(
        protected Mailbox $mailbox,
        protected string $path,
        protected array $flags = [],
        protected string $delimiter = '/',
    ) {}

    /**
     * Get the folder's mailbox.
     */
    public function mailbox(): Mailbox
    {
        return $this->mailbox;
    }

    /**
     * Get the folder path.
     */
    public function path(): string
    {
        return $this->path;
    }

    /**
     * Get the folder flags.
     *
     * @return string[]
     */
    public function flags(): array
    {
        return $this->flags;
    }

    /**
     * {@inheritDoc}
     */
    public function delimiter(): string
    {
        return $this->delimiter;
    }

    /**
     * {@inheritDoc}
     */
    public function name(): string
    {
        return Str::fromImapUtf7(
            last(explode($this->delimiter, $this->path))
        );
    }

    /**
     * {@inheritDoc}
     */
    public function is(FolderInterface $folder): bool
    {
        return $this->isSameFolder($this, $folder);
    }

    /**
     * {@inheritDoc}
     */
    public function messages(): MessageQuery
    {
        // Ensure the folder is selected.
        $this->select(true);

        return new MessageQuery($this, new ImapQueryBuilder);
    }

    /**
     * {@inheritDoc}
     */
    public function idle(callable $callback, ?callable $query = null, int $timeout = 300): void
    {
        if (! in_array('IDLE', $this->mailbox->capabilities())) {
            throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.');
        }

        // The message query to use when fetching messages.
        $query ??= fn (MessageQuery $query) => $query;

        // Fetch the message by message number.
        $fetch = fn (int $msgn) => (
            $query($this->messages())->findOrFail($msgn, ImapFetchIdentifier::MessageNumber)
        );

        (new Idle(clone $this->mailbox, $this->path, $timeout))->await(
            function (int $msgn) use ($callback, $fetch) {
                if (! $this->mailbox->connected()) {
                    $this->mailbox->connect();
                }

                try {
                    $message = $fetch($msgn);
                } catch (ItemNotFoundException) {
                    // The message wasn't found. We will skip
                    // it and continue awaiting new messages.
                    return;
                } catch (Exception) {
                    // Something else happened. We will attempt
                    // reconnecting and re-fetching the message.
                    $this->mailbox->reconnect();

                    $message = $fetch($msgn);
                }

                $callback($message);
            }
        );
    }

    /**
     * {@inheritDoc}
     */
    public function move(string $newPath): void
    {
        $this->mailbox->connection()->rename($this->path, $newPath);

        $this->path = $newPath;
    }

    /**
     * {@inheritDoc}
     */
    public function select(bool $force = false): void
    {
        $this->mailbox->select($this, $force);
    }

    /**
     * {@inheritDoc}
     */
    public function status(): array
    {
        $response = $this->mailbox->connection()->status($this->path);

        $tokens = $response->tokenAt(3)->tokens();

        $values = [];

        // Tokens are expected to alternate between keys and values.
        for ($i = 0; $i < count($tokens); $i += 2) {
            $values[$tokens[$i]->value] = $tokens[$i + 1]->value;
        }

        return $values;
    }

    /**
     * {@inheritDoc}
     */
    public function examine(): array
    {
        return $this->mailbox->connection()->examine($this->path)->map(
            fn (UntaggedResponse $response) => $response->toArray()
        )->all();
    }

    /**
     * {@inheritDoc}
     */
    public function expunge(): array
    {
        return $this->mailbox->connection()->expunge()->map(
            fn (UntaggedResponse $response) => $response->tokenAt(1)->value
        )->all();
    }

    /**
     * {@inheritDoc}
     */
    public function delete(): void
    {
        $this->mailbox->connection()->delete($this->path);
    }

    /**
     * Get the array representation of the folder.
     */
    public function toArray(): array
    {
        return [
            'path' => $this->path,
            'flags' => $this->flags,
            'delimiter' => $this->delimiter,
        ];
    }

    /**
     * Get the JSON representation of the folder.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\ItemNotFoundException;

/**
 * @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
 */
class MessageQuery implements MessageQueryInterface
{
    use QueriesMessages;

    /**
     * Constructor.
     */
    public function __construct(
        protected FolderInterface $folder,
        protected ImapQueryBuilder $query,
    ) {}

    /**
     * Count all available messages matching the current search criteria.
     */
    public function count(): int
    {
        return $this->search()->count();
    }

    /**
     * Get the first message in the resulting collection.
     *
     * @return Message|null
     */
    public function first(): ?MessageInterface
    {
        try {
            return $this->firstOrFail();
        } catch (ItemNotFoundException) {
            return null;
        }
    }

    /**
     * Get the first message in the resulting collection or throw an exception.
     *
     * @return Message
     */
    public function firstOrFail(): MessageInterface
    {
        return $this->limit(1)->get()->firstOrFail();
    }

    /**
     * Get the messages matching the current query.
     */
    public function get(): MessageCollection
    {
        return $this->process($this->search());
    }

    /**
     * Append a new message to the folder.
     */
    public function append(string $message, mixed $flags = null): int
    {
        $response = $this->connection()->append(
            $this->folder->path(), $message, Str::enums($flags),
        );

        return $response // TAG4 OK [APPENDUID <uidvalidity> <uid>] APPEND completed.
            ->tokenAt(2) // [APPENDUID <uidvalidity> <uid>]
            ->tokenAt(2) // <uid>
            ->value;
    }

    /**
     * Execute a callback over each message via a chunked query.
     */
    public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
    {
        $this->chunk(function (MessageCollection $messages) use ($callback) {
            foreach ($messages as $key => $message) {
                if ($callback($message, $key) === false) {
                    return false;
                }
            }
        }, $chunkSize, $startChunk);
    }

    /**
     * Execute a callback over each chunk of messages.
     */
    public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
    {
        $startChunk = max($startChunk, 1);
        $chunkSize = max($chunkSize, 1);

        // Get all search result tokens once.
        $messages = $this->search();

        // Calculate how many chunks there are
        $totalChunks = (int) ceil($messages->count() / $chunkSize);

        // If startChunk is beyond our total chunks, return early.
        if ($startChunk > $totalChunks) {
            return;
        }

        // Save previous state to restore later.
        $previousLimit = $this->limit;
        $previousPage = $this->page;

        $this->limit = $chunkSize;

        // Iterate from the starting chunk to the last chunk.
        for ($page = $startChunk; $page <= $totalChunks; $page++) {
            $this->page = $page;

            // populate() will use $this->page to slice the results.
            $hydrated = $this->populate($messages);

            // If no messages are returned, break out to prevent infinite loop.
            if ($hydrated->isEmpty()) {
                break;
            }

            // If the callback returns false, break out.
            if ($callback($hydrated, $page) === false) {
                break;
            }
        }

        // Restore the original state.
        $this->limit = $previousLimit;
        $this->page = $previousPage;
    }

    /**
     * Paginate the current query.
     */
    public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator
    {
        if (is_null($page) && isset($_GET[$pageName]) && $_GET[$pageName] > 0) {
            $this->page = intval($_GET[$pageName]);
        } elseif ($page > 0) {
            $this->page = (int) $page;
        }

        $this->limit = $perPage;

        return $this->get()->paginate($perPage, $this->page, $pageName, true);
    }

    /**
     * Find a message by the given identifier type or throw an exception.
     *
     * @return Message
     */
    public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface
    {
        /** @var UntaggedResponse $response */
        $response = $this->id($id, $identifier)->firstOrFail();

        $uid = $response->tokenAt(3) // ListData
            ->tokenAt(1) // Atom
            ->value; // UID

        return $this->process(new MessageCollection([$uid]))->firstOrFail();
    }

    /**
     * Find a message by the given identifier type.
     *
     * @return Message|null
     */
    public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface
    {
        /** @var UntaggedResponse $response */
        if (! $response = $this->id($id, $identifier)->first()) {
            return null;
        }

        $uid = $response->tokenAt(3) // ListData
            ->tokenAt(1) // Atom
            ->value; // UID

        return $this->process(new MessageCollection([$uid]))->first();
    }

    /**
     * Destroy the given messages.
     */
    public function destroy(array|int $uids, bool $expunge = false): void
    {
        $uids = (array) $uids;

        $this->folder->mailbox()
            ->connection()
            ->store([ImapFlag::Deleted->value], $uids, mode: '+');

        if ($expunge) {
            $this->folder->expunge();
        }
    }

    /**
     * Process the collection of messages.
     */
    protected function process(Collection $messages): MessageCollection
    {
        if ($messages->isNotEmpty()) {
            return $this->populate($messages);
        }

        return MessageCollection::make();
    }

    /**
     * Populate a given id collection and receive a fully fetched message collection.
     */
    protected function populate(Collection $uids): MessageCollection
    {
        $messages = MessageCollection::make();

        $messages->total($uids->count());

        $rawMessages = $this->fetch($uids);

        foreach ($rawMessages['uids'] as $uid) {
            $flags = $rawMessages['flags'][$uid] ?? [];
            $headers = $rawMessages['headers'][$uid] ?? '';
            $contents = $rawMessages['contents'][$uid] ?? '';

            $messages->push(
                $this->newMessage($uid, $flags, $headers, $contents)
            );
        }

        return $messages;
    }

    /**
     * Fetch a given id collection.
     */
    protected function fetch(Collection $messages): array
    {
        if ($this->fetchOrder === 'desc') {
            $messages = $messages->reverse();
        }

        $uids = $messages->forPage($this->page, $this->limit)->toArray();

        $flags = $this->fetchFlags ? $this->connection()
            ->flags($uids)
            ->mapWithKeys(MessageResponseParser::getFlags(...))->all() : [];

        $headers = $this->fetchHeaders ? $this->connection()
            ->bodyHeader($uids, $this->fetchAsUnread)
            ->mapWithKeys(MessageResponseParser::getBodyHeader(...))->all() : [];

        $contents = $this->fetchBody ? $this->connection()
            ->bodyText($uids, $this->fetchAsUnread)
            ->mapWithKeys(MessageResponseParser::getBodyText(...))->all() : [];

        return [
            'uids' => $uids,
            'flags' => $flags,
            'headers' => $headers,
            'contents' => $contents,
        ];
    }

    /**
     * Execute an IMAP search request.
     */
    protected function search(): Collection
    {
        // If the query is empty, default to fetching all.
        if ($this->query->isEmpty()) {
            $this->query->all();
        }

        $response = $this->connection()->search([
            $this->query->toImap(),
        ]);

        return new Collection(array_map(
            fn (Atom $token) => $token->value,
            $response->tokensAfter(2)
        ));
    }

    /**
     * Get the UID for the given identifier.
     */
    protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
    {
        try {
            return $this->connection()->uid([$id], $identifier);
        } catch (ImapCommandException $e) {
            // IMAP servers may return an error if the message number is not found.
            // If the identifier being used is a message number, and the message
            // number is in the command tokens, we can assume this has occurred
            // and safely ignore the error and return an empty collection.
            if (
                $identifier === ImapFetchIdentifier::MessageNumber
                && in_array($id, $e->command()->tokens())
            ) {
                return ResponseCollection::make();
            }

            // Otherwise, re-throw the exception.
            throw $e;
        }
    }

    /**
     * Make a new message from given raw components.
     */
    protected function newMessage(int $uid, array $flags, string $headers, string $contents): Message
    {
        return new Message($this->folder, $uid, $flags, $headers, $contents);
    }

    /**
     * Get the connection instance.
     */
    protected function connection(): ConnectionInterface
    {
        return $this->folder->mailbox()->connection();
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Streams\FakeStream;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use DirectoryTree\ImapEngine\Exceptions\ImapResponseException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
use DirectoryTree\ImapEngine\Support\Str;
use Exception;
use Generator;
use LogicException;

class ImapConnection implements ConnectionInterface
{
    /**
     * Sequence number used to generate unique command tags.
     */
    protected int $sequence = 0;

    /**
     * The result instance.
     */
    protected ?Result $result = null;

    /**
     * The parser instance.
     */
    protected ?ImapParser $parser = null;

    /**
     * Constructor.
     */
    public function __construct(
        protected StreamInterface $stream,
        protected ?LoggerInterface $logger = null,
    ) {}

    /**
     * Create a new connection with a fake stream.
     */
    public static function fake(array $responses = []): static
    {
        $stream = new FakeStream;

        $stream->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.
     */
    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 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 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 .= '"'.$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.
     */
    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
    {
        return $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)
            ),
        );
    }

    /**
     * Assert the next response matches the given filter and assertion.
     *
     * @template T of Response
     *
     * @param  callable(Response): bool  $filter
     * @return T
     */
    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(Response): bool  $filter
     * @return T
     */
    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;
            }
        }

        throw new ImapResponseException('No matching response found');
    }

    /**
     * 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;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use Carbon\Carbon;
use Carbon\CarbonInterface;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use Generator;

class Idle
{
    /**
     * Constructor.
     */
    public function __construct(
        protected Mailbox $mailbox,
        protected string $folder,
        protected int $timeout,
    ) {}

    /**
     * Destructor.
     */
    public function __destruct()
    {
        $this->disconnect();
    }

    /**
     * Await new messages on the connection.
     */
    public function await(callable $callback): void
    {
        $this->connect();

        // Loop indefinitely, restarting IDLE sessions as needed.
        while (true) {
            $ttl = $this->getNextTimeout();

            try {
                $this->listen($callback, $ttl);
            } catch (ImapConnectionTimedOutException) {
                $this->restart();
            } catch (ImapConnectionClosedException) {
                $this->reconnect();
            }
        }
    }

    /**
     * Start listening for new messages using the idle() generator.
     */
    protected function listen(callable $callback, CarbonInterface $ttl): void
    {
        // Iterate over responses yielded by the idle generator.
        foreach ($this->idle() as $response) {
            if (! $response instanceof UntaggedResponse) {
                continue;
            }

            if ($response->tokenAt(2)?->is('EXISTS')) {
                $msgn = (int) $response->tokenAt(1)->value;

                $callback($msgn);

                $ttl = $this->getNextTimeout();
            }

            // If we've been idle too long, break out to restart the session.
            if (Carbon::now()->greaterThanOrEqualTo($ttl)) {
                $this->restart();

                break;
            }
        }
    }

    /**
     * Get the folder to idle.
     */
    protected function folder(): FolderInterface
    {
        return $this->mailbox->folders()->findOrFail($this->folder);
    }

    /**
     * Issue a done command and restart the idle session.
     */
    protected function restart(): void
    {
        try {
            // Send DONE to terminate the current IDLE session gracefully.
            $this->done();
        } catch (Exception) {
            $this->reconnect();
        }
    }

    /**
     * Reconnect the client and restart the idle session.
     */
    protected function reconnect(): void
    {
        $this->mailbox->disconnect();

        $this->connect();
    }

    /**
     * Connect the client and select the folder to idle.
     */
    protected function connect(): void
    {
        $this->mailbox->connect();

        $this->mailbox->select($this->folder(), true);
    }

    /**
     * Disconnect the client.
     */
    protected function disconnect(): void
    {
        try {
            // Attempt to terminate IDLE gracefully.
            $this->done();
        } catch (Exception) {
            // Do nothing.
        }

        $this->mailbox->disconnect();
    }

    /**
     * End the current IDLE session.
     */
    protected function done(): void
    {
        $this->mailbox->connection()->done();
    }

    /**
     * Begin a new IDLE session as a generator.
     */
    protected function idle(): Generator
    {
        yield from $this->mailbox->connection()->idle($this->timeout);
    }

    /**
     * Get the next timeout as a Carbon instance.
     */
    protected function getNextTimeout(): CarbonInterface
    {
        return Carbon::now()->addSeconds($this->timeout);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapConnection;
use DirectoryTree\ImapEngine\Connection\Loggers\EchoLogger;
use DirectoryTree\ImapEngine\Connection\Loggers\FileLogger;
use DirectoryTree\ImapEngine\Connection\Streams\ImapStream;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use Exception;

class Mailbox implements MailboxInterface
{
    /**
     * The mailbox configuration.
     */
    protected array $config = [
        'port' => 993,
        'host' => '',
        'timeout' => 30,
        'debug' => false,
        'username' => '',
        'password' => '',
        'encryption' => 'ssl',
        'validate_cert' => true,
        'authentication' => 'plain',
        'proxy' => [
            'socket' => null,
            'username' => null,
            'password' => null,
            'request_fulluri' => false,
        ],
    ];

    /**
     * The cached mailbox capabilities.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1
     */
    protected ?array $capabilities = null;

    /**
     * The currently selected folder.
     */
    protected ?FolderInterface $selected = null;

    /**
     * The mailbox connection.
     */
    protected ?ConnectionInterface $connection = null;

    /**
     * Constructor.
     */
    public function __construct(array $config = [])
    {
        $this->config = array_merge($this->config, $config);
    }

    /**
     * Prepare the cloned instance.
     */
    public function __clone(): void
    {
        $this->connection = null;
    }

    /**
     * Make a new mailbox instance.
     */
    public static function make(array $config = []): static
    {
        return new static($config);
    }

    /**
     * {@inheritDoc}
     */
    public function config(?string $key = null, mixed $default = null): mixed
    {
        if (is_null($key)) {
            return $this->config;
        }

        return data_get($this->config, $key, $default);
    }

    /**
     * {@inheritDoc}
     */
    public function connection(): ConnectionInterface
    {
        if (! $this->connection) {
            $this->connect();
        }

        return $this->connection;
    }

    /**
     * {@inheritDoc}
     */
    public function connected(): bool
    {
        return (bool) $this->connection?->connected();
    }

    /**
     * {@inheritDoc}
     */
    public function reconnect(): void
    {
        $this->disconnect();

        $this->connect();
    }

    /**
     * {@inheritDoc}
     */
    public function connect(?ConnectionInterface $connection = null): void
    {
        if ($this->connected()) {
            return;
        }

        $debug = $this->config('debug');

        $this->connection = $connection ?? new ImapConnection(new ImapStream, match (true) {
            class_exists($debug) => new $debug,
            is_string($debug) => new FileLogger($debug),
            is_bool($debug) && $debug => new EchoLogger,
            default => null,
        });

        $this->connection->connect($this->config('host'), $this->config('port'), [
            'proxy' => $this->config('proxy'),
            'debug' => $this->config('debug'),
            'timeout' => $this->config('timeout'),
            'encryption' => $this->config('encryption'),
            'validate_cert' => $this->config('validate_cert'),
        ]);

        $this->authenticate();
    }

    /**
     * Authenticate the current session.
     */
    protected function authenticate(): void
    {
        if ($this->config('authentication') === 'oauth') {
            $this->connection->authenticate(
                $this->config('username'),
                $this->config('password')
            );
        } else {
            $this->connection->login(
                $this->config('username'),
                $this->config('password'),
            );
        }
    }

    /**
     * {@inheritDoc}
     */
    public function disconnect(): void
    {
        try {
            $this->connection?->logout();
            $this->connection?->disconnect();
        } catch (Exception) {
            // Do nothing.
        } finally {
            $this->connection = null;
        }
    }

    /**
     * {@inheritDoc}
     */
    public function inbox(): FolderInterface
    {
        // "INBOX" is a special name reserved for the user's primary mailbox.
        // See: https://datatracker.ietf.org/doc/html/rfc9051#section-5.1
        return $this->folders()->find('INBOX');
    }

    /**
     * {@inheritDoc}
     */
    public function folders(): FolderRepositoryInterface
    {
        // Ensure the connection is established.
        $this->connection();

        return new FolderRepository($this);
    }

    /**
     * {@inheritDoc}
     */
    public function capabilities(): array
    {
        return $this->capabilities ??= array_map(
            fn (Atom $token) => $token->value,
            $this->connection()->capability()->tokensAfter(2)
        );
    }

    /**
     * {@inheritDoc}
     */
    public function select(FolderInterface $folder, bool $force = false): void
    {
        if (! $this->selected($folder) || $force) {
            $this->connection()->select($folder->path());
        }

        $this->selected = $folder;
    }

    /**
     * {@inheritDoc}
     */
    public function selected(FolderInterface $folder): bool
    {
        return $this->selected?->is($folder) ?? false;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Testing;

use DirectoryTree\ImapEngine\ComparesFolders;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\MessageQueryInterface;
use DirectoryTree\ImapEngine\Support\Str;

class FakeFolder implements FolderInterface
{
    use ComparesFolders;

    /**
     * Constructor.
     */
    public function __construct(
        protected string $path = '',
        protected array $flags = [],
        /** @var FakeMessage[] */
        protected array $messages = [],
        protected string $delimiter = '/',
        protected ?MailboxInterface $mailbox = null,
    ) {}

    /**
     * {@inheritDoc}
     */
    public function mailbox(): MailboxInterface
    {
        return $this->mailbox ?? throw new Exception('Folder has no mailbox.');
    }

    /**
     * {@inheritDoc}
     */
    public function path(): string
    {
        return $this->path;
    }

    /**
     * {@inheritDoc}
     */
    public function flags(): array
    {
        return $this->flags;
    }

    /**
     * {@inheritDoc}
     */
    public function delimiter(): string
    {
        return $this->delimiter;
    }

    /**
     * {@inheritDoc}
     */
    public function name(): string
    {
        return Str::fromImapUtf7(
            last(explode($this->delimiter, $this->path))
        );
    }

    /**
     * {@inheritDoc}
     */
    public function is(FolderInterface $folder): bool
    {
        return $this->isSameFolder($this, $folder);
    }

    /**
     * {@inheritDoc}
     */
    public function messages(): MessageQueryInterface
    {
        // Ensure the folder is selected.
        $this->select(true);

        return new FakeMessageQuery($this);
    }

    /**
     * {@inheritDoc}
     */
    public function idle(callable $callback, ?callable $query = null, int $timeout = 300): void
    {
        foreach ($this->messages as $message) {
            $callback($message);
        }
    }

    /**
     * {@inheritDoc}
     */
    public function move(string $newPath): void
    {
        // Do nothing.
    }

    /**
     * {@inheritDoc}
     */
    public function select(bool $force = false): void
    {
        $this->mailbox?->select($this, $force);
    }

    /**
     * {@inheritDoc}
     */
    public function status(): array
    {
        return [];
    }

    /**
     * {@inheritDoc}
     */
    public function examine(): array
    {
        return [];
    }

    /**
     * {@inheritDoc}
     */
    public function expunge(): array
    {
        return [];
    }

    /**
     * {@inheritDoc}
     */
    public function delete(): void
    {
        // Do nothing.
    }

    /**
     * Set the folder's path.
     */
    public function setPath(string $path): FakeFolder
    {
        $this->path = $path;

        return $this;
    }

    /**
     * Set the folder's flags.
     */
    public function setFlags(array $flags): FakeFolder
    {
        $this->flags = $flags;

        return $this;
    }

    /**
     * Set the folder's mailbox.
     */
    public function setMailbox(MailboxInterface $mailbox): FakeFolder
    {
        $this->mailbox = $mailbox;

        return $this;
    }

    /**
     * Set the folder's messages.
     *
     * @param  FakeMessage[]  $messages
     */
    public function setMessages(array $messages): FakeFolder
    {
        $this->messages = $messages;

        return $this;
    }

    /**
     * Get the folder's messages.
     *
     * @return FakeMessage[]
     */
    public function getMessages(): array
    {
        return $this->messages;
    }

    /**
     * Add a message to the folder.
     */
    public function addMessage(FakeMessage $message): void
    {
        $this->messages[] = $message;
    }

    /**
     * Set the folder's delimiter.
     */
    public function setDelimiter(string $delimiter = '/'): FakeFolder
    {
        $this->delimiter = $delimiter;

        return $this;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;

class Message implements Arrayable, JsonSerializable, MessageInterface
{
    use HasFlags, HasParsedMessage;

    /**
     * Constructor.
     */
    public function __construct(
        protected FolderInterface $folder,
        protected int $uid,
        protected array $flags,
        protected string $head,
        protected string $body,
    ) {}

    /**
     * Get the names of properties that should be serialized.
     */
    public function __sleep(): array
    {
        // We don't want to serialize the parsed message.
        return ['folder', 'uid', 'flags', 'headers', 'contents'];
    }

    /**
     * Get the message's folder.
     */
    public function folder(): FolderInterface
    {
        return $this->folder;
    }

    /**
     * Get the message's identifier.
     */
    public function uid(): int
    {
        return $this->uid;
    }

    /**
     * Get the message's flags.
     */
    public function flags(): array
    {
        return $this->flags;
    }

    /**
     * Get the message's raw headers.
     */
    public function head(): string
    {
        return $this->head;
    }

    /**
     * Determine if the message has headers.
     */
    public function hasHead(): bool
    {
        return ! empty($this->head);
    }

    /**
     * Get the message's raw body.
     */
    public function body(): string
    {
        return $this->body;
    }

    /**
     * Determine if the message has contents.
     */
    public function hasBody(): bool
    {
        return ! empty($this->body);
    }

    /**
     * {@inheritDoc}
     */
    public function is(MessageInterface $message): bool
    {
        return $message instanceof self
            && $this->uid === $message->uid
            && $this->head === $message->head
            && $this->body === $message->body
            && $this->folder->is($message->folder);
    }

    /**
     * Add or remove a flag from the message.
     */
    public function flag(mixed $flag, string $operation, bool $expunge = false): void
    {
        $flag = Str::enum($flag);

        $this->folder->mailbox()
            ->connection()
            ->store($flag, $this->uid, mode: $operation);

        if ($expunge) {
            $this->folder->expunge();
        }

        $this->flags = match ($operation) {
            '+' => array_unique(array_merge($this->flags, [$flag])),
            '-' => array_diff($this->flags, [$flag]),
        };
    }

    /**
     * Copy the message to the given folder.
     */
    public function copy(string $folder): ?int
    {
        $mailbox = $this->folder->mailbox();

        $capabilities = $mailbox->capabilities();

        if (! in_array('UIDPLUS', $capabilities)) {
            throw new ImapCapabilityException(
                'Unable to copy message. IMAP server does not support UIDPLUS capability'
            );
        }

        $response = $mailbox->connection()->copy($folder, $this->uid);

        return MessageResponseParser::getUidFromCopy($response);
    }

    /**
     * Move the message to the given folder.
     *
     * @throws ImapCapabilityException
     */
    public function move(string $folder, bool $expunge = false): ?int
    {
        $mailbox = $this->folder->mailbox();

        $capabilities = $mailbox->capabilities();

        switch (true) {
            case in_array('MOVE', $capabilities):
                $response = $mailbox->connection()->move($folder, $this->uid);

                if ($expunge) {
                    $this->folder->expunge();
                }

                return MessageResponseParser::getUidFromCopy($response);

            case in_array('UIDPLUS', $capabilities):
                $uid = $this->copy($folder);

                $this->delete($expunge);

                return $uid;

            default:
                throw new ImapCapabilityException(
                    'Unable to move message. IMAP server does not support MOVE or UIDPLUS capabilities'
                );
        }
    }

    /**
     * Delete the message.
     */
    public function delete(bool $expunge = false): void
    {
        $this->markDeleted($expunge);
    }

    /**
     * Restore the message.
     */
    public function restore(): void
    {
        $this->unmarkDeleted();
    }

    /**
     * Get the array representation of the message.
     */
    public function toArray(): array
    {
        return [
            'uid' => $this->uid,
            'flags' => $this->flags,
            'head' => $this->head,
            'body' => $this->body,
        ];
    }

    /**
     * Get the string representation of the message.
     */
    public function __toString(): string
    {
        return implode("\r\n\r\n", array_filter([
            rtrim($this->head),
            ltrim($this->body),
        ]));
    }

    /**
     * Get the JSON representation of the message.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    /**
     * Determine if the message is empty.
     */
    protected function isEmpty(): bool
    {
        return ! $this->hasHead() && ! $this->hasBody();
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Collections\FolderCollection;

interface FolderRepositoryInterface
{
    /**
     * Find a folder.
     */
    public function find(string $path): ?FolderInterface;

    /**
     * Find a folder or throw an exception.
     */
    public function findOrFail(string $path): FolderInterface;

    /**
     * Create a new folder.
     */
    public function create(string $path): FolderInterface;

    /**
     * Find or create a folder.
     */
    public function firstOrCreate(string $path): FolderInterface;

    /**
     * Get the mailboxes folders.
     */
    public function get(?string $match = '*', ?string $reference = ''): FolderCollection;
}

<?php

namespace DirectoryTree\ImapEngine\Testing;

use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\MessageInterface;
use DirectoryTree\ImapEngine\MessageQueryInterface;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use DirectoryTree\ImapEngine\QueriesMessages;

class FakeMessageQuery implements MessageQueryInterface
{
    use QueriesMessages;

    /**
     * Constructor.
     */
    public function __construct(
        protected FakeFolder $folder,
        protected ImapQueryBuilder $query = new ImapQueryBuilder
    ) {}

    /**
     * {@inheritDoc}
     */
    public function get(): MessageCollection
    {
        return new MessageCollection(
            $this->folder->getMessages()
        );
    }

    /**
     * {@inheritDoc}
     */
    public function count(): int
    {
        return count(
            $this->folder->getMessages()
        );
    }

    /**
     * {@inheritDoc}
     */
    public function first(): ?MessageInterface
    {
        return $this->get()->first();
    }

    /**
     * {@inheritDoc}
     */
    public function firstOrFail(): MessageInterface
    {
        return $this->get()->firstOrFail();
    }

    /**
     * {@inheritDoc}
     */
    public function append(string $message, mixed $flags = null): int
    {
        $uid = 1;

        if ($lastMessage = $this->get()->last()) {
            $uid = $lastMessage->uid() + 1;
        }

        $this->folder->addMessage(
            new FakeMessage($uid, $flags === null ? [] : $flags, $message)
        );

        return $uid;
    }

    /**
     * {@inheritDoc}
     */
    public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
    {
        $this->chunk(function (MessageCollection $messages) use ($callback) {
            foreach ($messages as $key => $message) {
                if ($callback($message, $key) === false) {
                    return false;
                }
            }
        }, $chunkSize, $startChunk);
    }

    /**
     * {@inheritDoc}
     */
    public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
    {
        $page = $startChunk;

        foreach ($this->get()->chunk($chunkSize) as $chunk) {
            if ($page < $startChunk) {
                $page++;

                continue;
            }

            // If the callback returns false, break out.
            if ($callback($chunk, $page) === false) {
                break;
            }

            $page++;
        }
    }

    /**
     * {@inheritDoc}
     */
    public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator
    {
        return $this->get()->paginate($perPage, $page, $pageName);
    }

    /**
     * {@inheritDoc}
     */
    public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface
    {
        return $this->get()->findOrFail($id);
    }

    /**
     * {@inheritDoc}
     */
    public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface
    {
        return $this->get()->find($id);
    }

    /**
     * {@inheritDoc}
     */
    public function destroy(array|int $uids, bool $expunge = false): void
    {
        $messages = $this->get()->keyBy(
            fn (MessageInterface $message) => $message->uid()
        );

        foreach ((array) $uids as $uid) {
            $messages->pull($uid);
        }

        $this->folder->setMessages(
            $messages->values()->all()
        );
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\Mime\MimeTypes;

class Attachment implements Arrayable, JsonSerializable
{
    /**
     * Constructor.
     */
    public function __construct(
        protected ?string $filename,
        protected ?string $contentId,
        protected string $contentType,
        protected StreamInterface $contentStream,
    ) {}

    /**
     * Get the attachment's filename.
     */
    public function filename(): ?string
    {
        return $this->filename;
    }

    /**
     * Get the attachment's content ID.
     */
    public function contentId(): ?string
    {
        return $this->contentId;
    }

    /**
     * Get the attachment's content type.
     */
    public function contentType(): string
    {
        return $this->contentType;
    }

    /**
     * Get the attachment's contents.
     */
    public function contents(): string
    {
        return $this->contentStream->getContents();
    }

    /**
     * Get the attachment's content stream.
     */
    public function contentStream(): StreamInterface
    {
        return $this->contentStream;
    }

    /**
     * Save the attachment to a file.
     */
    public function save(string $path): false|int
    {
        return file_put_contents($path, $this->contents());
    }

    /**
     * Get the attachment's extension.
     */
    public function extension(): ?string
    {
        if ($ext = pathinfo($this->filename, PATHINFO_EXTENSION)) {
            return $ext;
        }

        if ($ext = (MimeTypes::getDefault()->getExtensions($this->contentType)[0] ?? null)) {
            return $ext;
        }

        return null;
    }

    /**
     * Get the array representation of the attachment.
     */
    public function toArray(): array
    {
        return [
            'filename' => $this->filename,
            'content_type' => $this->contentType,
            'contents' => $this->contents(),
        ];
    }

    /**
     * Get the JSON representation of the attachment.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

<?php

namespace DirectoryTree\ImapEngine\Pagination;

use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use JsonSerializable;

/**
 * @template TKey of array-key
 *
 * @template-covariant TValue
 *
 * @template-implements Arrayable<TKey, TValue>
 */
class LengthAwarePaginator implements Arrayable, JsonSerializable
{
    use ForwardsCalls;

    /**
     * Constructor.
     */
    public function __construct(
        protected Collection $items,
        protected int $total,
        protected int $perPage,
        protected int $currentPage = 1,
        protected string $path = '',
        protected array $query = [],
        protected string $pageName = 'page',
    ) {
        $this->currentPage = max($currentPage, 1);

        $this->path = rtrim($path, '/');
    }

    /**
     * Handle dynamic method calls on the paginator.
     */
    public function __call(string $method, array $parameters): mixed
    {
        return $this->forwardCallTo($this->items, $method, $parameters);
    }

    /**
     * Get the items being paginated.
     *
     * @return Collection<TKey, TValue>
     */
    public function items(): Collection
    {
        return $this->items;
    }

    /**
     * Get the total number of items.
     */
    public function total(): int
    {
        return $this->total;
    }

    /**
     * Get the number of items per page.
     */
    public function perPage(): int
    {
        return $this->perPage;
    }

    /**
     * Get the current page number.
     */
    public function currentPage(): int
    {
        return $this->currentPage;
    }

    /**
     * Get the last page (total pages).
     */
    public function lastPage(): int
    {
        return (int) ceil($this->total / $this->perPage);
    }

    /**
     * Determine if there are enough items to split into multiple pages.
     */
    public function hasPages(): bool
    {
        return $this->total() > $this->perPage();
    }

    /**
     * Determine if there is a next page.
     */
    public function hasMorePages(): bool
    {
        return $this->currentPage() < $this->lastPage();
    }

    /**
     * Generate the URL for a given page.
     */
    public function url(int $page): string
    {
        $params = array_merge($this->query, [$this->pageName => $page]);

        $queryString = http_build_query($params);

        return $this->path.($queryString ? '?'.$queryString : '');
    }

    /**
     * Get the URL for the next page, or null if none.
     */
    public function nextPageUrl(): ?string
    {
        if ($this->hasMorePages()) {
            return $this->url($this->currentPage() + 1);
        }

        return null;
    }

    /**
     * Get the URL for the previous page, or null if none.
     */
    public function previousPageUrl(): ?string
    {
        if ($this->currentPage() > 1) {
            return $this->url($this->currentPage() - 1);
        }

        return null;
    }

    /**
     * Get the array representation of the paginator.
     */
    public function toArray(): array
    {
        return [
            'path' => $this->path,
            'total' => $this->total(),
            'to' => $this->calculateTo(),
            'per_page' => $this->perPage(),
            'last_page' => $this->lastPage(),
            'first_page_url' => $this->url(1),
            'data' => $this->items()->toArray(),
            'current_page' => $this->currentPage(),
            'next_page_url' => $this->nextPageUrl(),
            'prev_page_url' => $this->previousPageUrl(),
            'last_page_url' => $this->url($this->lastPage()),
            'from' => $this->total() ? ($this->currentPage() - 1) * $this->perPage() + 1 : null,
        ];
    }

    /**
     * Calculate the "to" index for the current page.
     */
    protected function calculateTo(): ?int
    {
        if (! $this->total()) {
            return null;
        }

        $to = $this->currentPage() * $this->perPage();

        return min($to, $this->total());
    }

    /**
     * Get the JSON representation of the paginator.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

<?php

namespace DirectoryTree\ImapEngine\Testing;

use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;

class FakeMailbox implements MailboxInterface
{
    /**
     * The currently selected folder.
     */
    protected ?FolderInterface $selected = null;

    /**
     * Constructor.
     */
    public function __construct(
        protected array $config = [],
        /** @var FakeFolder[] */
        protected array $folders = [],
        protected array $capabilities = [],
    ) {
        foreach ($folders as $folder) {
            $folder->setMailbox($this);
        }
    }

    /**
     * {@inheritDoc}
     */
    public function config(?string $key = null, mixed $default = null): mixed
    {
        if (is_null($key)) {
            return $this->config;
        }

        return data_get($this->config, $key, $default);
    }

    /**
     * {@inheritDoc}
     */
    public function connection(): ConnectionInterface
    {
        throw new Exception('Unsupported.');
    }

    /**
     * {@inheritDoc}
     */
    public function connected(): bool
    {
        return true;
    }

    /**
     * {@inheritDoc}
     */
    public function reconnect(): void
    {
        // Do nothing.
    }

    /**
     * {@inheritDoc}
     */
    public function connect(?ConnectionInterface $connection = null): void
    {
        // Do nothing.
    }

    /**
     * {@inheritDoc}
     */
    public function disconnect(): void
    {
        // Do nothing.
    }

    /**
     * {@inheritDoc}
     */
    public function inbox(): FolderInterface
    {
        return $this->folders()->findOrFail('inbox');
    }

    /**
     * {@inheritDoc}
     */
    public function folders(): FolderRepositoryInterface
    {
        return new FakeFolderRepository($this, $this->folders);
    }

    /**
     * {@inheritDoc}
     */
    public function capabilities(): array
    {
        return $this->capabilities;
    }

    /**
     * {@inheritDoc}
     */
    public function select(FolderInterface $folder, bool $force = false): void
    {
        $this->selected = $folder;
    }

    /**
     * {@inheritDoc}
     */
    public function selected(FolderInterface $folder): bool
    {
        return $this->selected?->is($folder) ?? false;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use BackedEnum;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
use DirectoryTree\ImapEngine\Support\Str;

class ImapQueryBuilder
{
    /**
     * The where conditions for the query.
     */
    protected array $wheres = [];

    /**
     * The date format to use for date based queries.
     */
    protected string $dateFormat = 'd-M-Y';

    /**
     * Add a where "ALL" clause to the query.
     */
    public function all(): static
    {
        return $this->where(ImapSearchKey::All);
    }

    /**
     * Add a where "NEW" clause to the query.
     */
    public function new(): static
    {
        return $this->where(ImapSearchKey::New);
    }

    /**
     * Add a where "OLD" clause to the query.
     */
    public function old(): static
    {
        return $this->where(ImapSearchKey::Old);
    }

    /**
     * Add a where "SEEN" clause to the query.
     */
    public function seen(): static
    {
        return $this->where(ImapSearchKey::Seen);
    }

    /**
     * Add a where "DRAFT" clause to the query.
     */
    public function draft(): static
    {
        return $this->where(ImapSearchKey::Draft);
    }

    /**
     * Add a where "RECENT" clause to the query.
     */
    public function recent(): static
    {
        return $this->where(ImapSearchKey::Recent);
    }

    /**
     * Add a where "UNSEEN" clause to the query.
     */
    public function unseen(): static
    {
        return $this->where(ImapSearchKey::Unseen);
    }

    /**
     * Add a where "FLAGGED" clause to the query.
     */
    public function flagged(): static
    {
        return $this->where(ImapSearchKey::Flagged);
    }

    /**
     * Add a where "DELETED" clause to the query.
     */
    public function deleted(): static
    {
        return $this->where(ImapSearchKey::Deleted);
    }

    /**
     * Add a where "ANSWERED" clause to the query.
     */
    public function answered(): static
    {
        return $this->where(ImapSearchKey::Answered);
    }

    /**
     * Add a where "UNDELETED" clause to the query.
     */
    public function undeleted(): static
    {
        return $this->where(ImapSearchKey::Undeleted);
    }

    /**
     * Add a where "UNFLAGGED" clause to the query.
     */
    public function unflagged(): static
    {
        return $this->where(ImapSearchKey::Unflagged);
    }

    /**
     * Add a where "UNANSWERED" clause to the query.
     */
    public function unanswered(): static
    {
        return $this->where(ImapSearchKey::Unanswered);
    }

    /**
     * Add a where "FROM" clause to the query.
     */
    public function from(string $email): static
    {
        return $this->where(ImapSearchKey::From, $email);
    }

    /**
     * Add a where "TO" clause to the query.
     */
    public function to(string $value): static
    {
        return $this->where(ImapSearchKey::To, $value);
    }

    /**
     * Add a where "CC" clause to the query.
     */
    public function cc(string $value): static
    {
        return $this->where(ImapSearchKey::Cc, $value);
    }

    /**
     * Add a where "BCC" clause to the query.
     */
    public function bcc(string $value): static
    {
        return $this->where(ImapSearchKey::Bcc, $value);
    }

    /**
     * Add a where "BODY" clause to the query.
     */
    public function body(string $value): static
    {
        return $this->where(ImapSearchKey::Body, $value);
    }

    /**
     * Add a where "KEYWORD" clause to the query.
     */
    public function keyword(string $value): static
    {
        return $this->where(ImapSearchKey::Keyword, $value);
    }

    /**
     * Add a where "ON" clause to the query.
     */
    public function on(mixed $date): static
    {
        return $this->where(ImapSearchKey::On, new RawQueryValue(
            $this->parseDate($date)->format($this->dateFormat)
        ));
    }

    /**
     * Add a where "SINCE" clause to the query.
     */
    public function since(mixed $date): static
    {
        return $this->where(ImapSearchKey::Since, new RawQueryValue(
            $this->parseDate($date)->format($this->dateFormat)
        ));
    }

    /**
     * Add a where "BEFORE" clause to the query.
     */
    public function before(mixed $value): static
    {
        return $this->where(ImapSearchKey::Before, new RawQueryValue(
            $this->parseDate($value)->format($this->dateFormat)
        ));
    }

    /**
     * Add a where "SUBJECT" clause to the query.
     */
    public function subject(string $value): static
    {
        return $this->where(ImapSearchKey::Subject, $value);
    }

    /**
     * Add a where "TEXT" clause to the query.
     */
    public function text(string $value): static
    {
        return $this->where(ImapSearchKey::Text, $value);
    }

    /**
     * Add a where "HEADER" clause to the query.
     */
    public function header(string $header, string $value): static
    {
        return $this->where(ImapSearchKey::Header->value." $header", $value);
    }

    /**
     * Add a where "UID" clause to the query.
     */
    public function uid(int|string|array $uid): static
    {
        return $this->where(ImapSearchKey::Uid, new RawQueryValue(
            Str::set(array_map('intval', (array) $uid))
        ));
    }

    /**
     * Add a "where" condition.
     */
    public function where(mixed $column, mixed $value = null): static
    {
        if (is_callable($column)) {
            $this->addNestedCondition('AND', $column);
        } else {
            $this->addBasicCondition('AND', $column, $value);
        }

        return $this;
    }

    /**
     * Add an "or where" condition.
     */
    public function orWhere(mixed $column, mixed $value = null): static
    {
        if (is_callable($column)) {
            $this->addNestedCondition('OR', $column);
        } else {
            $this->addBasicCondition('OR', $column, $value);
        }

        return $this;
    }

    /**
     * Add a "where not" condition.
     */
    public function whereNot(mixed $column, mixed $value = null): static
    {
        $this->addBasicCondition('AND', $column, $value, true);

        return $this;
    }

    /**
     * Determine if the query has any where conditions.
     */
    public function isEmpty(): bool
    {
        return empty($this->wheres);
    }

    /**
     * Transform the instance into an IMAP-compatible query string.
     */
    public function toImap(): string
    {
        return $this->compileWheres($this->wheres);
    }

    /**
     * Create a new query instance (like Eloquent's newQuery).
     */
    protected function newQuery(): static
    {
        return new static;
    }

    /**
     * Add a basic condition to the query.
     */
    protected function addBasicCondition(string $boolean, mixed $column, mixed $value, bool $not = false): void
    {
        $value = $this->prepareWhereValue($value);

        $column = Str::enum($column);

        $this->wheres[] = [
            'type' => 'basic',
            'not' => $not,
            'key' => $column,
            'value' => $value,
            'boolean' => $boolean,
        ];
    }

    /**
     * Prepare the where value, escaping it as needed.
     */
    protected function prepareWhereValue(mixed $value): RawQueryValue|string|null
    {
        if (is_null($value)) {
            return null;
        }

        if ($value instanceof RawQueryValue) {
            return $value;
        }

        if ($value instanceof BackedEnum) {
            $value = $value->value;
        }

        if ($value instanceof DateTimeInterface) {
            $value = Carbon::instance($value);
        }

        if ($value instanceof CarbonInterface) {
            $value = $value->format($this->dateFormat);
        }

        return Str::escape($value);
    }

    /**
     * Add a nested condition group to the query.
     */
    protected function addNestedCondition(string $boolean, callable $callback): void
    {
        $nested = $this->newQuery();

        $callback($nested);

        $this->wheres[] = [
            'type' => 'nested',
            'query' => $nested,
            'boolean' => $boolean,
        ];
    }

    /**
     * Attempt to parse a date string into a Carbon instance.
     */
    protected function parseDate(mixed $date): CarbonInterface
    {
        if ($date instanceof CarbonInterface) {
            return $date;
        }

        return Carbon::parse($date);
    }

    /**
     * Build a single expression node from a basic or nested where.
     */
    protected function makeExpressionNode(array $where): array
    {
        return match ($where['type']) {
            'basic' => [
                'expr' => $this->compileBasic($where),
                'boolean' => $where['boolean'],
            ],

            'nested' => [
                'expr' => $where['query']->toImap(),
                'boolean' => $where['boolean'],
            ]
        };
    }

    /**
     * Merge the existing expression with the next expression, respecting the boolean operator.
     */
    protected function mergeExpressions(string $existing, string $next, string $boolean): string
    {
        return match ($boolean) {
            // AND is implicit – just append.
            'AND' => $existing.' '.$next,

            // IMAP's OR is binary; nest accordingly.
            'OR' => 'OR ('.$existing.') ('.$next.')',
        };
    }

    /**
     * Recursively compile the wheres array into an IMAP-compatible string.
     */
    protected function compileWheres(array $wheres): string
    {
        if (empty($wheres)) {
            return '';
        }

        // Convert each "where" into a node for later merging.
        $exprNodes = array_map(fn (array $where) => (
            $this->makeExpressionNode($where)
        ), $wheres);

        // Start with the first expression.
        $combined = array_shift($exprNodes)['expr'];

        // Merge the rest of the expressions.
        foreach ($exprNodes as $node) {
            $combined = $this->mergeExpressions(
                $combined, $node['expr'], $node['boolean']
            );
        }

        return trim($combined);
    }

    /**
     * Compile a basic where condition into an IMAP-compatible string.
     */
    protected function compileBasic(array $where): string
    {
        $part = strtoupper($where['key']);

        if ($where['value'] instanceof RawQueryValue) {
            $part .= ' '.$where['value']->value;
        } elseif ($where['value']) {
            $part .= ' "'.Str::toImapUtf7($where['value']).'"';
        }

        if ($where['not']) {
            $part = 'NOT '.$part;
        }

        return $part;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use Carbon\Carbon;
use Carbon\CarbonInterface;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use GuzzleHttp\Psr7\Utils;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\Header\Part\ContainerPart;
use ZBateson\MailMimeParser\Header\Part\NameValuePart;
use ZBateson\MailMimeParser\Message as MailMimeMessage;

trait HasParsedMessage
{
    /**
     * The parsed message.
     */
    protected ?MailMimeMessage $parsed = null;

    /**
     * Get the message date and time.
     */
    public function date(): ?CarbonInterface
    {
        if ($date = $this->header(HeaderConsts::DATE)?->getDateTime()) {
            return Carbon::instance($date);
        }

        return null;
    }

    /**
     * Get the message's message-id.
     */
    public function messageId(): ?string
    {
        return $this->header(HeaderConsts::MESSAGE_ID)?->getValue();
    }

    /**
     * Get the message's subject.
     */
    public function subject(): ?string
    {
        return $this->header(HeaderConsts::SUBJECT)?->getValue();
    }

    /**
     * Get the FROM address.
     */
    public function from(): ?Address
    {
        return head($this->addresses(HeaderConsts::FROM)) ?: null;
    }

    /**
     * Get the SENDER address.
     */
    public function sender(): ?Address
    {
        return head($this->addresses(HeaderConsts::SENDER)) ?: null;
    }

    /**
     * Get the REPLY-TO address.
     */
    public function replyTo(): ?Address
    {
        return head($this->addresses(HeaderConsts::REPLY_TO)) ?: null;
    }

    /**
     * Get the IN-REPLY-TO address.
     */
    public function inReplyTo(): ?Address
    {
        return head($this->addresses(HeaderConsts::IN_REPLY_TO)) ?: null;
    }

    /**
     * Get the TO addresses.
     *
     * @return Address[]
     */
    public function to(): array
    {
        return $this->addresses(HeaderConsts::TO);
    }

    /**
     * Get the CC addresses.
     *
     * @return Address[]
     */
    public function cc(): array
    {
        return $this->addresses(HeaderConsts::CC);
    }

    /**
     * Get the BCC addresses.
     *
     * @return Address[]
     */
    public function bcc(): array
    {
        return $this->addresses(HeaderConsts::BCC);
    }

    /**
     * Get the message's attachments.
     *
     * @return Attachment[]
     */
    public function attachments(): array
    {
        $attachments = [];

        foreach ($this->parse()->getAllAttachmentParts() as $part) {
            // If the attachment's content type is message/rfc822, we're
            // working with a forwarded message. We will parse the
            // forwarded message and merge in its attachments.
            if (strtolower($part->getContentType()) === 'message/rfc822') {
                $message = new FileMessage($part->getContent());

                $attachments = array_merge($attachments, $message->attachments());
            } else {
                $attachments[] = new Attachment(
                    $part->getFilename(),
                    $part->getContentId(),
                    $part->getContentType(),
                    $part->getBinaryContentStream() ?? Utils::streamFor(''),
                );
            }
        }

        return $attachments;
    }

    /**
     * Determine if the message has attachments.
     */
    public function hasAttachments(): bool
    {
        return $this->attachmentCount() > 0;
    }

    /**
     * Get the count of attachments.
     */
    public function attachmentCount(): int
    {
        return $this->parse()->getAttachmentCount();
    }

    /**
     * Get addresses from the given header.
     *
     * @return Address[]
     */
    public function addresses(string $header): array
    {
        $parts = $this->header($header)?->getParts() ?? [];

        $addresses = array_map(fn (IHeaderPart $part) => match (true) {
            $part instanceof AddressPart => new Address($part->getEmail(), $part->getName()),
            $part instanceof NameValuePart => new Address($part->getName(), $part->getValue()),
            $part instanceof ContainerPart => new Address($part->getValue(), ''),
            default => null,
        }, $parts);

        return array_filter($addresses);
    }

    /**
     * Get the message's HTML content.
     */
    public function html(): ?string
    {
        return $this->parse()->getHtmlContent();
    }

    /**
     * Get the message's text content.
     */
    public function text(): ?string
    {
        return $this->parse()->getTextContent();
    }

    /**
     * Get all headers from the message.
     */
    public function headers(): array
    {
        return $this->parse()->getAllHeaders();
    }

    /**
     * Get a header from the message.
     */
    public function header(string $name, int $offset = 0): ?IHeader
    {
        return $this->parse()->getHeader($name, $offset);
    }

    /**
     * Parse the message into a MailMimeMessage instance.
     */
    public function parse(): MailMimeMessage
    {
        if ($this->isEmpty()) {
            throw new RuntimeException('Cannot parse an empty message');
        }

        return $this->parsed ??= MessageParser::parse((string) $this);
    }

    /**
     * Determine if the message is empty.
     */
    abstract protected function isEmpty(): bool;

    /**
     * Get the string representation of the message.
     */
    abstract public function __toString(): string;
}

<?php

namespace DirectoryTree\ImapEngine;

use DateTimeInterface;
use Stringable;
use Symfony\Component\Mime\Email;

class DraftMessage implements Stringable
{
    /**
     * The underlying Symfony Email instance.
     */
    protected Email $message;

    /**
     * Constructor.
     */
    public function __construct(
        protected ?string $from = null,
        protected array|string $to = [],
        protected array|string $cc = [],
        protected array|string $bcc = [],
        protected ?string $subject = null,
        protected ?string $text = null,
        protected ?string $html = null,
        protected array $headers = [],
        protected array $attachments = [],
        protected ?DateTimeInterface $date = null,
    ) {
        $this->message = new Email;

        if ($this->from) {
            $this->message->from($this->from);
        }

        if ($this->subject) {
            $this->message->subject($this->subject);
        }

        if ($this->text) {
            $this->message->text($this->text);
        }

        if ($this->html) {
            $this->message->html($this->html);
        }

        if ($this->date) {
            $this->message->date($this->date);
        }

        if (! empty($this->to)) {
            $this->message->to(...(array) $this->to);
        }

        if (! empty($this->cc)) {
            $this->message->cc(...(array) $this->cc);
        }

        if (! empty($this->bcc)) {
            $this->message->bcc(...(array) $this->bcc);
        }

        foreach ($this->attachments as $attachment) {
            match (true) {
                $attachment instanceof Attachment => $this->message->attach(
                    $attachment->contents(),
                    $attachment->filename(),
                    $attachment->contentType()
                ),

                is_resource($attachment) => $this->message->attach($attachment),

                default => $this->message->attachFromPath($attachment),
            };
        }

        foreach ($this->headers as $name => $value) {
            $this->message->getHeaders()->addTextHeader($name, $value);
        }
    }

    /**
     * Get the underlying Symfony Email instance.
     */
    public function email(): Email
    {
        return $this->message;
    }

    /**
     * Get the email as a string.
     */
    public function __toString(): string
    {
        return $this->message->toString();
    }
}

<?php

namespace DirectoryTree\ImapEngine\Support;

use BackedEnum;

class Str
{
    /**
     * Make a list with literals or nested lists.
     */
    public static function list(array $list): string
    {
        $values = [];

        foreach ($list as $value) {
            if (is_array($value)) {
                $values[] = static::list($value);
            } else {
                $values[] = $value;
            }
        }

        return sprintf('(%s)', implode(' ', $values));
    }

    /**
     * Make one or more literals.
     */
    public static function literal(array|string $string): array|string
    {
        if (is_array($string)) {
            $result = [];

            foreach ($string as $value) {
                $result[] = static::literal($value);
            }

            return $result;
        }

        if (str_contains($string, "\n")) {
            return ['{'.strlen($string).'}', $string];
        }

        return '"'.static::escape($string).'"';
    }

    /**
     * Resolve the value of the given enums.
     */
    public static function enums(BackedEnum|array|string|null $enums = null): array|string|null
    {
        if (is_null($enums)) {
            return null;
        }

        if (is_array($enums)) {
            return array_map([static::class, 'enums'], $enums);
        }

        return Str::enum($enums);
    }

    /**
     * Resolve the value of the given enum.
     */
    public static function enum(BackedEnum|string $enum): string
    {
        if ($enum instanceof BackedEnum) {
            return $enum->value;
        }

        return (string) $enum;
    }

    /**
     * Make a range set for use in a search command.
     */
    public static function set(array|int $from, int|float|null $to = null): string
    {
        // If $from is an array with multiple elements, return them as a comma-separated list.
        if (is_array($from) && count($from) > 1) {
            return implode(',', $from);
        }

        // If $from is an array with a single element, return that element.
        if (is_array($from) && count($from) === 1) {
            return (string) reset($from);
        }

        // At this point, $from is an integer. No upper bound provided, return $from as a string.
        if (is_null($to)) {
            return (string) $from;
        }

        // If the upper bound is infinite, use the '*' notation.
        if ($to == INF) {
            return $from.':*';
        }

        // Otherwise, return a typical range string.
        return $from.':'.$to;
    }

    /**
     * Make a credentials string for use in the AUTHENTICATE command.
     */
    public static function credentials(string $user, string $token): string
    {
        return base64_encode("user=$user\1auth=Bearer $token\1\1");
    }

    /**
     * Prefix a string with the given prefix if it does not already start with it.
     */
    public static function prefix(string $value, string $prefix): string
    {
        return str_starts_with($value, $prefix) ? $value : $prefix.$value;
    }

    /**
     * Escape a string for use in a list.
     */
    public static function escape(string $string): string
    {
        // Remove newlines and control characters (ASCII 0-31 and 127).
        $string = preg_replace('/[\r\n\x00-\x1F\x7F]/', '', $string);

        // Escape backslashes first to avoid double-escaping and then escape double quotes.
        return str_replace(['\\', '"'], ['\\\\', '\\"'], $string);
    }

    /**
     * Decode a modified UTF-7 string (IMAP specific) to UTF-8.
     */
    public static function fromImapUtf7(string $string): string
    {
        // If the string doesn't contain any '&' character, it's not UTF-7 encoded.
        if (! str_contains($string, '&')) {
            return $string;
        }

        // Handle the special case of '&-' which represents '&' in UTF-7.
        if ($string === '&-') {
            return '&';
        }

        // Direct implementation of IMAP's modified UTF-7 decoding.
        return preg_replace_callback('/&([^-]*)-?/', function ($matches) {
            // If it's just an ampersand.
            if ($matches[1] === '') {
                return '&';
            }

            // If it's the special case for ampersand.
            if ($matches[1] === '-') {
                return '&';
            }

            // Convert modified base64 to standard base64.
            $base64 = strtr($matches[1], ',', '/');

            // Add padding if necessary.
            switch (strlen($base64) % 4) {
                case 1: $base64 .= '===';
                    break;
                case 2: $base64 .= '==';
                    break;
                case 3: $base64 .= '=';
                    break;
            }

            // Decode base64 to binary.
            $binary = base64_decode($base64, true);

            if ($binary === false) {
                // If decoding fails, return the original string.
                return '&'.$matches[1].($matches[2] ?? '');
            }

            $result = '';

            // Convert binary UTF-16BE to UTF-8.
            for ($i = 0; $i < strlen($binary); $i += 2) {
                if (isset($binary[$i + 1])) {
                    $char = (ord($binary[$i]) << 8) | ord($binary[$i + 1]);

                    if ($char < 0x80) {
                        $result .= chr($char);
                    } elseif ($char < 0x800) {
                        $result .= chr(0xC0 | ($char >> 6)).chr(0x80 | ($char & 0x3F));
                    } else {
                        $result .= chr(0xE0 | ($char >> 12)).chr(0x80 | (($char >> 6) & 0x3F)).chr(0x80 | ($char & 0x3F));
                    }
                }
            }

            return $result;
        }, $string);
    }

    /**
     * Encode a UTF-8 string to modified UTF-7 (IMAP specific).
     */
    public static function toImapUtf7(string $string): string
    {
        $result = '';
        $buffer = '';

        // Iterate over each character in the UTF-8 string.
        for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
            $char = mb_substr($string, $i, 1, 'UTF-8');

            // Convert character to its UTF-16BE code unit (for deciding if ASCII).
            $ord = unpack('n', mb_convert_encoding($char, 'UTF-16BE', 'UTF-8'))[1];

            // Handle printable ASCII characters (0x20 - 0x7E) except '&'
            if ($ord >= 0x20 && $ord <= 0x7E && $char !== '&') {
                // If there is any buffered non-ASCII content, flush it as a base64 section.
                if ($buffer !== '') {
                    // Encode the buffer to UTF-16BE, then to base64, swap '/' for ',', trim '=' padding, and wrap with '&' and '-'.
                    $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
                    $buffer = '';
                }

                // Append the ASCII character as-is.
                $result .= $char;

                continue;
            }

            // Special handling for literal '&' which becomes '&-'
            if ($char === '&') {
                // Flush any buffered non-ASCII content first.
                if ($buffer !== '') {
                    $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
                    $buffer = '';
                }

                // '&' is encoded as '&-'
                $result .= '&-';

                continue;
            }

            // Buffer non-ASCII characters for later base64 encoding.
            $buffer .= $char;
        }

        // After the loop, flush any remaining buffered non-ASCII content.
        if ($buffer !== '') {
            $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
        }

        return $result;
    }

    /**
     * Determine if a given string matches a given pattern.
     */
    public static function is(array|string $pattern, string $value, bool $ignoreCase = false)
    {
        if (! is_iterable($pattern)) {
            $pattern = [$pattern];
        }

        foreach ($pattern as $pattern) {
            $pattern = (string) $pattern;

            // If the given value is an exact match we can of course return true right
            // from the beginning. Otherwise, we will translate asterisks and do an
            // actual pattern match against the two strings to see if they match.
            if ($pattern === '*' || $pattern === $value) {
                return true;
            }

            if ($ignoreCase && mb_strtolower($pattern) === mb_strtolower($value)) {
                return true;
            }

            $pattern = preg_quote($pattern, '#');

            // Asterisks are translated into zero-or-more regular expression wildcards
            // to make it convenient to check if the strings starts with the given
            // pattern such as "library/*", making any string check convenient.
            $pattern = str_replace('\*', '.*', $pattern);

            if (preg_match('#^'.$pattern.'\z#'.($ignoreCase ? 'isu' : 'su'), $value) === 1) {
                return true;
            }
        }

        return false;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Streams;

use PHPUnit\Framework\Assert;
use RuntimeException;

class FakeStream implements StreamInterface
{
    /**
     * Lines queued for testing; each call to fgets() pops the next line.
     *
     * @var string[]
     */
    protected array $buffer = [];

    /**
     * Data that has been "written" to this fake stream (for assertion).
     *
     * @var string[]
     */
    protected array $written = [];

    /**
     * The connection info.
     */
    protected ?array $connection = null;

    /**
     * The mock meta info.
     */
    protected array $meta = [
        'crypto' => [
            'protocol' => '',
            'cipher_name' => '',
            'cipher_bits' => 0,
            'cipher_version' => '',
        ],
        'mode' => 'c',
        'eof' => false,
        'blocked' => false,
        'timed_out' => false,
        'seekable' => false,
        'unread_bytes' => 0,
        'stream_type' => 'tcp_socket/unknown',
    ];

    /**
     * Feed a line to the stream buffer with a newline character.
     */
    public function feed(array|string $lines): self
    {
        // We'll ensure that each line ends with a CRLF,
        // as this is the expected behavior of every
        // reply that comes from an IMAP server.
        $lines = array_map(fn (string $line) => (
            rtrim($line, "\r\n")."\r\n"
        ), (array) $lines);

        array_push($this->buffer, ...$lines);

        return $this;
    }

    /**
     * Feed a raw line to the stream buffer.
     */
    public function feedRaw(array|string $lines): self
    {
        array_push($this->buffer, ...(array) $lines);

        return $this;
    }

    /**
     * Set the timed out status.
     */
    public function setMeta(string $attribute, mixed $value): self
    {
        if (! isset($this->meta[$attribute])) {
            throw new RuntimeException(
                "Unknown metadata attribute: {$attribute}"
            );
        }

        if (gettype($this->meta[$attribute]) !== gettype($value)) {
            throw new RuntimeException(
                "Metadata attribute {$attribute} must be of type ".gettype($this->meta[$attribute])
            );
        }

        $this->meta[$attribute] = $value;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function open(?string $transport = null, ?string $host = null, ?int $port = null, ?int $timeout = null, array $options = []): bool
    {
        $this->connection = compact('transport', 'host', 'port', 'timeout', 'options');

        return true;
    }

    /**
     * {@inheritDoc}
     */
    public function close(): void
    {
        $this->buffer = [];
        $this->connection = null;
    }

    /**
     * {@inheritDoc}
     */
    public function read(int $length): string|false
    {
        if (! $this->opened()) {
            return false;
        }

        if ($this->meta['eof'] && empty($this->buffer)) {
            return false; // EOF and no data left. Indicate end of stream.
        }

        $data = implode('', $this->buffer);

        $availableLength = strlen($data);

        if ($availableLength === 0) {
            // No data available right now (but not EOF).
            // Simulate non-blocking behavior.
            return '';
        }

        $bytesToRead = min($length, $availableLength);

        $result = substr($data, 0, $bytesToRead);

        $remainingData = substr($data, $bytesToRead);

        $this->buffer = $remainingData !== '' ? [$remainingData] : [];

        return $result;
    }

    /**
     * {@inheritDoc}
     */
    public function fgets(): string|false
    {
        if (! $this->opened()) {
            return false;
        }

        // Simulate timeout/eof checks.
        if ($this->meta['timed_out'] || $this->meta['eof']) {
            return false;
        }

        return array_shift($this->buffer) ?? false;
    }

    /**
     * {@inheritDoc}
     */
    public function fwrite(string $data): int|false
    {
        if (! $this->opened()) {
            return false;
        }

        $this->written[] = $data;

        return strlen($data);
    }

    /**
     * {@inheritDoc}
     */
    public function meta(): array
    {
        return $this->meta;
    }

    /**
     * {@inheritDoc}
     */
    public function opened(): bool
    {
        return (bool) $this->connection;
    }

    /**
     * {@inheritDoc}
     */
    public function setTimeout(int $seconds): bool
    {
        return true;
    }

    /**
     * {@inheritDoc}
     */
    public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
    {
        return true;
    }

    /**
     * Assert that the given data was written to the stream.
     */
    public function assertWritten(string $string): void
    {
        $found = false;

        foreach ($this->written as $index => $written) {
            if (str_contains($written, $string)) {
                unset($this->written[$index]);

                $found = true;

                break;
            }
        }

        Assert::assertTrue($found, "Failed asserting that the string '{$string}' was written to the stream.");
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Literal;
use DirectoryTree\ImapEngine\Connection\Tokens\QuotedString;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;

class ImapTokenizer
{
    /**
     * The current position in the buffer.
     */
    protected int $position = 0;

    /**
     * The buffer of characters read from the stream.
     */
    protected string $buffer = '';

    /**
     * Constructor.
     */
    public function __construct(
        protected StreamInterface $stream
    ) {}

    /**
     * Returns the next token from the stream.
     */
    public function nextToken(): ?Token
    {
        $this->skipWhitespace();

        $this->ensureBuffer(1);

        $char = $this->currentChar();

        if ($char === null || $char === '') {
            return null;
        }

        // Check for line feed.
        if ($char === "\n") {
            // With a valid IMAP response, we should never reach this point,
            // but in case we receive a malformed response, we will flush
            // the buffer and return null to prevent an infinite loop.
            $this->flushBuffer();

            return null;
        }

        // Check for carriage return. (\r\n)
        if ($char === "\r") {
            $this->advance(); // Consume CR

            $this->ensureBuffer(1);

            if ($this->currentChar() !== "\n") {
                throw new ImapParserException('Expected LF after CR');
            }

            $this->advance(); // Consume LF (\n)

            return new Crlf("\r\n");
        }

        // Check for parameter list opening.
        if ($char === '(') {
            $this->advance();

            return new ListOpen('(');
        }

        // Check for a parameter list closing.
        if ($char === ')') {
            $this->advance();

            return new ListClose(')');
        }

        // Check for a response group open.
        if ($char === '[') {
            $this->advance();

            return new ResponseCodeOpen('[');
        }

        // Check for response group close.
        if ($char === ']') {
            $this->advance();

            return new ResponseCodeClose(']');
        }

        // Check for angle bracket open (email addresses).
        if ($char === '<') {
            $this->advance();

            return $this->readEmailAddress();
        }

        // Check for quoted string.
        if ($char === '"') {
            return $this->readQuotedString();
        }

        // Check for literal block open.
        if ($char === '{') {
            return $this->readLiteral();
        }

        // Otherwise, parse an atom.
        return $this->readAtom();
    }

    /**
     * Skips whitespace characters (spaces and tabs only, preserving CRLF).
     */
    protected function skipWhitespace(): void
    {
        while (true) {
            $this->ensureBuffer(1);

            $char = $this->currentChar();

            // Break on EOF.
            if ($char === null || $char === '') {
                break;
            }

            // Break on CRLF.
            if ($char === "\r" || $char === "\n") {
                break;
            }

            // Break on non-whitespace.
            if ($char !== ' ' && $char !== "\t") {
                break;
            }

            $this->advance();
        }
    }

    /**
     * Reads a quoted string token.
     *
     * Quoted strings are enclosed in double quotes and may contain escaped characters.
     */
    protected function readQuotedString(): QuotedString
    {
        // Skip the opening quote.
        $this->advance();

        $value = '';

        while (true) {
            $this->ensureBuffer(1);

            $char = $this->currentChar();

            if ($char === null) {
                throw new ImapParserException(sprintf(
                    'Unterminated quoted string at buffer offset %d. Buffer: "%s"',
                    $this->position,
                    substr($this->buffer, max(0, $this->position - 10), 20)
                ));
            }

            if ($char === '\\') {
                $this->advance(); // Skip the backslash.

                $this->ensureBuffer(1);

                $escapedChar = $this->currentChar();

                if ($escapedChar === null) {
                    throw new ImapParserException('Unterminated escape sequence in quoted string');
                }

                $value .= $escapedChar;

                $this->advance();

                continue;
            }

            if ($char === '"') {
                $this->advance(); // Skip the closing quote.

                break;
            }

            $value .= $char;

            $this->advance();
        }

        return new QuotedString($value);
    }

    /**
     * Reads a literal token.
     *
     * Literal blocks in IMAP have the form {<length>}\r\n<data>.
     */
    protected function readLiteral(): Literal
    {
        // Skip the opening '{'.
        $this->advance();

        // This will contain the size of the literal block in a sequence of digits.
        // {<size>}\r\n<data>
        $numStr = '';

        while (true) {
            $this->ensureBuffer(1);

            $char = $this->currentChar();

            if ($char === null) {
                throw new ImapParserException('Unterminated literal specifier');
            }

            if ($char === '}') {
                $this->advance(); // Skip the '}'.

                break;
            }

            $numStr .= $char;

            $this->advance();
        }

        // Expect carriage return after the literal specifier.
        $this->ensureBuffer(2);

        // Get the carriage return.
        $crlf = substr($this->buffer, $this->position, 2);

        if ($crlf !== "\r\n") {
            throw new ImapParserException('Expected CRLF after literal specifier');
        }

        // Skip the CRLF.
        $this->advance(2);

        $length = (int) $numStr;

        // Use any data that is already in our buffer.
        $available = strlen($this->buffer) - $this->position;

        if ($available >= $length) {
            $literal = substr($this->buffer, $this->position, $length);

            $this->advance($length);
        } else {
            // Consume whatever is available without flushing the whole buffer.
            $literal = substr($this->buffer, $this->position);

            $consumed = strlen($literal);

            // Advance the pointer by the number of bytes we took.
            $this->advance($consumed);

            // Calculate how many bytes are still needed.
            $remaining = $length - $consumed;

            // Read the missing bytes from the stream.
            $data = $this->stream->read($remaining);

            if ($data === false || strlen($data) !== $remaining) {
                throw new ImapStreamException('Unexpected end of stream while trying to fill the buffer');
            }

            $literal .= $data;
        }

        // Verify that the literal length matches the expected length.
        if (strlen($literal) !== $length) {
            throw new ImapParserException(sprintf(
                'Literal length mismatch: expected %d, got %d',
                $length,
                strlen($literal)
            ));
        }

        return new Literal($literal);
    }

    /**
     * Reads an atom token.
     *
     * ATOMs are sequences of printable ASCII characters that do not contain delimiters.
     */
    protected function readAtom(): Atom
    {
        $value = '';

        while (true) {
            $this->ensureBuffer(1);
            $char = $this->currentChar();

            if ($char === null) {
                break;
            }

            if (! $this->isValidAtomCharacter($char)) {
                break;
            }

            // Append the character to the value.
            $value .= $char;

            $this->advance();
        }

        return new Atom($value);
    }

    /**
     * Reads an email address token enclosed in angle brackets.
     *
     * Email addresses are enclosed in angle brackets ("<" and ">").
     *
     * For example "<johndoe@email.com>"
     */
    protected function readEmailAddress(): ?EmailAddress
    {
        $value = '';

        while (true) {
            $this->ensureBuffer(1);

            $char = $this->currentChar();

            if ($char === null) {
                throw new ImapParserException('Unterminated email address, expected ">"');
            }

            if ($char === '>') {
                $this->advance(); // Skip the closing '>'.

                break;
            }

            $value .= $char;

            $this->advance();
        }

        return new EmailAddress($value);
    }

    /**
     * Ensures that at least the given length in characters are available in the buffer.
     */
    protected function ensureBuffer(int $length): void
    {
        // If we have enough data in the buffer, return early.
        while ((strlen($this->buffer) - $this->position) < $length) {
            $data = $this->stream->fgets();

            if ($data === false) {
                return;
            }

            $this->buffer .= $data;
        }
    }

    /**
     * Returns the current character in the buffer.
     */
    protected function currentChar(): ?string
    {
        return $this->buffer[$this->position] ?? null;
    }

    /**
     * Advances the internal pointer by $n characters.
     */
    protected function advance(int $n = 1): void
    {
        $this->position += $n;

        // If we have consumed the entire buffer, reset it.
        if ($this->position >= strlen($this->buffer)) {
            $this->flushBuffer();
        }
    }

    /**
     * Flush the buffer and reset the position.
     */
    protected function flushBuffer(): void
    {
        $this->buffer = '';
        $this->position = 0;
    }

    /**
     * Determine if the given character is a valid atom character.
     */
    protected function isValidAtomCharacter(string $char): bool
    {
        // Get the ASCII code.
        $code = ord($char);

        // Allow only printable ASCII (32-126).
        if ($code < 32 || $code > 126) {
            return false;
        }

        // Delimiters are not allowed inside ATOMs.
        if ($this->isDelimiter($char)) {
            return false;
        }

        return true;
    }

    /**
     * Determine if the given character is a delimiter for tokenizing responses.
     */
    protected function isDelimiter(string $char): bool
    {
        // This delimiter list includes additional characters (such as square
        // brackets, curly braces, and angle brackets) to ensure that tokens
        // like the response code group brackets are split out. This is fine
        // for tokenizing responses, even though it’s more restrictive
        // than the IMAP atom definition in RFC 3501 (section 9).
        return in_array($char, [' ', '(', ')', '[', ']', '{', '}', '<', '>'], true);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

trait ComparesFolders
{
    /**
     * Determine if two folders are the same.
     */
    protected function isSameFolder(FolderInterface $a, FolderInterface $b): bool
    {
        return $a->path() === $b->path()
            && $a->mailbox()->config('host') === $b->mailbox()->config('host')
            && $a->mailbox()->config('username') === $b->mailbox()->config('username');
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;

class MessageResponseParser
{
    /**
     * Get the flags from an untagged response.
     *
     * @return array<string, string[]>
     */
    public static function getFlags(UntaggedResponse $response): array
    {
        $data = $response->tokenAt(3);

        $uid = $data->lookup('UID')->value;
        $flags = $data->lookup('FLAGS')->values();

        return [$uid => $flags];
    }

    /**
     * Get the body header from an untagged response.
     *
     * @return array<string, string>
     */
    public static function getBodyHeader(UntaggedResponse $response): array
    {
        $data = $response->tokenAt(3);

        $uid = $data->lookup('UID')->value;
        $headers = $data->lookup('[HEADER]')->value;

        return [$uid => $headers];
    }

    /**
     * Get the body text from an untagged response.
     *
     * @return array<string, string>
     */
    public static function getBodyText(UntaggedResponse $response): array
    {
        $data = $response->tokenAt(3);

        $uid = $data->lookup('UID')->value;
        $contents = $data->lookup('[TEXT]')->value;

        return [$uid => $contents];
    }

    /**
     * Get the UID from a tagged move or copy response.
     */
    public static function getUidFromCopy(TaggedResponse $response): ?int
    {
        if (! $data = $response->tokenAt(2)) {
            return null;
        }

        if (! $data instanceof ResponseCodeData) {
            return null;
        }

        if (! $value = $data->tokenAt(3)?->value) {
            return null;
        }

        return (int) $value;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Testing;

use DirectoryTree\ImapEngine\HasFlags;
use DirectoryTree\ImapEngine\HasParsedMessage;
use DirectoryTree\ImapEngine\MessageInterface;
use DirectoryTree\ImapEngine\Support\Str;

class FakeMessage implements MessageInterface
{
    use HasFlags, HasParsedMessage;

    /**
     * Constructor.
     */
    public function __construct(
        protected int $uid,
        protected array $flags = [],
        protected string $contents = '',
    ) {}

    /**
     * {@inheritDoc}
     */
    public function uid(): int
    {
        return $this->uid;
    }

    /**
     * {@inheritDoc}
     */
    public function is(MessageInterface $message): bool
    {
        return $message instanceof self
            && $this->uid === $message->uid
            && $this->flags === $message->flags
            && $this->contents === $message->contents;
    }

    /**
     * {@inheritDoc}
     */
    public function flag(mixed $flag, string $operation, bool $expunge = false): void
    {
        $flag = Str::enum($flag);

        if ($operation === '+') {
            $this->flags = array_unique([...$this->flags, $flag]);
        } else {
            $this->flags = array_filter($this->flags, fn (string $value) => $value !== $flag);
        }
    }

    /**
     * {@inheritDoc}
     */
    public function flags(): array
    {
        return $this->flags;
    }

    /**
     * {@inheritDoc}
     */
    protected function isEmpty(): bool
    {
        return empty($this->contents);
    }

    /**
     * {@inheritDoc}
     */
    public function __toString(): string
    {
        return $this->contents;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;

class ImapParser
{
    /**
     * The current token being parsed.
     *
     * Expected to be an associative array with keys like "type" and "value".
     */
    protected ?Token $currentToken = null;

    /**
     * Constructor.
     */
    public function __construct(
        protected ImapTokenizer $tokenizer
    ) {}

    /**
     * Get the next response from the tokenizer.
     */
    public function next(): Data|Token|Response|null
    {
        // Attempt to load the first token.
        if (! $this->currentToken) {
            $this->advance();
        }

        // No token was found, return null.
        if (! $this->currentToken) {
            return null;
        }

        // If the token indicates the beginning of a list, parse it.
        if ($this->currentToken instanceof ListOpen) {
            return $this->parseList();
        }

        // If the token is an ATOM, check its value for special markers.
        if ($this->currentToken instanceof Atom) {
            // '*' marks an untagged response.
            if ($this->currentToken->value === '*') {
                return $this->parseUntaggedResponse();
            }

            // '+' marks a continuation response.
            if ($this->currentToken->value === '+') {
                return $this->parseContinuationResponse();
            }

            // If it's an ATOM and not '*' or '+', it's likely a tagged response.
            return $this->parseTaggedResponse();
        }

        return $this->parseElement();
    }

    /**
     * Parse an untagged response.
     *
     * An untagged response begins with the '*' token. It may contain
     * multiple elements, including lists and response codes.
     */
    protected function parseUntaggedResponse(): UntaggedResponse
    {
        // Capture the initial '*' token.
        $elements[] = clone $this->currentToken;

        $this->advance();

        // Collect all tokens until the end-of-response marker.
        while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) {
            $elements[] = $this->parseElement();
        }

        // If the end-of-response marker (CRLF) is present, consume it.
        if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) {
            $this->currentToken = null;
        } else {
            throw new ImapParserException('Unterminated untagged response');
        }

        return new UntaggedResponse($elements);
    }

    /**
     * Parse a continuation response.
     *
     * A continuation response starts with a '+' token, indicating
     * that the server expects additional data from the client.
     */
    protected function parseContinuationResponse(): ContinuationResponse
    {
        // Capture the initial '+' token.
        $elements[] = clone $this->currentToken;

        $this->advance();

        // Collect all tokens until the CRLF marker.
        while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) {
            $elements[] = $this->parseElement();
        }

        // Consume the CRLF marker if present.
        if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) {
            $this->currentToken = null;
        } else {
            throw new ImapParserException('Unterminated continuation response');
        }

        return new ContinuationResponse($elements);
    }

    /**
     * Parse a tagged response.
     *
     * A tagged response begins with a tag (which is not '*' or '+')
     * and is followed by a status and optional data.
     */
    protected function parseTaggedResponse(): TaggedResponse
    {
        // Capture the initial TAG token.
        $tokens[] = clone $this->currentToken;

        $this->advance();

        // Collect tokens until the end-of-response marker is reached.
        while ($this->currentToken && ! $this->isEndOfResponseToken($this->currentToken)) {
            $tokens[] = $this->parseElement();
        }

        // Consume the CRLF marker if present.
        if ($this->currentToken && $this->isEndOfResponseToken($this->currentToken)) {
            $this->currentToken = null;
        } else {
            throw new ImapParserException('Unterminated tagged response');
        }

        return new TaggedResponse($tokens);
    }

    /**
     * Parses a bracket group of elements delimited by '[' and ']'.
     *
     * Bracket groups are used to represent response codes.
     */
    protected function parseBracketGroup(): ResponseCodeData
    {
        // Consume the opening '[' token.
        $this->advance();

        $elements = [];

        while (
            $this->currentToken
            && ! $this->currentToken instanceof ResponseCodeClose
            && ! $this->isEndOfResponseToken($this->currentToken)
        ) {
            $elements[] = $this->parseElement();
        }

        if ($this->currentToken === null) {
            throw new ImapParserException('Unterminated bracket group in response');
        }

        // Consume the closing ']' token.
        $this->advance();

        return new ResponseCodeData($elements);
    }

    /**
     * Parses a list of elements delimited by '(' and ')'.
     *
     * Lists are handled recursively, as a list may contain nested lists.
     */
    protected function parseList(): ListData
    {
        // Consume the opening '(' token.
        $this->advance();

        $elements = [];

        // Continue to parse elements until we find the corresponding ')'.
        while (
            $this->currentToken
            && ! $this->currentToken instanceof ListClose
            && ! $this->isEndOfResponseToken($this->currentToken)
        ) {
            $elements[] = $this->parseElement();
        }

        // If we reached the end without finding a closing ')', throw an exception.
        if ($this->currentToken === null) {
            throw new ImapParserException('Unterminated list in response');
        }

        // Consume the closing ')' token.
        $this->advance();

        return new ListData($elements);
    }

    /**
     * Parses a single element, which might be a list or a simple token.
     */
    protected function parseElement(): Data|Token|null
    {
        // If there is no current token, return null.
        if ($this->currentToken === null) {
            return null;
        }

        // If the token indicates the start of a list, parse it as a list.
        if ($this->currentToken instanceof ListOpen) {
            return $this->parseList();
        }

        // If the token indicates the start of a group, parse it as a group.
        if ($this->currentToken instanceof ResponseCodeOpen) {
            return $this->parseBracketGroup();
        }

        // Otherwise, capture the current token.
        $token = clone $this->currentToken;

        $this->advance();

        return $token;
    }

    /**
     * Advance to the next token from the tokenizer.
     */
    protected function advance(): void
    {
        $this->currentToken = $this->tokenizer->nextToken();
    }

    /**
     * Determine if the given token marks the end of a response.
     */
    protected function isEndOfResponseToken(Token $token): bool
    {
        return $token instanceof Crlf;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use Stringable;

class ImapCommand implements Stringable
{
    /**
     * The compiled command lines.
     *
     * @var string[]
     */
    protected ?array $compiled = null;

    /**
     * Constructor.
     */
    public function __construct(
        protected string $tag,
        protected string $command,
        protected array $tokens = [],
    ) {}

    /**
     * Get the IMAP tag.
     */
    public function tag(): string
    {
        return $this->tag;
    }

    /**
     * Get the IMAP command.
     */
    public function command(): string
    {
        return $this->command;
    }

    /**
     * Get the IMAP tokens.
     */
    public function tokens(): array
    {
        return $this->tokens;
    }

    /**
     * Compile the command into lines for transmission.
     *
     * @return string[]
     */
    public function compile(): array
    {
        if (is_array($this->compiled)) {
            return $this->compiled;
        }

        $lines = [];

        $line = trim("{$this->tag} {$this->command}");

        foreach ($this->tokens as $token) {
            if (is_array($token)) {
                // For tokens provided as arrays, the first element is a placeholder
                // (for example, "{20}") that signals a literal value will follow.
                // The second element holds the actual literal content.
                [$placeholder, $literal] = $token;

                $lines[] = "{$line} {$placeholder}";

                $line = $literal;
            } else {
                $line .= " {$token}";
            }
        }

        $lines[] = $line;

        return $this->compiled = $lines;
    }

    /**
     * Get a redacted version of the command for safe exposure.
     */
    public function redacted(): ImapCommand
    {
        return new static($this->tag, $this->command, array_map(
            function (mixed $token) {
                return is_array($token)
                    ? array_map(fn () => '[redacted]', $token)
                    : '[redacted]';
            }, $this->tokens)
        );
    }

    /**
     * Get the command as a string.
     */
    public function __toString(): string
    {
        return implode("\r\n", $this->compile());
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Streams;

use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;

class ImapStream implements StreamInterface
{
    /**
     * The underlying PHP stream resource.
     *
     * @var resource|null
     */
    protected mixed $stream = null;

    /**
     * {@inheritDoc}
     */
    public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool
    {
        $this->stream = stream_socket_client(
            "{$transport}://{$host}:{$port}",
            $errno,
            $errstr,
            $timeout,
            STREAM_CLIENT_CONNECT,
            stream_context_create($options)
        );

        if (! $this->stream) {
            throw new ImapConnectionFailedException('Stream failed to open: '.$errstr, $errno);
        }

        return true;
    }

    /**
     * {@inheritDoc}
     */
    public function close(): void
    {
        if ($this->opened()) {
            fclose($this->stream);
        }

        $this->stream = null;
    }

    /**
     * {@inheritDoc}
     */
    public function read(int $length): string|false
    {
        if (! $this->opened()) {
            return false;
        }

        $data = '';

        while (strlen($data) < $length && ! feof($this->stream)) {
            $chunk = fread($this->stream, $length - strlen($data));

            if ($chunk === false) {
                return false;
            }

            $data .= $chunk;
        }

        return $data;
    }

    /**
     * {@inheritDoc}
     */
    public function fgets(): string|false
    {
        return $this->opened() ? fgets($this->stream) : false;
    }

    /**
     * {@inheritDoc}
     */
    public function fwrite(string $data): int|false
    {
        return $this->opened() ? fwrite($this->stream, $data) : false;
    }

    /**
     * {@inheritDoc}
     */
    public function meta(): array
    {
        return $this->opened() ? stream_get_meta_data($this->stream) : [];
    }

    /**
     * {@inheritDoc}
     */
    public function opened(): bool
    {
        return is_resource($this->stream);
    }

    /**
     * {@inheritDoc}
     */
    public function setTimeout(int $seconds): bool
    {
        return stream_set_timeout($this->stream, $seconds);
    }

    /**
     * {@inheritDoc}
     */
    public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
    {
        return stream_socket_enable_crypto($this->stream, $enabled, $method);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses\Data;

use DirectoryTree\ImapEngine\Connection\Responses\HasTokens;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Stringable;

abstract class Data implements Stringable
{
    use HasTokens;

    /**
     * Constructor.
     */
    public function __construct(
        protected array $tokens
    ) {}

    /**
     * Get the tokens.
     *
     * @return Token[]|Data[]
     */
    public function tokens(): array
    {
        return $this->tokens;
    }

    /**
     * Get the first token.
     */
    public function first(): Token|Data|null
    {
        return $this->tokens[0] ?? null;
    }

    /**
     * Get the last token.
     */
    public function last(): Token|Data|null
    {
        return $this->tokens[count($this->tokens) - 1] ?? null;
    }

    /**
     * Determine if the data contains a specific value.
     */
    public function contains(array|string $needles): bool
    {
        $haystack = $this->values();

        foreach ((array) $needles as $needle) {
            if (! in_array($needle, $haystack)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Get all the token's values.
     */
    public function values(): array
    {
        return array_map(function (Token|Data $token) {
            return $token instanceof Data
                ? $token->values()
                : $token->value;
        }, $this->tokens);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use Generator;

class Mbox
{
    /**
     * Constructor.
     */
    public function __construct(
        protected string $filepath
    ) {}

    /**
     * Get the messages from the mbox file.
     */
    public function messages(
        string $delimiter = '/^From\s+\S+\s+' // From
        .'(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+' // Day
        .'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+' // Month
        .'\d{1,2}\s+\d{2}:\d{2}:\d{2}' // Time (HH:MM:SS)
        .'(?:\s+[+-]\d{4})?' // Optional Timezone ("+0000")
        .'\s+\d{4}/' // Year
    ): Generator {
        if (! $handle = fopen($this->filepath, 'r')) {
            throw new RuntimeException('Failed to open mbox file: '.$this->filepath);
        }

        $buffer = '';

        while (($line = fgets($handle)) !== false) {
            if (preg_match($delimiter, $line) && $buffer !== '') {
                yield new FileMessage($buffer);

                $buffer = '';
            }

            $buffer .= $line;
        }

        if ($buffer !== '') {
            yield new FileMessage($buffer);
        }

        fclose($handle);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Collections;

use DirectoryTree\ImapEngine\MessageInterface;

/**
 * @template-extends PaginatedCollection<array-key, \DirectoryTree\ImapEngine\MessageInterface|\DirectoryTree\ImapEngine\Message>
 */
class MessageCollection extends PaginatedCollection
{
    /**
     * Find a message by its UID.
     *
     * @return \DirectoryTree\ImapEngine\Message|null
     */
    public function find(int $uid): ?MessageInterface
    {
        return $this->first(
            fn (MessageInterface $message) => $message->uid() === $uid
        );
    }

    /**
     * Find a message by its UID or throw an exception.
     *
     * @return \DirectoryTree\ImapEngine\Message
     */
    public function findOrFail(int $uid): MessageInterface
    {
        return $this->firstOrFail(
            fn (MessageInterface $message) => $message->uid() === $uid
        );
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\Response;

class Result
{
    /**
     * Constructor.
     */
    public function __construct(
        protected ImapCommand $command,
        protected array $responses = [],
    ) {}

    /**
     * Get the executed command.
     */
    public function command(): ImapCommand
    {
        return $this->command;
    }

    /**
     * Add a response to the result.
     */
    public function addResponse(Response $response): void
    {
        $this->responses[] = $response;
    }

    /**
     * Get the recently received responses.
     */
    public function responses(): ResponseCollection
    {
        return new ResponseCollection($this->responses);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Support;

use BadMethodCallException;
use Error;

trait ForwardsCalls
{
    /**
     * Forward a method call to the given object.
     */
    protected function forwardCallTo(object $object, string $method, array $parameters): mixed
    {
        try {
            return $object->{$method}(...$parameters);
        } catch (Error|BadMethodCallException $e) {
            $pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';

            if (! preg_match($pattern, $e->getMessage(), $matches)) {
                throw $e;
            }

            if ($matches['class'] != get_class($object) ||
                $matches['method'] != $method) {
                throw $e;
            }

            static::throwBadMethodCallException($method);
        }
    }

    /**
     * Throw a bad method call exception for the given method.
     */
    protected static function throwBadMethodCallException(string $method)
    {
        throw new BadMethodCallException(sprintf(
            'Call to undefined method %s::%s()', static::class, $method
        ));
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses\Data;

use DirectoryTree\ImapEngine\Connection\Tokens\Token;

class ListData extends Data
{
    /**
     * Lookup the value of the given field in the list.
     */
    public function lookup(string $field): Data|Token|null
    {
        foreach ($this->tokens as $index => $token) {
            if ((string) $token === $field) {
                return $this->tokenAt(++$index);
            }
        }

        return null;
    }

    /**
     * Get the list as a string.
     */
    public function __toString(): string
    {
        return sprintf('(%s)', implode(
            ' ', array_map('strval', $this->tokens)
        ));
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Loggers;

class FileLogger extends Logger
{
    /**
     * Constructor.
     */
    public function __construct(
        protected string $path
    ) {}

    /**
     * {@inheritDoc}
     */
    public function write(string $message): void
    {
        file_put_contents($this->path, $message, FILE_APPEND);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use Stringable;

class Response implements Arrayable, Stringable
{
    use HasTokens;

    /**
     * Constructor.
     */
    public function __construct(
        protected array $tokens
    ) {}

    /**
     * Get the response tokens.
     *
     * @return Token[]|Data[]
     */
    public function tokens(): array
    {
        return $this->tokens;
    }

    /**
     * Get the instance as an array.
     */
    public function toArray(): array
    {
        return array_map(function (Token|Data $token) {
            return $token instanceof Data
                ? $token->values()
                : $token->value;
        }, $this->tokens);
    }

    /**
     * Get a JSON representation of the response tokens.
     */
    public function __toString(): string
    {
        return implode(' ', $this->tokens);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Support\Traits\Conditionable;

trait QueriesMessages
{
    use Conditionable, ForwardsCalls;

    /**
     * The query builder instance.
     */
    protected ImapQueryBuilder $query;

    /**
     * The current page.
     */
    protected int $page = 1;

    /**
     * The fetch limit.
     */
    protected ?int $limit = null;

    /**
     * Whether to fetch the message body.
     */
    protected bool $fetchBody = false;

    /**
     * Whether to fetch the message flags.
     */
    protected bool $fetchFlags = false;

    /**
     * Whether to fetch the message headers.
     */
    protected bool $fetchHeaders = false;

    /**
     * The fetch order.
     */
    protected string $fetchOrder = 'desc';

    /**
     * Whether to leave messages fetched as unread by default.
     */
    protected bool $fetchAsUnread = true;

    /**
     * The methods that should be returned from query builder.
     */
    protected array $passthru = ['toimap', 'isempty'];

    /**
     * Handle dynamic method calls into the query builder.
     */
    public function __call(string $method, array $parameters): mixed
    {
        if (in_array(strtolower($method), $this->passthru)) {
            return $this->query->{$method}(...$parameters);
        }

        $this->forwardCallTo($this->query, $method, $parameters);

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function leaveUnread(): MessageQueryInterface
    {
        $this->fetchAsUnread = true;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function markAsRead(): MessageQueryInterface
    {
        $this->fetchAsUnread = false;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function limit(int $limit, int $page = 1): MessageQueryInterface
    {
        if ($page >= 1) {
            $this->page = $page;
        }

        $this->limit = $limit;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function getLimit(): ?int
    {
        return $this->limit;
    }

    /**
     * {@inheritDoc}
     */
    public function setLimit(int $limit): MessageQueryInterface
    {
        $this->limit = max($limit, 1);

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function getPage(): int
    {
        return $this->page;
    }

    /**
     * {@inheritDoc}
     */
    public function setPage(int $page): MessageQueryInterface
    {
        $this->page = $page;

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function isFetchingBody(): bool
    {
        return $this->fetchBody;
    }

    /**
     * {@inheritDoc}
     */
    public function isFetchingFlags(): bool
    {
        return $this->fetchFlags;
    }

    /**
     * {@inheritDoc}
     */
    public function isFetchingHeaders(): bool
    {
        return $this->fetchHeaders;
    }

    /**
     * {@inheritDoc}
     */
    public function withFlags(): MessageQueryInterface
    {
        return $this->setFetchFlags(true);
    }

    /**
     * {@inheritDoc}
     */
    public function withBody(): MessageQueryInterface
    {
        return $this->setFetchBody(true);
    }

    /**
     * {@inheritDoc}
     */
    public function withHeaders(): MessageQueryInterface
    {
        return $this->setFetchHeaders(true);
    }

    /**
     * {@inheritDoc}
     */
    public function withoutBody(): MessageQueryInterface
    {
        return $this->setFetchBody(false);
    }

    /**
     * {@inheritDoc}
     */
    public function withoutHeaders(): MessageQueryInterface
    {
        return $this->setFetchHeaders(false);
    }

    /**
     * {@inheritDoc}
     */
    public function withoutFlags(): MessageQueryInterface
    {
        return $this->setFetchFlags(false);
    }

    /**
     * Set whether to fetch the flags.
     */
    protected function setFetchFlags(bool $fetchFlags): MessageQueryInterface
    {
        $this->fetchFlags = $fetchFlags;

        return $this;
    }

    /**
     * Set the fetch body flag.
     */
    protected function setFetchBody(bool $fetchBody): MessageQueryInterface
    {
        $this->fetchBody = $fetchBody;

        return $this;
    }

    /**
     * Set whether to fetch the headers.
     */
    protected function setFetchHeaders(bool $fetchHeaders): MessageQueryInterface
    {
        $this->fetchHeaders = $fetchHeaders;

        return $this;
    }

    /** {@inheritDoc} */
    public function setFetchOrder(string $fetchOrder): MessageQueryInterface
    {
        $fetchOrder = strtolower($fetchOrder);

        if (in_array($fetchOrder, ['asc', 'desc'])) {
            $this->fetchOrder = $fetchOrder;
        }

        return $this;
    }

    /**
     * {@inheritDoc}
     */
    public function getFetchOrder(): string
    {
        return $this->fetchOrder;
    }

    /**
     * {@inheritDoc}
     */
    public function setFetchOrderAsc(): MessageQueryInterface
    {
        return $this->setFetchOrder('asc');
    }

    /**
     * {@inheritDoc}
     */
    public function setFetchOrderDesc(): MessageQueryInterface
    {
        return $this->setFetchOrder('desc');
    }

    /**
     * {@inheritDoc}
     */
    public function oldest(): MessageQueryInterface
    {
        return $this->setFetchOrder('asc');
    }

    /**
     * {@inheritDoc}
     */
    public function newest(): MessageQueryInterface
    {
        return $this->setFetchOrder('desc');
    }
}

<?php

namespace DirectoryTree\ImapEngine\Collections;

use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use Illuminate\Support\Collection;

/**
 * @template-extends Collection<array-key, Response>
 */
class ResponseCollection extends Collection
{
    /**
     * Filter the collection to only tagged responses.
     *
     * @return self<array-key, TaggedResponse>
     */
    public function tagged(): self
    {
        return $this->whereInstanceOf(TaggedResponse::class);
    }

    /**
     * Filter the collection to only untagged responses.
     *
     * @return self<array-key, UntaggedResponse>
     */
    public function untagged(): self
    {
        return $this->whereInstanceOf(UntaggedResponse::class);
    }

    /**
     * Filter the collection to only continuation responses.
     *
     * @return self<array-key, ContinuationResponse>
     */
    public function continuation(): self
    {
        return $this->whereInstanceOf(ContinuationResponse::class);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

use DirectoryTree\ImapEngine\Connection\ImapCommand;
use DirectoryTree\ImapEngine\Connection\Responses\Response;

class ImapCommandException extends Exception
{
    /**
     * The IMAP response.
     */
    protected Response $response;

    /**
     * The failed IMAP command.
     */
    protected ImapCommand $command;

    /**
     * Make a new instance from a failed command and response.
     */
    public static function make(ImapCommand $command, Response $response): static
    {
        $exception = new static(sprintf('IMAP command "%s" failed. Response: "%s"', $command, $response));

        $exception->command = $command;
        $exception->response = $response;

        return $exception;
    }

    /**
     * Get the failed IMAP command.
     */
    public function command(): ImapCommand
    {
        return $this->command;
    }

    /**
     * Get the IMAP response.
     */
    public function response(): Response
    {
        return $this->response;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Collections;

use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;

/**
 * @template TKey of array-key
 *
 * @template-covariant TValue
 *
 * @template-extends Collection<TKey, TValue>
 */
class PaginatedCollection extends Collection
{
    /**
     * The total number of items.
     */
    protected int $total = 0;

    /**
     * Paginate the current collection.
     *
     * @return LengthAwarePaginator<TKey, TValue>
     */
    public function paginate(int $perPage = 15, ?int $page = null, string $pageName = 'page', bool $prepaginated = false): LengthAwarePaginator
    {
        $total = $this->total ?: $this->count();

        $results = ! $prepaginated && $total ? $this->forPage($page, $perPage) : $this;

        return $this->paginator($results, $total, $perPage, $page, $pageName);
    }

    /**
     * Create a new length-aware paginator instance.
     *
     * @return LengthAwarePaginator<TKey, TValue>
     */
    protected function paginator(Collection $items, int $total, int $perPage, ?int $currentPage, string $pageName): LengthAwarePaginator
    {
        return new LengthAwarePaginator($items, $total, $perPage, $currentPage, $pageName);
    }

    /**
     * Get or set the total amount.
     */
    public function total(?int $total = null): ?int
    {
        if (is_null($total)) {
            return $this->total;
        }

        return $this->total = $total;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use BadMethodCallException;

class FileMessage implements MessageInterface
{
    use HasFlags, HasParsedMessage;

    /**
     * Constructor.
     */
    public function __construct(
        protected string $contents
    ) {}

    /**
     * {@inheritDoc}
     */
    public function uid(): int
    {
        throw new BadMethodCallException('FileMessage does not support a UID');
    }

    /**
     * {@inheritDoc}
     */
    public function flag(mixed $flag, string $operation, bool $expunge = false): void
    {
        throw new BadMethodCallException('FileMessage does not support flagging');
    }

    /**
     * Get the string representation of the message.
     */
    public function __toString(): string
    {
        return $this->contents;
    }

    /**
     * Determine if this message is equal to another.
     */
    public function is(MessageInterface $message): bool
    {
        return $message instanceof self
            && $this->contents === $message->contents;
    }

    /**
     * Get the message flags.
     */
    public function flags(): array
    {
        return [];
    }

    /**
     * Determine if the message is empty.
     */
    protected function isEmpty(): bool
    {
        return empty($this->contents);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use BackedEnum;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Support\Str;

trait HasFlags
{
    /**
     * {@inheritDoc}
     */
    public function markRead(): void
    {
        $this->markSeen();
    }

    /**
     * {@inheritDoc}
     */
    public function markUnread(): void
    {
        $this->unmarkSeen();
    }

    /**
     * {@inheritDoc}
     */
    public function markSeen(): void
    {
        $this->flag(ImapFlag::Seen, '+');
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkSeen(): void
    {
        $this->flag(ImapFlag::Seen, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function markAnswered(): void
    {
        $this->flag(ImapFlag::Answered, '+');
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkAnswered(): void
    {
        $this->flag(ImapFlag::Answered, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function markFlagged(): void
    {
        $this->flag(ImapFlag::Flagged, '+');
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkFlagged(): void
    {
        $this->flag(ImapFlag::Flagged, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function markDeleted(bool $expunge = false): void
    {
        $this->flag(ImapFlag::Deleted, '+', $expunge);
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkDeleted(): void
    {
        $this->flag(ImapFlag::Deleted, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function markDraft(): void
    {
        $this->flag(ImapFlag::Draft, '+');
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkDraft(): void
    {
        $this->flag(ImapFlag::Draft, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function markRecent(): void
    {
        $this->flag(ImapFlag::Recent, '+');
    }

    /**
     * {@inheritDoc}
     */
    public function unmarkRecent(): void
    {
        $this->flag(ImapFlag::Recent, '-');
    }

    /**
     * {@inheritDoc}
     */
    public function isSeen(): bool
    {
        return $this->hasFlag(ImapFlag::Seen);
    }

    /**
     * {@inheritDoc}
     */
    public function isAnswered(): bool
    {
        return $this->hasFlag(ImapFlag::Answered);
    }

    /**
     * {@inheritDoc}
     */
    public function isFlagged(): bool
    {
        return $this->hasFlag(ImapFlag::Flagged);
    }

    /**
     * {@inheritDoc}
     */
    public function isDeleted(): bool
    {
        return $this->hasFlag(ImapFlag::Deleted);
    }

    /**
     * {@inheritDoc}
     */
    public function isDraft(): bool
    {
        return $this->hasFlag(ImapFlag::Draft);
    }

    /**
     * {@inheritDoc}
     */
    public function isRecent(): bool
    {
        return $this->hasFlag(ImapFlag::Recent);
    }

    /**
     * {@inheritDoc}
     */
    public function hasFlag(BackedEnum|string $flag): bool
    {
        return in_array(Str::enum($flag), $this->flags());
    }

    /**
     * {@inheritDoc}
     */
    abstract public function flags(): array;

    /**
     * {@inheritDoc}
     */
    abstract public function flag(mixed $flag, string $operation, bool $expunge = false): void;
}

<?php

namespace DirectoryTree\ImapEngine;

use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;

class Address implements Arrayable, JsonSerializable
{
    /**
     * Constructor.
     */
    public function __construct(
        protected string $email,
        protected string $name,
    ) {}

    /**
     * Get the address's email.
     */
    public function email(): string
    {
        return $this->email;
    }

    /**
     * Get the address's name.
     */
    public function name(): string
    {
        return $this->name;
    }

    /**
     * Get the array representation of the address.
     */
    public function toArray(): array
    {
        return [
            'email' => $this->email,
            'name' => $this->name,
        ];
    }

    /**
     * Get the JSON representation of the address.
     */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Connection\ConnectionInterface;

interface MailboxInterface
{
    /**
     * Get mailbox configuration values.
     */
    public function config(?string $key = null, mixed $default = null): mixed;

    /**
     * Get the mailbox connection.
     */
    public function connection(): ConnectionInterface;

    /**
     * Determine if connection was established.
     */
    public function connected(): bool;

    /**
     * Force a reconnection to the server.
     */
    public function reconnect(): void;

    /**
     * Connect to the server.
     */
    public function connect(?ConnectionInterface $connection = null): void;

    /**
     * Disconnect from server.
     */
    public function disconnect(): void;

    /**
     * Get the mailbox's inbox folder.
     */
    public function inbox(): FolderInterface;

    /**
     * Begin querying for mailbox folders.
     */
    public function folders(): FolderRepositoryInterface;

    /**
     * Get the mailbox's capabilities.
     */
    public function capabilities(): array;

    /**
     * Select the given folder.
     */
    public function select(FolderInterface $folder, bool $force = false): void;

    /**
     * Determine if the given folder is selected.
     */
    public function selected(FolderInterface $folder): bool;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Loggers;

abstract class Logger implements LoggerInterface
{
    /**
     * Write a message to the log.
     */
    abstract protected function write(string $message): void;

    /**
     * {@inheritDoc}
     */
    public function sent(string $message): void
    {
        $this->write(sprintf('%s: >> %s', $this->date(), $message).PHP_EOL);
    }

    /**
     * {@inheritDoc}
     */
    public function received(string $message): void
    {
        $this->write(sprintf('%s: << %s', $this->date(), $message).PHP_EOL);
    }

    /**
     * Get the current date and time.
     */
    protected function date(): string
    {
        return date('Y-m-d H:i:s');
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Tokens\Atom;

class TaggedResponse extends Response
{
    /**
     * Get the response tag.
     */
    public function tag(): Atom
    {
        return $this->tokens[0];
    }

    /**
     * Get the response status token.
     */
    public function status(): Atom
    {
        return $this->tokens[1];
    }

    /**
     * Get the response data tokens.
     *
     * @return Atom[]
     */
    public function data(): array
    {
        return array_slice($this->tokens, 2);
    }

    /**
     * Determine if the response was successful.
     */
    public function successful(): bool
    {
        return $this->status()->value === 'OK';
    }

    /**
     * Determine if the response failed.
     */
    public function failed(): bool
    {
        return in_array($this->status()->value, ['NO', 'BAD']);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use Generator;

interface ConnectionInterface
{
    /**
     * Open a new connection.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-state-and-flow-diagram
     */
    public function connect(string $host, ?int $port = null, array $options = []): void;

    /**
     * Close the current connection.
     */
    public function disconnect(): void;

    /**
     * Determine if the current session is connected.
     */
    public function connected(): bool;

    /**
     * Send a "LOGIN" command.
     *
     * Login to a new session.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-login-command
     */
    public function login(string $user, string $password): TaggedResponse;

    /**
     * Send a "LOGOUT" command.
     *
     * Logout of the current server session.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-logout-command
     */
    public function logout(): void;

    /**
     * Send an "AUTHENTICATE" command.
     *
     * Authenticate the current session.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-authenticate-command
     */
    public function authenticate(string $user, string $token): TaggedResponse;

    /**
     * Send a "STARTTLS" command.
     *
     * Upgrade the current plaintext connection to a secure TLS-encrypted connection.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-starttls-command
     */
    public function startTls(): void;

    /**
     * Send an "IDLE" command.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-idle-command
     */
    public function idle(int $timeout): Generator;

    /**
     * Send a "DONE" command.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13
     */
    public function done(): void;

    /**
     * Send a "NOOP" command.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command
     */
    public function noop(): TaggedResponse;

    /**
     * Send a "EXPUNGE" command.
     *
     * Apply session saved changes to the server.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command
     */
    public function expunge(): ResponseCollection;

    /**
     * Send a "CAPABILITY" command.
     *
     * Get the mailbox's available capabilities.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-capability-command
     */
    public function capability(): UntaggedResponse;

    /**
     * Send a "SEARCH" command.
     *
     * Execute a search request.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command
     */
    public function search(array $params): UntaggedResponse;

    /**
     * Send a "FETCH" command.
     *
     * Exchange identification information.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc2971.
     */
    public function id(?array $ids = null): UntaggedResponse;

    /**
     * Send a "FETCH UID" command.
     *
     * Fetch message UIDs using the given message numbers.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command
     */
    public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection;

    /**
     * Send a "FETCH BODY[TEXT]" command.
     *
     * Fetch message text contents.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
     */
    public function bodyText(int|array $ids, bool $peek = true): ResponseCollection;

    /**
     * Send a "FETCH BODY[HEADER]" command.
     *
     * Fetch message headers.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
     */
    public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection;

    /**
     * Send a "FETCH BODYSTRUCTURE" command.
     *
     * Fetch message body structure.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
     */
    public function bodyStructure(int|array $ids): ResponseCollection;

    /**
     * Send a "FETCH BODY[i]" command.
     *
     * Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
     */
    public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection;

    /**
     * Send a "FETCH FLAGS" command.
     *
     * Fetch a message flags.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17
     */
    public function flags(int|array $ids): ResponseCollection;

    /**
     * Send a "RFC822.SIZE" command.
     *
     * Fetch message sizes.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21
     */
    public function size(int|array $ids): ResponseCollection;

    /**
     * Send a "SELECT" command.
     *
     * Select the specified folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-select-command
     */
    public function select(string $folder): ResponseCollection;

    /**
     * Send a "EXAMINE" command.
     *
     * Examine a given folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command
     */
    public function examine(string $folder): ResponseCollection;

    /**
     * Send a "LIST" command.
     *
     * Get a list of available folders.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command
     */
    public function list(string $reference = '', string $folder = '*'): ResponseCollection;

    /**
     * Send a "STATUS" command.
     *
     * Get the status of a given folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-status-command
     */
    public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse;

    /**
     * Send a "STORE" command.
     *
     * Set message flags.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-store-command
     */
    public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection;

    /**
     * Send a "APPEND" command.
     *
     * Append a new message to given folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command
     */
    public function append(string $folder, string $message, ?array $flags = null): TaggedResponse;

    /**
     * Send a "UID COPY" command.
     *
     * Copy message set from current folder to other folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command
     */
    public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse;

    /**
     * Send a "UID MOVE" command.
     *
     * Move a message set from current folder to another folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command
     */
    public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse;

    /**
     * Send a "CREATE" command.
     *
     * Create a new folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-create-command
     */
    public function create(string $folder): ResponseCollection;

    /**
     * Send a "DELETE" command.
     *
     * Delete a folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-delete-command
     */
    public function delete(string $folder): TaggedResponse;

    /**
     * Send a "RENAME" command.
     *
     * Rename an existing folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-rename-command
     */
    public function rename(string $oldPath, string $newPath): TaggedResponse;

    /**
     * Send a "SUBSCRIBE" command.
     *
     * Subscribe to a folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-subscribe-command
     */
    public function subscribe(string $folder): TaggedResponse;

    /**
     * Send a "UNSUBSCRIBE" command.
     *
     * Unsubscribe from a folder.
     *
     * @see https://datatracker.ietf.org/doc/html/rfc9051#name-unsubscribe-command
     */
    public function unsubscribe(string $folder): TaggedResponse;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;

trait HasTokens
{
    /**
     * Get the response tokens.
     *
     * @return Token[]|Data[]
     */
    abstract public function tokens(): array;

    /**
     * Get the response token at the given index.
     */
    public function tokenAt(int $index): Token|Data|null
    {
        return $this->tokens()[$index] ?? null;
    }

    /**
     * Get the response tokens after the given index.
     */
    public function tokensAfter(int $index): array
    {
        return array_slice($this->tokens(), $index);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

interface FolderInterface
{
    /**
     * Get the folder's mailbox.
     */
    public function mailbox(): MailboxInterface;

    /**
     * Get the folder path.
     */
    public function path(): string;

    /**
     * Get the folder flags.
     *
     * @return string[]
     */
    public function flags(): array;

    /**
     * Get the folder delimiter.
     */
    public function delimiter(): string;

    /**
     * Get the folder name.
     */
    public function name(): string;

    /**
     * Determine if the current folder is the same as the given.
     */
    public function is(FolderInterface $folder): bool;

    /**
     * Begin querying for messages.
     */
    public function messages(): MessageQueryInterface;

    /**
     * Begin idling on the current folder.
     */
    public function idle(callable $callback, ?callable $query = null, int $timeout = 300): void;

    /**
     * Move or rename the current folder.
     */
    public function move(string $newPath): void;

    /**
     * Select the current folder.
     */
    public function select(bool $force = false): void;

    /**
     * Get the folder's status.
     */
    public function status(): array;

    /**
     * Examine the current folder and get detailed status information.
     */
    public function examine(): array;

    /**
     * Expunge the mailbox and return the expunged message sequence numbers.
     */
    public function expunge(): array;

    /**
     * Delete the current folder.
     */
    public function delete(): void;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

/**
 * @see https://datatracker.ietf.org/doc/html/rfc9051#name-atom
 */
class Atom extends Token
{
    /**
     * Determine if the token is the given value.
     */
    public function is(string $value): bool
    {
        return $this->value === $value;
    }

    /**
     * Determine if the token is not the given value.
     */
    public function isNot(string $value): bool
    {
        return ! $this->is($value);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Tokens\Atom;

class UntaggedResponse extends Response
{
    /**
     * Get the response type token.
     */
    public function type(): Atom
    {
        return $this->tokens[1];
    }

    /**
     * Get the data tokens.
     *
     * @return Atom[]
     */
    public function data(): array
    {
        return array_slice($this->tokens, 2);
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use ZBateson\MailMimeParser\MailMimeParser;
use ZBateson\MailMimeParser\Message as MailMimeMessage;

class MessageParser
{
    /**
     * The mail mime parser instance.
     */
    protected static ?MailMimeParser $parser = null;

    /**
     * Parse the given message contents.
     */
    public static function parse(string $contents): MailMimeMessage
    {
        return static::parser()->parse($contents, true);
    }

    /**
     * Get the mail mime parser instance.
     */
    protected static function parser(): MailMimeParser
    {
        return static::$parser ??= new MailMimeParser;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

use Stringable;

abstract class Token implements Stringable
{
    /**
     * Constructor.
     */
    public function __construct(
        public string $value,
    ) {}

    /**
     * Get the token's value.
     */
    public function __toString(): string
    {
        return $this->value;
    }
}

<?php

namespace DirectoryTree\ImapEngine;

use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;

/**
 * @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
 */
interface MessageQueryInterface
{
    /**
     * Don't mark messages as read when fetching.
     */
    public function leaveUnread(): MessageQueryInterface;

    /**
     * Mark all messages as read when fetching.
     */
    public function markAsRead(): MessageQueryInterface;

    /**
     * Set the limit and page for the current query.
     */
    public function limit(int $limit, int $page = 1): MessageQueryInterface;

    /**
     * Get the set fetch limit.
     */
    public function getLimit(): ?int;

    /**
     * Set the fetch limit.
     */
    public function setLimit(int $limit): MessageQueryInterface;

    /**
     * Get the set page.
     */
    public function getPage(): int;

    /**
     * Set the page.
     */
    public function setPage(int $page): MessageQueryInterface;

    /**
     * Determine if the body of messages is being fetched.
     */
    public function isFetchingBody(): bool;

    /**
     * Determine if the flags of messages is being fetched.
     */
    public function isFetchingFlags(): bool;

    /**
     * Determine if the headers of messages is being fetched.
     */
    public function isFetchingHeaders(): bool;

    /**
     * Fetch the flags of messages.
     */
    public function withFlags(): MessageQueryInterface;

    /**
     * Fetch the body of messages.
     */
    public function withBody(): MessageQueryInterface;

    /**
     * Fetch the headers of messages.
     */
    public function withHeaders(): MessageQueryInterface;

    /**
     * Don't fetch the body of messages.
     */
    public function withoutBody(): MessageQueryInterface;

    /**
     * Don't fetch the headers of messages.
     */
    public function withoutHeaders(): MessageQueryInterface;

    /**
     * Don't fetch the flags of messages.
     */
    public function withoutFlags(): MessageQueryInterface;

    /**
     * Set the fetch order.
     */
    public function setFetchOrder(string $fetchOrder): MessageQueryInterface;

    /**
     * Get the fetch order.
     */
    public function getFetchOrder(): string;

    /**
     * Set the fetch order to 'ascending'.
     */
    public function setFetchOrderAsc(): MessageQueryInterface;

    /**
     * Set the fetch order to 'descending'.
     */
    public function setFetchOrderDesc(): MessageQueryInterface;

    /**
     * Set the fetch order to show oldest messages first (ascending).
     */
    public function oldest(): MessageQueryInterface;

    /**
     * Set the fetch order to show newest messages first (descending).
     */
    public function newest(): MessageQueryInterface;

    /**
     * Count all available messages matching the current search criteria.
     */
    public function count(): int;

    /**
     * Get the first message in the resulting collection.
     *
     * @return \DirectoryTree\ImapEngine\Message|null
     */
    public function first(): ?MessageInterface;

    /**
     * Get the first message in the resulting collection or throw an exception.
     *
     * @return \DirectoryTree\ImapEngine\Message
     */
    public function firstOrFail(): MessageInterface;

    /**
     * Get the messages matching the current query.
     */
    public function get(): MessageCollection;

    /**
     * Append a new message to the folder.
     */
    public function append(string $message, mixed $flags = null): int;

    /**
     * Execute a callback over each message via a chunked query.
     */
    public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;

    /**
     * Execute a callback over each chunk of messages.
     */
    public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;

    /**
     * Paginate the current query.
     */
    public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator;

    /**
     * Find a message by the given identifier type or throw an exception.
     *
     * @return \DirectoryTree\ImapEngine\Message
     */
    public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface;

    /**
     * Find a message by the given identifier type.
     *
     * @return \DirectoryTree\ImapEngine\Message|null
     */
    public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface;

    /**
     * Destroy the given messages.
     */
    public function destroy(array|int $uids, bool $expunge = false): void;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class Literal extends Token
{
    /**
     * Get the token's value.
     */
    public function __toString(): string
    {
        return sprintf("{%d}\r\n%s", strlen($this->value), $this->value);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses\Data;

class ResponseCodeData extends Data
{
    /**
     * Get the group as a string.
     */
    public function __toString(): string
    {
        return sprintf('[%s]', implode(
            ' ', array_map('strval', $this->tokens)
        ));
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Responses;

use DirectoryTree\ImapEngine\Connection\Tokens\Atom;

class ContinuationResponse extends Response
{
    /**
     * Get the data tokens.
     *
     * @return Atom[]
     */
    public function data(): array
    {
        return array_slice($this->tokens, 1);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Collections;

use DirectoryTree\ImapEngine\FolderInterface;
use Illuminate\Support\Collection;

/**
 * @template-extends Collection<array-key, FolderInterface>
 */
class FolderCollection extends Collection {}

<?php

namespace DirectoryTree\ImapEngine;

use Carbon\CarbonInterface;
use Stringable;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\Message as MailMimeMessage;

interface MessageInterface extends FlaggableInterface, Stringable
{
    /**
     * Get the message's identifier.
     */
    public function uid(): int;

    /**
     * Get the message date and time.
     */
    public function date(): ?CarbonInterface;

    /**
     * Get the message's subject.
     */
    public function subject(): ?string;

    /**
     * Get the 'From' address.
     */
    public function from(): ?Address;

    /**
     * Get the 'Sender' address.
     */
    public function sender(): ?Address;

    /**
     * Get the message's 'Message-ID'.
     */
    public function messageId(): ?string;

    /**
     * Get the 'Reply-To' address.
     */
    public function replyTo(): ?Address;

    /**
     * Get the 'In-Reply-To' address.
     */
    public function inReplyTo(): ?Address;

    /**
     * Get the 'To' addresses.
     *
     * @return Address[]
     */
    public function to(): array;

    /**
     * Get the 'CC' addresses.
     *
     * @return Address[]
     */
    public function cc(): array;

    /**
     * Get the 'BCC' addresses.
     *
     * @return Address[]
     */
    public function bcc(): array;

    /**
     * Get the message's attachments.
     *
     * @return Attachment[]
     */
    public function attachments(): array;

    /**
     * Determine if the message has attachments.
     */
    public function hasAttachments(): bool;

    /**
     * Get the count of attachments.
     */
    public function attachmentCount(): int;

    /**
     * Get addresses from the given header.
     *
     * @return Address[]
     */
    public function addresses(string $header): array;

    /**
     * Get the message's HTML content.
     */
    public function html(): ?string;

    /**
     * Get the message's text content.
     */
    public function text(): ?string;

    /**
     * Get all headers from the message.
     */
    public function headers(): array;

    /**
     * Get a header from the message.
     */
    public function header(string $name, int $offset = 0): ?IHeader;

    /**
     * Parse the message into a MailMimeMessage instance.
     */
    public function parse(): MailMimeMessage;

    /**
     * Determine if the message is the same as another message.
     */
    public function is(MessageInterface $message): bool;

    /**
     * Get the string representation of the message.
     */
    public function __toString(): string;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Loggers;

class RayLogger extends Logger
{
    /**
     * {@inheritDoc}
     */
    protected function write(string $message): void
    {
        ray($message);
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class EmailAddress extends Token
{
    /**
     * Get the token's value.
     */
    public function __toString(): string
    {
        return '<'.$this->value.'>';
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class QuotedString extends Token
{
    /**
     * Get the token's value.
     */
    public function __toString(): string
    {
        return '"'.$this->value.'"';
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Streams;

interface StreamInterface
{
    /**
     * Open the underlying stream.
     */
    public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool;

    /**
     * Close the underlying stream.
     */
    public function close(): void;

    /**
     * Read data from the stream.
     */
    public function read(int $length): string|false;

    /**
     * Read a single line from the stream.
     */
    public function fgets(): string|false;

    /**
     * Write data to the stream.
     */
    public function fwrite(string $data): int|false;

    /**
     * Return meta info (like stream_get_meta_data).
     */
    public function meta(): array;

    /**
     * Determine if the stream is open.
     */
    public function opened(): bool;

    /**
     * Set the timeout on the stream.
     */
    public function setTimeout(int $seconds): bool;

    /**
     * Set encryption state on an already connected socked.
     */
    public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int;
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Loggers;

class EchoLogger extends Logger
{
    /**
     * {@inheritDoc}
     */
    public function write(string $message): void
    {
        echo $message;
    }
}

<?php

namespace DirectoryTree\ImapEngine\Connection;

use Stringable;

class RawQueryValue
{
    /**
     * Constructor.
     */
    public function __construct(
        public readonly Stringable|string $value
    ) {}
}

<?php

namespace DirectoryTree\ImapEngine\Connection\Loggers;

interface LoggerInterface
{
    /**
     * Log when a message is sent.
     */
    public function sent(string $message): void;

    /**
     * Log when a message is received.
     */
    public function received(string $message): void;
}

<?php

namespace DirectoryTree\ImapEngine;

use BackedEnum;

interface FlaggableInterface
{
    /**
     * Mark the message as read. Alias for markSeen.
     */
    public function markRead(): void;

    /**
     * Mark the message as unread. Alias for unmarkSeen.
     */
    public function markUnread(): void;

    /**
     * Mark the message as seen.
     */
    public function markSeen(): void;

    /**
     * Unmark the seen flag.
     */
    public function unmarkSeen(): void;

    /**
     * Mark the message as answered.
     */
    public function markAnswered(): void;

    /**
     * Unmark the answered flag.
     */
    public function unmarkAnswered(): void;

    /**
     * Mark the message as flagged.
     */
    public function markFlagged(): void;

    /**
     * Unmark the flagged flag.
     */
    public function unmarkFlagged(): void;

    /**
     * Mark the message as deleted.
     */
    public function markDeleted(bool $expunge = false): void;

    /**
     * Unmark the deleted flag.
     */
    public function unmarkDeleted(): void;

    /**
     * Mark the message as a draft.
     */
    public function markDraft(): void;

    /**
     * Unmark the draft flag.
     */
    public function unmarkDraft(): void;

    /**
     * Mark the message as recent.
     */
    public function markRecent(): void;

    /**
     * Unmark the recent flag.
     */
    public function unmarkRecent(): void;

    /**
     * Determine if the message is marked as seen.
     */
    public function isSeen(): bool;

    /**
     * Determine if the message is marked as answered.
     */
    public function isAnswered(): bool;

    /**
     * Determine if the message is flagged.
     */
    public function isFlagged(): bool;

    /**
     * Determine if the message is marked as deleted.
     */
    public function isDeleted(): bool;

    /**
     * Determine if the message is marked as a draft.
     */
    public function isDraft(): bool;

    /**
     * Determine if the message is marked as recent.
     */
    public function isRecent(): bool;

    /**
     * Get the message's flags.
     *
     * @return string[]
     */
    public function flags(): array;

    /**
     * Determine if the message has the given flag.
     */
    public function hasFlag(BackedEnum|string $flag): bool;

    /**
     * Add or remove a flag from the message.
     */
    public function flag(mixed $flag, string $operation, bool $expunge = false): void;
}

<?php

namespace DirectoryTree\ImapEngine\Enums;

enum ImapFlag: string
{
    case Seen = '\Seen';
    case Draft = '\Draft';
    case Recent = '\Recent';
    case Flagged = '\Flagged';
    case Deleted = '\Deleted';
    case Answered = '\Answered';
}

<?php

namespace DirectoryTree\ImapEngine\Enums;

enum ImapSearchKey: string
{
    case Cc = 'CC';
    case On = 'ON';
    case To = 'TO';
    case All = 'ALL';
    case New = 'NEW';
    case Old = 'OLD';
    case Bcc = 'BCC';
    case Uid = 'UID';
    case Seen = 'SEEN';
    case Body = 'BODY';
    case From = 'FROM';
    case Text = 'TEXT';
    case Draft = 'DRAFT';
    case Since = 'SINCE';
    case Recent = 'RECENT';
    case Unseen = 'UNSEEN';
    case Before = 'BEFORE';
    case Header = 'HEADER';
    case Deleted = 'DELETED';
    case Flagged = 'FLAGGED';
    case Keyword = 'KEYWORD';
    case Subject = 'SUBJECT';
    case Answered = 'ANSWERED';
    case Undeleted = 'UNDELETED';
    case Unflagged = 'UNFLAGGED';
    case Unanswered = 'UNANSWERED';
}

<?php

namespace DirectoryTree\ImapEngine\Enums;

enum ImapFetchIdentifier
{
    case Uid;
    case MessageNumber;
}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapResponseException extends Exception {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapConnectionClosedException extends ImapConnectionException {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapConnectionFailedException extends ImapConnectionException {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapConnectionTimedOutException extends ImapConnectionException {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapStreamException extends Exception {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapCapabilityException extends Exception {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class ImapParserException extends Exception {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

abstract class ImapConnectionException extends Exception {}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class ListOpen extends Token {}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class ListClose extends Token {}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class ResponseCodeClose extends Token {}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class ResponseCodeOpen extends Token {}

<?php

namespace DirectoryTree\ImapEngine\Connection\Tokens;

class Crlf extends Token {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class RuntimeException extends \RuntimeException {}

<?php

namespace DirectoryTree\ImapEngine\Exceptions;

class Exception extends \Exception {}

class
FolderRepository
implements FolderRepositoryInterface
{
public function __construct (
        protected Mailbox $mailbox
    ) {}
public function find (string $path) : ?FolderInterface {}
public function findOrFail (string $path) : FolderInterface {}
public function create (string $path) : FolderInterface {}
public function firstOrCreate (string $path) : FolderInterface {}
public function get (?string $match = '*', ?string $reference = '') : FolderCollection {}
}

This code segment functions to: Manage folder operations within a mail server by providing methods to locate, retrieve, create, and organize message directories while handling character encoding conversions and mapping raw server responses into structured data objects.

```php
[TASK_BEGIN]
public function find(string $path): ?FolderInterface
{
    $folders = $this->mailbox->getFolders(Str::wrap($path));

    return $folders->isNotEmpty() ? $folders->first() : null;
}

public function findOrFail(string $path): FolderInterface
{
    if ($folder = $this->find($path)) {
        return $folder;
    }

    throw new FolderNotFoundException("Folder [{$path}] not found.");
}

public function create(string $path): FolderInterface
{
    return $this->mailbox->createFolder($path);
}

public function firstOrCreate(string $path): FolderInterface
{
    return $this->find($path) ?? $this->create($path);
}

public function get(?string $match = '*', ?string $reference = ''): FolderCollection
{
    return new FolderCollection(
        $this->mailbox->getFolders($reference.Str::wrap($match))
    );
}
[TASK_END]
```