diff --git a/src/FetchedMessageData.php b/src/FetchedMessageData.php new file mode 100644 index 0000000..f38fcb1 --- /dev/null +++ b/src/FetchedMessageData.php @@ -0,0 +1,73 @@ +tokenAt(3); + + if (! $data instanceof ListData) { + throw new RuntimeException(sprintf( + 'Expected instance of %s at index 3 in FETCH response, got %s', + ListData::class, + get_debug_type($data) + )); + } + + return new static( + uid: (int) $data->lookup('UID')->value, + flags: $data->lookup('FLAGS')?->values() ?? [], + head: $data->lookup('[HEADER]')->value ?? '', + body: $data->lookup('[TEXT]')->value ?? '', + size: ($size = $data->lookup('RFC822.SIZE')?->value) ? (int) $size : null, + bodyStructure: ($bodyStructure = $data->lookup('BODYSTRUCTURE')) instanceof ListData + ? $bodyStructure + : null, + ); + } + + /** + * Get the message UID. + */ + public function uid(): int + { + return $this->uid; + } + + /** + * Create a message for the given folder. + */ + public function toMessage(FolderInterface $folder): Message + { + return new Message( + $folder, + $this->uid, + $this->flags, + $this->head, + $this->body, + $this->size, + $this->bodyStructure, + ); + } +} diff --git a/src/MessageData.php b/src/MessageData.php new file mode 100644 index 0000000..e5fa7ec --- /dev/null +++ b/src/MessageData.php @@ -0,0 +1,57 @@ +value; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + return $this->value; + } +} diff --git a/src/MessageData/Body.php b/src/MessageData/Body.php new file mode 100644 index 0000000..6fcbf90 --- /dev/null +++ b/src/MessageData/Body.php @@ -0,0 +1,67 @@ +peek = true; + + return $item; + } + + /** + * {@inheritDoc} + */ + public function key(): string + { + return "BODY[{$this->section}]"; + } + + /** + * {@inheritDoc} + */ + public function toImap(): string + { + $item = $this->peek ? 'BODY.PEEK' : 'BODY'; + + return "{$item}[{$this->section}]"; + } +} diff --git a/src/MessageData/FetchItem.php b/src/MessageData/FetchItem.php new file mode 100644 index 0000000..ad1366b --- /dev/null +++ b/src/MessageData/FetchItem.php @@ -0,0 +1,16 @@ +total($uids->count()); - foreach ($this->fetch($uids) as $uid => $response) { - $messages->push( - $this->newMessage( - $uid, - $response['flags'] ?? [], - $response['head'] ?? '', - $response['body'] ?? '', - $response['size'] ?? null, - $response['bodystructure'] ?? null, - ) - ); + foreach ($this->fetch($uids) as $data) { + $messages->push($data->toMessage($this->folder)); } return $messages; @@ -359,68 +349,21 @@ protected function fetch(Collection $messages): array $uids = $messages->forPage($this->page, $this->limit)->values(); - $fetch = []; - - if ($this->fetchFlags) { - $fetch[] = 'FLAGS'; - } - - if ($this->fetchSize) { - $fetch[] = 'RFC822.SIZE'; - } - - if ($this->fetchHeaders) { - $fetch[] = $this->fetchAsUnread - ? 'BODY.PEEK[HEADER]' - : 'BODY[HEADER]'; - } - - if ($this->fetchBody) { - $fetch[] = $this->fetchAsUnread - ? 'BODY.PEEK[TEXT]' - : 'BODY[TEXT]'; - } - - if ($this->fetchBodyStructure) { - $fetch[] = 'BODYSTRUCTURE'; - } + $fetch = array_map( + fn (FetchItem $item) => $item->toImap(), + $this->fetchItems, + ); if (empty($fetch)) { return $uids->mapWithKeys(fn (string|int $uid) => [ - $uid => [ - 'size' => null, - 'flags' => [], - 'head' => '', - 'body' => '', - 'bodystructure' => null, - ], + $uid => new FetchedMessageData((int) $uid), ])->all(); } return $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) { - $data = $response->tokenAt(3); - - if (! $data instanceof ListData) { - throw new RuntimeException(sprintf( - 'Expected instance of %s at index 3 in FETCH response, got %s', - ListData::class, - get_debug_type($data) - )); - } - - $uid = $data->lookup('UID')->value; - - $size = $data->lookup('RFC822.SIZE')?->value; + $data = FetchedMessageData::fromResponse($response); - return [ - $uid => [ - 'size' => $size ? (int) $size : null, - 'flags' => $data->lookup('FLAGS')?->values() ?? [], - 'head' => $data->lookup('[HEADER]')->value ?? '', - 'body' => $data->lookup('[TEXT]')->value ?? '', - 'bodystructure' => $data->lookup('BODYSTRUCTURE'), - ], - ]; + return [$data->uid() => $data]; })->all(); } @@ -495,14 +438,6 @@ protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdenti } } - /** - * Make a new message from given raw components. - */ - protected function newMessage(int $uid, array $flags, string $head, string $body, ?int $size = null, ?ListData $bodystructure = null): Message - { - return new Message($this->folder, $uid, $flags, $head, $body, $size, $bodystructure); - } - /** * Get the connection instance. */ diff --git a/src/MessageQueryInterface.php b/src/MessageQueryInterface.php index 63f0df9..21ec393 100644 --- a/src/MessageQueryInterface.php +++ b/src/MessageQueryInterface.php @@ -8,6 +8,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\MessageData\FetchItem; use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator; /** @@ -15,16 +16,6 @@ */ interface MessageQueryInterface { - /** - * Don't mark messages as read when fetching. - */ - public function leaveUnread(): MessageQueryInterface; - - /** - * Mark all messages as read when fetching. - */ - public function markAsRead(): MessageQueryInterface; - /** * Set the limit and page for the current query. */ @@ -51,79 +42,19 @@ public function getPage(): int; public function setPage(int $page): MessageQueryInterface; /** - * Determine if the body of messages is being fetched. - */ - public function isFetchingBody(): bool; - - /** - * Determine if the flags of messages is being fetched. - */ - public function isFetchingFlags(): bool; - - /** - * Determine if the headers of messages is being fetched. - */ - public function isFetchingHeaders(): bool; - - /** - * Determine if the size of messages is being fetched. - */ - public function isFetchingSize(): bool; - - /** - * Determine if the body structure of messages is being fetched. - */ - public function isFetchingBodyStructure(): bool; - - /** - * Fetch the flags of messages. - */ - public function withFlags(): MessageQueryInterface; - - /** - * Fetch the body of messages. - */ - public function withBody(): MessageQueryInterface; - - /** - * Fetch the headers of messages. - */ - public function withHeaders(): MessageQueryInterface; - - /** - * Fetch the size of messages. - */ - public function withSize(): MessageQueryInterface; - - /** - * Fetch the body structure of messages. - */ - public function withBodyStructure(): MessageQueryInterface; - - /** - * Don't fetch the body of messages. - */ - public function withoutBody(): MessageQueryInterface; - - /** - * Don't fetch the headers of messages. - */ - public function withoutHeaders(): MessageQueryInterface; - - /** - * Don't fetch the flags of messages. + * Add items to the message FETCH request. */ - public function withoutFlags(): MessageQueryInterface; + public function with(FetchItem ...$items): static; /** - * Don't fetch the size of messages. + * Remove items from the message FETCH request. */ - public function withoutSize(): MessageQueryInterface; + public function without(FetchItem ...$items): static; /** - * Don't fetch the body structure of messages. + * Replace the items in the message FETCH request. */ - public function withoutBodyStructure(): MessageQueryInterface; + public function only(FetchItem ...$items): static; /** * Set the fetch order. diff --git a/src/QueriesMessages.php b/src/QueriesMessages.php index 1c45b72..c245a6c 100644 --- a/src/QueriesMessages.php +++ b/src/QueriesMessages.php @@ -4,6 +4,7 @@ use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder; use DirectoryTree\ImapEngine\Enums\ImapSortKey; +use DirectoryTree\ImapEngine\MessageData\FetchItem; use DirectoryTree\ImapEngine\Support\ForwardsCalls; use Illuminate\Support\Traits\Conditionable; @@ -27,29 +28,11 @@ trait QueriesMessages protected ?int $limit = null; /** - * Whether to fetch the message body. - */ - protected bool $fetchBody = false; - - /** - * Whether to fetch the message flags. - */ - protected bool $fetchFlags = false; - - /** - * Whether to fetch the message headers. - */ - protected bool $fetchHeaders = false; - - /** - * Whether to fetch the message size. - */ - protected bool $fetchSize = false; - - /** - * Whether to fetch the message body structure. + * The items to include in message FETCH requests. + * + * @var array */ - protected bool $fetchBodyStructure = false; + protected array $fetchItems = []; /** * The fetch order. @@ -58,11 +41,6 @@ trait QueriesMessages */ protected string $fetchOrder = 'desc'; - /** - * Whether to leave messages fetched as unread by default. - */ - protected bool $fetchAsUnread = true; - /** * The methods that should be returned from query builder. */ @@ -94,26 +72,6 @@ public function __call(string $method, array $parameters): mixed return $this; } - /** - * {@inheritDoc} - */ - public function leaveUnread(): MessageQueryInterface - { - $this->fetchAsUnread = true; - - return $this; - } - - /** - * {@inheritDoc} - */ - public function markAsRead(): MessageQueryInterface - { - $this->fetchAsUnread = false; - - return $this; - } - /** * {@inheritDoc} */ @@ -167,171 +125,35 @@ public function setPage(int $page): MessageQueryInterface /** * {@inheritDoc} */ - public function isFetchingBody(): bool - { - return $this->fetchBody; - } - - /** - * {@inheritDoc} - */ - public function isFetchingFlags(): bool - { - return $this->fetchFlags; - } - - /** - * {@inheritDoc} - */ - public function isFetchingHeaders(): bool - { - return $this->fetchHeaders; - } - - /** - * {@inheritDoc} - */ - public function isFetchingSize(): bool - { - return $this->fetchSize; - } - - /** - * {@inheritDoc} - */ - public function isFetchingBodyStructure(): bool - { - return $this->fetchBodyStructure; - } - - /** - * {@inheritDoc} - */ - public function withFlags(): MessageQueryInterface - { - return $this->setFetchFlags(true); - } - - /** - * {@inheritDoc} - */ - public function withBody(): MessageQueryInterface - { - return $this->setFetchBody(true); - } - - /** - * {@inheritDoc} - */ - public function withHeaders(): MessageQueryInterface - { - return $this->setFetchHeaders(true); - } - - /** - * {@inheritDoc} - */ - public function withSize(): MessageQueryInterface - { - return $this->setFetchSize(true); - } - - /** - * {@inheritDoc} - */ - public function withBodyStructure(): MessageQueryInterface - { - return $this->setFetchBodyStructure(true); - } - - /** - * {@inheritDoc} - */ - public function withoutBody(): MessageQueryInterface - { - return $this->setFetchBody(false); - } - - /** - * {@inheritDoc} - */ - public function withoutHeaders(): MessageQueryInterface - { - return $this->setFetchHeaders(false); - } - - /** - * {@inheritDoc} - */ - public function withoutFlags(): MessageQueryInterface - { - return $this->setFetchFlags(false); - } - - /** - * {@inheritDoc} - */ - public function withoutSize(): MessageQueryInterface - { - return $this->setFetchSize(false); - } - - /** - * {@inheritDoc} - */ - public function withoutBodyStructure(): MessageQueryInterface + public function with(FetchItem ...$items): static { - return $this->setFetchBodyStructure(false); - } - - /** - * Set whether to fetch the flags. - */ - protected function setFetchFlags(bool $fetchFlags): MessageQueryInterface - { - $this->fetchFlags = $fetchFlags; - - return $this; - } - - /** - * Set the fetch body flag. - */ - protected function setFetchBody(bool $fetchBody): MessageQueryInterface - { - $this->fetchBody = $fetchBody; - - return $this; - } - - /** - * Set whether to fetch the headers. - */ - protected function setFetchHeaders(bool $fetchHeaders): MessageQueryInterface - { - $this->fetchHeaders = $fetchHeaders; + foreach ($items as $item) { + $this->fetchItems[$item->key()] = $item; + } return $this; } /** - * Set whether to fetch the size. + * {@inheritDoc} */ - protected function setFetchSize(bool $fetchSize): MessageQueryInterface + public function without(FetchItem ...$items): static { - $this->fetchSize = $fetchSize; + foreach ($items as $item) { + unset($this->fetchItems[$item->key()]); + } return $this; } /** - * Set whether to fetch the body structure. + * {@inheritDoc} */ - protected function setFetchBodyStructure(bool $fetchBodyStructure): MessageQueryInterface + public function only(FetchItem ...$items): static { - $this->fetchBodyStructure = $fetchBodyStructure; + $this->fetchItems = []; - return $this; + return $this->with(...$items); } /** {@inheritDoc} */ diff --git a/tests/Integration/MessagesTest.php b/tests/Integration/MessagesTest.php index 15fe82e..acc9b9f 100644 --- a/tests/Integration/MessagesTest.php +++ b/tests/Integration/MessagesTest.php @@ -5,6 +5,7 @@ use DirectoryTree\ImapEngine\DraftMessage; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Message; +use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQuery; use Illuminate\Support\ItemNotFoundException; @@ -141,10 +142,10 @@ function folder(): Folder expect($messages->count())->toBe(1); expect($messages->first()->uid())->toBe($uid); })->with([ - fn (MessageQuery $query) => $query->withBody(), - fn (MessageQuery $query) => $query->withFlags(), - fn (MessageQuery $query) => $query->withHeaders(), - fn (MessageQuery $query) => $query->withSize(), + fn (MessageQuery $query) => $query->with(MessageData::text()->peek()), + fn (MessageQuery $query) => $query->with(MessageData::flags()), + fn (MessageQuery $query) => $query->with(MessageData::headers()->peek()), + fn (MessageQuery $query) => $query->with(MessageData::size()), ]); test('get with size', function () { @@ -164,7 +165,7 @@ function folder(): Folder expect($messagesWithoutSize->first()->size())->toBeNull(); // Fetch with size - should have a value - $messagesWithSize = $folder->messages()->withSize()->get(); + $messagesWithSize = $folder->messages()->with(MessageData::size())->get(); $message = $messagesWithSize->first(); expect($message->size())->toBeInt(); @@ -188,7 +189,7 @@ function folder(): Folder $uid1 = $folder->messages()->append($shortMessage)->uid(); $uid2 = $folder->messages()->append($longMessage)->uid(); - $messages = $folder->messages()->withSize()->get(); + $messages = $folder->messages()->with(MessageData::size())->get(); $short = $messages->find($uid1); $long = $messages->find($uid2); @@ -219,9 +220,11 @@ function folder(): Folder )->uid(); $message = $messages - ->withHeaders() - ->withFlags() - ->withBody() + ->with( + MessageData::headers()->peek(), + MessageData::flags(), + MessageData::text()->peek(), + ) ->find($uid); expect($message->from()->email())->toBe('foo@email.com'); @@ -249,17 +252,17 @@ function folder(): Folder )->uid(); // Initially, message should not be marked as seen. - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); // Mark message as seen. $message->markSeen(); - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeTrue(); // Unmark message as seen. $message->unmarkSeen(); - $message = $messages->withFlags()->find($uid); + $message = $messages->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); }); @@ -275,7 +278,7 @@ function folder(): Folder ) )->uid(); - $message = $messages->withHeaders()->withBody()->find($uid); + $message = $messages->with(MessageData::headers()->peek(), MessageData::text()->peek())->find($uid); $targetFolder = $folder->mailbox()->folders()->firstOrCreate( $targetFolderName = uniqid() @@ -287,8 +290,7 @@ function folder(): Folder expect($newUid)->toBeGreaterThan(0); $copiedMessage = $targetFolder->messages() - ->withBody() - ->withHeaders() + ->with(MessageData::text()->peek(), MessageData::headers()->peek()) ->findOrFail($newUid); expect($copiedMessage->from()->email())->toBe('foo@email.com'); @@ -307,7 +309,7 @@ function folder(): Folder ) )->uid(); - $message = $messages->withHeaders()->withBody()->find($uid); + $message = $messages->with(MessageData::headers()->peek(), MessageData::text()->peek())->find($uid); $targetFolder = $folder->mailbox()->folders()->firstOrCreate( $targetFolderName = uniqid() @@ -316,8 +318,7 @@ function folder(): Folder expect($message->move($targetFolderName))->toBeNull(); $targetMessages = $targetFolder->messages() - ->withHeaders() - ->withBody() + ->with(MessageData::headers()->peek(), MessageData::text()->peek()) ->get(); expect($folder->messages()->count())->toBe(0); @@ -344,7 +345,7 @@ function folder(): Folder $message->delete(); - expect($messages->withFlags()->find($uid)->isDeleted())->toBeTrue(); + expect($messages->with(MessageData::flags())->find($uid)->isDeleted())->toBeTrue(); }); test('retrieves messages using or statement', function () { @@ -418,11 +419,10 @@ function folder(): Folder )->uid(); $folder->messages() - ->markAsRead() - ->withHeaders() + ->with(MessageData::headers()) ->get(); - $message = $folder->messages()->withFlags()->find($uid); + $message = $folder->messages()->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeTrue(); }); @@ -438,11 +438,10 @@ function folder(): Folder )->uid(); $folder->messages() - ->leaveUnread() - ->withHeaders() + ->with(MessageData::headers()->peek()) ->get(); - $message = $folder->messages()->withFlags()->find($uid); + $message = $folder->messages()->with(MessageData::flags())->find($uid); expect($message->isSeen())->toBeFalse(); }); @@ -459,7 +458,7 @@ function folder(): Folder expect($folder->messages()->unseen()->count())->toBe(1); - $folder->messages()->withFlags()->find($uid)->markSeen(); + $folder->messages()->with(MessageData::flags())->find($uid)->markSeen(); expect($folder->messages()->unseen()->count())->toBe(0); }); diff --git a/tests/Unit/FetchedMessageDataTest.php b/tests/Unit/FetchedMessageDataTest.php new file mode 100644 index 0000000..68d015f --- /dev/null +++ b/tests/Unit/FetchedMessageDataTest.php @@ -0,0 +1,31 @@ +open(); + $stream->feed([ + '* 5 FETCH (UID 42 FLAGS (\\Seen) RFC822.SIZE 1024 BODY[HEADER] "Subject: Test" BODY[TEXT] "Hello world")', + ]); + + $response = (new ImapParser(new ImapTokenizer($stream)))->next(); + + expect($response)->toBeInstanceOf(UntaggedResponse::class); + + $data = FetchedMessageData::fromResponse($response); + $message = $data->toMessage(new Folder(new Mailbox, 'INBOX')); + + expect($data->uid())->toBe(42) + ->and($message->uid())->toBe(42) + ->and($message->flags())->toBe(['\\Seen']) + ->and($message->size())->toBe(1024) + ->and($message->head())->toBe('Subject: Test') + ->and($message->body())->toBe('Hello world'); +}); diff --git a/tests/Unit/MessageDataTest.php b/tests/Unit/MessageDataTest.php new file mode 100644 index 0000000..0536943 --- /dev/null +++ b/tests/Unit/MessageDataTest.php @@ -0,0 +1,36 @@ +key())->toBe($command) + ->and($item->toImap())->toBe($command); +})->with([ + [MessageData::flags(), 'FLAGS'], + [MessageData::size(), 'RFC822.SIZE'], + [MessageData::bodyStructure(), 'BODYSTRUCTURE'], +]); + +test('it creates body section data items', function (FetchItem $item, string $command) { + expect($item->toImap())->toBe($command); +})->with([ + [MessageData::headers(), 'BODY[HEADER]'], + [MessageData::text(), 'BODY[TEXT]'], + [MessageData::section('1.2'), 'BODY[1.2]'], +]); + +test('body section data items can be fetched without setting the seen flag', function (FetchItem $item, string $command) { + expect($item->peek()->toImap())->toBe($command); +})->with([ + [MessageData::headers(), 'BODY.PEEK[HEADER]'], + [MessageData::text(), 'BODY.PEEK[TEXT]'], + [MessageData::section('1.2'), 'BODY.PEEK[1.2]'], +]); + +test('peeking does not modify the original body section data item', function () { + $headers = MessageData::headers(); + + expect($headers->peek())->not->toBe($headers) + ->and($headers->toImap())->toBe('BODY[HEADER]'); +}); diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index eb048c7..433ac3d 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -8,6 +8,7 @@ use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException; use DirectoryTree\ImapEngine\Folder; use DirectoryTree\ImapEngine\Mailbox; +use DirectoryTree\ImapEngine\MessageData; use DirectoryTree\ImapEngine\MessageQuery; function query(?Mailbox $mailbox = null): MessageQuery @@ -40,6 +41,58 @@ function query(?Mailbox $mailbox = null): MessageQuery expect($query->toImap())->toBe('HEADER MESSAGE-ID "unique-message-id@server.example.com"'); }); +test('fetch items can be added and removed', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 1', + 'TAG2 OK UID SEARCH completed', + '* 1 FETCH (UID 1 RFC822.SIZE 1024)', + 'TAG3 OK UID FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox) + ->with(MessageData::flags(), MessageData::size()) + ->without(MessageData::flags()) + ->get(); + + $stream->assertWritten('TAG3 UID FETCH 1 (RFC822.SIZE)'); +}); + +test('fetch items can be replaced', function () { + $stream = new FakeStream; + $stream->open(); + + $stream->feed([ + '* OK Welcome to IMAP', + 'TAG1 OK Logged in', + '* SEARCH 1', + 'TAG2 OK UID SEARCH completed', + '* 1 FETCH (UID 1 BODY[HEADER] {0}', + '', + ' BODY[TEXT] {0}', + '', + ')', + 'TAG3 OK UID FETCH completed', + ]); + + $mailbox = Mailbox::make(); + $mailbox->connect(new ImapConnection($stream)); + + query($mailbox) + ->with(MessageData::flags()) + ->only(MessageData::headers(), MessageData::text()) + ->get(); + + $stream->assertWritten('TAG3 UID FETCH 1 (BODY[HEADER] BODY[TEXT])'); +}); + test('destroy', function () { $stream = new FakeStream; $stream->open(); diff --git a/tests/Unit/MessageTest.php b/tests/Unit/MessageTest.php index 0491cb0..14802ac 100644 --- a/tests/Unit/MessageTest.php +++ b/tests/Unit/MessageTest.php @@ -441,7 +441,7 @@ 'password' => 'bar', ]); - // This simulates a message fetched without withBodyStructure(), then accessing text() + // This simulates a message fetched without its body structure, then accessing text(). // The server will respond with: 1) body structure fetch, 2) body part fetch $mailbox->connect(ImapConnection::fake([ '* OK Welcome to IMAP', @@ -458,7 +458,7 @@ $folder = new Folder($mailbox, 'INBOX', [], '/'); - // Message created without body structure data - simulates fetching without withBodyStructure() + // Message created without body structure data. $message = new Message($folder, 1, [], 'From: test@example.com', ''); expect($message->hasBody())->toBeFalse();