Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Mail\SmtpParams;
use Espo\Entities\Email;
interface Account
{
/**
* Update fetch-data.
*/
public function updateFetchData(FetchData $fetchData): void;
/**
* Update connected-at.
*/
public function updateConnectedAt(): void;
/**
* Relate an email with the account.
*/
public function relateEmail(Email $email): void;
/**
* Max number of emails to be fetched per iteration.
*/
public function getPortionLimit(): int;
/**
* Is available for fetching.
*/
public function isAvailableForFetching(): bool;
/**
* An email address of the account.
*/
public function getEmailAddress(): ?string;
/**
* A user fetched emails will be assigned to (through the `assignedUsers` field).
*/
public function getAssignedUser(): ?Link;
/**
* A user the account belongs to.
*/
public function getUser(): ?Link;
/**
* Users email should be related to (put into inbox).
*/
public function getUsers(): LinkMultiple;
/**
* Teams email should be related to.
*/
public function getTeams(): LinkMultiple;
/**
* Fetched emails won't be marked as read upon fetching.
*/
public function keepFetchedEmailsUnread(): bool;
/**
* Get fetch-data.
*/
public function getFetchData(): FetchData;
/**
* Fetch email since a specific date.
*/
public function getFetchSince(): ?Date;
/**
* A folder fetched emails should be put into.
*/
public function getEmailFolder(): ?Link;
/**
* A group folder fetched emails should be put into.
*/
public function getGroupEmailFolder(): ?Link;
/**
* Folders to fetch from.
*
* @return string[]
*/
public function getMonitoredFolderList(): array;
/**
* Gen an ID.
*/
public function getId(): ?string;
/**
* Get an entity type.
*/
public function getEntityType(): string;
/**
* Get IMAP params.
*/
public function getImapParams(): ?ImapParams;
/**
* @return ?class-string<object>
*/
public function getImapHandlerClassName(): ?string;
/**
* Get a SENT folder.
*/
public function getSentFolder(): ?string;
/**
* Is available for sending.
*/
public function isAvailableForSending(): bool;
/**
* Get SMTP params.
*/
public function getSmtpParams(): ?SmtpParams;
/**
* Store sent emails on IMAP.
*/
public function storeSentEmails(): bool;
/**
* Get the last connection time;
*/
public function getConnectedAt(): ?DateTime;
}

View File

@@ -0,0 +1,119 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Utils\ObjectUtil;
use Espo\Core\Field\DateTime;
use stdClass;
use RuntimeException;
class FetchData
{
private stdClass $data;
public function __construct(stdClass $data)
{
$this->data = ObjectUtil::clone($data);
}
public static function fromRaw(stdClass $data): self
{
return new self($data);
}
public function getRaw(): stdClass
{
return ObjectUtil::clone($this->data);
}
public function getLastUniqueId(string $folder): ?string
{
return $this->data->lastUID->$folder ?? null;
}
public function getLastDate(string $folder): ?DateTime
{
$value = $this->data->lastDate->$folder ?? null;
if ($value === null) {
return null;
}
// For backward compatibility.
if ($value === 0) {
return null;
}
if (!is_string($value)) {
throw new RuntimeException("Bad value in fetch-data.");
}
return DateTime::fromString($value);
}
public function getForceByDate(string $folder): bool
{
return $this->data->byDate->$folder ?? false;
}
public function setLastUniqueId(string $folder, ?string $uniqueId): void
{
if (!property_exists($this->data, 'lastUID')) {
$this->data->lastUID = (object) [];
}
$this->data->lastUID->$folder = $uniqueId;
}
public function setLastDate(string $folder, ?DateTime $lastDate): void
{
if (!property_exists($this->data, 'lastDate')) {
$this->data->lastDate = (object) [];
}
if ($lastDate === null) {
$this->data->lastDate->$folder = null;
return;
}
$this->data->lastDate->$folder = $lastDate->toString();
}
public function setForceByDate(string $folder, bool $forceByDate): void
{
if (!property_exists($this->data, 'byDate')) {
$this->data->byDate = (object) [];
}
$this->data->byDate->$folder = $forceByDate;
}
}

View File

@@ -0,0 +1,459 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Exceptions\Error;
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\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;
class Fetcher
{
public function __construct(
private Importer $importer,
private StorageFactory $storageFactory,
private Config $config,
private Log $log,
private EntityManager $entityManager,
private ParserFactory $parserFactory,
private ?BeforeFetchHook $beforeFetchHook,
private ?AfterFetchHook $afterFetchHook
) {}
/**
* @throws Error
* @throws ImapError
* @throws NoImap
*/
public function fetch(Account $account): void
{
if (!$account->isAvailableForFetching()) {
throw new Error("{$account->getEntityType()} {$account->getId()} is not active.");
}
$monitoredFolderList = $account->getMonitoredFolderList();
if (count($monitoredFolderList) === 0) {
return;
}
$filterList = $this->getFilterList($account);
$storage = $this->storageFactory->create($account);
foreach ($monitoredFolderList as $folder) {
$this->fetchFolder($account, $folder, $storage, $filterList);
}
$storage->close();
}
/**
* @param Collection<EmailFilter> $filterList
* @throws Error
*/
private function fetchFolder(
Account $account,
string $folderOriginal,
Storage $storage,
Collection $filterList
): void {
$fetchData = $account->getFetchData();
$folder = mb_convert_encoding($folderOriginal, 'UTF7-IMAP', 'UTF-8');
try {
$storage->selectFolder($folderOriginal);
} catch (Throwable $e) {
$this->log->error(
"{$account->getEntityType()} {$account->getId()}, " .
"could not select folder '$folder'; [{$e->getCode()}] {$e->getMessage()}"
);
return;
}
$lastUniqueId = $fetchData->getLastUniqueId($folder);
$lastDate = $fetchData->getLastDate($folder);
$forceByDate = $fetchData->getForceByDate($folder);
$portionLimit = $forceByDate ? 0 : $account->getPortionLimit();
$previousLastUniqueId = $lastUniqueId;
$idList = $this->getIdList(
$account,
$storage,
$lastUniqueId,
$lastDate,
$forceByDate
);
if (count($idList) === 1 && $lastUniqueId) {
if ($storage->getUniqueId($idList[0]) === $lastUniqueId) {
return;
}
}
$counter = 0;
foreach ($idList as $id) {
if ($counter == count($idList) - 1) {
$lastUniqueId = $storage->getUniqueId($id);
}
if ($forceByDate && $previousLastUniqueId) {
$uid = $storage->getUniqueId($id);
if ((int) $uid <= (int) $previousLastUniqueId) {
$counter++;
continue;
}
}
$email = $this->fetchEmail($account, $storage, $id, $filterList);
$isLast = $counter === count($idList) - 1;
$isLastInPortion = $counter === $portionLimit - 1;
if ($isLast || $isLastInPortion) {
$lastUniqueId = $storage->getUniqueId($id);
if ($email && $email->getDateSent()) {
$lastDate = $email->getDateSent();
if ($lastDate->toTimestamp() >= (new DateTime())->getTimestamp()) {
$lastDate = DateTimeField::createNow();
}
}
break;
}
$counter++;
}
if ($forceByDate) {
$lastDate = DateTimeField::createNow();
}
$fetchData->setLastDate($folder, $lastDate);
$fetchData->setLastUniqueId($folder, $lastUniqueId);
if ($forceByDate && $previousLastUniqueId) {
$idList = $storage->getIdsFromUniqueId($previousLastUniqueId);
if (count($idList)) {
$uid1 = $storage->getUniqueId($idList[0]);
if ((int) $uid1 > (int) $previousLastUniqueId) {
$fetchData->setForceByDate($folder, false);
}
}
}
if (
!$forceByDate &&
$previousLastUniqueId &&
count($idList) &&
(int) $previousLastUniqueId >= (int) $lastUniqueId
) {
// Handling broken numbering. Next time fetch since the last date rather than the last UID.
$fetchData->setForceByDate($folder, true);
}
$account->updateFetchData($fetchData);
}
/**
* @return int[]
* @throws Error
*/
private function getIdList(
Account $account,
Storage $storage,
?string $lastUID,
?DateTimeField $lastDate,
bool $forceByDate
): array {
if (!empty($lastUID) && !$forceByDate) {
return $storage->getIdsFromUniqueId($lastUID);
}
if ($lastDate) {
return $storage->getIdsSinceDate($lastDate);
}
if (!$account->getFetchSince()) {
throw new Error("{$account->getEntityType()} {$account->getId()}, no fetch-since.");
}
$fetchSince = $account->getFetchSince()->toDateTime();
return $storage->getIdsSinceDate(
DateTimeField::fromDateTime($fetchSince)
);
}
/**
* @param Collection<EmailFilter> $filterList
*/
private function fetchEmail(
Account $account,
Storage $storage,
int $id,
Collection $filterList
): ?Email {
$teamIdList = $account->getTeams()->getIdList();
$userIdList = $account->getUsers()->getIdList();
$userId = $account->getUser() ? $account->getUser()->getId() : null;
$assignedUserId = $account->getAssignedUser() ? $account->getAssignedUser()->getId() : null;
$groupEmailFolderId = $account->getGroupEmailFolder() ? $account->getGroupEmailFolder()->getId() : null;
$fetchOnlyHeader = $this->checkFetchOnlyHeader($storage, $id);
$folderData = [];
if ($userId && $account->getEmailFolder()) {
$folderData[$userId] = $account->getEmailFolder()->getId();
}
$flags = null;
$parser = $this->parserFactory->create();
$importerData = ImporterData
::create()
->withTeamIdList($teamIdList)
->withFilterList($filterList)
->withFetchOnlyHeader($fetchOnlyHeader)
->withFolderData($folderData)
->withUserIdList($userIdList)
->withAssignedUserId($assignedUserId)
->withGroupEmailFolderId($groupEmailFolderId);
try {
$message = new MessageWrapper($id, $storage, $parser);
$hookResult = null;
if ($this->beforeFetchHook) {
$hookResult = $this->processBeforeFetchHook($account, $message);
}
if ($hookResult && $hookResult->toSkip()) {
return null;
}
if ($message->isFetched() && $account->keepFetchedEmailsUnread()) {
$flags = $message->getFlags();
}
$email = $this->importMessage($account, $message, $importerData);
if (!$email) {
return null;
}
if (
$account->keepFetchedEmailsUnread() &&
$flags !== null &&
!in_array(Flag::SEEN, $flags)
) {
$storage->setFlags($id, self::flagsWithoutRecent($flags));
}
} catch (Throwable $e) {
$this->log->error(
"{$account->getEntityType()} {$account->getId()}, get message; " .
"{$e->getCode()} {$e->getMessage()}"
);
return null;
}
$account->relateEmail($email);
if (!$this->afterFetchHook) {
return $email;
}
try {
$this->afterFetchHook->process(
$account,
$email,
$hookResult ?? BeforeFetchHookResult::create()
);
} catch (Throwable $e) {
$this->log->error(
"{$account->getEntityType()} {$account->getId()}, after-fetch hook; " .
"{$e->getCode()} {$e->getMessage()}"
);
}
return $email;
}
private function processBeforeFetchHook(Account $account, MessageWrapper $message): BeforeFetchHookResult
{
assert($this->beforeFetchHook !== null);
try {
return $this->beforeFetchHook->process($account, $message);
} catch (Throwable $e) {
$this->log->error(
"{$account->getEntityType()} {$account->getId()}, before-fetch hook; " .
"{$e->getCode()} {$e->getMessage()}"
);
}
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');
if (!$maxSize) {
return false;
}
try {
$size = $storage->getSize($id);
} catch (Throwable) {
return false;
}
if ($size > $maxSize * 1024 * 1024) {
return true;
}
return false;
}
private function importMessage(
Account $account,
MessageWrapper $message,
ImporterData $data
): ?Email {
try {
return $this->importer->import($message, $data);
} catch (Throwable $e) {
$this->log->error(
"{$account->getEntityType()} {$account->getId()}, import message; " .
"{$e->getCode()} {$e->getMessage()}"
);
if ($this->entityManager->getLocker()->isLocked()) {
$this->entityManager->getLocker()->rollback();
}
}
return null;
}
/**
* @param string[] $flags
* @return string[]
*/
private static function flagsWithoutRecent(array $flags): array
{
return array_values(
array_diff($flags, [Flag::RECENT])
);
}
}

