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

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;
use Espo\Core\Utils\Config;
class ConfigDataProvider
{
public function __construct(
private Config $config,
) {}
public function getSystemOutboundAddress(): ?string
{
return $this->config->get('outboundEmailFromAddress');
}
public function isSystemOutboundAddressShared(): bool
{
return (bool) $this->config->get('outboundEmailIsShared');
}
}

View File

@@ -0,0 +1,53 @@
<?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;
use Espo\ORM\EntityManager;
use Espo\Entities\Email;
/**
* Creates email instances.
*/
class EmailFactory
{
public function __construct(private EntityManager $entityManager)
{}
/**
* Create an email instance.
*/
public function create(): Email
{
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
return $email;
}
}

View File

@@ -0,0 +1,193 @@
<?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;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\Account\SendingAccountProvider;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Laminas\Mail\Message;
/**
* A service for email sending. Can send with SMTP parameters of the system email account or with specific parameters.
* Uses a builder to send with specific parameters.
*/
class EmailSender
{
public function __construct(
private Config $config,
private SendingAccountProvider $accountProvider,
private InjectableFactory $injectableFactory
) {}
private function createSender(): Sender
{
return $this->injectableFactory->createWithBinding(
Sender::class,
BindingContainerBuilder
::create()
->bindInstance(SendingAccountProvider::class, $this->accountProvider)
->build()
);
}
/**
* Create a builder.
*/
public function create(): Sender
{
return $this->createSender();
}
/**
* With parameters.
*
* @param SenderParams|array<string, mixed> $params
*/
public function withParams($params): Sender
{
return $this->createSender()->withParams($params);
}
/**
* With specific SMTP parameters.
*
* @param SmtpParams|array<string, mixed> $params
*/
public function withSmtpParams($params): Sender
{
return $this->createSender()->withSmtpParams($params);
}
/**
* With specific attachments.
*
* @param iterable<Attachment> $attachmentList
* @noinspection PhpUnused
*/
public function withAttachments(iterable $attachmentList): Sender
{
return $this->createSender()->withAttachments($attachmentList);
}
/**
* With an envelope from address.
*
* @since 9.1.0
* @noinspection PhpUnused
*/
public function withEnvelopeFromAddress(string $fromAddress): void
{
$this->createSender()->withEnvelopeFromAddress($fromAddress);
}
/**
* With envelope options.
*
* @param array{from: string} $options
* @deprecated As of v9.1.
* @todo Remove in v10.0. Use `withEnvelopeFromAddress`.
*/
public function withEnvelopeOptions(array $options): Sender
{
return $this->createSender()->withEnvelopeOptions($options);
}
/**
* Set a message instance.
*
* @deprecated As of v9.1. Use `withAddedHeader`.
* @todo Remove in v10.0.
*/
public function withMessage(Message $message): Sender
{
return $this->createSender()->withMessage($message);
}
/**
* Add a header.
*
* @param string $name A header name.
* @param string $value A header value.
* @since 9.1.0
*/
public function withAddedHeader(string $name, string $value): Sender
{
return $this->createSender()->withAddedHeader($name, $value);
}
/**
* Whether system SMTP is configured.
*/
public function hasSystemSmtp(): bool
{
if ($this->config->get('smtpServer')) {
return true;
}
if ($this->accountProvider->getSystem()) {
return true;
}
return false;
}
/**
* Send an email.
*
* @throws Exceptions\SendingError
*/
public function send(Email $email): void
{
$this->createSender()->send($email);
}
/**
* Generate a message ID.
*/
static public function generateMessageId(Email $email): string
{
$rand = mt_rand(1000, 9999);
$messageId = $email->getParentType() && $email->getParentId() ?
sprintf("%s/%s/%s/%s@espo", $email->getParentType(), $email->getParentId(), time(), $rand) :
sprintf("%s/%s/%s@espo", md5($email->get(Field::NAME)), time(), $rand);
if ($email->get('isSystem')) {
$messageId .= '-system';
}
return $messageId;
}
}

View File

@@ -0,0 +1,246 @@
<?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\Event;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use DateTime;
use DateTimeZone;
use RuntimeException;
class Event
{
private ?string $attendees = null;
private ?string $organizer = null;
private ?string $dateStart = null;
private ?string $dateEnd = null;
private ?string $location = null;
private ?string $name = null;
private ?string $description = null;
private ?string $timezone = null;
private ?string $uid = null;
private bool $isAllDay = false;
public function withAttendees(?string $attendees): self
{
$obj = clone $this;
$obj->attendees = $attendees;
return $obj;
}
public function withOrganizer(?string $organizer): self
{
$obj = clone $this;
$obj->organizer = $organizer;
return $obj;
}
public function withDateStart(?string $dateStart): self
{
$obj = clone $this;
$obj->dateStart = $dateStart;
return $obj;
}
public function withDateEnd(?string $dateEnd): self
{
$obj = clone $this;
$obj->dateEnd = $dateEnd;
return $obj;
}
public function withLocation(?string $location): self
{
$obj = clone $this;
$obj->location = $location;
return $obj;
}
public function withName(?string $name): self
{
$obj = clone $this;
$obj->name = $name;
return $obj;
}
public function withDescription(?string $description): self
{
$obj = clone $this;
$obj->description = $description;
return $obj;
}
public function withTimezone(?string $timezone): self
{
$obj = clone $this;
$obj->timezone = $timezone;
return $obj;
}
public function withUid(?string $uid): self
{
$obj = clone $this;
$obj->uid = $uid;
return $obj;
}
public function withIsAllDay(bool $isAllDay): self
{
$obj = clone $this;
$obj->isAllDay = $isAllDay;
return $obj;
}
public function getUid(): ?string
{
return $this->uid;
}
public function isAllDay(): bool
{
return $this->isAllDay;
}
public function getName(): ?string
{
return $this->name;
}
public function getDateStart(): ?string
{
return $this->convertDate($this->dateStart);
}
public function getDateEnd(): ?string
{
return $this->convertDate($this->dateEnd, true);
}
public function getLocation(): ?string
{
return $this->location;
}
public function getDescription(): ?string
{
return $this->description;
}
public static function create(): self
{
return new self();
}
private function convertDate(?string $value, bool $isEnd = false): ?string
{
if ($value === null) {
return null;
}
if ($this->isAllDay) {
$dt = DateTime::createFromFormat('Ymd', $value);
if ($dt === false) {
throw new RuntimeException("Could not parse '{$value}'.");
}
if ($isEnd) {
$dt->modify('-1 day');
}
return $dt->format(DateTimeUtil::SYSTEM_DATE_FORMAT);
}
$timezone = $this->timezone ?? 'UTC';
$dt = DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($timezone));
if ($dt === false) {
throw new RuntimeException("Could not parse '{$value}'.");
}
$dt->setTimezone(new DateTimeZone('UTC'));
return $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
}
public function getOrganizerEmailAddress(): ?string
{
return $this->getEmailAddressFromAttendee($this->organizer);
}
/**
* @return string[]
*/
public function getAttendeeEmailAddressList(): array
{
if ($this->attendees === null || $this->attendees === '') {
return [];
}
$list = [];
foreach (explode(',', $this->attendees) as $item) {
$emailAddress = $this->getEmailAddressFromAttendee($item);
if ($emailAddress === null) {
continue;
}
$list[] = $emailAddress;
}
return $list;
}
private function getEmailAddressFromAttendee(?string $item): ?string
{
if ($item === null || $item === '') {
return null;
}
if (explode(':', $item)[0] !== 'MAILTO') {
return null;
}
return explode(':', $item)[1] ?? null;
}
}

View File

