* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Service\Remote; use DateTimeImmutable; use Generator; use KTXM\ProviderImap\Client\Client; use KTXM\ProviderImap\Client\Command\FetchManyCommand; use KTXM\ProviderImap\Client\Command\ExpungeCommand; use KTXM\ProviderImap\Client\Command\ListCommand; use KTXM\ProviderImap\Client\Command\SearchCommand; use KTXM\ProviderImap\Client\Command\SelectCommand; use KTXM\ProviderImap\Client\Command\SortCommand; use KTXM\ProviderImap\Client\Command\StatusCommand; use KTXM\ProviderImap\Client\Command\StoreCommand; use KTXM\ProviderImap\Client\Command\CopyCommand; use KTXM\ProviderImap\Client\Command\CreateCommand; use KTXM\ProviderImap\Client\Command\RenameCommand; use KTXM\ProviderImap\Client\Command\DeleteCommand; use KTXM\ProviderImap\Client\FetchTarget; use KTXM\ProviderImap\Client\FetchOptions; use KTXM\ProviderImap\Client\IdentifierMode; use KTXM\ProviderImap\Client\ImapException; use KTXM\ProviderImap\Client\ListReturnOptions; use KTXM\ProviderImap\Client\Mailbox; use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\MessageAddress; use KTXM\ProviderImap\Client\MessagePart; use KTXM\ProviderImap\Client\Command\MoveCommand; use KTXM\ProviderImap\Client\SearchCriteriaBuilder; use KTXM\ProviderImap\Client\SequenceSet; use KTXF\Mail\Collection\CollectionRoles; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Filter\FilterComparisonOperator; use KTXF\Resource\Filter\FilterConjunctionOperator; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; /** * IMAP Remote Mail Service */ class RemoteMailService { private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription']; private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']; public function __construct( private readonly Client $client, private readonly string $provider, private readonly string|int $service, ) {} /** * 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 { // Prepare location filter if (!empty($location)) { $location = ltrim($location, '/'); if (!str_ends_with($location, '/')) { $location .= '/'; } } else { $location = ''; } // construct the most efficient LIST command based on server capabilities if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) { $command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)); $rfc5258 = true; } else { $command = new ListCommand($location, $depth); $rfc5258 = false; } // retrieve list of mailboxes from remote $mailboxes = []; foreach ($this->client->perform($command) as $mailbox) { // apply filter if ($filter === null || $this->mailboxFilter($mailbox, $filter)) { if ($rfc5258) { yield $mailbox->name() => $mailbox; } else { $mailboxes[] = $mailbox; } } } // enrich with STATUS info if not already provided by LIST-STATUS response if (!$rfc5258) { foreach ($mailboxes as $index => $mailbox) { if (!$mailbox->isSelectable()) { yield $mailbox->name() => $mailbox; continue; } try { $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); $mailbox->fromStatus($status); } catch (ImapException) { // do nothing } yield $mailbox->name() => $mailbox; } } return; } /** * Fetch a single mailbox by its full name. * * Returns null when no mailbox matching $identifier is found. */ public function collectionFetch(string $identifier): ?Mailbox { // retrieve mailbox from remote $mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS)))); if (empty($mailbox)) { return null; } $mailbox = reset($mailbox); // enrich with STATUS $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); $mailbox->fromStatus($status); return $mailbox; } /** * 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): Mailbox { $result = $this->client->perform(new CreateCommand($name)); if (!$result->isOk()) { throw new ImapException('Failed to create mailbox: ' . $name); } // Attempt to refetch the new mailbox from the server $mailbox = $this->collectionFetch($name); if ($mailbox === null) { throw new ImapException('Failed to create mailbox: ' . $name); } return $mailbox; } /** * Rename a mailbox and return the updated resource. */ public function collectionRename(string $oldName, string $newName): Mailbox { $result = $this->client->perform(new RenameCommand($oldName, $newName)); if (!$result->isOk()) { throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); } $mailbox = $this->collectionFetch($newName); if ($mailbox === null) { throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); } return $mailbox; } /** * Delete a mailbox by its full name. */ public function collectionDestroy(string $name): bool { $result = $this->client->perform(new DeleteCommand($name)); if (!$result->isOk()) { throw new ImapException('Failed to delete mailbox: ' . $name); } return true; } // ── Entity (message) operations ─────────────────────────────────────────── /** * Find UIDs of messages in a mailbox matching the given filter, sorted and paginated as requested. * * @return int[] list of UIDs matching the filter, sorted and paginated as requested */ public function entityFind(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { $nativeFilter = $this->buildEntitySearchCriteria($filter); $nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : []; $this->client->perform(new SelectCommand($collection, true)); $rfc5258 = $this->client->hasCapability('SORT'); $uids = []; if ($nativeSort !== [] && $rfc5258) { $uids = $this->client->perform(new SortCommand( $nativeSort, $nativeFilter, IdentifierMode::Uid, ))->matches(); } else { $uids = $this->client->perform(new SearchCommand( $nativeFilter, IdentifierMode::Uid, ))->matches(); } if ($uids === []) { return []; } if ($sort !== null && $nativeSort === []) { $uids = $this->entitySortClientSide($uids, $sort); } return $this->entityApplyRange($uids, $range); } /** * Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested. * * @return Message[] list of messages matching the filter, sorted and paginated as requested */ public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator { // find all the UIDs matching the filter $uids = $this->entityFind($collection, $filter, $sort, $range); if (empty($uids)) { return []; } $options = FetchOptions::default()->withBodyText(); yield from $this->entityFetch($collection, $options, ...$uids); } /** * Fetch one or more messages by UID and return EntityResource objects. * * @param int ...$uids * @return Message[] keyed by UID */ public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator { if (empty($uids)) { return []; } $options ??= FetchOptions::default(); $this->client->perform(new SelectCommand($collection, true)); $request = new FetchManyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $options, ); foreach ($this->client->perform($request) as $message) { $uid = $message->uid() ?: $message->sequence(); yield $uid => $message; } } /** * 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->perform(new SelectCommand($collection, false)); $this->client->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $flags, $action, )); } /** * Permanently delete one or more messages by UID. */ public function entityDestroy(string $collection, int ...$uids): array { if (empty($uids)) { return []; } $target = FetchTarget::uid(SequenceSet::items(...array_values($uids))); $this->client->perform(new SelectCommand($collection, false)); $this->client->perform(new StoreCommand($target, ['\\Deleted'], '+')); $this->client->perform(new ExpungeCommand($target)); // TODO: find a way to determine which actual UID's were deleted return array_fill_keys($uids, true); } public function entityMove(string $targetCollection, string $sourceCollection, int ...$uids): array { if (empty($uids)) { return []; } $rfc6851 = $this->client->hasCapability('MOVE'); // if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE if ($rfc6851) { $this->client->perform(new SelectCommand($sourceCollection, false)); $response = $this->client->perform(new MoveCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $targetCollection, )); } else { $this->client->perform(new SelectCommand($sourceCollection, false)); $response = $this->client->perform(new CopyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $targetCollection, )); if ($response->isOk()) { $this->client->perform(new StoreCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), ['\\Deleted'], '+', )); $this->client->perform(new ExpungeCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), )); } } if (!$response->isOk()) { throw new ImapException('Failed to move messages: ' . implode(', ', $response->responseCodes())); } // construct operation result as a map of source UID to boolean or destination UID, depending on server support $map = $response->copyUidMap(); if ($map === []) { $result = array_fill_keys(array_map('strval', $uids), true); } else { $result = array_fill_keys(array_map('strval', $uids), false); foreach ($uids as $uid) { $result[$uid] = $map[$uid] ?? false; } } return $result; } private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder { if ($filter === null || $filter->conditions() === []) { return SearchCriteriaBuilder::create()->all(); } $expression = null; foreach ($filter->conditions() as $condition) { $operand = $this->entityFilterOperand($condition); if ($operand === null) { continue; } if ($expression === null) { $expression = $operand; continue; } $expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR) ? SearchCriteriaBuilder::create()->or($expression, $operand) : SearchCriteriaBuilder::create()->group($expression)->group($operand); } if ($expression === null) { return SearchCriteriaBuilder::create()->all(); } return $expression instanceof SearchCriteriaBuilder ? $expression : SearchCriteriaBuilder::create()->group($expression); } /** * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ private function entityFilterOperand(array $condition): SearchCriteriaBuilder|array|string|null { $attribute = $condition['attribute'] ?? ''; $value = $condition['value'] ?? null; $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; return match ($attribute) { '*', 'all' => $this->entityStringCriteria('text', $value, $comparator), 'from', 'to', 'cc', 'bcc', 'subject', 'body' => $this->entityStringCriteria($attribute, $value, $comparator), 'before' => $this->entityDateCriteria('before', $value, $comparator), 'after' => $this->entityDateCriteria('after', $value, $comparator), 'min' => $this->entitySizeCriteria('min', $value, $comparator), 'max' => $this->entitySizeCriteria('max', $value, $comparator), default => null, }; } private function entityStringCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null { $values = is_array($value) ? array_values($value) : [$value]; $values = array_values(array_filter(array_map( static fn (mixed $item): string => trim((string) $item), $values, ), static fn (string $item): bool => $item !== '')); if ($values === []) { return null; } $mapper = fn (string $item): SearchCriteriaBuilder => $this->entityStringCriterion($attribute, $item); return match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE => count($values) === 1 ? $mapper($values[0]) : $this->entityOrCriteria(array_map($mapper, $values)), FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE => count($values) === 1 ? SearchCriteriaBuilder::create()->not($mapper($values[0])) : SearchCriteriaBuilder::create()->group($this->entityAndCriteria(array_map( static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)), $values, ))), FilterComparisonOperator::IN => $this->entityOrCriteria(array_map($mapper, $values)), FilterComparisonOperator::NIN => $this->entityAndCriteria(array_map( static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)), $values, )), default => null, }; } private function entityStringCriterion(string $attribute, string $value): SearchCriteriaBuilder { return match ($attribute) { 'from' => SearchCriteriaBuilder::create()->from($value), 'to' => SearchCriteriaBuilder::create()->to($value), 'cc' => SearchCriteriaBuilder::create()->cc($value), 'bcc' => SearchCriteriaBuilder::create()->bcc($value), 'subject' => SearchCriteriaBuilder::create()->subject($value), 'body' => SearchCriteriaBuilder::create()->body($value), default => SearchCriteriaBuilder::create()->text($value), }; } private function entityDateCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null { if ($value === null || $value === '') { return null; } $date = $this->normalizeImapDate($value); return match ($attribute) { 'before' => match ($comparator) { FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date), FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)), FilterComparisonOperator::LT, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->before($date), default => null, }, 'after' => match ($comparator) { FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date), FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)), FilterComparisonOperator::GT, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->since($date), default => null, }, default => null, }; } private function entitySizeCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null { if (!is_int($value) && !is_numeric($value)) { return null; } $size = max(0, (int) $value); return match ($attribute) { 'min' => match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)), FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size), FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size), FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1), FilterComparisonOperator::NEQ => $this->entityOrCriteria([ SearchCriteriaBuilder::create()->smaller($size), SearchCriteriaBuilder::create()->larger($size), ]), default => null, }, 'max' => match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1), FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size), FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size), FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)), FilterComparisonOperator::NEQ => $this->entityOrCriteria([ SearchCriteriaBuilder::create()->smaller($size), SearchCriteriaBuilder::create()->larger($size), ]), default => null, }, default => null, }; } /** * @return list */ private function entitySortCriteria(ISort $sort): array { $criteria = []; foreach ($sort->conditions() as $condition) { $attribute = $condition['attribute'] ?? ''; $key = match ($attribute) { 'from' => 'FROM', 'to' => 'TO', 'subject' => 'SUBJECT', 'received' => 'ARRIVAL', 'sent' => 'DATE', 'size' => 'SIZE', default => null, }; if ($key === null) { return []; } $criteria[] = !($condition['direction'] ?? true) ? 'REVERSE ' . $key : $key; } return $criteria; } /** * @param list $uids * @return list */ private function entitySortClientSide(array $uids, ISort $sort): array { $options = FetchOptions::summary(); foreach ($sort->conditions() as $condition) { if (in_array($condition['attribute'] ?? '', ['from', 'to', 'subject', 'sent'], true)) { $options = $options->withEnvelope(); break; } } $messages = iterator_to_array($this->client->perform(new FetchManyCommand( FetchTarget::uid(SequenceSet::items(...array_values($uids))), $options, ))); usort($messages, function (Message $left, Message $right) use ($sort): int { foreach ($sort->conditions() as $condition) { $direction = ($condition['direction'] ?? true) ? 1 : -1; $comparison = $this->entityCompareMessages($left, $right, $condition['attribute'] ?? ''); if ($comparison !== 0) { return $comparison * $direction; } } return $left->uid() <=> $right->uid(); }); return array_values(array_map( static fn (Message $message): int => $message->uid(), $messages, )); } private function entityCompareMessages(Message $left, Message $right, string $attribute): int { return match ($attribute) { 'from' => $this->entityPrimaryAddressValue($left->from()) <=> $this->entityPrimaryAddressValue($right->from()), 'to' => $this->entityPrimaryAddressValue($left->to()) <=> $this->entityPrimaryAddressValue($right->to()), 'subject' => $this->entityScalarValue($left->subject()) <=> $this->entityScalarValue($right->subject()), 'received' => $this->entityTimestampValue($left->internalDate()) <=> $this->entityTimestampValue($right->internalDate()), 'sent' => $this->entityTimestampValue($left->sentAt()) <=> $this->entityTimestampValue($right->sentAt()), 'size' => $left->size() <=> $right->size(), default => 0, }; } /** * @param list $uids * @return list */ private function entityApplyRange(array $uids, ?IRange $range): array { if (!$range instanceof IRangeTally) { return array_values($uids); } $tally = max(0, $range->getTally()); if ($tally === 0) { return []; } $start = 0; if ($range->getAnchor() === RangeAnchorType::ABSOLUTE) { $start = max(0, (int) $range->getPosition()); } else { $anchor = (int) $range->getPosition(); $index = array_search($anchor, $uids, true); $start = $index === false ? 0 : $index; } return array_values(array_slice($uids, $start, $tally)); } /** * @param list $criteria */ private function entityOrCriteria(array $criteria): SearchCriteriaBuilder { $expression = array_shift($criteria); if ($expression === null) { return SearchCriteriaBuilder::create()->all(); } foreach ($criteria as $criterion) { $expression = SearchCriteriaBuilder::create()->or($expression, $criterion); } return $expression; } /** * @param list $criteria */ private function entityAndCriteria(array $criteria): SearchCriteriaBuilder { $expression = SearchCriteriaBuilder::create(); foreach ($criteria as $criterion) { $expression->group($criterion); } return $expression; } private function normalizeImapDate(mixed $value): string { if ($value instanceof DateTimeImmutable) { return $value->format('d-M-Y'); } $stringValue = trim((string) $value); if ($stringValue === '') { throw new ImapException('Date filter values must not be empty.'); } try { return (new DateTimeImmutable($stringValue))->format('d-M-Y'); } catch (\Exception) { return $stringValue; } } /** * @param list $addresses */ private function entityPrimaryAddressValue(array $addresses): string { $address = $addresses[0] ?? null; if ($address === null) { return ''; } return strtolower(trim((string) ($address->email() ?? $address->name() ?? ''))); } private function entityScalarValue(?string $value): string { return strtolower(trim((string) $value)); } private function entityTimestampValue(?string $value): int { if ($value === null || trim($value) === '') { return 0; } try { return (new DateTimeImmutable($value))->getTimestamp(); } catch (\Exception) { return 0; } } private function mailboxFilter(Mailbox $mailbox, IFilter $filter): bool { $result = null; foreach ($filter->conditions() as $condition) { $attribute = $condition['attribute'] ?? ''; if (!in_array($attribute, self::COLLECTION_FILTER_OPTIONS, true)) { continue; } $matches = match ($condition['attribute'] ?? '') { 'name' => $this->mailboxFilterByName($mailbox, $condition), 'role' => $this->mailboxFilterByRole($mailbox, $condition), 'subscription' => $this->mailboxFilterBySubscription($mailbox, $condition), default => false, }; if ($result === null) { $result = $matches; continue; } $result = ($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR ? ($result || $matches) : ($result && $matches); } return $result ?? true; } /** * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ private function mailboxFilterByName(Mailbox $mailbox, array $condition): bool { $actualValue = $mailbox->name(); $expectedValue = $condition['value']; $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; return match ($comparator) { FilterComparisonOperator::EQ => $actualValue === $expectedValue, FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true), FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true), FilterComparisonOperator::LIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) === 1, FilterComparisonOperator::NLIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) !== 1, default => false, }; } /** * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ private function mailboxFilterByRole(Mailbox $mailbox, array $condition): bool { $actualValue = $this->mailboxRole($mailbox); $expectedValue = $condition['value']; $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; return match ($comparator) { FilterComparisonOperator::EQ => $actualValue === $expectedValue, FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true), FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true), default => false, }; } /** * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ private function mailboxFilterBySubscription(Mailbox $mailbox, array $condition): bool { $actualValue = in_array('\\SUBSCRIBED', $mailbox->attributes(), true) || in_array('\\Subscribed', $mailbox->attributes(), true); $expectedValue = $condition['value']; $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; return match ($comparator) { FilterComparisonOperator::EQ => $actualValue === $expectedValue, FilterComparisonOperator::NEQ => $actualValue !== $expectedValue, default => false, }; } private function mailboxRole(Mailbox $mailbox): string { foreach ($mailbox->attributes() as $attribute) { $role = match (strtolower($attribute)) { '\\sent' => CollectionRoles::Sent, '\\trash' => CollectionRoles::Trash, '\\drafts' => CollectionRoles::Drafts, '\\junk' => CollectionRoles::Junk, '\\archive' => CollectionRoles::Archive, default => null, }; if ($role !== null) { return $role->value; } } return CollectionRoles::None->value; } }