feat: initial version

Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit was merged in pull request #1.
This commit is contained in:
Sebastian Krupinski
2026-02-20 16:41:19 -05:00
committed by Sebastian Krupinski
parent a313767846
commit e51c65bf19
139 changed files with 11256 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use DateTimeInterface;
use Gricob\IMAP\Protocol\Command\Argument\DateTime;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
use Gricob\IMAP\Protocol\Command\Argument\SynchronizingLiteral;
use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList;
final readonly class AppendCommand extends Command implements Continuable
{
/**
* @param list<string>|null $flags
*/
public function __construct(
string $mailboxName,
private string $message,
?array $flags,
?DateTimeInterface $internalDate
) {
parent::__construct(
'APPEND',
...array_filter([
new QuotedString($mailboxName),
ParenthesizedList::tryFrom($flags),
DateTime::tryFrom($internalDate),
new SynchronizingLiteral($this->message),
])
);
}
public function continue(): string
{
return $this->message;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
interface Argument
{
public function __toString(): string;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
use DateTimeInterface;
readonly class Date implements Argument
{
public function __construct(private DateTimeInterface $value)
{
}
public static function tryFrom(?DateTimeInterface $value): ?self
{
return is_null($value) ? null : new self($value);
}
public function __toString(): string
{
return $this->value->format('d-M-Y');
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
use DateTimeInterface;
readonly class DateTime implements Argument
{
public function __construct(private DateTimeInterface $value)
{
}
public static function tryFrom(?DateTimeInterface $value): ?self
{
return is_null($value) ? null : new self($value);
}
public function __toString(): string
{
return '"'.$this->value->format('d-M-Y H:i:s O').'"';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class ParenthesizedList implements Argument
{
/**
* @param list<string> $items
*/
public function __construct(public array $items)
{
}
/**
* @param list<string> $items
*/
public static function tryFrom(?array $items): ?self
{
return empty($items) ? null : new self($items);
}
public function __toString(): string
{
return sprintf('(%s)', implode(' ', $this->items));
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class QuotedString implements Argument
{
public function __construct(private string $value)
{
}
public function __toString(): string
{
return sprintf('"%s"', $this->value);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
class All implements Criteria
{
public function __toString(): string
{
return 'ALL';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Date;
readonly class Before extends Date implements Criteria
{
public function __toString(): string
{
return 'BEFORE '.parent::__toString();
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
interface Criteria extends Argument
{
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
class Header implements Criteria
{
public function __construct(
private string $fieldName,
private string $value,
) {
}
public function __toString(): string
{
return sprintf('HEADER %s %s', $this->fieldName, new QuotedString($this->value));
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
final readonly class Not implements Criteria
{
public function __construct(private Criteria $criteria)
{
}
public function __toString(): string
{
return 'NOT ('.$this->criteria.')';
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
use Gricob\IMAP\Protocol\Command\Argument\Date;
readonly class Since extends Date implements Criteria
{
public function __toString(): string
{
return 'SINCE '.parent::__toString();
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final class SequenceSet implements Argument
{
/**
* @var array<int>
*/
private array $numbers;
private ?string $range;
public function __construct(int ...$numbers)
{
$this->numbers = $numbers;
$this->range = null;
}
public static function range(int $from, int $to): self
{
$set = new self();
$set->range = $from . ':' . $to;
return $set;
}
public function __toString(): string
{
if ($this->range !== null) {
return $this->range;
}
return implode(',', $this->numbers);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument\Store;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
final readonly class Flags implements Argument
{
/**
* @param list<string> $flags
*/
public function __construct(
private array $flags,
private string $modifier = '',
private bool $silent = true,
) {
}
public function __toString(): string
{
return sprintf(
'%sFLAGS%s (%s)',
$this->modifier,
$this->silent ? '.SILENT' : '',
implode(' ', $this->flags),
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Argument;
final readonly class SynchronizingLiteral implements Argument
{
public function __construct(private string $value)
{
}
public function __toString(): string
{
return sprintf(
'{%s}',
strlen($this->value)
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Authenticate;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
use Gricob\IMAP\Protocol\Command\Continuable;
interface SASLMechanism extends Argument, Continuable
{
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command\Authenticate;
final readonly class XOAuth2 implements SASLMechanism
{
public function __construct(
private string $user,
private string $accessToken
) {
}
public function __toString(): string
{
return 'XOAUTH2';
}
public function continue(): string
{
return base64_encode(
sprintf("user=%s\1auth=Bearer %s\1\1", $this->user, $this->accessToken)
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Authenticate\SASLMechanism;
readonly class AuthenticateCommand extends Command implements Continuable
{
public function __construct(private SASLMechanism $mechanism)
{
parent::__construct('AUTHENTICATE', $mechanism);
}
public function continue(): string
{
return $this->mechanism->continue();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\Argument;
use Stringable;
abstract readonly class Command implements Stringable
{
private string $command;
/**
* @var Argument[]
*/
private array $arguments;
public function __construct(
string $command,
Argument ...$arguments,
) {
$this->command = $command;
$this->arguments = $arguments;
}
public function command(): string
{
return $this->command;
}
public function __toString(): string
{
return sprintf(
'%s %s',
$this->command,
implode(' ', $this->arguments)
);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
interface Continuable
{
public function continue(): string;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
final readonly class CreateCommand extends Command
{
public function __construct(string $mailboxName)
{
parent::__construct('CREATE', new QuotedString($mailboxName));
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
final readonly class ExpungeCommand extends Command
{
public function __construct()
{
parent::__construct('EXPUNGE');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
final readonly class FetchCommand extends Command
{
/**
* @param bool $uid
* @param SequenceSet $sequenceSet
* @param list<string> $items
*/
public function __construct(
bool $uid,
SequenceSet $sequenceSet,
array $items,
) {
parent::__construct(
$uid ? 'UID FETCH' : 'FETCH',
$sequenceSet,
new ParenthesizedList($items),
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
readonly class ListCommand extends Command
{
public function __construct(string $referenceName, string $pattern)
{
parent::__construct(
'LIST',
new QuotedString($referenceName),
new QuotedString($pattern)
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
final readonly class LogInCommand extends Command
{
public function __construct(string $user, string $password)
{
parent::__construct(
'LOGIN',
new QuotedString($user),
new QuotedString($password)
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
final readonly class SearchCommand extends Command
{
public function __construct(
bool $uid,
Criteria ...$criteria,
) {
parent::__construct(
$uid ? 'UID SEARCH' : 'SEARCH',
...$criteria,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
readonly class SelectCommand extends Command
{
public function __construct(string $mailbox)
{
parent::__construct('SELECT', new QuotedString($mailbox));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
/**
* STARTTLS command (RFC 3501 §6.2.1) — patched into gricob/imap.
*
* After the server responds OK, upgradeTls() must be called on the underlying
* SocketConnection to complete the TLS handshake.
*/
final readonly class StartTlsCommand extends Command
{
public function __construct()
{
parent::__construct('STARTTLS');
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Command;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
final readonly class StoreCommand extends Command
{
public function __construct(
bool $uid,
SequenceSet $sequenceSet,
Flags $dataItem
) {
parent::__construct(
$uid ? 'UID STORE' : 'STORE',
$sequenceSet,
$dataItem,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use RuntimeException;
class CommandFailed extends RuntimeException
{
public static function withStatus(Status $status): self
{
return new self($status->message);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Continuable;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Transport\Connection;
use RuntimeException;
final readonly class CommandInteraction implements ContinuationHandler
{
public function __construct(
private Connection $connection,
private ResponseHandler $responseHandler,
private string $tag,
private Command $command,
) {
}
public function interact(): Response
{
$request = sprintf(
"%s %s\r\n",
$this->tag,
$this->command,
);
$this->connection->send($request);
$streamResponse = $this->connection->receive();
return $this->responseHandler->handle($this->tag, $streamResponse, $this);
}
/**
* Like interact() but yields each untagged Line immediately as it arrives.
* The terminal Status is the generator's return value.
*
* @return Generator<int, Line, mixed, Status>
*/
public function streamInteract(): Generator
{
$request = sprintf(
"%s %s\r\n",
$this->tag,
$this->command,
);
$this->connection->send($request);
$streamResponse = $this->connection->receive();
yield from $this->responseHandler->stream($this->tag, $streamResponse, $this);
}
public function continue(): void
{
if (!$this->command instanceof Continuable) {
throw new RuntimeException(
sprintf('Command %s does not support continuable interaction', $this->command->command())
);
}
$this->connection->send($this->command->continue()."\r\n");
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use RuntimeException;
class ConnectionRejected extends RuntimeException
{
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
interface ContinuationHandler
{
public function continue(): void;
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\StartTlsCommand;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Line\Status\StatusType;
use Gricob\IMAP\Protocol\Response\Parser\Parser;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Transport\Connection;
use RuntimeException;
class Imap
{
protected Connection $connection;
private TagGenerator $tagGenerator;
private ResponseHandler $responseHandler;
public function __construct(Connection $connection)
{
$this->connection = $connection;
$this->tagGenerator = new TagGenerator();
$this->responseHandler = new ResponseHandler(new Parser());
}
public function __destruct()
{
$this->disconnect();
}
public function connect(): void
{
if ($this->connection->isOpen()) {
return;
}
$this->connection->open();
$responseStream = $this->connection->receive();
$greeting = $this->responseHandler->handle('*', $responseStream, new UnexpectedContinuationHandler());
match ($greeting->status->type) {
StatusType::OK => null, // Do nothing
StatusType::PREAUTH => throw new RuntimeException('pre-auth is not supported'),
StatusType::BAD,
StatusType::NO,
StatusType::BYE => throw new ConnectionRejected($greeting->status->message),
};
}
public function disconnect(): void
{
$this->connection->close();
}
/**
* Perform STARTTLS negotiation (patch).
*
* Sends the STARTTLS command and upgrades the underlying socket to TLS.
* The connection must be a SocketConnection (or any Connection that
* implements upgradeTls()). Call this after connect() but before logIn().
*
* @throws \RuntimeException if the server rejects STARTTLS
* @throws \BadMethodCallException if the connection does not support TLS upgrade
*/
public function startTls(): void
{
if (!method_exists($this->connection, 'upgradeTls')) {
throw new \BadMethodCallException(
'The current Connection implementation does not support STARTTLS upgrade'
);
}
$response = $this->send(new StartTlsCommand());
if ($response->status->type !== StatusType::OK) {
throw new \RuntimeException(
'Server rejected STARTTLS: ' . $response->status->message
);
}
$this->connection->upgradeTls();
}
public function send(Command $command): Response
{
$interaction = new CommandInteraction(
$this->connection,
$this->responseHandler,
$this->tagGenerator->next(),
$command,
);
$response = $interaction->interact();
if ($response->status->type != StatusType::OK) {
throw CommandFailed::withStatus($response->status);
}
return $response;
}
/**
* Sends $command and returns a Generator that yields each untagged Line as
* it arrives from the socket. CommandFailed is thrown (inside the generator)
* if the server responds with NO or BAD.
*
* @return Generator<int, Line, mixed, Status>
*/
public function sendStreaming(Command $command): Generator
{
$this->connect();
$interaction = new CommandInteraction(
$this->connection,
$this->responseHandler,
$this->tagGenerator->next(),
$command,
);
yield from $interaction->streamInteract();
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line;
final readonly class CommandContinuation implements Line
{
public function __construct(
public string $message,
) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class CapabilityData implements Data
{
/**
* @param list<string> $capabilities
*/
public function __construct(public array $capabilities)
{
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
use Gricob\IMAP\Protocol\Response\Line\Line;
interface Data extends Line
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class ExistsData implements Data
{
public function __construct(public int $numberOfMessages)
{
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class ExpungeData implements Data
{
public function __construct(public int $id)
{
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch;
final readonly class Address
{
public function __construct(
public ?string $displayName,
public ?string $atDomainList,
public ?string $mailboxName,
public ?string $hostName,
) {
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch;
final readonly class BodySection
{
public function __construct(public string $section, public string $text)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
class BodyStructure
{
public function __construct(
public Part $part,
) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
final readonly class Disposition
{
/**
* @param array<string, string> $attributes
*/
public function __construct(
public string $type,
public array $attributes,
) {
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope;
readonly class MessagePart extends SinglePart
{
/**
* @param array<string, string> $attributes
* @param string[]|null $language
*/
public function __construct(
array $attributes,
?string $id,
?string $description,
string $encoding,
int $size,
public Envelope $envelope,
public BodyStructure $bodyStructure,
public int $textLines,
?string $md5,
?Disposition $disposition,
?array $language,
?string $location,
) {
parent::__construct(
'MESSAGE',
'RFC822',
$attributes,
$id,
$description,
$encoding,
$size,
$md5,
$disposition,
$language,
$location,
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
final readonly class MultiPart extends Part
{
/**
* @param array<string,string> $attributes
* @param string[] $language
* @param list<Part> $parts
*/
public function __construct(
string $subtype,
array $attributes,
public array $parts,
public ?Disposition $disposition,
public ?array $language,
public ?string $location,
) {
parent::__construct('MULTIPART', $subtype, $attributes);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
abstract readonly class Part
{
/**
* @param array<string,string> $attributes
*/
public function __construct(
public string $type,
public string $subtype,
public array $attributes,
) {
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
readonly class SinglePart extends Part
{
/**
* @param array<string,string> $attributes
* @param string[]|null $language
*/
public function __construct(
string $type,
string $subtype,
array $attributes,
public ?string $id,
public ?string $description,
public string $encoding,
public int $size,
public ?string $md5,
public ?Disposition $disposition,
public ?array $language,
public ?string $location,
) {
parent::__construct($type, $subtype, $attributes);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
final readonly class TextPart extends SinglePart
{
/**
* @param array<string, string> $attributes
* @param string[]|null $language
*/
public function __construct(
string $subtype,
array $attributes,
?string $id,
?string $description,
string $encoding,
int $size,
public int $textLines,
?string $md5,
?Disposition $disposition,
?array $language,
?string $location,
) {
parent::__construct(
'TEXT',
$subtype,
$attributes,
$id,
$description,
$encoding,
$size,
$md5,
$disposition,
$language,
$location,
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data\Fetch;
use DateTimeImmutable;
final readonly class Envelope
{
/**
* @param Address[]|null $from
* @param Address[]|null $sender
* @param Address[]|null $replyTo
* @param Address[]|null $to
* @param Address[]|null $cc
* @param Address[]|null $bcc
*/
public function __construct(
public ?DateTimeImmutable $date,
public ?string $subject,
public ?array $from,
public ?array $sender,
public ?array $replyTo,
public ?array $to,
public ?array $cc,
public ?array $bcc,
public ?string $inReplyTo,
public ?string $messageId,
) {
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodySection;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope;
final readonly class FetchData implements Data
{
/**
* @param array<string>|null $flags
* @param BodySection[] $bodySections
*/
public function __construct(
public int $id,
public ?array $flags = null,
public ?\DateTimeImmutable $internalDate = null,
public ?Envelope $envelope = null,
public ?int $rfc822Size = null,
public ?string $rfc822 = null,
public ?int $uid = null,
public ?BodyStructure $bodyStructure = null,
public array $bodySections = [],
) {
}
public function getBodySection(string $name): ?BodySection
{
foreach (($this->bodySections ?? []) as $bodySection) {
if ($bodySection->section == $name) {
return $bodySection;
}
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class FlagsData implements Data
{
/**
* @param list<string> $flags
*/
public function __construct(public array $flags)
{
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final class ListData implements Data
{
/**
* @param list<string> $nameAttributes
*/
public function __construct(
public array $nameAttributes,
public string $hierarchyDelimiter,
public string $name
) {
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final class RecentData implements Data
{
public function __construct(public int $numberOfMessages)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Data;
final readonly class SearchData implements Data
{
/**
* @param list<int> $numbers
*/
public function __construct(public array $numbers)
{
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line;
interface Line
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class AppendUidCode implements Code
{
public function __construct(
public int $uidValidity,
public int $uid,
) {
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
interface Code
{
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class PermanentFlagsCode implements Code
{
/**
* @param string[] $flags
*/
public function __construct(
public array $flags,
) {
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class ReadOnlyCode implements Code
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class ReadWriteCode implements Code
{
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class UidNextCode implements Code
{
public function __construct(
public int $value,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class UidValidityCode implements Code
{
public function __construct(
public int $value,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status\Code;
final readonly class UnseenCode implements Code
{
public function __construct(
public int $seq,
) {
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response\Line\Status;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Code\Code;
final readonly class Status implements Line
{
final public function __construct(
public string $tag,
public StatusType $type,
public ?Code $code,
public string $message
) {
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Line\Status;
enum StatusType: string
{
case OK = 'OK';
case NO = 'NO';
case BAD = 'BAD';
case PREAUTH = 'PREAUTH';
case BYE = 'BYE';
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Parser;
use Doctrine\Common\Lexer\AbstractLexer;
/**
* @extends AbstractLexer<TokenType, string>
*/
class Lexer extends AbstractLexer
{
protected function getCatchablePatterns(): array
{
return [
'[a-zA-Z0-9\.\-]+',
'\r\n',
];
}
protected function getNonCatchablePatterns(): array
{
return [];
}
protected function getType(string &$value)
{
$normalizedValue = strtoupper($value);
return match($normalizedValue) {
' ' => TokenType::SP,
'.' => TokenType::DOT,
'*' => TokenType::ASTERISK,
'%' => TokenType::PERCENT_SIGN,
'+' => TokenType::PLUS_SIGN,
'=' => TokenType::EQUALS_SIGN,
'"' => TokenType::DOUBLE_QUOTE,
'[' => TokenType::OPEN_BRACKETS,
']' => TokenType::CLOSE_BRACKETS,
'{' => TokenType::OPEN_BRACES,
'}' => TokenType::CLOSE_BRACES,
'(' => TokenType::OPEN_PARENTHESIS,
')' => TokenType::CLOSE_PARENTHESIS,
'\\' => TokenType::BACKSLASH,
"\r\n" => TokenType::CRLF,
'NIL' => TokenType::NIL,
'OK', 'NO', 'BAD', 'BYE', 'PREAUTH' => TokenType::STATUS,
'APPENDUID' => TokenType::APPENDUID,
'UNSEEN' => TokenType::UNSEEN,
'UIDVALIDITY' => TokenType::UIDVALIDITY,
'UIDNEXT' => TokenType::UIDNEXT,
'PERMANENTFLAGS' => TokenType::PERMANENTFLAGS,
'READ-WRITE' => TokenType::READ_WRITE,
'READ-ONLY' => TokenType::READ_ONLY,
'CAPABILITY' => TokenType::CAPABILITY,
'LIST' => TokenType::LIST,
'FLAGS' => TokenType::FLAGS,
'RECENT' => TokenType::RECENT,
'FETCH' => TokenType::FETCH,
'INTERNALDATE' => TokenType::INTERNALDATE,
'SEARCH' => TokenType::SEARCH,
'EXISTS' => TokenType::EXISTS,
'EXPUNGE' => TokenType::EXPUNGE,
'BODY' => TokenType::BODY,
'BODYSTRUCTURE' => TokenType::BODYSTRUCTURE,
'ENVELOPE' => TokenType::ENVELOPE,
'RFC822' => TokenType::RFC822,
'RFC822.SIZE' => TokenType::RFC822_SIZE,
'RFC822.TEXT' => TokenType::RFC822_TEXT,
'RFC822.HEAD' => TokenType::RFC822_HEAD,
'UID' => TokenType::UID,
default => match (true) {
is_numeric($value) => TokenType::NUMBER,
ctype_alnum($value) => TokenType::ALPHANUMERIC,
ctype_cntrl($value) => TokenType::CTL,
default => TokenType::UNKNOWN,
},
};
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Parser;
final class ParseError extends \Exception
{
/**
* @param TokenType[] $expected
*/
public static function unexpectedToken(?TokenType $given, array $expected, string $input): self
{
return new self(
sprintf(
"Expected token of type %s. Given %s.\n%s",
implode(
' or ',
array_map(fn (TokenType $type) => $type->name, $expected)
),
$given?->name ?? 'null',
$input
)
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
<?php
namespace Gricob\IMAP\Protocol\Response\Parser;
enum TokenType
{
case SP;
case DOT;
case ASTERISK;
case PERCENT_SIGN;
case PLUS_SIGN;
case EQUALS_SIGN;
case DOUBLE_QUOTE;
case NUMBER;
case ALPHANUMERIC;
case NIL;
case OPEN_BRACKETS;
case CLOSE_BRACKETS;
case OPEN_BRACES;
case CLOSE_BRACES;
case OPEN_PARENTHESIS;
case CLOSE_PARENTHESIS;
case BACKSLASH;
case CRLF;
case CTL;
case STATUS;
case APPENDUID;
case UNSEEN;
case UIDVALIDITY;
case UIDNEXT;
case PERMANENTFLAGS;
case READ_WRITE;
case READ_ONLY;
case CAPABILITY;
case LIST;
case FLAGS;
case INTERNALDATE;
case RECENT;
case FETCH;
case SEARCH;
case EXISTS;
case EXPUNGE;
case BODY;
case BODYSTRUCTURE;
case ENVELOPE;
case RFC822;
case RFC822_SIZE;
case RFC822_HEAD;
case RFC822_TEXT;
case UID;
case UNKNOWN;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
final readonly class Response
{
/**
* @param list<Line> $data
*/
public function __construct(
public Status $status,
public array $data,
) {
}
/**
* @template T of Line
* @param class-string<T> $type
* @return T[]
*/
public function getData(string $type): array
{
$result = [];
foreach ($this->data as $data) {
if ($data instanceof $type) {
$result[] = $data;
}
}
return $result;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol\Response;
use BadMethodCallException;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
class ResponseBuilder
{
private ?Status $status = null;
/**
* @var list<Line>
*/
private array $data = [];
public function __construct(private readonly string $statusTag)
{
}
public function addLine(Line $line): void
{
if ($line instanceof Status && $line->tag === $this->statusTag) {
$this->status = $line;
return;
}
$this->data[] = $line;
}
public function hasStatus(): bool
{
return $this->status !== null;
}
public function build(): Response
{
if (null === $this->status) {
throw new BadMethodCallException();
}
return new Response(
$this->status,
$this->data,
);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use Generator;
use Gricob\IMAP\Protocol\Response\Line\CommandContinuation;
use Gricob\IMAP\Protocol\Response\Line\Line;
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
use Gricob\IMAP\Protocol\Response\Line\Status\StatusType;
use Gricob\IMAP\Protocol\Response\Parser\Parser;
use Gricob\IMAP\Protocol\Response\Response;
use Gricob\IMAP\Protocol\Response\ResponseBuilder;
use Gricob\IMAP\Transport\ResponseStream;
use RuntimeException;
readonly class ResponseHandler
{
public function __construct(private Parser $parser)
{
}
public function handle(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Response
{
$responseBuilder = new ResponseBuilder($statusTag);
do {
$raw = $stream->readLine();
while (preg_match('/\{(?<bytes>\d+)}\r\n$/', $raw, $matches)) {
$raw .= $stream->read((int) $matches['bytes']);
$raw .= $stream->readLine();
}
$line = $this->parser->parse($raw);
if ($line instanceof CommandContinuation) {
$continuationHandler->continue();
continue;
}
$responseBuilder->addLine($line);
} while (!$responseBuilder->hasStatus());
return $responseBuilder->build();
}
/**
* Streams parsed response lines one at a time as a Generator, yielding each
* untagged Line immediately as it arrives from the socket. The terminal
* Status line is NOT yielded; instead it is set as the generator return
* value so callers can retrieve it via $gen->getReturn() after exhaustion.
*
* @throws CommandFailed if the tagged status is NO or BAD
*
* @return Generator<int, Line, mixed, Status>
*/
public function stream(string $statusTag, ResponseStream $stream, ContinuationHandler $continuationHandler): Generator
{
$status = null;
do {
$raw = $stream->readLine();
while (preg_match('/\{(?<bytes>\d+)}\r\n$/', $raw, $matches)) {
$raw .= $stream->read((int) $matches['bytes']);
$raw .= $stream->readLine();
}
$line = $this->parser->parse($raw);
if ($line instanceof CommandContinuation) {
$continuationHandler->continue();
continue;
}
if ($line instanceof Status && $line->tag === $statusTag) {
$status = $line;
break;
}
yield $line;
} while (true);
if ($status->type !== StatusType::OK) {
throw CommandFailed::withStatus($status);
}
return $status;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
final class TagGenerator
{
private const MAX_NUMBER = 999;
private const NUMBER_PART_LENGTH = 3;
private const INITIAL_LETTER = 'A';
private const INITIAL_NUMBER = 0;
private string $letter = self::INITIAL_LETTER;
private int $number = self::INITIAL_NUMBER;
public function next(): string
{
$this->number += 1;
if ($this->number > self::MAX_NUMBER) {
$this->letter++;
$this->number = self::INITIAL_NUMBER;
}
if (strlen($this->letter) > 1) {
$this->letter = self::INITIAL_LETTER;
}
return sprintf(
'%s%s',
$this->letter,
str_pad((string) $this->number, self::NUMBER_PART_LENGTH, '0', STR_PAD_LEFT)
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Gricob\IMAP\Protocol;
use RuntimeException;
class UnexpectedContinuationHandler implements ContinuationHandler
{
public function continue(): void
{
throw new RuntimeException('Unexpected continuation response');
}
}