generated from Nodarx/template
624 lines
19 KiB
PHP
624 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Gricob\IMAP;
|
|
|
|
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 Psr\Log\LoggerInterface;
|
|
use RuntimeException;
|
|
|
|
class Client
|
|
{
|
|
public Configuration $configuration;
|
|
private Imap $imap;
|
|
|
|
private Mailbox $selectedMailbox;
|
|
|
|
private function __construct(
|
|
Configuration $configuration,
|
|
?LoggerInterface $logger,
|
|
) {
|
|
$connection = new SocketConnection(
|
|
$configuration->transport,
|
|
$configuration->host,
|
|
$configuration->port,
|
|
$configuration->timeout,
|
|
$configuration->verifyPeer,
|
|
$configuration->verifyPeerName,
|
|
$configuration->allowSelfSigned,
|
|
);
|
|
|
|
if (null !== $logger) {
|
|
$connection = new TraceableConnection($connection, $logger);
|
|
}
|
|
|
|
$this->configuration = $configuration;
|
|
$this->imap = new Imap($connection);
|
|
$this->selectedMailbox = new Mailbox([], '', '');
|
|
}
|
|
|
|
public static function create(Configuration $configuration, ?LoggerInterface $logger = null): self
|
|
{
|
|
return new self($configuration, $logger);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
$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,
|
|
));
|
|
|
|
$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;
|
|
}
|
|
|
|
return array_map(fn (int $id) => new LazyMessage($this, $id), $ids);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>|null
|
|
*/
|
|
private function createHeaders(FetchData $data): ?array
|
|
{
|
|
if (null === $headerSection = $data->getBodySection('HEADER')) {
|
|
return null;
|
|
}
|
|
|
|
return iconv_mime_decode_headers($headerSection->text, ICONV_MIME_DECODE_CONTINUE_ON_ERROR) ?: [];
|
|
}
|
|
|
|
private function createMessagePart(int $id, string $section, BodyStructure\Part $part): Mime\Part\Part
|
|
{
|
|
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);
|
|
}
|
|
}
|