View File

@@ -0,0 +1,384 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Account\ImapParams;
use Espo\Core\Mail\Smtp\HandlerProcessor;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Crypt;
use Espo\Entities\GroupEmailFolder;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
use Espo\Core\Mail\Account\Account as AccountInterface;
use Espo\Core\Mail\Account\FetchData;
use Espo\ORM\Name\Attribute;
use RuntimeException;
class Account implements AccountInterface
{
private const PORTION_LIMIT = 20;
private ?LinkMultiple $users = null;
private ?LinkMultiple $teams = null;
private ?GroupEmailFolder $groupEmailFolder = null;
public function __construct(
private InboundEmail $entity,
private EntityManager $entityManager,
private Config $config,
private HandlerProcessor $handlerProcessor,
private Crypt $crypt
) {}
public function updateFetchData(FetchData $fetchData): void
{
$this->entity->set('fetchData', $fetchData->getRaw());
$this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]);
}
public function updateConnectedAt(): void
{
$this->entity->set('connectedAt', DateTime::createNow()->toString());
$this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]);
}
public function relateEmail(Email $email): void
{
$this->entityManager
->getRDBRepository(InboundEmail::ENTITY_TYPE)
->getRelation($this->entity, 'emails')
->relate($email);
}
public function getEntity(): InboundEmail
{
return $this->entity;
}
public function getPortionLimit(): int
{
return $this->config->get('inboundEmailMaxPortionSize', self::PORTION_LIMIT);
}
public function isAvailableForFetching(): bool
{
return $this->entity->isAvailableForFetching();
}
public function getEmailAddress(): ?string
{
return $this->entity->getEmailAddress();
}
public function getUsers(): LinkMultiple
{
if (!$this->users) {
$this->users = $this->loadUsers();
}
return $this->users;
}
private function getGroupEmailFolderEntity(): ?GroupEmailFolder
{
if ($this->groupEmailFolder) {
return $this->groupEmailFolder;
}
if ($this->entity->getGroupEmailFolder()) {
$this->groupEmailFolder = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->getById($this->entity->getGroupEmailFolder()->getId());
return $this->groupEmailFolder;
}
return null;
}
private function loadUsers(): LinkMultiple
{
$teamIds = [];
if ($this->entity->getGroupEmailFolder()) {
$groupEmailFolder = $this->getGroupEmailFolderEntity();
if ($groupEmailFolder) {
$teamIds = $groupEmailFolder->getTeams()->getIdList();
}
}
if ($this->entity->addAllTeamUsers()) {
$teamIds = array_merge($teamIds, $this->entity->getTeams()->getIdList());
}
$teamIds = array_unique($teamIds);
$teamIds = array_values($teamIds);
if ($teamIds === []) {
return LinkMultiple::create();
}
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->select([Attribute::ID])
->distinct()
->join(Field::TEAMS)
->where([
'type' => [User::TYPE_REGULAR, User::TYPE_ADMIN],
'isActive' => true,
'teamsMiddle.teamId' => $teamIds,
])
->find();
$linkMultiple = LinkMultiple::create();
foreach ($users as $user) {
$linkMultiple = $linkMultiple->withAddedId($user->getId());
}
return $linkMultiple;
}
public function getUser(): ?Link
{
return null;
}
public function getAssignedUser(): ?Link
{
return $this->entity->getAssignToUser();
}
public function getTeams(): LinkMultiple
{
if (!$this->teams) {
$this->teams = $this->loadTeams();
}
return $this->teams;
}
private function loadTeams(): LinkMultiple
{
$teams = $this->entity->getTeams();
if ($this->entity->getTeam()) {
$teams = $teams->withAddedId($this->entity->getTeam()->getId());
}
if ($this->getGroupEmailFolder()) {
$groupEmailFolder = $this->getGroupEmailFolderEntity();
if ($groupEmailFolder) {
$teams = $teams->withAddedIdList($groupEmailFolder->getTeams()->getIdList());
}
}
return $teams;
}
public function keepFetchedEmailsUnread(): bool
{
return $this->entity->keepFetchedEmailsUnread();
}
public function getFetchData(): FetchData
{
return FetchData::fromRaw(
$this->entity->getFetchData()
);
}
public function getFetchSince(): ?Date
{
return $this->entity->getFetchSince();
}
public function getEmailFolder(): ?Link
{
return $this->entity->getEmailFolder();
}
/**
* @return string[]
*/
public function getMonitoredFolderList(): array
{
return $this->entity->getMonitoredFolderList();
}
public function getId(): ?string
{
return $this->entity->getId();
}
public function getEntityType(): string
{
return $this->entity->getEntityType();
}
/**
* @return ?class-string<object>
*/
public function getImapHandlerClassName(): ?string
{
return $this->entity->getImapHandlerClassName();
}
public function createCase(): bool
{
return $this->entity->createCase();
}
public function autoReply(): bool
{
return $this->entity->autoReply();
}
public function getSentFolder(): ?string
{
return $this->entity->getSentFolder();
}
public function getGroupEmailFolder(): ?Link
{
return $this->entity->getGroupEmailFolder();
}
public function isAvailableForSending(): bool
{
return $this->entity->isAvailableForSending();
}
/**
* @throws NoSmtp
*/
public function getSmtpParams(): ?SmtpParams
{
$host = $this->entity->getSmtpHost();
if (!$host) {
return null;
}
$port = $this->entity->getSmtpPort();
if ($port === null) {
throw new NoSmtp("Empty port.");
}
$smtpParams = SmtpParams::create($host, $port)
->withSecurity($this->entity->getSmtpSecurity())
->withAuth($this->entity->getSmtpAuth());
if ($this->entity->getSmtpAuth()) {
$password = $this->entity->getSmtpPassword();
if ($password !== null) {
$password = $this->crypt->decrypt($password);
}
$smtpParams = $smtpParams
->withUsername($this->entity->getSmtpUsername())
->withPassword($password)
->withAuthMechanism($this->entity->getSmtpAuthMechanism());
}
if ($this->entity->getFromName()) {
$smtpParams = $smtpParams->withFromName($this->entity->getFromName());
}
$handlerClassName = $this->entity->getSmtpHandlerClassName();
if (!$handlerClassName) {
return $smtpParams;
}
return $this->handlerProcessor->handle($handlerClassName, $smtpParams, $this->getId());
}
public function storeSentEmails(): bool
{
return $this->entity->storeSentEmails();
}
public function getImapParams(): ?ImapParams
{
$host = $this->entity->getHost();
$port = $this->entity->getPort();
$username = $this->entity->getUsername();
$password = $this->entity->getPassword();
$security = $this->entity->getSecurity();
if (!$host) {
return null;
}
if ($port === null) {
throw new RuntimeException("No port.");
}
if ($username === null) {
throw new RuntimeException("No username.");
}
if ($password !== null) {
$password = $this->crypt->decrypt($password);
}
return new ImapParams(
$host,
$port,
$username,
$password,
$security
);
}
public function getConnectedAt(): ?DateTime
{
/** @var DateTime */
return $this->entity->getValueObject('connectedAt');
}
}

View File

