IMAP UIDVALIDITY handling (#3655)

* IMAP UID validity

* Fetcher test and refactoring
This commit is contained in:
Yurii Kuznietsov
2026-04-28 13:52:36 +03:00
committed by GitHub
parent 17e8587ac0
commit eda91f687b
10 changed files with 741 additions and 105 deletions

View File

@@ -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;

View File

@@ -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<EmailFilter>
*/
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<EmailFilter> */
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);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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');
}
}

View File

@@ -0,0 +1,86 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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<EmailFilter>
*/
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<EmailFilter> */
return $builder->find();
}
}

View File

@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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();
}
}
}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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,
) {}
}

View File

@@ -0,0 +1,298 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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,
);
}
}