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

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

View File

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

View File

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

View File

@@ -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 = [];