@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Entities\InboundEmail;
use Espo\ORM\EntityManager;
class AccountFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private EntityManager $entityManager
) {}
/**
* @throws Error
*/
public function create(string $id): Account
{
$entity = $this->entityManager->getEntityById(InboundEmail::ENTITY_TYPE, $id);
if (!$entity) {
throw new Error("InboundEmail '{$id}' not found.");
}
$binding = BindingContainerBuilder::create()
->bindInstance(InboundEmail::class, $entity)
->build();
return $this->injectableFactory->createWithBinding(Account::class, $binding);
}
}

View File

@@ -0,0 +1,151 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Mail\Message;
use Espo\Core\Mail\Message\Part;
use const PREG_SPLIT_NO_EMPTY;
class BouncedRecognizer
{
/** @var string[] */
private array $hardBounceCodeList = [
'5.0.0',
'5.1.1', // bad destination mailbox address
'5.1.2', // bad destination system address
'5.1.6', // destination mailbox has moved, no forwarding address
'5.4.1', // no answer from host
];
public function isBounced(Message $message): bool
{
$from = $message->getHeader('From');
$contentType = $message->getHeader('Content-Type');
if (preg_match('/MAILER-DAEMON|POSTMASTER/i', $from ?? '')) {
return true;
}
if (str_starts_with($contentType ?? '', 'multipart/report')) {
// @todo Check whether ever works.
$deliveryStatusPart = $this->getDeliveryStatusPart($message);
if ($deliveryStatusPart) {
return true;
}
$content = $message->getRawContent();
if (
str_contains($content, 'message/delivery-status') &&
str_contains($content, 'Status: ')
) {
return true;
}
}
return false;
}
public function isHard(Message $message): bool
{
$content = $message->getRawContent();
/** @noinspection RegExpSimplifiable */
/** @noinspection RegExpDuplicateCharacterInClass */
if (preg_match('/permanent[ ]*[error|failure]/', $content)) {
return true;
}
$m = null;
$has5xxStatus = preg_match('/Status: (5\.[0-9]\.[0-9])/', $content, $m);
if ($has5xxStatus) {
$status = $m[1] ?? null;
if (in_array($status, $this->hardBounceCodeList)) {
return true;
}
}
return false;
}
public function extractStatus(Message $message): ?string
{
$content = $message->getRawContent();
$m = null;
$hasStatus = preg_match('/Status: ([0-9]\.[0-9]\.[0-9])/', $content, $m);
if ($hasStatus) {
return $m[1] ?? null;
}
return null;
}
public function extractQueueItemId(Message $message): ?string
{
$content = $message->getRawContent();
if (preg_match('/X-Queue-Item-Id: [a-z0-9\-]*/', $content, $m)) {
/** @var array{string} $arr */
$arr = preg_split('/X-Queue-Item-Id: /', $m[0], -1, PREG_SPLIT_NO_EMPTY);
return $arr[0];
}
$to = $message->getHeader('to');
if (preg_match('/\+bounce-qid-[a-z0-9\-]*/', $to ?? '', $m)) {
/** @var array{string} $arr */
$arr = preg_split('/\+bounce-qid-/', $m[0], -1, PREG_SPLIT_NO_EMPTY);
return $arr[0];
}
return null;
}
private function getDeliveryStatusPart(Message $message): ?Part
{
foreach ($message->getPartList() as $part) {
if ($part->getContentType() === 'message/delivery-status') {
return $part;
}
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Binding\Factory;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\Account\Hook\BeforeFetch;
use Espo\Core\Mail\Account\Hook\AfterFetch;
use Espo\Core\Mail\Account\GroupAccount\Hooks\BeforeFetch as GroupAccountBeforeFetch;
use Espo\Core\Mail\Account\GroupAccount\Hooks\AfterFetch as GroupAccountAfterFetch;
use Espo\Core\Mail\Account\StorageFactory;
use Espo\Core\Mail\Account\GroupAccount\StorageFactory as GroupAccountStorageFactory;
use Espo\Core\Mail\Account\Fetcher;
/**
* @implements Factory<Fetcher>
*/
class FetcherFactory implements Factory
{
private InjectableFactory $injectableFactory;
public function __construct(InjectableFactory $injectableFactory)
{
$this->injectableFactory = $injectableFactory;
}
public function create(): Fetcher
{
$binding = BindingContainerBuilder::create()
->bindImplementation(BeforeFetch::class, GroupAccountBeforeFetch::class)
->bindImplementation(AfterFetch::class, GroupAccountAfterFetch::class)
->bindImplementation(StorageFactory::class, GroupAccountStorageFactory::class)
->build();
return $this->injectableFactory->createWithBinding(Fetcher::class, $binding);
}
}

View File

@@ -0,0 +1,612 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount\Hooks;
use Espo\Core\Name\Field;
use Espo\Core\Name\Link;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Templates\Entities\Person;
use Espo\Core\Utils\SystemUser;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\Tools\Email\Util;
use Espo\Tools\EmailTemplate\Data as EmailTemplateData;
use Espo\Tools\EmailTemplate\Params as EmailTemplateParams;
use Espo\Tools\EmailTemplate\Service as EmailTemplateService;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Account\Hook\BeforeFetchResult;
use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchInterface;
use Espo\Core\Mail\Account\GroupAccount\Account as GroupAccount;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Log;
use Espo\Tools\Stream\Service as StreamService;
use Espo\Entities\InboundEmail;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\Entities\Team;
use Espo\Entities\EmailAddress;
use Espo\Entities\Attachment;
use Espo\Modules\Crm\Entities\CaseObj;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\ORM\EntityManager;
use Espo\Modules\Crm\Tools\Case\Distribution\RoundRobin;
use Espo\Modules\Crm\Tools\Case\Distribution\LeastBusy;
use Throwable;
use DateTime;
class AfterFetch implements AfterFetchInterface
{
private const DEFAULT_AUTOREPLY_LIMIT = 5;
private const DEFAULT_AUTOREPLY_SUPPRESS_PERIOD = '2 hours';
public function __construct(
private EntityManager $entityManager,
private StreamService $streamService,
private Config $config,
private EmailSender $emailSender,
private Log $log,
private RoundRobin $roundRobin,
private LeastBusy $leastBusy,
private GroupAccountFactory $groupAccountFactory,
private EmailTemplateService $emailTemplateService,
private SystemUser $systemUser
) {}
public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void
{
if (!$account instanceof GroupAccount) {
return;
}
if (!$account->createCase() && !$email->isFetched()) {
$this->noteAboutEmail($email);
}
if ($account->createCase()) {
if ($beforeFetchResult->get('isAutoSubmitted')) {
return;
}
$emailToProcess = $email;
if ($email->isFetched()) {
$emailToProcess = $this->entityManager->getEntityById(Email::ENTITY_TYPE, $email->getId());
} else {
$emailToProcess->updateFetchedValues();
}
if ($emailToProcess) {
$this->createCase($account, $email);
}
return;
}
if ($account->autoReply()) {
if (
$beforeFetchResult->get('isAutoSubmitted') ||
$beforeFetchResult->get('skipAutoReply')
) {
return;
}
$user = null;
if ($account->getAssignedUser()) {
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $account->getAssignedUser()->getId());
}
$this->autoReply($account, $email, null, $user);
}
}
private function noteAboutEmail(Email $email): void
{
if (!$email->getParent()) {
return;
}
$this->streamService->noteEmailReceived($email->getParent(), $email);
}
private function autoReply(
GroupAccount $account,
Email $email,
?CaseObj $case = null,
?User $user = null
): void {
$inboundEmail = $account->getEntity();
$fromAddress = $email->getFromAddress();
if (!$fromAddress) {
return;
}
$replyEmailTemplateId = $inboundEmail->get('replyEmailTemplateId');
if (!$replyEmailTemplateId) {
return;
}
$limit = $this->config->get('emailAutoReplyLimit', self::DEFAULT_AUTOREPLY_LIMIT);
$d = new DateTime();
$period = $this->config->get('emailAutoReplySuppressPeriod', self::DEFAULT_AUTOREPLY_SUPPRESS_PERIOD);
$d->modify('-' . $period);
$threshold = $d->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
/** @var EmailAddressRepository $emailAddressRepository */
$emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
$emailAddress = $emailAddressRepository->getByAddress($fromAddress);
if ($emailAddress) {
$sentCount = $this->entityManager
->getRDBRepository(Email::ENTITY_TYPE)
->where([
'toEmailAddresses.id' => $emailAddress->getId(),
'dateSent>' => $threshold,
'status' => Email::STATUS_SENT,
'createdById' => $this->systemUser->getId(),
])
->join('toEmailAddresses')
->count();
if ($sentCount >= $limit) {
return;
}
}
$sender = $this->emailSender->create();
if ($email->getMessageId()) {
$sender->withAddedHeader('In-Reply-To', $email->getMessageId());
}
$sender
->withAddedHeader('Auto-Submitted', 'auto-replied')
->withAddedHeader('X-Auto-Response-Suppress', 'All')
->withAddedHeader('Precedence', 'auto_reply');
try {
$entityHash = [];
$contact = null;
if ($case) {
$entityHash[CaseObj::ENTITY_TYPE] = $case;
$contact = $case->getContact();
}
if (!$contact) {
$contact = $this->entityManager->getNewEntity(Contact::ENTITY_TYPE);
$fromName = Util::parseFromName($email->getFromString() ?? '');
if (!empty($fromName)) {
$contact->set(Field::NAME, $fromName);
}
}
$entityHash[Person::TEMPLATE_TYPE] = $contact;
$entityHash[Contact::ENTITY_TYPE] = $contact;
$entityHash[Email::ENTITY_TYPE] = $email;
if ($user) {
$entityHash[User::ENTITY_TYPE] = $user;
}
$replyData = $this->emailTemplateService->process(
$replyEmailTemplateId,
EmailTemplateData::create()
->withEntityHash($entityHash),
EmailTemplateParams::create()
->withApplyAcl(false)
->withCopyAttachments()
);
$subject = $replyData->getSubject();
if ($case && $case->getNumber() !== null) {
$subject = "[#{$case->getNumber()}] $subject";
}
$reply = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$reply
->addToAddress($fromAddress)
->setSubject($subject)
->setBody($replyData->getBody())
->setIsHtml($replyData->isHtml())
->setIsAutoReply()
->setTeams($email->getTeams());
if ($email->getParentId() && $email->getParentType()) {
$reply->setParent($email->getParent());
}
$this->entityManager->saveEntity($reply);
$senderParams = SenderParams::create();
if ($inboundEmail->isAvailableForSending()) {
$groupAccount = $this->groupAccountFactory->create($inboundEmail->getId());
$smtpParams = $groupAccount->getSmtpParams();
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
if ($groupAccount->getEmailAddress()) {
$senderParams = $senderParams->withFromAddress($groupAccount->getEmailAddress());
}
}
if ($inboundEmail->getFromName()) {
$senderParams = $senderParams->withFromName($inboundEmail->getFromName());
}
if ($inboundEmail->getReplyFromAddress()) {
$senderParams = $senderParams->withFromAddress($inboundEmail->getReplyFromAddress());
}
if ($inboundEmail->getReplyFromName()) {
$senderParams = $senderParams->withFromName($inboundEmail->getReplyFromName());
}
if ($inboundEmail->getReplyToAddress()) {
$senderParams = $senderParams->withReplyToAddress($inboundEmail->getReplyToAddress());
}
$sender
->withParams($senderParams)
->withAttachments($replyData->getAttachmentList())
->send($reply);
$this->entityManager->saveEntity($reply);
} catch (Throwable $e) {
$this->log->error("Inbound Email: Auto-reply error: " . $e->getMessage(), ['exception' => $e]);
}
}
private function createCase(GroupAccount $account, Email $email): void
{
$inboundEmail = $account->getEntity();
$parentId = $email->getParentId();
if (
$email->getParentType() === CaseObj::ENTITY_TYPE &&
$parentId
) {
$case = $this->entityManager
->getRDBRepositoryByClass(CaseObj::class)
->getById($parentId);
if (!$case) {
return;
}
$this->processCaseToEmailFields($case, $email);
if (!$email->isFetched()) {
$this->streamService->noteEmailReceived($case, $email);
}
return;
}
/** @noinspection RegExpRedundantEscape */
if (preg_match('/\[#([0-9]+)[^0-9]*\]/', $email->get(Field::NAME), $m)) {
$caseNumber = $m[1];
$case = $this->entityManager
->getRDBRepository(CaseObj::ENTITY_TYPE)
->where([
'number' => $caseNumber,
])
->findOne();
if (!$case) {
return;
}
$email->set('parentType', CaseObj::ENTITY_TYPE);
$email->set('parentId', $case->getId());
$this->processCaseToEmailFields($case, $email);
if (!$email->isFetched()) {
$this->streamService->noteEmailReceived($case, $email);
}
return;
}
$params = [
'caseDistribution' => $inboundEmail->getCaseDistribution(),
'teamId' => $inboundEmail->get('teamId'),
'userId' => $inboundEmail->get('assignToUserId'),
'targetUserPosition' => $inboundEmail->getTargetUserPosition(),
'inboundEmailId' => $inboundEmail->getId(),
];
$case = $this->emailToCase($email, $params);
$assignedUserLink = $case->getAssignedUser();
$user = $assignedUserLink ?
$this->entityManager->getEntityById(User::ENTITY_TYPE, $assignedUserLink->getId())
: null;
$this->streamService->noteEmailReceived($case, $email, true);
if ($account->autoReply()) {
$this->autoReply($account, $email, $case, $user);
}
}
private function processCaseToEmailFields(CaseObj $case, Email $email): void
{
$userIdList = [];
if ($case->hasLinkMultipleField(Field::ASSIGNED_USERS)) {
$userIdList = $case->getLinkMultipleIdList(Field::ASSIGNED_USERS);
} else {
$assignedUserLink = $case->getAssignedUser();
if ($assignedUserLink) {
$userIdList[] = $assignedUserLink->getId();
}
}
foreach ($userIdList as $userId) {
$email->addLinkMultipleId('users', $userId);
}
$teamIdList = $case->getLinkMultipleIdList(Field::TEAMS);
foreach ($teamIdList as $teamId) {
$email->addLinkMultipleId(Field::TEAMS, $teamId);
}
$this->entityManager->saveEntity($email, [
'skipLinkMultipleRemove' => true,
'skipLinkMultipleUpdate' => true,
]);
}
/**
* @param array<string, mixed> $params
*/
private function emailToCase(Email $email, array $params): CaseObj
{
/** @var CaseObj $case */
$case = $this->entityManager->getNewEntity(CaseObj::ENTITY_TYPE);
$case->populateDefaults();
$case->set(Field::NAME, $email->get(Field::NAME));
$bodyPlain = $email->getBodyPlain() ?? '';
/** @var string $replacedBodyPlain */
$replacedBodyPlain = preg_replace('/\s+/', '', $bodyPlain);
if (trim($replacedBodyPlain) === '') {
$bodyPlain = '';
}
if ($bodyPlain) {
$case->set('description', $bodyPlain);
}
$attachmentIdList = $email->getLinkMultipleIdList('attachments');
$copiedAttachmentIdList = [];
/** @var AttachmentRepository $attachmentRepository*/
$attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE);
foreach ($attachmentIdList as $attachmentId) {
$attachment = $attachmentRepository->getById($attachmentId);
if (!$attachment) {
continue;
}
$copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment);
$copiedAttachmentIdList[] = $copiedAttachment->getId();
}
if (count($copiedAttachmentIdList)) {
$case->setLinkMultipleIdList('attachments', $copiedAttachmentIdList);
}
$userId = null;
if (!empty($params['userId'])) {
$userId = $params['userId'];
}
if (!empty($params['inboundEmailId'])) {
$case->set('inboundEmailId', $params['inboundEmailId']);
}
$teamId = false;
if (!empty($params['teamId'])) {
$teamId = $params['teamId'];
}
if ($teamId) {
$case->set('teamsIds', [$teamId]);
}
$caseDistribution = '';
if (!empty($params['caseDistribution'])) {
$caseDistribution = $params['caseDistribution'];
}
$targetUserPosition = null;
if (!empty($params['targetUserPosition'])) {
$targetUserPosition = $params['targetUserPosition'];
}
switch ($caseDistribution) {
case InboundEmail::CASE_DISTRIBUTION_DIRECT_ASSIGNMENT:
if ($userId) {
$case->set('assignedUserId', $userId);
$case->set('status', CaseObj::STATUS_ASSIGNED);
}
break;
case InboundEmail::CASE_DISTRIBUTION_ROUND_ROBIN:
if ($teamId) {
/** @var ?Team $team */
$team = $this->entityManager->getEntityById(Team::ENTITY_TYPE, $teamId);
if ($team) {
$this->assignRoundRobin($case, $team, $targetUserPosition);
}
}
break;
case InboundEmail::CASE_DISTRIBUTION_LEAST_BUSY:
if ($teamId) {
/** @var ?Team $team */
$team = $this->entityManager->getEntityById(Team::ENTITY_TYPE, $teamId);
if ($team) {
$this->assignLeastBusy($case, $team, $targetUserPosition);
}
}
break;
}
$assignedUserLink = $case->getAssignedUser();
if ($assignedUserLink) {
$email->set('assignedUserId', $assignedUserLink->getId());
}
if ($email->get('accountId')) {
$case->set('accountId', $email->get('accountId'));
}
$contact = $this->entityManager
->getRDBRepository(Contact::ENTITY_TYPE)
->join(Link::EMAIL_ADDRESSES, 'emailAddressesMultiple')
->where([
'emailAddressesMultiple.id' => $email->get('fromEmailAddressId'),
])
->findOne();
if ($contact) {
$case->set('contactId', $contact->getId());
} else {
if (!$case->get('accountId')) {
$lead = $this->entityManager
->getRDBRepository(Lead::ENTITY_TYPE)
->join(Link::EMAIL_ADDRESSES, 'emailAddressesMultiple')
->where([
'emailAddressesMultiple.id' => $email->get('fromEmailAddressId')
])
->findOne();
if ($lead) {
$case->set('leadId', $lead->getId());
}
}
}
$this->entityManager->saveEntity($case);
$email->set('parentType', CaseObj::ENTITY_TYPE);
$email->set('parentId', $case->getId());
$this->entityManager->saveEntity($email, [
'skipLinkMultipleRemove' => true,
'skipLinkMultipleUpdate' => true,
]);
// Unknown reason of doing this.
$fetchedCase = $this->entityManager
->getRDBRepositoryByClass(CaseObj::class)
->getById($case->getId());
if ($fetchedCase) {
return $fetchedCase;
}
$this->entityManager->refreshEntity($case);
return $case;
}
private function assignRoundRobin(CaseObj $case, Team $team, ?string $targetUserPosition): void
{
$user = $this->roundRobin->getUser($team, $targetUserPosition);
if ($user) {
$case->set('assignedUserId', $user->getId());
$case->set('status', CaseObj::STATUS_ASSIGNED);
}
}
private function assignLeastBusy(CaseObj $case, Team $team, ?string $targetUserPosition): void
{
$user = $this->leastBusy->getUser($team, $targetUserPosition);
if ($user) {
$case->set('assignedUserId', $user->getId());
$case->set('status', CaseObj::STATUS_ASSIGNED);
}
}
}

