feat: speed improvements

Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit is contained in:
Sebastian Krupinski
2026-02-20 23:34:30 -05:00
parent e51c65bf19
commit 7446edced3
37 changed files with 648 additions and 1086 deletions

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP BODY <string> search criteria.
*/
final readonly class BodyCriteria implements Criteria
{
public function __construct(private string $value) {}
public function __toString(): string
{
return 'BODY "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
}
}

View File

@@ -1,38 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
/**
* Raw UID COPY command.
*
* gricob does not expose message copying; this thin wrapper fills the gap.
* Accepts a set of UIDs formatted as a comma-separated sequence set.
*
* Example: UID COPY 1,3,7 "INBOX.Archive"
*/
final readonly class CopyCommand extends Command
{
/**
* @param int[] $uids Source message UIDs
* @param string $destination Target mailbox name
*/
public function __construct(array $uids, string $destination)
{
parent::__construct(
'UID COPY',
new SequenceSet(...$uids),
new QuotedString($destination),
);
}
}

View File

@@ -1,28 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
/**
* Raw IMAP DELETE command for a mailbox.
*
* gricob does not expose mailbox deletion; this thin wrapper fills the gap.
*
* Example: DELETE "INBOX.Trash"
*/
final readonly class DeleteCommand extends Command
{
public function __construct(string $mailbox)
{
parent::__construct('DELETE', new QuotedString($mailbox));
}
}

View File

@@ -1,23 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP FLAGGED search criteria.
*/
final readonly class FlaggedCriteria implements Criteria
{
public function __toString(): string
{
return 'FLAGGED';
}
}

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP FROM <string> search criteria.
*/
final readonly class FromCriteria implements Criteria
{
public function __construct(private string $value) {}
public function __toString(): string
{
return 'FROM "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
}
}

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP LARGER <n> search criteria (messages larger than n octets).
*/
final readonly class LargerCriteria implements Criteria
{
public function __construct(private int $size) {}
public function __toString(): string
{
return 'LARGER ' . $this->size;
}
}

View File

@@ -1,28 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Argument\QuotedString;
/**
* Raw IMAP RENAME command.
*
* gricob does not expose mailbox renaming; this thin wrapper fills the gap.
*
* Example: RENAME "OldName" "NewName"
*/
final readonly class RenameCommand extends Command
{
public function __construct(string $oldName, string $newName)
{
parent::__construct('RENAME', new QuotedString($oldName), new QuotedString($newName));
}
}

View File

@@ -1,23 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP SEEN search criteria.
*/
final readonly class SeenCriteria implements Criteria
{
public function __toString(): string
{
return 'SEEN';
}
}

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP SMALLER <n> search criteria (messages smaller than n octets).
*/
final readonly class SmallerCriteria implements Criteria
{
public function __construct(private int $size) {}
public function __toString(): string
{
return 'SMALLER ' . $this->size;
}
}

View File

@@ -1,27 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
/**
* STARTTLS command (RFC 3501 §6.2.1).
*
* Instructs the server to begin TLS negotiation on the current connection.
* After the server responds OK, the client must call upgradeTls() on the
* underlying SocketConnection to complete the handshake.
*/
final readonly class StartTlsCommand extends Command
{
public function __construct()
{
parent::__construct('STARTTLS');
}
}

View File

@@ -1,39 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
use Gricob\IMAP\Protocol\Command\Argument\Store\Flags;
/**
* Bulk UID STORE command for flag mutations.
*
* A thin ergonomic wrapper around gricob's FetchCommand that accepts an array
* of UIDs and a pre-built Flags argument so callers don't have to construct
* SequenceSet directly.
*
* Example: UID STORE 1,3,7 +FLAGS.SILENT (\Seen)
*/
final readonly class StoreCommand extends Command
{
/**
* @param int[] $uids UIDs to operate on
* @param Flags $flags e.g. new Flags(['\Seen'], '+')
*/
public function __construct(array $uids, Flags $flags)
{
parent::__construct(
'UID STORE',
new SequenceSet(...$uids),
$flags,
);
}
}

