refactor: split service into ServiceBase and LiveService

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 21:49:31 -04:00
parent eea9b5630a
commit 4e12d41ee9
18 changed files with 649 additions and 516 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\LiveService;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXC\Context\TenantContext;
@@ -162,7 +162,7 @@ class ConnectCommand extends Command
'secret' => $password,
]);
$service = new Service();
$service = new LiveService();
$service->setLocation($location);
$service->setIdentity($identity);
+2 -2
View File
@@ -10,7 +10,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXC\Context\TenantContext;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -96,7 +96,7 @@ class DisconnectCommand extends Command
$choices = [];
foreach ($services as $id => $service) {
$label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id;
$label = $service instanceof ServiceBase ? ($service->getLabel() ?? $id) : $id;
$choices[$id] = "{$label} [{$id}]";
}
+2 -2
View File
@@ -10,7 +10,7 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Console;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\LiveService;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXM\ProviderImap\Service\Discovery;
@@ -191,7 +191,7 @@ class DiscoverCommand extends Command
// ── Test before saving ───────────────────────────────────────────────
$service = new Service();
$service = new LiveService();
$service->setLocation($location);
$service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([
'identity' => $username,
+2 -2
View File
@@ -11,7 +11,7 @@ namespace KTXM\ProviderImap\Console;
use KTXC\Context\TenantContext;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
use KTXM\ProviderImap\Stores\MailboxStore;
@@ -104,7 +104,7 @@ class HarmonizeCommand extends Command
$this->tenantContext->resolveIdentifier($tenantId);
$service = $this->provider->serviceFetch($tenantId, $userId, $serviceId);
if (!$service instanceof Service) {
if (!$service instanceof ServiceBase) {
$io->error(sprintf("Service '%s' not found.", $serviceId));
return Command::FAILURE;
}
+2 -2
View File
@@ -16,7 +16,7 @@ use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Message;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Providers\Provider;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -299,7 +299,7 @@ class TestCommand extends Command
return $limit >= 0 ? $limit : $default;
}
private function formatTarget(Service $service): string
private function formatTarget(ServiceBase $service): string
{
$location = $service->getLocation();
if ($location === null) {
@@ -44,352 +44,22 @@ use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Client\Mailbox;
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
/**
* IMAP mail service that talks to the server directly.
*
* Holds the mail API rules (validation, delete modes, submission, move / copy
* result mapping); CachedService delegates writes here.
*/
class LiveService extends ServiceBase
{
private const PROVIDER_IDENTIFIER = 'imap';
private ?string $serviceTenantId = null;
private ?string $serviceUserId = null;
private ?string $serviceIdentifier = null;
private ?string $serviceLabel = null;
private bool $serviceEnabled = false;
private array $primaryAddress = [];
private array $secondaryAddresses = [];
private ?ServiceLocation $location = null;
private ?ServiceIdentityBasic $identity = null;
private array $auxiliary = [];
private array $serviceAbilities = [
self::CAPABILITY_COLLECTION_LIST => true,
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_SORT_LABEL,
self::CAPABILITY_COLLECTION_SORT_RANK,
],
self::CAPABILITY_COLLECTION_FETCH => true,
self::CAPABILITY_COLLECTION_EXTANT => 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 => [
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_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_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 => true,
self::CAPABILITY_ENTITY_MODIFY => true,
self::CAPABILITY_ENTITY_PATCH => true,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
self::CAPABILITY_ENTITY_COPY => false,
'EntityTransmit' => true,
];
private LiveMailService $liveService;
public function __construct() {}
private function initialize(): void
{
if (!isset($this->liveService)) {
$this->liveService = new LiveMailService($this);
}
}
public function toStore(): array
{
return array_filter([
'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,
], fn($v) => $v !== null);
}
public function fromStore(array $data): static
{
$this->serviceTenantId = $data['tid'] ?? null;
$this->serviceUserId = $data['uid'] ?? null;
$this->serviceIdentifier = $data['sid'] ?? null;
$this->serviceLabel = $data['label'] ?? '';
$this->serviceEnabled = $data['enabled'] ?? false;
if (isset($data['primaryAddress'])) {
$this->primaryAddress = $data['primaryAddress'];
}
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
$this->secondaryAddresses = $data['secondaryAddresses'];
}
if (isset($data['location'])) {
$this->location = (new ServiceLocation())->fromStore($data['location']);
}
if (isset($data['identity'])) {
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
}
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
$this->auxiliary = $data['auxiliary'];
}
return $this;
}
public function jsonSerialize(): array
{
return array_filter([
self::PROPERTY_TYPE => self::JSON_TYPE,
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
self::PROPERTY_LABEL => $this->serviceLabel,
self::PROPERTY_ENABLED => $this->serviceEnabled,
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
self::PROPERTY_AUXILIARY => $this->auxiliary,
], fn($v) => $v !== null);
}
public function jsonDeserialize(array|string $data, bool $delta = false): static
{
if (is_string($data)) {
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
}
if (isset($data[self::PROPERTY_ENABLED])) {
$this->setEnabled($data[self::PROPERTY_ENABLED]);
}
if (isset($data[self::PROPERTY_LABEL])) {
$this->setLabel($data[self::PROPERTY_LABEL]);
}
if (isset($data[self::PROPERTY_LOCATION])) {
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
}
if (isset($data[self::PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
}
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
}
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
$data[self::PROPERTY_SECONDARY_ADDRESSES]
));
}
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
}
return $this;
}
public function capable(string $value): bool
{
return isset($this->serviceAbilities[$value]);
}
public function capabilities(): array
{
return $this->serviceAbilities;
}
public function provider(): string
{
return self::PROVIDER_IDENTIFIER;
}
public function identifier(): string|int
{
return $this->serviceIdentifier;
}
public function tenantIdentifier(): ?string
{
return $this->serviceTenantId;
}
public function getLabel(): ?string
{
return $this->serviceLabel;
}
public function setLabel(string $label): static
{
$this->serviceLabel = $label;
return $this;
}
public function getEnabled(): bool
{
return $this->serviceEnabled;
}
public function setEnabled(bool $enabled): static
{
$this->serviceEnabled = $enabled;
return $this;
}
public function getPrimaryAddress(): AddressInterface
{
return Address::fromArray($this->primaryAddress);
}
public function setPrimaryAddress(AddressInterface $value): static
{
$this->primaryAddress = $value->toArray();
return $this;
}
public function getSecondaryAddresses(): array
{
return array_map(
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
, $this->secondaryAddresses);
}
public function setSecondaryAddresses(array $addresses): static
{
$this->secondaryAddresses = array_map(
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
$addresses
);
return $this;
}
public function hasAddress(string $address): bool
{
$address = strtolower(trim($address));
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
return true;
}
foreach ($this->secondaryAddresses as $secondary) {
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
if (strtolower($secondaryAddr) === $address) {
return true;
}
}
return false;
}
public function getLocation(): ServiceLocation
{
return $this->location;
}
public function setLocation(ResourceServiceLocationInterface $location): static
{
$this->location = $location;
return $this;
}
public function freshLocation(?string $type = null, array $data = []): ServiceLocation
{
$loc = new ServiceLocation();
$loc->jsonDeserialize($data);
return $loc;
}
public function getIdentity(): ServiceIdentityBasic
{
return $this->identity;
}
public function setIdentity(ResourceServiceIdentityInterface $identity): static
{
$this->identity = $identity;
return $this;
}
public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic
{
$id = new ServiceIdentityBasic();
$id->jsonDeserialize($data);
return $id;
}
public function getDebug(): bool
{
return ($this->auxiliary['debug'] ?? false) === true;
}
public function setDebug(bool $debug): static
{
$this->auxiliary['debug'] = $debug;
return $this;
}
public function getAuxiliary(): array
{
return $this->auxiliary;
}
public function setAuxiliary(array $auxiliary): static
{
$this->auxiliary = $auxiliary;
return $this;
}
private LiveMailService $mailService;
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
{
$this->initialize();
$list = [];
foreach ($this->liveService->collectionList($location, $filter, $sort) as $mailbox) {
$resource = $this->collectionFresh();
$resource->fromImap($mailbox);
$list[$mailbox->name()] = $resource;
}
return $list;
}
public function collectionListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
}
public function collectionListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
return $this->mailService->collectionList($location === null ? null : (string) $location, $filter, $sort);
}
public function collectionExtant(string|int ...$identifiers): array
@@ -400,9 +70,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($identifiers as $identifier) {
$key = (string) $identifier;
$result = $this->liveService->collectionFetch($key);
$list[$key] = $result !== false;
$list[$key] = $this->mailService->collectionFetch($key) !== null;
}
return $list;
@@ -412,20 +80,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{
$this->initialize();
$mailbox = $this->liveService->collectionFetch((string) $identifier);
if ($mailbox === null) {
return null;
}
$collection = $this->collectionFresh();
$collection->fromImap($mailbox);
return $collection;
}
public function collectionFresh(): CollectionResource
{
return new CollectionResource($this->provider(), $this->identifier());
return $this->mailService->collectionFetch((string) $identifier);
}
public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface
@@ -441,18 +96,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
if ($target !== null) {
$path = $target->collection();
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
$mailboxes = iterator_to_array($this->liveService->collectionList(null, null, null, ''));
$rootMailbox = $mailboxes === [] ? null : reset($mailboxes);
$delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/');
$delimiter = $this->mailService->collectionDelimiter();
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
}
$mailbox = $this->liveService->collectionCreate($label);
$collection = $this->collectionFresh();
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
return $collection;
return $this->mailService->collectionCreate($label, $delimiter ?? null);
}
public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface
@@ -467,11 +115,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// In IMAP, "update" = rename to the new label
$oldPath = (string) $target->collection();
$newName = $properties->getLabel();
$mailbox = $this->liveService->collectionRename($oldPath, $newName);
$collection = $this->collectionFresh();
$collection->fromImap($mailbox);
return $collection;
return $this->mailService->collectionRename($oldPath, $newName);
}
public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true
@@ -494,7 +138,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$result = match ($deleteMode) {
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target),
'hard' => $this->liveService->collectionDestroy((string) $target->collection()),
'hard' => $this->mailService->collectionDestroy((string) $target->collection()),
};
return $result;
}
@@ -503,8 +147,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{
$this->initialize();
$sourceMailbox = $this->liveService->collectionFetch((string) $source->collection());
$targetMailbox = $this->liveService->collectionFetch((string) $target->collection());
$sourceMailbox = $this->mailService->collectionFetch((string) $source->collection());
$targetMailbox = $this->mailService->collectionFetch((string) $target->collection());
if ($sourceMailbox === null) {
throw new \RuntimeException('Source collection not found for move operation');
}
@@ -512,19 +156,15 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
throw new \RuntimeException('Target collection not found for move operation');
}
$sourceDelimiter = $sourceMailbox->delimiter() ?? '/';
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
$sourceDelimiter = $sourceMailbox->getProperties()->getDelimiter() ?: '/';
$targetDelimiter = $targetMailbox->getProperties()->getDelimiter() ?: '/';
$extantPath = $sourceMailbox->name();
$extantPath = (string) $sourceMailbox->identifier();
$extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter));
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
$freshPath = rtrim((string) $targetMailbox->identifier(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
$mutatedMailbox = $this->liveService->collectionRename($extantPath, $freshPath);
$collection = $this->collectionFresh();
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
return $collection;
return $this->mailService->collectionRename($extantPath, $freshPath, $targetDelimiter);
}
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
@@ -536,31 +176,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{
$this->initialize();
foreach ($this->liveService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) {
$resource = $this->entityFresh();
$resource->fromImap($message, $collection);
foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $resource) {
yield $resource->urn() => $resource;
}
}
public function entityListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
}
public function entityListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
}
public function entityListRange(RangeType $type): IRange
{
return match ($type) {
RangeType::TALLY => new RangeTally(),
default => new Range(),
};
}
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
{
return iterator_to_array($this->entityFetchStream(...$identifiers), true);
@@ -574,9 +194,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($identifiers as $collection => $entities) {
$uids = array_keys($entities);
foreach ($this->liveService->entityFetch((string) $collection, null, ...$uids) as $uid => $message) {
$resource = $this->entityFresh();
$resource->fromImap($message, $collection);
foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $resource) {
yield $resource->urn() => $resource;
}
}
@@ -589,7 +207,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$uid = (int) $target->entity();
$partId = isset($part['partId']) ? (string) $part['partId'] : null;
return $this->liveService->entityDownload($collection, $uid, $partId);
return $this->mailService->entityDownload($collection, $uid, $partId);
}
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
@@ -607,7 +225,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
static fn (int $uid): bool => $uid > 0,
));
$existing = array_flip($this->liveService->entityExtant((string) $collection, ...$uids));
$existing = array_flip($this->mailService->entityExtant((string) $collection, ...$uids));
$extant = [];
foreach ($identifiers as $id) {
@@ -616,11 +234,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
return $extant;
}
public function entityFresh(): EntityResource
{
return new EntityResource($this->provider(), $this->identifier());
}
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
{
if ($message === null) {
@@ -640,7 +253,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
}
$this->initialize();
$smtp = $this->liveService->smtpClient();
$smtp = $this->mailService->smtpClient();
try {
$queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw);
} finally {
@@ -661,7 +274,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$this->initialize();
$sentCollection = $this->resolveSentCollection();
if ($sentCollection !== null) {
$uid = $this->liveService->entityCreate($sentCollection, $raw, ['\\Seen']);
$uid = $this->mailService->entityCreate($sentCollection, $raw, ['\\Seen']);
if ($uid !== null && $uid > 0) {
$sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid);
}
@@ -680,7 +293,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
&& $source->service() === $this->identifier()) {
try {
$this->initialize();
$this->liveService->entityDestroy($source->collection(), (int) $source->entity());
$this->mailService->entityDestroy($source->collection(), (int) $source->entity());
} catch (\Throwable) {
// ignore — the message was already delivered
}
@@ -703,7 +316,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options);
$created = $this->liveService->entityCreate(
$created = $this->mailService->entityCreate(
(string)$target->collection(),
$nativeMessage,
$nativeFlags,
@@ -724,7 +337,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$this->initialize();
[$nativeMessage, $nativeFlags] = $this->messagePayload($properties);
$modified = $this->liveService->entityReplace(
$modified = $this->mailService->entityReplace(
(string)$target->collection(),
(int)$target->entity(),
$nativeMessage,
@@ -760,7 +373,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
}
}
$mutations = $this->liveService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids);
$mutations = $this->mailService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids);
foreach ($uids as $uid) {
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
@@ -807,8 +420,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$uids = array_keys($sourceEntities);
$mutations = match ($deleteMode) {
'soft' => $this->liveService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
'hard' => $this->liveService->entityDestroy($sourceCollection, ...$uids),
'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids),
};
foreach ($uids as $uid) {
@@ -842,7 +455,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($sources as $sourceCollection => $sourceEntities) {
$uids = array_keys($sourceEntities);
$mutations = $this->liveService->entityMove($target->collection(), $sourceCollection, ...$uids);
$mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids);
foreach ($uids as $uid) {
$mutatedUid = $mutations[$uid] ?? null;
@@ -876,7 +489,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($sources as $sourceCollection => $sourceEntities) {
$uids = array_keys($sourceEntities);
$mutations = $this->liveService->entityCopy($target->collection(), $sourceCollection, ...$uids);
$mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids);
foreach ($uids as $uid) {
$mutatedUid = $mutations[$uid] ?? null;
@@ -899,13 +512,9 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$filter = $this->collectionListFilter();
$filter->condition('role', $role->value);
/** @var Mailbox[] $mailboxes */
$mailboxes = iterator_to_array($this->liveService->collectionList(null, $filter, null));
if ($mailboxes === []) {
return null;
}
$mailbox = reset($mailboxes);
return $mailbox === false ? null : $mailbox->name();
$collections = $this->mailService->collectionList(null, $filter, null);
return $collections === [] ? null : (string) array_key_first($collections);
}
/**
@@ -988,4 +597,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
return [(new MessageBuilder())->build($properties), $flags];
}
protected function initialize(): void
{
if (!isset($this->mailService)) {
$this->mailService = new LiveMailService($this);
}
}
}
+10 -10
View File
@@ -106,7 +106,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
return $list;
}
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ServiceBase
{
$serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier);
if ($serviceData === null) {
@@ -116,9 +116,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
return $serviceInstance;
}
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBase
{
/** @var Service[] $services */
/** @var ServiceBase[] $services */
$services = $this->serviceList($tenantId, $userId);
foreach ($services as $service) {
if ($service->hasAddress($address)) {
@@ -133,14 +133,14 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
return $this->serviceStore->extant($tenantId, $userId, $identifiers);
}
public function serviceFresh(): Service
public function serviceFresh(): ServiceBase
{
return new Service();
return new LiveService();
}
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
if (!($service instanceof Service)) {
if (!($service instanceof ServiceBase)) {
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
}
@@ -150,7 +150,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
{
if (!($service instanceof Service)) {
if (!($service instanceof ServiceBase)) {
throw new \InvalidArgumentException('Service must be instance of IMAP Service');
}
@@ -160,7 +160,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
{
if (!($service instanceof Service)) {
if (!($service instanceof ServiceBase)) {
return false;
}
@@ -185,7 +185,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
$startTime = microtime(true);
try {
if (!($service instanceof Service)) {
if (!($service instanceof ServiceBase)) {
throw new \InvalidArgumentException('Service must be an instance of IMAP Service');
}
// augment the service with any provided test options (e.g. override location or credentials)
@@ -193,7 +193,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
// Attempt to authenticate and list mailboxes as a connectivity check
$live = new LiveMailService($service);
$mailboxes = iterator_to_array($live->collectionList());
$mailboxes = $live->collectionList();
$latency = (int) round((microtime(true) - $startTime) * 1000);
+404
View File
@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImap\Providers;
use Generator;
use KTXF\Mail\Collection\CollectionBaseInterface;
use KTXF\Mail\Collection\CollectionPropertiesBaseInterface;
use KTXF\Mail\Object\Address;
use KTXF\Mail\Object\AddressInterface;
use KTXF\Mail\Service\ServiceBaseInterface;
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Mail\Submission\EntitySubmitResult;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Delta\Delta;
use KTXF\Resource\Filter\Filter;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\Range;
use KTXF\Resource\Range\RangeTally;
use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Sort\ISort;
use KTXF\Resource\Sort\Sort;
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
use KTXM\ProviderImap\Providers\ServiceLocation;
use KTXM\ProviderImap\Mime\MessageBuilder;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXM\ProviderImap\Providers\EntityResource;
/**
* IMAP mail service: account configuration and serialization.
*
* The mail API (collection / entity methods, declared by the implemented
* interfaces) is left to the concrete classes: LiveService talks to the IMAP
* server, CachedService serves reads from the cache. The Provider picks the
* class by mode when loading a service.
*/
abstract class ServiceBase implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
{
protected const PROVIDER_IDENTIFIER = 'imap';
protected ?string $serviceTenantId = null;
protected ?string $serviceUserId = null;
protected ?string $serviceIdentifier = null;
protected ?string $serviceLabel = null;
protected bool $serviceEnabled = false;
protected array $primaryAddress = [];
protected array $secondaryAddresses = [];
protected ?ServiceLocation $location = null;
protected ?ServiceIdentityBasic $identity = null;
protected array $auxiliary = [];
protected array $serviceAbilities = [
self::CAPABILITY_COLLECTION_LIST => true,
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_SORT_LABEL,
self::CAPABILITY_COLLECTION_SORT_RANK,
],
self::CAPABILITY_COLLECTION_FETCH => true,
self::CAPABILITY_COLLECTION_EXTANT => 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 => [
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_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_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 => true,
self::CAPABILITY_ENTITY_MODIFY => true,
self::CAPABILITY_ENTITY_PATCH => true,
self::CAPABILITY_ENTITY_DELETE => true,
self::CAPABILITY_ENTITY_MOVE => true,
self::CAPABILITY_ENTITY_COPY => false,
'EntityTransmit' => true,
];
public function __construct() {}
public function toStore(): array
{
return array_filter([
'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,
], fn($v) => $v !== null);
}
public function fromStore(array $data): static
{
$this->serviceTenantId = $data['tid'] ?? null;
$this->serviceUserId = $data['uid'] ?? null;
$this->serviceIdentifier = $data['sid'] ?? null;
$this->serviceLabel = $data['label'] ?? '';
$this->serviceEnabled = $data['enabled'] ?? false;
if (isset($data['primaryAddress'])) {
$this->primaryAddress = $data['primaryAddress'];
}
if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) {
$this->secondaryAddresses = $data['secondaryAddresses'];
}
if (isset($data['location'])) {
$this->location = (new ServiceLocation())->fromStore($data['location']);
}
if (isset($data['identity'])) {
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
}
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
$this->auxiliary = $data['auxiliary'];
}
return $this;
}
public function jsonSerialize(): array
{
return array_filter([
self::PROPERTY_TYPE => self::JSON_TYPE,
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
self::PROPERTY_LABEL => $this->serviceLabel,
self::PROPERTY_ENABLED => $this->serviceEnabled,
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
self::PROPERTY_AUXILIARY => $this->auxiliary,
], fn($v) => $v !== null);
}
public function jsonDeserialize(array|string $data, bool $delta = false): static
{
if (is_string($data)) {
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
}
if (isset($data[self::PROPERTY_ENABLED])) {
$this->setEnabled($data[self::PROPERTY_ENABLED]);
}
if (isset($data[self::PROPERTY_LABEL])) {
$this->setLabel($data[self::PROPERTY_LABEL]);
}
if (isset($data[self::PROPERTY_LOCATION])) {
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
}
if (isset($data[self::PROPERTY_IDENTITY])) {
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
}
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) {
$value = $data[self::PROPERTY_PRIMARY_ADDRESS];
$this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value));
}
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
$this->setSecondaryAddresses(array_map(
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
$data[self::PROPERTY_SECONDARY_ADDRESSES]
));
}
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
}
return $this;
}
public function capable(string $value): bool
{
return isset($this->serviceAbilities[$value]);
}
public function capabilities(): array
{
return $this->serviceAbilities;
}
public function provider(): string
{
return self::PROVIDER_IDENTIFIER;
}
public function identifier(): string|int
{
return $this->serviceIdentifier;
}
public function tenantIdentifier(): ?string
{
return $this->serviceTenantId;
}
public function getLabel(): ?string
{
return $this->serviceLabel;
}
public function setLabel(string $label): static
{
$this->serviceLabel = $label;
return $this;
}
public function getEnabled(): bool
{
return $this->serviceEnabled;
}
public function setEnabled(bool $enabled): static
{
$this->serviceEnabled = $enabled;
return $this;
}
public function getPrimaryAddress(): AddressInterface
{
return Address::fromArray($this->primaryAddress);
}
public function setPrimaryAddress(AddressInterface $value): static
{
$this->primaryAddress = $value->toArray();
return $this;
}
public function getSecondaryAddresses(): array
{
return array_map(
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
, $this->secondaryAddresses);
}
public function setSecondaryAddresses(array $addresses): static
{
$this->secondaryAddresses = array_map(
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
$addresses
);
return $this;
}
public function hasAddress(string $address): bool
{
$address = strtolower(trim($address));
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
return true;
}
foreach ($this->secondaryAddresses as $secondary) {
$secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary;
if (strtolower($secondaryAddr) === $address) {
return true;
}
}
return false;
}
public function getLocation(): ServiceLocation
{
return $this->location;
}
public function setLocation(ResourceServiceLocationInterface $location): static
{
$this->location = $location;
return $this;
}
public function freshLocation(?string $type = null, array $data = []): ServiceLocation
{
$loc = new ServiceLocation();
$loc->jsonDeserialize($data);
return $loc;
}
public function getIdentity(): ServiceIdentityBasic
{
return $this->identity;
}
public function setIdentity(ResourceServiceIdentityInterface $identity): static
{
$this->identity = $identity;
return $this;
}
public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic
{
$id = new ServiceIdentityBasic();
$id->jsonDeserialize($data);
return $id;
}
public function getDebug(): bool
{
return ($this->auxiliary['debug'] ?? false) === true;
}
public function setDebug(bool $debug): static
{
$this->auxiliary['debug'] = $debug;
return $this;
}
public function getAuxiliary(): array
{
return $this->auxiliary;
}
public function setAuxiliary(array $auxiliary): static
{
$this->auxiliary = $auxiliary;
return $this;
}
public function collectionListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
}
public function collectionListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
}
public function collectionFresh(): CollectionResource
{
return new CollectionResource($this->provider(), $this->identifier());
}
public function entityListFilter(): Filter
{
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
}
public function entityListSort(): Sort
{
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
}
public function entityListRange(RangeType $type): IRange
{
return match ($type) {
RangeType::TALLY => new RangeTally(),
default => new Range(),
};
}
public function entityFresh(): EntityResource
{
return new EntityResource($this->provider(), $this->identifier());
}
}
+6 -10
View File
@@ -10,10 +10,9 @@ declare(strict_types=1);
namespace KTXM\ProviderImap\Service\Cache;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Providers\MessageProperties;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use KTXM\ProviderImap\Stores\MailboxStore;
use KTXM\ProviderImap\Stores\MessageFileStore;
@@ -35,7 +34,7 @@ class HarmonizationService
/** Lifetime of a mailbox harmonization lock in seconds; long enough for a large first run */
public const LOCK_TTL = 900;
private ?Service $service = null;
private ?ServiceBase $service = null;
private ?LiveMailService $live = null;
public function __construct(
@@ -48,7 +47,7 @@ class HarmonizationService
/**
* Bind a service; returns a new instance so the shared one stays unbound.
*/
public function for(Service $service, ?LiveMailService $live = null): static
public function for(ServiceBase $service, ?LiveMailService $live = null): static
{
$bound = clone $this;
$bound->service = $service;
@@ -72,10 +71,7 @@ class HarmonizationService
$tenantId = (string) $service->tenantIdentifier();
$serviceId = (string) $service->identifier();
$remote = [];
foreach ($live->collectionList() as $name => $mailbox) {
$remote[(string) $name] = (new CollectionResource($service->provider(), $serviceId))->fromImap($mailbox);
}
$remote = $live->collectionList();
$cached = $this->mailboxStore->list($serviceId);
$result = ['added' => [], 'updated' => [], 'removed' => []];
@@ -158,7 +154,7 @@ class HarmonizationService
try {
$state = $this->mailboxStore->state($serviceId, $mailbox);
$selected = $live->collectionFetch($mailbox)
$selected = $live->mailboxFetch($mailbox)
?? throw new RuntimeException("Mailbox not found on server: {$mailbox}");
$uidValidity = $selected->uidValidity()
?? throw new RuntimeException("Server did not report UIDVALIDITY for mailbox: {$mailbox}");
@@ -239,7 +235,7 @@ class HarmonizationService
$options = FetchOptions::message()->withBodyText(MessageIngestor::BODY_TEXT_LIMIT);
$ingested = [];
foreach ($live->entityFetch($mailbox, $options, ...$uids) as $message) {
foreach ($live->messageFetch($mailbox, $options, ...$uids) as $message) {
$entity = (new EntityResource($service->provider(), $serviceId))->fromImap($message, $mailbox);
array_push($ingested, ...$this->ingestor->ingest($tenantId, $serviceId, $uidValidity, $entity));
}
+98 -22
View File
@@ -33,6 +33,8 @@ use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions;
use KTXM\ProviderImap\Client\Mailbox;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Client\Message;
use KTXM\ProviderImap\Client\MessageAddress;
use KTXM\ProviderImap\Client\MessagePart;
@@ -49,7 +51,7 @@ use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeTally;
use KTXF\Resource\Sort\ISort;
use KTXF\Resource\BinaryResource;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
use RuntimeException;
@@ -69,7 +71,7 @@ class LiveMailService
private ?Mailbox $selected = null;
public function __construct(
private readonly Service $service,
private readonly ServiceBase $service,
) {}
/**
@@ -140,11 +142,48 @@ class LiveMailService
}
/**
* list of collections in remote storage
*
* @since Release 1.0.0
*/
public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
* List the collections of the account, keyed by mailbox name.
*
* @return array<string, CollectionResource>
*/
public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array
{
$list = [];
foreach ($this->mailboxList($location, $filter, $sort) as $name => $mailbox) {
$list[(string) $name] = $this->collectionResource($mailbox);
}
return $list;
}
/**
* Fetch a single collection by its mailbox name; null when it does not exist.
*/
public function collectionFetch(string $identifier): ?CollectionResource
{
$mailbox = $this->mailboxFetch($identifier);
return $mailbox === null ? null : $this->collectionResource($mailbox);
}
/**
* Hierarchy delimiter of the account, from `LIST "" ""` (no mailboxes are listed); "/" when unknown.
*/
public function collectionDelimiter(): string
{
foreach ($this->imapClient()->perform(new ListCommand('', '')) as $mailbox) {
return $mailbox->delimiter() ?: '/';
}
return '/';
}
/**
* List the IMAP mailboxes of the account (with status), keyed by name.
*
* @return Generator<string, Mailbox>
*/
public function mailboxList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
{
// Prepare location filter
if (!empty($location)) {
@@ -196,11 +235,11 @@ class LiveMailService
}
/**
* Fetch a single mailbox by its full name.
* Fetch a single IMAP mailbox (with status) by its full name.
*
* Returns null when no mailbox matching $identifier is found.
*/
public function collectionFetch(string $identifier): ?Mailbox
public function mailboxFetch(string $identifier): ?Mailbox
{
// LIST-STATUS (RFC 5819) returns the status with the LIST response in one round trip
$listStatus = $this->imapClient()->hasCapability('LIST-STATUS');
@@ -225,38 +264,38 @@ class LiveMailService
}
/**
* Create a new IMAP mailbox and return it.
* Create a new IMAP mailbox and return it as a collection resource.
*
* If the server-side LIST cannot confirm the new mailbox (e.g., immediate
* consistency), a lightweight stub resource is returned instead.
*/
public function collectionCreate(string $name): Mailbox
public function collectionCreate(string $name, ?string $delimiter = null): CollectionResource
{
$this->imapClient()->perform(new CreateCommand($name));
// Attempt to refetch the new mailbox from the server
$mailbox = $this->collectionFetch($name);
$mailbox = $this->mailboxFetch($name);
if ($mailbox === null) {
throw new ImapException('Failed to create mailbox: ' . $name);
}
return $mailbox;
return $this->collectionResource($mailbox, $delimiter);
}
/**
* Rename a mailbox and return the updated resource.
*/
public function collectionRename(string $oldName, string $newName): Mailbox
public function collectionRename(string $oldName, string $newName, ?string $delimiter = null): CollectionResource
{
$this->imapClient()->perform(new RenameCommand($oldName, $newName));
$mailbox = $this->collectionFetch($newName);
$mailbox = $this->mailboxFetch($newName);
if ($mailbox === null) {
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
}
return $mailbox;
return $this->collectionResource($mailbox, $delimiter);
}
/**
@@ -354,11 +393,36 @@ class LiveMailService
}
/**
* Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested.
* List the entities of a mailbox matching the given filter, sorted and paginated as requested.
*
* @return Message[] list of messages matching the filter, sorted and paginated as requested
* @return Generator<int, EntityResource> keyed by UID
*/
public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
{
foreach ($this->messageList($collection, $filter, $sort, $range) as $message) {
$entity = $this->entityResource($message, $collection);
yield (int) $entity->identifier() => $entity;
}
}
/**
* Fetch entities by UID (full TEXT).
*
* @return Generator<int, EntityResource> keyed by UID
*/
public function entityFetch(string $collection, int ...$uids): Generator
{
foreach ($this->messageFetch($collection, null, ...$uids) as $uid => $message) {
yield $uid => $this->entityResource($message, $collection);
}
}
/**
* List IMAP messages of a mailbox matching the given filter, sorted and paginated as requested.
*
* @return Generator<Message>
*/
public function messageList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
{
// text parts normally precede attachments, so capping TEXT avoids transferring attachment bytes
$options = FetchOptions::message()->withBodyText(self::LIST_BODY_TEXT_LIMIT);
@@ -387,16 +451,16 @@ class LiveMailService
return [];
}
yield from $this->entityFetch($collection, $options, ...$uids);
yield from $this->messageFetch($collection, $options, ...$uids);
}
/**
* Fetch one or more messages by UID and return EntityResource objects.
* Fetch one or more messages by UID as IMAP messages.
*
* @param int ...$uids
* @return Message[] keyed by UID
* @return Generator<int, Message> keyed by UID
*/
public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
{
if (empty($uids)) {
return [];
@@ -1181,6 +1245,18 @@ class LiveMailService
return $normalized;
}
private function collectionResource(Mailbox $mailbox, ?string $delimiter = null): CollectionResource
{
return (new CollectionResource($this->service->provider(), $this->service->identifier()))
->fromImap($mailbox, ['delimiter' => $delimiter]);
}
private function entityResource(Message $message, string $collection): EntityResource
{
return (new EntityResource($this->service->provider(), $this->service->identifier()))
->fromImap($message, $collection);
}
/**
* Select a mailbox, reusing the current selection when possible.
*
+3 -3
View File
@@ -12,7 +12,7 @@ namespace KTXM\ProviderImap\Stores;
use KTXC\Db\DataStore;
use KTXF\Security\Crypto;
use KTXF\Utile\UUID;
use KTXM\ProviderImap\Providers\Service;
use KTXM\ProviderImap\Providers\ServiceBase;
/**
* IMAP Service Store
@@ -114,7 +114,7 @@ class ServiceStore
/**
* Create a new service
*/
public function create(string $tenantId, string $userId, Service $service): array
public function create(string $tenantId, string $userId, ServiceBase $service): array
{
$document = $service->toStore();
@@ -136,7 +136,7 @@ class ServiceStore
/**
* Modify an existing service
*/
public function modify(string $tenantId, string $userId, Service $service): array
public function modify(string $tenantId, string $userId, ServiceBase $service): array
{
$serviceId = $service->identifier();
if (empty($serviceId)) {