View File

@@ -0,0 +1,166 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount\Hooks;
use Espo\Core\Mail\Account\Hook\BeforeFetch as BeforeFetchInterface;
use Espo\Core\Mail\Account\Hook\BeforeFetchResult;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Importer\AutoReplyDetector;
use Espo\Core\Mail\Message;
use Espo\Core\Mail\Account\GroupAccount\BouncedRecognizer;
use Espo\Core\Utils\Log;
use Espo\Entities\EmailAddress;
use Espo\ORM\EntityManager;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
use Throwable;
class BeforeFetch implements BeforeFetchInterface
{
public function __construct(
private Log $log,
private EntityManager $entityManager,
private BouncedRecognizer $bouncedRecognizer,
private CampaignService $campaignService,
private AutoReplyDetector $autoReplyDetector,
) {}
public function process(Account $account, Message $message): BeforeFetchResult
{
if ($this->bouncedRecognizer->isBounced($message)) {
try {
$toSkip = $this->processBounced($message);
} catch (Throwable $e) {
$logMessage = 'InboundEmail ' . $account->getId() . ' ' .
'Process Bounced Message; ' . $e->getCode() . ' ' . $e->getMessage();
$this->log->error($logMessage, ['exception' => $e]);
return BeforeFetchResult::create()->withToSkip();
}
if ($toSkip) {
return BeforeFetchResult::create()->withToSkip();
}
}
return BeforeFetchResult::create()
->with('skipAutoReply', $this->checkMessageCannotBeAutoReplied($message))
->with('isAutoSubmitted', $this->checkMessageIsAutoSubmitted($message));
}
private function processBounced(Message $message): bool
{
$isHard = $this->bouncedRecognizer->isHard($message);
$queueItemId = $this->bouncedRecognizer->extractQueueItemId($message);
if (!$queueItemId) {
return false;
}
$queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId);
if (!$queueItem) {
return false;
}
$campaignId = $queueItem->getMassEmail()?->getCampaignId();
$emailAddress = $queueItem->getEmailAddress();
if (!$emailAddress) {
return true;
}
/** @var EmailAddressRepository $emailAddressRepository */
$emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
if ($isHard) {
$emailAddressEntity = $emailAddressRepository->getByAddress($emailAddress);
if ($emailAddressEntity) {
$emailAddressEntity->setInvalid(true);
$this->entityManager->saveEntity($emailAddressEntity);
}
}
$targetType = $queueItem->getTargetType();
$targetId = $queueItem->getTargetId();
$target = $this->entityManager->getEntityById($targetType, $targetId);
if ($campaignId && $target) {
$this->campaignService->logBounced($campaignId, $queueItem, $isHard);
}
return true;
}
private function checkMessageIsAutoReply(Message $message): bool
{
if ($this->checkMessageIsAutoSubmitted($message)) {
return true;
}
return $this->autoReplyDetector->detect($message);
}
private function checkMessageCannotBeAutoReplied(Message $message): bool
{
if (
$message->getHeader('X-Auto-Response-Suppress') === 'AutoReply' ||
$message->getHeader('X-Auto-Response-Suppress') === 'All'
) {
return true;
}
if ($this->checkMessageIsAutoSubmitted($message)) {
return true;
}
if ($this->checkMessageIsAutoReply($message)) {
return true;
}
return false;
}
private function checkMessageIsAutoSubmitted(Message $message): bool
{
if ($this->autoReplyDetector->detect($message)) {
return true;
}
return $message->getHeader('Auto-Submitted') &&
strtolower($message->getHeader('Auto-Submitted')) !== 'no';
}
}