View File

@@ -1,39 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Command;
use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList;
use Gricob\IMAP\Protocol\Command\Argument\SequenceSet;
/**
* Streaming single-message fetch command.
*
* Wraps gricob's UID FETCH for one or more UIDs with a configurable item list.
* Used inside ImapClientWrapper::streamMessages() (one UID per call) and
* ImapClientWrapper::fetchMessages() (variadic UIDs for bulk prefetch).
*
* Example: UID FETCH 42 (FLAGS ENVELOPE INTERNALDATE BODYSTRUCTURE BODY[])
*/
final readonly class StreamFetchCommand extends Command
{
/**
* @param int[] $uids One or more UIDs; formatted as "1,3,7" by SequenceSet
* @param string[] $items IMAP fetch data items (e.g. 'FLAGS', 'ENVELOPE', 'BODY[]')
*/
public function __construct(array $uids, array $items)
{
parent::__construct(
'UID FETCH',
new SequenceSet(...$uids),
new ParenthesizedList($items),
);
}
}

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP SUBJECT <string> search criteria.
*/
final readonly class SubjectCriteria implements Criteria
{
public function __construct(private string $value) {}
public function __toString(): string
{
return 'SUBJECT "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
}
}

View File

@@ -1,25 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP TO <string> search criteria.
*/
final readonly class ToCriteria implements Criteria
{
public function __construct(private string $value) {}
public function __toString(): string
{
return 'TO "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"';
}
}

View File

@@ -1,23 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP UNFLAGGED search criteria.
*/
final readonly class UnflaggedCriteria implements Criteria
{
public function __toString(): string
{
return 'UNFLAGGED';
}
}

View File

@@ -1,26 +0,0 @@
<?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\Command;
use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria;
/**
* IMAP UNSEEN search criteria.
*
* gricob does not include an UNSEEN criteria; this thin class fills the gap.
* Used with SearchCommand to count unread messages via SEARCH UNSEEN.
*/
final readonly class UnseenCriteria implements Criteria
{
public function __toString(): string
{
return 'UNSEEN';
}
}

View File

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

View File

