From 9cdebd82b86ebff18b7d6f3a6ee7a0186d828a29 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 23 May 2026 20:18:58 -0400 Subject: [PATCH] feat: implement download Signed-off-by: Sebastian Krupinski --- lib/Client/Client.php | 11 +- lib/Client/ClientInterface.php | 8 ++ lib/Client/FetchOptions.php | 5 + lib/Client/Message.php | 5 + lib/Client/MessageParser.php | 3 - lib/Client/MessagePart.php | 3 +- lib/Client/Protocol/CommandExecutor.php | 38 ++++++- lib/Client/Protocol/ProtocolReader.php | 77 +++++++++++-- lib/Client/Transport/ConnectionInterface.php | 8 ++ lib/Client/Transport/SocketConnection.php | 20 ++++ lib/Providers/MessageAttachment.php | 4 +- lib/Providers/MessagePart.php | 84 +------------- lib/Providers/Provider.php | 31 +---- lib/Providers/Service.php | 99 ++++++++-------- lib/Service/Remote/RemoteMailService.php | 112 +++++++++++++++++++ 15 files changed, 336 insertions(+), 172 deletions(-) diff --git a/lib/Client/Client.php b/lib/Client/Client.php index aa55f64..deda273 100644 --- a/lib/Client/Client.php +++ b/lib/Client/Client.php @@ -6,8 +6,8 @@ namespace KTXM\ProviderImap\Client; use KTXM\ProviderImap\Client\Command\CapabilityCommand; use KTXM\ProviderImap\Client\Command\CommandInterface; +use KTXM\ProviderImap\Client\FetchTarget; use KTXM\ProviderImap\Client\Command\LoginCommand; -use KTXM\ProviderImap\Client\Command\StatusCommand; use KTXM\ProviderImap\Client\Command\StartTlsCommand; use KTXM\ProviderImap\Client\Protocol\CommandExecutor; use KTXM\ProviderImap\Client\Protocol\ProtocolReader; @@ -87,6 +87,15 @@ final class Client implements ClientInterface return $this->executor->perform($command, $this->session); } + public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator + { + if ($this->session === null || $this->executor === null) { + throw new ImapException('IMAP client is not connected.'); + } + + return $this->executor->download($target, $section, $chunkSize, $this->session); + } + public function session(): SessionContext { if ($this->session === null) { diff --git a/lib/Client/ClientInterface.php b/lib/Client/ClientInterface.php index 9831d9d..eb69753 100644 --- a/lib/Client/ClientInterface.php +++ b/lib/Client/ClientInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Client; use KTXM\ProviderImap\Client\Command\CommandInterface; +use KTXM\ProviderImap\Client\FetchTarget; interface ClientInterface { @@ -21,4 +22,11 @@ interface ClientInterface * @return TResult */ public function perform(CommandInterface $command): mixed; + + /** + * Stream the raw bytes of a single IMAP BODY section without buffering. + * + * @return \Generator raw (transfer-encoded) bytes from the socket + */ + public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator; } \ No newline at end of file diff --git a/lib/Client/FetchOptions.php b/lib/Client/FetchOptions.php index ed46cb4..02e2061 100644 --- a/lib/Client/FetchOptions.php +++ b/lib/Client/FetchOptions.php @@ -89,6 +89,11 @@ final class FetchOptions return $this->with('BODY[TEXT]'); } + public function withBody(): self + { + return $this->with('BODY[]'); + } + public function withBodySection(string $section): self { $section = strtoupper(trim($section)); diff --git a/lib/Client/Message.php b/lib/Client/Message.php index 7694c09..9bf362c 100644 --- a/lib/Client/Message.php +++ b/lib/Client/Message.php @@ -156,6 +156,11 @@ final class Message return $this->bodySections; } + public function bodyRaw(): ?string + { + return $this->bodySections[''] ?? null; + } + /** * @param array $bodySections */ diff --git a/lib/Client/MessageParser.php b/lib/Client/MessageParser.php index c264020..1e8970d 100644 --- a/lib/Client/MessageParser.php +++ b/lib/Client/MessageParser.php @@ -524,9 +524,6 @@ final class MessageParser } $section = strtoupper(trim($matches[1])); - if ($section === '') { - continue; - } if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) { $section = $partMatches[1]; diff --git a/lib/Client/MessagePart.php b/lib/Client/MessagePart.php index 5f6c4ae..ab1f608 100644 --- a/lib/Client/MessagePart.php +++ b/lib/Client/MessagePart.php @@ -167,8 +167,9 @@ final class MessagePart { $data = [ 'partId' => $this->partId, + 'blobId' => $this->partId, + 'cId' => $this->contentId, 'type' => $this->mimeType, - 'blobId' => $this->contentId, 'charset' => $this->parameters['charset'] ?? null, 'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null, 'encoding' => $this->encoding, diff --git a/lib/Client/Protocol/CommandExecutor.php b/lib/Client/Protocol/CommandExecutor.php index 071a2cf..a84cf69 100644 --- a/lib/Client/Protocol/CommandExecutor.php +++ b/lib/Client/Protocol/CommandExecutor.php @@ -6,9 +6,11 @@ namespace KTXM\ProviderImap\Client\Protocol; use Generator; use KTXM\ProviderImap\Client\Command\CommandInterface; +use KTXM\ProviderImap\Client\FetchTarget; use KTXM\ProviderImap\Client\ImapException; use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse; use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse; +use KTXM\ProviderImap\Client\Protocol\RequestFrame; use KTXM\ProviderImap\Client\SessionContext; use KTXM\ProviderImap\Client\SessionState; use Psr\Log\LoggerInterface; @@ -41,10 +43,42 @@ final class CommandExecutor $this->writer->write($tag, $frame); return $command->handle(new ResponseStream(function () use ($tag, $context): Generator { - yield from $this->responsesUntilCompletion($tag, $context); + yield from $this->processPerform($tag, $context); }), $context); } + /** + * Stream the raw bytes of a single IMAP BODY section without buffering. + * + * Sends a UID FETCH for the given section and yields the literal bytes + * directly from the socket in chunks, never assembling a full string. + * The caller MUST fully exhaust the returned Generator before issuing + * any further IMAP commands. + * + * @return \Generator raw (transfer-encoded) bytes from the socket + */ + public function download(FetchTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator + { + $this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)'); + + $tag = $this->tags->next(); + $this->writer->write($tag, new RequestFrame(sprintf( + 'UID FETCH %s (UID BODY[%s])', + $target->sequenceSet()->toCommand(), + $section, + ))); + + $result = $this->reader->readUntilFetchLiteral($tag); + + if ($result === null) { + return; // UID not found or empty FETCH result + } + + yield from $this->reader->streamLiteral($result['literalLength'], $chunkSize); + + $this->reader->readToEnd($tag); + } + /** * @param list $allowedStates */ @@ -63,7 +97,7 @@ final class CommandExecutor )); } - private function responsesUntilCompletion(string $tag, SessionContext $context): Generator + private function processPerform(string $tag, SessionContext $context): Generator { while (true) { $response = $this->reader->readResponse(); diff --git a/lib/Client/Protocol/ProtocolReader.php b/lib/Client/Protocol/ProtocolReader.php index e908ecf..e71845d 100644 --- a/lib/Client/Protocol/ProtocolReader.php +++ b/lib/Client/Protocol/ProtocolReader.php @@ -42,7 +42,14 @@ final class ProtocolReader public function readResponse(): ResponseInterface { - $raw = $this->readRawResponse(); + $raw = $this->connection->readLine(); + + while (($literalLength = $this->trailingLiteralLength($raw)) !== null) { + $raw .= $this->connection->readBytes($literalLength); + $raw .= $this->connection->readLine(); + } + + $raw = $this->trimTrailingLineEnding($raw); if ($raw === '') { throw new ImapException('Received empty IMAP response line.'); @@ -85,16 +92,72 @@ final class ProtocolReader return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw); } - private function readRawResponse(): string + /** + * Read responses until an untagged FETCH response containing a literal marker is found, + * returning the literal byte count WITHOUT consuming the literal bytes from the socket. + * Returns null if the tagged OK/NO/BAD for $tag arrives before any literal is detected. + * + * ⚠️ After a non-null return the literal bytes MUST be consumed (via streamLiteral()) + * before any further reads are made on this reader. + * + * @return array{literalLength: int, prefixLine: string}|null + */ + public function readUntilFetchLiteral(string $tag): ?array { - $raw = $this->connection->readLine(); + while (true) { + $line = $this->connection->readLine(); + $trimmed = $this->trimTrailingLineEnding($line); - while (($literalLength = $this->trailingLiteralLength($raw)) !== null) { - $raw .= $this->connection->readBytes($literalLength); - $raw .= $this->connection->readLine(); + // Literal marker at the end of the line — stop before consuming the bytes + $literalLength = $this->trailingLiteralLength($line); + if ($literalLength !== null) { + return ['literalLength' => $literalLength, 'prefixLine' => $trimmed]; + } + + // Tagged completion for our command + if (str_starts_with($trimmed, $tag . ' ')) { + $parts = preg_split('/\s+/', $trimmed, 3) ?: []; + $status = strtoupper($parts[1] ?? ''); + if ($status === 'NO' || $status === 'BAD') { + throw new ImapException(sprintf('FETCH command failed: %s', $trimmed)); + } + return null; // Tagged OK without ever finding a literal → UID not found + } + + // Any other untagged response — discard and continue } + } - return $this->trimTrailingLineEnding($raw); + public function readToEnd(string $tag): TaggedResponse + { + while (true) { + $response = $this->readResponse(); + + if ($response instanceof TaggedResponse && $response->tag() === $tag) { + if (!$response->isOk()) { + throw new ImapException(sprintf('FETCH failed: %s', $response->text())); + } + return $response; + } + } + } + + /** + * Yield the literal bytes already waiting in the socket as chunks. + * After the generator is fully exhausted this method reads the one trailing + * line that closes the FETCH parenthesised response (e.g. ")\r\n"). + * + * Contract: the caller MUST exhaust this generator before issuing any further + * reads on this reader. + * + * @return \Generator + */ + public function streamLiteral(int $length, int $chunkSize = 8192): \Generator + { + yield from $this->connection->readBytesChunked($length, $chunkSize); + + // Consume the closing portion of the FETCH parenthesised list (e.g. ")\r\n") + $this->connection->readLine(); } private function trailingLiteralLength(string $raw): ?int diff --git a/lib/Client/Transport/ConnectionInterface.php b/lib/Client/Transport/ConnectionInterface.php index 8cde93a..5a0a856 100644 --- a/lib/Client/Transport/ConnectionInterface.php +++ b/lib/Client/Transport/ConnectionInterface.php @@ -20,5 +20,13 @@ interface ConnectionInterface public function readBytes(int $length): string; + /** + * Yield the literal payload in chunks without buffering the full content. + * Reads exactly $length bytes from the socket, never crossing the literal boundary. + * + * @return \Generator + */ + public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator; + public function upgradeToTls(): void; } \ No newline at end of file diff --git a/lib/Client/Transport/SocketConnection.php b/lib/Client/Transport/SocketConnection.php index 61c18df..4cc4c5a 100644 --- a/lib/Client/Transport/SocketConnection.php +++ b/lib/Client/Transport/SocketConnection.php @@ -135,6 +135,26 @@ final class SocketConnection implements ConnectionInterface return $buffer; } + public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator + { + if ($length < 0) { + throw new ImapException('IMAP socket cannot read a negative number of bytes.'); + } + + $remaining = $length; + + while ($remaining > 0) { + $chunk = fread($this->stream(), min($chunkSize, $remaining)); + + if ($chunk === false || $chunk === '') { + throw new ImapException('Failed to read literal payload from IMAP socket.'); + } + + $remaining -= strlen($chunk); + yield $chunk; + } + } + public function upgradeToTls(): void { $stream = $this->stream(); diff --git a/lib/Providers/MessageAttachment.php b/lib/Providers/MessageAttachment.php index 2309829..f504973 100644 --- a/lib/Providers/MessageAttachment.php +++ b/lib/Providers/MessageAttachment.php @@ -9,12 +9,14 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; +use KTXF\Mail\Object\MessagePartInterface; + /** * Mail Attachment Object * * @since 1.0.0 */ -class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface { +class MessageAttachment implements MessagePartInterface { protected MessagePart $_meta; protected ?string $_contents = null; diff --git a/lib/Providers/MessagePart.php b/lib/Providers/MessagePart.php index cc9654c..616e6e6 100644 --- a/lib/Providers/MessagePart.php +++ b/lib/Providers/MessagePart.php @@ -9,10 +9,8 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; -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 KTXF\Mail\Object\MessagePartMutableAbstract; +use KTXM\ProviderImap\Client\MessagePart as ImapMessagePart; /** * Mail Message Part Implementation @@ -25,7 +23,7 @@ class MessagePart extends MessagePartMutableAbstract { * @param Part $part gricob BodyStructure Part (SinglePart or MultiPart) * @param string $partId numeric part identifier (e.g. "1", "1.1", "2") */ - public function fromImap(Part $part, string $partId = '1'): static { + public function fromImap(ImapMessagePart $part, string $partId = '1'): static { $this->data['partId'] = $partId; @@ -104,82 +102,4 @@ class MessagePart extends MessagePartMutableAbstract { return $this; } - /** - * Convert message part to store array - */ - public function toStore(): array { - $data = $this->data; - if (count($this->parts) > 0) { - $data['subParts'] = []; - foreach ($this->parts as $subPart) { - if ($subPart instanceof MessagePart) { - $data['subParts'][] = $subPart->toStore(); - } - } - } else { - $data['subParts'] = null; - } - return $data; - } - - /** - * Hydrate message part from store array - */ - public function fromStore(array $data): static { - if (isset($data['subParts']) && is_array($data['subParts'])) { - foreach ($data['subParts'] as $subPart) { - $this->parts[] = (new MessagePart())->fromStore($subPart); - } - unset($data['subParts']); - } - $this->data = $data; - return $this; - } - - /** - * Inject decoded body content from a map of IMAP section-ID → raw encoded text. - * - * 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 array $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2"); - * Values: raw (transfer-encoded) body text - */ - public function injectSections(array $sectionMap): void - { - // MultiPart: recurse into children - if (!empty($this->parts)) { - foreach ($this->parts as $childPart) { - if ($childPart instanceof MessagePart) { - $childPart->injectSections($sectionMap); - } - } - return; - } - - // 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); - } - } diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index a572230..27312d7 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -194,7 +194,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, // Attempt to authenticate and list mailboxes as a connectivity check $client = RemoteService::freshClient($service); $service = RemoteService::mailService($service, $client); - $mailboxes = $service->collectionList(); + $mailboxes = iterator_to_array($service->collectionList()); $latency = (int) round((microtime(true) - $startTime) * 1000); @@ -205,36 +205,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, . ' (Latency: ' . $latency . ' ms)', ]; } catch (\Throwable $e) { - $latency = (int) round((microtime(true) - $startTime) * 1000); - - $location = ($service instanceof Service) ? $service->getLocation() : null; - $target = $location - ? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort() - : 'unknown host'; - - // stream_socket_client errors are suppressed with @ in gricob — recover them - $phpError = error_get_last(); - $detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? ''); - - if ($detail === '' && $location !== null) { - $host = $location->getHost(); - if ($host !== '' && gethostbyname($host) === $host) { - $detail = "hostname '{$host}' could not be resolved"; - } else { - $detail = 'connection refused or timed out — check port and encryption settings'; - } - } elseif ($detail === '') { - $detail = 'no details — check host, port, and encryption settings'; - } - return [ 'success' => false, - 'message' => sprintf( - 'Connection to %s failed (%s): %s', - $target, - (new \ReflectionClass($e))->getShortName(), - $detail, - ), + 'message' => 'Test failed: ' . $e->getMessage(), ]; } } diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index 43252e5..2b4c560 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -19,6 +19,7 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Mail\Service\DownloadResult; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Delta\Delta; @@ -67,7 +68,10 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1', self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1', ], - self::CAPABILITY_COLLECTION_LIST_SORT => [], + self::CAPABILITY_COLLECTION_LIST_SORT => [ + self::CAPABILITY_COLLECTION_SORT_LABEL, + self::CAPABILITY_COLLECTION_SORT_RANK, + ], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, @@ -85,10 +89,19 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', ], - self::CAPABILITY_ENTITY_LIST_SORT => [], - self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']], - self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_LIST_SORT => [ + self::CAPABILITY_ENTITY_SORT_FROM, + self::CAPABILITY_ENTITY_SORT_TO, + self::CAPABILITY_ENTITY_SORT_SUBJECT, + self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED, + self::CAPABILITY_ENTITY_SORT_DATE_SENT, + self::CAPABILITY_ENTITY_SORT_SIZE, + ], + self::CAPABILITY_ENTITY_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_CREATE => false, self::CAPABILITY_ENTITY_MODIFY => false, self::CAPABILITY_ENTITY_PATCH => true, @@ -101,8 +114,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC public function __construct() {} - // ── Lazy initialisation ─────────────────────────────────────────────────── - private function initialize(): void { if (!isset($this->mailService)) { @@ -111,31 +122,29 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } } - // ── Store (MongoDB persistence) ─────────────────────────────────────────── - public function toStore(): array { return array_filter([ - 'tid' => $this->serviceTenantId, - 'uid' => $this->serviceUserId, - 'sid' => $this->serviceIdentifier, - 'label' => $this->serviceLabel, - 'enabled' => $this->serviceEnabled, - 'primaryAddress' => $this->primaryAddress, + 'tid' => $this->serviceTenantId, + 'uid' => $this->serviceUserId, + 'sid' => $this->serviceIdentifier, + 'enabled' => $this->serviceEnabled, + 'label' => $this->serviceLabel, + 'primaryAddress' => $this->primaryAddress, 'secondaryAddresses'=> $this->secondaryAddresses, - 'location' => $this->location?->toStore(), - 'identity' => $this->identity?->toStore(), - 'auxiliary' => $this->auxiliary, + 'location' => $this->location?->toStore(), + 'identity' => $this->identity?->toStore(), + 'auxiliary' => $this->auxiliary, ], fn($v) => $v !== null); } public function fromStore(array $data): static { - $this->serviceTenantId = $data['tid'] ?? null; - $this->serviceUserId = $data['uid'] ?? null; + $this->serviceTenantId = $data['tid'] ?? null; + $this->serviceUserId = $data['uid'] ?? null; $this->serviceIdentifier = $data['sid'] ?? null; - $this->serviceLabel = $data['label'] ?? ''; - $this->serviceEnabled = $data['enabled'] ?? false; + $this->serviceLabel = $data['label'] ?? ''; + $this->serviceEnabled = $data['enabled'] ?? false; if (isset($data['primaryAddress'])) { $this->primaryAddress = $data['primaryAddress']; @@ -156,22 +165,20 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $this; } - // ── JSON ────────────────────────────────────────────────────────────────── - public function jsonSerialize(): array { return array_filter([ - self::JSON_PROPERTY_TYPE => self::JSON_TYPE, - self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, - self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, - self::JSON_PROPERTY_LABEL => $this->serviceLabel, - self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, - self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, + self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, + self::JSON_PROPERTY_LABEL => $this->serviceLabel, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, - self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), - self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), - self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, + self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), + self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), + self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, ], fn($v) => $v !== null); } @@ -181,12 +188,12 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } - if (isset($data[self::JSON_PROPERTY_LABEL])) { - $this->setLabel($data[self::JSON_PROPERTY_LABEL]); - } if (isset($data[self::JSON_PROPERTY_ENABLED])) { $this->setEnabled($data[self::JSON_PROPERTY_ENABLED]); } + if (isset($data[self::JSON_PROPERTY_LABEL])) { + $this->setLabel($data[self::JSON_PROPERTY_LABEL]); + } if (isset($data[self::JSON_PROPERTY_LOCATION])) { $this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION])); } @@ -209,8 +216,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $this; } - // ── ServiceBaseInterface ────────────────────────────────────────────────── - public function capable(string $value): bool { return isset($this->serviceAbilities[$value]); @@ -231,8 +236,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $this->serviceIdentifier; } - // ── ServiceMutableInterface ─────────────────────────────────────────────── - public function getLabel(): ?string { return $this->serviceLabel; @@ -295,8 +298,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } - // ── ServiceConfigurableInterface ────────────────────────────────────────── - public function getLocation(): ServiceLocation { return $this->location; @@ -355,8 +356,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $this; } - // ── Collection operations ───────────────────────────────────────────────── - public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); @@ -398,7 +397,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $list; } - public function collectionFetch(string|int $identifier): ?CollectionBaseInterface + public function collectionFetch(string|int $identifier): ?CollectionResource { $this->initialize(); @@ -530,8 +529,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $collection; } - // ── Entity operations ───────────────────────────────────────────────────── - public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { return iterator_to_array($this->entityListStream((string) $collection, $filter, $sort, $range), true); @@ -789,6 +786,16 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $list; } + public function entityDownload(EntityIdentifier $target, array|null $part): DownloadResult { + $this->initialize(); + + $collection = $target->collection(); + $uid = (int) $target->entity(); + $partId = isset($part['partId']) ? (string) $part['partId'] : null; + + return $this->mailService->entityDownload($collection, $uid, $partId); + } + private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array { $list = []; diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 09b2e82..ac041e2 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -13,6 +13,7 @@ use DateTimeImmutable; use Generator; use KTXM\ProviderImap\Client\Client; use KTXM\ProviderImap\Client\Command\FetchManyCommand; +use KTXM\ProviderImap\Client\Command\FetchOneCommand; use KTXM\ProviderImap\Client\Command\ExpungeCommand; use KTXM\ProviderImap\Client\Command\ListCommand; use KTXM\ProviderImap\Client\Command\SearchCommand; @@ -45,6 +46,7 @@ use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; +use KTXF\Mail\Service\DownloadResult; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; @@ -295,6 +297,116 @@ class RemoteMailService } } + /** + * Stream the raw bytes of a message or a specific MIME part without buffering. + * + * When $partId is given, first fetches BODYSTRUCTURE to determine the + * correct filename and MIME type, then starts the streaming body fetch. + * + * @param string $collection Mailbox name + * @param int $uid Message UID + * @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822 + */ + public function entityDownload(string $collection, int $uid, ?string $partId = null): DownloadResult + { + $this->client->perform(new SelectCommand($collection, true)); + + $encoding = null; + + if ($partId === null) { + $filename = 'message.eml'; + $mimeType = 'message/rfc822'; + } else { + // Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred) + $message = $this->client->perform(new FetchOneCommand( + FetchTarget::uid(SequenceSet::items($uid)), + FetchOptions::of('BODYSTRUCTURE'), + )); + $bodyStructure = $message->bodyStructure(); + $part = $bodyStructure !== null ? $this->findBodyPart($bodyStructure, $partId) : null; + $mimeType = $part?->mimeType() ?? 'application/octet-stream'; + $partData = $part?->toArray() ?? []; + $filename = isset($partData['name']) && $partData['name'] !== '' + ? $partData['name'] + : "attachment-{$partId}"; + $encoding = $part?->encoding(); + } + + // Start download stream + $stream = $this->decodeStream( + $this->client->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''), + $encoding + ); + + return new DownloadResult($filename, $mimeType, $stream); + } + + private function findBodyPart(MessagePart $root, string $partId): ?MessagePart + { + if ($root->partId() === $partId) { + return $root; + } + foreach ($root->parts() as $child) { + $found = $this->findBodyPart($child, $partId); + if ($found !== null) { + return $found; + } + } + return null; + } + + /** + * Wraps a raw IMAP body stream with a transfer-encoding decoder. + * + * IMAP BODY[n] literals are delivered in the transfer encoding declared + * by BODYSTRUCTURE (typically base64 or quoted-printable for attachments). + * 7bit / 8bit / binary sections pass through unchanged. + */ + private function decodeStream(\Generator $stream, ?string $encoding): \Generator + { + return match (strtolower($encoding ?? '')) { + 'base64' => $this->decodeBase64Stream($stream), + 'quoted-printable' => $this->decodeQpStream($stream), + default => $stream, + }; + } + + private function decodeBase64Stream(\Generator $source): \Generator + { + $buffer = ''; + foreach ($source as $chunk) { + // IMAP folds base64 at 76 chars with CRLF — strip all whitespace + $buffer .= preg_replace('/\s+/', '', $chunk); + // Decode complete 4-character groups; keep any partial tail + $remainder = strlen($buffer) % 4; + $complete = strlen($buffer) - $remainder; + if ($complete > 0) { + yield base64_decode(substr($buffer, 0, $complete), true); + $buffer = substr($buffer, $complete); + } + } + // Flush remainder (handles padded or stripped trailing '=') + if ($buffer !== '') { + yield base64_decode($buffer, true); + } + } + + private function decodeQpStream(\Generator $source): \Generator + { + // Buffer until we have complete lines so soft-line-breaks are intact + $buffer = ''; + foreach ($source as $chunk) { + $buffer .= $chunk; + while (($pos = strpos($buffer, "\n")) !== false) { + yield quoted_printable_decode(substr($buffer, 0, $pos + 1)); + $buffer = substr($buffer, $pos + 1); + } + } + if ($buffer !== '') { + yield quoted_printable_decode($buffer); + } + } + /** * Append a raw RFC 822 message to a mailbox and return the assigned UID. * -- 2.39.5