Initial commit
This commit is contained in:
384
application/Espo/Core/Mail/Account/GroupAccount/Account.php
Normal file
384
application/Espo/Core/Mail/Account/GroupAccount/Account.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
160
application/Espo/Core/Mail/Account/GroupAccount/Service.php
Normal file
160
application/Espo/Core/Mail/Account/GroupAccount/Service.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user