@@ -11,40 +11,33 @@ namespace KTXM\ProviderImapMail\Service\Remote;
use DateTimeImmutable;
use Generator;
use Gricob\IMAP\Client;
use Gricob\IMAP\Protocol\Command\Argument\Search\Before;
use Gricob\IMAP\Protocol\Command\Argument\Search\Body;
use Gricob\IMAP\Protocol\Command\Argument\Search\Flagged;
use Gricob\IMAP\Protocol\Command\Argument\Search\From;
use Gricob\IMAP\Protocol\Command\Argument\Search\Larger;
use Gricob\IMAP\Protocol\Command\Argument\Search\Seen;
use Gricob\IMAP\Protocol\Command\Argument\Search\Since;
use Gricob\IMAP\Protocol\Command\Argument\Search\Smaller;
use Gricob\IMAP\Protocol\Command\Argument\Search\Subject;
use Gricob\IMAP\Protocol\Command\Argument\Search\To;
use Gricob\IMAP\Protocol\Command\Argument\Search\Unflagged;
use Gricob\IMAP\Protocol\Command\Argument\Search\Unseen;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeTally;
use KTXM\ProviderImapMail\Providers\CollectionResource;
use KTXM\ProviderImapMail\Providers\EntityResource;
use KTXM\ProviderImapMail\Service\Remote\Command\BodyCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\FlaggedCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\FromCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\LargerCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\SeenCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\SmallerCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\SubjectCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\ToCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\UnflaggedCriteria;
use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria;
/**
* IMAP Remote Mail Service
*
* Provides collection (mailbox) and entity (message) operations against a live
* IMAP server via ImapClientWrapper. All methods are stateless — no caching or
* local storage happens here.
*/
class RemoteMailService
{
/**
* Default IMAP FETCH data items used for message hydration.
*
* RFC 822 size, flags, arrival date, envelope headers, and the BODYSTRUCTURE
* MIME tree give us everything needed to build an EntityResource without
* downloading the full message body.
* Default IMAP FETCH data items used for message hydration
*/
private const DEFAULT_FETCH_ITEMS = [
'FLAGS',
@@ -57,13 +50,11 @@ class RemoteMailService
];
public function __construct(
private readonly ImapClientWrapper $client,
private readonly Client $client,
private readonly string $provider,
private readonly string|int $service,
) {}
// ── Collection (mailbox) operations ──────────────────────────────────────
/**
* List all selectable mailboxes on the server.
*
@@ -165,23 +156,22 @@ class RemoteMailService
*/
public function entityList(string $collection, ?IFilter $filter = null, ?IRange $range = null): array
{
// ── Build IMAP SEARCH criteria from filter ────────────────────────────
$criteria = [];
if ($filter !== null) {
foreach ($filter->conditions() as $condition) {
$attribute = $condition['attribute'];
$value = $condition['value'];
$criterion = match ($attribute) {
'seen' => $value ? new SeenCriteria() : new UnseenCriteria(),
'flagged' => $value ? new FlaggedCriteria() : new UnflaggedCriteria(),
'from' => new FromCriteria($value),
'to' => new ToCriteria($value),
'subject' => new SubjectCriteria($value),
'body' => new BodyCriteria($value),
'seen' => $value ? new Seen() : new Unseen(),
'flagged' => $value ? new Flagged() : new Unflagged(),
'from' => new From($value),
'to' => new To($value),
'subject' => new Subject($value),
'body' => new Body($value),
'before' => new Before(new DateTimeImmutable($value)),
'after' => new Since(new DateTimeImmutable($value)),
'min' => new LargerCriteria($value),
'max' => new SmallerCriteria($value),
'min' => new Larger($value),
'max' => new Smaller($value),
default => null,
};
if ($criterion !== null) {
@@ -190,19 +180,14 @@ class RemoteMailService
}
}
// ── Execute IMAP SEARCH (ALL when no criteria) ────────────────────────
$uids = empty($criteria)
? $this->client->searchAll($collection)
: $this->client->searchMessages($collection, $criteria);
$uids = $this->client->searchMessages($collection, $criteria);
if (empty($uids)) {
return [];
}
// ── Sort descending: highest UID (newest) first ───────────────────────
rsort($uids);
// ── Apply RangeTally pagination ───────────────────────────────────────
if ($range instanceof RangeTally) {
$position = (int) $range->getPosition();
$tally = $range->getTally();
@@ -223,11 +208,6 @@ class RemoteMailService
/**
* Fetch one or more messages by UID and return EntityResource objects.
*
* Uses client->fetchMultiple() which streams FetchData responses one at a
* time via sendStreaming — memory-efficient even for large UID sets. Body
* content is NOT pre-loaded; call fetchBody() on the returned resource
* when the decoded body is needed (lazy, one extra round-trip per message).
*
* @param int ...$uids
* @return EntityResource[] keyed by UID
*/
@@ -237,10 +217,11 @@ class RemoteMailService
return [];
}
$this->client->select($collection);
$result = [];
foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection, null);
$resource->fromImap($fetchData, $collection);
$result[$uid] = $resource;
}
return $result;
@@ -252,9 +233,50 @@ class RemoteMailService
return;
}
foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
$this->client->select($collection);
foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection, null);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
/**
* Fetch every message in a mailbox using a single FETCH 1:* command and
* return all EntityResource objects as an array keyed by UID.
*
* Use this for unfiltered, unpaginated listing where a two-round-trip
* SEARCH-then-FETCH approach would be wasteful.
*
* @param string[] $items IMAP fetch data items
* @return EntityResource[] keyed by UID
*/
public function entityFetchAll(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): array
{
$result = [];
foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
$result[$uid] = $resource;
}
return $result;
}
/**
* Stream every message in a mailbox using FETCH 1:*, yielding
* uid => EntityResource as each FETCH response arrives off the socket.
*
* Use this for unfiltered streaming where a SEARCH ALL round-trip would be
* an unnecessary extra RTT.
*
* @param string[] $items IMAP fetch data items
* @return Generator<int, EntityResource>
*/
public function entityFetchAllStream(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): Generator
{
foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
@@ -272,11 +294,12 @@ class RemoteMailService
* @param string[] $items IMAP fetch data items
* @return \Generator<int, EntityResource>
*/
public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): \Generator
public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): Generator
{
foreach ($this->client->fetchMultiple($collection, $uids, $items) as $uid => $fetchData) {
$this->client->select($collection);
foreach ($this->client->streamByUids($uids, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection, null);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
@@ -288,7 +311,7 @@ class RemoteMailService
*/
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
{
return $this->client->append($rawMessage, $collection, $flags);
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
}
/**
@@ -331,45 +354,4 @@ class RemoteMailService
$this->client->copyMessages($collection, array_values($uids), $destination);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Compact a flat array of UIDs into an IMAP sequence-set string.
*
* Consecutive UIDs are collapsed into n:m ranges; non-consecutive UIDs are
* comma-separated. The input does not need to be sorted.
*
* Examples:
* [1, 2, 3, 5, 6, 10] → "1:3,5:6,10"
* [42] → "42"
* [7, 3, 4, 5] → "3:5,7"
*
* @param int[] $uids
*/
private function uidsToRangeSet(array $uids): string
{
if (empty($uids)) {
return '';
}
$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;
}
}
}
return implode(',', $ranges);
}
}

