generated from Nodarx/template
refactor: use custom imap client
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use Gricob\IMAP\Mailbox;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
use KTXF\Mail\Collection\CollectionPropertiesMutableAbstract;
|
||||
use KTXF\Mail\Collection\CollectionRoles;
|
||||
|
||||
@@ -24,26 +24,28 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
|
||||
// ── IMAP hydration ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Populate from a gricob Mailbox object.
|
||||
* Populate from a standalone IMAP Mailbox value object.
|
||||
*
|
||||
* Total / unread counts are NOT available from a LIST response alone.
|
||||
* They must be set separately (after SELECT + SEARCH UNSEEN).
|
||||
* Total / unread counts are available when the caller uses LIST-STATUS.
|
||||
*/
|
||||
public function fromImap(Mailbox $mailbox): static
|
||||
public function fromImap(Mailbox $mailbox, array $options = []): static
|
||||
{
|
||||
$delimiter = $mailbox->hierarchyDelimiter;
|
||||
$this->data['label'] = ($delimiter !== '' && str_contains($mailbox->name, $delimiter))
|
||||
? substr($mailbox->name, strrpos($mailbox->name, $delimiter) + strlen($delimiter))
|
||||
: $mailbox->name;
|
||||
$delimiter = $mailbox->delimiter() ?? '';
|
||||
$name = $mailbox->name();
|
||||
$attributes = $mailbox->attributes();
|
||||
|
||||
$this->data['label'] = ($delimiter !== '' && str_contains($name, $delimiter))
|
||||
? substr($name, strrpos($name, $delimiter) + strlen($delimiter))
|
||||
: $name;
|
||||
$this->data['delimiter'] = $delimiter;
|
||||
$this->data['attributes'] = $mailbox->nameAttributes;
|
||||
$this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true);
|
||||
$this->data['total'] = 0;
|
||||
$this->data['unread'] = 0;
|
||||
$this->data['attributes'] = $attributes;
|
||||
$this->data['subscribed'] = in_array('\\SUBSCRIBED', $attributes, true) || in_array('\\Subscribed', $attributes, true);
|
||||
$this->data['total'] = $mailbox->messages();
|
||||
$this->data['unread'] = $mailbox->unread();
|
||||
$this->data['rank'] = 0;
|
||||
|
||||
// Map standard IMAP role attributes
|
||||
$this->data['role'] = $this->roleFromAttributes($mailbox->nameAttributes)->value;
|
||||
$this->data['role'] = $this->roleFromAttributes($attributes)->value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -91,6 +93,6 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
return CollectionRoles::Custom;
|
||||
return CollectionRoles::None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use Gricob\IMAP\Mailbox;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
use KTXF\Mail\Collection\CollectionMutableAbstract;
|
||||
|
||||
/**
|
||||
@@ -29,26 +29,27 @@ class CollectionResource extends CollectionMutableAbstract
|
||||
// ── IMAP hydration ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Populate from a gricob Mailbox object.
|
||||
* Populate from a standalone IMAP Mailbox value object.
|
||||
*
|
||||
* @param Mailbox $mailbox gricob Mailbox value object from LIST response
|
||||
* @param Mailbox $mailbox mailbox value object from LIST response
|
||||
* @param array $options additional options, e.g., ['delimiter' => '/']
|
||||
*/
|
||||
public function fromImap(Mailbox $mailbox): static
|
||||
public function fromImap(Mailbox $mailbox, array $options = []): static
|
||||
{
|
||||
// The mailbox name is its unique identifier within the account
|
||||
$this->data['identifier'] = $mailbox->name;
|
||||
$this->data['identifier'] = $mailbox->name();
|
||||
|
||||
// Derive parent collection from path + delimiter
|
||||
$delimiter = $mailbox->hierarchyDelimiter;
|
||||
if ($delimiter && str_contains($mailbox->name, $delimiter)) {
|
||||
$parts = explode($delimiter, $mailbox->name);
|
||||
$delimiter = $mailbox->delimiter() ?? $options['delimiter'] ?? '/';
|
||||
if ($delimiter && str_contains($mailbox->name(), $delimiter)) {
|
||||
$parts = explode($delimiter, $mailbox->name());
|
||||
array_pop($parts);
|
||||
$this->data['collection'] = implode($delimiter, $parts);
|
||||
} else {
|
||||
$this->data['collection'] = null;
|
||||
$this->data['collection'] = null; // top-level mailbox
|
||||
}
|
||||
|
||||
$this->getProperties()->fromImap($mailbox);
|
||||
$this->getProperties()->fromImap($mailbox, $options);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ declare(strict_types=1);
|
||||
namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Gricob\IMAP\Mime\Part\Part;
|
||||
use Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXF\Mail\Entity\EntityMutableAbstract;
|
||||
|
||||
/**
|
||||
@@ -27,25 +26,19 @@ class EntityResource extends EntityMutableAbstract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gricob FetchData to mail entity object
|
||||
*
|
||||
* @param FetchData $fetchData result from IMAP FETCH command
|
||||
* @param string $mailbox IMAP mailbox name (used as collection)
|
||||
* Convert IMAP data to a mail entity object.
|
||||
*/
|
||||
public function fromImap(FetchData $fetchData, string $mailbox): static {
|
||||
|
||||
// Collection = the IMAP mailbox name
|
||||
public function fromImap(Message $message, string $mailbox): static
|
||||
{
|
||||
$this->data['collection'] = $mailbox;
|
||||
|
||||
// Identifier = UID (preferred) or sequence number as fallback
|
||||
$this->data['identifier'] = $fetchData->uid ?? $fetchData->id;
|
||||
$this->data['identifier'] = $message->uid() ?: $message->sequence();
|
||||
|
||||
// Created = INTERNALDATE (server arrival time)
|
||||
if ($fetchData->internalDate !== null) {
|
||||
$this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM);
|
||||
if ($message->internalDate() !== null) {
|
||||
$this->data['created'] = $message->internalDate();
|
||||
}
|
||||
|
||||
$this->getProperties()->fromImap($fetchData);
|
||||
$this->getProperties()->fromImap($message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,8 @@ namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
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 Gricob\IMAP\Protocol\Response\Line\Data\FetchData;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\MessagePart as ClientMessagePart;
|
||||
use KTXF\Mail\Object\MessagePropertiesMutableAbstract;
|
||||
|
||||
/**
|
||||
@@ -23,117 +21,111 @@ use KTXF\Mail\Object\MessagePropertiesMutableAbstract;
|
||||
class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
|
||||
/**
|
||||
* Convert IMAP data to mail message properties object
|
||||
*
|
||||
* @param FetchData $fetchData result from IMAP FETCH command
|
||||
* Convert IMAP data to mail message properties object.
|
||||
*/
|
||||
public function fromImap(FetchData $fetchData): static {
|
||||
public function fromImap(Message $message): static
|
||||
{
|
||||
$this->data['size'] = $message->size();
|
||||
|
||||
// ── Size ──────────────────────────────────────────────────────
|
||||
$this->data['size'] = $fetchData->rfc822Size ?? 0;
|
||||
|
||||
// ── Flags ─────────────────────────────────────────────────────
|
||||
$this->data['flags'] = [];
|
||||
foreach ($fetchData->flags ?? [] as $flag) {
|
||||
foreach ($message->flags() as $flag) {
|
||||
$flag = ltrim($flag, '\\');
|
||||
$normalized = match (strtolower($flag)) {
|
||||
'seen' => 'read',
|
||||
'flagged' => 'flagged',
|
||||
'seen' => 'read',
|
||||
'flagged' => 'flagged',
|
||||
'answered' => 'answered',
|
||||
'draft' => 'draft',
|
||||
'deleted' => 'deleted',
|
||||
default => strtolower($flag),
|
||||
'draft' => 'draft',
|
||||
'deleted' => 'deleted',
|
||||
default => strtolower($flag),
|
||||
};
|
||||
$this->data['flags'][$normalized] = true;
|
||||
}
|
||||
|
||||
// ── Envelope ──────────────────────────────────────────────────
|
||||
if ($fetchData->envelope !== null) {
|
||||
$envelope = $fetchData->envelope;
|
||||
|
||||
if ($envelope->messageId !== null) {
|
||||
$this->data['urid'] = trim($envelope->messageId, '<>');
|
||||
}
|
||||
|
||||
if ($envelope->subject !== null) {
|
||||
// Decode MIME encoded-word in subject
|
||||
$this->data['subject'] = mb_decode_mimeheader($envelope->subject);
|
||||
}
|
||||
|
||||
if ($envelope->date !== null) {
|
||||
$date = $envelope->date instanceof DateTimeImmutable
|
||||
? $envelope->date
|
||||
: new DateTimeImmutable($envelope->date);
|
||||
$this->data['date'] = $date->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($envelope->inReplyTo !== null) {
|
||||
$this->data['inReplyTo'] = $envelope->inReplyTo;
|
||||
}
|
||||
|
||||
$addressToArray = static function ($addr): array {
|
||||
$email = '';
|
||||
if ($addr->mailboxName !== null && $addr->hostName !== null) {
|
||||
$email = $addr->mailboxName . '@' . $addr->hostName;
|
||||
} elseif ($addr->mailboxName !== null) {
|
||||
$email = $addr->mailboxName;
|
||||
}
|
||||
return [
|
||||
'address' => $email,
|
||||
'label' => $addr->displayName ?? null,
|
||||
];
|
||||
};
|
||||
|
||||
if (!empty($envelope->from)) {
|
||||
$this->data['from'] = $addressToArray($envelope->from[0]);
|
||||
}
|
||||
|
||||
if (!empty($envelope->sender)) {
|
||||
$this->data['sender'] = $addressToArray($envelope->sender[0]);
|
||||
}
|
||||
|
||||
foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) {
|
||||
$envField = $field === 'replyTo' ? 'replyTo' : $field;
|
||||
if (!empty($envelope->$envField)) {
|
||||
$this->data[$field] = [];
|
||||
foreach ($envelope->$envField as $addr) {
|
||||
$this->data[$field][] = $addressToArray($addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($message->messageId() !== null) {
|
||||
$this->data['urid'] = $message->messageId();
|
||||
}
|
||||
|
||||
// ── Body Structure ────────────────────────────────────────────
|
||||
if ($fetchData->bodyStructure !== null) {
|
||||
$bodyStructure = $fetchData->bodyStructure;
|
||||
// Root multipart containers have no fetchable section ID; their
|
||||
// children are numbered "1", "2", … to match IMAP section IDs.
|
||||
$isRootMultipart = $bodyStructure->part instanceof MultiPart;
|
||||
$rootPartId = $isRootMultipart ? '' : '1';
|
||||
$rootPart = (new MessagePart())->fromImap($bodyStructure->part, $rootPartId);
|
||||
if ($message->subject() !== null) {
|
||||
$this->data['subject'] = $message->subject();
|
||||
}
|
||||
|
||||
// ── Body Content: inject decoded content onto part nodes ──────
|
||||
if (!empty($fetchData->bodySections)) {
|
||||
$sectionMap = [];
|
||||
foreach ($fetchData->bodySections as $bs) {
|
||||
$sectionMap[$bs->section] = $bs->text;
|
||||
}
|
||||
$rootPart->injectSections($sectionMap);
|
||||
if ($message->sentAt() !== null) {
|
||||
$date = new DateTimeImmutable($message->sentAt());
|
||||
$this->data['date'] = $date->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($message->inReplyTo() !== null) {
|
||||
$this->data['inReplyTo'] = $message->inReplyTo();
|
||||
}
|
||||
|
||||
if ($message->from() !== []) {
|
||||
$this->data['from'] = $message->from()[0]->toArray();
|
||||
}
|
||||
|
||||
if ($message->sender() !== []) {
|
||||
$this->data['sender'] = $message->sender()[0]->toArray();
|
||||
}
|
||||
|
||||
foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) {
|
||||
$addresses = $message->{$field}();
|
||||
if ($addresses === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->data['body'] = $rootPart->toStore();
|
||||
$this->data[$field] = array_map(
|
||||
static fn ($address): array => $address->toArray(),
|
||||
$addresses,
|
||||
);
|
||||
}
|
||||
|
||||
if ($message->bodyStructure() !== null) {
|
||||
$this->data['body'] = $message->bodyStructure()->toArray();
|
||||
|
||||
// Collect attachments: non-body parts with name or attachment disposition
|
||||
$attachments = [];
|
||||
self::collectAttachments($bodyStructure->part, $rootPartId, $attachments);
|
||||
if (!empty($attachments)) {
|
||||
self::collectAttachments($message->bodyStructure(), $attachments);
|
||||
if ($attachments !== []) {
|
||||
$this->data['attachments'] = $attachments;
|
||||
}
|
||||
}
|
||||
|
||||
if ($message->bodyStructure() !== null) {
|
||||
$this->data['body'] = $message->bodyStructure()->toArray();
|
||||
// Recursively add content from bodyValues to matching parts
|
||||
if (is_array($message->bodySections())) {
|
||||
$addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) {
|
||||
// If this part has a partId and matching bodyValue, add content
|
||||
if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) {
|
||||
$structure['content'] = $bodyValues[$structure['partId']] ?? null;
|
||||
}
|
||||
// Recursively process subParts
|
||||
if (isset($structure['subParts']) && is_array($structure['subParts'])) {
|
||||
foreach ($structure['subParts'] as &$subPart) {
|
||||
$addContentToParts($subPart, $bodyValues);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addContentToParts($this->data['body'], $message->bodySections());
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private static function normalizeFlag(string $flag): string
|
||||
{
|
||||
$flag = ltrim($flag, '\\');
|
||||
|
||||
return match (strtolower($flag)) {
|
||||
'seen' => 'read',
|
||||
'flagged' => 'flagged',
|
||||
'answered' => 'answered',
|
||||
'draft' => 'draft',
|
||||
'deleted' => 'deleted',
|
||||
default => strtolower($flag),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to UTF-8 from the given charset.
|
||||
*
|
||||
@@ -165,42 +157,28 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
||||
/**
|
||||
* Recursively collect attachment parts from body structure
|
||||
*/
|
||||
private static function collectAttachments(
|
||||
Part $part,
|
||||
string $partId,
|
||||
array &$attachments,
|
||||
): void {
|
||||
if ($part instanceof SinglePart) {
|
||||
$type = strtolower($part->type ?? '');
|
||||
$subtype = strtolower($part->subtype ?? '');
|
||||
$disposition = strtolower($part->disposition?->type ?? '');
|
||||
$name = null;
|
||||
if (!empty($part->attributes)) {
|
||||
foreach ($part->attributes as $k => $v) {
|
||||
if (strtolower($k) === 'name') {
|
||||
$name = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($part->disposition?->attributes)) {
|
||||
foreach ($part->disposition->attributes as $k => $v) {
|
||||
if (strtolower($k) === 'filename') {
|
||||
$name = $name ?? $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
$isInlineText = ($type === 'text' && ($subtype === 'plain' || $subtype === 'html') && $disposition !== 'attachment');
|
||||
if (!$isInlineText && ($disposition !== '' || $name !== null)) {
|
||||
$mp = (new MessagePart())->fromImap($part, $partId);
|
||||
$attachments[] = $mp->toStore();
|
||||
}
|
||||
} elseif ($part instanceof MultiPart) {
|
||||
foreach ($part->parts as $index => $subPart) {
|
||||
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
|
||||
self::collectAttachments($subPart, $subPartId, $attachments);
|
||||
private static function collectAttachments(ClientMessagePart $part, array &$attachments): void
|
||||
{
|
||||
$children = $part->parts();
|
||||
if ($children !== []) {
|
||||
foreach ($children as $childPart) {
|
||||
self::collectAttachments($childPart, $attachments);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$mimeType = strtolower($part->mimeType());
|
||||
$disposition = strtolower($part->disposition() ?? '');
|
||||
$name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null;
|
||||
$isInlineText = str_starts_with($mimeType, 'text/')
|
||||
&& in_array($mimeType, ['text/plain', 'text/html'], true)
|
||||
&& $disposition !== 'attachment';
|
||||
|
||||
if ($isInlineText || ($disposition === '' && $name === null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachments[] = $part->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,10 +188,13 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove
|
||||
if (!($service instanceof Service)) {
|
||||
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)
|
||||
$service->fromStore(['sid' => 'test']);
|
||||
|
||||
// Attempt to authenticate and list mailboxes as a connectivity check
|
||||
$wrapper = RemoteService::freshClient($service);
|
||||
$mailboxes = $wrapper->mailboxes();
|
||||
$client = RemoteService::freshClient($service);
|
||||
$service = RemoteService::mailService($service, $client);
|
||||
$mailboxes = $service->collectionList();
|
||||
|
||||
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Providers;
|
||||
|
||||
use Gricob\IMAP\Configuration;
|
||||
use KTXM\ProviderImap\Client\ConnectionConfig;
|
||||
use KTXM\ProviderImap\Client\ConnectionSecurity;
|
||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||
|
||||
/**
|
||||
@@ -101,34 +102,28 @@ class ServiceLocation implements ResourceServiceLocationInterface
|
||||
public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; }
|
||||
public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; }
|
||||
|
||||
// ── gricob helper ────────────────────────────────────────────────────────
|
||||
// ── Client helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a Gricob IMAP Configuration from this location.
|
||||
*
|
||||
* gricob passes the transport directly to stream_socket_client:
|
||||
* 'ssl' → ssl://host:port (implicit TLS, port 993)
|
||||
* 'tcp' → tcp://host:port (plain TCP; STARTTLS negotiation is not
|
||||
* supported by gricob, so starttls/none both
|
||||
* use plain TCP)
|
||||
* Build a standalone IMAP client ConnectionConfig from this location.
|
||||
*/
|
||||
public function toConfiguration(): Configuration
|
||||
public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig
|
||||
{
|
||||
// Map our encryption label to a stream_socket_client transport.
|
||||
// gricob has no STARTTLS negotiation, so starttls falls back to tcp.
|
||||
$transport = match ($this->encryption) {
|
||||
'ssl', 'tls' => 'ssl',
|
||||
default => 'tcp', // starttls, none
|
||||
$security = match ($this->encryption) {
|
||||
'ssl', 'tls' => ConnectionSecurity::Tls,
|
||||
'starttls' => ConnectionSecurity::StartTls,
|
||||
default => ConnectionSecurity::Plain,
|
||||
};
|
||||
|
||||
return new Configuration(
|
||||
transport: $transport,
|
||||
host: $this->host,
|
||||
port: $this->port,
|
||||
verifyPeer: $this->verifyPeer,
|
||||
verifyPeerName: $this->verifyPeerName,
|
||||
allowSelfSigned: $this->allowSelfSigned,
|
||||
useUid: true,
|
||||
return new ConnectionConfig(
|
||||
host: $this->host,
|
||||
port: $this->port,
|
||||
security: $security,
|
||||
username: $username,
|
||||
password: $password,
|
||||
verifyPeer: $this->verifyPeer,
|
||||
verifyPeerName: $this->verifyPeerName,
|
||||
allowSelfSigned: $this->allowSelfSigned,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user