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

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -445,11 +445,17 @@ 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 [];
}
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
@@ -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);