feat: implement download

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-05-23 20:18:58 -04:00
parent 640e3aa811
commit 9cdebd82b8
15 changed files with 336 additions and 172 deletions

View File

@@ -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.
*