generated from Nodarx/template
feat: speed improvements
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit is contained in:
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -27,6 +28,7 @@ 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;
|
||||
@@ -238,6 +240,43 @@ class Client
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
@@ -378,6 +417,75 @@ class Client
|
||||
$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
|
||||
*/
|
||||
@@ -412,7 +520,7 @@ class Client
|
||||
public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array
|
||||
{
|
||||
$response = $this->send(
|
||||
new Protocol\Command\SearchCommand(
|
||||
new SearchCommand(
|
||||
$this->configuration->useUid,
|
||||
...$criteria
|
||||
)
|
||||
|
||||
15
lib/Client/Protocol/Command/Argument/Search/Body.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/Body.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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) . '"';
|
||||
}
|
||||
}
|
||||
13
lib/Client/Protocol/Command/Argument/Search/Flagged.php
Normal file
13
lib/Client/Protocol/Command/Argument/Search/Flagged.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Flagged implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'FLAGGED';
|
||||
}
|
||||
}
|
||||
15
lib/Client/Protocol/Command/Argument/Search/From.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/From.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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) . '"';
|
||||
}
|
||||
}
|
||||
15
lib/Client/Protocol/Command/Argument/Search/Larger.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/Larger.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
13
lib/Client/Protocol/Command/Argument/Search/Seen.php
Normal file
13
lib/Client/Protocol/Command/Argument/Search/Seen.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Seen implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'SEEN';
|
||||
}
|
||||
}
|
||||
15
lib/Client/Protocol/Command/Argument/Search/Smaller.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/Smaller.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
15
lib/Client/Protocol/Command/Argument/Search/Subject.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/Subject.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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) . '"';
|
||||
}
|
||||
}
|
||||
15
lib/Client/Protocol/Command/Argument/Search/To.php
Normal file
15
lib/Client/Protocol/Command/Argument/Search/To.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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) . '"';
|
||||
}
|
||||
}
|
||||
13
lib/Client/Protocol/Command/Argument/Search/Unflagged.php
Normal file
13
lib/Client/Protocol/Command/Argument/Search/Unflagged.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Unflagged implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'UNFLAGGED';
|
||||
}
|
||||
}
|
||||
13
lib/Client/Protocol/Command/Argument/Search/Unseen.php
Normal file
13
lib/Client/Protocol/Command/Argument/Search/Unseen.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Gricob\IMAP\Protocol\Command\Argument\Search;
|
||||
|
||||
final readonly class Unseen implements Criteria
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'UNSEEN';
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,56 @@ final class SequenceSet implements Argument
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Gricob\IMAP\Protocol\Response\Parser;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Lexer\Token;
|
||||
use Gricob\IMAP\Mime\Part\Body;
|
||||
use Gricob\IMAP\Protocol\Response\Line\CommandContinuation;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\CapabilityData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\ExistsData;
|
||||
@@ -11,6 +12,9 @@ use Gricob\IMAP\Protocol\Response\Line\Data\ExpungeData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Address;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodySection;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FlagsData;
|
||||
@@ -353,7 +357,7 @@ readonly class Parser
|
||||
$this->space();
|
||||
$text = $this->literal();
|
||||
|
||||
$bodySections[] = new BodySection($section, $text);
|
||||
$bodySections = $this->fetchBody($bodyStructure, $text);
|
||||
}
|
||||
break;
|
||||
case TokenType::ENVELOPE:
|
||||
@@ -384,6 +388,145 @@ readonly class Parser
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BodySection[]
|
||||
*/
|
||||
private function fetchBody(?BodyStructure $node, string $data): array {
|
||||
return $this->fetchBodyNode($node->part, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BodySection[]
|
||||
*/
|
||||
private function fetchBodyNode(?Part $node, string $data, string $partId = ''): array {
|
||||
if ($node instanceof MultiPart) {
|
||||
return $this->fetchBodyMultipart($node, $data, $partId);
|
||||
}
|
||||
|
||||
if ($node instanceof SinglePart) {
|
||||
return [$this->fetchBodySinglePart($data, $partId)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BodySection
|
||||
*/
|
||||
private function fetchBodySinglePart(string $data, string $partId = ''): BodySection
|
||||
{
|
||||
$partId = empty($partId) ? '1' : $partId;
|
||||
return new BodySection($partId, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BodySection[]
|
||||
*/
|
||||
private function fetchBodyMultipart(MultiPart $structure, string $data, string $partId = ''): array
|
||||
{
|
||||
$boundary = null;
|
||||
foreach ($structure->attributes as $key => $value) {
|
||||
if (strtolower($key) === 'boundary') {
|
||||
$boundary = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($boundary === null) {
|
||||
throw new \RuntimeException('Multipart missing boundary attribute');
|
||||
}
|
||||
|
||||
$chunks = $this->splitOnBoundary($data, $boundary);
|
||||
|
||||
$parts = [];
|
||||
foreach ($structure->parts as $i => $childStructure) {
|
||||
$chunk = $chunks[$i] ?? '';
|
||||
$chunk = $this->stripPartHeaders($chunk);
|
||||
$id = empty($partId) ? (string)($i + 1) : $partId . '.' . ($i + 1);
|
||||
$parts = array_merge($parts, $this->fetchBodyNode($childStructure, $chunk, $id));
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split $raw on MIME boundary delimiter lines, returning one string per
|
||||
* body part. The preamble (before the first delimiter) and epilogue
|
||||
* (after the close delimiter) are discarded.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function splitOnBoundary(string $raw, string $boundary): array
|
||||
{
|
||||
$delimiter = '--' . $boundary;
|
||||
$closeDelimiter = '--' . $boundary . '--';
|
||||
|
||||
$parts = [];
|
||||
$current = null;
|
||||
|
||||
// Handle both CRLF and bare-LF line endings
|
||||
$lines = preg_split('/\r?\n/', $raw);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = rtrim($line);
|
||||
|
||||
if ($trimmed === $closeDelimiter) {
|
||||
if ($current !== null) {
|
||||
$parts[] = rtrim($current, "\r\n");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($trimmed === $delimiter) {
|
||||
if ($current !== null) {
|
||||
$parts[] = rtrim($current, "\r\n");
|
||||
}
|
||||
$current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($current !== null) {
|
||||
$current .= $line . "\r\n";
|
||||
}
|
||||
// Lines before the first delimiter are preamble — ignored
|
||||
}
|
||||
|
||||
// If the close delimiter was absent, flush whatever is buffered
|
||||
if ($current !== null && $current !== '') {
|
||||
$trimmed = rtrim($current, "\r\n");
|
||||
if (!in_array($trimmed, $parts, true)) {
|
||||
$parts[] = $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip MIME part headers from a body chunk.
|
||||
*
|
||||
* Each part chunk begins with its own headers (Content-Type,
|
||||
* Content-Transfer-Encoding, etc.) followed by a blank line.
|
||||
* Since BODYSTRUCTURE already supplies all encoding/charset info,
|
||||
* we discard the part headers and return the raw body bytes only.
|
||||
*/
|
||||
private function stripPartHeaders(string $raw): string
|
||||
{
|
||||
// Try CRLF blank line first, then bare LF
|
||||
$crlfPos = strpos($raw, "\r\n\r\n");
|
||||
$lfPos = strpos($raw, "\n\n");
|
||||
|
||||
if ($crlfPos !== false && ($lfPos === false || $crlfPos <= $lfPos)) {
|
||||
return substr($raw, $crlfPos + 4);
|
||||
}
|
||||
|
||||
if ($lfPos !== false) {
|
||||
return substr($raw, $lfPos + 2);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ParseError
|
||||
*/
|
||||
@@ -1005,13 +1148,48 @@ readonly class Parser
|
||||
return $raw;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < strlen($raw); $i++) {
|
||||
$character = $raw[$i];
|
||||
if (!mb_check_encoding($character, 'US-ASCII')) {
|
||||
$raw[$i] = ' ';
|
||||
$result = '';
|
||||
$pos = 0;
|
||||
$len = strlen($raw);
|
||||
|
||||
while ($pos < $len) {
|
||||
if (preg_match('/\{(\d+)\}\r\n/', $raw, $m, PREG_OFFSET_CAPTURE, $pos)) {
|
||||
$braceOff = (int) $m[0][1];
|
||||
$literalLen = (int) $m[1][0];
|
||||
$headerLen = strlen($m[0][0]);
|
||||
|
||||
// Sanitize structural text that precedes this literal
|
||||
$result .= $this->sanitizeChunk(substr($raw, $pos, $braceOff - $pos));
|
||||
|
||||
// Preserve the {N}\r\n marker verbatim
|
||||
$result .= $m[0][0];
|
||||
|
||||
// Preserve the literal body bytes verbatim (may be UTF-8 / 8-bit)
|
||||
$result .= substr($raw, $braceOff + $headerLen, $literalLen);
|
||||
|
||||
$pos = $braceOff + $headerLen + $literalLen;
|
||||
} else {
|
||||
// No more literals — sanitize the remainder
|
||||
$result .= $this->sanitizeChunk(substr($raw, $pos));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $raw;
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function sanitizeChunk(string $chunk): string
|
||||
{
|
||||
if (mb_check_encoding($chunk, 'US-ASCII')) {
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
for ($i = 0, $len = strlen($chunk); $i < $len; $i++) {
|
||||
if (!mb_check_encoding($chunk[$i], 'US-ASCII')) {
|
||||
$chunk[$i] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
return $chunk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,11 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
|
||||
*/
|
||||
public function fromImap(Mailbox $mailbox): static
|
||||
{
|
||||
$this->data['label'] = $mailbox->name;
|
||||
$this->data['delimiter'] = $mailbox->hierarchyDelimiter;
|
||||
$delimiter = $mailbox->hierarchyDelimiter;
|
||||
$this->data['label'] = ($delimiter !== '' && str_contains($mailbox->name, $delimiter))
|
||||
? substr($mailbox->name, strrpos($mailbox->name, $delimiter) + strlen($delimiter))
|
||||
: $mailbox->name;
|
||||
$this->data['delimiter'] = $delimiter;
|
||||
$this->data['attributes'] = $mailbox->nameAttributes;
|
||||
$this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true);
|
||||
$this->data['total'] = 0;
|
||||
|
||||
@@ -31,9 +31,8 @@ class EntityResource extends EntityMutableAbstract {
|
||||
*
|
||||
* @param FetchData $fetchData result from IMAP FETCH command
|
||||
* @param string $mailbox IMAP mailbox name (used as collection)
|
||||
* @param Part|null $bodyPart MIME Part tree for body content (optional)
|
||||
*/
|
||||
public function fromImap(FetchData $fetchData, string $mailbox, ?Part $bodyPart = null): static {
|
||||
public function fromImap(FetchData $fetchData, string $mailbox): static {
|
||||
|
||||
// Collection = the IMAP mailbox name
|
||||
$this->data['collection'] = $mailbox;
|
||||
@@ -46,13 +45,7 @@ class EntityResource extends EntityMutableAbstract {
|
||||
$this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
$this->getProperties()->fromImap(
|
||||
flags: $fetchData->flags ?? [],
|
||||
envelope: $fetchData->envelope,
|
||||
bodyStructure: $fetchData->bodyStructure,
|
||||
size: $fetchData->rfc822Size ?? 0,
|
||||
bodyPart: $bodyPart,
|
||||
);
|
||||
$this->getProperties()->fromImap($fetchData);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -93,8 +93,10 @@ class MessagePart extends MessagePartMutableAbstract {
|
||||
}
|
||||
|
||||
// Recursively process sub-parts
|
||||
// When this part has no section ID (root multipart) children are
|
||||
// numbered "1", "2", … to match IMAP section numbering.
|
||||
foreach ($part->parts as $index => $subPart) {
|
||||
$subPartId = $partId . '.' . ($index + 1);
|
||||
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
|
||||
$this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId);
|
||||
}
|
||||
}
|
||||
@@ -135,42 +137,49 @@ class MessagePart extends MessagePartMutableAbstract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject decoded body content from a parallel gricob Mime Part tree.
|
||||
* Inject decoded body content from a map of IMAP section-ID → raw encoded text.
|
||||
*
|
||||
* Walks the gricob Mime Part tree alongside this MessagePart tree and
|
||||
* sets 'content' on each leaf single-part node from its decoded body.
|
||||
* Walks the MessagePart tree recursively. For each text/* leaf part whose
|
||||
* partId is present in $sectionMap the raw text is decoded according to the
|
||||
* part's Content-Transfer-Encoding and converted to UTF-8 before being
|
||||
* stored in 'content'. Binary parts (images, PDFs, …) are skipped.
|
||||
*
|
||||
* @param \Gricob\IMAP\Mime\Part\Part $mimePart Corresponding gricob Mime Part node
|
||||
* @param array<string,string> $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2");
|
||||
* Values: raw (transfer-encoded) body text
|
||||
*/
|
||||
public function injectBodyContent(\Gricob\IMAP\Mime\Part\Part $mimePart): void
|
||||
public function injectSections(array $sectionMap): void
|
||||
{
|
||||
if ($mimePart instanceof \Gricob\IMAP\Mime\Part\MultiPart) {
|
||||
foreach ($mimePart->parts as $index => $childMimePart) {
|
||||
$childPart = $this->parts[$index] ?? null;
|
||||
// MultiPart: recurse into children
|
||||
if (!empty($this->parts)) {
|
||||
foreach ($this->parts as $childPart) {
|
||||
if ($childPart instanceof MessagePart) {
|
||||
$childPart->injectBodyContent($childMimePart);
|
||||
$childPart->injectSections($sectionMap);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($mimePart instanceof \Gricob\IMAP\Mime\Part\SinglePart) {
|
||||
// Only inject content for text/* parts; binary parts (images, PDFs, …)
|
||||
// produce raw bytes that cannot be JSON-encoded as UTF-8 strings.
|
||||
$type = strtolower($this->data['type'] ?? '');
|
||||
if (!str_starts_with($type, 'text/')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$decoded = $mimePart->decodedBody();
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
if ($decoded !== null && $decoded !== '') {
|
||||
$charset = $mimePart->charset() ?? 'utf-8';
|
||||
$this->data['content'] = MessageProperties::toUtf8($decoded, $charset);
|
||||
}
|
||||
// SinglePart: only inject decoded content for text/* MIME types
|
||||
$type = strtolower($this->data['type'] ?? '');
|
||||
if (!str_starts_with($type, 'text/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$partId = $this->data['partId'] ?? null;
|
||||
if ($partId === null || !array_key_exists($partId, $sectionMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = $sectionMap[$partId];
|
||||
$encoding = strtolower($this->data['encoding'] ?? '7bit');
|
||||
$decoded = match ($encoding) {
|
||||
'quoted-printable' => quoted_printable_decode($raw),
|
||||
'base64' => base64_decode($raw, strict: false),
|
||||
default => $raw, // 7bit, 8bit, binary
|
||||
};
|
||||
|
||||
$charset = $this->data['charset'] ?? 'us-ascii';
|
||||
$this->data['content'] = MessageProperties::toUtf8($decoded, $charset);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,12 +11,10 @@ namespace KTXM\ProviderImapMail\Providers;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Gricob\IMAP\Mime\Part\Part as MimePart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableAbstract;
|
||||
|
||||
/**
|
||||
@@ -27,25 +25,16 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
/**
|
||||
* Convert IMAP data to mail message properties object
|
||||
*
|
||||
* @param array $flags IMAP flags (e.g. ['\Seen', '\Flagged', ...])
|
||||
* @param ?Envelope $envelope parsed envelope from gricob
|
||||
* @param ?BodyStructure $bodyStructure parsed body structure from gricob
|
||||
* @param int $size RFC822.SIZE byte count
|
||||
* @param FetchData $fetchData result from IMAP FETCH command
|
||||
*/
|
||||
public function fromImap(
|
||||
array $flags,
|
||||
?Envelope $envelope,
|
||||
?BodyStructure $bodyStructure,
|
||||
int $size = 0,
|
||||
?MimePart $bodyPart = null,
|
||||
): static {
|
||||
public function fromImap(FetchData $fetchData): static {
|
||||
|
||||
// ── Size ──────────────────────────────────────────────────────
|
||||
$this->data['size'] = $size;
|
||||
$this->data['size'] = $fetchData->rfc822Size ?? 0;
|
||||
|
||||
// ── Flags ─────────────────────────────────────────────────────
|
||||
$this->data['flags'] = [];
|
||||
foreach ($flags as $flag) {
|
||||
foreach ($fetchData->flags ?? [] as $flag) {
|
||||
$flag = ltrim($flag, '\\');
|
||||
$normalized = match (strtolower($flag)) {
|
||||
'seen' => 'read',
|
||||
@@ -59,7 +48,8 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
}
|
||||
|
||||
// ── Envelope ──────────────────────────────────────────────────
|
||||
if ($envelope !== null) {
|
||||
if ($fetchData->envelope !== null) {
|
||||
$envelope = $fetchData->envelope;
|
||||
|
||||
if ($envelope->messageId !== null) {
|
||||
$this->data['urid'] = trim($envelope->messageId, '<>');
|
||||
@@ -114,19 +104,28 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
}
|
||||
|
||||
// ── Body Structure ────────────────────────────────────────────
|
||||
if ($bodyStructure !== null) {
|
||||
$rootPart = (new MessagePart())->fromImap($bodyStructure->part, '1');
|
||||
if ($fetchData->bodyStructure !== null) {
|
||||
$bodyStructure = $fetchData->bodyStructure;
|
||||
// Root multipart containers have no fetchable section ID; their
|
||||
// children are numbered "1", "2", … to match IMAP section IDs.
|
||||
$isRootMultipart = $bodyStructure->part instanceof MultiPart;
|
||||
$rootPartId = $isRootMultipart ? '' : '1';
|
||||
$rootPart = (new MessagePart())->fromImap($bodyStructure->part, $rootPartId);
|
||||
|
||||
// ── Body Content: inject decoded content onto part nodes ──────
|
||||
if ($bodyPart !== null) {
|
||||
$rootPart->injectBodyContent($bodyPart);
|
||||
if (!empty($fetchData->bodySections)) {
|
||||
$sectionMap = [];
|
||||
foreach ($fetchData->bodySections as $bs) {
|
||||
$sectionMap[$bs->section] = $bs->text;
|
||||
}
|
||||
$rootPart->injectSections($sectionMap);
|
||||
}
|
||||
|
||||
$this->data['body'] = $rootPart->toStore();
|
||||
|
||||
// Collect attachments: non-body parts with name or attachment disposition
|
||||
$attachments = [];
|
||||
self::collectAttachments($bodyStructure->part, '1', $attachments);
|
||||
self::collectAttachments($bodyStructure->part, $rootPartId, $attachments);
|
||||
if (!empty($attachments)) {
|
||||
$this->data['attachments'] = $attachments;
|
||||
}
|
||||
@@ -198,7 +197,8 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
}
|
||||
} elseif ($part instanceof MultiPart) {
|
||||
foreach ($part->parts as $index => $subPart) {
|
||||
self::collectAttachments($subPart, $partId . '.' . ($index + 1), $attachments);
|
||||
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
|
||||
self::collectAttachments($subPart, $subPartId, $attachments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +445,12 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
// Unfiltered + unpaginated: skip the SEARCH round-trip and use FETCH 1:*
|
||||
if ($filter === null && $range === null) {
|
||||
return $this->mailService->entityFetchAll((string) $collection);
|
||||
}
|
||||
|
||||
// Filtered or paginated: SEARCH to get a UID list, then FETCH by UIDs
|
||||
$uids = $this->mailService->entityList((string) $collection, $filter, $range);
|
||||
if (empty($uids)) {
|
||||
return [];
|
||||
@@ -457,6 +463,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
// Unfiltered: skip the SEARCH round-trip and stream via FETCH 1:*
|
||||
if ($filter === null) {
|
||||
yield from $this->mailService->entityFetchAllStream((string) $collection);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filtered: SEARCH for matching UIDs then stream only those messages
|
||||
$uids = $this->mailService->entityList((string) $collection, $filter, $range);
|
||||
if (empty($uids)) {
|
||||
return;
|
||||
@@ -483,10 +496,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta sync is not supported for IMAP (no CONDSTORE/QRESYNC initially).
|
||||
* Returns an empty Delta so callers detect the absence of changes gracefully.
|
||||
*/
|
||||
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
||||
{
|
||||
return new Delta(signature: $signature);
|
||||
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user