refactor: use custom imap client

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-05-08 00:16:43 -04:00
parent a728aeb11c
commit a8764747fd
179 changed files with 6782 additions and 5907 deletions

View File

@@ -35,15 +35,12 @@ use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
use KTXM\ProviderImap\Service\Remote\RemoteService;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXF\Mail\Collection\CollectionRoles;
use KTXM\ProviderImap\Providers\EntityResource;
/**
* IMAP Mail Service
*
* Represents a single IMAP account configuration and acts as the primary
* entry-point for all mail operations (collections + entities).
*
* The RemoteMailService is initialised lazily on first use so that the object
* can be constructed cheaply for serialisation/deserialisation tasks.
*/
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface
{
@@ -64,25 +61,28 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
private array $serviceAbilities = [
self::CAPABILITY_COLLECTION_LIST => true,
self::CAPABILITY_COLLECTION_LIST_FILTER => [],
self::CAPABILITY_COLLECTION_LIST_FILTER => [
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256',
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_EXTANT => true,
self::CAPABILITY_COLLECTION_FETCH => true,
self::CAPABILITY_COLLECTION_CREATE => true,
self::CAPABILITY_COLLECTION_UPDATE => true,
self::CAPABILITY_COLLECTION_DELETE => true,
self::CAPABILITY_COLLECTION_MOVE => true,
self::CAPABILITY_ENTITY_LIST => true,
self::CAPABILITY_ENTITY_LIST_FILTER => [
'seen' => 'b:0:1:1',
'flagged' => 'b:0:1:1',
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256',
self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256',
self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256',
self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 's:32:1:1',
self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1',
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1',
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']],
@@ -350,10 +350,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// ── Collection operations ─────────────────────────────────────────────────
public function collectionList(string|int|null $location, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
{
$this->initialize();
return $this->mailService->collectionList();
foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) {
$resource = $this->collectionFresh();
$resource->fromImap($mailbox);
$list[$mailbox->name()] = $resource;
}
return $list;
}
public function collectionListFilter(): Filter
@@ -370,7 +377,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{
$this->initialize();
$existing = $this->mailService->collectionList();
$mailboxes = $this->collectionList();
$extant = [];
foreach ($identifiers as $id) {
$extant[(string) $id] = isset($existing[(string) $id]);
@@ -381,12 +388,21 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
public function collectionFetch(string|int $identifier): ?CollectionBaseInterface
{
$this->initialize();
return $this->mailService->collectionFetch((string) $identifier);
$mailbox = $this->mailService->collectionFetch((string) $identifier);
if ($mailbox === null) {
return null;
}
$collection = $this->collectionFresh();
$collection->fromImap($mailbox);
return $collection;
}
public function collectionFresh(): CollectionMutableInterface
{
return new CollectionResource();
return new CollectionResource($this->provider(), $this->identifier());
}
public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface
@@ -397,22 +413,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$label = $collection->getProperties()->getLabel() ?? '';
if ($location !== null && $location !== '') {
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
$existing = $this->mailService->collectionList();
$delimiter = '/';
foreach ($existing as $c) {
$props = $c->getProperties();
if ($props instanceof CollectionProperties) {
$d = $props->getDelimiter();
if ($d !== null && $d !== '') {
$delimiter = $d;
break;
}
}
}
$mailboxes = iterator_to_array($this->mailService->collectionList(null, null, null, ''));
$delimiter = $mailboxes ? reset($mailboxes)->delimiter() ?? '/' : '/';
$label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter);
}
return $this->mailService->collectionCreate($label);
$mailbox = $this->mailService->collectionCreate($label);
$collection = $this->collectionFresh();
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
return $collection;
}
public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface
@@ -421,64 +432,94 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// In IMAP, "update" = rename to the new label
$newName = $collection->getProperties()->getLabel() ?? (string) $identifier;
return $this->mailService->collectionRename((string) $identifier, $newName);
$mailbox = $this->mailService->collectionRename((string) $identifier, $newName);
$collection = $this->collectionFresh();
$collection->fromImap($mailbox);
return $collection;
}
public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool
{
$this->initialize();
return $this->mailService->collectionDestroy((string) $identifier);
}
public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface
public function collectionDelete(string|int $identifier, bool $force = false): CollectionBaseInterface | true
{
$this->initialize();
// IMAP RENAME effectively moves+renames the mailbox
$existing = $this->mailService->collectionFetch((string) $identifier);
$label = $existing?->getProperties()->getLabel() ?? basename((string) $identifier);
$newName = $targetLocation !== null ? rtrim((string) $targetLocation, '/') . '/' . $label : $label;
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
return $this->mailService->collectionRename((string) $identifier, $newName);
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
}
// Move to target collection (e.g. Trash) instead of deleting
if ($deleteMode === 'soft' && $deleteTarget !== null) {
return $this->collectionMove((string) $identifier, (string) $deleteTarget);
}
if ($deleteMode === 'soft' && $deleteTarget === null) {
$filter = $this->collectionListFilter();
$filter->condition('role', CollectionRoles::Trash->value);
$mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null));
if (empty($mailboxes)) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
$deleteTarget = key($mailboxes);
}
// we need to determine if the folder being deleted is already in the trash
if (str_starts_with((string) $identifier, (string) $deleteTarget)) {
// if so, we should hard delete instead of moving to avoid duplicates in the trash
$deleteMode = 'hard';
}
$result = match ($deleteMode) {
'soft' => $this->collectionMove((string) $identifier, (string) $deleteTarget),
'hard' => $this->mailService->collectionDestroy((string) $identifier)
};
return $result;
}
public function collectionMove(string|int $identifier, string|int|null $target): CollectionBaseInterface
{
$this->initialize();
$sourceMailbox = $this->mailService->collectionFetch((string) $identifier);
$targetMailbox = $this->mailService->collectionFetch((string) $target);
if ($sourceMailbox === null) {
throw new \RuntimeException('Source collection not found for move operation');
}
if ($targetMailbox === null) {
throw new \RuntimeException('Target collection not found for move operation');
}
$sourceDelimiter = $sourceMailbox->delimiter() ?? '/';
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
$targetPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $sourceMailbox->name()));
$mutatedMailbox = $this->mailService->collectionRename($sourceMailbox->name(), $targetPath);
$collection = $this->collectionFresh();
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
return $collection;
}
// ── Entity operations ─────────────────────────────────────────────────────
public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
{
$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);
return itterator_to_array($this->entityList((string) $collection, $filter, $sort, $range), true);
}
public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
{
$this->initialize();
// Unfiltered: skip the SEARCH round-trip and stream via FETCH 1:*
if ($filter === null) {
yield from $this->mailService->entityFetchAllStream((string) $collection);
return;
foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) {
$resource = $this->entityFresh();
$resource->fromImap($message, $collection);
yield $identifier => $resource;
}
// Filtered: SEARCH for matching UIDs then stream only those messages
$uids = $this->mailService->entityList((string) $collection, $filter, $range);
if (empty($uids)) {
return;
}
yield from $this->mailService->entityFetchStream((string) $collection, ...$uids);
}
public function entityListFilter(): Filter
@@ -498,6 +539,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
default => new Range(),
};
}
public function entityFetch(string|int $collection, string|int ...$identifiers): array
{
$this->initialize();
$uids = array_map('intval', $identifiers);
return $this->mailService->entityFetch((string) $collection, ...$uids);
}
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
{
@@ -517,57 +566,112 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
return $extant;
}
public function entityFetch(string|int $collection, string|int ...$identifiers): array
public function entityFresh(): EntityResource
{
$this->initialize();
$uids = array_map('intval', $identifiers);
return $this->mailService->entityFetch((string) $collection, ...$uids);
return new EntityResource($this->provider(), $this->identifier());
}
public function entityDelete(EntityIdentifier ...$identifiers): array
{
// validate identifiers and group by collection
$collections = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
}
$collections[$identifier->collection()][] = (int) $identifier->entity();
}
$identifiers = $this->groupEntitiesByCollection(...$identifiers);
// determine delete mode and target collection (e.g. Trash) if applicable
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
}
// connect to remote store
$this->initialize();
// attempt to find a target collection for soft deletion if none was specified
if ($deleteMode === 'soft' && $deleteTarget === null) {
$filter = $this->collectionListFilter();
$filter->condition('role', CollectionRoles::Trash->value);
$mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null));
if (empty($mailboxes)) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
$deleteTargetNative = reset($mailboxes)->name();
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
} else {
$deleteTargetNative = $deleteTarget;
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
}
// entities need to be moved or deleted by collection
$list = [];
foreach ($identifiers as $sourceCollection => $sourceEntities) {
if ($deleteMode === 'soft' && $sourceCollection === $deleteTargetNative) {
continue;
}
$uids = array_keys($sourceEntities);
$mutations = match ($deleteMode) {
'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids),
};
// delete entities per collection and build result map
$result = [];
foreach ($collections as $collection => $uids) {
$this->mailService->entityDestroy($collection, ...$uids);
foreach ($uids as $uid) {
$result[(string) $uid] = true;
$mutatedUid = $mutations[$uid] ?? null;
$results[(string)$sourceEntities[$uid]] = [
'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted',
'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null,
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null,
];
}
}
return $result;
return $results;
}
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array
{
// validate target belongs to this service
if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Target collection does not belong to this service');
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
}
// validate identifiers and construct ID list
$ids = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier);
}
$ids[] = $identifier->entity();
}
// validate identifiers and group by collection
$identifiers = $this->groupEntitiesByCollection(...$identifiers);
// move entities on remote store and construct result map
$this->initialize();
$list = [];
foreach ($identifiers as $sourceCollection => $sourceEntities) {
$uids = array_keys($sourceEntities);
return $this->mailService->entityMove($target->collection(), ...$ids);
$mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids);
foreach ($uids as $uid) {
$mutatedUid = $mutations[$uid] ?? null;
$list[(string)$sourceEntities[$uid]] = [
'disposition' => 'moved',
'destination' => $target,
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
];
}
}
return $list;
}
private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array
{
$list = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || $identifier->service() !== $this->identifier()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier);
}
$list[$identifier->collection()][$identifier->entity()] = $identifier;
}
return $list;
}
}