Rename plugins to libs and update all file references

This commit is contained in:
johnnyq
2026-07-10 13:24:20 -04:00
parent 7ba1571400
commit 8da3a107fb
3410 changed files with 140 additions and 140 deletions

View File

@@ -0,0 +1,344 @@
<?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 DirectoryTree\ImapEngine\Enums\ImapSortKey;
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 "SORT" command.
*
* Execute a sort request using RFC 5256.
*
* @see https://datatracker.ietf.org/doc/html/rfc5256
*/
public function sort(ImapSortKey $key, string $direction, 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 "FETCH" command.
*
* Fetch one or more items for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-fetch-command
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection;
/**
* Send a "RFC822.SIZE" command.
*
* Fetch message sizes for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21
*/
public function size(int|array $ids): ResponseCollection;
/**
* Send an IMAP command.
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void;
/**
* 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;
/**
* Send a "GETQUOTA" command.
*
* Retrieve quota information about a specific quota root.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquota
*/
public function quota(string $root): UntaggedResponse;
/**
* Send a "GETQUOTAROOT" command.
*
* Retrieve quota root information about a mailbox.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquotaroot
*/
public function quotaRoot(string $mailbox): ResponseCollection;
}

View File

@@ -0,0 +1,105 @@
<?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());
}
}

View File

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

View File

@@ -0,0 +1,270 @@
<?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\Number;
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 or Number, check its value for special markers.
if ($this->currentToken instanceof Atom || $this->currentToken instanceof Number) {
// '*' 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->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// If the end-of-response marker (CRLF) is present, consume it.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$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->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$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->currentToken instanceof Crlf) {
$tokens[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$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
) {
// Skip CRLF tokens that may appear inside bracket groups.
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$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
) {
// Skip CRLF tokens that appear inside lists (after literals).
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$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();
}
}

View File

