generated from Nodarx/template
feat: initial version
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit was merged in pull request #1.
This commit is contained in:
470
lib/Service/Remote/ImapClientWrapper.php
Normal file
470
lib/Service/Remote/ImapClientWrapper.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderImapMail\Service\Remote;
|
||||
|
||||
use Generator;
|
||||
use DateTimeInterface;
|
||||
use Gricob\IMAP\Client;
|
||||
use Gricob\IMAP\Mailbox;
|
||||
use Gricob\IMAP\Mime\Part\Body;
|
||||
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\SearchCommand;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
|
||||
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
|
||||
use Gricob\IMAP\Protocol\Command\ExpungeCommand;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\SearchData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart as BodySinglePart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart as BodyMultiPart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part as BodyPart;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\CopyCommand;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\DeleteCommand;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\RenameCommand;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\StreamFetchCommand;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\StoreCommand as ModuleStoreCommand;
|
||||
use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria;
|
||||
|
||||
/**
|
||||
* Wraps a gricob IMAP Client to provide a stable, higher-level interface.
|
||||
*
|
||||
* Goals
|
||||
* -----
|
||||
* - Hide raw gricob types from the rest of the service layer
|
||||
* - Fill gricob's gaps (DELETE, RENAME, UID COPY) with thin command wrappers
|
||||
* - Provide memory-efficient streaming via a Generator for cache sync
|
||||
* - Make the wrapper easy to mock in tests
|
||||
*/
|
||||
class ImapClientWrapper
|
||||
{
|
||||
public function __construct(private readonly Client $client) {}
|
||||
|
||||
// ── Escape hatch ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Access the underlying gricob Client directly for operations not covered here. */
|
||||
public function raw(): Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
// ── Message Fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stream messages one UID at a time to avoid loading entire mailboxes into
|
||||
* memory. Ideal for cache-sync operations on large mailboxes.
|
||||
*
|
||||
* @param string[] $items IMAP fetch data items ('FLAGS', 'ENVELOPE', 'INTERNALDATE', 'BODYSTRUCTURE', 'BODY[]', …)
|
||||
* @return Generator<int, FetchData> Yields uid => FetchData
|
||||
*/
|
||||
public function streamMessages(string $mailbox, array $uids, array $items): Generator
|
||||
{
|
||||
$this->client->select($mailbox);
|
||||
|
||||
$response = $this->client->send(new StreamFetchCommand($uids, $items));
|
||||
foreach ($response->getData(FetchData::class) as $fetchData) {
|
||||
yield ($fetchData->uid ?? $fetchData->id) => $fetchData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-fetch messages for specific UIDs using the Client's sendStreaming
|
||||
* path. Responses are processed one at a time as they arrive off the
|
||||
* socket — no buffering of the full server reply.
|
||||
*
|
||||
* Prefer this over streamMessages() for large UID sets where memory
|
||||
* pressure matters. streamMessages() collects all FetchData objects into
|
||||
* a Response first; this method yields each one as it is received.
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @param string[] $items IMAP fetch data items
|
||||
* @return Generator<int, FetchData> Yields uid => FetchData
|
||||
*/
|
||||
public function fetchMultiple(string $mailbox, array $uids, array $items): Generator
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->select($mailbox);
|
||||
yield from $this->client->streamByUids($uids, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetch a known small batch of messages in a single IMAP round-trip.
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @param string[] $items
|
||||
* @return FetchData[]
|
||||
*/
|
||||
public function fetchMessages(string $mailbox, array $uids, array $items): array
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->client->select($mailbox);
|
||||
$response = $this->client->send(new StreamFetchCommand($uids, $items));
|
||||
|
||||
return $response->getData(FetchData::class);
|
||||
}
|
||||
|
||||
// ── Flag Mutation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a flag store operation to multiple messages in a single round-trip.
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @param string $action '+' | '-' | '' (replace)
|
||||
* @param string[] $flags e.g. ['\Seen', '\Answered']
|
||||
*/
|
||||
public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->select($mailbox);
|
||||
$this->client->send(new ModuleStoreCommand($uids, new Flags($flags, $action)));
|
||||
}
|
||||
|
||||
// ── Message Copy ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copy multiple messages to a destination mailbox in a single round-trip.
|
||||
*
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public function copyMessages(string $mailbox, array $uids, string $destination): void
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->client->select($mailbox);
|
||||
$this->client->send(new CopyCommand($uids, $destination));
|
||||
}
|
||||
|
||||
// ── Mailbox Commands ──────────────────────────────────────────────────────
|
||||
|
||||
/** Delete a mailbox (gricob gap — uses raw DELETE command). */
|
||||
public function deleteMailbox(string $mailbox): void
|
||||
{
|
||||
$this->client->send(new DeleteCommand($mailbox));
|
||||
}
|
||||
|
||||
/** Rename a mailbox (gricob gap — uses raw RENAME command). */
|
||||
public function renameMailbox(string $oldName, string $newName): void
|
||||
{
|
||||
$this->client->send(new RenameCommand($oldName, $newName));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all mailboxes (via IMAP LIST command).
|
||||
*
|
||||
* @return Mailbox[]
|
||||
*/
|
||||
public function mailboxes(): array
|
||||
{
|
||||
return $this->client->mailboxes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new mailbox.
|
||||
*/
|
||||
public function createMailbox(string $name): void
|
||||
{
|
||||
$this->client->createMailbox($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a raw RFC822 message to a mailbox.
|
||||
*
|
||||
* @param string[] $flags e.g. ['\\Seen']
|
||||
* @return int the UID assigned to the new message
|
||||
*/
|
||||
public function append(
|
||||
string $rawMessage,
|
||||
string $mailbox = 'INBOX',
|
||||
array $flags = [],
|
||||
?DateTimeInterface $internalDate = null,
|
||||
): int {
|
||||
return $this->client->append(
|
||||
$rawMessage,
|
||||
$mailbox,
|
||||
!empty($flags) ? $flags : null,
|
||||
$internalDate,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark multiple messages as \\Deleted then EXPUNGE the mailbox.
|
||||
*
|
||||
* Performs a UID STORE +FLAGS.SILENT (\\Deleted) on all UIDs in a single
|
||||
* round-trip, then issues a plain EXPUNGE.
|
||||
*
|
||||
* @param int[] $uids
|
||||
*/
|
||||
public function deleteMessages(string $mailbox, array $uids): void
|
||||
{
|
||||
if (empty($uids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storeFlags($mailbox, $uids, '+', ['\\Deleted']);
|
||||
$this->client->send(new ExpungeCommand());
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all UIDs in the currently-selected mailbox matching UNSEEN.
|
||||
*
|
||||
* Sends UID SEARCH UNSEEN directly since gricob's Search builder has no
|
||||
* unseen() method. Results are UIDs (useUid=true on Configuration).
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function searchUnseen(string $mailbox): array
|
||||
{
|
||||
$this->client->select($mailbox);
|
||||
|
||||
$response = $this->client->send(
|
||||
new SearchCommand(
|
||||
$this->client->configuration->useUid,
|
||||
new UnseenCriteria(),
|
||||
)
|
||||
);
|
||||
|
||||
$ids = [];
|
||||
foreach ($response->getData(SearchData::class) as $searchData) {
|
||||
array_push($ids, ...$searchData->numbers);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all UIDs in the selected mailbox.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function searchAll(string $mailbox): array
|
||||
{
|
||||
$mailbox = $this->client->select($mailbox);
|
||||
// search()->get() with no criteria uses ALL; returns LazyMessage[]
|
||||
// where id() is the UID when useUid=true
|
||||
$messages = $this->client->search()->get();
|
||||
return array_map(fn ($m) => $m->id(), $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return UIDs in the selected mailbox matching the given criteria.
|
||||
*
|
||||
* Sends a UID SEARCH command with the provided Criteria instances.
|
||||
* Pass an empty array to match ALL messages.
|
||||
*
|
||||
* @param \Gricob\IMAP\Protocol\Command\Argument\Search\Criteria[] $criteria
|
||||
* @return int[]
|
||||
*/
|
||||
public function searchMessages(string $mailbox, array $criteria): array
|
||||
{
|
||||
$this->client->select($mailbox);
|
||||
|
||||
$response = $this->client->send(
|
||||
new SearchCommand(
|
||||
$this->client->configuration->useUid,
|
||||
...$criteria,
|
||||
)
|
||||
);
|
||||
|
||||
$ids = [];
|
||||
foreach ($response->getData(SearchData::class) as $searchData) {
|
||||
array_push($ids, ...$searchData->numbers);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the MIME body Part tree for a single message.
|
||||
*
|
||||
* The returned Part tree uses LazyBody instances that defer actual
|
||||
* BODY[section] fetches until decodedBody() is called. Call
|
||||
* Part::findPartByMimeType('text/html') and
|
||||
* Part::findPartByMimeType('text/plain') on the result.
|
||||
*
|
||||
* Note: the mailbox must already be selected (fetchMessages() does this).
|
||||
*/
|
||||
public function fetchBodyParts(int $uid): Part
|
||||
{
|
||||
return $this->client->fetchBody($uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the MIME body Part tree from an already-fetched FetchData object.
|
||||
*
|
||||
* Optionally accepts a pre-loaded sections map (section => raw text) to
|
||||
* avoid any secondary IMAP fetches. Falls back to LazyBody for sections
|
||||
* not present in the map.
|
||||
*
|
||||
* @param array<string,string> $sections pre-fetched section bodies keyed by section path
|
||||
*/
|
||||
public function fetchBodyPartsFromData(FetchData $fetchData, array $sections = []): ?Part
|
||||
{
|
||||
if ($fetchData->bodyStructure === null || $fetchData->bodyStructure->part === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uid = $fetchData->uid ?? $fetchData->id;
|
||||
|
||||
return $this->buildPartsFromStructure($uid, '0', $fetchData->bodyStructure->part, $sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-phase batch fetch: metadata+BODYSTRUCTURE for all UIDs in one
|
||||
* command, then all text/* body sections for all UIDs in a second command.
|
||||
*
|
||||
* Yields uid => [FetchData $meta, ?Part $body] with no per-message or
|
||||
* per-section secondary fetches.
|
||||
*
|
||||
* @param int[] $uids
|
||||
* @return \Generator<int, array{0: FetchData, 1: ?Part}>
|
||||
*/
|
||||
public function fetchMessagesWithBody(string $mailbox, array $uids): \Generator
|
||||
{
|
||||
$this->client->select($mailbox);
|
||||
|
||||
// ── Phase 1: metadata + BODYSTRUCTURE for all UIDs in one round-trip ──
|
||||
$metaItems = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'];
|
||||
$response = $this->client->send(new StreamFetchCommand($uids, $metaItems));
|
||||
|
||||
/** @var array<int, FetchData> $metaByUid */
|
||||
$metaByUid = [];
|
||||
foreach ($response->getData(FetchData::class) as $fetchData) {
|
||||
$uid = $fetchData->uid ?? $fetchData->id;
|
||||
$metaByUid[$uid] = $fetchData;
|
||||
}
|
||||
|
||||
// ── Discover text section paths across all messages ───────────────────
|
||||
$allSections = []; // section-path => true (de-duplicated union)
|
||||
foreach ($metaByUid as $fetchData) {
|
||||
if ($fetchData->bodyStructure?->part !== null) {
|
||||
foreach ($this->findTextSections($fetchData->bodyStructure->part) as $section) {
|
||||
$allSections[$section] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: fetch all text sections for all UIDs in one round-trip ───
|
||||
// Some servers return NIL for an empty/missing body section instead of
|
||||
// a literal, which gricob's parser cannot handle. If the batch fetch
|
||||
// fails for any reason we leave $sectionsByUid empty so that individual
|
||||
// parts fall back to LazyBody on first access.
|
||||
/** @var array<int, array<string,string>> $sectionsByUid uid => [section => text] */
|
||||
$sectionsByUid = [];
|
||||
if (!empty($allSections)) {
|
||||
try {
|
||||
$sectionItems = array_map(fn ($s) => 'BODY[' . $s . ']', array_keys($allSections));
|
||||
array_unshift($sectionItems, 'UID');
|
||||
$bodyResponse = $this->client->send(new StreamFetchCommand($uids, $sectionItems));
|
||||
foreach ($bodyResponse->getData(FetchData::class) as $fetchData) {
|
||||
$uid = $fetchData->uid ?? $fetchData->id;
|
||||
$map = [];
|
||||
foreach ($fetchData->bodySections as $bodySection) {
|
||||
$map[$bodySection->section] = $bodySection->text;
|
||||
}
|
||||
$sectionsByUid[$uid] = $map;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Parser could not handle a NIL body section — LazyBody will
|
||||
// fetch individual sections on demand instead.
|
||||
$sectionsByUid = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Yield merged result ───────────────────────────────────────────────
|
||||
foreach ($metaByUid as $uid => $fetchData) {
|
||||
$sections = $sectionsByUid[$uid] ?? [];
|
||||
yield $uid => [$fetchData, $this->fetchBodyPartsFromData($fetchData, $sections)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a BodyStructure tree and return the IMAP section paths of all
|
||||
* text/* leaves (text/plain, text/html, text/calendar, …).
|
||||
*
|
||||
* Sections with size=0 are excluded: servers respond to such fetches with
|
||||
* NIL instead of a literal and gricob's parser cannot handle that.
|
||||
*
|
||||
* @return string[] e.g. ['1', '2'] or ['1.1', '1.2']
|
||||
*/
|
||||
private function findTextSections(BodyPart $part, string $section = '0'): array
|
||||
{
|
||||
if ($part instanceof BodySinglePart) {
|
||||
$resolvedSection = $section === '0' ? '1' : $section;
|
||||
return (strtolower($part->type) === 'text' && $part->size > 0) ? [$resolvedSection] : [];
|
||||
}
|
||||
|
||||
if ($part instanceof BodyMultiPart) {
|
||||
$sections = [];
|
||||
foreach ($part->parts as $index => $childPart) {
|
||||
$childIndex = (string) ($index + 1);
|
||||
$childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex;
|
||||
array_push($sections, ...$this->findTextSections($childPart, $childSection));
|
||||
}
|
||||
return $sections;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build a Mime Part tree from a BodyStructure part.
|
||||
*
|
||||
* @param array<string,string> $sections pre-fetched section bodies (section path => raw text).
|
||||
* Missing sections fall back to LazyBody.
|
||||
*/
|
||||
private function buildPartsFromStructure(int $uid, string $section, BodyPart $part, array $sections = []): Part
|
||||
{
|
||||
if ($part instanceof BodySinglePart) {
|
||||
$resolvedSection = $section === '0' ? '1' : $section;
|
||||
$body = isset($sections[$resolvedSection])
|
||||
? new Body($sections[$resolvedSection])
|
||||
: new LazyBody($this->client, $uid, $resolvedSection);
|
||||
return new SinglePart(
|
||||
$part->type,
|
||||
$part->subtype,
|
||||
$part->attributes,
|
||||
$body,
|
||||
$part->attributes['charset'] ?? 'utf-8',
|
||||
$part->encoding,
|
||||
$part->disposition !== null
|
||||
? new Disposition(
|
||||
$part->disposition->type,
|
||||
$part->disposition->attributes['filename'] ?? null,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($part instanceof BodyMultiPart) {
|
||||
$childParts = [];
|
||||
foreach ($part->parts as $index => $childPart) {
|
||||
$childIndex = (string) ($index + 1);
|
||||
$childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex;
|
||||
$childParts[] = $this->buildPartsFromStructure($uid, $childSection, $childPart, $sections);
|
||||
}
|
||||
return new MultiPart($part->subtype, $part->attributes, $childParts);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unexpected BodyStructure part type: ' . $part::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user