Files
provider_imap/lib/Service/Remote/RemoteMailService.php
2026-03-28 12:43:42 -04:00

358 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImap\Service\Remote;
use DateTimeImmutable;
use Generator;
use Gricob\IMAP\Client;
use Gricob\IMAP\Protocol\Command\Argument\Search\Before;
use Gricob\IMAP\Protocol\Command\Argument\Search\Body;
use Gricob\IMAP\Protocol\Command\Argument\Search\Flagged;
use Gricob\IMAP\Protocol\Command\Argument\Search\From;
use Gricob\IMAP\Protocol\Command\Argument\Search\Larger;
use Gricob\IMAP\Protocol\Command\Argument\Search\Seen;
use Gricob\IMAP\Protocol\Command\Argument\Search\Since;
use Gricob\IMAP\Protocol\Command\Argument\Search\Smaller;
use Gricob\IMAP\Protocol\Command\Argument\Search\Subject;
use Gricob\IMAP\Protocol\Command\Argument\Search\To;
use Gricob\IMAP\Protocol\Command\Argument\Search\Unflagged;
use Gricob\IMAP\Protocol\Command\Argument\Search\Unseen;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeTally;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
/**
* IMAP Remote Mail Service
*/
class RemoteMailService
{
/**
* Default IMAP FETCH data items used for message hydration
*/
private const DEFAULT_FETCH_ITEMS = [
'FLAGS',
'ENVELOPE',
'INTERNALDATE',
'RFC822.SIZE',
'BODYSTRUCTURE',
'UID',
'BODY[TEXT]'
];
public function __construct(
private readonly Client $client,
private readonly string $provider,
private readonly string|int $service,
) {}
/**
* List all selectable mailboxes on the server.
*
* @return array<string, CollectionResource> keyed by mailbox name
*/
public function collectionList(): array
{
$result = [];
foreach ($this->client->mailboxes() as $mailbox) {
if (!$mailbox->isSelectable()) {
continue;
}
$resource = new CollectionResource($this->provider, $this->service);
$resource->fromImap($mailbox);
$result[$resource->identifier()] = $resource;
}
return $result;
}
/**
* Fetch a single mailbox by its full name.
*
* Returns null when no mailbox matching $name is found.
*/
public function collectionFetch(string $name): ?CollectionResource
{
foreach ($this->client->mailboxes() as $mailbox) {
if ($mailbox->name === $name) {
$resource = new CollectionResource($this->provider, $this->service);
$resource->fromImap($mailbox);
return $resource;
}
}
return null;
}
/**
* Create a new IMAP mailbox and return it.
*
* 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): CollectionResource
{
$this->client->createMailbox($name);
// Attempt to refetch the new mailbox from the server
$resource = $this->collectionFetch($name);
if ($resource !== null) {
return $resource;
}
// Fallback: return a minimal resource with just the name set
$resource = new CollectionResource($this->provider, $this->service);
$resource->fromStore(['identifier' => $name, 'collection' => null]);
return $resource;
}
/**
* Rename a mailbox and return the updated resource.
*/
public function collectionRename(string $oldName, string $newName): CollectionResource
{
$this->client->renameMailbox($oldName, $newName);
$resource = $this->collectionFetch($newName);
if ($resource !== null) {
return $resource;
}
$resource = new CollectionResource($this->provider, $this->service);
$resource->fromStore(['identifier' => $newName, 'collection' => null]);
return $resource;
}
/**
* Delete a mailbox by its full name.
*/
public function collectionDestroy(string $name): bool
{
$this->client->deleteMailbox($name);
return true;
}
// ── Entity (message) operations ───────────────────────────────────────────
/**
* Return UIDs present in a mailbox, optionally filtered and paginated.
*
* UIDs are always returned descending (highest = newest first).
* When a RangeTally $range is supplied:
* - ABSOLUTE anchor: slice from position offset for tally items
* - RELATIVE anchor: find the UID whose value equals position, then
* return the next tally items (cursor-based paging)
*
* @return int[]
*/
public function entityList(string $collection, ?IFilter $filter = null, ?IRange $range = null): array
{
$criteria = [];
if ($filter !== null) {
foreach ($filter->conditions() as $condition) {
$attribute = $condition['attribute'];
$value = $condition['value'];
$criterion = match ($attribute) {
'seen' => $value ? new Seen() : new Unseen(),
'flagged' => $value ? new Flagged() : new Unflagged(),
'from' => new From($value),
'to' => new To($value),
'subject' => new Subject($value),
'body' => new Body($value),
'before' => new Before(new DateTimeImmutable($value)),
'after' => new Since(new DateTimeImmutable($value)),
'min' => new Larger($value),
'max' => new Smaller($value),
default => null,
};
if ($criterion !== null) {
$criteria[] = $criterion;
}
}
}
$uids = $this->client->searchMessages($collection, $criteria);
if (empty($uids)) {
return [];
}
rsort($uids);
if ($range instanceof RangeTally) {
$position = (int) $range->getPosition();
$tally = $range->getTally();
if ($range->getAnchor() === RangeAnchorType::RELATIVE) {
// Cursor-based: find the anchor UID then take the next slice
$index = array_search($position, $uids, true);
$start = $index !== false ? $index + 1 : 0;
} else {
// Absolute offset
$start = $position;
}
$uids = array_slice($uids, $start, $tally);
}
return $uids;
}
/**
* Fetch one or more messages by UID and return EntityResource objects.
*
* @param int ...$uids
* @return EntityResource[] keyed by UID
*/
public function entityFetch(string $collection, int ...$uids): array
{
if (empty($uids)) {
return [];
}
$this->client->select($collection);
$result = [];
foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
$result[$uid] = $resource;
}
return $result;
}
public function entityFetchStream(string $collection, int ...$uids): Generator
{
if (empty($uids)) {
return;
}
$this->client->select($collection);
foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
/**
* Fetch every message in a mailbox using a single FETCH 1:* command and
* return all EntityResource objects as an array keyed by UID.
*
* Use this for unfiltered, unpaginated listing where a two-round-trip
* SEARCH-then-FETCH approach would be wasteful.
*
* @param string[] $items IMAP fetch data items
* @return EntityResource[] keyed by UID
*/
public function entityFetchAll(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): array
{
$result = [];
foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
$result[$uid] = $resource;
}
return $result;
}
/**
* Stream every message in a mailbox using FETCH 1:*, yielding
* uid => EntityResource as each FETCH response arrives off the socket.
*
* Use this for unfiltered streaming where a SEARCH ALL round-trip would be
* an unnecessary extra RTT.
*
* @param string[] $items IMAP fetch data items
* @return Generator<int, EntityResource>
*/
public function entityFetchAllStream(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): Generator
{
foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
/**
* Stream messages one at a time as EntityResource objects.
*
* Yields uid (int) => EntityResource. Use this for large mailbox syncs to
* avoid holding thousands of objects in memory simultaneously.
*
* Pass a custom $items array to restrict the fetched data (e.g. ['FLAGS', 'UID']
* for a flags-only sync). Defaults to DEFAULT_FETCH_ITEMS.
*
* @param int[] $uids
* @param string[] $items IMAP fetch data items
* @return \Generator<int, EntityResource>
*/
public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): Generator
{
$this->client->select($collection);
foreach ($this->client->streamByUids($uids, $items) as $uid => $fetchData) {
$resource = new EntityResource($this->provider, $this->service);
$resource->fromImap($fetchData, $collection);
yield $uid => $resource;
}
}
/**
* Append a raw RFC 822 message to a mailbox and return the assigned UID.
*
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
*/
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
{
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
}
/**
* Modify message flags for one or more messages.
*
* @param string $action '+' to add, '-' to remove, '' to replace
* @param string[] $flags e.g. ['\\Seen', '\\Flagged']
* @param int ...$uids
*/
public function entityModify(string $collection, string $action, array $flags, int ...$uids): void
{
if (empty($uids)) {
return;
}
$this->client->storeFlags($collection, array_values($uids), $action, $flags);
}
/**
* Permanently delete one or more messages by UID.
*/
public function entityDestroy(string $collection, int ...$uids): void
{
if (empty($uids)) {
return;
}
$this->client->deleteMessages($collection, array_values($uids));
}
/**
* Copy one or more messages to a destination mailbox.
*/
public function entityCopy(string $collection, string $destination, int ...$uids): void
{
if (empty($uids)) {
return;
}
$this->client->copyMessages($collection, array_values($uids), $destination);
}
}