mirror of
https://github.com/espocrm/espocrm.git
synced 2026-06-28 06:56:05 +00:00
IMAP UIDVALIDITY handling (#3655)
* IMAP UID validity * Fetcher test and refactoring
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
application/Espo/Core/Mail/Account/Fetcher/Unlocker.php
Normal file
49
application/Espo/Core/Mail/Account/Fetcher/Unlocker.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
40
application/Espo/Core/Mail/Account/Storage/FolderStatus.php
Normal file
40
application/Espo/Core/Mail/Account/Storage/FolderStatus.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
298
tests/unit/Espo/Core/Mail/Account/FetcherTest.php
Normal file
298
tests/unit/Espo/Core/Mail/Account/FetcherTest.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user