@@ -0,0 +1,527 @@
<?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 largest UID value allowed by IMAP.
*/
protected const MAX_UID = '4294967295';
/**
* 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 "UNKEYWORD" clause to the query.
*/
public function unkeyword(string $value): static
{
return $this->where(ImapSearchKey::Unkeyword, $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 "SENTON" clause to the query.
*/
public function sentOn(mixed $date): static
{
return $this->where(ImapSearchKey::SentOn, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTSINCE" clause to the query.
*/
public function sentSince(mixed $date): static
{
return $this->where(ImapSearchKey::SentSince, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTBEFORE" clause to the query.
*/
public function sentBefore(mixed $date): static
{
return $this->where(ImapSearchKey::SentBefore, new RawQueryValue(
$this->parseDate($date)->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 "HEADER Message-ID" clause to the query.
*/
public function messageId(string $messageId): static
{
return $this->header('Message-ID', trim(trim($messageId), '<>'));
}
/**
* Add a where "UID" clause to the query.
*/
public function uid(int|string|array $from, int|float|null $to = null): static
{
if ($to === INF) {
$to = self::MAX_UID;
}
return $this->where(ImapSearchKey::Uid, new RawQueryValue(Str::set($from, $to)));
}
/**
* Add a where "LARGER" clause to the query.
*/
public function larger(int $bytes): static
{
return $this->where(ImapSearchKey::Larger, new RawQueryValue($bytes));
}
/**
* Add a where "SMALLER" clause to the query.
*/
public function smaller(int $bytes): static
{
return $this->where(ImapSearchKey::Smaller, new RawQueryValue($bytes));
}
/**
* 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.
*
* @param array{type: 'basic'|'nested', boolean: 'AND'|'OR', query: ImapQueryBuilder} $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.
*
* @param 'AND'|'OR' $boolean
*/
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;
}
}

View File

@@ -0,0 +1,511 @@
<?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\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
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 a number or atom.
return $this->readNumberOrAtom();
}
/**
* 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 a number or atom token.
*/
protected function readNumberOrAtom(): Token
{
$position = $this->position;
// First char must be a digit to even consider a number.
if (! ctype_digit($this->buffer[$position] ?? '')) {
return $this->readAtom();
}
// Walk forward to find the end of the digit run.
while (ctype_digit($this->buffer[$position] ?? '')) {
$position++;
$this->ensureBuffer($position - $this->position + 1);
}
$next = $this->buffer[$position] ?? null;
// If next is EOF or a delimiter, it's a Number.
if ($next === null || $this->isDelimiter($next)) {
return $this->readNumber();
}
// Otherwise it's an Atom.
return $this->readAtom();
}
/**
* Reads a number token.
*
* A number consists of one or more digit characters and represents a numeric value.
*/
protected function readNumber(): Number
{
$start = $this->position;
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! ctype_digit($char)) {
break;
}
$this->advance();
}
return new Number(substr($this->buffer, $start, $this->position - $start));
}
/**
* 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;
}
$value .= $char;
$this->advance();
}
if (strcasecmp($value, 'NIL') === 0) {
return new Nil($value);
}
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 its more restrictive
// than the IMAP atom definition in RFC 3501 (section 9).
return in_array($char, [' ', '(', ')', '[', ']', '{', '}', '<', '>'], true);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class EchoLogger extends Logger
{
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
echo $message;
}
}

View File

@@ -0,0 +1,21 @@
<?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);
}
}

View File

@@ -0,0 +1,35 @@
<?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');
}
}

View File

@@ -0,0 +1,16 @@
<?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;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class RayLogger extends Logger
{
/**
* {@inheritDoc}
*/
protected function write(string $message): void
{
ray($message);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class RawQueryValue
{
/**
* Constructor.
*/
public function __construct(
public readonly Stringable|string $value
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ContinuationResponse extends Response
{
/**
* Get the data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 1);
}
}

View File

@@ -0,0 +1,73 @@
<?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);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ListData extends Data
{
/**
* Find the immediate successor token 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;
}
/**
* Convert alternating key/value tokens to an associative array.
*/
public function toKeyValuePairs(): array
{
$pairs = [];
for ($i = 0; $i < count($this->tokens) - 1; $i += 2) {
$key = strtolower($this->tokens[$i]->value);
$pairs[$key] = $this->tokens[$i + 1]->value;
}
return $pairs;
}
/**
* Get the list as a string.
*/
public function __toString(): string
{
return sprintf('(%s)', implode(
' ', array_map('strval', $this->tokens)
));
}
}

View File

@@ -0,0 +1,16 @@
<?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)
));
}
}

View File

@@ -0,0 +1,32 @@
<?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);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
class MessageResponseParser
{
/**
* 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;
}
}

View File

@@ -0,0 +1,50 @@
<?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);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class TaggedResponse extends Response
{
/**
* Get the response tag.
*/
public function tag(): Atom|Number
{
return $this->tokens[0];
}
/**
* Get the response status token.
*/
public function status(): Atom
{
return $this->tokens[1];
}
/**
* Get the response data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
/**
* Determine if the response was successful.
*/
public function successful(): bool
{
return strtoupper($this->status()->value) === 'OK';
}
/**
* Determine if the response failed.
*/
public function failed(): bool
{
return in_array(strtoupper($this->status()->value), ['NO', 'BAD']);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
class UntaggedResponse extends Response
{
/**
* Get the response type token.
*/
public function type(): Atom|Number
{
return $this->tokens[1];
}
/**
* Get the data tokens.
*
* @return Atom[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
}

View File

@@ -0,0 +1,41 @@
<?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);
}
}

View File

@@ -0,0 +1,232 @@
<?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.");
}
}

View File

@@ -0,0 +1,120 @@
<?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(
$address = "{$transport}://{$host}:{$port}",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
stream_context_create($options)
);
if (! $this->stream) {
throw new ImapConnectionFailedException("Unable to connect to {$address} ({$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);
}
}

View File

@@ -0,0 +1,51 @@
<?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;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-atom
*/
class Atom extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Crlf extends Token {}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class EmailAddress extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '<'.$this->value.'>';
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListClose extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListOpen extends Token {}

View File

@@ -0,0 +1,14 @@
<?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);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-nil
*/
class Nil extends Atom {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Number extends Token {}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class QuotedString extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '"'.$this->value.'"';
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeClose extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeOpen extends Token {}

View File

@@ -0,0 +1,39 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
use Stringable;
abstract class Token implements Stringable
{
/**
* Constructor.
*/
public function __construct(
public string $value,
) {}
/**
* 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);
}
/**
* Get the token's value.
*/
public function __toString(): string
{
return $this->value;
}
}