View File

@@ -10,24 +10,25 @@ declare(strict_types=1);
namespace KTXM\ProviderImapMail\Service\Remote;
use Gricob\IMAP\Client;
use KTXC\Server;
use KTXC\Logger\PlainFileLogger;
use KTXM\ProviderImapMail\Providers\Service;
/**
* Static factory for IMAP remote service objects.
*
* - freshClient() → builds a imap client from service config
* - mailService() → constructs a RemoteMailService from the wrapper
* - freshClient() → builds a gricob Client from service config
* - mailService() → constructs a RemoteMailService from the client
*/
class RemoteService
{
/**
* Build a fully-configured imap client from a Service's location and identity.
* Build a fully-configured IMAP client from a Service's location and identity.
*
* Handles STARTTLS: connects on plain TCP, sends STARTTLS, upgrades to TLS,
* then authenticates — all before returning the wrapper.
* then authenticates — all before returning the client.
*/
public static function freshClient(Service $service, string $logDir): ImapClientWrapper
public static function freshClient(Service $service): Client
{
$location = $service->getLocation();
$identity = $service->getIdentity();
@@ -35,6 +36,7 @@ class RemoteService
// Build a file logger when debug mode is enabled, otherwise pass null
$logger = null;
if ($service->getDebug()) {
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
$logger = new PlainFileLogger($logDir . '/imap', $service->identifier());
}
@@ -47,17 +49,17 @@ class RemoteService
$client->logIn($identity->getIdentity(), $identity->getSecret());
return new ImapClientWrapper($client);
return $client;
}
/**
* Build a RemoteMailService from a Service and a pre-authenticated wrapper.
* Build a RemoteMailService from a Service and a pre-authenticated client.
*
* The provider identifier and service ID are taken directly from the Service
* object so the caller does not have to repeat them.
*/
public static function mailService(Service $service, ImapClientWrapper $wrapper): RemoteMailService
public static function mailService(Service $service, Client $client): RemoteMailService
{
return new RemoteMailService($wrapper, $service->provider(), $service->identifier());
return new RemoteMailService($client, $service->provider(), $service->identifier());
}
}