View File

@@ -0,0 +1,160 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Mail\Account\Account as Account;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\Account\Fetcher;
use Espo\Core\Mail\Account\Storage\Params;
use Espo\Core\Mail\Account\StorageFactory;
use Espo\Core\Mail\Account\Util\NotificationHelper;
use Espo\Core\Mail\Exceptions\ImapError;
use Espo\Core\Mail\Exceptions\NoImap;
use Espo\Core\Mail\Sender\Message;
use Espo\Core\Utils\Log;
use Exception;
use Laminas\Mail\Exception\ExceptionInterface;
class Service
{
public function __construct(
private Fetcher $fetcher,
private AccountFactory $accountFactory,
private StorageFactory $storageFactory,
private Log $log,
private NotificationHelper $notificationHelper
) {}
/**
* @param string $id Account ID.
* @throws Error
* @throws NoImap
* @throws ImapError
*/
public function fetch(string $id): void
{
$account = $this->accountFactory->create($id);
try {
$this->fetcher->fetch($account);
} catch (ImapError $e) {
$this->notificationHelper->processImapError($account);
throw $e;
}
$account->updateConnectedAt();
}
/**
* @return string[]
* @throws Error
* @throws ImapError
*/
public function getFolderList(Params $params): array
{
if ($params->getId()) {
$account = $this->accountFactory->create($params->getId());
$params = $params
->withPassword($this->getPassword($params, $account))
->withImapHandlerClassName($account->getImapHandlerClassName());
}
$storage = $this->storageFactory->createWithParams($params);
return $storage->getFolderNames();
}
/**
* @throws Error
*/
public function testConnection(Params $params): void
{
if ($params->getId()) {
$account = $this->accountFactory->create($params->getId());
$params = $params
->withPassword($this->getPassword($params, $account))
->withImapHandlerClassName($account->getImapHandlerClassName());
}
try {
$storage = $this->storageFactory->createWithParams($params);
$storage->getFolderNames();
} catch (Exception $e) {
$this->log->warning("IMAP test connection failed; {message}", [
'exception' => $e,
'message' => $e->getMessage(),
]);
$message = $e instanceof ExceptionInterface || $e instanceof ImapError ?
$e->getMessage() : '';
throw new ErrorSilent($message);
}
}
private function getPassword(Params $params, Account $account): ?string
{
$password = $params->getPassword();
if ($password !== null) {
return $password;
}
$imapParams = $account->getImapParams();
return $imapParams?->getPassword();
}
/**
* @param string $id Account ID.
* @throws Error
* @throws ImapError
* @throws NoImap
*/
public function storeSentMessage(string $id, Message $message): void
{
$account = $this->accountFactory->create($id);
$folder = $account->getSentFolder();
if (!$folder) {
throw new Error("No sent folder for Group Email Account $id.");
}
$storage = $this->storageFactory->create($account);
$storage->appendMessage($message->toString(), $folder);
}
}

View File

