generated from Nodarx/template
refactor: use custom imap client #10
@@ -27,8 +27,7 @@
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KTXM\\ProviderImap\\": "lib/",
|
||||
"Gricob\\IMAP\\": "lib/Client"
|
||||
"KTXM\\ProviderImap\\": "lib/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
@@ -2,622 +2,97 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP;
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Generator;
|
||||
use Gricob\IMAP\Mime\LazyMessage;
|
||||
use Gricob\IMAP\Mime\Message;
|
||||
use Gricob\IMAP\Mime\Part\Disposition;
|
||||
use Gricob\IMAP\Mime\Part\LazyBody;
|
||||
use Gricob\IMAP\Mime\Part\MultiPart;
|
||||
use Gricob\IMAP\Mime\Part\Part;
|
||||
use Gricob\IMAP\Mime\Part\SinglePart;
|
||||
use Gricob\IMAP\Protocol\Command\AppendCommand;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
|
||||
use Gricob\IMAP\Protocol\Command\Authenticate\SASLMechanism;
|
||||
use Gricob\IMAP\Protocol\Command\AuthenticateCommand;
|
||||
use Gricob\IMAP\Protocol\Command\Command;
|
||||
use Gricob\IMAP\Protocol\Command\CreateCommand;
|
||||
use Gricob\IMAP\Protocol\Command\ExpungeCommand;
|
||||
use Gricob\IMAP\Protocol\Command\FetchCommand;
|
||||
use Gricob\IMAP\Protocol\Command\ListCommand;
|
||||
use Gricob\IMAP\Protocol\Command\LogInCommand;
|
||||
use Gricob\IMAP\Protocol\Command\SearchCommand;
|
||||
use Gricob\IMAP\Protocol\Command\SelectCommand;
|
||||
use Gricob\IMAP\Protocol\Command\StoreCommand;
|
||||
use Gricob\IMAP\Protocol\Imap;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FlagsData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\ExistsData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\RecentData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\ListData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\SearchData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Code\AppendUidCode;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Code\PermanentFlagsCode;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidNextCode;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UidValidityCode;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Code\UnseenCode;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Status\Status;
|
||||
use Gricob\IMAP\Protocol\Response\Response;
|
||||
use Gricob\IMAP\Transport\Socket\SocketConnection;
|
||||
use Gricob\IMAP\Transport\Traceable\TraceableConnection;
|
||||
use KTXM\ProviderImap\Client\Command\CapabilityCommand;
|
||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||
use KTXM\ProviderImap\Client\Command\LoginCommand;
|
||||
use KTXM\ProviderImap\Client\Command\StatusCommand;
|
||||
use KTXM\ProviderImap\Client\Command\StartTlsCommand;
|
||||
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
|
||||
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
|
||||
use KTXM\ProviderImap\Client\Protocol\ProtocolWriter;
|
||||
use KTXM\ProviderImap\Client\Protocol\TagGenerator;
|
||||
use KTXM\ProviderImap\Client\Transport\ConnectionFactoryInterface;
|
||||
use KTXM\ProviderImap\Client\Transport\SocketConnectionFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
class Client
|
||||
final class Client implements ClientInterface
|
||||
{
|
||||
public Configuration $configuration;
|
||||
private Imap $imap;
|
||||
private ?SessionContext $session = null;
|
||||
private ?CommandExecutor $executor = null;
|
||||
|
||||
private Mailbox $selectedMailbox;
|
||||
public function __construct(
|
||||
private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(),
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
private function __construct(
|
||||
Configuration $configuration,
|
||||
?LoggerInterface $logger,
|
||||
) {
|
||||
$connection = new SocketConnection(
|
||||
$configuration->transport,
|
||||
$configuration->host,
|
||||
$configuration->port,
|
||||
$configuration->timeout,
|
||||
$configuration->verifyPeer,
|
||||
$configuration->verifyPeerName,
|
||||
$configuration->allowSelfSigned,
|
||||
);
|
||||
public function connect(ConnectionConfig $config): void
|
||||
{
|
||||
$connection = $this->connectionFactory->create($config, $this->logger);
|
||||
$connection->connect($config);
|
||||
|
||||
if (null !== $logger) {
|
||||
$connection = new TraceableConnection($connection, $logger);
|
||||
$reader = new ProtocolReader($connection, $this->logger);
|
||||
$writer = new ProtocolWriter($connection, $this->logger);
|
||||
$session = new SessionContext($config, $connection);
|
||||
$greeting = $reader->readGreeting();
|
||||
|
||||
$session->setGreeting($greeting);
|
||||
$session->setState(match ($greeting->status()) {
|
||||
'OK' => SessionState::NotAuthenticated,
|
||||
'PREAUTH' => SessionState::Authenticated,
|
||||
'BYE' => SessionState::Logout,
|
||||
default => throw new ImapException('Unexpected IMAP greeting status: ' . $greeting->status()),
|
||||
});
|
||||
|
||||
if ($session->state() === SessionState::Logout) {
|
||||
throw new ImapException('IMAP server rejected the connection: ' . $greeting->text());
|
||||
}
|
||||
|
||||
$this->configuration = $configuration;
|
||||
$this->imap = new Imap($connection);
|
||||
$this->selectedMailbox = new Mailbox([], '', '');
|
||||
}
|
||||
$this->session = $session;
|
||||
$this->executor = new CommandExecutor($reader, $writer, new TagGenerator(), $this->logger);
|
||||
|
||||
public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self
|
||||
{
|
||||
return new self($configuration, $logger);
|
||||
}
|
||||
$this->perform(new CapabilityCommand());
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$this->imap->connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform STARTTLS negotiation (patch).
|
||||
*
|
||||
* Call after connect() but before logIn(). The underlying Imap protocol
|
||||
* layer sends the STARTTLS command and upgrades the socket to TLS.
|
||||
*/
|
||||
public function startTls(): void
|
||||
{
|
||||
$this->imap->startTls();
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
$this->imap->disconnect();
|
||||
}
|
||||
|
||||
public function logIn(string $username, string $password): void
|
||||
{
|
||||
$this->send(new LogInCommand($username, $password));
|
||||
}
|
||||
|
||||
public function authenticate(SASLMechanism $mechanism): void
|
||||
{
|
||||
$this->send(new AuthenticateCommand($mechanism));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Mailbox>
|
||||
*/
|
||||
public function mailboxes(string $referenceName = '', string $pattern = '*'): array
|
||||
{
|
||||
$response = $this->send(new ListCommand($referenceName, $pattern));
|
||||
|
||||
return array_map(
|
||||
fn (ListData $data) => new Mailbox($data->nameAttributes, $data->hierarchyDelimiter, $data->name),
|
||||
$response->getData(ListData::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function select(Mailbox|string $mailbox): Mailbox
|
||||
{
|
||||
if (is_string($mailbox)) {
|
||||
$mailbox = new Mailbox([], '', $mailbox);
|
||||
if ($config->security() === ConnectionSecurity::StartTls) {
|
||||
$this->perform(new StartTlsCommand());
|
||||
$this->perform(new CapabilityCommand());
|
||||
}
|
||||
|
||||
$response = $this->send(new SelectCommand($mailbox->name));
|
||||
|
||||
if ($flagsData = $response->getData(FlagsData::class)[0] ?? null) {
|
||||
$mailbox->flags = $flagsData->flags;
|
||||
}
|
||||
|
||||
if ($existsData = $response->getData(ExistsData::class)[0] ?? null) {
|
||||
$mailbox->exists = $existsData->numberOfMessages;
|
||||
}
|
||||
|
||||
if ($recentData = $response->getData(RecentData::class)[0] ?? null) {
|
||||
$mailbox->recent = $recentData->numberOfMessages;
|
||||
}
|
||||
|
||||
foreach ($response->getData(Status::class) as $status) {
|
||||
if ($status->code instanceof UnseenCode) {
|
||||
$mailbox->unseen = $status->code->seq;
|
||||
} elseif ($status->code instanceof UidValidityCode) {
|
||||
$mailbox->uidValidity = $status->code->value;
|
||||
} elseif ($status->code instanceof UidNextCode) {
|
||||
$mailbox->uidNext = $status->code->value;
|
||||
} elseif ($status->code instanceof PermanentFlagsCode) {
|
||||
$mailbox->permanentFlags = $status->code->flags;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->selectedMailbox = $mailbox;
|
||||
}
|
||||
|
||||
public function search(): Search
|
||||
{
|
||||
return new Search($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws MessageNotFound
|
||||
*/
|
||||
public function fetch(int $id): Message
|
||||
{
|
||||
$response = $this->imap->send(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
['INTERNALDATE', 'BODY[HEADER]', 'BODYSTRUCTURE']
|
||||
)
|
||||
);
|
||||
|
||||
$data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound();
|
||||
|
||||
if (null === $internalDate = $data->internalDate) {
|
||||
throw new Exception('Unable to fetch internal date from message '.$id);
|
||||
}
|
||||
|
||||
if (null === $part = $data->bodyStructure?->part) {
|
||||
throw new Exception('Unable to fetch body structure from message '.$id);
|
||||
}
|
||||
|
||||
return new Message(
|
||||
$id,
|
||||
$this->createHeaders($data) ?? [],
|
||||
$this->createMessagePart($id, '0', $part),
|
||||
$internalDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream FetchData for a specific set of UIDs, one response line at a time.
|
||||
*
|
||||
* Uses the same sendStreaming path as fetchMultiple() so responses are
|
||||
* processed as they arrive off the socket without buffering the entire
|
||||
* server reply. Items can be tailored per call-site; defaults to a rich
|
||||
* set that populates EntityResource fully (flags, envelope, body structure,
|
||||
* size, arrival date).
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @param string[] $items IMAP fetch data items
|
||||
* @return Generator<int, FetchData> Yields uid => FetchData
|
||||
*/
|
||||
public function streamByUids(
|
||||
array $uids,
|
||||
array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'],
|
||||
): Generator {
|
||||
$gen = $this->imap->sendStreaming(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet(...$uids),
|
||||
$items,
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($gen as $line) {
|
||||
if (!$line instanceof FetchData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $line->id;
|
||||
if ($this->configuration->useUid) {
|
||||
$id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id);
|
||||
}
|
||||
|
||||
yield $id => $line;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream every message in the currently-selected mailbox using a 1:*
|
||||
* sequence set, yielding uid (or sequence number) => FetchData as each
|
||||
* FETCH response arrives off the socket.
|
||||
*
|
||||
* @param string $mailbox Mailbox to select before fetching
|
||||
* @param string[] $items IMAP FETCH data items
|
||||
* @return Generator<int, FetchData>
|
||||
*/
|
||||
public function streamAll(
|
||||
string $mailbox,
|
||||
array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'],
|
||||
): Generator {
|
||||
$this->select($mailbox);
|
||||
|
||||
$gen = $this->imap->sendStreaming(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
SequenceSet::all(),
|
||||
$items,
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($gen as $line) {
|
||||
if (!$line instanceof FetchData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $line->id;
|
||||
if ($this->configuration->useUid) {
|
||||
$id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id);
|
||||
}
|
||||
|
||||
yield $id => $line;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream messages from a sequence range as a Generator, yielding each
|
||||
* LazyMessage as soon as its FETCH response line arrives off the socket —
|
||||
* without waiting for the entire batch to complete.
|
||||
*
|
||||
* Usage with an NDJSON HTTP response:
|
||||
*
|
||||
* foreach ($client->fetchMultiple(1, 50) as $message) {
|
||||
* echo json_encode($message) . "\n";
|
||||
* flush();
|
||||
* }
|
||||
*
|
||||
* @param int $from First sequence number (inclusive)
|
||||
* @param int $to Last sequence number (inclusive)
|
||||
* @return Generator<int, LazyMessage>
|
||||
*/
|
||||
public function fetchMultiple(int $from, int $to): Generator
|
||||
{
|
||||
$items = ['FLAGS', 'INTERNALDATE', 'BODY[HEADER]'];
|
||||
|
||||
$gen = $this->imap->sendStreaming(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
SequenceSet::range($from, $to),
|
||||
$items,
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($gen as $line) {
|
||||
if (!$line instanceof FetchData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $line->id;
|
||||
if ($this->configuration->useUid) {
|
||||
$id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id);
|
||||
}
|
||||
|
||||
yield new LazyMessage(
|
||||
$this,
|
||||
$id,
|
||||
$this->createHeaders($line),
|
||||
$line->internalDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
* @throws MessageNotFound
|
||||
*/
|
||||
public function fetchHeaders(int $id): array
|
||||
{
|
||||
$response = $this->imap->send(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
['BODY[HEADER]']
|
||||
)
|
||||
);
|
||||
|
||||
/** @var FetchData $data */
|
||||
$data = $response->getData(FetchData::class)[0] ?? throw new MessageNotFound();
|
||||
|
||||
return $this->createHeaders($data) ?? [];
|
||||
}
|
||||
|
||||
public function fetchBody(int $id): Part
|
||||
{
|
||||
$response = $this->send(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
['BODYSTRUCTURE']
|
||||
)
|
||||
);
|
||||
|
||||
$data = $response->getData(FetchData::class)[0];
|
||||
|
||||
if (null === $part = $data->bodyStructure?->part) {
|
||||
throw new Exception('Unable to fetch body from message '.$id);
|
||||
}
|
||||
|
||||
return $this->createMessagePart($id, '0', $part);
|
||||
}
|
||||
|
||||
public function fetchInternalDate(int $id): DateTimeImmutable
|
||||
{
|
||||
$response = $this->send(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
['INTERNALDATE']
|
||||
)
|
||||
);
|
||||
|
||||
$data = $response->getData(FetchData::class)[0];
|
||||
|
||||
if (null === $internalDate = $data->internalDate) {
|
||||
throw new Exception('Unable to fetch internal date from message '.$id);
|
||||
}
|
||||
|
||||
return $internalDate;
|
||||
}
|
||||
|
||||
public function fetchSectionBody(int $id, string $section): string
|
||||
{
|
||||
$response = $this->send(
|
||||
new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
["BODY[$section]"]
|
||||
)
|
||||
);
|
||||
|
||||
$data = $response->getData(FetchData::class)[0];
|
||||
|
||||
return $data->getBodySection($section)?->text ?? '';
|
||||
}
|
||||
|
||||
public function deleteMessage(Message|int $message): void
|
||||
{
|
||||
$id = $message instanceof Message ? $message->id() : $message;
|
||||
|
||||
$this->send(
|
||||
new StoreCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet($id),
|
||||
new Flags(['\Deleted'], '+')
|
||||
)
|
||||
);
|
||||
|
||||
$this->send(new ExpungeCommand());
|
||||
}
|
||||
|
||||
public function createMailbox(string $name): void
|
||||
{
|
||||
$this->send(new CreateCommand($name));
|
||||
}
|
||||
|
||||
/** Delete a mailbox by name. */
|
||||
public function deleteMailbox(string $name): void
|
||||
{
|
||||
$this->send(new Command('DELETE', new QuotedString($name)));
|
||||
}
|
||||
|
||||
/** Rename a mailbox. */
|
||||
public function renameMailbox(string $oldName, string $newName): void
|
||||
{
|
||||
$this->send(new Command('RENAME', new QuotedString($oldName), new QuotedString($newName)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy messages to a destination mailbox.
|
||||
*
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public function copyMessages(string $mailbox, array $uids, string $destination): void
|
||||
{
|
||||
$this->select($mailbox);
|
||||
$this->send(new Command('UID COPY', new SequenceSet(...$uids), new QuotedString($destination)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set, add, or remove flags on a set of messages in a single round-trip.
|
||||
*
|
||||
* @param string $action '+' to add, '-' to remove, '' to replace
|
||||
* @param string[] $flags e.g. ['\\Seen', '\\Flagged']
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void
|
||||
{
|
||||
$this->select($mailbox);
|
||||
$this->send(new StoreCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet(...$uids),
|
||||
new Flags($flags, $action),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete messages by UID (marks \\Deleted then EXPUNGEs).
|
||||
*
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public function deleteMessages(string $mailbox, array $uids): void
|
||||
{
|
||||
$this->storeFlags($mailbox, $uids, '+', ['\\Deleted']);
|
||||
$this->send(new ExpungeCommand());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a mailbox with the given criteria and return matching UIDs (or
|
||||
* sequence numbers when useUid is false).
|
||||
*
|
||||
* @param Criteria[] $criteria Pass no criteria to match ALL messages.
|
||||
* @return int[]
|
||||
*/
|
||||
public function searchMessages(string $mailbox, array $criteria = []): array
|
||||
{
|
||||
$this->select($mailbox);
|
||||
$response = $this->send(new SearchCommand($this->configuration->useUid, ...$criteria));
|
||||
$ids = [];
|
||||
foreach ($response->getData(SearchData::class) as $searchData) {
|
||||
array_push($ids, ...$searchData->numbers);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $flags
|
||||
*/
|
||||
public function append(
|
||||
string $message,
|
||||
string $mailbox = 'INBOX',
|
||||
?array $flags = null,
|
||||
?DateTimeInterface $internalDate = null
|
||||
): int
|
||||
{
|
||||
$response = $this->send(new AppendCommand($mailbox, $message, $flags, $internalDate));
|
||||
|
||||
$code = $response->status->code;
|
||||
if ($code instanceof AppendUidCode) {
|
||||
return $code->uid;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unable to retrieve uid from append response');
|
||||
}
|
||||
|
||||
public function send(Command $command): Response
|
||||
{
|
||||
$this->imap->connect();
|
||||
|
||||
return $this->imap->send($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Criteria> $criteria
|
||||
* @return array<Message>
|
||||
*/
|
||||
public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array
|
||||
{
|
||||
$response = $this->send(
|
||||
new SearchCommand(
|
||||
$this->configuration->useUid,
|
||||
...$criteria
|
||||
)
|
||||
);
|
||||
|
||||
$ids = [];
|
||||
foreach ($response->data as $data) {
|
||||
if ($data instanceof SearchData) {
|
||||
array_push($ids, ...$data->numbers);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (null !== $preFetchOptions) {
|
||||
$items = [];
|
||||
|
||||
if ($preFetchOptions->headers) {
|
||||
$items[] = 'BODY[HEADER]';
|
||||
}
|
||||
|
||||
if ($preFetchOptions->internalDate) {
|
||||
$items[] = 'INTERNALDATE';
|
||||
}
|
||||
|
||||
$preFetchResult = $this->send(new FetchCommand(
|
||||
$this->configuration->useUid,
|
||||
new SequenceSet(...$ids),
|
||||
$items,
|
||||
if ($config->hasCredentials()) {
|
||||
$this->perform(new LoginCommand(
|
||||
$config->username() ?? '',
|
||||
$config->password() ?? '',
|
||||
));
|
||||
|
||||
$messages = [];
|
||||
foreach ($preFetchResult->data as $data) {
|
||||
if ($data instanceof FetchData) {
|
||||
$id = $data->id;
|
||||
if ($this->configuration->useUid) {
|
||||
$id = $data->uid ?? throw new RuntimeException('Unable to get uid from message '.$id);
|
||||
}
|
||||
|
||||
$messages[] = new LazyMessage(
|
||||
$this,
|
||||
$id,
|
||||
$this->createHeaders($data),
|
||||
$data->internalDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
$this->perform(new CapabilityCommand());
|
||||
}
|
||||
|
||||
return array_map(fn (int $id) => new LazyMessage($this, $id), $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
private function createHeaders(FetchData $data): ?array
|
||||
public function capabilities(): array
|
||||
{
|
||||
if (null === $headerSection = $data->getBodySection('HEADER')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: [];
|
||||
return $this->session()->capabilities();
|
||||
}
|
||||
|
||||
private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
if ($part instanceof BodyStructure\SinglePart) {
|
||||
return new SinglePart(
|
||||
$part->type,
|
||||
$part->subtype,
|
||||
$part->attributes,
|
||||
new LazyBody($this, $id, $section === '0' ? '1' : $section),
|
||||
$part->attributes['charset'] ?? 'utf-8',
|
||||
$part->encoding,
|
||||
null !== $part->disposition
|
||||
? new Disposition(
|
||||
$part->disposition->type,
|
||||
$part->disposition->attributes['filename'] ?? null
|
||||
) : null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!$part instanceof BodyStructure\MultiPart) {
|
||||
throw new Exception('Unable to create message part from body structure part of class '.$part::class);
|
||||
}
|
||||
|
||||
$childParts = [];
|
||||
foreach ($part->parts as $index => $childPart) {
|
||||
$childIndex = (string) ($index + 1);
|
||||
$childSection = $section === '0' ? $childIndex : $section.'.'.$childIndex;
|
||||
$childParts[] = $this->createMessagePart($id, $childSection, $childPart);
|
||||
}
|
||||
|
||||
return new MultiPart($part->subtype, $part->attributes, $childParts);
|
||||
return $this->session()->hasCapability($capability);
|
||||
}
|
||||
}
|
||||
|
||||
public function perform(CommandInterface $command): mixed
|
||||
{
|
||||
if ($this->session === null || $this->executor === null) {
|
||||
throw new ImapException('IMAP client is not connected.');
|
||||
}
|
||||
|
||||
return $this->executor->perform($command, $this->session);
|
||||
}
|
||||
|
||||
public function session(): SessionContext
|
||||
{
|
||||
if ($this->session === null) {
|
||||
throw new ImapException('IMAP client is not connected.');
|
||||
}
|
||||
|
||||
return $this->session;
|
||||
}
|
||||
}
|
||||
24
lib/Client/ClientInterface.php
Normal file
24
lib/Client/ClientInterface.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||
|
||||
interface ClientInterface
|
||||
{
|
||||
public function connect(ConnectionConfig $config): void;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* @template TResult
|
||||
* @param CommandInterface<TResult> $command
|
||||
* @return TResult
|
||||
*/
|
||||
public function perform(CommandInterface $command): mixed;
|
||||
}
|
||||
62
lib/Client/Command/CapabilityCommand.php
Normal file
62
lib/Client/Command/CapabilityCommand.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CapabilityResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CapabilityResult>
|
||||
*/
|
||||
final class CapabilityCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'CAPABILITY';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('CAPABILITY');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CapabilityResult
|
||||
{
|
||||
$capabilities = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
|
||||
$capabilities = array_map('strtoupper', $response->payloadTokens());
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('CAPABILITY failed: ' . $response->text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context->replaceCapabilities(...$capabilities);
|
||||
|
||||
return new CapabilityResult($context->capabilities());
|
||||
}
|
||||
}
|
||||
30
lib/Client/Command/CommandInterface.php
Normal file
30
lib/Client/Command/CommandInterface.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @template TResult
|
||||
*/
|
||||
interface CommandInterface
|
||||
{
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @return list<SessionState>
|
||||
*/
|
||||
public function allowedStates(): array;
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame;
|
||||
|
||||
/**
|
||||
* @return TResult
|
||||
*/
|
||||
public function handle(ResponseStream $responses, SessionContext $context): mixed;
|
||||
}
|
||||
47
lib/Client/Command/CopyCommand.php
Normal file
47
lib/Client/Command/CopyCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<MessageTransferResult>
|
||||
*/
|
||||
final class CopyCommand implements CommandInterface
|
||||
{
|
||||
private readonly MessageTransferCommand $command;
|
||||
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
string $destinationMailbox = '',
|
||||
) {
|
||||
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->command->name();
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return $this->command->allowedStates();
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
return $this->command->encode($tag, $context);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
|
||||
{
|
||||
return $this->command->handle($responses, $context);
|
||||
}
|
||||
}
|
||||
65
lib/Client/Command/CreateCommand.php
Normal file
65
lib/Client/Command/CreateCommand.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class CreateCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'CREATE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf('CREATE %s', $this->quote($this->mailbox)));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('CREATE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('CREATE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
68
lib/Client/Command/DeleteCommand.php
Normal file
68
lib/Client/Command/DeleteCommand.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class DeleteCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'DELETE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf('DELETE %s', $this->quote($this->mailbox)));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
if ($context->selectedMailbox() === $this->mailbox) {
|
||||
$context->setSelectedMailbox(null);
|
||||
$context->setState(SessionState::Authenticated);
|
||||
}
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('DELETE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('DELETE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
106
lib/Client/Command/ExpungeCommand.php
Normal file
106
lib/Client/Command/ExpungeCommand.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<list<int>>
|
||||
*/
|
||||
final class ExpungeCommand implements CommandInterface
|
||||
{
|
||||
private readonly ?SequenceSet $sequenceSet;
|
||||
|
||||
public function __construct(FetchTarget|string|SequenceSet|null $target = null)
|
||||
{
|
||||
if ($target === null) {
|
||||
$this->sequenceSet = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedTarget = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($resolvedTarget === null || $resolvedTarget->identifierMode() !== IdentifierMode::Uid) {
|
||||
throw new ImapException('Targeted EXPUNGE requires a UID target.');
|
||||
}
|
||||
|
||||
$this->sequenceSet = $resolvedTarget->sequenceSet();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'EXPUNGE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag);
|
||||
|
||||
if ($this->sequenceSet === null) {
|
||||
unset($context);
|
||||
|
||||
return new RequestFrame('EXPUNGE');
|
||||
}
|
||||
|
||||
if (!$context->hasCapability('UIDPLUS')) {
|
||||
throw new ImapException('UID EXPUNGE requires the IMAP UIDPLUS capability.');
|
||||
}
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'UID EXPUNGE %s',
|
||||
$this->sequenceSet->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): array
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('EXPUNGE requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$expunged = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) {
|
||||
$expunged[] = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException($this->sequenceSet === null
|
||||
? 'EXPUNGE failed: ' . $response->text()
|
||||
: 'UID EXPUNGE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return $expunged;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException($this->sequenceSet === null
|
||||
? 'EXPUNGE did not receive a tagged completion response.'
|
||||
: 'UID EXPUNGE did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
67
lib/Client/Command/FetchManyCommand.php
Normal file
67
lib/Client/Command/FetchManyCommand.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\FetchOptions;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Generator<int, Message>>
|
||||
*/
|
||||
final class FetchManyCommand implements CommandInterface
|
||||
{
|
||||
private readonly FetchTarget $target;
|
||||
private readonly FetchOptions $options;
|
||||
|
||||
public function __construct(FetchTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
|
||||
{
|
||||
$this->target = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => FetchTarget::all(),
|
||||
};
|
||||
$this->options = $options ?? FetchOptions::default();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'FETCH';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%s %s (%s)',
|
||||
$this->target->toCommand(),
|
||||
$this->target->sequenceSet()->toCommand(),
|
||||
$this->options->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Generator
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('FETCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
return (new FetchResponseParser())->parseMany($responses);
|
||||
}
|
||||
}
|
||||
62
lib/Client/Command/FetchOneCommand.php
Normal file
62
lib/Client/Command/FetchOneCommand.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\FetchOptions;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Message>
|
||||
*/
|
||||
final class FetchOneCommand implements CommandInterface
|
||||
{
|
||||
private readonly FetchTarget $target;
|
||||
private readonly FetchOptions $options;
|
||||
|
||||
public function __construct(FetchTarget|int|string $target, ?FetchOptions $options = null)
|
||||
{
|
||||
$this->target = $target instanceof FetchTarget
|
||||
? $target
|
||||
: FetchTarget::sequence($target);
|
||||
$this->options = $options ?? FetchOptions::default();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'FETCH';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%s %s (%s)',
|
||||
$this->target->toCommand(),
|
||||
$this->target->sequenceSet()->toCommand(),
|
||||
$this->options->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Message
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('FETCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
return (new FetchResponseParser())->parseOne($responses);
|
||||
}
|
||||
}
|
||||
58
lib/Client/Command/FetchResponseParser.php
Normal file
58
lib/Client/Command/FetchResponseParser.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\MessageParser;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
|
||||
final class FetchResponseParser
|
||||
{
|
||||
public function parseOne(ResponseStream $responses): Message
|
||||
{
|
||||
$message = null;
|
||||
|
||||
foreach ($this->parseMany($responses) as $summary) {
|
||||
if ($message !== null) {
|
||||
throw new ImapException('FETCH returned multiple messages for a single-message request.');
|
||||
}
|
||||
|
||||
$message = $summary;
|
||||
}
|
||||
|
||||
if ($message === null) {
|
||||
throw new ImapException('FETCH did not return a message summary.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Generator<int, Message>
|
||||
*/
|
||||
public function parseMany(ResponseStream $responses): Generator
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) {
|
||||
yield MessageParser::parse($response->raw());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('FETCH failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('FETCH did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
263
lib/Client/Command/ListCommand.php
Normal file
263
lib/Client/Command/ListCommand.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\ListReturnOptions;
|
||||
use KTXM\ProviderImap\Client\ListSelectionOptions;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Generator<int, Mailbox>>
|
||||
*/
|
||||
final class ListCommand implements CommandInterface
|
||||
{
|
||||
private readonly ListSelectionOptions $selectionOptions;
|
||||
private readonly ListReturnOptions $returnOptions;
|
||||
private readonly StatusResponseParser $statusResponseParser;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $reference = '',
|
||||
private readonly string $pattern = '*',
|
||||
?ListSelectionOptions $selectionOptions = null,
|
||||
?ListReturnOptions $returnOptions = null,
|
||||
) {
|
||||
$this->selectionOptions = $selectionOptions ?? ListSelectionOptions::none();
|
||||
$this->returnOptions = $returnOptions ?? ListReturnOptions::none();
|
||||
$this->statusResponseParser = new StatusResponseParser();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'LIST';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$command = 'LIST';
|
||||
|
||||
$selectionOptions = $this->selectionOptions->toCommand();
|
||||
if ($selectionOptions !== null) {
|
||||
$command .= ' ' . $selectionOptions;
|
||||
}
|
||||
|
||||
$command .= sprintf(
|
||||
' %s %s',
|
||||
$this->quote($this->reference),
|
||||
$this->quote($this->pattern),
|
||||
);
|
||||
|
||||
$returnOptions = $this->returnOptions->toCommand();
|
||||
if ($returnOptions !== null) {
|
||||
$command .= ' RETURN ' . $returnOptions;
|
||||
}
|
||||
|
||||
return new RequestFrame($command);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Generator
|
||||
{
|
||||
unset($context);
|
||||
|
||||
if (!$this->returnOptions->hasStatus()) {
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
||||
yield $this->parseMailbox($response->payload());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LIST failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LIST did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
$mailboxes = [];
|
||||
$statuses = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'LIST') {
|
||||
$mailbox = $this->parseMailbox($response->payload());
|
||||
$mailboxes[$mailbox->name()] = $this->applyStatus(
|
||||
$mailbox,
|
||||
$statuses[$mailbox->name()] ?? [],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
|
||||
[$mailboxName, $status] = $this->statusResponseParser->parse($response->payload());
|
||||
$statuses[$mailboxName] = $status;
|
||||
|
||||
if (isset($mailboxes[$mailboxName])) {
|
||||
$mailboxes[$mailboxName] = $this->applyStatus($mailboxes[$mailboxName], $status);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LIST failed: ' . $response->text());
|
||||
}
|
||||
|
||||
foreach ($mailboxes as $mailbox) {
|
||||
yield $mailbox;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LIST did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function parseMailbox(string $payload): Mailbox
|
||||
{
|
||||
$payload = trim($payload);
|
||||
$offset = 0;
|
||||
|
||||
$attributesToken = $this->readToken($payload, $offset);
|
||||
$delimiterToken = $this->readToken($payload, $offset);
|
||||
$nameToken = $this->readToken($payload, $offset);
|
||||
|
||||
if ($attributesToken === null || $delimiterToken === null || $nameToken === null) {
|
||||
throw new ImapException('Unable to parse LIST response payload: ' . $payload);
|
||||
}
|
||||
|
||||
$attributeString = trim($attributesToken, '() ');
|
||||
$attributes = $attributeString === '' || strtoupper($attributeString) === 'NIL'
|
||||
? []
|
||||
: array_map('strtoupper', preg_split('/\s+/', $attributeString) ?: []);
|
||||
|
||||
$delimiter = $this->decodeAtom($delimiterToken);
|
||||
$name = $this->decodeMailboxName($nameToken);
|
||||
|
||||
return new Mailbox($name, $delimiter, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $status
|
||||
*/
|
||||
private function applyStatus(Mailbox $mailbox, array $status): Mailbox
|
||||
{
|
||||
return new Mailbox(
|
||||
$mailbox->name(),
|
||||
$mailbox->delimiter(),
|
||||
$mailbox->attributes(),
|
||||
$status['MESSAGES'] ?? $mailbox->messages(),
|
||||
$status['UNSEEN'] ?? $mailbox->unread(),
|
||||
$mailbox->state(),
|
||||
$mailbox->recent(),
|
||||
$mailbox->flags(),
|
||||
$mailbox->readOnly(),
|
||||
);
|
||||
}
|
||||
|
||||
private function readToken(string $payload, int &$offset): ?string
|
||||
{
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length && ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
if ($offset >= $length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '(') {
|
||||
$end = strpos($payload, ')', $offset);
|
||||
|
||||
if ($end === false) {
|
||||
throw new ImapException('Unterminated LIST attribute block: ' . $payload);
|
||||
}
|
||||
|
||||
$token = substr($payload, $offset, $end - $offset + 1);
|
||||
$offset = $end + 1;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '"') {
|
||||
$start = $offset;
|
||||
$offset++;
|
||||
|
||||
while ($offset < $length) {
|
||||
if ($payload[$offset] === '\\') {
|
||||
$offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '"') {
|
||||
$offset++;
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated quoted LIST token: ' . $payload);
|
||||
}
|
||||
|
||||
$start = $offset;
|
||||
while ($offset < $length && !ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
|
||||
private function decodeAtom(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (strtoupper($value) === 'NIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
||||
return stripcslashes(substr($value, 1, -1));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function decodeMailboxName(string $value): string
|
||||
{
|
||||
$name = $this->decodeAtom($value);
|
||||
|
||||
// LIST may advertise the root mailbox as an empty quoted string.
|
||||
return $name ?? '';
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
67
lib/Client/Command/LoginCommand.php
Normal file
67
lib/Client/Command/LoginCommand.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class LoginCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'LOGIN';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::NotAuthenticated];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'LOGIN %s %s',
|
||||
$this->quote($this->username),
|
||||
$this->quote($this->password),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LOGIN failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->setState(SessionState::Authenticated);
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LOGIN did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
59
lib/Client/Command/LogoutCommand.php
Normal file
59
lib/Client/Command/LogoutCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class LogoutCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'LOGOUT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('LOGOUT');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LOGOUT failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->setSelectedMailbox(null);
|
||||
$context->setState(SessionState::Logout);
|
||||
$context->connection()->disconnect();
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LOGOUT did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
243
lib/Client/Command/MessageTransferCommand.php
Normal file
243
lib/Client/Command/MessageTransferCommand.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<MessageTransferResult>
|
||||
*/
|
||||
final class MessageTransferCommand implements CommandInterface
|
||||
{
|
||||
private readonly string $operation;
|
||||
private readonly SequenceSet $sequenceSet;
|
||||
private readonly IdentifierMode $identifierMode;
|
||||
|
||||
public function __construct(
|
||||
string $operation,
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
private readonly string $destinationMailbox = '',
|
||||
) {
|
||||
$resolvedTarget = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => FetchTarget::all(),
|
||||
};
|
||||
|
||||
$this->operation = strtoupper(trim($operation));
|
||||
|
||||
if (!in_array($this->operation, ['COPY', 'MOVE'], true)) {
|
||||
throw new ImapException('Unsupported transfer operation: ' . $this->operation);
|
||||
}
|
||||
|
||||
$this->sequenceSet = $resolvedTarget->sequenceSet();
|
||||
$this->identifierMode = $resolvedTarget->identifierMode();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->operation;
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%s%s %s %s',
|
||||
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
|
||||
$this->operation,
|
||||
$this->sequenceSet->toCommand(),
|
||||
$this->quote($this->destinationMailbox),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException($this->operation . ' requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$responseCodes = [];
|
||||
$copyUid = null;
|
||||
$tryCreate = false;
|
||||
$highestModSeq = null;
|
||||
$expunged = [];
|
||||
$vanished = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse) {
|
||||
$this->collectUntaggedData(
|
||||
$response,
|
||||
$responseCodes,
|
||||
$copyUid,
|
||||
$tryCreate,
|
||||
$highestModSeq,
|
||||
$expunged,
|
||||
$vanished,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
$this->collectResponseCode(
|
||||
'tagged',
|
||||
$response->text(),
|
||||
$responseCodes,
|
||||
$copyUid,
|
||||
$tryCreate,
|
||||
$highestModSeq,
|
||||
);
|
||||
|
||||
$result = new MessageTransferResult(
|
||||
$response->status(),
|
||||
$response->text(),
|
||||
$responseCodes,
|
||||
$copyUid,
|
||||
$tryCreate,
|
||||
$highestModSeq,
|
||||
$expunged,
|
||||
$vanished,
|
||||
);
|
||||
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException($this->operation . ' failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException($this->operation . ' did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
|
||||
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
|
||||
* @param list<int> $expunged
|
||||
* @param list<array{earlier:bool, knownUids:string}> $vanished
|
||||
*/
|
||||
private function collectUntaggedData(
|
||||
UntaggedResponse $response,
|
||||
array &$responseCodes,
|
||||
?array &$copyUid,
|
||||
bool &$tryCreate,
|
||||
?string &$highestModSeq,
|
||||
array &$expunged,
|
||||
array &$vanished,
|
||||
): void {
|
||||
$label = strtoupper($response->label());
|
||||
|
||||
if (in_array($label, ['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'], true)) {
|
||||
$this->collectResponseCode(
|
||||
'untagged',
|
||||
$response->payload(),
|
||||
$responseCodes,
|
||||
$copyUid,
|
||||
$tryCreate,
|
||||
$highestModSeq,
|
||||
);
|
||||
}
|
||||
|
||||
if (preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) {
|
||||
$expunged[] = (int) $matches[1];
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('/^\*\s+VANISHED(?:\s+\((EARLIER)\))?\s+(.+)$/i', $response->raw(), $matches) === 1) {
|
||||
$vanished[] = [
|
||||
'earlier' => isset($matches[1]) && strtoupper($matches[1]) === 'EARLIER',
|
||||
'knownUids' => trim($matches[2]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
|
||||
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
|
||||
*/
|
||||
private function collectResponseCode(
|
||||
string $source,
|
||||
string $text,
|
||||
array &$responseCodes,
|
||||
?array &$copyUid,
|
||||
bool &$tryCreate,
|
||||
?string &$highestModSeq,
|
||||
): void {
|
||||
$responseCode = $this->parseResponseCode($text);
|
||||
if ($responseCode === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$responseCodes[] = [
|
||||
'source' => $source,
|
||||
'name' => $responseCode['name'],
|
||||
'arguments' => $responseCode['arguments'],
|
||||
'text' => $responseCode['text'],
|
||||
];
|
||||
|
||||
if ($responseCode['name'] === 'TRYCREATE') {
|
||||
$tryCreate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($responseCode['name'] === 'HIGHESTMODSEQ' && isset($responseCode['arguments'][0])) {
|
||||
$highestModSeq = $responseCode['arguments'][0];
|
||||
return;
|
||||
}
|
||||
|
||||
if ($responseCode['name'] !== 'COPYUID' || count($responseCode['arguments']) < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
$copyUid = [
|
||||
'uidValidity' => $responseCode['arguments'][0],
|
||||
'sourceUids' => $responseCode['arguments'][1],
|
||||
'destinationUids' => $responseCode['arguments'][2],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array{name:string, arguments:list<string>, text:string}
|
||||
*/
|
||||
private function parseResponseCode(string $text): ?array
|
||||
{
|
||||
$text = trim($text);
|
||||
|
||||
if (preg_match('/^\[([A-Z0-9.-]+)(?:\s+([^\]]+))?\](?:\s*(.*))?$/i', $text, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = trim($matches[2] ?? '');
|
||||
|
||||
return [
|
||||
'name' => strtoupper($matches[1]),
|
||||
'arguments' => $arguments === '' ? [] : (preg_split('/\s+/', $arguments) ?: []),
|
||||
'text' => trim($matches[3] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
47
lib/Client/Command/MoveCommand.php
Normal file
47
lib/Client/Command/MoveCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<MessageTransferResult>
|
||||
*/
|
||||
final class MoveCommand implements CommandInterface
|
||||
{
|
||||
private readonly MessageTransferCommand $command;
|
||||
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
string $destinationMailbox = '',
|
||||
) {
|
||||
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->command->name();
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return $this->command->allowedStates();
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
return $this->command->encode($tag, $context);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
|
||||
{
|
||||
return $this->command->handle($responses, $context);
|
||||
}
|
||||
}
|
||||
57
lib/Client/Command/NoopCommand.php
Normal file
57
lib/Client/Command/NoopCommand.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class NoopCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'NOOP';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('NOOP');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('NOOP failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('NOOP did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
72
lib/Client/Command/RenameCommand.php
Normal file
72
lib/Client/Command/RenameCommand.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class RenameCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $fromMailbox,
|
||||
private readonly string $toMailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'RENAME';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'RENAME %s %s',
|
||||
$this->quote($this->fromMailbox),
|
||||
$this->quote($this->toMailbox),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('RENAME failed: ' . $response->text());
|
||||
}
|
||||
|
||||
if ($context->selectedMailbox() === $this->fromMailbox) {
|
||||
$context->setSelectedMailbox($this->toMailbox);
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('RENAME did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
28
lib/Client/Command/Result/CapabilityResult.php
Normal file
28
lib/Client/Command/Result/CapabilityResult.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class CapabilityResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $capabilities
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $capabilities,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function capabilities(): array
|
||||
{
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
public function has(string $capability): bool
|
||||
{
|
||||
return in_array(strtoupper($capability), $this->capabilities, true);
|
||||
}
|
||||
}
|
||||
28
lib/Client/Command/Result/CommandStatusResult.php
Normal file
28
lib/Client/Command/Result/CommandStatusResult.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class CommandStatusResult
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $status,
|
||||
private readonly string $text,
|
||||
) {}
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isOk(): bool
|
||||
{
|
||||
return $this->status === 'OK';
|
||||
}
|
||||
}
|
||||
152
lib/Client/Command/Result/MessageTransferResult.php
Normal file
152
lib/Client/Command/Result/MessageTransferResult.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class MessageTransferResult
|
||||
{
|
||||
/**
|
||||
* @param list<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
|
||||
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
|
||||
* @param list<int> $expunged
|
||||
* @param list<array{earlier:bool, knownUids:string}> $vanished
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $status,
|
||||
private readonly string $text,
|
||||
private readonly array $responseCodes = [],
|
||||
private readonly ?array $copyUid = null,
|
||||
private readonly bool $tryCreate = false,
|
||||
private readonly ?string $highestModSeq = null,
|
||||
private readonly array $expunged = [],
|
||||
private readonly array $vanished = [],
|
||||
) {}
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isOk(): bool
|
||||
{
|
||||
return $this->status === 'OK';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
|
||||
*/
|
||||
public function responseCodes(): array
|
||||
{
|
||||
return $this->responseCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array{uidValidity:string, sourceUids:string, destinationUids:string}
|
||||
*/
|
||||
public function copyUid(): ?array
|
||||
{
|
||||
return $this->copyUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function copyUidMap(): array
|
||||
{
|
||||
if ($this->copyUid === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceUids = $this->expandUidSet($this->copyUid['sourceUids']);
|
||||
$destinationUids = $this->expandUidSet($this->copyUid['destinationUids']);
|
||||
|
||||
if (count($sourceUids) !== count($destinationUids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
foreach ($sourceUids as $index => $sourceUid) {
|
||||
$mapping[$sourceUid] = $destinationUids[$index];
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
public function tryCreate(): bool
|
||||
{
|
||||
return $this->tryCreate;
|
||||
}
|
||||
|
||||
public function highestModSeq(): ?string
|
||||
{
|
||||
return $this->highestModSeq;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function expunged(): array
|
||||
{
|
||||
return $this->expunged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{earlier:bool, knownUids:string}>
|
||||
*/
|
||||
public function vanished(): array
|
||||
{
|
||||
return $this->vanished;
|
||||
}
|
||||
|
||||
public function hasResponseCode(string $name): bool
|
||||
{
|
||||
$name = strtoupper(trim($name));
|
||||
|
||||
foreach ($this->responseCodes as $responseCode) {
|
||||
if ($responseCode['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandUidSet(string $value): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach (explode(',', $value) as $segment) {
|
||||
$segment = trim($segment);
|
||||
if ($segment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_contains($segment, ':')) {
|
||||
$expanded[] = $segment;
|
||||
continue;
|
||||
}
|
||||
|
||||
[$start, $end] = array_map('trim', explode(':', $segment, 2));
|
||||
|
||||
if (!ctype_digit($start) || !ctype_digit($end)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$range = range((int) $start, (int) $end, (int) $start <= (int) $end ? 1 : -1);
|
||||
foreach ($range as $uid) {
|
||||
$expanded[] = (string) $uid;
|
||||
}
|
||||
}
|
||||
|
||||
return $expanded;
|
||||
}
|
||||
}
|
||||
36
lib/Client/Command/Result/SearchResult.php
Normal file
36
lib/Client/Command/Result/SearchResult.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
|
||||
final class SearchResult
|
||||
{
|
||||
/**
|
||||
* @param list<int> $matches
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $matches,
|
||||
private readonly IdentifierMode $identifierMode,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function matches(): array
|
||||
{
|
||||
return $this->matches;
|
||||
}
|
||||
|
||||
public function identifierMode(): IdentifierMode
|
||||
{
|
||||
return $this->identifierMode;
|
||||
}
|
||||
|
||||
public function isUidSearch(): bool
|
||||
{
|
||||
return $this->identifierMode === IdentifierMode::Uid;
|
||||
}
|
||||
}
|
||||
36
lib/Client/Command/Result/SortResult.php
Normal file
36
lib/Client/Command/Result/SortResult.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
|
||||
final class SortResult
|
||||
{
|
||||
/**
|
||||
* @param list<int> $matches
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $matches,
|
||||
private readonly IdentifierMode $identifierMode,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function matches(): array
|
||||
{
|
||||
return $this->matches;
|
||||
}
|
||||
|
||||
public function identifierMode(): IdentifierMode
|
||||
{
|
||||
return $this->identifierMode;
|
||||
}
|
||||
|
||||
public function isUidSort(): bool
|
||||
{
|
||||
return $this->identifierMode === IdentifierMode::Uid;
|
||||
}
|
||||
}
|
||||
54
lib/Client/Command/Result/StatusResult.php
Normal file
54
lib/Client/Command/Result/StatusResult.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class StatusResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly array $items,
|
||||
) {}
|
||||
|
||||
public function mailbox(): string
|
||||
{
|
||||
return $this->mailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function items(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function value(string $item, int $default = 0): int
|
||||
{
|
||||
return $this->items[strtoupper(trim($item))] ?? $default;
|
||||
}
|
||||
|
||||
public function messages(): int
|
||||
{
|
||||
return $this->value('MESSAGES');
|
||||
}
|
||||
|
||||
public function unseen(): int
|
||||
{
|
||||
return $this->value('UNSEEN');
|
||||
}
|
||||
|
||||
public function read(): int
|
||||
{
|
||||
return max(0, $this->messages() - $this->unseen());
|
||||
}
|
||||
|
||||
public function state(): int
|
||||
{
|
||||
return $this->value('UIDVALIDITY');
|
||||
}
|
||||
}
|
||||
126
lib/Client/Command/SearchCommand.php
Normal file
126
lib/Client/Command/SearchCommand.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\SearchResult;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<SearchResult>
|
||||
*/
|
||||
final class SearchCommand implements CommandInterface
|
||||
{
|
||||
/**
|
||||
* @param SearchCriteriaBuilder|list<string> $criteria
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
|
||||
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
|
||||
private readonly ?string $charset = 'UTF-8',
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'SEARCH';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$criteria = $this->normalizeCriteria(
|
||||
$this->criteria instanceof SearchCriteriaBuilder
|
||||
? $this->criteria->toArray()
|
||||
: $this->criteria,
|
||||
);
|
||||
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SEARCH' : 'SEARCH';
|
||||
|
||||
if ($this->charset !== null && $this->charset !== '') {
|
||||
$command .= ' CHARSET ' . strtoupper(trim($this->charset));
|
||||
}
|
||||
|
||||
$command .= ' ' . implode(' ', $criteria);
|
||||
|
||||
return new RequestFrame($command);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): SearchResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('SEARCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'SEARCH') {
|
||||
$matches = $this->parseMatches($response->payloadTokens());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('SEARCH failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new SearchResult($matches, $this->identifierMode);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('SEARCH did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = trim($criterion);
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized === [] ? ['ALL'] : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseMatches(array $tokens): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = (int) $token;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
124
lib/Client/Command/SelectCommand.php
Normal file
124
lib/Client/Command/SelectCommand.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Mailbox>
|
||||
*/
|
||||
final class SelectCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly bool $readOnly = true,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->readOnly ? 'EXAMINE' : 'SELECT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%s %s',
|
||||
$this->name(),
|
||||
$this->quote($this->mailbox),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Mailbox
|
||||
{
|
||||
$exists = 0;
|
||||
$recent = 0;
|
||||
$flags = [];
|
||||
$readOnly = $this->readOnly;
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse) {
|
||||
$raw = $response->raw();
|
||||
|
||||
if (preg_match('/^\*\s+(\d+)\s+EXISTS$/i', $raw, $matches)) {
|
||||
$exists = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\*\s+(\d+)\s+RECENT$/i', $raw, $matches)) {
|
||||
$recent = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) {
|
||||
$flags = $this->parseFlags($matches[1]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException($this->name() . ' failed: ' . $response->text());
|
||||
}
|
||||
|
||||
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
|
||||
$readOnly = true;
|
||||
}
|
||||
|
||||
$context->setSelectedMailbox($this->mailbox);
|
||||
$context->setState(SessionState::Selected);
|
||||
|
||||
return new Mailbox(
|
||||
$this->mailbox,
|
||||
null,
|
||||
[],
|
||||
$exists,
|
||||
0,
|
||||
null,
|
||||
$recent,
|
||||
$flags,
|
||||
$readOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException($this->name() . ' did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseFlags(string $flags): array
|
||||
{
|
||||
$flags = trim($flags);
|
||||
|
||||
if ($flags === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return preg_split('/\s+/', $flags) ?: [];
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
154
lib/Client/Command/SortCommand.php
Normal file
154
lib/Client/Command/SortCommand.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\SortResult;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<SortResult>
|
||||
*/
|
||||
final class SortCommand implements CommandInterface
|
||||
{
|
||||
/**
|
||||
* @param SearchCriteriaBuilder|list<string> $criteria
|
||||
* @param list<string> $sortCriteria
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $sortCriteria,
|
||||
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
|
||||
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
|
||||
private readonly string $charset = 'UTF-8',
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'SORT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$sortCriteria = $this->normalizeSortCriteria($this->sortCriteria);
|
||||
if ($sortCriteria === []) {
|
||||
throw new ImapException('SORT requires at least one sort criterion.');
|
||||
}
|
||||
|
||||
$criteria = $this->normalizeCriteria(
|
||||
$this->criteria instanceof SearchCriteriaBuilder
|
||||
? $this->criteria->toArray()
|
||||
: $this->criteria,
|
||||
);
|
||||
|
||||
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SORT' : 'SORT';
|
||||
$command .= sprintf(
|
||||
' (%s) %s %s',
|
||||
implode(' ', $sortCriteria),
|
||||
strtoupper(trim($this->charset)),
|
||||
implode(' ', $criteria),
|
||||
);
|
||||
|
||||
return new RequestFrame($command);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): SortResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('SORT requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'SORT') {
|
||||
$matches = $this->parseMatches($response->payloadTokens());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('SORT failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new SortResult($matches, $this->identifierMode);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('SORT did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = trim($criterion);
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized === [] ? ['ALL'] : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeSortCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = strtoupper(trim($criterion));
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseMatches(array $tokens): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = (int) $token;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
54
lib/Client/Command/StartTlsCommand.php
Normal file
54
lib/Client/Command/StartTlsCommand.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class StartTlsCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'STARTTLS';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::NotAuthenticated];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('STARTTLS');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STARTTLS failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->connection()->upgradeToTls();
|
||||
$context->replaceCapabilities();
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STARTTLS did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
118
lib/Client/Command/StatusCommand.php
Normal file
118
lib/Client/Command/StatusCommand.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<StatusResult>
|
||||
*/
|
||||
final class StatusCommand implements CommandInterface
|
||||
{
|
||||
private readonly StatusResponseParser $statusResponseParser;
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly array $items = ['MESSAGES', 'UNSEEN'],
|
||||
?StatusResponseParser $statusResponseParser = null,
|
||||
) {
|
||||
$this->statusResponseParser = $statusResponseParser ?? new StatusResponseParser();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'STATUS';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'STATUS %s (%s)',
|
||||
$this->quote($this->mailbox),
|
||||
implode(' ', $this->normalizeItems($this->items)),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): StatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
$items = [];
|
||||
$mailbox = $this->mailbox;
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
|
||||
[$mailbox, $items] = $this->statusResponseParser->parse($response->payload());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STATUS failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new StatusResult($mailbox, $items);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STATUS did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim($item));
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Z0-9.-]+$/', $item)) {
|
||||
throw new ImapException('Invalid STATUS item: ' . $item);
|
||||
}
|
||||
|
||||
if (in_array($item, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $item;
|
||||
}
|
||||
|
||||
if ($normalized === []) {
|
||||
throw new ImapException('STATUS requires at least one data item.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
139
lib/Client/Command/StatusResponseParser.php
Normal file
139
lib/Client/Command/StatusResponseParser.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
|
||||
final class StatusResponseParser
|
||||
{
|
||||
/**
|
||||
* @return array{0: string, 1: array<string, int>}
|
||||
*/
|
||||
public function parse(string $payload): array
|
||||
{
|
||||
$payload = trim($payload);
|
||||
$offset = 0;
|
||||
|
||||
$nameToken = $this->readToken($payload, $offset);
|
||||
$statusToken = $this->readToken($payload, $offset);
|
||||
|
||||
if ($nameToken === null || $statusToken === null) {
|
||||
throw new ImapException('Unable to parse STATUS response payload: ' . $payload);
|
||||
}
|
||||
|
||||
$mailbox = $this->decodeAtom($nameToken);
|
||||
if ($mailbox === null || $mailbox === '') {
|
||||
throw new ImapException('STATUS response is missing a mailbox name: ' . $payload);
|
||||
}
|
||||
|
||||
return [$mailbox, $this->parseItems($statusToken, $payload)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function parseItems(string $statusToken, string $payload): array
|
||||
{
|
||||
$statusToken = trim($statusToken);
|
||||
|
||||
if (!str_starts_with($statusToken, '(') || !str_ends_with($statusToken, ')')) {
|
||||
throw new ImapException('Invalid STATUS data payload: ' . $payload);
|
||||
}
|
||||
|
||||
$items = trim(substr($statusToken, 1, -1));
|
||||
if ($items === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $items) ?: [];
|
||||
if (count($tokens) % 2 !== 0) {
|
||||
throw new ImapException('Malformed STATUS item list: ' . $payload);
|
||||
}
|
||||
|
||||
$status = [];
|
||||
|
||||
for ($index = 0; $index < count($tokens); $index += 2) {
|
||||
$item = strtoupper($tokens[$index]);
|
||||
$value = $tokens[$index + 1];
|
||||
|
||||
if (!preg_match('/^\d+$/', $value)) {
|
||||
throw new ImapException('STATUS item value must be numeric: ' . $payload);
|
||||
}
|
||||
|
||||
$status[$item] = (int) $value;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function readToken(string $payload, int &$offset): ?string
|
||||
{
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length && ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
if ($offset >= $length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '(') {
|
||||
$end = strpos($payload, ')', $offset);
|
||||
|
||||
if ($end === false) {
|
||||
throw new ImapException('Unterminated STATUS item block: ' . $payload);
|
||||
}
|
||||
|
||||
$token = substr($payload, $offset, $end - $offset + 1);
|
||||
$offset = $end + 1;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '"') {
|
||||
$start = $offset;
|
||||
$offset++;
|
||||
|
||||
while ($offset < $length) {
|
||||
if ($payload[$offset] === '\\') {
|
||||
$offset += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($payload[$offset] === '"') {
|
||||
$offset++;
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated quoted STATUS token: ' . $payload);
|
||||
}
|
||||
|
||||
$start = $offset;
|
||||
while ($offset < $length && !ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
|
||||
private function decodeAtom(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (strtoupper($value) === 'NIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
|
||||
return stripcslashes(substr($value, 1, -1));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
112
lib/Client/Command/StoreCommand.php
Normal file
112
lib/Client/Command/StoreCommand.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class StoreCommand implements CommandInterface
|
||||
{
|
||||
private readonly SequenceSet $sequenceSet;
|
||||
private readonly IdentifierMode $identifierMode;
|
||||
|
||||
/**
|
||||
* @param list<string> $flags
|
||||
*/
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
private readonly array $flags = [],
|
||||
private readonly string $action = '',
|
||||
private readonly bool $silent = true,
|
||||
) {
|
||||
$resolvedTarget = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => FetchTarget::all(),
|
||||
};
|
||||
|
||||
$normalizedAction = trim($this->action);
|
||||
if (!in_array($normalizedAction, ['', '+', '-'], true)) {
|
||||
throw new ImapException('STORE action must be one of "", "+", or "-".');
|
||||
}
|
||||
|
||||
$normalizedFlags = array_values(array_filter(array_map(
|
||||
static fn (string $flag): string => trim($flag),
|
||||
$this->flags,
|
||||
), static fn (string $flag): bool => $flag !== ''));
|
||||
|
||||
if ($normalizedFlags === []) {
|
||||
throw new ImapException('STORE requires at least one flag.');
|
||||
}
|
||||
|
||||
$this->flags = $normalizedFlags;
|
||||
$this->action = $normalizedAction;
|
||||
$this->sequenceSet = $resolvedTarget->sequenceSet();
|
||||
$this->identifierMode = $resolvedTarget->identifierMode();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'STORE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%sSTORE %s %s (%s)',
|
||||
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
|
||||
$this->sequenceSet->toCommand(),
|
||||
$this->itemName(),
|
||||
implode(' ', $this->flags),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('STORE requires a selected mailbox.');
|
||||
}
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STORE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STORE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function itemName(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%sFLAGS%s',
|
||||
$this->action,
|
||||
$this->silent ? '.SILENT' : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP;
|
||||
|
||||
final readonly class Configuration
|
||||
{
|
||||
public function __construct(
|
||||
public string $transport,
|
||||
public string $host,
|
||||
public int $port = 993,
|
||||
public int $timeout = 60,
|
||||
public bool $verifyPeer = true,
|
||||
public bool $verifyPeerName = true,
|
||||
public bool $allowSelfSigned = false,
|
||||
public bool $useUid = true,
|
||||
) {
|
||||
}
|
||||
}
|
||||
88
lib/Client/ConnectionConfig.php
Normal file
88
lib/Client/ConnectionConfig.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class ConnectionConfig
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $host,
|
||||
private readonly int $port = 993,
|
||||
private readonly ConnectionSecurity $security = ConnectionSecurity::Tls,
|
||||
private readonly ?string $username = null,
|
||||
private readonly ?string $password = null,
|
||||
private readonly float $timeout = 30.0,
|
||||
private readonly bool $verifyPeer = true,
|
||||
private readonly bool $verifyPeerName = true,
|
||||
private readonly bool $allowSelfSigned = false,
|
||||
) {}
|
||||
|
||||
public function host(): string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function port(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function security(): ConnectionSecurity
|
||||
{
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
public function username(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function password(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function hasCredentials(): bool
|
||||
{
|
||||
return $this->username !== null && $this->password !== null;
|
||||
}
|
||||
|
||||
public function timeout(): float
|
||||
{
|
||||
return $this->timeout;
|
||||
}
|
||||
|
||||
public function verifyPeer(): bool
|
||||
{
|
||||
return $this->verifyPeer;
|
||||
}
|
||||
|
||||
public function verifyPeerName(): bool
|
||||
{
|
||||
return $this->verifyPeerName;
|
||||
}
|
||||
|
||||
public function allowSelfSigned(): bool
|
||||
{
|
||||
return $this->allowSelfSigned;
|
||||
}
|
||||
|
||||
public function endpoint(): string
|
||||
{
|
||||
return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port);
|
||||
}
|
||||
|
||||
public function streamContextOptions(): array
|
||||
{
|
||||
return [
|
||||
'ssl' => [
|
||||
'verify_peer' => $this->verifyPeer,
|
||||
'verify_peer_name' => $this->verifyPeerName,
|
||||
'allow_self_signed' => $this->allowSelfSigned,
|
||||
'SNI_enabled' => true,
|
||||
'peer_name' => $this->host,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
17
lib/Client/ConnectionSecurity.php
Normal file
17
lib/Client/ConnectionSecurity.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
enum ConnectionSecurity: string
|
||||
{
|
||||
case Plain = 'plain';
|
||||
case Tls = 'tls';
|
||||
case StartTls = 'starttls';
|
||||
|
||||
public function transport(): string
|
||||
{
|
||||
return $this === self::Tls ? 'ssl' : 'tcp';
|
||||
}
|
||||
}
|
||||
152
lib/Client/FetchOptions.php
Normal file
152
lib/Client/FetchOptions.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class FetchOptions
|
||||
{
|
||||
/**
|
||||
* @param list<string> $items
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly array $items,
|
||||
) {}
|
||||
|
||||
public static function default(): self
|
||||
{
|
||||
return self::message();
|
||||
}
|
||||
|
||||
public static function summary(): self
|
||||
{
|
||||
return new self([
|
||||
'UID',
|
||||
'FLAGS',
|
||||
'INTERNALDATE',
|
||||
'RFC822.SIZE',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function message(): self
|
||||
{
|
||||
return self::summary()
|
||||
->withEnvelope()
|
||||
->withBodyStructure();
|
||||
}
|
||||
|
||||
public static function fullMessage(): self
|
||||
{
|
||||
return self::message()->withBodyText();
|
||||
}
|
||||
|
||||
public function withBodySection(string $section): self
|
||||
{
|
||||
$section = strtoupper(trim($section));
|
||||
|
||||
if ($section === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->with(sprintf('BODY[%s]', $section));
|
||||
}
|
||||
|
||||
public static function of(string ...$items): self
|
||||
{
|
||||
return new self(self::normalize($items));
|
||||
}
|
||||
|
||||
public function withUid(): self
|
||||
{
|
||||
return $this->with('UID');
|
||||
}
|
||||
|
||||
public function withFlags(): self
|
||||
{
|
||||
return $this->with('FLAGS');
|
||||
}
|
||||
|
||||
public function withInternalDate(): self
|
||||
{
|
||||
return $this->with('INTERNALDATE');
|
||||
}
|
||||
|
||||
public function withSize(): self
|
||||
{
|
||||
return $this->with('RFC822.SIZE');
|
||||
}
|
||||
|
||||
public function withEnvelope(): self
|
||||
{
|
||||
return $this->with('ENVELOPE');
|
||||
}
|
||||
|
||||
public function withBodyStructure(): self
|
||||
{
|
||||
return $this->with('BODYSTRUCTURE');
|
||||
}
|
||||
|
||||
public function withBodyText(): self
|
||||
{
|
||||
return $this->withBodySection('TEXT');
|
||||
}
|
||||
|
||||
public function withHeaderFields(string ...$fields): self
|
||||
{
|
||||
$fields = array_values(array_filter(array_map(
|
||||
static fn (string $field): string => strtoupper(trim($field)),
|
||||
$fields,
|
||||
), static fn (string $field): bool => $field !== ''));
|
||||
|
||||
if ($fields === []) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields)));
|
||||
}
|
||||
|
||||
public function with(string $item): self
|
||||
{
|
||||
return new self(self::normalize([
|
||||
...$this->items,
|
||||
$item,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function toCommand(): string
|
||||
{
|
||||
return implode(' ', $this->items);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalize(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item = trim($item);
|
||||
if ($item === '' || in_array($item, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $item;
|
||||
}
|
||||
|
||||
if (!in_array('UID', $normalized, true)) {
|
||||
array_unshift($normalized, 'UID');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
52
lib/Client/FetchTarget.php
Normal file
52
lib/Client/FetchTarget.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class FetchTarget
|
||||
{
|
||||
private function __construct(
|
||||
private readonly SequenceSet $sequenceSet,
|
||||
private readonly IdentifierMode $identifierMode,
|
||||
) {}
|
||||
|
||||
public static function all(IdentifierMode $identifierMode = IdentifierMode::Sequence): self
|
||||
{
|
||||
return new self(SequenceSet::all(), $identifierMode);
|
||||
}
|
||||
|
||||
public static function sequence(int|string|SequenceSet $target): self
|
||||
{
|
||||
return new self(self::coerceSequenceSet($target), IdentifierMode::Sequence);
|
||||
}
|
||||
|
||||
public static function uid(int|string|SequenceSet $target): self
|
||||
{
|
||||
return new self(self::coerceSequenceSet($target), IdentifierMode::Uid);
|
||||
}
|
||||
|
||||
public function sequenceSet(): SequenceSet
|
||||
{
|
||||
return $this->sequenceSet;
|
||||
}
|
||||
|
||||
public function identifierMode(): IdentifierMode
|
||||
{
|
||||
return $this->identifierMode;
|
||||
}
|
||||
|
||||
public function toCommand(): string
|
||||
{
|
||||
return $this->identifierMode->toCommand();
|
||||
}
|
||||
|
||||
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
|
||||
{
|
||||
return match (true) {
|
||||
$target instanceof SequenceSet => $target,
|
||||
is_int($target) => SequenceSet::single($target),
|
||||
default => SequenceSet::parse($target),
|
||||
};
|
||||
}
|
||||
}
|
||||
16
lib/Client/IdentifierMode.php
Normal file
16
lib/Client/IdentifierMode.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
enum IdentifierMode
|
||||
{
|
||||
case Sequence;
|
||||
case Uid;
|
||||
|
||||
public function toCommand(): string
|
||||
{
|
||||
return $this === self::Uid ? 'UID FETCH' : 'FETCH';
|
||||
}
|
||||
}
|
||||
11
lib/Client/ImapException.php
Normal file
11
lib/Client/ImapException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ImapException extends RuntimeException
|
||||
{
|
||||
}
|
||||
180
lib/Client/ListReturnOptions.php
Normal file
180
lib/Client/ListReturnOptions.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class ListReturnOptions
|
||||
{
|
||||
private const SUBSCRIBED = 'SUBSCRIBED';
|
||||
private const CHILDREN = 'CHILDREN';
|
||||
private const SPECIAL_USE = 'SPECIAL-USE';
|
||||
|
||||
/**
|
||||
* @param list<string> $options
|
||||
* @param list<string>|null $statusItems
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly array $options,
|
||||
private readonly ?array $statusItems = null,
|
||||
) {}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
public static function of(string ...$options): self
|
||||
{
|
||||
return new self(self::normalize($options));
|
||||
}
|
||||
|
||||
public static function subscribed(): self
|
||||
{
|
||||
return self::none()->withSubscribed();
|
||||
}
|
||||
|
||||
public static function children(): self
|
||||
{
|
||||
return self::none()->withChildren();
|
||||
}
|
||||
|
||||
public static function specialUse(): self
|
||||
{
|
||||
return self::none()->withSpecialUse();
|
||||
}
|
||||
|
||||
public static function status(string ...$items): self
|
||||
{
|
||||
return self::none()->withStatus(...$items);
|
||||
}
|
||||
|
||||
public function withSubscribed(): self
|
||||
{
|
||||
return $this->with(self::SUBSCRIBED);
|
||||
}
|
||||
|
||||
public function withChildren(): self
|
||||
{
|
||||
return $this->with(self::CHILDREN);
|
||||
}
|
||||
|
||||
public function withSpecialUse(): self
|
||||
{
|
||||
return $this->with(self::SPECIAL_USE);
|
||||
}
|
||||
|
||||
public function withStatus(string ...$items): self
|
||||
{
|
||||
return new self($this->options, self::normalizeStatusItems($items));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$options = $this->options;
|
||||
|
||||
if ($this->statusItems !== null) {
|
||||
$options[] = sprintf('STATUS (%s)', implode(' ', $this->statusItems));
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function toCommand(): ?string
|
||||
{
|
||||
$options = $this->toArray();
|
||||
if ($options === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '(' . implode(' ', $options) . ')';
|
||||
}
|
||||
|
||||
public function hasStatus(): bool
|
||||
{
|
||||
return $this->statusItems !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function statusItems(): array
|
||||
{
|
||||
return $this->statusItems ?? [];
|
||||
}
|
||||
|
||||
private function with(string $option): self
|
||||
{
|
||||
return new self(self::normalize([
|
||||
...$this->options,
|
||||
$option,
|
||||
]), $this->statusItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $options
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalize(array $options): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($options as $option) {
|
||||
$option = strtoupper(trim($option));
|
||||
if ($option === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($option, [
|
||||
self::SUBSCRIBED,
|
||||
self::CHILDREN,
|
||||
self::SPECIAL_USE,
|
||||
], true)) {
|
||||
throw new ImapException('Unsupported LIST return option: ' . $option);
|
||||
}
|
||||
|
||||
if (in_array($option, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $option;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeStatusItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim($item));
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Z0-9.-]+$/', $item)) {
|
||||
throw new ImapException('Invalid LIST STATUS data item: ' . $item);
|
||||
}
|
||||
|
||||
if (in_array($item, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $item;
|
||||
}
|
||||
|
||||
if ($normalized === []) {
|
||||
throw new ImapException('LIST STATUS return option requires at least one STATUS data item.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
128
lib/Client/ListSelectionOptions.php
Normal file
128
lib/Client/ListSelectionOptions.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class ListSelectionOptions
|
||||
{
|
||||
private const SUBSCRIBED = 'SUBSCRIBED';
|
||||
private const REMOTE = 'REMOTE';
|
||||
private const RECURSIVEMATCH = 'RECURSIVEMATCH';
|
||||
private const SPECIAL_USE = 'SPECIAL-USE';
|
||||
|
||||
/**
|
||||
* @param list<string> $options
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly array $options,
|
||||
) {}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
public static function of(string ...$options): self
|
||||
{
|
||||
return new self(self::normalize($options));
|
||||
}
|
||||
|
||||
public static function subscribed(): self
|
||||
{
|
||||
return self::none()->withSubscribed();
|
||||
}
|
||||
|
||||
public static function remote(): self
|
||||
{
|
||||
return self::none()->withRemote();
|
||||
}
|
||||
|
||||
public static function specialUse(): self
|
||||
{
|
||||
return self::none()->withSpecialUse();
|
||||
}
|
||||
|
||||
public function withSubscribed(): self
|
||||
{
|
||||
return $this->with(self::SUBSCRIBED);
|
||||
}
|
||||
|
||||
public function withRemote(): self
|
||||
{
|
||||
return $this->with(self::REMOTE);
|
||||
}
|
||||
|
||||
public function withRecursiveMatch(): self
|
||||
{
|
||||
return $this->with(self::RECURSIVEMATCH);
|
||||
}
|
||||
|
||||
public function withSpecialUse(): self
|
||||
{
|
||||
return $this->with(self::SPECIAL_USE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
public function toCommand(): ?string
|
||||
{
|
||||
if ($this->options === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '(' . implode(' ', $this->options) . ')';
|
||||
}
|
||||
|
||||
private function with(string $option): self
|
||||
{
|
||||
return new self(self::normalize([
|
||||
...$this->options,
|
||||
$option,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $options
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalize(array $options): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($options as $option) {
|
||||
$option = strtoupper(trim($option));
|
||||
if ($option === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!in_array($option, [
|
||||
self::SUBSCRIBED,
|
||||
self::REMOTE,
|
||||
self::RECURSIVEMATCH,
|
||||
self::SPECIAL_USE,
|
||||
], true)) {
|
||||
throw new ImapException('Unsupported LIST selection option: ' . $option);
|
||||
}
|
||||
|
||||
if (in_array($option, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $option;
|
||||
}
|
||||
|
||||
if (in_array(self::RECURSIVEMATCH, $normalized, true)
|
||||
&& !in_array(self::SUBSCRIBED, $normalized, true)) {
|
||||
throw new ImapException('RECURSIVEMATCH requires SUBSCRIBED in LIST selection options.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,101 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP;
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
class Mailbox
|
||||
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
|
||||
|
||||
final class Mailbox
|
||||
{
|
||||
private const ATTRIBUTE_NOSELECT = '\Noselect';
|
||||
|
||||
public array $flags = [];
|
||||
public int $exists = 0;
|
||||
public int $recent = 0;
|
||||
public ?int $unseen = null;
|
||||
public ?int $uidValidity = null;
|
||||
public ?int $uidNext = null;
|
||||
public array $permanentFlags = [];
|
||||
|
||||
/**
|
||||
* @param list<string> $nameAttributes
|
||||
* @param list<string> $attributes
|
||||
* @param list<string> $flags
|
||||
*/
|
||||
public function __construct(
|
||||
public array $nameAttributes,
|
||||
public string $hierarchyDelimiter,
|
||||
public string $name,
|
||||
) {
|
||||
private readonly string $name,
|
||||
private readonly ?string $delimiter,
|
||||
private readonly array $attributes,
|
||||
private readonly int $messages = 0,
|
||||
private readonly int $unread = 0,
|
||||
private readonly ?int $state = null,
|
||||
private readonly int $recent = 0,
|
||||
private readonly array $flags = [],
|
||||
private readonly bool $readOnly = true,
|
||||
) {}
|
||||
|
||||
public function fromStatus(StatusResult $status): self
|
||||
{
|
||||
return new self(
|
||||
$this->name,
|
||||
$this->delimiter,
|
||||
$this->attributes,
|
||||
$status->messages() ?? $this->messages,
|
||||
$status->unseen() ?? $this->unread,
|
||||
$status->state() ?? $this->state,
|
||||
$this->recent,
|
||||
$this->flags,
|
||||
$this->readOnly,
|
||||
);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function delimiter(): ?string
|
||||
{
|
||||
return $this->delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
public function state(): ?int
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function messages(): int
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
public function unread(): int
|
||||
{
|
||||
return $this->unread;
|
||||
}
|
||||
|
||||
public function read(): int
|
||||
{
|
||||
return max(0, $this->messages - $this->unread);
|
||||
}
|
||||
|
||||
public function recent(): int
|
||||
{
|
||||
return $this->recent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function flags(): array
|
||||
{
|
||||
return $this->flags;
|
||||
}
|
||||
|
||||
public function readOnly(): bool
|
||||
{
|
||||
return $this->readOnly;
|
||||
}
|
||||
|
||||
public function isSelectable(): bool
|
||||
{
|
||||
return !in_array(self::ATTRIBUTE_NOSELECT, $this->nameAttributes);
|
||||
return !in_array('\\NOSELECT', $this->attributes, true);
|
||||
}
|
||||
}
|
||||
178
lib/Client/Message.php
Normal file
178
lib/Client/Message.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class Message
|
||||
{
|
||||
/**
|
||||
* @param list<string> $flags
|
||||
* @param list<MessageAddress> $from
|
||||
* @param list<MessageAddress> $sender
|
||||
* @param list<MessageAddress> $replyTo
|
||||
* @param list<MessageAddress> $to
|
||||
* @param list<MessageAddress> $cc
|
||||
* @param list<MessageAddress> $bcc
|
||||
* @param array<string, string> $bodySections
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $sequence,
|
||||
private readonly int $uid,
|
||||
private readonly int $size,
|
||||
private readonly ?string $internalDate,
|
||||
private readonly array $flags,
|
||||
private readonly ?string $subject,
|
||||
private readonly ?string $sentAt,
|
||||
private readonly ?string $messageId,
|
||||
private readonly ?string $inReplyTo,
|
||||
private readonly array $from,
|
||||
private readonly array $sender,
|
||||
private readonly array $replyTo,
|
||||
private readonly array $to,
|
||||
private readonly array $cc,
|
||||
private readonly array $bcc,
|
||||
private readonly ?MessagePart $bodyStructure,
|
||||
private readonly array $bodySections,
|
||||
) {}
|
||||
|
||||
public function sequence(): int
|
||||
{
|
||||
return $this->sequence;
|
||||
}
|
||||
|
||||
public function uid(): int
|
||||
{
|
||||
return $this->uid;
|
||||
}
|
||||
|
||||
public function size(): int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function internalDate(): ?string
|
||||
{
|
||||
return $this->internalDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function flags(): array
|
||||
{
|
||||
return $this->flags;
|
||||
}
|
||||
|
||||
public function subject(): ?string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function sentAt(): ?string
|
||||
{
|
||||
return $this->sentAt;
|
||||
}
|
||||
|
||||
public function messageId(): ?string
|
||||
{
|
||||
return $this->messageId;
|
||||
}
|
||||
|
||||
public function inReplyTo(): ?string
|
||||
{
|
||||
return $this->inReplyTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function from(): array
|
||||
{
|
||||
return $this->from;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function sender(): array
|
||||
{
|
||||
return $this->sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function replyTo(): array
|
||||
{
|
||||
return $this->replyTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function to(): array
|
||||
{
|
||||
return $this->to;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function cc(): array
|
||||
{
|
||||
return $this->cc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
public function bcc(): array
|
||||
{
|
||||
return $this->bcc;
|
||||
}
|
||||
|
||||
public function bodyStructure(): ?MessagePart
|
||||
{
|
||||
return $this->bodyStructure;
|
||||
}
|
||||
|
||||
public function bodyText(): ?string
|
||||
{
|
||||
return $this->bodyText;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function bodySections(): array
|
||||
{
|
||||
return $this->bodySections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $bodySections
|
||||
*/
|
||||
public function withBodyData(?MessagePart $bodyStructure, array $bodySections): self
|
||||
{
|
||||
return new self(
|
||||
$this->sequence,
|
||||
$this->uid,
|
||||
$this->size,
|
||||
$this->internalDate,
|
||||
$this->flags,
|
||||
$this->subject,
|
||||
$this->sentAt,
|
||||
$this->messageId,
|
||||
$this->inReplyTo,
|
||||
$this->from,
|
||||
$this->sender,
|
||||
$this->replyTo,
|
||||
$this->to,
|
||||
$this->cc,
|
||||
$this->bcc,
|
||||
$bodyStructure,
|
||||
$bodySections,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/Client/MessageAddress.php
Normal file
50
lib/Client/MessageAddress.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class MessageAddress
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?string $name,
|
||||
private readonly ?string $mailbox,
|
||||
private readonly ?string $host,
|
||||
) {}
|
||||
|
||||
public function name(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function mailbox(): ?string
|
||||
{
|
||||
return $this->mailbox;
|
||||
}
|
||||
|
||||
public function host(): ?string
|
||||
{
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function email(): ?string
|
||||
{
|
||||
if ($this->mailbox === null || $this->mailbox === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->host === null || $this->host === '') {
|
||||
return $this->mailbox;
|
||||
}
|
||||
|
||||
return $this->mailbox . '@' . $this->host;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'address' => $this->email(),
|
||||
'label' => $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP;
|
||||
|
||||
use Exception;
|
||||
|
||||
class MessageNotFound extends Exception
|
||||
{
|
||||
}
|
||||
665
lib/Client/MessageParser.php
Normal file
665
lib/Client/MessageParser.php
Normal file
@@ -0,0 +1,665 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class MessageParser
|
||||
{
|
||||
public static function isFetchMessage(string $payload): bool
|
||||
{
|
||||
return str_contains(strtoupper($payload), 'FETCH (');
|
||||
}
|
||||
|
||||
public static function parse(string $raw): Message
|
||||
{
|
||||
if (!preg_match('/^\*\s+(\d+)\s+FETCH\s+\((.*)\)$/is', $raw, $matches)) {
|
||||
throw new ImapException('Unable to parse FETCH response: ' . $raw);
|
||||
}
|
||||
|
||||
$sequence = (int) $matches[1];
|
||||
$attributes = self::parseAttributes($matches[2]);
|
||||
$uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw);
|
||||
$envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null;
|
||||
$bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null;
|
||||
$bodySections = self::parseBodySections($attributes, $bodyStructure);
|
||||
|
||||
return new Message(
|
||||
$sequence,
|
||||
$uid,
|
||||
self::toOptionalInt($attributes['RFC822.SIZE'] ?? null) ?? 0,
|
||||
self::toNullableString($attributes['INTERNALDATE'] ?? null),
|
||||
self::parseFlags($attributes['FLAGS'] ?? null),
|
||||
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
|
||||
self::envelopeString($envelope, 0),
|
||||
self::trimAngles(self::envelopeString($envelope, 9)),
|
||||
self::envelopeString($envelope, 8),
|
||||
self::parseAddressList($envelope[2] ?? null),
|
||||
self::parseAddressList($envelope[3] ?? null),
|
||||
self::parseAddressList($envelope[4] ?? null),
|
||||
self::parseAddressList($envelope[5] ?? null),
|
||||
self::parseAddressList($envelope[6] ?? null),
|
||||
self::parseAddressList($envelope[7] ?? null),
|
||||
$bodyStructure,
|
||||
$bodySections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function parseAttributes(string $payload): array
|
||||
{
|
||||
$attributes = [];
|
||||
$offset = 0;
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length) {
|
||||
self::skipWhitespace($payload, $offset);
|
||||
if ($offset >= $length) {
|
||||
break;
|
||||
}
|
||||
|
||||
$name = self::parseAttributeName($payload, $offset);
|
||||
if (!is_string($name) || $name === '') {
|
||||
throw new ImapException('Unable to parse FETCH attribute name: ' . $payload);
|
||||
}
|
||||
|
||||
self::skipWhitespace($payload, $offset);
|
||||
$attributes[strtoupper($name)] = self::parseToken($payload, $offset);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private static function parseAttributeName(string $payload, int &$offset): string
|
||||
{
|
||||
self::skipWhitespace($payload, $offset);
|
||||
|
||||
if (preg_match('/\GBODY(?:\.PEEK)?\[/Ai', $payload, $matches, 0, $offset) === 1) {
|
||||
$start = $offset;
|
||||
$offset += strlen($matches[0]);
|
||||
$depth = 1;
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length) {
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '[') {
|
||||
$depth++;
|
||||
} elseif ($char === ']') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$offset++;
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
}
|
||||
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated FETCH BODY section attribute.');
|
||||
}
|
||||
|
||||
$name = self::parseToken($payload, $offset);
|
||||
if (!is_string($name)) {
|
||||
throw new ImapException('Invalid FETCH attribute name.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function parseToken(string $payload, int &$offset): mixed
|
||||
{
|
||||
self::skipWhitespace($payload, $offset);
|
||||
$length = strlen($payload);
|
||||
|
||||
if ($offset >= $length) {
|
||||
throw new ImapException('Unexpected end of FETCH response.');
|
||||
}
|
||||
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '(') {
|
||||
$offset++;
|
||||
$items = [];
|
||||
|
||||
while (true) {
|
||||
self::skipWhitespace($payload, $offset);
|
||||
if ($offset >= $length) {
|
||||
throw new ImapException('Unterminated FETCH list response.');
|
||||
}
|
||||
|
||||
if ($payload[$offset] === ')') {
|
||||
$offset++;
|
||||
return $items;
|
||||
}
|
||||
|
||||
$items[] = self::parseToken($payload, $offset);
|
||||
}
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
return self::parseQuotedString($payload, $offset);
|
||||
}
|
||||
|
||||
if ($char === '{') {
|
||||
return self::parseLiteral($payload, $offset);
|
||||
}
|
||||
|
||||
$start = $offset;
|
||||
while ($offset < $length && !ctype_space($payload[$offset]) && $payload[$offset] !== '(' && $payload[$offset] !== ')') {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
$atom = substr($payload, $start, $offset - $start);
|
||||
if (strtoupper($atom) === 'NIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $atom;
|
||||
}
|
||||
|
||||
private static function parseLiteral(string $payload, int &$offset): string
|
||||
{
|
||||
if (preg_match('/\G\{(\d+)\}\r\n/As', $payload, $matches, 0, $offset) !== 1
|
||||
&& preg_match('/\G\{(\d+)\}\n/As', $payload, $matches, 0, $offset) !== 1) {
|
||||
throw new ImapException('Invalid FETCH literal marker.');
|
||||
}
|
||||
|
||||
$offset += strlen($matches[0]);
|
||||
$length = (int) $matches[1];
|
||||
$literal = substr($payload, $offset, $length);
|
||||
|
||||
if (strlen($literal) !== $length) {
|
||||
throw new ImapException('FETCH literal length does not match payload.');
|
||||
}
|
||||
|
||||
$offset += $length;
|
||||
|
||||
return $literal;
|
||||
}
|
||||
|
||||
private static function parseQuotedString(string $payload, int &$offset): string
|
||||
{
|
||||
$offset++;
|
||||
$length = strlen($payload);
|
||||
$value = '';
|
||||
|
||||
while ($offset < $length) {
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '\\') {
|
||||
$offset++;
|
||||
if ($offset >= $length) {
|
||||
break;
|
||||
}
|
||||
|
||||
$value .= $payload[$offset];
|
||||
$offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$offset++;
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value .= $char;
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated quoted FETCH string.');
|
||||
}
|
||||
|
||||
private static function skipWhitespace(string $payload, int &$offset): void
|
||||
{
|
||||
$length = strlen($payload);
|
||||
while ($offset < $length && ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
}
|
||||
|
||||
private static function toInt(mixed $value, string $message): int
|
||||
{
|
||||
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
|
||||
throw new ImapException($message);
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private static function toOptionalInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseFlags(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $flag): ?string => is_string($flag) && $flag !== '' ? $flag : null,
|
||||
$value,
|
||||
)));
|
||||
}
|
||||
|
||||
private static function toNullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private static function envelopeString(?array $envelope, int $index): ?string
|
||||
{
|
||||
if ($envelope === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::toNullableString($envelope[$index] ?? null);
|
||||
}
|
||||
|
||||
private static function decodeMimeHeader(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return function_exists('mb_decode_mimeheader') ? mb_decode_mimeheader($value) : $value;
|
||||
}
|
||||
|
||||
private static function trimAngles(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($value, '<>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
private static function parseAddressList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$addresses = [];
|
||||
foreach ($value as $address) {
|
||||
if (!is_array($address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$addresses[] = new MessageAddress(
|
||||
self::decodeMimeHeader(self::toNullableString($address[0] ?? null)),
|
||||
self::toNullableString($address[2] ?? null),
|
||||
self::toNullableString($address[3] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
return $addresses;
|
||||
}
|
||||
|
||||
private static function parseBodyPart(mixed $value, string $partId): ?MessagePart
|
||||
{
|
||||
if (!is_array($value) || $value === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value[0] ?? null)) {
|
||||
$parts = [];
|
||||
$index = 0;
|
||||
while (isset($value[$index]) && is_array($value[$index])) {
|
||||
$childPartId = $partId === '' ? (string) ($index + 1) : $partId . '.' . ($index + 1);
|
||||
$child = self::parseBodyPart($value[$index], $childPartId);
|
||||
if ($child !== null) {
|
||||
$parts[] = $child;
|
||||
}
|
||||
$index++;
|
||||
}
|
||||
|
||||
$subtype = strtolower(self::toNullableString($value[$index] ?? null) ?? 'mixed');
|
||||
$parameters = self::parsePairs($value[$index + 1] ?? null);
|
||||
[$disposition, $dispositionParameters] = self::parseDisposition($value[$index + 2] ?? null);
|
||||
$language = self::parseStringList($value[$index + 3] ?? null);
|
||||
$location = self::toNullableString($value[$index + 4] ?? null);
|
||||
|
||||
return new MessagePart(
|
||||
$partId,
|
||||
'multipart/' . $subtype,
|
||||
$parameters,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$disposition,
|
||||
$dispositionParameters,
|
||||
$language,
|
||||
$location,
|
||||
null,
|
||||
$parts,
|
||||
);
|
||||
}
|
||||
|
||||
$type = strtolower(self::toNullableString($value[0] ?? null) ?? 'application');
|
||||
$subtype = strtolower(self::toNullableString($value[1] ?? null) ?? 'octet-stream');
|
||||
$parameters = self::parsePairs($value[2] ?? null);
|
||||
$contentId = self::trimAngles(self::toNullableString($value[3] ?? null));
|
||||
$description = self::toNullableString($value[4] ?? null);
|
||||
$encoding = self::toNullableString($value[5] ?? null);
|
||||
$size = self::toOptionalInt($value[6] ?? null);
|
||||
|
||||
$tailOffset = in_array($type, ['text', 'message'], true) ? 8 : 7;
|
||||
[$disposition, $dispositionParameters] = self::parseDisposition($value[$tailOffset + 1] ?? null);
|
||||
$language = self::parseStringList($value[$tailOffset + 2] ?? null);
|
||||
$location = self::toNullableString($value[$tailOffset + 3] ?? null);
|
||||
|
||||
return new MessagePart(
|
||||
$partId === '' ? '1' : $partId,
|
||||
$type . '/' . $subtype,
|
||||
$parameters,
|
||||
$contentId,
|
||||
$description,
|
||||
$encoding,
|
||||
$size,
|
||||
$disposition,
|
||||
$dispositionParameters,
|
||||
$language,
|
||||
$location,
|
||||
null,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null): array
|
||||
{
|
||||
$sections = [];
|
||||
|
||||
foreach ($attributes as $name => $value) {
|
||||
if (!preg_match('/^BODY(?:\.PEEK)?\[(.*)\]$/i', $name, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section = strtoupper(trim($matches[1]));
|
||||
if ($section === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
|
||||
$section = $partMatches[1];
|
||||
}
|
||||
|
||||
$sections[$section] = $value;
|
||||
}
|
||||
|
||||
if ($bodyStructure === null || !isset($sections['TEXT'])) {
|
||||
return $bodyStructure === null ? $sections : self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
if ($bodyStructure->isMultipart()) {
|
||||
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure);
|
||||
unset($sections['TEXT']);
|
||||
|
||||
foreach ($derivedSections as $section => $content) {
|
||||
$sections[$section] ??= $content;
|
||||
}
|
||||
|
||||
return self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
if (str_starts_with($bodyStructure->mimeType(), 'text/')) {
|
||||
$sections[$bodyStructure->partId()] ??= $sections['TEXT'];
|
||||
unset($sections['TEXT']);
|
||||
}
|
||||
|
||||
return self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $sections
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function decodeSections(array $sections, MessagePart $bodyStructure): array
|
||||
{
|
||||
$decodedSections = [];
|
||||
|
||||
foreach ($sections as $section => $content) {
|
||||
$part = self::findBodyPart($bodyStructure, (string) $section);
|
||||
if ($part === null || !str_starts_with($part->mimeType(), 'text/')) {
|
||||
$decodedSections[$section] = $content;
|
||||
continue;
|
||||
}
|
||||
|
||||
$decodedSections[$section] = self::decodeSectionContent(
|
||||
$content,
|
||||
$part->encoding(),
|
||||
$part->parameters()['charset'] ?? 'us-ascii',
|
||||
);
|
||||
}
|
||||
|
||||
return $decodedSections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function sectionsFromBodyText(string $content, MessagePart $part): array
|
||||
{
|
||||
if ($part->isMultipart()) {
|
||||
$boundary = $part->parameters()['boundary'] ?? '';
|
||||
if ($boundary === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$segments = self::splitMultipartBody($content, $boundary);
|
||||
foreach ($part->parts() as $index => $childPart) {
|
||||
if (!isset($segments[$index])) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) {
|
||||
$sections[$section] = $childContent;
|
||||
}
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
if (!str_starts_with($part->mimeType(), 'text/')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$part->partId() => $content];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function sectionsFromMimeEntity(string $content, MessagePart $part): array
|
||||
{
|
||||
[, $body] = self::splitMimeEntity($content);
|
||||
|
||||
if ($part->isMultipart()) {
|
||||
return self::sectionsFromBodyText($body, $part);
|
||||
}
|
||||
|
||||
if (!str_starts_with($part->mimeType(), 'text/')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$part->partId() => $body];
|
||||
}
|
||||
|
||||
private static function findBodyPart(MessagePart $part, string $section): ?MessagePart
|
||||
{
|
||||
if ($part->partId() === $section) {
|
||||
return $part;
|
||||
}
|
||||
|
||||
foreach ($part->parts() as $childPart) {
|
||||
$match = self::findBodyPart($childPart, $section);
|
||||
if ($match !== null) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function splitMultipartBody(string $content, string $boundary): array
|
||||
{
|
||||
$pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/';
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$segmentStart = null;
|
||||
|
||||
foreach ($matches[0] as $index => [$match, $offset]) {
|
||||
if ($segmentStart !== null) {
|
||||
$segments[] = substr($content, $segmentStart, $offset - $segmentStart);
|
||||
}
|
||||
|
||||
$isClosing = isset($matches[1][$index][1])
|
||||
&& $matches[1][$index][1] !== -1
|
||||
&& $matches[1][$index][0] === '--';
|
||||
|
||||
if ($isClosing) {
|
||||
break;
|
||||
}
|
||||
|
||||
$segmentStart = $offset + strlen($match);
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private static function splitMimeEntity(string $content): array
|
||||
{
|
||||
foreach (["\r\n\r\n", "\n\n"] as $separator) {
|
||||
$position = strpos($content, $separator);
|
||||
if ($position === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
substr($content, 0, $position),
|
||||
substr($content, $position + strlen($separator)),
|
||||
];
|
||||
}
|
||||
|
||||
return ['', $content];
|
||||
}
|
||||
|
||||
private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string
|
||||
{
|
||||
$decoded = match (strtolower($encoding ?? '7bit')) {
|
||||
'quoted-printable' => quoted_printable_decode($content),
|
||||
'base64' => base64_decode($content, true) ?: '',
|
||||
default => $content,
|
||||
};
|
||||
|
||||
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$converted = mb_convert_encoding($decoded, 'UTF-8', $charset);
|
||||
if ($converted !== false) {
|
||||
return $converted;
|
||||
}
|
||||
} catch (\ValueError) {
|
||||
}
|
||||
|
||||
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded);
|
||||
$decoded = $converted !== false ? $converted : $decoded;
|
||||
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function parsePairs(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
for ($index = 0; $index < count($value); $index += 2) {
|
||||
$name = self::toNullableString($value[$index] ?? null);
|
||||
if ($name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairs[strtolower($name)] = self::toNullableString($value[$index + 1] ?? null) ?? '';
|
||||
}
|
||||
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: array<string, string>}
|
||||
*/
|
||||
private static function parseDisposition(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
return [
|
||||
strtolower(self::toNullableString($value[0] ?? null) ?? ''),
|
||||
self::parsePairs($value[1] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseStringList(mixed $value): array
|
||||
{
|
||||
if ($value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $item): ?string => is_string($item) && $item !== '' ? $item : null,
|
||||
$value,
|
||||
)));
|
||||
}
|
||||
}
|
||||
217
lib/Client/MessagePart.php
Normal file
217
lib/Client/MessagePart.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client;
|
||||
|
||||
final class MessagePart
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $parameters
|
||||
* @param array<string, string> $dispositionParameters
|
||||
* @param list<string> $language
|
||||
* @param list<MessagePart> $parts
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $partId,
|
||||
private readonly string $mimeType,
|
||||
private readonly array $parameters = [],
|
||||
private readonly ?string $contentId = null,
|
||||
private readonly ?string $description = null,
|
||||
private readonly ?string $encoding = null,
|
||||
private readonly ?int $size = null,
|
||||
private readonly ?string $disposition = null,
|
||||
private readonly array $dispositionParameters = [],
|
||||
private readonly array $language = [],
|
||||
private readonly ?string $location = null,
|
||||
private readonly ?string $content = null,
|
||||
private readonly array $parts = [],
|
||||
) {}
|
||||
|
||||
public function partId(): string
|
||||
{
|
||||
return $this->partId;
|
||||
}
|
||||
|
||||
public function mimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function parameters(): array
|
||||
{
|
||||
return $this->parameters;
|
||||
}
|
||||
|
||||
public function contentId(): ?string
|
||||
{
|
||||
return $this->contentId;
|
||||
}
|
||||
|
||||
public function description(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function encoding(): ?string
|
||||
{
|
||||
return $this->encoding;
|
||||
}
|
||||
|
||||
public function size(): ?int
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
public function disposition(): ?string
|
||||
{
|
||||
return $this->disposition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function dispositionParameters(): array
|
||||
{
|
||||
return $this->dispositionParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function language(): array
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function location(): ?string
|
||||
{
|
||||
return $this->location;
|
||||
}
|
||||
|
||||
public function content(): ?string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessagePart>
|
||||
*/
|
||||
public function parts(): array
|
||||
{
|
||||
return $this->parts;
|
||||
}
|
||||
|
||||
public function isMultipart(): bool
|
||||
{
|
||||
return str_starts_with($this->mimeType, 'multipart/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $sections
|
||||
*/
|
||||
public function withInjectedSections(array $sections): self
|
||||
{
|
||||
if ($this->parts !== []) {
|
||||
$parts = [];
|
||||
foreach ($this->parts as $part) {
|
||||
$parts[] = $part->withInjectedSections($sections);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$this->partId,
|
||||
$this->mimeType,
|
||||
$this->parameters,
|
||||
$this->contentId,
|
||||
$this->description,
|
||||
$this->encoding,
|
||||
$this->size,
|
||||
$this->disposition,
|
||||
$this->dispositionParameters,
|
||||
$this->language,
|
||||
$this->location,
|
||||
$this->content,
|
||||
$parts,
|
||||
);
|
||||
}
|
||||
|
||||
if (!str_starts_with($this->mimeType, 'text/')) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (!array_key_exists($this->partId, $sections)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self(
|
||||
$this->partId,
|
||||
$this->mimeType,
|
||||
$this->parameters,
|
||||
$this->contentId,
|
||||
$this->description,
|
||||
$this->encoding,
|
||||
$this->size,
|
||||
$this->disposition,
|
||||
$this->dispositionParameters,
|
||||
$this->language,
|
||||
$this->location,
|
||||
self::decodeContent($sections[$this->partId], $this->encoding, $this->parameters['charset'] ?? 'us-ascii'),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'partId' => $this->partId,
|
||||
'type' => $this->mimeType,
|
||||
'blobId' => $this->contentId,
|
||||
'charset' => $this->parameters['charset'] ?? null,
|
||||
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
|
||||
'encoding' => $this->encoding,
|
||||
'size' => $this->size,
|
||||
'disposition' => $this->disposition,
|
||||
'language' => $this->language === [] ? null : implode(',', $this->language),
|
||||
'location' => $this->location,
|
||||
'content' => $this->content,
|
||||
];
|
||||
|
||||
$children = [];
|
||||
foreach ($this->parts as $part) {
|
||||
$children[] = $part->toArray();
|
||||
}
|
||||
|
||||
$data['subParts'] = $children === [] ? null : $children;
|
||||
|
||||
return array_filter($data, static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
private static function decodeContent(string $content, ?string $encoding, string $charset): string
|
||||
{
|
||||
$decoded = match (strtolower($encoding ?? '7bit')) {
|
||||
'quoted-printable' => quoted_printable_decode($content),
|
||||
'base64' => base64_decode($content, true) ?: '',
|
||||
default => $content,
|
||||
};
|
||||
|
||||
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$converted = mb_convert_encoding($decoded, 'UTF-8', $charset);
|
||||
if ($converted !== false) {
|
||||
return $converted;
|
||||
}
|
||||
} catch (\ValueError) {
|
||||
}
|
||||
|
||||
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded);
|
||||
$decoded = $converted !== false ? $converted : $decoded;
|
||||
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Gricob\IMAP\Client;
|
||||
use Gricob\IMAP\Mime\Part\Part;
|
||||
|
||||
class LazyMessage extends Message
|
||||
{
|
||||
public function __construct(
|
||||
private Client $client,
|
||||
int $id,
|
||||
?array $headers = null,
|
||||
?DateTimeImmutable $internalDate = null,
|
||||
) {
|
||||
$this->id = $id;
|
||||
|
||||
if (null !== $headers) {
|
||||
$this->headers = $headers;
|
||||
}
|
||||
|
||||
if (null !== $internalDate) {
|
||||
$this->internalDate = $internalDate;
|
||||
}
|
||||
}
|
||||
|
||||
public function headers(): array
|
||||
{
|
||||
if (!isset($this->headers)) {
|
||||
$this->headers = $this->client->fetchHeaders($this->id);
|
||||
}
|
||||
|
||||
return parent::headers();
|
||||
}
|
||||
|
||||
public function body(): Part
|
||||
{
|
||||
if (!isset($this->body)) {
|
||||
$this->body = $this->client->fetchBody($this->id);
|
||||
}
|
||||
|
||||
return parent::body();
|
||||
}
|
||||
|
||||
public function internalDate(): DateTimeImmutable
|
||||
{
|
||||
if (!isset($this->internalDate)) {
|
||||
$this->internalDate = $this->client->fetchInternalDate($this->id);
|
||||
}
|
||||
|
||||
return parent::internalDate();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Gricob\IMAP\Mime\Part\Part;
|
||||
|
||||
class Message
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $id,
|
||||
protected array $headers,
|
||||
protected Part $body,
|
||||
protected DateTimeImmutable $internalDate,
|
||||
) {
|
||||
}
|
||||
|
||||
public function id(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function headers(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function body(): Part
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function internalDate(): DateTimeImmutable
|
||||
{
|
||||
return $this->internalDate;
|
||||
}
|
||||
|
||||
public function textBody(): ?string
|
||||
{
|
||||
return $this->body()->findPartByMimeType('text/plain')?->decodedBody();
|
||||
}
|
||||
|
||||
public function htmlBody(): ?string
|
||||
{
|
||||
return $this->body()->findPartByMimeType('text/html')?->decodedBody();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
use Stringable;
|
||||
|
||||
class Body implements Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $value
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
final readonly class Disposition
|
||||
{
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public ?string $filename,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
use Gricob\IMAP\Client;
|
||||
|
||||
class LazyBody extends Body
|
||||
{
|
||||
public function __construct(
|
||||
private Client $client,
|
||||
private int $id,
|
||||
private string $section,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if (!isset($this->value)) {
|
||||
$this->value = $this->client->fetchSectionBody($this->id, $this->section);
|
||||
}
|
||||
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
final readonly class MultiPart extends Part
|
||||
{
|
||||
/**
|
||||
* @param array<string,string> $attributes
|
||||
* @param list<Part> $parts
|
||||
*/
|
||||
public function __construct(
|
||||
string $subtype,
|
||||
array $attributes,
|
||||
public array $parts,
|
||||
) {
|
||||
parent::__construct('multipart', $subtype, $attributes);
|
||||
}
|
||||
|
||||
public function findPartByMimeType(string $mimeType): ?SinglePart
|
||||
{
|
||||
foreach ($this->parts as $part) {
|
||||
if ($matchedPart = $part->findPartByMimeType(strtolower($mimeType))) {
|
||||
return $matchedPart;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
abstract readonly class Part
|
||||
{
|
||||
public string $type;
|
||||
public string $subtype;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $attributes;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $attributes
|
||||
*/
|
||||
public function __construct(
|
||||
string $type,
|
||||
string $subtype,
|
||||
array $attributes,
|
||||
) {
|
||||
$this->subtype = strtolower($subtype);
|
||||
$this->type = strtolower($type);
|
||||
$this->attributes = $attributes;
|
||||
}
|
||||
|
||||
abstract public function findPartByMimeType(string $mimeType): ?SinglePart;
|
||||
|
||||
public function mimeType(): string
|
||||
{
|
||||
return $this->type.'/'.$this->subtype;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Mime\Part;
|
||||
|
||||
final readonly class SinglePart extends Part
|
||||
{
|
||||
private string $encoding;
|
||||
|
||||
public function __construct(
|
||||
string $type,
|
||||
string $subtype,
|
||||
array $attributes,
|
||||
private Body $body,
|
||||
private string $charset,
|
||||
string $encoding,
|
||||
private ?Disposition $disposition,
|
||||
) {
|
||||
|
||||
$this->encoding = strtolower($encoding);
|
||||
parent::__construct($type, $subtype, $attributes);
|
||||
}
|
||||
|
||||
public function body(): string
|
||||
{
|
||||
return (string) $this->body;
|
||||
}
|
||||
|
||||
public function decodedBody(): string
|
||||
{
|
||||
return match ($this->encoding) {
|
||||
'quoted-printable' => quoted_printable_decode($this->body()),
|
||||
'base64' => base64_decode($this->body()),
|
||||
default => $this->body(),
|
||||
};
|
||||
}
|
||||
|
||||
public function charset(): string
|
||||
{
|
||||
return $this->charset;
|
||||
}
|
||||
|
||||
public function encoding(): string
|
||||
{
|
||||
return $this->encoding;
|
||||
}
|
||||
|
||||
public function disposition(): ?Disposition
|
||||
{
|
||||
return $this->disposition;
|
||||
}
|
||||
|
||||
public function findPartByMimeType(string $mimeType): ?SinglePart
|
||||
{
|
||||
if ($this->mimeType() === strtolower($mimeType)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP;
|
||||
|
||||
final readonly class PreFetchOptions
|
||||
{
|
||||
public function __construct(
|
||||
public bool $internalDate = false,
|
||||
public bool $headers = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument;
|
||||
|
||||
interface Argument
|
||||
{
|
||||
public function __toString(): string;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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').'"';
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
class All implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'ALL';
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Body implements Criteria
|
||||
{
|
||||
public function __construct(private string $value) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'BODY "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
use Gricob\IMAP\Protocol\Command\Argument\Argument;
|
||||
|
||||
interface Criteria extends Argument
|
||||
{
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Flagged implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'FLAGGED';
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class From implements Criteria
|
||||
{
|
||||
public function __construct(private string $value) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'FROM "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Larger implements Criteria
|
||||
{
|
||||
public function __construct(private int $size) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'LARGER ' . $this->size;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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.')';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Seen implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'SEEN';
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Smaller implements Criteria
|
||||
{
|
||||
public function __construct(private int $size) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'SMALLER ' . $this->size;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Subject implements Criteria
|
||||
{
|
||||
public function __construct(private string $value) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'SUBJECT "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class To implements Criteria
|
||||
{
|
||||
public function __construct(private string $value) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'TO "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Unflagged implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'UNFLAGGED';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Unseen implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'UNSEEN';
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SequenceSet that matches every message in the mailbox (1:*).
|
||||
*/
|
||||
public static function all(): self
|
||||
{
|
||||
$set = new self();
|
||||
$set->range = '1:*';
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SequenceSet from a flat array of UIDs, collapsing consecutive
|
||||
* values into n:m ranges.
|
||||
*
|
||||
* Examples:
|
||||
* [1, 2, 3, 5, 6, 10] → "1:3,5:6,10"
|
||||
* [42] → "42"
|
||||
* [7, 3, 4, 5] → "3:5,7"
|
||||
*
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public static function list(array $uids): self
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return new self();
|
||||
}
|
||||
|
||||
$uids = array_unique($uids);
|
||||
sort($uids);
|
||||
|
||||
$ranges = [];
|
||||
$start = $end = $uids[0];
|
||||
|
||||
for ($i = 1, $count = count($uids); $i <= $count; $i++) {
|
||||
$current = $uids[$i] ?? null;
|
||||
if ($current !== null && $current === $end + 1) {
|
||||
$end = $current;
|
||||
} else {
|
||||
$ranges[] = $start === $end ? (string) $start : $start . ':' . $end;
|
||||
if ($current !== null) {
|
||||
$start = $end = $current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$set = new self();
|
||||
$set->range = implode(',', $ranges);
|
||||
return $set;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->range !== null) {
|
||||
return $this->range;
|
||||
}
|
||||
|
||||
return implode(',', $this->numbers);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?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
|
||||
{
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command;
|
||||
|
||||
interface Continuable
|
||||
{
|
||||
public function continue(): string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command;
|
||||
|
||||
final readonly class ExpungeCommand extends Command
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('EXPUNGE');
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
86
lib/Client/Protocol/CommandExecutor.php
Normal file
86
lib/Client/Protocol/CommandExecutor.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class CommandExecutor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProtocolReader $reader,
|
||||
private readonly ProtocolWriter $writer,
|
||||
private readonly TagGenerator $tags = new TagGenerator(),
|
||||
private readonly ?LoggerInterface $logger = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @template TResult
|
||||
* @param CommandInterface<TResult> $command
|
||||
* @return TResult
|
||||
*/
|
||||
public function perform(CommandInterface $command, SessionContext $context): mixed
|
||||
{
|
||||
$this->assertState($command->allowedStates(), $context->state(), $command->name());
|
||||
|
||||
$this->logger?->debug('IMAP command execution started: {command} (state={state})', [
|
||||
'command' => $command->name(),
|
||||
'state' => $context->state()->value,
|
||||
]);
|
||||
|
||||
$tag = $this->tags->next();
|
||||
$frame = $command->encode($tag, $context);
|
||||
$this->writer->write($tag, $frame);
|
||||
|
||||
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
|
||||
yield from $this->responsesUntilCompletion($tag, $context);
|
||||
}), $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SessionState> $allowedStates
|
||||
*/
|
||||
private function assertState(array $allowedStates, SessionState $currentState, string $commandName): void
|
||||
{
|
||||
foreach ($allowedStates as $allowedState) {
|
||||
if ($allowedState === $currentState) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException(sprintf(
|
||||
'Command %s is not allowed while session is in state %s.',
|
||||
$commandName,
|
||||
$currentState->value,
|
||||
));
|
||||
}
|
||||
|
||||
private function responsesUntilCompletion(string $tag, SessionContext $context): Generator
|
||||
{
|
||||
while (true) {
|
||||
$response = $this->reader->readResponse();
|
||||
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
|
||||
$context->replaceCapabilities(...$response->payloadTokens());
|
||||
}
|
||||
|
||||
yield $response;
|
||||
|
||||
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
|
||||
$this->logger?->debug('IMAP command execution completed: tag={tag} status={status}', [
|
||||
'tag' => $tag,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?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");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ConnectionRejected extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol;
|
||||
|
||||
interface ContinuationHandler
|
||||
{
|
||||
public function continue(): void;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user