* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderJmapc\Service\Remote; use Exception; use JmapClient\Client; use JmapClient\Requests\Blob\BlobGet; use JmapClient\Requests\Blob\BlobSet; use JmapClient\Requests\Mail\MailboxGet; use JmapClient\Requests\Mail\MailboxParameters as MailboxParametersRequest; use JmapClient\Requests\Mail\MailboxQuery; use JmapClient\Requests\Mail\MailboxSet; use JmapClient\Requests\Mail\MailChanges; use JmapClient\Requests\Mail\MailGet; use JmapClient\Requests\Mail\MailIdentityGet; use JmapClient\Requests\Mail\MailParameters as MailParametersRequest; use JmapClient\Requests\Mail\MailQuery; use JmapClient\Requests\Mail\MailQueryChanges; use JmapClient\Requests\Mail\MailSet; use JmapClient\Requests\Mail\MailSubmissionSet; use JmapClient\Responses\Mail\MailboxParameters as MailboxParametersResponse; use JmapClient\Responses\Mail\MailParameters as MailParametersResponse; use JmapClient\Responses\ResponseException; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Delta\DeltaCollection; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\Range; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderJmapc\Exception\JmapUnknownMethod; use KTXM\ProviderJmapc\Objects\Mail\Collection as MailCollectionObject; class RemoteMailService { protected Client $dataStore; protected string $dataAccount; protected ?string $resourceNamespace = null; protected ?string $resourceCollectionLabel = null; protected ?string $resourceEntityLabel = null; protected array $defaultMailProperties = [ 'id', 'blobId', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'messageId', 'inReplyTo', 'references', 'sender', 'from', 'to', 'cc', 'bcc', 'replyTo', 'subject', 'sentAt', 'hasAttachment', 'attachments', 'preview', 'bodyStructure', 'bodyValues' ]; public function __construct() { } public function initialize(Client $dataStore, ?string $dataAccount = null) { $this->dataStore = $dataStore; // evaluate if client is connected if (!$this->dataStore->sessionStatus()) { $this->dataStore->connect(); } // determine account if ($dataAccount === null) { if ($this->resourceNamespace !== null) { $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); } else { $account = $dataStore->sessionAccountDefault('mail'); } $this->dataAccount = $account !== null ? $account->id() : ''; } else { $this->dataAccount = $dataAccount; } } /** * 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, IRange|null $range = null): array { // construct request $r0 = new MailboxQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // define location if (!empty($location)) { $r0->filter()->in($location); } // define filter if ($filter !== null) { foreach ($filter->conditions() as $condition) { $value = $condition['value']; match($condition['attribute']) { 'in' => $r0->filter()->in($value), 'name' => $r0->filter()->name($value), 'role' => $r0->filter()->role($value), 'hasRoles' => $r0->filter()->hasRoles($value), 'subscribed' => $r0->filter()->isSubscribed($value), default => null }; } } // define order if ($sort !== null) { foreach ($sort->conditions() as $condition) { $direction = $condition['direction']; match($condition['attribute']) { 'name' => $r0->sort()->name($direction), 'order' => $r0->sort()->order($direction), default => null }; } } // construct request $r1 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // define target $r1->targetFromRequest($r0, '/ids'); // transceive $bundle = $this->dataStore->perform([$r0, $r1]); // extract response $response = $bundle->response(1); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // convert jmap objects to collection objects $list = []; foreach ($response->objects() as $so) { if (!$so instanceof MailboxParametersResponse) { continue; } $id = $so->id(); $list[$id] = $so->parametersRaw(); $list[$id]['signature'] = $response->state(); } // return collection of collections return $list; } /** * fresh instance of collection filter object * * @since Release 1.0.0 */ public function collectionListFilter(): Filter { return new Filter(['in', 'name', 'role', 'hasRoles', 'subscribed']); } /** * fresh instance of collection sort object * * @since Release 1.0.0 */ public function collectionListSort(): Sort { return new Sort(['name', 'order']); } /** * check existence of collections in remote storage * * @since Release 1.0.0 */ public function collectionExtant(string ...$identifiers): array { $extant = []; // construct request $r0 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); $r0->property('id'); // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // convert jmap objects to collection objects foreach ($response->objects() as $so) { if (!$so instanceof MailboxParametersResponse) { continue; } $extant[$so->id()] = true; } return $extant; } /** * retrieve properties for specific collection * * @since Release 1.0.0 */ public function collectionFetch(string $identifier): ?array { // construct request $r0 = new MailboxGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target($identifier); // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // convert jmap object to collection object $so = $response->object(0); $to = null; if ($so instanceof MailboxParametersResponse) { $to = $so->parametersRaw(); $to['signature'] = $response->state(); } return $to; } /** * create collection in remote storage * * @since Release 1.0.0 */ public function collectionCreate(string|null $location, array $so): ?array { // convert entity $to = new MailboxParametersRequest(); $to->parametersRaw($so); // define location if (!empty($location)) { $to->in($location); } $id = uniqid(); // construct request $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create($id, $to); // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // check for success $result = $response->createSuccess($id); if ($result !== null) { return array_merge($so, $result); } // check for failure $result = $response->createFailure($id); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; throw new Exception("$type: $description", 1); } // return null if creation failed without failure reason return null; } /** * modify collection in remote storage * * @since Release 1.0.0 * */ public function collectionModify(string $identifier, array $so): ?array { // convert entity $to = new MailboxParametersRequest(); $to->parametersRaw($so); // construct request $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($identifier, $to); // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // check for success $result = $response->updateSuccess($identifier); if ($result !== null) { return array_merge($so, $result); } // check for failure $result = $response->updateFailure($identifier); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; throw new Exception("$type: $description", 1); } // return null if modification failed without failure reason return null; } /** * delete collection in remote storage * * @since Release 1.0.0 * */ public function collectionDestroy(string $identifier, bool $force = false, bool $recursive = false): ?string { // construct request $r0 = new MailboxSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->delete($identifier); if ($force) { $r0->destroyContents(true); } // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // check for success $result = $response->deleteSuccess($identifier); if ($result !== null) { return (string)$result['id']; } // check for failure $result = $response->deleteFailure($identifier); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; throw new Exception("$type: $description", 1); } // return null if deletion failed without failure reason return null; } /** * retrieve entities from remote storage * * @since Release 1.0.0 */ public function entityList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, IRange|null $range = null, string|null $granularity = null): array { // construct request $r0 = new MailQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // define location if (!empty($location)) { $r0->filter()->in($location); } // define filter if ($filter !== null) { foreach ($filter->conditions() as $condition) { $value = $condition['value']; match($condition['attribute']) { '*' => $r0->filter()->text($value), 'in' => $r0->filter()->in($value), 'inOmit' => $r0->filter()->inOmit($value), 'from' => $r0->filter()->from($value), 'to' => $r0->filter()->to($value), 'cc' => $r0->filter()->cc($value), 'bcc' => $r0->filter()->bcc($value), 'subject' => $r0->filter()->subject($value), 'body' => $r0->filter()->body($value), 'attachmentPresent' => $r0->filter()->hasAttachment($value), 'tagPresent' => $r0->filter()->keywordPresent($value), 'tagAbsent' => $r0->filter()->keywordAbsent($value), 'before' => $r0->filter()->receivedBefore($value), 'after' => $r0->filter()->receivedAfter($value), 'min' => $r0->filter()->sizeMin((int)$value), 'max' => $r0->filter()->sizeMax((int)$value), default => null }; } } // define order if ($sort !== null) { foreach ($sort->conditions() as $condition) { $direction = $condition['direction']; match($condition['attribute']) { 'from' => $r0->sort()->from($direction), 'to' => $r0->sort()->to($direction), 'subject' => $r0->sort()->subject($direction), 'received' => $r0->sort()->received($direction), 'sent' => $r0->sort()->sent($direction), 'size' => $r0->sort()->size($direction), 'tag' => $r0->sort()->keyword($direction), default => null }; } } // define range if ($range !== null) { if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::ABSOLUTE) { $r0->limitAbsolute($range->getPosition(), $range->getTally()); } if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::RELATIVE) { $r0->limitRelative($range->getPosition(), $range->getTally()); } } // construct get request $r1 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set target to query request $r1->targetFromRequest($r0, '/ids'); // select properties to return $r1->property(...$this->defaultMailProperties); $r1->bodyAll(true); // transmit request and receive response $bundle = $this->dataStore->perform([$r0, $r1]); // extract response $response = $bundle->response(1); // convert json objects to message objects $state = $response->state(); $list = $response->objects(); foreach ($list as $id => $entry) { if (!$entry instanceof MailParametersResponse) { continue; } $list[$id] = $entry->parametersRaw(); } // return message collection return ['list' => $list, 'state' => $state]; } /** * fresh instance of object filter * * @since Release 1.0.0 */ public function entityListFilter(): Filter { return new Filter([ 'in', 'inOmit', 'text', 'from', 'to', 'cc', 'bcc', 'subject', 'body', 'attachmentPresent', 'tagPresent', 'tagAbsent', 'receivedBefore', 'receivedAfter', 'sizeMin', 'sizeMax' ]); } /** * fresh instance of object sort * * @since Release 1.0.0 */ public function entityListSort(): Sort { return new Sort([ 'received', 'sent', 'from', 'to', 'subject', 'size', 'tag' ]); } /** * fresh instance of object range * * @since Release 1.0.0 */ public function entityListRange(): RangeTally { return new RangeTally(); } /** * check existence of entities in remote storage * * @since Release 1.0.0 */ public function entityExtant(string ...$identifiers): array { $extant = []; // construct request $r0 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); $r0->property('id'); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // convert json objects to message objects foreach ($response->objects() as $so) { if (!$so instanceof MailParametersResponse) { continue; } $extant[$so->id()] = true; } return $extant; } /** * delta for entities in remote storage * * @since Release 1.0.0 * * @return Delta */ public function entityDelta(?string $location, string $state, string $granularity = 'D'): Delta { if (empty($state)) { $results = $this->entityList($location, null, null, null, 'B'); $delta = new Delta(); $delta->signature = $results['state']; foreach ($results['list'] as $entry) { $delta->additions[] = $entry['id']; } return $delta; } if (empty($location)) { return $this->entityDeltaDefault($state, $granularity); } else { return $this->entityDeltaSpecific($location, $state, $granularity); } } /** * delta of changes for specific collection in remote storage * * @since Release 1.0.0 * */ public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): Delta { // construct set request $r0 = new MailQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set location constraint if (!empty($location)) { $r0->filter()->in($location); } // set state constraint if (!empty($state)) { $r0->state($state); } else { $r0->state('0'); } // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // convert jmap object to delta object $delta = new Delta(); $delta->signature = $response->stateNew(); $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); $delta->modifications = new DeltaCollection([]); $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); return $delta; } /** * delta of changes in remote storage * * @since Release 1.0.0 * */ public function entityDeltaDefault(string $state, string $granularity = 'D'): Delta { // construct set request $r0 = new MailChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set state constraint if (!empty($state)) { $r0->state($state); } else { $r0->state(''); } // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // convert jmap object to delta object $delta = new Delta(); $delta->signature = $response->stateNew(); $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); $delta->modifications = new DeltaCollection([]); $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); return $delta; } /** * retrieve entity from remote storage * * @since Release 1.0.0 */ public function entityFetch(string ...$identifiers): ?array { // construct request $r0 = new MailGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); // select properties to return $r0->property(...$this->defaultMailProperties); $r0->bodyAll(true); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // convert json objects to message objects $list = []; foreach ($response->objects() as $so) { if (!$so instanceof MailParametersResponse) { continue; } $id = $so->id(); $list[$id] = $so->parametersRaw(); $list[$id]['signature'] = $response->state(); } // return message collection return $list; } /** * create entity in remote storage * * @since Release 1.0.0 */ public function entityCreate(string $location, array $so): ?array { // convert entity $to = new MailParametersRequest(); $to->parametersRaw($so); $to->in($location); $id = uniqid(); // construct request $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create($id, $to); // transceive $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // check for command error if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } // check for success $result = $response->createSuccess($id); if ($result !== null) { return array_merge($so, $result); } // check for failure $result = $response->createFailure($id); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; throw new Exception("$type: $description", 1); } // return null if creation failed without failure reason return null; } /** * update entity in remote storage * * @since Release 1.0.0 */ public function entityModify(array $so): ?array { // extract entity id $id = $so['id']; // convert entity $to = new MailParametersRequest(); $to->parametersRaw($so); // construct request $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($id, $to); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_key_exists($id, $response->updated())) { // update entity $ro = $response->updated()[$id]; $so = array_merge($so, $ro); return $so; } return null; } /** * delete entity from remote storage * * @since Release 1.0.0 */ public function entityDelete(string $id): ?string { // construct set request $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // construct object $r0->delete($id); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_search($id, $response->deleted()) !== false) { return $response->stateNew(); } return null; } /** * copy entity in remote storage * * @since Release 1.0.0 * */ public function entityCopy(string $location, MailMessageObject $so): ?MailMessageObject { return null; } /** * move entity in remote storage * * @since Release 1.0.0 * */ public function entityMove(string $location, array $so): ?array { // extract entity id $id = $so['id']; // construct request $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($id)->in($location); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_key_exists($id, $response->updated())) { $so = array_merge($so, ['mailboxIds' => [$location => true]]); return $so; } return null; } /** * send entity * * @since Release 1.0.0 * */ public function entitySend(string $identity, MailMessageObject $message, ?string $presendLocation = null, ?string $postsendLocation = null): string { // determine if pre-send location is present if ($presendLocation === null || empty($presendLocation)) { throw new Exception('Pre-Send Location is missing', 1); } // determine if post-send location is present if ($postsendLocation === null || empty($postsendLocation)) { throw new Exception('Post-Send Location is missing', 1); } // determine if we have the basic required data and fail otherwise if (empty($message->getFrom())) { throw new Exception('Missing Requirements: Message MUST have a From address', 1); } if (empty($message->getTo())) { throw new Exception('Missing Requirements: Message MUST have a To address(es)', 1); } // determine if message has attachments if (count($message->getAttachments()) > 0) { // process attachments first $message = $this->depositAttachmentsFromMessage($message); } // convert from address object to string $from = $message->getFrom()->getAddress(); // convert to, cc and bcc address object arrays to single strings array $to = array_map( function ($entry) { return $entry->getAddress(); }, array_merge($message->getTo(), $message->getCc(), $message->getBcc()) ); unset($cc, $bcc); // construct set request $r0 = new MailSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create('1', $message)->in($presendLocation); // construct set request $r1 = new MailSubmissionSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // construct envelope $e1 = $r1->create('2'); $e1->identity($identity); $e1->message('#1'); $e1->from($from); $e1->to($to); // transmit request and receive response $bundle = $this->dataStore->perform([$r0, $r1]); // extract response $response = $bundle->response(1); // return collection information return (string)$response->created()['2']['id']; } /** * retrieve collection entity attachment from remote storage * * @since Release 1.0.0 * */ public function depositAttachmentsFromMessage(MailMessageObject $message): MailMessageObject { $parameters = $message->toJmap(); $attachments = $message->getAttachments(); $matches = []; $this->findAttachmentParts($parameters['bodyStructure'], $matches); foreach ($attachments as $attachment) { $part = $attachment->toJmap(); if (isset($matches[$part->getId()])) { // deposit attachment in data store $response = $this->blobDeposit($account, $part->getType(), $attachment->getContents()); // transfer blobId and size to mail part $matches[$part->getId()]->blobId = $response['blobId']; $matches[$part->getId()]->size = $response['size']; unset($matches[$part->getId()]->partId); } } return (new MailMessageObject())->fromJmap($parameters); } protected function findAttachmentParts(object &$part, array &$matches) { if ($part->disposition === 'attachment' || $part->disposition === 'inline') { $matches[$part->partId] = $part; } foreach ($part->subParts as $entry) { $this->findAttachmentParts($entry, $matches); } } /** * retrieve identity from remote storage * * @since Release 1.0.0 * */ public function identityFetch(?string $account = null): array { if ($account === null) { $account = $this->dataAccount; } // construct set request $r0 = new MailIdentityGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // convert json object to message object and return return $response->objects(); } }