@@ -0,0 +1,135 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\GroupAccount;
use Espo\Core\Mail\Account\Storage\Params;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Account\StorageFactory as StorageFactoryInterface;
use Espo\Core\Mail\Account\Storage\LaminasStorage;
use Espo\Core\Mail\Exceptions\ImapError;
use Espo\Core\Mail\Exceptions\NoImap;
use Espo\Core\Mail\Mail\Storage\Imap;
use Espo\Core\Utils\Log;
use Espo\Core\InjectableFactory;
use Espo\ORM\Name\Attribute;
use Laminas\Mail\Storage\Exception\RuntimeException;
use Laminas\Mail\Storage\Exception\InvalidArgumentException;
use Laminas\Mail\Protocol\Exception\RuntimeException as ProtocolRuntimeException;
use Throwable;
class StorageFactory implements StorageFactoryInterface
{
public function __construct(
private Log $log,
private InjectableFactory $injectableFactory
) {}
public function create(Account $account): LaminasStorage
{
$imapParams = $account->getImapParams();
if (!$imapParams) {
throw new NoImap("No IMAP params.");
}
$params = Params::createBuilder()
->setHost($imapParams->getHost())
->setPort($imapParams->getPort())
->setSecurity($imapParams->getSecurity())
->setUsername($imapParams->getUsername())
->setPassword($imapParams->getPassword())
->setId($account->getId())
->setImapHandlerClassName($account->getImapHandlerClassName())
->build();
return $this->createWithParams($params);
}
public function createWithParams(Params $params): LaminasStorage
{
$rawParams = [
'host' => $params->getHost(),
'port' => $params->getPort(),
'username' => $params->getUsername(),
'password' => $params->getPassword(),
'imapHandler' => $params->getImapHandlerClassName(),
Attribute::ID => $params->getId(),
];
if ($params->getSecurity()) {
$rawParams['security'] = $params->getSecurity();
}
$imapParams = null;
$handlerClassName = $rawParams['imapHandler'] ?? null;
$handler = null;
if ($handlerClassName && !empty($rawParams['id'])) {
try {
$handler = $this->injectableFactory->create($handlerClassName);
} catch (Throwable $e) {
$this->log->error("InboundEmail: Could not create Imap Handler. Error: " . $e->getMessage());
}
if ($handler && method_exists($handler, 'prepareProtocol')) {
// for backward compatibility
$rawParams['ssl'] = $rawParams['security'] ?? null;
// @todo Incorporate an interface `LaminasProtocolPreparator`.
$imapParams = $handler->prepareProtocol($rawParams['id'], $rawParams);
}
}
if (!$imapParams) {
$imapParams = [
'host' => $rawParams['host'],
'port' => $rawParams['port'],
'user' => $rawParams['username'],
'password' => $rawParams['password'],
];
if (!empty($rawParams['security'])) {
$imapParams['ssl'] = $rawParams['security'];
}
}
try {
$storage = new Imap($imapParams);
} catch (RuntimeException|InvalidArgumentException|ProtocolRuntimeException $e) {
throw new ImapError($e->getMessage(), 0, $e);
}
return new LaminasStorage($storage);
}
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Hook;
use Espo\Core\Mail\Account\Account;
use Espo\Entities\Email;
interface AfterFetch
{
public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void;
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Hook;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Message;
interface BeforeFetch
{
public function process(Account $account, Message $message): BeforeFetchResult;
}

View File

@@ -0,0 +1,73 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Hook;
class BeforeFetchResult
{
private bool $toSkip = false;
/** @var array<string, mixed> */
private array $data = [];
public static function create(): self
{
return new self();
}
public function withToSkip(bool $toSkip = true): self
{
$obj = clone $this;
$obj->toSkip = $toSkip;
return $obj;
}
public function with(string $name, mixed $value): self
{
$obj = clone $this;
$obj->data[$name] = $value;
return $obj;
}
public function toSkip(): bool
{
return $this->toSkip;
}
public function get(string $name): mixed
{
return $this->data[$name] ?? null;
}
public function has(string $name): bool
{
return array_key_exists($name, $this->data);
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
/**
* Immutable.
*/
class ImapParams
{
public function __construct(
private string $host,
private int $port,
private string $username,
private ?string $password,
private ?string $security
) {}
public function getHost(): string
{
return $this->host;
}
public function getPort(): int
{
return $this->port;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getSecurity(): ?string
{
return $this->security;
}
}

View File

@@ -0,0 +1,331 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount;
use Espo\Core\Exceptions\Error;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\Link;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Field\LinkMultipleItem;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Account\ImapParams;
use Espo\Core\Mail\Smtp\HandlerProcessor;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Crypt;
use Espo\Entities\EmailAccount;
use Espo\Entities\User;
use Espo\Entities\Email;
use Espo\Core\Mail\Account\Account as AccountInterface;
use Espo\Core\Mail\Account\FetchData;
use Espo\ORM\EntityManager;
use RuntimeException;
class Account implements AccountInterface
{
private const PORTION_LIMIT = 10;
private User $user;
private Crypt $crypt;
/**
* @throws Error
*/
public function __construct(
private EmailAccount $entity,
private EntityManager $entityManager,
private Config $config,
private HandlerProcessor $handlerProcessor,
Crypt $crypt
) {
if (!$this->entity->getAssignedUser()) {
throw new Error("No assigned user.");
}
$userId = $this->entity->getAssignedUser()->getId();
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user) {
throw new Error("Assigned user not found.");
}
$this->user = $user;
$this->crypt = $crypt;
}
public function updateFetchData(FetchData $fetchData): void
{
$this->entity->set('fetchData', $fetchData->getRaw());
$this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]);
}
public function updateConnectedAt(): void
{
$this->entity->set('connectedAt', DateTime::createNow()->toString());
$this->entityManager->saveEntity($this->entity, [SaveOption::SILENT => true]);
}
public function relateEmail(Email $email): void
{
$this->entityManager
->getRDBRepository(EmailAccount::ENTITY_TYPE)
->getRelation($this->entity, 'emails')
->relate($email);
}
public function getEntity(): EmailAccount
{
return $this->entity;
}
public function getPortionLimit(): int
{
return $this->config->get('personalEmailMaxPortionSize', self::PORTION_LIMIT);
}
public function isAvailableForFetching(): bool
{
return $this->entity->isAvailableForFetching();
}
public function getEmailAddress(): ?string
{
return $this->entity->getEmailAddress();
}
public function getUsers(): LinkMultiple
{
$linkMultiple = LinkMultiple::create();
$userLink = $this->getUser();
return $linkMultiple->withAdded(
LinkMultipleItem
::create($userLink->getId())
->withName($userLink->getName() ?? '')
);
}
/**
* A user to assign emails to. Not need for personal accounts.
*/
public function getAssignedUser(): ?Link
{
return null;
}
/**
* @throws Error
*/
public function getUser(): Link
{
$userLink = $this->entity->getAssignedUser();
if (!$userLink) {
throw new Error("No assigned user.");
}
return $userLink;
}
public function getTeams(): LinkMultiple
{
$linkMultiple = LinkMultiple::create();
$team = $this->user->getDefaultTeam();
if (!$team) {
return $linkMultiple;
}
return $linkMultiple->withAdded(
LinkMultipleItem
::create($team->getId())
->withName($team->getName() ?? '')
);
}
public function keepFetchedEmailsUnread(): bool
{
return $this->entity->keepFetchedEmailsUnread();
}
public function getFetchData(): FetchData
{
return FetchData::fromRaw(
$this->entity->getFetchData()
);
}
public function getFetchSince(): ?Date
{
return $this->entity->getFetchSince();
}
public function getEmailFolder(): ?Link
{
return $this->entity->getEmailFolder();
}
/**
* @return string[]
*/
public function getMonitoredFolderList(): array
{
return $this->entity->getMonitoredFolderList();
}
public function getId(): ?string
{
return $this->entity->getId();
}
public function getEntityType(): string
{
return $this->entity->getEntityType();
}
/**
* @return ?class-string<object>
*/
public function getImapHandlerClassName(): ?string
{
return $this->entity->getImapHandlerClassName();
}
public function getSentFolder(): ?string
{
return $this->entity->getSentFolder();
}
public function getGroupEmailFolder(): ?Link
{
return null;
}
public function isAvailableForSending(): bool
{
return $this->entity->isAvailableForSending();
}
/**
* @throws NoSmtp
*/
public function getSmtpParams(): ?SmtpParams
{
$host = $this->entity->getSmtpHost();
if (!$host) {
return null;
}
$port = $this->entity->getSmtpPort();
if ($port === null) {
throw new NoSmtp("Empty port.");
}
$smtpParams = SmtpParams::create($host, $port)
->withSecurity($this->entity->getSmtpSecurity())
->withAuth($this->entity->getSmtpAuth());
if ($this->entity->getSmtpAuth()) {
$password = $this->entity->getSmtpPassword();
if ($password !== null) {
$password = $this->crypt->decrypt($password);
}
$smtpParams = $smtpParams
->withUsername($this->entity->getSmtpUsername())
->withPassword($password)
->withAuthMechanism($this->entity->getSmtpAuthMechanism());
}
$handlerClassName = $this->entity->getSmtpHandlerClassName();
if (!$handlerClassName) {
return $smtpParams;
}
return $this->handlerProcessor->handle($handlerClassName, $smtpParams, $this->getId());
}
public function storeSentEmails(): bool
{
return $this->entity->storeSentEmails();
}
public function getImapParams(): ?ImapParams
{
$host = $this->entity->getHost();
$port = $this->entity->getPort();
$username = $this->entity->getUsername();
$password = $this->entity->getPassword();
$security = $this->entity->getSecurity();
if (!$host) {
return null;
}
if ($port === null) {
throw new RuntimeException("No port.");
}
if ($username === null) {
throw new RuntimeException("No username.");
}
if ($password !== null) {
$password = $this->crypt->decrypt($password);
}
return new ImapParams(
$host,
$port,
$username,
$password,
$security
);
}
public function getConnectedAt(): ?DateTime
{
/** @var DateTime */
return $this->entity->getValueObject('connectedAt');
}
}

View File

@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Entities\EmailAccount;
use Espo\ORM\EntityManager;
class AccountFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private EntityManager $entityManager
) {}
/**
* @throws Error
*/
public function create(string $id): Account
{
$entity = $this->entityManager->getEntityById(EmailAccount::ENTITY_TYPE, $id);
if (!$entity) {
throw new Error("EmailAccount '{$id}' not found.");
}
$binding = BindingContainerBuilder::create()
->bindInstance(EmailAccount::class, $entity)
->build();
return $this->injectableFactory->createWithBinding(Account::class, $binding);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount;
use Espo\Core\Binding\Factory;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\Account\Hook\AfterFetch;
use Espo\Core\Mail\Account\PersonalAccount\Hooks\AfterFetch as PersonalAccountAfterFetch;
use Espo\Core\Mail\Account\Fetcher;
use Espo\Core\Mail\Account\StorageFactory;
use Espo\Core\Mail\Account\PersonalAccount\StorageFactory as PersonalAccountStorageFactory;
/**
* @implements Factory<Fetcher>
*/
class FetcherFactory implements Factory
{
public function __construct(private InjectableFactory $injectableFactory)
{}
public function create(): Fetcher
{
$binding = BindingContainerBuilder::create()
->bindImplementation(StorageFactory::class, PersonalAccountStorageFactory::class)
->bindImplementation(AfterFetch::class, PersonalAccountAfterFetch::class)
->build();
return $this->injectableFactory->createWithBinding(Fetcher::class, $binding);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount\Hooks;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Account\Hook\BeforeFetchResult;
use Espo\Core\Mail\Account\Hook\AfterFetch as AfterFetchInterface;
use Espo\Tools\Stream\Service as StreamService;
use Espo\Entities\Email;
class AfterFetch implements AfterFetchInterface
{
public function __construct(
private StreamService $streamService
) {}
public function process(Account $account, Email $email, BeforeFetchResult $beforeFetchResult): void
{
if (!$email->isFetched()) {
$this->noteAboutEmail($email);
}
}
private function noteAboutEmail(Email $email): void
{
if (!$email->getParent()) {
return;
}
$this->streamService->noteEmailReceived($email->getParent(), $email);
}
}

View File

@@ -0,0 +1,197 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Mail\Account\Util\NotificationHelper;
use Espo\Core\Mail\Exceptions\ImapError;
use Espo\Core\Mail\Exceptions\NoImap;
use Espo\Core\Utils\Log;
use Espo\Core\Mail\Account\Account as Account;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\Account\Fetcher;
use Espo\Core\Mail\Account\Storage\Params;
use Espo\Core\Mail\Account\StorageFactory;
use Espo\Entities\User;
use Espo\Core\Mail\Sender\Message;
use Laminas\Mail\Exception\ExceptionInterface;
use Exception;
class Service
{
public function __construct(
private Fetcher $fetcher,
private AccountFactory $accountFactory,
private StorageFactory $storageFactory,
private User $user,
private Log $log,
private NotificationHelper $notificationHelper
) {}
/**
* @param string $id Account ID.
* @throws Error
* @throws NoImap
* @throws ImapError
*/
public function fetch(string $id): void
{
$account = $this->accountFactory->create($id);
try {
$this->fetcher->fetch($account);
} catch (ImapError $e) {
$this->notificationHelper->processImapError($account);
throw $e;
}
$account->updateConnectedAt();
}
/**
* @return string[]
* @throws Forbidden
* @throws Error
* @throws ImapError
*/
public function getFolderList(Params $params): array
{
$userId = $params->getUserId();
if (
$userId &&
!$this->user->isAdmin() &&
$userId !== $this->user->getId()
) {
throw new Forbidden();
}
if ($params->getId()) {
$account = $this->accountFactory->create($params->getId());
$params = $params
->withPassword($this->getPassword($params, $account))
->withImapHandlerClassName($account->getImapHandlerClassName());
}
$storage = $this->storageFactory->createWithParams($params);
return $storage->getFolderNames();
}
/**
* @throws Forbidden
* @throws Error
*/
public function testConnection(Params $params): void
{
$userId = $params->getUserId();
if (
$userId &&
!$this->user->isAdmin() &&
$userId !== $this->user->getId()
) {
throw new Forbidden();
}
if (!$params->getId() && $params->getPassword() === null) {
throw new Forbidden();
}
if ($params->getId()) {
$account = $this->accountFactory->create($params->getId());
if (
!$this->user->isAdmin() &&
$account->getUser()->getId() !== $this->user->getId()
) {
throw new Forbidden();
}
$params = $params
->withPassword($this->getPassword($params, $account))
->withImapHandlerClassName($account->getImapHandlerClassName());
}
try {
$storage = $this->storageFactory->createWithParams($params);
$storage->getFolderNames();
} catch (Exception $e) {
$this->log->warning("IMAP test connection failed; {message}", [
'exception' => $e,
'message' => $e->getMessage(),
]);
$message = $e instanceof ExceptionInterface || $e instanceof ImapError ?
$e->getMessage() : '';
throw new ErrorSilent($message);
}
}
private function getPassword(Params $params, Account $account): ?string
{
$password = $params->getPassword();
if ($password !== null) {
return $password;
}
$imapParams = $account->getImapParams();
return $imapParams?->getPassword();
}
/**
* @param string $id Account ID.
* @throws Error
* @throws ImapError
* @throws NoImap
*/
public function storeSentMessage(string $id, Message $message): void
{
$account = $this->accountFactory->create($id);
$folder = $account->getSentFolder();
if (!$folder) {
throw new Error("No sent folder for Email Account $id.");
}
$storage = $this->storageFactory->create($account);
$storage->appendMessage($message->toString(), $folder);
}
}

View File

@@ -0,0 +1,149 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\PersonalAccount;
use Espo\Core\Mail\Account\Storage\Params;
use Espo\Core\Mail\Account\StorageFactory as StorageFactoryInterface;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Exceptions\ImapError;
use Espo\Core\Mail\Exceptions\NoImap;
use Espo\Core\Mail\Mail\Storage\Imap;
use Espo\Core\Mail\Account\Storage\LaminasStorage;
use Espo\Core\Utils\Log;
use Espo\Core\InjectableFactory;
use Espo\ORM\Name\Attribute;
use Laminas\Mail\Protocol\Exception\RuntimeException as ProtocolRuntimeException;
use Laminas\Mail\Storage\Exception\InvalidArgumentException;
use Laminas\Mail\Storage\Exception\RuntimeException;
use LogicException;
use Throwable;
class StorageFactory implements StorageFactoryInterface
{
public function __construct(
private Log $log,
private InjectableFactory $injectableFactory,
) {}
public function create(Account $account): LaminasStorage
{
$userLink = $account->getUser();
if (!$userLink) {
throw new LogicException("No user for mail account.");
}
$userId = $userLink->getId();
$imapParams = $account->getImapParams();
if (!$imapParams) {
throw new NoImap("No IMAP params.");
}
$params = Params::createBuilder()
->setHost($imapParams->getHost())
->setPort($imapParams->getPort())
->setSecurity($imapParams->getSecurity())
->setUsername($imapParams->getUsername())
->setPassword($imapParams->getPassword())
->setEmailAddress($account->getEmailAddress())
->setUserId($userId)
->setId($account->getId())
->setImapHandlerClassName($account->getImapHandlerClassName())
->build();
return $this->createWithParams($params);
}
public function createWithParams(Params $params): LaminasStorage
{
$rawParams = [
'host' => $params->getHost(),
'port' => $params->getPort(),
'username' => $params->getUsername(),
'password' => $params->getPassword(),
'emailAddress' => $params->getEmailAddress(),
'userId' => $params->getUserId(),
'imapHandler' => $params->getImapHandlerClassName(),
Attribute::ID => $params->getId(),
];
if ($params->getSecurity()) {
$rawParams['security'] = $params->getSecurity();
}
/** @var ?class-string $handlerClassName */
$handlerClassName = $rawParams['imapHandler'] ?? null;
$handler = null;
$imapParams = null;
if ($handlerClassName && !empty($rawParams['id'])) {
try {
$handler = $this->injectableFactory->create($handlerClassName);
} catch (Throwable $e) {
$this->log->error(
"EmailAccount: Could not create Imap Handler. Error: " . $e->getMessage()
);
}
if ($handler && method_exists($handler, 'prepareProtocol')) {
// for backward compatibility
$rawParams['ssl'] = $rawParams['security'] ?? null;
$imapParams = $handler->prepareProtocol($rawParams['id'], $rawParams);
}
}
if (!$imapParams) {
$imapParams = [
'host' => $rawParams['host'],
'port' => $rawParams['port'],
'user' => $rawParams['username'],
'password' => $rawParams['password'],
];
if (!empty($rawParams['security'])) {
$imapParams['ssl'] = $rawParams['security'];
}
}
try {
$storage = new Imap($imapParams);
} catch (RuntimeException|InvalidArgumentException|ProtocolRuntimeException $e) {
throw new ImapError($e->getMessage(), 0, $e);
}
return new LaminasStorage($storage);
}
}

View File

@@ -0,0 +1,248 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Acl\Table;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory;
use Espo\Core\Mail\ConfigDataProvider;
use Espo\Core\Name\Field;
use Espo\Entities\EmailAccount as EmailAccountEntity;
use Espo\Entities\InboundEmail as InboundEmailEntity;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
use RuntimeException;
class SendingAccountProvider
{
private ?Account $system = null;
private bool $systemIsCached = false;
public function __construct(
private EntityManager $entityManager,
private GroupAccountFactory $groupAccountFactory,
private PersonalAccountFactory $personalAccountFactory,
private AclManager $aclManager,
private ConfigDataProvider $configDataProvider,
) {}
public function getShared(User $user, string $emailAddress): ?Account
{
$level = $this->aclManager->getPermissionLevel($user, 'groupEmailAccount');
$entity = null;
if ($level === Table::LEVEL_TEAM) {
$teamIdList = $user->getTeamIdList();
if ($teamIdList === []) {
return null;
}
$entity = $this->entityManager
->getRDBRepositoryByClass(InboundEmailEntity::class)
->select([Attribute::ID])
->distinct()
->join(Field::TEAMS)
->where([
'status' => InboundEmailEntity::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
'teamsMiddle.teamId' => $teamIdList,
])
->where(
Condition::equal(
Expression::lowerCase(
Expression::column('emailAddress')
),
strtolower($emailAddress)
)
)
->findOne();
}
if ($level === Table::LEVEL_ALL) {
$entity = $this->entityManager
->getRDBRepositoryByClass(InboundEmailEntity::class)
->select([Attribute::ID])
->where([
'status' => InboundEmailEntity::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
])
->where(
Condition::equal(
Expression::lowerCase(
Expression::column('emailAddress')
),
strtolower($emailAddress)
)
)
->findOne();
}
if (!$entity) {
return null;
}
try {
return $this->groupAccountFactory->create($entity->getId());
} catch (Error) {
throw new RuntimeException();
}
}
public function getGroup(string $emailAddress): ?Account
{
$entity = $this->entityManager
->getRDBRepositoryByClass(InboundEmailEntity::class)
->select([Attribute::ID])
->where([
'status' => InboundEmailEntity::STATUS_ACTIVE,
'useSmtp' => true,
'smtpHost!=' => null,
])
->where(
Condition::equal(
Expression::lowerCase(
Expression::column('emailAddress')
),
strtolower($emailAddress)
)
)
->findOne();
if (!$entity) {
return null;
}
try {
return $this->groupAccountFactory->create($entity->getId());
} catch (Error) {
throw new RuntimeException();
}
}
/**
* Get a personal user account.
*/
public function getPersonal(User $user, ?string $emailAddress): ?Account
{
if (!$emailAddress) {
$emailAddress = $user->getEmailAddress();
}
if (!$emailAddress) {
return null;
}
$entity = $this->entityManager
->getRDBRepositoryByClass(EmailAccountEntity::class)
->select([Attribute::ID])
->where([
'assignedUserId' => $user->getId(),
'status' => EmailAccountEntity::STATUS_ACTIVE,
'useSmtp' => true,
])
->where(
Condition::equal(
Expression::lowerCase(
Expression::column('emailAddress')
),
strtolower($emailAddress)
)
)
->findOne();
if (!$entity) {
return null;
}
try {
return $this->personalAccountFactory->create($entity->getId());
} catch (Error) {
throw new RuntimeException();
}
}
/**
* Get a system account.
*/
public function getSystem(): ?Account
{
if (!$this->systemIsCached) {
$this->loadSystem();
$this->systemIsCached = true;
}
return $this->system;
}
private function loadSystem(): void
{
$address = $this->configDataProvider->getSystemOutboundAddress();
if (!$address) {
return;
}
$entity = $this->entityManager
->getRDBRepositoryByClass(InboundEmailEntity::class)
->where([
'status' => InboundEmailEntity::STATUS_ACTIVE,
'useSmtp' => true,
])
->where(
Condition::equal(
Expression::lowerCase(
Expression::column('emailAddress')
),
strtolower($address)
)
)
->findOne();
if (!$entity) {
return;
}
try {
$this->system = $this->groupAccountFactory->create($entity->getId());
} catch (Error) {
throw new RuntimeException();
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Field\DateTime;
interface Storage
{
/**
* Set message flags.
*
* @param string[] $flags
*/
public function setFlags(int $id, array $flags): void;
/**
* Get a message size.
*/
public function getSize(int $id): int;
/**
* Get message raw content.
*/
public function getRawContent(int $id): string;
/**
* Get a message unique ID.
*/
public function getUniqueId(int $id): string;
/**
* Get IDs from unique ID.
*
* @return int[]
*/
public function getIdsFromUniqueId(string $uniqueId): array;
/**
* Get IDs since a specific date.
*
* @return int[]
*/
public function getIdsSinceDate(DateTime $since): array;
/**
* Get only header and flags. Won't fetch the whole email.
*
* @return array{header: string, flags: string[]}
*/
public function getHeaderAndFlags(int $id): array;
/**
* Close the resource.
*/
public function close(): void;
/**
* @return string[]
*/
public function getFolderNames(): array;
/**
* Select a folder.
*/
public function selectFolder(string $name): void;
/**
* Store a message.
*/
public function appendMessage(string $content, ?string $folder = null): void;
}

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
class Flag
{
public const PASSED = 'Passed';
public const SEEN = '\Seen';
public const UNSEEN = '\Unseen';
public const ANSWERED = '\Answered';
public const FLAGGED = '\Flagged';
public const DELETED = '\Deleted';
public const DRAFT = '\Draft';
public const RECENT = '\Recent';
}

View File

@@ -0,0 +1,134 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Mail\Account\Storage;
use Espo\Core\Mail\Mail\Storage\Imap;
use Espo\Core\Field\DateTime;
use RecursiveIteratorIterator;
class LaminasStorage implements Storage
{
public function __construct(private Imap $imap)
{}
/**
* @param string[] $flags
*/
public function setFlags(int $id, array $flags): void
{
$this->imap->setFlags($id, $flags);
}
public function getSize(int $id): int
{
/** @var int */
return $this->imap->getSize($id);
}
public function getRawContent(int $id): string
{
return $this->imap->getRawContent($id);
}
public function getUniqueId(int $id): string
{
/** @var string */
return $this->imap->getUniqueId($id);
}
/**
* @return int[]
*/
public function getIdsFromUniqueId(string $uniqueId): array
{
return $this->imap->getIdsFromUniqueId($uniqueId);
}
/**
* @return int[]
*/
public function getIdsSinceDate(DateTime $since): array
{
return $this->imap->getIdsSinceDate(
$since->toDateTime()->format('d-M-Y')
);
}
/**
* @return array{header: string, flags: string[]}
*/
public function getHeaderAndFlags(int $id): array
{
return $this->imap->getHeaderAndFlags($id);
}
public function close(): void
{
$this->imap->close();
}
/**
* @return string[]
*/
public function getFolderNames(): array
{
$folderIterator = new RecursiveIteratorIterator(
$this->imap->getFolders(),
RecursiveIteratorIterator::SELF_FIRST
);
$list = [];
foreach ($folderIterator as $folder) {
$list[] = mb_convert_encoding($folder->getGlobalName(), 'UTF-8', 'UTF7-IMAP');
}
/** @var string[] */
return $list;
}
public function selectFolder(string $name): void
{
$nameConverted = mb_convert_encoding($name, 'UTF7-IMAP', 'UTF-8');
$this->imap->selectFolder($nameConverted);
}
public function appendMessage(string $content, ?string $folder = null): void
{
if ($folder !== null) {
$folder = mb_convert_encoding($folder, 'UTF7-IMAP', 'UTF-8');
}
$this->imap->appendMessage($content, $folder);
}
}

View File

@@ -0,0 +1,130 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use SensitiveParameter;
/**
* Immutable.
*/
class Params
{
/** @var ?class-string<object> */
private ?string $imapHandlerClassName;
/**
* @param ?class-string<object> $imapHandlerClassName
*/
public function __construct(
private ?string $host,
private ?int $port,
private ?string $username,
private ?string $password,
private ?string $security,
?string $imapHandlerClassName,
private ?string $id,
private ?string $userId,
private ?string $emailAddress
) {
$this->imapHandlerClassName = $imapHandlerClassName;
}
public static function createBuilder(): ParamsBuilder
{
return new ParamsBuilder();
}
public function getHost(): ?string
{
return $this->host;
}
public function getPort(): ?int
{
return $this->port;
}
public function getUsername(): ?string
{
return $this->username;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getSecurity(): ?string
{
return $this->security;
}
/**
* @return ?class-string
*/
public function getImapHandlerClassName(): ?string
{
return $this->imapHandlerClassName;
}
public function getId(): ?string
{
return $this->id;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getEmailAddress(): ?string
{
return $this->emailAddress;
}
public function withPassword(#[SensitiveParameter] ?string $password): self
{
$obj = clone $this;
$obj->password = $password;
return $obj;
}
/**
* @param ?class-string $imapHandlerClassName
*/
public function withImapHandlerClassName(?string $imapHandlerClassName): self
{
$obj = clone $this;
$obj->imapHandlerClassName = $imapHandlerClassName;
return $obj;
}
}

View File

@@ -0,0 +1,127 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use SensitiveParameter;
class ParamsBuilder
{
private ?string $host = null;
private ?int $port = null;
private ?string $username = null;
private ?string $password = null;
private ?string $security = null;
/** @var ?class-string<object> */
private ?string $imapHandlerClassName = null;
private ?string $id = null;
private ?string $userId = null;
private ?string $emailAddress = null;
public function build(): Params
{
return new Params(
$this->host,
$this->port,
$this->username,
$this->password,
$this->security,
$this->imapHandlerClassName,
$this->id,
$this->userId,
$this->emailAddress
);
}
public function setHost(?string $host): self
{
$this->host = $host;
return $this;
}
public function setPort(?int $port): self
{
$this->port = $port;
return $this;
}
public function setUsername(?string $username): self
{
$this->username = $username;
return $this;
}
public function setPassword(#[SensitiveParameter] ?string $password): self
{
$this->password = $password;
return $this;
}
public function setSecurity(?string $security): self
{
$this->security = $security;
return $this;
}
/**
* @param ?class-string<object> $imapHandlerClassName
*/
public function setImapHandlerClassName(?string $imapHandlerClassName): self
{
$this->imapHandlerClassName = $imapHandlerClassName;
return $this;
}
public function setId(?string $id): self
{
$this->id = $id;
return $this;
}
public function setUserId(?string $userId): self
{
$this->userId = $userId;
return $this;
}
public function setEmailAddress(?string $emailAddress): self
{
$this->emailAddress = $emailAddress;
return $this;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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;
use Espo\Core\Mail\Account\Storage\Params;
use Espo\Core\Mail\Exceptions\ImapError;
use Espo\Core\Mail\Exceptions\NoImap;
interface StorageFactory
{
/**
* Create an account.
*
* @throws NoImap
* @throws ImapError
*/
public function create(Account $account): Storage;
/**
* Create an account with parameters.
*
* @throws ImapError
*/
public function createWithParams(Params $params): Storage;
}

View File

@@ -0,0 +1,147 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Util;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\LinkParent;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Utils\Language;
use Espo\Entities\InboundEmail;
use Espo\Entities\Notification;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
class NotificationHelper
{
private const PERIOD = '1 day';
private const WAIT_PERIOD = '10 minutes';
public function __construct(
private EntityManager $entityManager,
private Language $language
) {}
public function processImapError(Account $account): void
{
$userId = $account->getUser()?->getId();
$id = $account->getId();
$entityType = $account->getEntityType();
if (!$id) {
return;
}
if (
$account->getConnectedAt() &&
DateTime::createNow()
->modify('-' . self::WAIT_PERIOD)
->isLessThan($account->getConnectedAt())
) {
return;
}
$userIds = [];
if ($entityType === InboundEmail::ENTITY_TYPE) {
$userIds = $this->getAdminUserIds();
} else if ($userId) {
$userIds[] = $userId;
}
foreach ($userIds as $userId) {
$this->processImapErrorForUser($entityType, $id, $userId);
}
}
private function exists(string $entityType, string $id, string $userId): bool
{
$one = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->where([
'relatedId' => $id,
'relatedType' => $entityType,
'userId' => $userId,
'createdAt>' => DateTime::createNow()->modify('-' . self::PERIOD)->toString(),
])
->findOne();
return $one !== null;
}
private function getMessage(string $entityType, string $id): string
{
$message = $this->language->translateLabel('imapNotConnected', 'messages', $entityType);
return str_replace('{id}', $id, $message);
}
private function processImapErrorForUser(string $entityType, string $id, string $userId): void
{
if ($this->exists($entityType, $id, $userId)) {
return;
}
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
$message = $this->getMessage($entityType, $id);
$notification
->setType(Notification::TYPE_MESSAGE)
->setMessage($message)
->setUserId($userId)
->setRelated(LinkParent::create($entityType, $id));
$this->entityManager->saveEntity($notification);
}
/**
* @return string[]
*/
private function getAdminUserIds(): array
{
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->select([Attribute::ID])
->where([
'isActive' => true,
'type' => User::TYPE_ADMIN,
])
->find();
$ids = [];
foreach ($users as $user) {
$ids[] = $user->getId();
}
return $ids;
}
}