generated from Nodarx/template
376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\ProviderImapMail\Service\Remote;
|
|
|
|
use DateTimeImmutable;
|
|
use Generator;
|
|
use Gricob\IMAP\Protocol\Command\Argument\Search\Before;
|
|
use Gricob\IMAP\Protocol\Command\Argument\Search\Since;
|
|
use KTXF\Resource\Filter\IFilter;
|
|
use KTXF\Resource\Range\IRange;
|
|
use KTXF\Resource\Range\RangeAnchorType;
|
|
use KTXF\Resource\Range\RangeTally;
|
|
use KTXM\ProviderImapMail\Providers\CollectionResource;
|
|
use KTXM\ProviderImapMail\Providers\EntityResource;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\BodyCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\FlaggedCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\FromCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\LargerCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\SeenCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\SmallerCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\SubjectCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\ToCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\UnflaggedCriteria;
|
|
use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria;
|
|
|
|
/**
|
|
* IMAP Remote Mail Service
|
|
*
|
|
* Provides collection (mailbox) and entity (message) operations against a live
|
|
* IMAP server via ImapClientWrapper. All methods are stateless — no caching or
|
|
* local storage happens here.
|
|
*/
|
|
class RemoteMailService
|
|
{
|
|
/**
|
|
* Default IMAP FETCH data items used for message hydration.
|
|
*
|
|
* RFC 822 size, flags, arrival date, envelope headers, and the BODYSTRUCTURE
|
|
* MIME tree give us everything needed to build an EntityResource without
|
|
* downloading the full message body.
|
|
*/
|
|
private const DEFAULT_FETCH_ITEMS = [
|
|
'FLAGS',
|
|
'ENVELOPE',
|
|
'INTERNALDATE',
|
|
'RFC822.SIZE',
|
|
'BODYSTRUCTURE',
|
|
'UID',
|
|
'BODY[TEXT]'
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly ImapClientWrapper $client,
|
|
private readonly string $provider,
|
|
private readonly string|int $service,
|
|
) {}
|
|
|
|
// ── Collection (mailbox) operations ──────────────────────────────────────
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// ── Build IMAP SEARCH criteria from filter ────────────────────────────
|
|
$criteria = [];
|
|
if ($filter !== null) {
|
|
foreach ($filter->conditions() as $condition) {
|
|
$attribute = $condition['attribute'];
|
|
$value = $condition['value'];
|
|
$criterion = match ($attribute) {
|
|
'seen' => $value ? new SeenCriteria() : new UnseenCriteria(),
|
|
'flagged' => $value ? new FlaggedCriteria() : new UnflaggedCriteria(),
|
|
'from' => new FromCriteria($value),
|
|
'to' => new ToCriteria($value),
|
|
'subject' => new SubjectCriteria($value),
|
|
'body' => new BodyCriteria($value),
|
|
'before' => new Before(new DateTimeImmutable($value)),
|
|
'after' => new Since(new DateTimeImmutable($value)),
|
|
'min' => new LargerCriteria($value),
|
|
'max' => new SmallerCriteria($value),
|
|
default => null,
|
|
};
|
|
if ($criterion !== null) {
|
|
$criteria[] = $criterion;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Execute IMAP SEARCH (ALL when no criteria) ────────────────────────
|
|
$uids = empty($criteria)
|
|
? $this->client->searchAll($collection)
|
|
: $this->client->searchMessages($collection, $criteria);
|
|
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
// ── Sort descending: highest UID (newest) first ───────────────────────
|
|
rsort($uids);
|
|
|
|
// ── Apply RangeTally pagination ───────────────────────────────────────
|
|
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.
|
|
*
|
|
* Uses client->fetchMultiple() which streams FetchData responses one at a
|
|
* time via sendStreaming — memory-efficient even for large UID sets. Body
|
|
* content is NOT pre-loaded; call fetchBody() on the returned resource
|
|
* when the decoded body is needed (lazy, one extra round-trip per message).
|
|
*
|
|
* @param int ...$uids
|
|
* @return EntityResource[] keyed by UID
|
|
*/
|
|
public function entityFetch(string $collection, int ...$uids): array
|
|
{
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
|
|
$resource = new EntityResource($this->provider, $this->service);
|
|
$resource->fromImap($fetchData, $collection, null);
|
|
$result[$uid] = $resource;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public function entityFetchStream(string $collection, int ...$uids): Generator
|
|
{
|
|
if (empty($uids)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) {
|
|
$resource = new EntityResource($this->provider, $this->service);
|
|
$resource->fromImap($fetchData, $collection, null);
|
|
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
|
|
{
|
|
foreach ($this->client->fetchMultiple($collection, $uids, $items) as $uid => $fetchData) {
|
|
$resource = new EntityResource($this->provider, $this->service);
|
|
$resource->fromImap($fetchData, $collection, null);
|
|
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, $flags);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compact a flat array of UIDs into an IMAP sequence-set string.
|
|
*
|
|
* Consecutive UIDs are collapsed into n:m ranges; non-consecutive UIDs are
|
|
* comma-separated. The input does not need to be sorted.
|
|
*
|
|
* Examples:
|
|
* [1, 2, 3, 5, 6, 10] → "1:3,5:6,10"
|
|
* [42] → "42"
|
|
* [7, 3, 4, 5] → "3:5,7"
|
|
*
|
|
* @param int[] $uids
|
|
*/
|
|
private function uidsToRangeSet(array $uids): string
|
|
{
|
|
if (empty($uids)) {
|
|
return '';
|
|
}
|
|
|
|
$uids = array_unique($uids);
|
|
sort($uids);
|
|
|
|
$ranges = [];
|
|
$start = $end = $uids[0];
|
|
|
|
for ($i = 1, $count = count($uids); $i <= $count; $i++) {
|
|
$current = $uids[$i] ?? null;
|
|
if ($current !== null && $current === $end + 1) {
|
|
$end = $current;
|
|
} else {
|
|
$ranges[] = $start === $end ? (string) $start : $start . ':' . $end;
|
|
if ($current !== null) {
|
|
$start = $end = $current;
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode(',', $ranges);
|
|
}
|
|
}
|