@@ -0,0 +1,70 @@
<?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\Event;
use ICal\Event as ICalEvent;
use ICal\ICal as U01jmg3ICal;
use RuntimeException;
class EventFactory
{
public static function createFromU01jmg3Ical(U01jmg3ICal $ical): Event
{
/* @var ?ICalEvent $event */
$event = $ical->events()[0] ?? null;
if (!$event) {
throw new RuntimeException();
}
$dateStart = $event->dtstart_tz ?? null;
$dateEnd = $event->dtend_tz ?? null;
$isAllDay = strlen($event->dtstart) === 8;
if ($isAllDay) {
$dateStart = $event->dtstart ?? null;
$dateEnd = $event->dtend ?? null;
}
return Event::create()
->withUid($event->uid ?? null)
->withIsAllDay($isAllDay)
->withDateStart($dateStart)
->withDateEnd($dateEnd)
->withName($event->summary ?? null)
->withLocation($event->location ?? null)
->withDescription($event->description ?? null)
->withTimezone($ical->calendarTimeZone() ?? null) /** @phpstan-ignore-line */
->withOrganizer($event->organizer ?? null)
->withAttendees($event->attendee ?? null);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Exceptions;
class ImapError extends \Exception {}

View File

@@ -0,0 +1,32 @@
<?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\Exceptions;
class NoImap extends \Exception {}

View File

@@ -0,0 +1,32 @@
<?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\Exceptions;
class NoSmtp extends SendingError {}

View File

@@ -0,0 +1,32 @@
<?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\Exceptions;
class SendingError extends \Exception {}

View File

@@ -0,0 +1,208 @@
<?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;
use Espo\Entities\Email;
use Espo\Entities\EmailFilter;
class FiltersMatcher
{
/**
* @param iterable<EmailFilter> $filterList
* @param bool $skipBody Not to match if the body-contains is not empty.
*/
public function findMatch(Email $email, $filterList, bool $skipBody = false): ?EmailFilter
{
foreach ($filterList as $filter) {
if ($this->match($email, $filter, $skipBody)) {
return $filter;
}
}
return null;
}
/**
* @param bool $skipBody Not to match if the body-contains is not empty.
*/
public function match(Email $email, EmailFilter $filter, bool $skipBody = false): bool
{
$filterCount = 0;
$from = $filter->getFrom();
$subject = $filter->getSubject();
if ($from) {
$filterCount++;
if (
!$this->matchString(
strtolower($from),
strtolower($email->getFromAddress() ?? '')
)
) {
return false;
}
}
if ($filter->getTo()) {
$filterCount++;
if (!$this->matchTo($email, $filter)) {
return false;
}
}
if ($subject) {
$filterCount++;
if (
!$this->matchString($subject, $email->getSubject() ?? '')
) {
return false;
}
}
if (count($filter->getBodyContains())) {
$filterCount++;
if ($skipBody) {
return false;
}
if (!$this->matchBody($email, $filter)) {
return false;
}
}
if (count($filter->getBodyContainsAll())) {
$filterCount++;
if ($skipBody) {
return false;
}
if (!$this->matchBodyAll($email, $filter)) {
return false;
}
}
if ($filterCount) {
return true;
}
return false;
}
private function matchTo(Email $email, EmailFilter $filter): bool
{
$filterTo = $filter->getTo();
if ($filterTo === null) {
return false;
}
if (count($email->getToAddressList())) {
foreach ($email->getToAddressList() as $to) {
if (
$this->matchString(
strtolower($filterTo),
strtolower($to)
)
) {
return true;
}
}
}
return false;
}
private function matchBody(Email $email, EmailFilter $filter): bool
{
$phraseList = $filter->getBodyContains();
$body = $email->getBody();
$bodyPlain = $email->getBodyPlain();
foreach ($phraseList as $phrase) {
if ($phrase === '') {
continue;
}
if ($bodyPlain && stripos($bodyPlain, $phrase) !== false) {
return true;
}
if ($body && stripos($body, $phrase) !== false) {
return true;
}
}
return false;
}
private function matchBodyAll(Email $email, EmailFilter $filter): bool
{
$phraseList = $filter->getBodyContainsAll();
$body = $email->getBody() ?? $email->getBodyPlain() ?? '';
if ($phraseList === []) {
return true;
}
foreach ($phraseList as $phrase) {
if ($phrase === '') {
continue;
}
if (stripos($body, $phrase) === false) {
return false;
}
}
return true;
}
private function matchString(string $pattern, string $value): bool
{
if ($pattern == $value) {
return true;
}
$pattern = preg_quote($pattern, '#');
$pattern = str_replace('\*', '.*', $pattern) . '\z';
if (preg_match('#^' . $pattern . '#', $value)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,41 @@
<?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;
use Espo\Core\Mail\Importer\Data;
use Espo\Entities\Email;
/**
* Imports email messages. Handles duplicate checking, parent look-up.
*/
interface Importer
{
public function import(Message $message, Data $data): ?Email;
}

View File

@@ -0,0 +1,42 @@
<?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\Importer;
use Espo\Core\Mail\Message;
/**
* Detects if an email is auto-response.
*
* @since 9.2.0
*/
interface AutoReplyDetector
{
public function detect(Message $message): bool;
}

View File

@@ -0,0 +1,172 @@
<?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\Importer;
use Espo\Entities\EmailFilter;
/**
* Immutable.
*/
class Data
{
private ?string $assignedUserId = null;
/** @var string[] */
private array $teamIdList = [];
/** @var string[] */
private array $userIdList = [];
/** @var iterable<EmailFilter> */
private iterable $filterList = [];
private bool $fetchOnlyHeader = false;
/** @var array<string, string> */
private array $folderData = [];
private ?string $groupEmailFolderId = null;
public static function create(): self
{
return new self();
}
public function getAssignedUserId(): ?string
{
return $this->assignedUserId;
}
/**
* @return string[]
*/
public function getTeamIdList(): array
{
return $this->teamIdList;
}
/**
* @return string[]
*/
public function getUserIdList(): array
{
return $this->userIdList;
}
/**
* @return iterable<EmailFilter>
*/
public function getFilterList(): iterable
{
return $this->filterList;
}
public function fetchOnlyHeader(): bool
{
return $this->fetchOnlyHeader;
}
/**
* @return array<string, string>
*/
public function getFolderData(): array
{
return $this->folderData;
}
public function getGroupEmailFolderId(): ?string
{
return $this->groupEmailFolderId;
}
public function withAssignedUserId(?string $assignedUserId): self
{
$obj = clone $this;
$obj->assignedUserId = $assignedUserId;
return $obj;
}
/**
* @param string[] $teamIdList
*/
public function withTeamIdList(array $teamIdList): self
{
$obj = clone $this;
$obj->teamIdList = $teamIdList;
return $obj;
}
/**
* @param string[] $userIdList
*/
public function withUserIdList(array $userIdList): self
{
$obj = clone $this;
$obj->userIdList = $userIdList;
return $obj;
}
/**
* @param iterable<EmailFilter> $filterList
*/
public function withFilterList(iterable $filterList): self
{
$obj = clone $this;
$obj->filterList = $filterList;
return $obj;
}
public function withFetchOnlyHeader(bool $fetchOnlyHeader = true): self
{
$obj = clone $this;
$obj->fetchOnlyHeader = $fetchOnlyHeader;
return $obj;
}
/**
* @param array<string, string> $folderData
*/
public function withFolderData(array $folderData): self
{
$obj = clone $this;
$obj->folderData = $folderData;
return $obj;
}
public function withGroupEmailFolderId(?string $groupEmailFolderId): self
{
$obj = clone $this;
$obj->groupEmailFolderId = $groupEmailFolderId;
return $obj;
}
}

View File

@@ -0,0 +1,55 @@
<?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\Importer;
use Espo\Core\Mail\Message;
class DefaultAutoReplyDetector implements AutoReplyDetector
{
public function detect(Message $message): bool
{
if ($message->getHeader('X-Autoreply')) {
return true;
}
if ($message->getHeader('X-Autorespond')) {
return true;
}
if (
$message->getHeader('Auto-Submitted') &&
strtolower($message->getHeader('Auto-Submitted')) === 'auto-replied'
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
<?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\Importer;
use Espo\Core\Mail\Message;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
class DefaultDuplicateFinder implements DuplicateFinder
{
public function __construct(private EntityManager $entityManager)
{}
public function find(Email $email, Message $message): ?Email
{
if (!$email->getMessageId()) {
return null;
}
return $this->entityManager
->getRDBRepositoryByClass(Email::class)
->select([Attribute::ID, 'status'])
->where([
'messageId' => $email->getMessageId(),
])
->findOne();
}
}

View File

@@ -0,0 +1,723 @@
<?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\Importer;
use Espo\Core\Field\DateTime as DateTimeField;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Field\LinkParent;
use Espo\Core\Job\Job\Data as JobData;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Core\Mail\FiltersMatcher;
use Espo\Core\Mail\Importer;
use Espo\Core\Mail\Message;
use Espo\Core\Mail\MessageWrapper;
use Espo\Core\Mail\Parser;
use Espo\Core\Mail\ParserFactory;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Notification\AssignmentNotificator;
use Espo\Core\Notification\AssignmentNotificatorFactory;
use Espo\Core\Notification\AssignmentNotificator\Params as AssignmentNotificatorParams;
use Espo\Core\Utils\Config;
use Espo\Core\FieldProcessing\Relation\LinkMultipleSaver;
use Espo\Core\FieldProcessing\Saver\Params as SaverParams;
use Espo\Core\Job\QueueName;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\EmailFilter;
use Espo\Entities\GroupEmailFolder;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\SelectBuilder;
use Espo\Repositories\Email as EmailRepository;
use Espo\ORM\EntityManager;
use Espo\Tools\Stream\Jobs\ProcessNoteAcl;
use DateTime;
use Exception;
class DefaultImporter implements Importer
{
private const SUBJECT_MAX_LENGTH = 255;
private const PROCESS_ACL_DELAY_PERIOD = '5 seconds';
/** @var AssignmentNotificator<Email> */
private AssignmentNotificator $notificator;
private FiltersMatcher $filtersMatcher;
public function __construct(
private EntityManager $entityManager,
private Config $config,
AssignmentNotificatorFactory $notificatorFactory,
private ParserFactory $parserFactory,
private LinkMultipleSaver $linkMultipleSaver,
private DuplicateFinder $duplicateFinder,
private JobSchedulerFactory $jobSchedulerFactory,
private ParentFinder $parentFinder,
private AutoReplyDetector $autoReplyDetector,
) {
$this->notificator = $notificatorFactory->createByClass(Email::class);
$this->filtersMatcher = new FiltersMatcher();
}
public function import(Message $message, Data $data): ?Email
{
$parser = $this->getParser($message);
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->set('isBeingImported', true);
$subject = $this->getSubject($parser, $message);
$email
->setSubject($subject)
->setStatus(Email::STATUS_ARCHIVED)
->setIsHtml(false)
->setGroupFolderId($data->getGroupEmailFolderId())
->setTeams(LinkMultiple::create()->withAddedIdList($data->getTeamIdList()));
if ($data->getAssignedUserId()) {
$email->setAssignedUserId($data->getAssignedUserId());
$email->addAssignedUserId($data->getAssignedUserId());
}
foreach ($data->getUserIdList() as $uId) {
$email->addUserId($uId);
}
$this->setFromStrings($parser, $message, $email);
$this->setAddresses($parser, $message, $email);
foreach ($data->getFolderData() as $uId => $folderId) {
$email->setUserColumnFolderId($uId, $folderId);
}
$toSkip = $this->processFilters($email, $data->getFilterList(), true);
if ($toSkip) {
return null;
}
$isSystemEmail = $this->processMessageId($parser, $message, $email);
if ($isSystemEmail) {
return null;
}
$this->processDate($parser, $message, $email);
$duplicate = $this->findDuplicate($email, $message);
if ($duplicate && $duplicate->getStatus() !== Email::STATUS_BEING_IMPORTED) {
$this->entityManager->refreshEntity($duplicate);
$this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId());
return $duplicate;
}
$email->setIsAutoReply($this->autoReplyDetector->detect($message));
$this->processDeliveryDate($parser, $message, $email);
if (!$email->getDateSent()) {
$email->setDateSent(DateTimeField::createNow());
}
$inlineAttachmentList = [];
if (!$data->fetchOnlyHeader()) {
$inlineAttachmentList = $parser->getInlineAttachmentList($message, $email);
$toSkip = $this->processFilters($email, $data->getFilterList());
if ($toSkip) {
return null;
}
} else {
$email->setBody('Not fetched. The email size exceeds the limit.');
$email->setIsHtml(false);
}
$this->processInReplyTo($parser, $message, $email);
$parentFound = $this->parentFinder->find($email, $message);
if ($parentFound) {
$email->setParent($parentFound);
}
if (!$duplicate) {
$this->entityManager->getLocker()->lockExclusive(Email::ENTITY_TYPE);
$duplicate = $this->findDuplicate($email, $message);
if ($duplicate) {
$this->entityManager->getLocker()->rollback();
if ($duplicate->getStatus() !== Email::STATUS_BEING_IMPORTED) {
$this->entityManager->refreshEntity($duplicate);
$this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId());
return $duplicate;
}
}
}
if ($duplicate) {
$this->copyAttributesToDuplicate($email, $duplicate);
$this->getEmailRepository()->fillAccount($duplicate);
$this->processDuplicate($duplicate, $data, $email->getGroupFolder()?->getId());
return $duplicate;
}
if (!$email->getMessageId()) {
$email->setDummyMessageId();
}
$email->setStatus(Email::STATUS_BEING_IMPORTED);
$this->entityManager->saveEntity($email, [
SaveOption::SKIP_ALL => true,
SaveOption::KEEP_NEW => true,
]);
$this->entityManager->getLocker()->commit();
if ($parentFound) {
$this->processEmailWithParent($email);
}
$email->setStatus(Email::STATUS_ARCHIVED);
$this->processFinalTransactionalSave($email);
$this->processAttachmentSave($inlineAttachmentList, $email);
return $email;
}
private function copyAttributesToDuplicate(Email $email, Email $duplicate): void
{
$duplicate->set([
'from' => $email->get('from'),
'to' => $email->get('to'),
'cc' => $email->get('cc'),
'bcc' => $email->get('bcc'),
'replyTo' => $email->get('replyTo'),
'name' => $email->get(Field::NAME),
'dateSent' => $email->get('dateSent'),
'body' => $email->get('body'),
'bodyPlain' => $email->get('bodyPlain'),
'parentType' => $email->get('parentType'),
'parentId' => $email->get('parentId'),
'isHtml' => $email->get('isHtml'),
'messageId' => $email->get('messageId'),
'fromString' => $email->get('fromString'),
'replyToString' => $email->get('replyToString'),
]);
}
private function processEmailWithParent(Email $email): void
{
$parentType = $email->get(Field::PARENT . 'Type');
$parentId = $email->get(Field::PARENT . 'Id');
if (!$parentId || !$parentType) {
return;
}
$emailKeepParentTeamsEntityList = $this->config->get('emailKeepParentTeamsEntityList') ?? [];
if (
!in_array($parentType, $emailKeepParentTeamsEntityList) ||
!$this->entityManager->hasRepository($parentType)
) {
return;
}
$parent = $email->getParent();
if (!$parent) {
return;
}
if (!$parent instanceof CoreEntity) {
return;
}
foreach ($parent->getLinkMultipleIdList(Field::TEAMS) as $parentTeamId) {
$email->addTeamId($parentTeamId);
}
}
private function findDuplicate(Email $email, Message $message): ?Email
{
return $this->duplicateFinder->find($email, $message);
}
private function processDuplicate(Email $email, Data $data, ?string $groupFolderId): void
{
$assignedUserId = $data->getAssignedUserId();
if ($email->getStatus() === Email::STATUS_ARCHIVED) {
$this->getEmailRepository()->loadFromField($email);
$this->getEmailRepository()->loadToField($email);
}
$fetchedTeamIds = $email->getTeams()->getIdList();
$fetchedUserIds = $email->getUsers()->getIdList();
$fetchedAssignedUserIds = $email->getAssignedUsers()->getIdList();
$email->setLinkMultipleIdList('users', []);
$email->setLinkMultipleIdList(Field::TEAMS, []);
$email->setLinkMultipleIdList(Field::ASSIGNED_USERS, []);
$processNoteAcl = false;
if ($assignedUserId) {
if (!in_array($assignedUserId, $fetchedUserIds)) {
$processNoteAcl = true;
$email->addUserId($assignedUserId);
}
if (!in_array($assignedUserId, $fetchedAssignedUserIds)) {
$email->addAssignedUserId($assignedUserId);
}
}
foreach ($data->getUserIdList() as $uId) {
if (!in_array($uId, $fetchedUserIds)) {
$processNoteAcl = true;
$email->addUserId($uId);
}
}
foreach ($data->getFolderData() as $uId => $folderId) {
if (!in_array($uId, $fetchedUserIds)) {
$email->setUserColumnFolderId($uId, $folderId);
continue;
}
// Can cause skip-notification bypass. @todo Revise.
$this->entityManager
->getRelation($email, 'users')
->updateColumnsById($uId, [Email::USERS_COLUMN_FOLDER_ID => $folderId]);
}
$email->set('isBeingImported', true);
$this->getEmailRepository()->applyUsersFilters($email);
if ($groupFolderId && !$email->getGroupFolder()) {
$this->relateWithGroupFolder($email, $groupFolderId);
$addedFromFolder = $this->applyGroupFolder(
$email,
$groupFolderId,
$fetchedUserIds,
$fetchedTeamIds
);
if ($addedFromFolder) {
$processNoteAcl = true;
}
}
foreach ($data->getTeamIdList() as $teamId) {
if (!in_array($teamId, $fetchedTeamIds)) {
$processNoteAcl = true;
$email->addTeamId($teamId);
}
}
$saverParams = SaverParams::create()->withRawOptions([
'skipLinkMultipleRemove' => true,
'skipLinkMultipleUpdate' => true,
]);
$this->linkMultipleSaver->process($email, 'users', $saverParams);
$this->linkMultipleSaver->process($email, Field::ASSIGNED_USERS, $saverParams);
$this->linkMultipleSaver->process($email, Field::TEAMS, $saverParams);
if ($this->notificationsEnabled()) {
$notificatorParams = AssignmentNotificatorParams::create()
->withRawOptions([Email::SAVE_OPTION_IS_BEING_IMPORTED => true]);
$this->notificator->process($email, $notificatorParams);
}
$email->set('isBeingImported', false);
$email->clear('teamsIds');
$email->clear('usersIds');
$email->clear('assignedUsersIds');
$email->setAsFetched();
if ($email->getParentType() && $processNoteAcl) {
$this->scheduleAclJob($email);
}
}
private function notificationsEnabled(): bool
{
return in_array(
Email::ENTITY_TYPE,
$this->config->get('assignmentNotificationsEntityList') ?? []
);
}
private function getSubject(Parser $parser, Message $message): string
{
$subject = '';
if ($parser->hasHeader($message, 'subject')) {
$subject = $parser->getHeader($message, 'subject');
}
if (!empty($subject)) {
$subject = trim($subject);
}
if ($subject !== '0' && empty($subject)) {
$subject = '(No Subject)';
}
if (strlen($subject) > self::SUBJECT_MAX_LENGTH) {
$subject = substr($subject, 0, self::SUBJECT_MAX_LENGTH);
}
return $subject;
}
private function setFromStrings(Parser $parser, Message $message, Email $email): void
{
$fromAddressData = $parser->getAddressData($message, 'from');
if ($fromAddressData) {
$namePart = ($fromAddressData->name ? ($fromAddressData->name . ' ') : '');
$email->set('fromString', "$namePart<$fromAddressData->address>");
}
$replyToData = $parser->getAddressData($message, 'reply-To');
if ($replyToData) {
$namePart = ($replyToData->name ? ($replyToData->name . ' ') : '');
$email->set('replyToString', "$namePart<$replyToData->address>");
}
}
private function setAddresses(Parser $parser, Message $message, Email $email): void
{
$from = $parser->getAddressList($message, 'from');
$to = $parser->getAddressList($message, 'to');
$cc = $parser->getAddressList($message, 'cc');
$replyTo = $parser->getAddressList($message, 'reply-To');
$email->setFromAddress($from[0] ?? null);
$email->setToAddressList($to);
$email->setCcAddressList($cc);
$email->setReplyToAddressList($replyTo);
$email->set('addressNameMap', $parser->getAddressNameMap($message));
}
/**
* @return bool True if an email is system.
*/
private function processMessageId(Parser $parser, Message $message, Email $email): bool
{
if (!$parser->hasHeader($message, 'message-Id')) {
return false;
}
$messageId = $parser->getMessageId($message);
if (!$messageId) {
return false;
}
$email->setMessageId($messageId);
if ($parser->hasHeader($message, 'delivered-To')) {
$deliveredTo = $parser->getHeader($message, 'delivered-To') ?? '';
$email->set('messageIdInternal', "$messageId-$deliveredTo");
}
if (stripos($messageId, '@espo-system') !== false) {
return true;
}
return false;
}
private function processDate(Parser $parser, Message $message, Email $email): void
{
if (!$parser->hasHeader($message, 'date')) {
return;
}
$dateString = $parser->getHeader($message, 'date') ?? 'now';
try {
$dateSent = DateTimeField::fromDateTime(new DateTime($dateString));
} catch (Exception) {
return;
}
$email->setDateSent($dateSent);
}
private function processDeliveryDate(Parser $parser, Message $message, Email $email): void
{
if (!$parser->hasHeader($message, 'delivery-Date')) {
return;
}
$dateString = $parser->getHeader($message, 'delivery-Date') ?? 'now';
try {
$deliveryDate = DateTimeField::fromDateTime(new DateTime($dateString));
} catch (Exception) {
return;
}
$email->setDeliveryDate($deliveryDate);
}
private function processInReplyTo(Parser $parser, Message $message, Email $email): void
{
if (!$parser->hasHeader($message, 'in-Reply-To')) {
return;
}
$stringValue = $parser->getHeader($message, 'in-Reply-To');
if (!$stringValue) {
return;
}
$values = explode(' ', $stringValue);
$inReplyTo = $values[0] ?? null;
if (!$inReplyTo) {
return;
}
if ($inReplyTo[0] !== '<') {
$inReplyTo = "<$inReplyTo>";
}
$replied = $this->entityManager
->getRDBRepositoryByClass(Email::class)
->where(['messageId' => $inReplyTo])
->findOne();
if (!$replied) {
return;
}
$email->setReplied($replied);
foreach ($replied->getTeams()->getIdList() as $teamId) {
$email->addTeamId($teamId);
}
}
/**
* @param iterable<EmailFilter> $filterList
* @return bool True if to skip.
*/
private function processFilters(Email $email, iterable $filterList, bool $skipBody = false): bool
{
$matchedFilter = $this->filtersMatcher->findMatch($email, $filterList, $skipBody);
if (!$matchedFilter) {
return false;
}
if ($matchedFilter->getAction() === EmailFilter::ACTION_SKIP) {
return true;
}
if (
$matchedFilter->getAction() === EmailFilter::ACTION_MOVE_TO_GROUP_FOLDER &&
$matchedFilter->getGroupEmailFolderId()
) {
$this->applyGroupFolder($email, $matchedFilter->getGroupEmailFolderId());
}
return false;
}
private function processFinalTransactionalSave(Email $email): void
{
$this->entityManager->getTransactionManager()->start();
$this->entityManager
->getRDBRepositoryByClass(Email::class)
->forUpdate()
->where([Attribute::ID => $email->getId()])
->findOne();
$this->entityManager->saveEntity($email, [Email::SAVE_OPTION_IS_BEING_IMPORTED => true]);
$this->entityManager->getTransactionManager()->commit();
}
/**
* @param Attachment[] $inlineAttachmentList
*/
private function processAttachmentSave(array $inlineAttachmentList, Email $email): void
{
foreach ($inlineAttachmentList as $attachment) {
$attachment->setTargetField('body');
$attachment->setRelated(LinkParent::createFromEntity($email));
$this->entityManager->saveEntity($attachment);
}
}
private function getParser(Message $message): Parser
{
return $message instanceof MessageWrapper ?
($message->getParser() ?? $this->parserFactory->create()) :
$this->parserFactory->create();
}
private function getEmailRepository(): EmailRepository
{
/** @var EmailRepository */
return $this->entityManager->getRDBRepositoryByClass(Email::class);
}
private function relateWithGroupFolder(Email $email, string $groupFolderId): void
{
$this->entityManager
->getRelation($email, 'groupFolder')
->relateById($groupFolderId);
}
/**
* @param string[] $fetchedUserIds
* @param string[] $fetchedTeamIds
*/
private function applyGroupFolder(
Email $email,
string $groupFolderId,
array $fetchedUserIds = [],
array $fetchedTeamIds = [],
): bool {
$email->setGroupFolderId($groupFolderId);
$groupFolder = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->getById($groupFolderId);
if (!$groupFolder || !$groupFolder->getTeams()->getCount()) {
return false;
}
$added = false;
foreach ($groupFolder->getTeams()->getIdList() as $teamId) {
if (!in_array($teamId, $fetchedTeamIds)) {
$added = true;
$email->addTeamId($teamId);
}
}
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->select([Attribute::ID])
->where([
'type' => [User::TYPE_REGULAR, User::TYPE_ADMIN],
'isActive' => true,
Attribute::ID . '!=' => $fetchedUserIds,
])
->where(
Condition::in(
Expression::column(Attribute::ID),
SelectBuilder::create()
->from(Team::RELATIONSHIP_TEAM_USER)
->select('userId')
->where(['teamId' => $groupFolder->getTeams()->getIdList()])
->build()
)
)
->find();
foreach ($users as $user) {
$added = true;
$email->addUserId($user->getId());
}
return $added;
}
private function scheduleAclJob(Email $email): void
{
// Need to update acl fields (users and teams)
// of notes related to the duplicate email.
// To grant access to the user who received the email.
$dt = new DateTime();
$dt->modify('+' . self::PROCESS_ACL_DELAY_PERIOD);
$this->jobSchedulerFactory
->create()
->setClassName(ProcessNoteAcl::class)
->setData(
JobData::create(['notify' => true])
->withTargetId($email->getId())
->withTargetType(Email::ENTITY_TYPE)
)
->setQueue(QueueName::Q1)
->setTime($dt)
->schedule();
}
}

View File

@@ -0,0 +1,271 @@
<?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\Importer;
use Espo\Core\Mail\Message;
use Espo\Core\Name\Field;
use Espo\Core\Templates\Entities\Company;
use Espo\Core\Templates\Entities\Person;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Email;
use Espo\Entities\EmailAddress;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
class DefaultParentFinder implements ParentFinder
{
/** @var string[] */
private array $entityTypeList;
public function __construct(
private EntityManager $entityManager,
private Config $config,
private Metadata $metadata
) {
$this->entityTypeList = $this->entityManager
->getDefs()
->getEntity(Email::ENTITY_TYPE)
->getField(Field::PARENT)
->getParam('entityList') ?? [];
}
public function find(Email $email, Message $message): ?Entity
{
return
$this->getByReferences($message) ??
$this->getFromReplied($email) ??
$this->getByFromAddress($email) ??
$this->getByReplyToAddress($email) ??
$this->getByToAddress($email);
}
private function isEntityTypeAllowed(string $entityType): bool
{
return in_array($entityType, $this->entityTypeList);
}
private function getByFromAddress(Email $email): ?Entity
{
$from = $email->getFromAddress();
if (!$from) {
return null;
}
return $this->getByAddress($from);
}
private function getByReplyToAddress(Email $email): ?Entity
{
$list = $email->getReplyToAddressList();
if ($list === []) {
return null;
}
return $this->getByAddress($list[0]);
}
private function getByToAddress(Email $email): ?Entity
{
$list = $email->getToAddressList();
if ($list === []) {
return null;
}
return $this->getByAddress($list[0]);
}
private function getByAddress(string $emailAddress): ?Entity
{
$contact = $this->entityManager
->getRDBRepositoryByClass(Contact::class)
->where(['emailAddress' => $emailAddress])
->findOne();
if ($contact) {
if (
!$this->config->get('b2cMode') &&
$this->isEntityTypeAllowed(Account::ENTITY_TYPE) &&
$contact->getAccount()
) {
return $contact->getAccount();
}
if ($this->isEntityTypeAllowed(Contact::ENTITY_TYPE)) {
return $contact;
}
}
if ($this->isEntityTypeAllowed(Account::ENTITY_TYPE)) {
$account = $this->entityManager
->getRDBRepositoryByClass(Account::class)
->where(['emailAddress' => $emailAddress])
->findOne();
if ($account) {
return $account;
}
}
if ($this->isEntityTypeAllowed(Lead::ENTITY_TYPE)) {
$lead = $this->entityManager
->getRDBRepositoryByClass(Lead::class)
->where(['emailAddress' => $emailAddress])
->findOne();
if ($lead) {
return $lead;
}
}
$entityTypeList = array_filter(
$this->entityTypeList,
function ($entityType) {
return
!in_array(
$entityType,
[Account::ENTITY_TYPE, Contact::ENTITY_TYPE, Lead::ENTITY_TYPE]
) &&
in_array(
$this->metadata->get(['scopes', $entityType, 'type']),
[Company::TEMPLATE_TYPE, Person::TEMPLATE_TYPE]
);
}
);
/** @var EmailAddressRepository $emailAddressRepository */
$emailAddressRepository = $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
foreach ($entityTypeList as $entityType) {
$entity = $emailAddressRepository->getEntityByAddress($emailAddress, $entityType);
if ($entity) {
return $entity;
}
}
return null;
}
private function getFromReplied(Email $email): ?Entity
{
$replied = $email->getReplied();
return $replied?->getParent();
}
private function getByReferences(Message $message): ?Entity
{
$references = $message->getHeader('References');
if (!$references) {
return null;
}
$delimiter = strpos($references, '>,') ? ',' : ' ';
foreach (explode($delimiter, $references) as $reference) {
$reference = str_replace(['/', '@'], ' ', trim(trim($reference), '<>'));
$parent = $this->getByReferencesItem($reference);
if ($parent) {
return $parent;
}
}
return null;
}
private function getByReferencesItem(string $reference): ?Entity
{
$parentType = null;
$parentId = null;
$number = null;
$emailSent = PHP_INT_MAX;
$n = sscanf($reference, '%s %s %d %d espo', $parentType, $parentId, $emailSent, $number);
if ($n !== 4) {
$n = sscanf($reference, '%s %s %d %d espo-system', $parentType, $parentId, $emailSent, $number);
}
if ($n !== 4 || $emailSent >= time()) {
return null;
}
if (!$parentType || !$parentId) {
return null;
}
if (!is_string($parentType) || !is_string($parentId)) {
return null;
}
if (!$this->entityManager->hasRepository($parentType)) {
return null;
}
$parent = $this->entityManager->getEntityById($parentType, $parentId);
if ($parent instanceof Lead) {
return $this->getFromLead($parent) ?? $parent;
}
return $parent;
}
private function getFromLead(Lead $lead): ?Entity
{
if ($lead->getStatus() !== Lead::STATUS_CONVERTED) {
return null;
}
if ($lead->getCreatedAccount()) {
return $lead->getCreatedAccount();
}
if (
$this->config->get('b2cMode') &&
$lead->getCreatedContact()
) {
return $lead->getCreatedContact();
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
<?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\Importer;
use Espo\Core\Mail\Message;
use Espo\Entities\Email;
/**
* Finds an existing duplicate of an email being imported.
*/
interface DuplicateFinder
{
public function find(Email $email, Message $message): ?Email;
}

View File

@@ -0,0 +1,42 @@
<?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\Importer;
use Espo\Entities\Email;
use Espo\Core\Mail\Message;
use Espo\ORM\Entity;
/**
* Finds a parent record for an email being imported.
*/
interface ParentFinder
{
public function find(Email $email, Message $message): ?Entity;
}

View File

@@ -0,0 +1,33 @@
<?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\Mail;
class Headers extends \Laminas\Mail\Headers
{}

View File

@@ -0,0 +1,81 @@
<?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\Mail\Storage;
class Imap extends \Laminas\Mail\Storage\Imap
{
/**
* @return int[]
*/
public function getIdsFromUniqueId(string $uid): array
{
$nextUid = strval(intval($uid) + 1);
assert($this->protocol !== null);
return $this->protocol->search(['UID ' . $nextUid . ':*']);
}
/**
* @param string $date A date in the `d-M-Y` format.
* @return int[]
*/
public function getIdsSinceDate(string $date): array
{
assert($this->protocol !== null);
return $this->protocol->search(['SINCE ' . $date]);
}
/**
* @param int $id
* @return array{header: string, flags: string[]}
*/
public function getHeaderAndFlags(int $id): array
{
assert($this->protocol !== null);
/** @var array{'RFC822.HEADER': string, 'FLAGS': string[]} $data */
$data = $this->protocol->fetch(['FLAGS', 'RFC822.HEADER'], $id);
$header = $data['RFC822.HEADER'];
$flags = [];
foreach ($data['FLAGS'] as $flag) {
$flags[] = static::$knownFlags[$flag] ?? $flag;
}
return [
'flags' => $flags,
'header' => $header,
];
}
}

View File

@@ -0,0 +1,77 @@
<?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;
use Espo\Core\Mail\Message\Part;
interface Message
{
/**
* Whether has a specific header.
*/
public function hasHeader(string $name): bool;
/**
* Get a specific header.
*/
public function getHeader(string $attribute): ?string;
/**
* Get a raw header part.
*/
public function getRawHeader(): string;
/**
* Get a raw content part.
*/
public function getRawContent(): string;
/**
* Get a full raw message.
*/
public function getFullRawContent(): string;
/**
* Get flags.
*
* @return string[]
*/
public function getFlags(): array;
/**
* Whether contents is fetched.
*/
public function isFetched(): bool;
/**
* @return Part[]
*/
public function getPartList(): array;
}

View File

@@ -0,0 +1,79 @@
<?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\Message\MailMimeParser;
use Espo\Core\Mail\Message\Part as PartInterface;
use ZBateson\MailMimeParser\Message\IMessagePart;
class Part implements PartInterface
{
private IMessagePart $part;
public function __construct(IMessagePart $part)
{
$this->part = $part;
}
public function getContentType(): ?string
{
return $this->part->getContentType();
}
public function hasContent(): bool
{
return $this->part->hasContent();
}
public function getContent(): ?string
{
return $this->part->getContent();
}
public function getContentId(): ?string
{
return $this->part->getContentId();
}
public function getCharset(): ?string
{
return $this->part->getCharset();
}
public function getContentDisposition(): ?string
{
return $this->part->getContentDisposition();
}
public function getFilename(): ?string
{
return $this->part->getFilename();
}
}

View File

@@ -0,0 +1,47 @@
<?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\Message;
interface Part
{
public function getContentType(): ?string;
public function hasContent(): bool;
public function getContent(): ?string;
public function getContentId(): ?string;
public function getCharset(): ?string;
public function getContentDisposition(): ?string;
public function getFilename(): ?string;
}

View File

@@ -0,0 +1,150 @@
<?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;
use Espo\Core\Mail\Account\Storage;
use Espo\Core\Mail\Message\Part;
use RuntimeException;
class MessageWrapper implements Message
{
private ?string $rawHeader = null;
private ?string $rawContent = null;
/** @var ?string[] */
private ?array $flagList = null;
public function __construct(
private int $id,
private ?Storage $storage = null,
private ?Parser $parser = null,
private ?string $fullRawContent = null
) {
if ($storage) {
$data = $storage->getHeaderAndFlags($id);
$this->rawHeader = $data['header'];
$this->flagList = $data['flags'];
}
if (
!$storage &&
$this->fullRawContent
) {
$rawHeader = null;
$rawBody = null;
if (str_contains($this->fullRawContent, "\r\n\r\n")) {
[$rawHeader, $rawBody] = explode("\r\n\r\n", $this->fullRawContent, 2);
} else if (str_contains($this->fullRawContent, "\n\n")) {
[$rawHeader, $rawBody] = explode("\n\n", $this->fullRawContent, 2);
}
$this->rawHeader = $rawHeader;
$this->rawContent = $rawBody;
}
}
public function getRawHeader(): string
{
return $this->rawHeader ?? '';
}
public function getParser(): ?Parser
{
return $this->parser;
}
public function hasHeader(string $name): bool
{
if (!$this->parser) {
throw new RuntimeException();
}
return $this->parser->hasHeader($this, $name);
}
public function getHeader(string $attribute): ?string
{
if (!$this->parser) {
throw new RuntimeException();
}
return $this->parser->getHeader($this, $attribute);
}
public function getRawContent(): string
{
if (is_null($this->rawContent)) {
if (!$this->storage) {
throw new RuntimeException();
}
$this->rawContent = $this->storage->getRawContent($this->id);
}
return $this->rawContent ?? '';
}
public function getFullRawContent(): string
{
if ($this->fullRawContent) {
return $this->fullRawContent;
}
return $this->getRawHeader() . "\n" . $this->getRawContent();
}
/**
* @return string[]
*/
public function getFlags(): array
{
return $this->flagList ?? [];
}
public function isFetched(): bool
{
return (bool) $this->rawHeader;
}
/**
* @return Part[]
*/
public function getPartList(): array
{
if (!$this->parser) {
throw new RuntimeException();
}
return $this->parser->getPartList($this);
}
}

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;
use Espo\Entities\Email;
use Espo\Entities\Attachment;
use Espo\Core\Mail\Message\Part;
use stdClass;
interface Parser
{
public function hasHeader(Message $message, string $name): bool;
public function getHeader(Message $message, string $name): ?string;
public function getMessageId(Message $message): ?string;
public function getAddressNameMap(Message $message): stdClass;
/**
* @return ?object{address: string, name: string}
*/
public function getAddressData(Message $message, string $type): ?object;
/**
* @return string[]
*/
public function getAddressList(Message $message, string $type): array;
/**
* @return Attachment[] A list of inline attachments.
*/
public function getInlineAttachmentList(Message $message, Email $email): array;
/**
* @return Part[]
*/
public function getPartList(Message $message): array;
}

View File

@@ -0,0 +1,46 @@
<?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;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\Parsers\MailMimeParser;
class ParserFactory
{
protected const DEFAULT_PARSER_CLASS_NAME = MailMimeParser::class;
public function __construct(private InjectableFactory $injectableFactory)
{}
public function create(): Parser
{
return $this->injectableFactory->create(self::DEFAULT_PARSER_CLASS_NAME);
}
}

View File

@@ -0,0 +1,464 @@
<?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\Parsers;
use Espo\Entities\Email;
use Espo\Entities\Attachment;
use Espo\ORM\EntityManager;
use Espo\Core\Mail\Message;
use Espo\Core\Mail\Parser;
use Espo\Core\Mail\Message\Part;
use Espo\Core\Mail\Message\MailMimeParser\Part as WrapperPart;
use ZBateson\MailMimeParser\Header\AddressHeader;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser as WrappeeParser;
use ZBateson\MailMimeParser\Message\MessagePart;
use ZBateson\MailMimeParser\Message\MimePart;
use stdClass;
/**
* An adapter for MailMimeParser library.
*/
class MailMimeParser implements Parser
{
/** @var array<string, string> */
private array $extMimeTypeMap = [
'jpg' => 'image/jpg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
];
private ?WrappeeParser $parser = null;
private const FIELD_BODY = 'body';
private const FIELD_ATTACHMENTS = 'attachments';
private const DISPOSITION_INLINE = 'inline';
public const TYPE_MESSAGE_RFC822 = 'message/rfc822';
public const TYPE_OCTET_STREAM = 'application/octet-stream';
/** @var array<string, IMessage> */
private array $messageHash = [];
public function __construct(private EntityManager $entityManager)
{}
private function getParser(): WrappeeParser
{
if (!$this->parser) {
$this->parser = new WrappeeParser();
}
return $this->parser;
}
private function loadContent(Message $message): void
{
$raw = $message->getFullRawContent();
$key = spl_object_hash($message);
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
}
/**
* @return IMessage
*/
private function getMessage(Message $message)
{
$key = spl_object_hash($message);
if (!array_key_exists($key, $this->messageHash)) {
$raw = $message->getRawHeader();
if (!$raw) {
$raw = $message->getFullRawContent();
}
$this->messageHash[$key] = $this->getParser()->parse($raw, false);
}
return $this->messageHash[$key];
}
public function hasHeader(Message $message, string $name): bool
{
return $this->getMessage($message)->getHeaderValue($name) !== null;
}
public function getHeader(Message $message, string $name): ?string
{
if (!$this->hasHeader($message, $name)) {
return null;
}
return $this->getMessage($message)->getHeaderValue($name);
}
public function getMessageId(Message $message): ?string
{
$messageId = $this->getHeader($message, 'Message-ID');
if (!$messageId) {
return null;
}
if ($messageId[0] !== '<') {
$messageId = '<' . $messageId . '>';
}
return $messageId;
}
public function getAddressNameMap(Message $message): stdClass
{
$map = (object) [];
foreach (['from', 'to', 'cc', 'reply-To'] as $type) {
$header = $this->getMessage($message)->getHeader($type);
if (!$header || !method_exists($header, 'getAddresses')) {
continue;
}
/** @var AddressHeader $header */
$list = $header->getAddresses();
foreach ($list as $item) {
$address = $item->getEmail();
$name = $item->getName();
if ($name && $address) {
$map->$address = $name;
}
}
}
return $map;
}
public function getAddressData(Message $message, string $type): ?object
{
$header = $this->getMessage($message)->getHeader($type);
/** @var ?AddressHeader $header */
if ($header && method_exists($header, 'getAddresses')) {
foreach ($header->getAddresses() as $item) {
return (object) [
'address' => $item->getEmail(),
'name' => $item->getName(),
];
}
}
return null;
}
/**
* @return string[]
*/
public function getAddressList(Message $message, string $type): array
{
$addressList = [];
$header = $this->getMessage($message)->getHeader($type);
/** @var ?AddressHeader $header */
if ($header && method_exists($header, 'getAddresses')) {
$list = $header->getAddresses();
foreach ($list as $address) {
$addressList[] = $address->getEmail();
}
}
return $addressList;
}
/**
* @return Part[]
*/
public function getPartList(Message $message): array
{
$wrappeeList = $this->getMessage($message)->getChildParts();
$partList = [];
foreach ($wrappeeList as $wrappee) {
$partList[] = new WrapperPart($wrappee);
}
return $partList;
}
/**
* @return Attachment[]
*/
public function getInlineAttachmentList(Message $message, Email $email): array
{
$inlineAttachmentList = [];
$this->loadContent($message);
$bodyPlain = '';
$bodyHtml = '';
$htmlPartCount = $this->getMessage($message)->getHtmlPartCount();
$textPartCount = $this->getMessage($message)->getTextPartCount();
if (!$htmlPartCount) {
$bodyHtml = $this->getMessage($message)->getHtmlContent();
}
if (!$textPartCount) {
$bodyPlain = $this->getMessage($message)->getTextContent();
}
for ($i = 0; $i < $htmlPartCount; $i++) {
if ($i) {
$bodyHtml .= "<br>";
}
$inlinePart = $this->getMessage($message)->getHtmlPart($i);
$bodyHtml .= $inlinePart?->getContent() ?? '';
}
for ($i = 0; $i < $textPartCount; $i++) {
if ($i) {
$bodyPlain .= "\n";
}
$inlinePart = $this->getMessage($message)->getTextPart($i);
$bodyPlain .= $inlinePart?->getContent() ?? '';
}
if ($bodyHtml) {
$email->setIsHtml();
$email->setBody($bodyHtml);
if ($bodyPlain) {
$email->setBodyPlain($bodyPlain);
}
} else {
$email->setIsHtml(false);
$email->setBody($bodyPlain);
$email->setBodyPlain($bodyPlain);
}
if (!$email->getBody() && $email->hasBodyPlain()) {
$email->setBody($email->getBodyPlain());
}
$attachmentPartList = $this->getMessage($message)->getAllAttachmentParts();
$inlineAttachmentMap = [];
foreach ($attachmentPartList as $i => $attachmentPart) {
if (!$attachmentPart instanceof MimePart) {
continue;
}
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getNew();
$filename = $this->extractFileName($attachmentPart, $i);
$contentType = $this->detectAttachmentContentType($attachmentPart, $filename);
$disposition = $attachmentPart->getHeaderValue(HeaderConsts::CONTENT_DISPOSITION);
if ($contentType) {
$contentType = strtolower($contentType);
}
$attachment->setName($filename);
$attachment->setType($contentType);
$content = '';
$binaryContentStream = $attachmentPart->getBinaryContentStream();
if ($binaryContentStream) {
$content = $binaryContentStream->getContents();
}
$contentId = $attachmentPart->getHeaderValue('Content-ID');
if ($contentId) {
$contentId = trim($contentId, '<>');
}
if ($disposition === self::DISPOSITION_INLINE) {
$attachment->setRole(Attachment::ROLE_INLINE_ATTACHMENT);
$attachment->setTargetField(self::FIELD_BODY);
} else {
$attachment->setRole(Attachment::ROLE_ATTACHMENT);
$attachment->setTargetField(self::FIELD_ATTACHMENTS);
}
$attachment->setContents($content);
$this->entityManager->saveEntity($attachment);
if ($attachment->getRole() === Attachment::ROLE_ATTACHMENT) {
$email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId());
if ($contentId) {
$inlineAttachmentMap[$contentId] = $attachment;
}
continue;
}
// Inline disposition.
if ($contentId) {
$inlineAttachmentMap[$contentId] = $attachment;
$inlineAttachmentList[] = $attachment;
continue;
}
// No ID found, fallback to attachment.
$attachment
->setRole(Attachment::ROLE_ATTACHMENT)
->setTargetField(self::FIELD_ATTACHMENTS);
$this->entityManager->saveEntity($attachment);
$email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId());
}
$body = $email->getBody();
if ($body) {
foreach ($inlineAttachmentMap as $cid => $attachment) {
if (str_contains($body, 'cid:' . $cid)) {
$body = str_replace(
'cid:' . $cid,
'?entryPoint=attachment&amp;id=' . $attachment->getId(),
$body
);
continue;
}
// Fallback to attachment.
if ($attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT) {
$attachment
->setRole(Attachment::ROLE_ATTACHMENT)
->setTargetField(self::FIELD_ATTACHMENTS);
$this->entityManager->saveEntity($attachment);
$email->addLinkMultipleId(self::FIELD_ATTACHMENTS, $attachment->getId());
}
}
$email->setBody($body);
}
/** @var ?MessagePart $textCalendarPart */
$textCalendarPart =
$this->getMessage($message)->getAllPartsByMimeType('text/calendar')[0] ??
$this->getMessage($message)->getAllPartsByMimeType('application/ics')[0] ??
null;
if ($textCalendarPart && $textCalendarPart->hasContent()) {
$email->set('icsContents', $textCalendarPart->getContent());
}
return $inlineAttachmentList;
}
private function detectAttachmentContentType(MimePart $part, ?string $filename): ?string
{
$contentType = $part->getHeaderValue(HeaderConsts::CONTENT_TYPE);
if ($contentType && strtolower($contentType) !== self::TYPE_OCTET_STREAM) {
return $contentType;
}
if (!$filename) {
return null;
}
$ext = $this->getAttachmentFilenameExtension($filename);
if (!$ext) {
return null;
}
return $this->extMimeTypeMap[$ext] ?? null;
}
private function getAttachmentFilenameExtension(string $filename): ?string
{
if (!$filename) {
return null;
}
$ext = explode('.', $filename)[1] ?? null;
if (!$ext) {
return null;
}
return strtolower($ext);
}
private function extractFileName(MimePart $attachmentPart, int $i): string
{
$filename = $attachmentPart->getHeaderParameter(HeaderConsts::CONTENT_DISPOSITION, 'filename');
if ($filename === null) {
$filename = $attachmentPart->getHeaderParameter(HeaderConsts::CONTENT_TYPE, 'name');
}
if ($filename === null && $attachmentPart->getContentType() === self::TYPE_MESSAGE_RFC822) {
$filename = 'message-' . ($i + 1) . '.eml';
}
if ($filename === null) {
$filename = 'unnamed';
}
return $filename;
}
}

View File

@@ -0,0 +1,648 @@
<?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;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Sender\MessageContainer;
use Espo\Core\Mail\Sender\TransportPreparatorFactory;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\ORM\EntityCollection;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Core\Mail\Account\SendingAccountProvider;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
use Laminas\Mail\Headers;
use Laminas\Mail\Message as LaminasMessage;
use RuntimeException;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email as Message;
use Symfony\Component\Mime\Part\DataPart;
use Exception;
use LogicException;
use InvalidArgumentException;
/**
* Sends emails. Builds parameters for sending. Should not be used directly.
*/
class Sender
{
private ?TransportInterface $transport = null;
private bool $isGlobal = false;
/** @var array<string, mixed> */
private array $params = [];
/** @var array<string, mixed> */
private array $overrideParams = [];
private ?string $envelopeFromAddress = null;
private ?LaminasMessage $laminasMessage = null;
/** @var ?iterable<Attachment> */
private $attachmentList = null;
/** @var array{string, string}[] */
private array $headers = [];
private ?MessageContainer $messageContainer = null;
private const ATTACHMENT_ATTR_CONTENTS = 'contents';
public function __construct(
private Config $config,
private EntityManager $entityManager,
private Log $log,
private SendingAccountProvider $accountProvider,
private FileStorageManager $fileStorageManager,
private ConfigDataProvider $configDataProvider,
private TransportPreparatorFactory $transportPreparatorFactory,
) {
$this->useGlobal();
}
private function resetParams(): void
{
$this->params = [];
$this->envelopeFromAddress = null;
$this->laminasMessage = null;
$this->attachmentList = null;
$this->overrideParams = [];
$this->headers = [];
$this->messageContainer = null;
}
/**
* With parameters.
*
* @param SenderParams|array<string, mixed> $params
*/
public function withParams($params): self
{
if ($params instanceof SenderParams) {
$params = $params->toArray();
} else if (!is_array($params)) {
throw new InvalidArgumentException();
}
$paramList = [
'fromAddress',
'fromName',
'replyToAddress',
'replyToName',
];
foreach (array_keys($params) as $key) {
if (!in_array($key, $paramList)) {
unset($params[$key]);
}
}
$this->overrideParams = array_merge($this->overrideParams, $params);
return $this;
}
/**
* With specific SMTP parameters.
*
* @param SmtpParams|array<string, mixed> $params
*/
public function withSmtpParams($params): self
{
if ($params instanceof SmtpParams) {
$params = $params->toArray();
} else if (!is_array($params)) {
throw new InvalidArgumentException();
}
/** @noinspection PhpDeprecationInspection */
return $this->useSmtp($params);
}
/**
* With specific attachments.
*
* @param iterable<Attachment> $attachmentList
*/
public function withAttachments(iterable $attachmentList): self
{
$this->attachmentList = $attachmentList;
return $this;
}
/**
* With an envelope from address.
*
* @since 9.1.0
*/
public function withEnvelopeFromAddress(string $fromAddress): void
{
$this->envelopeFromAddress = $fromAddress;
}
/**
* With envelope options.
*
* @param array{from: string} $options
* @deprecated As of v9.1.
* @todo Remove in v10.0. Use `withEnvelopeFromAddress`.
*/
public function withEnvelopeOptions(array $options): self
{
/** @noinspection PhpDeprecationInspection */
return $this->setEnvelopeOptions($options);
}
/**
* @since 9.2.0
* @internal
*/
public function withMessageContainer(MessageContainer $messageContainer): self
{
$this->messageContainer = $messageContainer;
return $this;
}
/**
* Set a message instance.
*
* @deprecated As of v9.1. Use `withAddedHeader`.
* @todo Remove in v10.0.
*/
public function withMessage(LaminasMessage $message): self
{
$this->laminasMessage = $message;
return $this;
}
/**
* Add a header.
*
* @param string $name A header name.
* @param string $value A header value.
* @since 9.1.0
*/
public function withAddedHeader(string $name, string $value): self
{
$this->headers[] = [$name, $value];
return $this;
}
/**
* @deprecated As of v6.0. Use withParams.
* @param array<string, mixed> $params
* @todo Remove in v10.0.
*/
public function setParams(array $params = []): self
{
$this->params = array_merge($this->params, $params);
return $this;
}
/**
* @deprecated As of 6.0. Use withSmtpParams.
* @param array<string, mixed> $params
* @todo Make private in v10.0.
*/
public function useSmtp(array $params = []): self
{
$this->isGlobal = false;
$this->applySmtp($params);
return $this;
}
private function useGlobal(): void
{
$this->params = [];
$this->isGlobal = true;
}
/**
* @param array<string, mixed> $params
*/
private function applySmtp(array $params = []): void
{
$this->params = $params;
$smtpParams = SmtpParams::fromArray($params);
$preparator = $this->transportPreparatorFactory->create($smtpParams);
$this->transport = $preparator->prepare($smtpParams);
}
/**
* @throws NoSmtp
*/
private function applyGlobal(): void
{
$systemAccount = $this->accountProvider->getSystem();
if (!$systemAccount) {
throw new NoSmtp("No system SMTP settings.");
}
$smtpParams = $systemAccount->getSmtpParams();
if (!$smtpParams) {
throw new NoSmtp("No system SMTP settings.");
}
$this->applySmtp($smtpParams->toArray());
}
/**
* Send an email.
*
* @throws SendingError
*/
public function send(Email $email): void
{
if ($this->isGlobal) {
$this->applyGlobal();
}
$message = new Message();
$params = array_merge($this->params, $this->overrideParams);
$this->applyHeaders($email, $message);
$this->applyFrom($email, $message, $params);
$this->addRecipientAddresses($email, $message);
$this->applyReplyTo($email, $message, $params);
$this->applySubject($email, $message);
$this->applyBody($email, $message);
$this->applyMessageId($email, $message);
$this->applyLaminasMessageHeaders($message);
if (!$this->transport) {
throw new LogicException();
}
$envelope = $this->prepareEnvelope($message);
if ($this->messageContainer) {
$this->messageContainer->message = new Sender\Message($message);
}
try {
$this->transport->send($message, $envelope);
} catch (Exception|TransportExceptionInterface $e) {
$this->resetParams();
$this->useGlobal();
$this->handleException($e);
}
$email
->setStatus(Email::STATUS_SENT)
->setDateSent(DateTime::createNow())
->setSendAt(null);
$this->resetParams();
$this->useGlobal();
}
/**
* @return DataPart[]
*/
private function getAttachmentParts(Email $email): array
{
/** @var EntityCollection<Attachment> $collection */
$collection = $this->entityManager
->getCollectionFactory()
->create(Attachment::ENTITY_TYPE);
if (!$email->isNew()) {
foreach ($email->getAttachments() as $attachment) {
$collection[] = $attachment;
}
}
if ($this->attachmentList !== null) {
foreach ($this->attachmentList as $attachment) {
$collection[] = $attachment;
}
}
$list = [];
foreach ($collection as $attachment) {
$contents = $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ?
$attachment->get(self::ATTACHMENT_ATTR_CONTENTS) :
$this->fileStorageManager->getContents($attachment);
$part = new DataPart(
body: $contents,
filename: $attachment->getName() ?? '',
contentType: $attachment->getType(),
);
$list[] = $part;
}
return $list;
}
/**
* @return DataPart[]
*/
private function getInlineAttachmentParts(Email $email): array
{
$list = [];
foreach ($email->getInlineAttachmentList() as $attachment) {
$contents = $attachment->has(self::ATTACHMENT_ATTR_CONTENTS) ?
$attachment->get(self::ATTACHMENT_ATTR_CONTENTS) :
$this->fileStorageManager->getContents($attachment);
$part = (new DataPart($contents, null, $attachment->getType()))
->asInline()
->setContentId($attachment->getId() . '@espo');
$list[] = $part;
}
return $list;
}
/**
* @throws SendingError
*/
private function handleException(Exception|TransportExceptionInterface $e): never
{
if ($e instanceof TransportExceptionInterface) {
$message = "unknownError";
if (
stripos($e->getMessage(), 'password') !== false ||
stripos($e->getMessage(), 'credentials') !== false ||
stripos($e->getMessage(), '5.7.8') !== false ||
stripos($e->getMessage(), '5.7.3') !== false
) {
$message = 'invalidCredentials';
}
$this->log->error("Email sending error: " . $e->getMessage(), ['exception' => $e]);
throw new SendingError($message);
}
throw new SendingError($e->getMessage());
}
/**
* @deprecated Since v9.1.0. Use EmailSender::generateMessageId.
* @noinspection PhpUnused
* @todo Remove in v10.0.
*/
static public function generateMessageId(Email $email): string
{
return EmailSender::generateMessageId($email);
}
/**
* @deprecated As of v6.0.
*
* @param array{from: string} $options
* @todo Make private in v10.0. Use `withEnvelopeFromAddress`.
*/
public function setEnvelopeOptions(array $options): self
{
$this->envelopeFromAddress = $options['from'];
return $this;
}
private function addRecipientAddresses(Email $email, Message $message): void
{
$value = $email->get('to');
if ($value) {
foreach (explode(';', $value) as $address) {
$message->addTo(trim($address));
}
}
$value = $email->get('cc');
if ($value) {
foreach (explode(';', $value) as $address) {
$message->addCC(trim($address));
}
}
$value = $email->get('bcc');
if ($value) {
foreach (explode(';', $value) as $address) {
$message->addBCC(trim($address));
}
}
$value = $email->get('replyTo');
if ($value) {
foreach (explode(';', $value) as $address) {
$message->addReplyTo(trim($address));
}
}
}
/**
* @param array<string, mixed> $params
* @throws NoSmtp
*/
private function applyFrom(Email $email, Message $message, array $params): void
{
$fromName = $params['fromName'] ?? $this->config->get('outboundEmailFromName');
$fromAddress = $email->get('from');
if ($fromAddress) {
$fromAddress = trim($fromAddress);
} else {
if (
empty($params['fromAddress']) &&
!$this->configDataProvider->getSystemOutboundAddress()
) {
throw new NoSmtp('outboundEmailFromAddress is not specified in config.');
}
$fromAddress = $params['fromAddress'] ?? $this->configDataProvider->getSystemOutboundAddress();
$email->setFromAddress($fromAddress);
}
$message->addFrom(new Address($fromAddress, $fromName ?? ''));
$fromString = '<' . $fromAddress . '>';
if ($fromName) {
$fromString = $fromName . ' ' . $fromString;
}
$email->set('fromString', $fromString);
$message->sender($fromAddress);
}
/**
* @param array<string, mixed> $params
*/
private function applyReplyTo(Email $email, Message $message, array $params): void
{
$address = $params['replyToAddress'] ?? null;
$name = $params['replyToName'] ?? null;
if (!$address) {
return;
}
$message->replyTo(new Address($address, $name ?? ''));
$email->setReplyToAddressList([$address]);
}
private function applyMessageId(Email $email, Message $message): void
{
$messageId = $email->getMessageId();
if (
!$messageId ||
strlen($messageId) < 4 ||
str_starts_with($messageId, 'dummy:')
) {
$messageId = EmailSender::generateMessageId($email);
$email->setMessageId('<' . $messageId . '>');
if ($email->hasId()) {
$this->entityManager->saveEntity($email, [SaveOption::SILENT => true]);
}
} else {
$messageId = substr($messageId, 1, strlen($messageId) - 2);
}
$message->getHeaders()->addIdHeader('Message-ID', $messageId);
}
private function applyBody(Email $email, Message $message): void
{
$message->text($email->getBodyPlainForSending());
if ($email->isHtml()) {
$message->html($email->getBodyForSending());
}
foreach ($this->getAttachmentParts($email) as $part) {
$message->addPart($part);
}
foreach ($this->getInlineAttachmentParts($email) as $part) {
$message->addPart($part);
}
}
private function applySubject(Email $email, Message $message): void
{
$message->subject($email->getSubject() ?? '');
}
private function applyHeaders(Email $email, Message $message): void
{
foreach ($this->headers as $item) {
$message->getHeaders()->addTextHeader($item[0], $item[1]);
}
if ($this->laminasMessage) {
// For bc.
foreach ($this->laminasMessage->getHeaders() as $it) {
if ($it->getFieldName() === 'Date') {
continue;
}
$message->getHeaders()->addTextHeader($it->getFieldName(), $it->getFieldValue());
}
}
if ($email->isAutoReply() && !$message->getHeaders()->has('Auto-Submitted')) {
$message->getHeaders()->addTextHeader('Auto-Submitted', 'auto-replied');
}
}
private function prepareEnvelope(Message $message): ?Envelope
{
if (!$this->envelopeFromAddress) {
return null;
}
$recipients = [
...$message->getTo(),
...$message->getCc(),
...$message->getBcc(),
];
return new Envelope(new Address($this->envelopeFromAddress), $recipients);
}
private function applyLaminasMessageHeaders(Message $message): void
{
if (!$this->laminasMessage) {
return;
}
$parts = preg_split("/\R\R/", $message->toString(), 2);
if (!is_array($parts) || count($parts) < 2) {
throw new RuntimeException("Could not split email.");
}
/** @noinspection PhpMultipleClassDeclarationsInspection */
$this->laminasMessage
->setHeaders(
Headers::fromString($parts[0])
)
->setBody($parts[1]);
}
}

View File

@@ -0,0 +1,118 @@
<?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\Sender;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Utils\Config;
use RuntimeException;
use Symfony\Component\Mailer\Transport\Dsn;
use Symfony\Component\Mailer\Transport\Smtp\Auth\CramMd5Authenticator;
use Symfony\Component\Mailer\Transport\Smtp\Auth\LoginAuthenticator;
use Symfony\Component\Mailer\Transport\Smtp\Auth\PlainAuthenticator;
use Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransportFactory;
use Symfony\Component\Mailer\Transport\TransportInterface;
class DefaultTransportPreparator implements TransportPreparator
{
public function __construct(
private Config $config,
) {}
public function prepare(SmtpParams $smtpParams): TransportInterface
{
$localHostName = $this->config->get('smtpLocalHostName', gethostname());
// 'SSL' is treated as implicit SSL/TLS. 'TLS' is treated as STARTTLS.
// STARTTLS is the most common method.
$scheme = $smtpParams->getSecurity() === 'SSL' ? 'smtps' : 'smtp';
if ($smtpParams->getSecurity() === 'TLS' && !defined('OPENSSL_VERSION_NUMBER')) {
throw new RuntimeException("OpenSSL is not available.");
}
// @todo Use `auto_tls=false` if no security when Symfony v7.1 is installed.
// @todo If starttls, it should be enforced.
$transport = (new EsmtpTransportFactory())
->create(
new Dsn(
scheme: $scheme,
host: $smtpParams->getServer(),
port: $smtpParams->getPort(),
)
);
if (!$transport instanceof EsmtpTransport) {
throw new RuntimeException();
}
$transport->setLocalDomain($localHostName);
$authMechanism = null;
// @todo For xoauth, set authMechanism, username, password in handlers.
$connectionOptions = $smtpParams->getConnectionOptions() ?? [];
$authString = $connectionOptions['authString'] ?? null;
if ($authString) {
$decodedAuthString = base64_decode($authString);
/** @noinspection RegExpRedundantEscape */
if (preg_match("/user=(.*?)\\\1auth=Bearer (.*?)\\\1\\\1/", $decodedAuthString, $matches) !== false) {
$username = $matches[1];
$token = $matches[2];
$transport->setUsername($username);
$transport->setPassword($token);
}
$authMechanism = SmtpParams::AUTH_MECHANISM_XOAUTH;
} else if ($smtpParams->useAuth()) {
$authMechanism = $smtpParams->getAuthMechanism() ?: SmtpParams::AUTH_MECHANISM_LOGIN;
$transport->setUsername($smtpParams->getUsername() ?? '');
$transport->setPassword($smtpParams->getPassword() ?? '');
}
if ($authMechanism === SmtpParams::AUTH_MECHANISM_LOGIN) {
$transport->setAuthenticators([new LoginAuthenticator()]);
} else if ($authMechanism === SmtpParams::AUTH_MECHANISM_CRAMMD5) {
$transport->setAuthenticators([new CramMd5Authenticator()]);
} else if ($authMechanism === SmtpParams::AUTH_MECHANISM_PLAIN) {
$transport->setAuthenticators([new PlainAuthenticator()]);
} else if ($authMechanism === SmtpParams::AUTH_MECHANISM_XOAUTH) {
$transport->setAuthenticators([new XOAuth2Authenticator()]);
}
return $transport;
}
}

View File

@@ -0,0 +1,48 @@
<?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\Sender;
use Symfony\Component\Mime\Email;
/**
* @since 9.2.0
* @internal
*/
class Message
{
public function __construct(
private Email $email,
) {}
public function toString(): string
{
return $this->email->toString();
}
}

View File

@@ -0,0 +1,41 @@
<?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\Sender;
/**
* @since 9.2.0
* @internal
*/
class MessageContainer
{
public function __construct(
public ?Message $message = null,
) {}
}

View File

@@ -0,0 +1,41 @@
<?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\Sender;
use Espo\Core\Mail\SmtpParams;
use Symfony\Component\Mailer\Transport\TransportInterface;
/**
* @since 9.1.0
*/
interface TransportPreparator
{
public function prepare(SmtpParams $smtpParams): TransportInterface;
}

View File

@@ -0,0 +1,47 @@
<?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\Sender;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\SmtpParams;
class TransportPreparatorFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
) {}
public function create(SmtpParams $smtpParams): TransportPreparator
{
$className = $smtpParams->getTransportPreparatorClassName() ?? DefaultTransportPreparator::class;
return $this->injectableFactory->create($className);
}
}

View File

@@ -0,0 +1,140 @@
<?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;
/**
* Sender parameters.
*
* Immutable.
*/
class SenderParams
{
private ?string $fromAddress = null;
private ?string $fromName = null;
private ?string $replyToAddress = null;
private ?string $replyToName = null;
/** @var string[] */
private $paramList = [
'fromAddress',
'fromName',
'replyToAddress',
'replyToName',
];
public static function create(): self
{
return new self();
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$params = [];
foreach ($this->paramList as $name) {
if ($this->$name !== null) {
$params[$name] = $this->$name;
}
}
return $params;
}
/**
* @param array<string, mixed> $params
*/
public static function fromArray(array $params): self
{
$obj = new self();
foreach ($obj->paramList as $name) {
if (array_key_exists($name, $params)) {
$obj->$name = $params[$name];
}
}
return $obj;
}
public function getFromAddress(): ?string
{
return $this->fromAddress;
}
public function getFromName(): ?string
{
return $this->fromName;
}
public function getReplyToAddress(): ?string
{
return $this->replyToAddress;
}
public function getReplyToName(): ?string
{
return $this->replyToName;
}
public function withFromAddress(?string $fromAddress): self
{
$obj = clone $this;
$obj->fromAddress = $fromAddress;
return $obj;
}
public function withFromName(?string $fromName): self
{
$obj = clone $this;
$obj->fromName = $fromName;
return $obj;
}
public function withReplyToAddress(?string $replyToAddress): self
{
$obj = clone $this;
$obj->replyToAddress = $replyToAddress;
return $obj;
}
public function withReplyToName(?string $replyToName): self
{
$obj = clone $this;
$obj->replyToName = $replyToName;
return $obj;
}
}

View File

@@ -0,0 +1,37 @@
<?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\Smtp;
use Espo\Core\Mail\SmtpParams;
interface Handler
{
public function handle(SmtpParams $params, ?string $id): SmtpParams;
}

View File

@@ -0,0 +1,61 @@
<?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\Smtp;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\SmtpParams;
class HandlerProcessor
{
public function __construct(private InjectableFactory $injectableFactory)
{}
/**
* @param class-string<object> $className
*/
public function handle(string $className, SmtpParams $params, ?string $id): SmtpParams
{
$handler = $this->injectableFactory->create($className);
if ($handler instanceof Handler) {
return $handler->handle($params, $id);
}
if (method_exists($handler, 'applyParams')) {
$raw = $params->toArray();
$handler->applyParams($id, $raw);
return SmtpParams::fromArray($raw);
}
return $params;
}
}

View File

@@ -0,0 +1,276 @@
<?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;
use Espo\Core\Mail\Sender\TransportPreparator;
use RuntimeException;
use SensitiveParameter;
/**
* SMTP parameters.
*
* Immutable.
*/
class SmtpParams
{
private ?string $fromAddress = null;
private ?string $fromName = null;
/** @var ?array<string, mixed> */
private ?array $connectionOptions = null;
private bool $auth = false;
private ?string $authMechanism = null;
private ?string $username = null;
private ?string $password = null;
private ?string $security = null;
/** @var ?class-string<TransportPreparator> */
private ?string $transportPreparatorClassName = null;
public const AUTH_MECHANISM_LOGIN = 'login';
public const AUTH_MECHANISM_CRAMMD5 = 'crammd5';
public const AUTH_MECHANISM_PLAIN = 'plain';
public const AUTH_MECHANISM_XOAUTH = 'xoauth';
/** @var string[] */
private array $paramList = [
'server',
'port',
'fromAddress',
'fromName',
'connectionOptions',
'auth',
'authMechanism',
'username',
'password',
'security',
'transportPreparatorClassName',
];
public function __construct(
private string $server,
private int $port
) {}
public static function create(string $server, int $port): self
{
return new self($server, $port);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$params = [];
foreach ($this->paramList as $name) {
if ($this->$name !== null) {
$params[$name] = $this->$name;
}
}
return $params;
}
/**
* @param array<string, mixed> $params
*/
public static function fromArray(array $params): self
{
$server = $params['server'] ?? null;
$port = $params['port'] ?? null;
$auth = $params['auth'] ?? false;
if ($server === null) {
throw new RuntimeException("Empty server.");
}
if ($port === null) {
throw new RuntimeException("Empty port.");
}
$obj = new self($server, $port);
$obj->auth = $auth;
foreach ($obj->paramList as $name) {
if ($obj->$name !== null) {
continue;
}
if (array_key_exists($name, $params)) {
$obj->$name = $params[$name];
}
}
return $obj;
}
public function getServer(): string
{
return $this->server;
}
public function getPort(): int
{
return $this->port;
}
public function getFromAddress(): ?string
{
return $this->fromAddress;
}
public function getFromName(): ?string
{
return $this->fromName;
}
/**
* @return ?array<string, mixed>
*/
public function getConnectionOptions(): ?array
{
return $this->connectionOptions;
}
public function useAuth(): bool
{
return $this->auth;
}
public function getAuthMechanism(): ?string
{
return $this->authMechanism;
}
public function getUsername(): ?string
{
return $this->username;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getSecurity(): ?string
{
return $this->security;
}
public function withFromAddress(?string $fromAddress): self
{
$obj = clone $this;
$obj->fromAddress = $fromAddress;
return $obj;
}
public function withFromName(?string $fromName): self
{
$obj = clone $this;
$obj->fromName = $fromName;
return $obj;
}
/**
* @param ?array<string, mixed> $connectionOptions
*/
public function withConnectionOptions(?array $connectionOptions): self
{
$obj = clone $this;
$obj->connectionOptions = $connectionOptions;
return $obj;
}
public function withAuth(bool $auth = true): self
{
$obj = clone $this;
$obj->auth = $auth;
return $obj;
}
public function withAuthMechanism(?string $authMechanism): self
{
$obj = clone $this;
$obj->authMechanism = $authMechanism;
return $obj;
}
public function withUsername(?string $username): self
{
$obj = clone $this;
$obj->username = $username;
return $obj;
}
public function withPassword(#[SensitiveParameter] ?string $password): self
{
$obj = clone $this;
$obj->password = $password;
return $obj;
}
public function withSecurity(?string $security): self
{
$obj = clone $this;
$obj->security = $security;
return $obj;
}
/**
* @param ?class-string<TransportPreparator> $transportPreparatorClassName
* @since 9.1.0.
* @noinspection PhpUnused
*/
public function withTransportPreparatorClassName(?string $transportPreparatorClassName): self
{
$obj = clone $this;
$obj->transportPreparatorClassName = $transportPreparatorClassName;
return $obj;
}
/**
* @return ?class-string<TransportPreparator>
* @since 9.1.0.
*/
public function getTransportPreparatorClassName(): ?string
{
return $this->transportPreparatorClassName;
}
}