From eda91f687bf385cb25a3e70dbc2b7e52a29ace51 Mon Sep 17 00:00:00 2001 From: Yurii Kuznietsov Date: Tue, 28 Apr 2026 13:52:36 +0300 Subject: [PATCH] IMAP UIDVALIDITY handling (#3655) * IMAP UID validity * Fetcher test and refactoring --- .../Espo/Core/Mail/Account/FetchData.php | 27 +- .../Espo/Core/Mail/Account/Fetcher.php | 197 ++++++------ .../Account/Fetcher/ConfigDataProvider.php | 44 +++ .../Mail/Account/Fetcher/FiltersProvider.php | 86 +++++ .../Mail/Account/Fetcher/MessageFactory.php | 62 ++++ .../Core/Mail/Account/Fetcher/Unlocker.php | 49 +++ .../Espo/Core/Mail/Account/Storage.php | 9 + .../Account/Storage/DirectoryTreeStorage.php | 34 ++ .../Mail/Account/Storage/FolderStatus.php | 40 +++ .../Espo/Core/Mail/Account/FetcherTest.php | 298 ++++++++++++++++++ 10 files changed, 741 insertions(+), 105 deletions(-) create mode 100644 application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php create mode 100644 application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php create mode 100644 application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php create mode 100644 application/Espo/Core/Mail/Account/Fetcher/Unlocker.php create mode 100644 application/Espo/Core/Mail/Account/Storage/FolderStatus.php create mode 100644 tests/unit/Espo/Core/Mail/Account/FetcherTest.php diff --git a/application/Espo/Core/Mail/Account/FetchData.php b/application/Espo/Core/Mail/Account/FetchData.php index eda9ea718d..2137e00f21 100644 --- a/application/Espo/Core/Mail/Account/FetchData.php +++ b/application/Espo/Core/Mail/Account/FetchData.php @@ -39,9 +39,12 @@ class FetchData { private stdClass $data; - public function __construct(stdClass $data) + /** + * @internal + */ + public function __construct(?stdClass $data = null) { - $this->data = ObjectUtil::clone($data); + $this->data = ObjectUtil::clone($data ?? (object) []); } public static function fromRaw(stdClass $data): self @@ -66,6 +69,26 @@ class FetchData return (int) $id; } + public function getUidValidity(string $folder): ?int + { + $id = $this->data->uidValidity->$folder ?? null; + + if (!is_int($id)) { + return null; + } + + return $id; + } + + public function setUidValidity(string $folder, ?int $uid): void + { + if (!property_exists($this->data, 'uidValidity')) { + $this->data->uidValidity = (object) []; + } + + $this->data->uidValidity->$folder = $uid; + } + public function getLastDate(string $folder): ?DateTime { $value = $this->data->lastDate->$folder ?? null; diff --git a/application/Espo/Core/Mail/Account/Fetcher.php b/application/Espo/Core/Mail/Account/Fetcher.php index b9d1a76f92..bd65fde7c2 100644 --- a/application/Espo/Core/Mail/Account/Fetcher.php +++ b/application/Espo/Core/Mail/Account/Fetcher.php @@ -30,26 +30,25 @@ namespace Espo\Core\Mail\Account; use Espo\Core\Exceptions\Error; +use Espo\Core\Mail\Account\Fetcher\ConfigDataProvider; +use Espo\Core\Mail\Account\Fetcher\FiltersProvider; +use Espo\Core\Mail\Account\Fetcher\MessageFactory; +use Espo\Core\Mail\Account\Fetcher\Unlocker; use Espo\Core\Mail\Account\Storage\Flag; use Espo\Core\Mail\Exceptions\ImapError; use Espo\Core\Mail\Exceptions\NoImap; use Espo\Core\Mail\Importer; use Espo\Core\Mail\Importer\Data as ImporterData; +use Espo\Core\Mail\Message; use Espo\Core\Mail\ParserFactory; -use Espo\Core\Mail\MessageWrapper; use Espo\Core\Mail\Account\Hook\BeforeFetch as BeforeFetchHook; use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchHook; use Espo\Core\Mail\Account\Hook\BeforeFetchResult as BeforeFetchHookResult; -use Espo\Core\Utils\Config; use Espo\Core\Utils\Log; use Espo\Core\Field\DateTime as DateTimeField; use Espo\Entities\EmailFilter; use Espo\Entities\Email; -use Espo\Entities\InboundEmail; use Espo\ORM\Collection; -use Espo\ORM\EntityManager; -use Espo\ORM\Query\Part\Expression; -use Espo\ORM\Query\Part\Order; use Throwable; use DateTime; @@ -58,12 +57,14 @@ class Fetcher public function __construct( private Importer $importer, private StorageFactory $storageFactory, - private Config $config, + private ConfigDataProvider $configDataProvider, private Log $log, - private EntityManager $entityManager, private ParserFactory $parserFactory, - private ?BeforeFetchHook $beforeFetchHook, - private ?AfterFetchHook $afterFetchHook, + private FiltersProvider $filtersProvider, + private Unlocker $unlocker, + private MessageFactory $messageFactory, + private ?BeforeFetchHook $beforeFetchHook = null, + private ?AfterFetchHook $afterFetchHook = null, ) {} /** @@ -83,7 +84,7 @@ class Fetcher return; } - $filterList = $this->getFilterList($account); + $filterList = $this->filtersProvider->get($account); $storage = $this->storageFactory->create($account); @@ -112,21 +113,34 @@ class Fetcher try { $storage->selectFolder($folderOriginal); - } catch (Throwable $e) { - $message = "{$account->getEntityType()} {$account->getId()}, " . - "could not select folder '$folder'; {$e->getMessage()}"; - $this->log->error($message, ['exception' => $e]); + $uidValidity = $storage->getFolderStatus()->uidValidity; + } catch (Throwable $e) { + $this->log->error("Could not select IMAP folder. {type} {id}", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); return; } + $lastUidValidity = $fetchData->getUidValidity($folder); $lastId = $fetchData->getLastUid($folder); $lastDate = $fetchData->getLastDate($folder); $forceByDate = $fetchData->getForceByDate($folder); $portionLimit = $forceByDate ? 0 : $account->getPortionLimit(); $previousLastId = $lastId; + $uidReset = false; + + if ($lastUidValidity !== null && $uidValidity !== $lastUidValidity) { + $forceByDate = true; + $previousLastId = null; + $lastId = null; + + $uidReset = true; + } $ids = $this->fetchIds( account: $account, @@ -136,10 +150,6 @@ class Fetcher forceByDate: $forceByDate, ); - if (count($ids) === 1 && $ids[0] === $lastId) { - return; - } - $counter = 0; foreach ($ids as $id) { @@ -187,6 +197,7 @@ class Fetcher $fetchData->setLastDate($folder, $lastDate); $fetchData->setLastUid($folder, $lastId); + $fetchData->setUidValidity($folder, $uidValidity); if ($forceByDate && $previousLastId) { $ids = $storage->getUidsFromUid($previousLastId); @@ -196,6 +207,10 @@ class Fetcher } } + if ($uidReset) { + $fetchData->setForceByDate($folder, false); + } + if ( !$forceByDate && count($ids) && @@ -203,6 +218,8 @@ class Fetcher $previousLastId >= $lastId ) { // Handling broken numbering. Next time fetch since the last date rather than the last UID. + // Supposed not to happen. + // @todo Consider removing. $fetchData->setForceByDate($folder, true); } @@ -277,7 +294,7 @@ class Fetcher ->withGroupEmailFolderId($groupEmailFolderId); try { - $message = new MessageWrapper( + $message = $this->messageFactory->create( id: $id, storage: $storage, parser: $parser, @@ -304,18 +321,11 @@ class Fetcher return null; } - if ( - $account->keepFetchedEmailsUnread() && - $flags !== null && - !in_array(Flag::SEEN, $flags) - ) { - $storage->unmarkSeen($id); - } + $this->processUnseen($account, $flags, $storage, $id); } catch (Throwable $e) { - $message = "{$account->getEntityType()} {id}, get message."; - - $this->log->error($message, [ + $this->log->error("Import email message error. {type} {id}", [ 'exception' => $e, + 'type' => $account->getEntityType(), 'id' => $account->getId(), ]); @@ -323,87 +333,31 @@ class Fetcher } $account->relateEmail($email); - - if (!$this->afterFetchHook) { - return $email; - } - - try { - $this->afterFetchHook->process( - $account, - $email, - $hookResult ?? BeforeFetchHookResult::create() - ); - } catch (Throwable $e) { - $message = "{$account->getEntityType()} {$account->getId()}, after-fetch hook; " . - "{$e->getCode()} {$e->getMessage()}"; - - $this->log->error($message, ['exception' => $e]); - } + $this->processAfterSaveHook($account, $email, $hookResult); return $email; } - private function processBeforeFetchHook(Account $account, MessageWrapper $message): BeforeFetchHookResult + private function processBeforeFetchHook(Account $account, Message $message): BeforeFetchHookResult { assert($this->beforeFetchHook !== null); try { return $this->beforeFetchHook->process($account, $message); } catch (Throwable $e) { - $message = "{$account->getEntityType()} {$account->getId()}, before-fetch hook; " . - "{$e->getCode()} {$e->getMessage()}"; - - $this->log->error($message, ['exception' => $e]); + $this->log->error("Before-fetch message hook error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); } return BeforeFetchHookResult::create()->withToSkip(); } - /** - * @return Collection - */ - private function getFilterList(Account $account): Collection - { - $actionList = [EmailFilter::ACTION_SKIP]; - - if ($account->getEntityType() === InboundEmail::ENTITY_TYPE) { - $actionList[] = EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER; - } - - $builder = $this->entityManager - ->getRDBRepository(EmailFilter::ENTITY_TYPE) - ->where([ - 'action' => $actionList, - 'OR' => [ - [ - 'parentType' => $account->getEntityType(), - 'parentId' => $account->getId(), - 'action' => $actionList, - ], - [ - 'parentId' => null, - 'action' => EmailFilter::ACTION_SKIP, - ], - ] - ]); - - if (count($actionList) > 1) { - $builder->order( - Order::createByPositionInList( - Expression::column('action'), - $actionList - ) - ); - } - - /** @var Collection */ - return $builder->find(); - } - private function checkFetchOnlyHeader(Storage $storage, int $id): bool { - $maxSize = $this->config->get('emailMessageMaxSize'); + $maxSize = $this->configDataProvider->getMessageMaxSize(); if (!$maxSize) { return false; @@ -424,21 +378,20 @@ class Fetcher private function importMessage( Account $account, - MessageWrapper $message, - ImporterData $data + Message $message, + ImporterData $data, ): ?Email { try { return $this->importer->import($message, $data); } catch (Throwable $e) { - $message = "{$account->getEntityType()} {$account->getId()}, import message; " . - "{$e->getCode()} {$e->getMessage()}"; + $this->log->error("Import message error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); - $this->log->error($message, ['exception' => $e]); - - if ($this->entityManager->getLocker()->isLocked()) { - $this->entityManager->getLocker()->rollback(); - } + $this->unlocker->process(); } return null; @@ -463,4 +416,42 @@ class Fetcher return $folderData; } + + private function processAfterSaveHook(Account $account, Email $email, ?BeforeFetchHookResult $hookResult): void + { + if (!$this->afterFetchHook) { + return; + } + + try { + $this->afterFetchHook->process( + account: $account, + email: $email, + beforeFetchResult: $hookResult ?? BeforeFetchHookResult::create(), + ); + } catch (Throwable $e) { + $this->log->error("After-fetch message hook error. {type} {id}.", [ + 'exception' => $e, + 'type' => $account->getEntityType(), + 'id' => $account->getId(), + ]); + } + } + + /** + * Handles cases where the PEEK command is not supported by the IMAP server. + * + * @param ?string[] $flags + * @throws ImapError + */ + private function processUnseen(Account $account, ?array $flags, Storage $storage, int $id): void + { + if ( + $account->keepFetchedEmailsUnread() && + $flags !== null && + !in_array(Flag::SEEN, $flags) + ) { + $storage->unmarkSeen($id); + } + } } diff --git a/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php b/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php new file mode 100644 index 0000000000..f691d590fc --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/ConfigDataProvider.php @@ -0,0 +1,44 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Account\Fetcher; + +use Espo\Core\Utils\Config; + +class ConfigDataProvider +{ + public function __construct( + private Config $config, + ) {} + + public function getMessageMaxSize(): ?float + { + return $this->config->get('emailMessageMaxSize'); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php b/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php new file mode 100644 index 0000000000..3c4fb1716d --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/FiltersProvider.php @@ -0,0 +1,86 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Account\Fetcher; + +use Espo\Core\Mail\Account\Account; +use Espo\Entities\EmailFilter; +use Espo\Entities\InboundEmail; +use Espo\ORM\Collection; +use Espo\ORM\EntityManager; +use Espo\ORM\Query\Part\Expression; +use Espo\ORM\Query\Part\Order; + +class FiltersProvider +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + /** + * @return Collection + */ + public function get(Account $account): Collection + { + $actionList = [EmailFilter::ACTION_SKIP]; + + if ($account->getEntityType() === InboundEmail::ENTITY_TYPE) { + $actionList[] = EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER; + } + + $builder = $this->entityManager + ->getRDBRepository(EmailFilter::ENTITY_TYPE) + ->where([ + 'action' => $actionList, + 'OR' => [ + [ + 'parentType' => $account->getEntityType(), + 'parentId' => $account->getId(), + 'action' => $actionList, + ], + [ + 'parentId' => null, + 'action' => EmailFilter::ACTION_SKIP, + ], + ] + ]); + + if (count($actionList) > 1) { + $builder->order( + Order::createByPositionInList( + Expression::column('action'), + $actionList + ) + ); + } + + /** @var Collection */ + return $builder->find(); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php b/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php new file mode 100644 index 0000000000..a2a843aec3 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/MessageFactory.php @@ -0,0 +1,62 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Account\Fetcher; + +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Exceptions\ImapError; +use Espo\Core\Mail\Message; +use Espo\Core\Mail\MessageWrapper; +use Espo\Core\Mail\Parser; + +/** + * @internal + */ +class MessageFactory +{ + public function __construct() {} + + /** + * @throws ImapError + */ + public function create( + int $id, + Storage $storage, + Parser $parser, + bool $peek, + ): Message { + + return new MessageWrapper( + id: $id, + storage: $storage, + parser: $parser, + peek: $peek, + ); + } +} diff --git a/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php b/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php new file mode 100644 index 0000000000..27f0b210f7 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Fetcher/Unlocker.php @@ -0,0 +1,49 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Account\Fetcher; + +use Espo\ORM\EntityManager; + +/** + * @internal + */ +class Unlocker +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function process(): void + { + if ($this->entityManager->getLocker()->isLocked()) { + $this->entityManager->getLocker()->rollback(); + } + } +} diff --git a/application/Espo/Core/Mail/Account/Storage.php b/application/Espo/Core/Mail/Account/Storage.php index eaba7b0d70..a1feae3651 100644 --- a/application/Espo/Core/Mail/Account/Storage.php +++ b/application/Espo/Core/Mail/Account/Storage.php @@ -30,6 +30,7 @@ namespace Espo\Core\Mail\Account; use Espo\Core\Field\DateTime; +use Espo\Core\Mail\Account\Storage\FolderStatus; use Espo\Core\Mail\Exceptions\ImapError; interface Storage @@ -109,4 +110,12 @@ interface Storage * @throws ImapError */ public function appendMessage(string $content, string $folder): void; + + /** + * Get folder status. Should be called after the folder is selected. + * + * @throws ImapError + * @since 10.0 + */ + public function getFolderStatus(): FolderStatus; } diff --git a/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php index 68b047e417..3d950404e7 100644 --- a/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php +++ b/application/Espo/Core/Mail/Account/Storage/DirectoryTreeStorage.php @@ -323,4 +323,38 @@ class DirectoryTreeStorage implements Storage return $folder; } + + /** + * @noinspection PhpRedundantCatchClauseInspection + */ + public function getFolderStatus(): FolderStatus + { + if (!$this->selectedFolder) { + throw new LogicException("Folder is not selected."); + } + + try { + $statusData = $this->selectedFolder->status(); + } catch (CommonException $e) { + throw new ImapError("Get folder status error.", previous: $e); + } + + $uidValidity = $statusData['UIDVALIDITY'] ?? null; + + if (is_numeric($uidValidity)) { + $uidValidity = (int) $uidValidity; + } + + if (!is_int($uidValidity)) { + throw new ImapError("Bad or no UIDVALIDITY value."); + } + + if ($uidValidity === 0) { + throw new ImapError("UIDVALIDITY value is zero."); + } + + return new FolderStatus( + uidValidity: $uidValidity, + ); + } } diff --git a/application/Espo/Core/Mail/Account/Storage/FolderStatus.php b/application/Espo/Core/Mail/Account/Storage/FolderStatus.php new file mode 100644 index 0000000000..b2d7f22916 --- /dev/null +++ b/application/Espo/Core/Mail/Account/Storage/FolderStatus.php @@ -0,0 +1,40 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\Mail\Account\Storage; + +/** + * @since 10.0.0 + */ +readonly class FolderStatus +{ + public function __construct( + public int $uidValidity, + ) {} +} diff --git a/tests/unit/Espo/Core/Mail/Account/FetcherTest.php b/tests/unit/Espo/Core/Mail/Account/FetcherTest.php new file mode 100644 index 0000000000..42b42bc7fd --- /dev/null +++ b/tests/unit/Espo/Core/Mail/Account/FetcherTest.php @@ -0,0 +1,298 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace tests\unit\Espo\Core\Mail\Account; + +use Espo\Core\Field\Date; +use Espo\Core\Field\DateTime; +use Espo\Core\Mail\Account\Account; +use Espo\Core\Mail\Account\FetchData; +use Espo\Core\Mail\Account\Fetcher; +use Espo\Core\Mail\Account\Fetcher\ConfigDataProvider; +use Espo\Core\Mail\Account\Storage; +use Espo\Core\Mail\Account\StorageFactory; +use Espo\Core\Mail\Importer; +use Espo\Core\Mail\ParserFactory; +use Espo\Core\Utils\Log; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class FetcherTest extends TestCase +{ + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testFetchSinceDate(): void + { + $storage = $this->createMock(Storage::class); + $importer = $this->createMock(Importer::class); + + $fetcher = $this->prepareFetcher($storage, $importer); + + $fetchData = $this->prepareFetchData( + lastUid: null, + lastDate: null, + uidValidity: null, + ); + + $account = $this->prepareAccount($fetchData); + + $storage + ->expects($this->once()) + ->method('selectFolder') + ->with('INBOX'); + + $storage + ->expects($this->once()) + ->method('getFolderStatus') + ->willReturn(new Storage\FolderStatus(uidValidity: 1)); + + $storage + ->expects($this->once()) + ->method('getUidsSinceDate') + ->with(DateTime::fromString('2026-01-01 00:00')) + ->willReturn([1, 2, 3]); + + $storage + ->expects($this->once()) + ->method('close'); + + $importer + ->expects($this->exactly(3)) + ->method('import'); + + $fetchData + ->expects($this->once()) + ->method('setUidValidity') + ->with('INBOX', 1); + + $fetchData + ->expects($this->once()) + ->method('setLastUid') + ->with('INBOX', 3); + + $fetcher->fetch($account); + } + + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testFetchSinceLastUid(): void + { + $storage = $this->createMock(Storage::class); + $importer = $this->createMock(Importer::class); + + $fetcher = $this->prepareFetcher($storage, $importer); + + $fetchData = $this->prepareFetchData( + lastUid: 3, + lastDate: DateTime::fromString('2026-01-02 00:00'), + uidValidity: 1, + ); + + $account = $this->prepareAccount($fetchData); + + $storage + ->expects($this->once()) + ->method('selectFolder') + ->with('INBOX'); + + $storage + ->expects($this->once()) + ->method('getFolderStatus') + ->willReturn(new Storage\FolderStatus(uidValidity: 1)); + + $storage + ->expects($this->once()) + ->method('getUidsFromUid') + ->with(3) + ->willReturn([4]); + + $storage + ->expects($this->once()) + ->method('close'); + + $importer + ->expects($this->exactly(1)) + ->method('import'); + + $fetchData + ->expects($this->once()) + ->method('setLastUid') + ->with('INBOX', 4); + + $fetcher->fetch($account); + } + + /** + * @noinspection PhpUnhandledExceptionInspection + */ + public function testFetchUidValidityBroken(): void + { + $storage = $this->createMock(Storage::class); + $importer = $this->createMock(Importer::class); + + $fetcher = $this->prepareFetcher($storage, $importer); + + $fetchData = $this->prepareFetchData( + lastUid: 3, + lastDate: DateTime::fromString('2026-01-02 00:00'), + uidValidity: 1, + ); + + $account = $this->prepareAccount($fetchData); + + $storage + ->expects($this->once()) + ->method('selectFolder') + ->with('INBOX'); + + $storage + ->expects($this->once()) + ->method('getFolderStatus') + ->willReturn(new Storage\FolderStatus(uidValidity: 2)); + + $storage + ->expects($this->once()) + ->method('getUidsSinceDate') + ->with(DateTime::fromString('2026-01-02 00:00')) + ->willReturn([4]); + + $storage + ->expects($this->once()) + ->method('close'); + + $importer + ->expects($this->exactly(1)) + ->method('import'); + + $fetchData + ->expects($this->once()) + ->method('setUidValidity') + ->with('INBOX', 2); + + $fetchData + ->expects($this->once()) + ->method('setLastUid') + ->with('INBOX', 4); + + $fetcher->fetch($account); + } + + private function prepareFetchData( + ?int $lastUid, + ?DateTime $lastDate, + ?int $uidValidity, + ): FetchData & MockObject { + + $fetchData = $this->createMock(FetchData::class); + + $fetchData + ->method('getLastUid') + ->willReturn($lastUid); + + $fetchData + ->method('getLastDate') + ->willReturn($lastDate); + + $fetchData + ->method('getUidValidity') + ->willReturn($uidValidity); + + $fetchData + ->method('getForceByDate') + ->willReturn(false); + + return $fetchData; + } + + private function prepareAccount(FetchData $fetchData): Account & MockObject + { + $account = $this->createMock(Account::class); + + $account + ->method('isAvailableForFetching') + ->willReturn(true); + + $account + ->method('getMonitoredFolderList') + ->willReturn(['INBOX']); + + $account + ->expects($this->once()) + ->method('getPortionLimit') + ->willReturn(5); + + $account + ->expects($this->once()) + ->method('getFetchData') + ->willReturn($fetchData); + + $account + ->method('getFetchSince') + ->willReturn(Date::fromString('2026-01-01')); + + $account + ->expects($this->once()) + ->method('updateFetchData') + ->with($fetchData); + + return $account; + } + + private function prepareFetcher( + Storage & MockObject $storage, + Importer & MockObject $importer, + ): Fetcher { + + $storageFactory = $this->createMock(StorageFactory::class); + $configDataProvider = $this->createMock(ConfigDataProvider::class); + $log = $this->createMock(Log::class); + $parserFactory = $this->createMock(ParserFactory::class); + $filtersProvider = $this->createMock(Fetcher\FiltersProvider::class); + $unlocker = $this->createMock(Fetcher\Unlocker::class); + $messageFactory = $this->createMock(Fetcher\MessageFactory::class); + + $storageFactory + ->expects($this->once()) + ->method('create') + ->willReturn($storage); + + return new Fetcher( + importer: $importer, + storageFactory: $storageFactory, + configDataProvider: $configDataProvider, + log: $log, + parserFactory: $parserFactory, + filtersProvider: $filtersProvider, + unlocker: $unlocker, + messageFactory: $messageFactory, + ); + } +}