some big beautiful update
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Controllers;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Mail\Account\GroupAccount\Service;
|
||||
use Espo\Core\Mail\Account\Storage\Params as StorageParams;
|
||||
|
||||
use Espo\Core\Controllers\Record;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Mail\Exceptions\ImapError;
|
||||
|
||||
class InboundEmail extends Record
|
||||
{
|
||||
protected function checkAccess(): bool
|
||||
{
|
||||
return $this->getUser()->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
* @throws Error
|
||||
* @throws ImapError
|
||||
*/
|
||||
public function postActionGetFolders(Request $request): array
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$params = StorageParams::createBuilder()
|
||||
->setHost($data->host ?? null)
|
||||
->setPort($data->port ?? null)
|
||||
->setSecurity($data->security ?? null)
|
||||
->setUsername($data->username ?? null)
|
||||
->setPassword($data->password ?? null)
|
||||
->setId($data->id ?? null)
|
||||
->build();
|
||||
|
||||
return $this->getInboundEmailService()->getFolderList($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
public function postActionTestConnection(Request $request): bool
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$params = StorageParams::createBuilder()
|
||||
->setHost($data->host ?? null)
|
||||
->setPort($data->port ?? null)
|
||||
->setSecurity($data->security ?? null)
|
||||
->setUsername($data->username ?? null)
|
||||
->setPassword($data->password ?? null)
|
||||
->setId($data->id ?? null)
|
||||
->build();
|
||||
|
||||
$this->getInboundEmailService()->testConnection($params);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getInboundEmailService(): Service
|
||||
{
|
||||
/** @var Service */
|
||||
return $this->injectableFactory->create(Service::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Controllers;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Tools\App\SettingsService as Service;
|
||||
use Espo\Entities\User;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class Settings
|
||||
{
|
||||
public function __construct(
|
||||
private Service $service,
|
||||
private User $user,
|
||||
) {}
|
||||
|
||||
public function getActionRead(): stdClass
|
||||
{
|
||||
return $this->getConfigData();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function putActionUpdate(Request $request): stdClass
|
||||
{
|
||||
if (!$this->user->isAdmin()) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$this->service->setConfigData($data);
|
||||
|
||||
return $this->getConfigData();
|
||||
}
|
||||
|
||||
private function getConfigData(): stdClass
|
||||
{
|
||||
$data = $this->service->getConfigData();
|
||||
$metadataData = $this->service->getMetadataConfigData();
|
||||
|
||||
foreach (get_object_vars($metadataData) as $key => $value) {
|
||||
$data->$key = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Mail\Account\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;
|
||||
|
||||
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 ImapError ?
|
||||
$e->getMessage() : '';
|
||||
|
||||
throw new ErrorSilent($message, previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
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,195 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Mail\Account\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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Mail\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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Utils\Security;
|
||||
|
||||
use const DNS_A;
|
||||
use const FILTER_FLAG_NO_PRIV_RANGE;
|
||||
use const FILTER_FLAG_NO_RES_RANGE;
|
||||
use const FILTER_VALIDATE_IP;
|
||||
use const FILTER_VALIDATE_URL;
|
||||
use const PHP_URL_HOST;
|
||||
|
||||
class UrlCheck
|
||||
{
|
||||
public function isUrl(string $url): bool
|
||||
{
|
||||
return filter_var($url, FILTER_VALIDATE_URL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a URL does not follow to an internal host.
|
||||
*/
|
||||
public function isNotInternalUrl(string $url): bool
|
||||
{
|
||||
if (!$this->isUrl($url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (!is_string($host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$records = dns_get_record($host, DNS_A);
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return $this->ipAddressIsNotInternal($host);
|
||||
}
|
||||
|
||||
if (!$records) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
/** @var ?string $idAddress */
|
||||
$idAddress = $record['ip'] ?? null;
|
||||
|
||||
if (!$idAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->ipAddressIsNotInternal($idAddress)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function ipAddressIsNotInternal(string $ipAddress): bool
|
||||
{
|
||||
return (bool) filter_var(
|
||||
$ipAddress,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Webhook;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Json;
|
||||
use Espo\Entities\Webhook;
|
||||
|
||||
/**
|
||||
* Sends a portion.
|
||||
*/
|
||||
class Sender
|
||||
{
|
||||
private const CONNECT_TIMEOUT = 5;
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
public function __construct(private Config $config)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $dataList
|
||||
* @throws Error
|
||||
*/
|
||||
public function send(Webhook $webhook, array $dataList): int
|
||||
{
|
||||
$payload = Json::encode($dataList);
|
||||
|
||||
$signature = null;
|
||||
$legacySignature = null;
|
||||
|
||||
$secretKey = $webhook->getSecretKey();
|
||||
|
||||
if ($secretKey) {
|
||||
$signature = $this->buildSignature($webhook, $payload, $secretKey);
|
||||
$legacySignature = $this->buildSignatureLegacy($webhook, $payload, $secretKey);
|
||||
}
|
||||
|
||||
$connectTimeout = $this->config->get('webhookConnectTimeout', self::CONNECT_TIMEOUT);
|
||||
$timeout = $this->config->get('webhookTimeout', self::TIMEOUT);
|
||||
|
||||
$headerList = [];
|
||||
|
||||
$headerList[] = 'Content-Type: application/json';
|
||||
$headerList[] = 'Content-Length: ' . strlen($payload);
|
||||
|
||||
if ($signature) {
|
||||
$headerList[] = 'Signature: ' . $signature;
|
||||
}
|
||||
|
||||
if ($legacySignature) {
|
||||
$headerList[] = 'X-Signature: ' . $legacySignature;
|
||||
}
|
||||
|
||||
$url = $webhook->getUrl();
|
||||
|
||||
if (!$url) {
|
||||
throw new Error("Webhook does not have URL.");
|
||||
}
|
||||
|
||||
$handler = curl_init($url);
|
||||
|
||||
if ($handler === false) {
|
||||
throw new Error("Could not init CURL for URL {$url}.");
|
||||
}
|
||||
|
||||
curl_setopt($handler, \CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($handler, \CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($handler, \CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($handler, \CURLOPT_HEADER, true);
|
||||
curl_setopt($handler, \CURLOPT_CUSTOMREQUEST, 'POST');
|
||||
curl_setopt($handler, \CURLOPT_CONNECTTIMEOUT, $connectTimeout);
|
||||
curl_setopt($handler, \CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($handler, \CURLOPT_PROTOCOLS, \CURLPROTO_HTTPS | \CURLPROTO_HTTP);
|
||||
curl_setopt($handler, \CURLOPT_REDIR_PROTOCOLS, \CURLPROTO_HTTPS);
|
||||
curl_setopt($handler, \CURLOPT_HTTPHEADER, $headerList);
|
||||
curl_setopt($handler, \CURLOPT_POSTFIELDS, $payload);
|
||||
|
||||
curl_exec($handler);
|
||||
|
||||
$code = curl_getinfo($handler, \CURLINFO_HTTP_CODE);
|
||||
|
||||
if (!is_numeric($code)) {
|
||||
$code = 0;
|
||||
}
|
||||
|
||||
if (!is_int($code)) {
|
||||
$code = intval($code);
|
||||
}
|
||||
|
||||
$errorNumber = curl_errno($handler);
|
||||
|
||||
if (
|
||||
$errorNumber &&
|
||||
in_array($errorNumber, [\CURLE_OPERATION_TIMEDOUT, \CURLE_OPERATION_TIMEOUTED])
|
||||
) {
|
||||
$code = 408;
|
||||
}
|
||||
|
||||
curl_close($handler);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function buildSignature(Webhook $webhook, string $payload, string $secretKey): string
|
||||
{
|
||||
$webhookId = $webhook->getId();
|
||||
$hash = hash_hmac('sha256', $payload, $secretKey);
|
||||
|
||||
return base64_encode("$webhookId:$hash");
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo Remove in v11.0.
|
||||
*/
|
||||
private function buildSignatureLegacy(Webhook $webhook, string $payload, string $secretKey): string
|
||||
{
|
||||
return base64_encode($webhook->getId() . ':' . hash_hmac('sha256', $payload, $secretKey, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "personName",
|
||||
"isPersonalData": true
|
||||
},
|
||||
"salutationName": {
|
||||
"type": "enum",
|
||||
"options": ["", "Mr.", "Ms.", "Mrs.", "Dr."]
|
||||
},
|
||||
"firstName": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100
|
||||
},
|
||||
"lastName": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"required": true
|
||||
},
|
||||
"accountAnyId": {
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"customizationDisabled": true,
|
||||
"utility": true,
|
||||
"type": "varchar",
|
||||
"where": {
|
||||
"=": {
|
||||
"whereClause": {
|
||||
"id=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"accountId": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"<>": {
|
||||
"whereClause": {
|
||||
"id!=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"accountId": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IN": {
|
||||
"whereClause": {
|
||||
"id=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"accountId": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOT IN": {
|
||||
"whereClause": {
|
||||
"id!=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"accountId": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IS NULL": {
|
||||
"whereClause": {
|
||||
"accountId": null
|
||||
}
|
||||
},
|
||||
"IS NOT NULL": {
|
||||
"whereClause": {
|
||||
"accountId!=": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"view": "crm:views/contact/fields/title",
|
||||
"directUpdateDisabled": true,
|
||||
"notStorable": true,
|
||||
"select": {
|
||||
"select": "accountContactPrimary.role",
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactPrimary",
|
||||
{
|
||||
"contact.id:": "accountContactPrimary.contactId",
|
||||
"contact.accountId:": "accountContactPrimary.accountId",
|
||||
"accountContactPrimary.deleted": false
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"order": [
|
||||
["accountContactPrimary.role", "{direction}"]
|
||||
],
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactPrimary",
|
||||
{
|
||||
"contact.id:": "accountContactPrimary.contactId",
|
||||
"contact.accountId:": "accountContactPrimary.accountId",
|
||||
"accountContactPrimary.deleted": false
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"where": {
|
||||
"LIKE": {
|
||||
"whereClause": {
|
||||
"id=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"role*": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOT LIKE": {
|
||||
"whereClause": {
|
||||
"id!=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"role*": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"=": {
|
||||
"whereClause": {
|
||||
"id=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"role": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"<>": {
|
||||
"whereClause": {
|
||||
"id!=s": {
|
||||
"from": "AccountContact",
|
||||
"select": ["contactId"],
|
||||
"whereClause": {
|
||||
"deleted": false,
|
||||
"role": "{value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IS NULL": {
|
||||
"whereClause": {
|
||||
"NOT": {
|
||||
"EXISTS": {
|
||||
"from": "Contact",
|
||||
"fromAlias": "sq",
|
||||
"select": ["id"],
|
||||
"leftJoins": [
|
||||
[
|
||||
"accounts",
|
||||
"m",
|
||||
{},
|
||||
{"onlyMiddle": true}
|
||||
]
|
||||
],
|
||||
"whereClause": {
|
||||
"AND": [
|
||||
{"m.role!=": null},
|
||||
{"m.role!=": ""},
|
||||
{"sq.id:": "contact.id"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IS NOT NULL": {
|
||||
"whereClause": {
|
||||
"EXISTS": {
|
||||
"from": "Contact",
|
||||
"fromAlias": "sq",
|
||||
"select": ["id"],
|
||||
"leftJoins": [
|
||||
[
|
||||
"accounts",
|
||||
"m",
|
||||
{},
|
||||
{"onlyMiddle": true}
|
||||
]
|
||||
],
|
||||
"whereClause": {
|
||||
"AND": [
|
||||
{"m.role!=": null},
|
||||
{"m.role!=": ""},
|
||||
{"sq.id:": "contact.id"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizationOptionsDisabled": true,
|
||||
"textFilterDisabled": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"emailAddress": {
|
||||
"type": "email",
|
||||
"isPersonalData": true
|
||||
},
|
||||
"phoneNumber": {
|
||||
"type": "phone",
|
||||
"typeList": ["Mobile", "Office", "Home", "Fax", "Other"],
|
||||
"defaultType": "Mobile",
|
||||
"isPersonalData": true
|
||||
},
|
||||
"doNotCall": {
|
||||
"type": "bool"
|
||||
},
|
||||
"address": {
|
||||
"type": "address",
|
||||
"isPersonalData": true
|
||||
},
|
||||
"addressStreet": {
|
||||
"type": "text",
|
||||
"maxLength": 255,
|
||||
"dbType": "varchar"
|
||||
},
|
||||
"addressCity": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"addressState": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"addressCountry": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"addressPostalCode": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"account": {
|
||||
"type": "link",
|
||||
"view": "crm:views/contact/fields/account"
|
||||
},
|
||||
"accounts": {
|
||||
"type": "linkMultiple",
|
||||
"view": "crm:views/contact/fields/accounts",
|
||||
"columns": {
|
||||
"role": "contactRole",
|
||||
"isInactive": "contactIsInactive"
|
||||
},
|
||||
"additionalAttributeList": ["columns"],
|
||||
"orderBy": "name",
|
||||
"detailLayoutIncompatibleFieldList": ["account"]
|
||||
},
|
||||
"accountRole": {
|
||||
"type": "varchar",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"directUpdateDisabled": true,
|
||||
"layoutDetailDisabled": true,
|
||||
"layoutMassUpdateDisabled": true,
|
||||
"layoutFiltersDisabled": true,
|
||||
"layoutAvailabilityList": ["listForAccount"],
|
||||
"exportDisabled": true,
|
||||
"importDisabled": true,
|
||||
"view": "crm:views/contact/fields/account-role",
|
||||
"customizationOptionsDisabled": true,
|
||||
"textFilterDisabled": true
|
||||
},
|
||||
"accountIsInactive": {
|
||||
"type": "bool",
|
||||
"notStorable": true,
|
||||
"mergeDisabled": true,
|
||||
"foreignAccessDisabled": true,
|
||||
"select": {
|
||||
"select": "accountContactPrimary.isInactive",
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactPrimary",
|
||||
{
|
||||
"contact.id:": "accountContactPrimary.contactId",
|
||||
"contact.accountId:": "accountContactPrimary.accountId",
|
||||
"accountContactPrimary.deleted": false
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"order": {
|
||||
"order": [
|
||||
["accountContactPrimary.isInactive", "{direction}"]
|
||||
],
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactPrimary",
|
||||
{
|
||||
"contact.id:": "accountContactPrimary.contactId",
|
||||
"contact.accountId:": "accountContactPrimary.accountId",
|
||||
"accountContactPrimary.deleted": false
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"where": {
|
||||
"= TRUE": {
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactFilterIsInactive",
|
||||
{
|
||||
"accountContactFilterIsInactive.contactId:": "id",
|
||||
"accountContactFilterIsInactive.accountId:": "accountId",
|
||||
"accountContactFilterIsInactive.deleted": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"whereClause": {
|
||||
"accountContactFilterIsInactive.isInactive": true
|
||||
}
|
||||
},
|
||||
"= FALSE": {
|
||||
"leftJoins": [
|
||||
[
|
||||
"AccountContact",
|
||||
"accountContactFilterIsInactive",
|
||||
{
|
||||
"accountContactFilterIsInactive.contactId:": "id",
|
||||
"accountContactFilterIsInactive.accountId:": "accountId",
|
||||
"accountContactFilterIsInactive.deleted": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"whereClause": {
|
||||
"OR": [
|
||||
{
|
||||
"accountContactFilterIsInactive.isInactive!=": true
|
||||
},
|
||||
{
|
||||
"accountContactFilterIsInactive.isInactive=": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layoutListDisabled": true,
|
||||
"layoutDetailDisabled": true,
|
||||
"layoutMassUpdateDisabled": true
|
||||
},
|
||||
"accountType": {
|
||||
"type": "foreign",
|
||||
"link": "account",
|
||||
"field": "type",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/foreign-enum"
|
||||
},
|
||||
"opportunityRole": {
|
||||
"type": "enum",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"options": ["", "Decision Maker", "Evaluator", "Influencer"],
|
||||
"layoutMassUpdateDisabled": true,
|
||||
"layoutListDisabled": true,
|
||||
"layoutDetailDisabled": true,
|
||||
"customizationRequiredDisabled": true,
|
||||
"customizationIsSortedDisabled": true,
|
||||
"customizationAuditedDisabled": true,
|
||||
"customizationReadOnlyDisabled": true,
|
||||
"converterClassName": "Espo\\Classes\\FieldConverters\\RelationshipRole",
|
||||
"converterData": {
|
||||
"column": "role",
|
||||
"link": "opportunities",
|
||||
"relationName": "contactOpportunity",
|
||||
"nearKey": "contactId"
|
||||
},
|
||||
"directUpdateDisabled": true,
|
||||
"view": "crm:views/contact/fields/opportunity-role"
|
||||
},
|
||||
"acceptanceStatus": {
|
||||
"type": "varchar",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"exportDisabled": true,
|
||||
"utility": true,
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"acceptanceStatusMeetings": {
|
||||
"type": "enum",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"directAccessDisabled": true,
|
||||
"filtersEnabled": true,
|
||||
"directUpdateDisabled": true,
|
||||
"layoutAvailabilityList": ["filters"],
|
||||
"importDisabled": true,
|
||||
"exportDisabled": true,
|
||||
"view": "crm:views/lead/fields/acceptance-status",
|
||||
"link": "meetings",
|
||||
"column": "status",
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"acceptanceStatusCalls": {
|
||||
"type": "enum",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"directUpdateDisabled": true,
|
||||
"directAccessDisabled": true,
|
||||
"filtersEnabled": true,
|
||||
"layoutAvailabilityList": ["filters"],
|
||||
"importDisabled": true,
|
||||
"exportDisabled": true,
|
||||
"view": "crm:views/lead/fields/acceptance-status",
|
||||
"link": "calls",
|
||||
"column": "status",
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"campaign": {
|
||||
"type": "link"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true,
|
||||
"fieldManagerParamList": [
|
||||
"useNumericFormat"
|
||||
]
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true,
|
||||
"fieldManagerParamList": [
|
||||
"useNumericFormat"
|
||||
]
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/user",
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/user",
|
||||
"fieldManagerParamList": []
|
||||
},
|
||||
"assignedUser": {
|
||||
"type": "link",
|
||||
"view": "views/fields/assigned-user"
|
||||
},
|
||||
"teams": {
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/teams"
|
||||
},
|
||||
"targetLists": {
|
||||
"type": "linkMultiple",
|
||||
"layoutDetailDisabled": true,
|
||||
"layoutListDisabled": true,
|
||||
"importDisabled": true,
|
||||
"directAccessDisabled": true,
|
||||
"filtersEnabled": true,
|
||||
"directUpdateEnabled": true,
|
||||
"noLoad": true
|
||||
},
|
||||
"targetList": {
|
||||
"type": "link",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"layoutDetailDisabled": true,
|
||||
"layoutListDisabled": true,
|
||||
"layoutMassUpdateDisabled": true,
|
||||
"layoutFiltersDisabled": true,
|
||||
"exportDisabled": true,
|
||||
"entity": "TargetList",
|
||||
"directAccessDisabled": true,
|
||||
"importEnabled": true
|
||||
},
|
||||
"portalUser": {
|
||||
"type": "linkOne",
|
||||
"readOnly": true,
|
||||
"notStorable": true,
|
||||
"view": "views/fields/link-one"
|
||||
},
|
||||
"hasPortalUser": {
|
||||
"type": "bool",
|
||||
"notStorable": true,
|
||||
"readOnly": true,
|
||||
"mergeDisabled": true,
|
||||
"customizationDefaultDisabled": true,
|
||||
"customizationReadOnlyDisabled": true,
|
||||
"foreignAccessDisabled": true,
|
||||
"select": {
|
||||
"select": "IS_NOT_NULL:(portalUser.id)",
|
||||
"leftJoins": [["portalUser", "portalUser"]]
|
||||
},
|
||||
"where": {
|
||||
"= TRUE": {
|
||||
"whereClause": {
|
||||
"portalUser.id!=": null
|
||||
},
|
||||
"leftJoins": [["portalUser", "portalUser"]]
|
||||
},
|
||||
"= FALSE": {
|
||||
"whereClause": {
|
||||
"portalUser.id=": null
|
||||
},
|
||||
"leftJoins": [["portalUser", "portalUser"]]
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"order": [
|
||||
["portalUser.id", "{direction}"]
|
||||
],
|
||||
"leftJoins": [["portalUser", "portalUser"]],
|
||||
"additionalSelect": ["portalUser.id"]
|
||||
}
|
||||
},
|
||||
"originalLead": {
|
||||
"type": "linkOne",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/link-one"
|
||||
},
|
||||
"targetListIsOptedOut": {
|
||||
"type": "bool",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"readOnly": true,
|
||||
"utility": true
|
||||
},
|
||||
"originalEmail": {
|
||||
"type": "link",
|
||||
"notStorable": true,
|
||||
"orderDisabled": true,
|
||||
"entity": "Email",
|
||||
"customizationDisabled": true,
|
||||
"layoutAvailabilityList": [],
|
||||
"directAccessDisabled": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"assignedUser": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"teams": {
|
||||
"type": "hasMany",
|
||||
"entity": "Team",
|
||||
"relationName": "entityTeam",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"account": {
|
||||
"type": "belongsTo",
|
||||
"entity": "Account",
|
||||
"deferredLoad": true
|
||||
},
|
||||
"accounts": {
|
||||
"type": "hasMany",
|
||||
"entity": "Account",
|
||||
"foreign": "contacts",
|
||||
"additionalColumns": {
|
||||
"role": {
|
||||
"type": "varchar",
|
||||
"len": 100
|
||||
},
|
||||
"isInactive": {
|
||||
"type": "bool",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalAttributeList": ["columns"],
|
||||
"layoutRelationshipsDisabled": true,
|
||||
"columnAttributeMap": {
|
||||
"role": "accountRole",
|
||||
"isInactive": "accountIsInactive"
|
||||
}
|
||||
},
|
||||
"opportunities": {
|
||||
"type": "hasMany",
|
||||
"entity": "Opportunity",
|
||||
"foreign": "contacts",
|
||||
"columnAttributeMap": {
|
||||
"role": "opportunityRole"
|
||||
}
|
||||
},
|
||||
"opportunitiesPrimary": {
|
||||
"type": "hasMany",
|
||||
"entity": "Opportunity",
|
||||
"foreign": "contact",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"casesPrimary": {
|
||||
"type": "hasMany",
|
||||
"entity": "Case",
|
||||
"foreign": "contact",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"cases": {
|
||||
"type": "hasMany",
|
||||
"entity": "Case",
|
||||
"foreign": "contacts"
|
||||
},
|
||||
"meetings": {
|
||||
"type": "hasMany",
|
||||
"entity": "Meeting",
|
||||
"foreign": "contacts",
|
||||
"audited": true,
|
||||
"columnAttributeMap": {
|
||||
"status": "acceptanceStatus"
|
||||
}
|
||||
},
|
||||
"calls": {
|
||||
"type": "hasMany",
|
||||
"entity": "Call",
|
||||
"foreign": "contacts",
|
||||
"audited": true,
|
||||
"columnAttributeMap": {
|
||||
"status": "acceptanceStatus"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"type": "hasChildren",
|
||||
"entity": "Task",
|
||||
"foreign": "parent",
|
||||
"audited": true
|
||||
},
|
||||
"emails": {
|
||||
"type": "hasChildren",
|
||||
"entity": "Email",
|
||||
"foreign": "parent",
|
||||
"layoutRelationshipsDisabled": true
|
||||
},
|
||||
"campaign": {
|
||||
"type": "belongsTo",
|
||||
"entity": "Campaign",
|
||||
"foreign": "contacts"
|
||||
},
|
||||
"campaignLogRecords": {
|
||||
"type": "hasChildren",
|
||||
"entity": "CampaignLogRecord",
|
||||
"foreign": "parent"
|
||||
},
|
||||
"targetLists": {
|
||||
"type": "hasMany",
|
||||
"entity": "TargetList",
|
||||
"foreign": "contacts",
|
||||
"columnAttributeMap": {
|
||||
"optedOut": "targetListIsOptedOut"
|
||||
}
|
||||
},
|
||||
"portalUser": {
|
||||
"type": "hasOne",
|
||||
"entity": "User",
|
||||
"foreign": "contact"
|
||||
},
|
||||
"originalLead": {
|
||||
"type": "hasOne",
|
||||
"entity": "Lead",
|
||||
"foreign": "createdContact"
|
||||
},
|
||||
"documents": {
|
||||
"type": "hasMany",
|
||||
"entity": "Document",
|
||||
"foreign": "contacts",
|
||||
"audited": true
|
||||
},
|
||||
"tasksPrimary": {
|
||||
"type": "hasMany",
|
||||
"entity": "Task",
|
||||
"foreign": "contact",
|
||||
"layoutRelationshipsDisabled": true
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "createdAt",
|
||||
"order": "desc",
|
||||
"textFilterFields": ["name", "emailAddress"]
|
||||
},
|
||||
"indexes": {
|
||||
"createdAt": {
|
||||
"columns": ["createdAt", "deleted"]
|
||||
},
|
||||
"createdAtId": {
|
||||
"unique": true,
|
||||
"columns": ["createdAt", "id"]
|
||||
},
|
||||
"firstName": {
|
||||
"columns": ["firstName", "deleted"]
|
||||
},
|
||||
"name": {
|
||||
"columns": ["firstName", "lastName"]
|
||||
},
|
||||
"assignedUser": {
|
||||
"columns": ["assignedUserId", "deleted"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"required": true,
|
||||
"pattern": "$noBadCharacters"
|
||||
},
|
||||
"emailAddress": {
|
||||
"type": "varchar",
|
||||
"required": true,
|
||||
"maxLength": 100,
|
||||
"tooltip": true,
|
||||
"view": "views/email-account/fields/email-address"
|
||||
},
|
||||
"status": {
|
||||
"type": "enum",
|
||||
"options": ["Active", "Inactive"],
|
||||
"style": {
|
||||
"Inactive": "info"
|
||||
},
|
||||
"default": "Active"
|
||||
},
|
||||
"host": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"port": {
|
||||
"type": "int",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"default": 993,
|
||||
"disableFormatting": true
|
||||
},
|
||||
"security": {
|
||||
"type": "enum",
|
||||
"default": "SSL",
|
||||
"options": ["", "SSL", "TLS"]
|
||||
},
|
||||
"username": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"password": {
|
||||
"type": "password"
|
||||
},
|
||||
"monitoredFolders": {
|
||||
"type": "array",
|
||||
"default": ["INBOX"],
|
||||
"view": "views/email-account/fields/folders",
|
||||
"displayAsList": true,
|
||||
"noEmptyString": true,
|
||||
"duplicateIgnore": true,
|
||||
"tooltip": true,
|
||||
"fullNameAdditionalAttributeList": ["folderMap"]
|
||||
},
|
||||
"sentFolder": {
|
||||
"type": "varchar",
|
||||
"view": "views/email-account/fields/folder",
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"folderMap": {
|
||||
"type": "jsonObject",
|
||||
"validatorClassNameList": [
|
||||
"Espo\\Classes\\FieldValidators\\EmailAccount\\FolderMap\\Valid"
|
||||
]
|
||||
},
|
||||
"storeSentEmails": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"keepFetchedEmailsUnread": {
|
||||
"type": "bool"
|
||||
},
|
||||
"fetchSince": {
|
||||
"type": "date",
|
||||
"validatorClassNameList": [
|
||||
"Espo\\Classes\\FieldValidators\\InboundEmail\\FetchSince\\Required"
|
||||
],
|
||||
"forceValidation": true
|
||||
},
|
||||
"fetchData": {
|
||||
"type": "jsonObject",
|
||||
"readOnly": true,
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"emailFolder": {
|
||||
"type": "link",
|
||||
"view": "views/email-account/fields/email-folder",
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"assignedUser": {
|
||||
"type": "link",
|
||||
"required": true,
|
||||
"view": "views/fields/assigned-user"
|
||||
},
|
||||
"connectedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"useImap": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"useSmtp": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"smtpPort": {
|
||||
"type": "int",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"default": 587,
|
||||
"disableFormatting": true
|
||||
},
|
||||
"smtpAuth": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"smtpSecurity": {
|
||||
"type": "enum",
|
||||
"default": "TLS",
|
||||
"options": ["", "SSL", "TLS"]
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "password"
|
||||
},
|
||||
"smtpAuthMechanism": {
|
||||
"type": "enum",
|
||||
"options": ["login", "crammd5", "plain"],
|
||||
"default": "login"
|
||||
},
|
||||
"imapHandler": {
|
||||
"type": "varchar",
|
||||
"readOnly": true
|
||||
},
|
||||
"smtpHandler": {
|
||||
"type": "varchar",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"assignedUser": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"filters": {
|
||||
"type": "hasChildren",
|
||||
"foreign": "parent",
|
||||
"entity": "EmailFilter"
|
||||
},
|
||||
"emails": {
|
||||
"type": "hasMany",
|
||||
"entity": "Email",
|
||||
"foreign": "emailAccounts"
|
||||
},
|
||||
"emailFolder": {
|
||||
"type": "belongsTo",
|
||||
"entity": "EmailFolder"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "name",
|
||||
"order": "asc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"required": true,
|
||||
"pattern": "$noBadCharacters",
|
||||
"view": "views/inbound-email/fields/name"
|
||||
},
|
||||
"emailAddress": {
|
||||
"type": "varchar",
|
||||
"required": true,
|
||||
"maxLength": 100,
|
||||
"view": "views/inbound-email/fields/email-address"
|
||||
},
|
||||
"status": {
|
||||
"type": "enum",
|
||||
"options": ["Active", "Inactive"],
|
||||
"style": {
|
||||
"Inactive": "info"
|
||||
},
|
||||
"default": "Active"
|
||||
},
|
||||
"host": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"port": {
|
||||
"type": "int",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"default": 993,
|
||||
"disableFormatting": true
|
||||
},
|
||||
"security": {
|
||||
"type": "enum",
|
||||
"default": "SSL",
|
||||
"options": ["", "SSL", "TLS"]
|
||||
},
|
||||
"username": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"password": {
|
||||
"type": "password"
|
||||
},
|
||||
"monitoredFolders": {
|
||||
"type": "array",
|
||||
"default": ["INBOX"],
|
||||
"view": "views/inbound-email/fields/folders",
|
||||
"displayAsList": true,
|
||||
"noEmptyString": true,
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"fetchSince": {
|
||||
"type": "date",
|
||||
"validatorClassNameList": [
|
||||
"Espo\\Classes\\FieldValidators\\InboundEmail\\FetchSince\\Required"
|
||||
],
|
||||
"forceValidation": true
|
||||
},
|
||||
"fetchData": {
|
||||
"type": "jsonObject",
|
||||
"readOnly": true,
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"assignToUser": {
|
||||
"type": "link",
|
||||
"tooltip": true
|
||||
},
|
||||
"team": {
|
||||
"type": "link",
|
||||
"tooltip": true
|
||||
},
|
||||
"teams": {
|
||||
"type": "linkMultiple",
|
||||
"tooltip": true
|
||||
},
|
||||
"addAllTeamUsers": {
|
||||
"type": "bool",
|
||||
"tooltip": true,
|
||||
"default": true
|
||||
},
|
||||
"isSystem": {
|
||||
"type": "bool",
|
||||
"notStorable": true,
|
||||
"readOnly": true,
|
||||
"directAccessDisabled": true,
|
||||
"tooltip": true
|
||||
},
|
||||
"sentFolder": {
|
||||
"type": "varchar",
|
||||
"view": "views/inbound-email/fields/folder",
|
||||
"duplicateIgnore": true
|
||||
},
|
||||
"storeSentEmails": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"keepFetchedEmailsUnread": {
|
||||
"type": "bool"
|
||||
},
|
||||
"connectedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"excludeFromReply": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"useImap": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"useSmtp": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"smtpIsShared": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"smtpIsForMassEmail": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"smtpPort": {
|
||||
"type": "int",
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"default": 587,
|
||||
"disableFormatting": true
|
||||
},
|
||||
"smtpAuth": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"smtpSecurity": {
|
||||
"type": "enum",
|
||||
"default": "TLS",
|
||||
"options": ["", "SSL", "TLS"]
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "password"
|
||||
},
|
||||
"smtpAuthMechanism": {
|
||||
"type": "enum",
|
||||
"options": ["login", "crammd5", "plain"],
|
||||
"default": "login"
|
||||
},
|
||||
"createCase": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"caseDistribution": {
|
||||
"type": "enum",
|
||||
"options": ["", "Direct-Assignment", "Round-Robin", "Least-Busy"],
|
||||
"default": "Direct-Assignment",
|
||||
"tooltip": true
|
||||
},
|
||||
"targetUserPosition": {
|
||||
"type": "enum",
|
||||
"view": "views/inbound-email/fields/target-user-position",
|
||||
"tooltip": true
|
||||
},
|
||||
"reply": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"replyEmailTemplate": {
|
||||
"type": "link"
|
||||
},
|
||||
"replyFromAddress": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"replyToAddress": {
|
||||
"type": "varchar",
|
||||
"view": "views/fields/email-address",
|
||||
"tooltip": true
|
||||
},
|
||||
"replyFromName": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"fromName": {
|
||||
"type": "varchar"
|
||||
},
|
||||
"groupEmailFolder": {
|
||||
"type": "link",
|
||||
"tooltip": true
|
||||
},
|
||||
"imapHandler": {
|
||||
"type": "varchar",
|
||||
"readOnly": true
|
||||
},
|
||||
"smtpHandler": {
|
||||
"type": "varchar",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"teams": {
|
||||
"type": "hasMany",
|
||||
"entity": "Team",
|
||||
"foreign": "inboundEmails"
|
||||
},
|
||||
"assignToUser": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"team": {
|
||||
"type": "belongsTo",
|
||||
"entity": "Team"
|
||||
},
|
||||
"replyEmailTemplate": {
|
||||
"type": "belongsTo",
|
||||
"entity": "EmailTemplate"
|
||||
},
|
||||
"filters": {
|
||||
"type": "hasChildren",
|
||||
"foreign": "parent",
|
||||
"entity": "EmailFilter"
|
||||
},
|
||||
"emails": {
|
||||
"type": "hasMany",
|
||||
"entity": "Email",
|
||||
"foreign": "inboundEmails"
|
||||
},
|
||||
"groupEmailFolder": {
|
||||
"type": "belongsTo",
|
||||
"entity": "GroupEmailFolder"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "name",
|
||||
"order": "asc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"fields": {
|
||||
"event": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"required": true,
|
||||
"view": "views/webhook/fields/event"
|
||||
},
|
||||
"url": {
|
||||
"type": "varchar",
|
||||
"maxLength": 512,
|
||||
"required": true,
|
||||
"copyToClipboard": true
|
||||
},
|
||||
"isActive": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"user": {
|
||||
"type": "link",
|
||||
"view": "views/webhook/fields/user"
|
||||
},
|
||||
"entityType": {
|
||||
"type": "varchar",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/entity-type"
|
||||
},
|
||||
"type": {
|
||||
"type": "enum",
|
||||
"options": [
|
||||
"create",
|
||||
"update",
|
||||
"fieldUpdate",
|
||||
"delete"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"field": {
|
||||
"type": "varchar",
|
||||
"readOnly": true
|
||||
},
|
||||
"secretKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"readOnly": true,
|
||||
"layoutMassUpdateDisabled": true,
|
||||
"layoutFiltersDisabled": true,
|
||||
"layoutListDisabled": true
|
||||
},
|
||||
"skipOwn": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"queueItems": {
|
||||
"type": "hasMany",
|
||||
"entity": "WebhookQueueItem",
|
||||
"foreign": "webhook",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "createdAt",
|
||||
"order": "desc",
|
||||
"textFilterFields": ["event"]
|
||||
},
|
||||
"indexes": {
|
||||
"event": {
|
||||
"columns": ["event"]
|
||||
},
|
||||
"entityTypeType": {
|
||||
"columns": ["entityType", "type"]
|
||||
},
|
||||
"entityTypeField": {
|
||||
"columns": ["entityType", "field"]
|
||||
}
|
||||
},
|
||||
"hooksDisabled": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"massActions": {
|
||||
"update": {
|
||||
"allowed": true
|
||||
},
|
||||
"delete": {
|
||||
"allowed": true
|
||||
}
|
||||
},
|
||||
"createInputFilterClassNameList": [
|
||||
"Espo\\Classes\\Record\\InboundEmail\\PasswordsInputFilter"
|
||||
],
|
||||
"updateInputFilterClassNameList": [
|
||||
"Espo\\Classes\\Record\\InboundEmail\\PasswordsInputFilter"
|
||||
],
|
||||
"beforeCreateHookClassNameList": [
|
||||
"Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeCreate",
|
||||
"Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave"
|
||||
],
|
||||
"beforeUpdateHookClassNameList": [
|
||||
"Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"massActions": {
|
||||
"update": {
|
||||
"allowed": true
|
||||
},
|
||||
"delete": {
|
||||
"allowed": true
|
||||
}
|
||||
},
|
||||
"createInputFilterClassNameList": [
|
||||
"Espo\\Classes\\Record\\InboundEmail\\PasswordsInputFilter"
|
||||
],
|
||||
"updateInputFilterClassNameList": [
|
||||
"Espo\\Classes\\Record\\InboundEmail\\PasswordsInputFilter"
|
||||
],
|
||||
"readLoaderClassNameList": [
|
||||
"Espo\\Classes\\FieldProcessing\\InboundEmail\\IsSystemLoader"
|
||||
],
|
||||
"listLoaderClassNameList": [
|
||||
"Espo\\Classes\\FieldProcessing\\InboundEmail\\IsSystemLoader"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\App;
|
||||
|
||||
use Espo\Core\Mail\ConfigDataProvider as EmailConfigDataProvider;
|
||||
use Espo\Core\Utils\ThemeManager;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Entities\Settings;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider;
|
||||
use Espo\Core\ApplicationState;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\DataManager;
|
||||
use Espo\Core\FieldValidation\FieldValidationManager;
|
||||
use Espo\Core\Utils\Currency\DatabasePopulator as CurrencyDatabasePopulator;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Config\ConfigWriter;
|
||||
use Espo\Core\Utils\Config\Access;
|
||||
use Espo\Entities\Portal;
|
||||
use Espo\Repositories\Portal as PortalRepository;
|
||||
use Espo\Tools\Currency\SyncManager as CurrencySyncManager;
|
||||
use stdClass;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
public function __construct(
|
||||
private ApplicationState $applicationState,
|
||||
private Config $config,
|
||||
private ConfigWriter $configWriter,
|
||||
private Metadata $metadata,
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private DataManager $dataManager,
|
||||
private FieldValidationManager $fieldValidationManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Access $access,
|
||||
private AuthenticationMethodProvider $authenticationMethodProvider,
|
||||
private ThemeManager $themeManager,
|
||||
private Config\SystemConfig $systemConfig,
|
||||
private EmailConfigDataProvider $emailConfigDataProvider,
|
||||
private Acl\Cache\Clearer $aclCacheClearer,
|
||||
private CurrencySyncManager $currencySyncManager,
|
||||
private CurrencyDatabasePopulator $currencyDatabasePopulator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get config data.
|
||||
*/
|
||||
public function getConfigData(): stdClass
|
||||
{
|
||||
$data = $this->config->getAllNonInternalData();
|
||||
|
||||
$this->filterDataByAccess($data);
|
||||
$this->filterData($data);
|
||||
$this->loadAdditionalParams($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata to be used in config.
|
||||
*/
|
||||
public function getMetadataConfigData(): stdClass
|
||||
{
|
||||
$data = (object) [];
|
||||
|
||||
unset($data->loginView);
|
||||
|
||||
$loginView = $this->metadata->get(['clientDefs', 'App', 'loginView']);
|
||||
|
||||
if ($loginView) {
|
||||
$data->loginView = $loginView;
|
||||
}
|
||||
|
||||
$loginData = $this->getLoginData();
|
||||
|
||||
if ($loginData) {
|
||||
$data->loginData = (object) $loginData;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array{
|
||||
* handler: string,
|
||||
* fallback: bool,
|
||||
* data: stdClass,
|
||||
* method: string,
|
||||
* }
|
||||
*/
|
||||
private function getLoginData(): ?array
|
||||
{
|
||||
$method = $this->authenticationMethodProvider->get();
|
||||
|
||||
/** @var array<string, mixed> $mData */
|
||||
$mData = $this->metadata->get(['authenticationMethods', $method, 'login']) ?? [];
|
||||
|
||||
/** @var ?string $handler */
|
||||
$handler = $mData['handler'] ?? null;
|
||||
|
||||
if (!$handler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isProvider = $this->isPortalWithAuthenticationProvider();
|
||||
|
||||
if (!$isProvider && $this->applicationState->isPortal()) {
|
||||
/** @var ?bool $portal */
|
||||
$portal = $mData['portal'] ?? null;
|
||||
|
||||
if ($portal === null) {
|
||||
/** @var ?string $portalConfigParam */
|
||||
$portalConfigParam = $mData['portalConfigParam'] ?? null;
|
||||
|
||||
$portal = $portalConfigParam && $this->config->get($portalConfigParam);
|
||||
}
|
||||
|
||||
if (!$portal) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var ?bool $fallback */
|
||||
$fallback = !$this->applicationState->isPortal() ?
|
||||
($mData['fallback'] ?? null) :
|
||||
false;
|
||||
|
||||
if ($fallback === null) {
|
||||
/** @var ?string $fallbackConfigParam */
|
||||
$fallbackConfigParam = $mData['fallbackConfigParam'] ?? null;
|
||||
|
||||
$fallback = $fallbackConfigParam && $this->config->get($fallbackConfigParam);
|
||||
}
|
||||
|
||||
if ($isProvider) {
|
||||
$fallback = false;
|
||||
}
|
||||
|
||||
/** @var stdClass $data */
|
||||
$data = (object) ($mData['data'] ?? []);
|
||||
|
||||
return [
|
||||
'handler' => $handler,
|
||||
'fallback' => $fallback,
|
||||
'method' => $method,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
private function isPortalWithAuthenticationProvider(): bool
|
||||
{
|
||||
if (!$this->applicationState->isPortal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$portal = $this->applicationState->getPortal();
|
||||
|
||||
return (bool) $this->authenticationMethodProvider->getForPortal($portal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set config data.
|
||||
*
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
*/
|
||||
public function setConfigData(stdClass $data): void
|
||||
{
|
||||
$user = $this->applicationState->getUser();
|
||||
|
||||
if (!$user->isAdmin()) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$ignoreItemList = array_merge(
|
||||
$this->access->getSystemParamList(),
|
||||
$this->access->getReadOnlyParamList(),
|
||||
$this->isRestrictedMode() && !$user->isSuperAdmin() ?
|
||||
$this->access->getSuperAdminParamList() : []
|
||||
);
|
||||
|
||||
foreach ($ignoreItemList as $item) {
|
||||
unset($data->$item);
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getNewEntity(Settings::ENTITY_TYPE);
|
||||
|
||||
$entity->set($data);
|
||||
$entity->setAsNotNew();
|
||||
|
||||
$this->processValidation($entity, $data);
|
||||
|
||||
if (
|
||||
isset($data->useCache) &&
|
||||
$data->useCache !== $this->systemConfig->useCache()
|
||||
) {
|
||||
$this->dataManager->clearCache();
|
||||
}
|
||||
|
||||
$this->configWriter->setMultiple(get_object_vars($data));
|
||||
$this->configWriter->save();
|
||||
|
||||
if (isset($data->personNameFormat)) {
|
||||
$this->dataManager->clearCache();
|
||||
}
|
||||
|
||||
if (property_exists($data, 'baselineRoleId')) {
|
||||
$this->aclCacheClearer->clearForAllInternalUsers();
|
||||
}
|
||||
|
||||
if (isset($data->baseCurrency) || isset($data->currencyList) || isset($data->defaultCurrency)) {
|
||||
$this->currencySyncManager->sync();
|
||||
$this->currencyDatabasePopulator->process();
|
||||
}
|
||||
}
|
||||
|
||||
private function loadAdditionalParams(stdClass $data): void
|
||||
{
|
||||
if ($this->applicationState->isPortal()) {
|
||||
$portal = $this->applicationState->getPortal();
|
||||
|
||||
$this->getPortalRepository()->loadUrlField($portal);
|
||||
|
||||
$data->siteUrl = $portal->get('url');
|
||||
}
|
||||
|
||||
if (
|
||||
(
|
||||
$this->emailConfigDataProvider->getSystemOutboundAddress() ||
|
||||
$this->config->get('internalSmtpServer')
|
||||
) &&
|
||||
!$this->config->get('passwordRecoveryDisabled')
|
||||
) {
|
||||
$data->passwordRecoveryEnabled = true;
|
||||
}
|
||||
|
||||
$data->logoSrc = $this->themeManager->getLogoSrc();
|
||||
}
|
||||
|
||||
private function filterDataByAccess(stdClass $data): void
|
||||
{
|
||||
$user = $this->applicationState->getUser();
|
||||
|
||||
$ignoreItemList = [];
|
||||
|
||||
foreach ($this->access->getSystemParamList() as $item) {
|
||||
$ignoreItemList[] = $item;
|
||||
}
|
||||
|
||||
foreach ($this->access->getInternalParamList() as $item) {
|
||||
$ignoreItemList[] = $item;
|
||||
}
|
||||
|
||||
if (!$user->isAdmin() || $user->isSystem()) {
|
||||
foreach ($this->access->getAdminParamList() as $item) {
|
||||
$ignoreItemList[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
/*if ($this->isRestrictedMode() && !$user->isSuperAdmin()) {
|
||||
// @todo Maybe add restriction level for non-super admins.
|
||||
}*/
|
||||
|
||||
foreach ($ignoreItemList as $item) {
|
||||
unset($data->$item);
|
||||
}
|
||||
|
||||
if ($user->isSystem()) {
|
||||
$globalItemList = $this->access->getGlobalParamList();
|
||||
|
||||
foreach (array_keys(get_object_vars($data)) as $item) {
|
||||
if (!in_array($item, $globalItemList)) {
|
||||
unset($data->$item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function filterEntityTypeParams(stdClass $data): void
|
||||
{
|
||||
$entityTypeListParamList = $this->metadata->get(['app', 'config', 'entityTypeListParamList']) ?? [];
|
||||
|
||||
/** @var string[] $scopeList */
|
||||
$scopeList = array_keys($this->metadata->get(['entityDefs'], []));
|
||||
|
||||
foreach ($scopeList as $scope) {
|
||||
if (!$this->metadata->get(['scopes', $scope, 'acl'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->acl->tryCheck($scope)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($entityTypeListParamList as $param) {
|
||||
$list = $data->$param ?? [];
|
||||
|
||||
foreach ($list as $i => $item) {
|
||||
if ($item === $scope) {
|
||||
unset($list[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
$data->$param = array_values($list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function filterData(stdClass $data): void
|
||||
{
|
||||
$user = $this->applicationState->getUser();
|
||||
|
||||
if (!$user->isAdmin() && !$user->isSystem()) {
|
||||
$this->filterEntityTypeParams($data);
|
||||
}
|
||||
|
||||
$fieldDefs = $this->metadata->get(['entityDefs', 'Settings', 'fields']);
|
||||
|
||||
foreach ($fieldDefs as $field => $fieldParams) {
|
||||
if ($fieldParams['type'] === 'password') {
|
||||
unset($data->$field);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($data->useWebSocket)) {
|
||||
unset($data->webSocketUrl);
|
||||
}
|
||||
|
||||
if ($user->isSystem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE) ||
|
||||
!$this->emailConfigDataProvider->isSystemOutboundAddressShared()
|
||||
) {
|
||||
unset($data->outboundEmailFromAddress);
|
||||
unset($data->outboundEmailFromName);
|
||||
unset($data->outboundEmailBccAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private function isRestrictedMode(): bool
|
||||
{
|
||||
return (bool) $this->config->get('restrictedMode');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function processValidation(Entity $entity, stdClass $data): void
|
||||
{
|
||||
$this->fieldValidationManager->process($entity, $data);
|
||||
}
|
||||
|
||||
private function getPortalRepository(): PortalRepository
|
||||
{
|
||||
/** @var PortalRepository */
|
||||
return $this->entityManager->getRepository(Portal::ENTITY_TYPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Email\Api;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Api\Action;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Api\Response;
|
||||
use Espo\Core\Api\ResponseComposer;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Mail\Exceptions\NoSmtp;
|
||||
use Espo\Core\Mail\SmtpParams;
|
||||
use Espo\Entities\Email;
|
||||
use Espo\Tools\Email\SendService;
|
||||
use Espo\Tools\Email\TestSendData;
|
||||
|
||||
/**
|
||||
* Sends test emails.
|
||||
*/
|
||||
class PostSendTest implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private SendService $sendService,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws Error
|
||||
* @throws NoSmtp
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
if (!$this->acl->checkScope(Email::ENTITY_TYPE)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$type = $data->type ?? null;
|
||||
$id = $data->id ?? null;
|
||||
$server = $data->server ?? null;
|
||||
$port = $data->port ?? null;
|
||||
$username = $data->username ?? null;
|
||||
$password = $data->password ?? null;
|
||||
$auth = $data->auth ?? null;
|
||||
$authMechanism = $data->authMechanism ?? null;
|
||||
$security = $data->security ?? null;
|
||||
$userId = $data->userId ?? null;
|
||||
$fromAddress = $data->fromAddress ?? null;
|
||||
$fromName = $data->fromName ?? null;
|
||||
$emailAddress = $data->emailAddress ?? null;
|
||||
|
||||
if (!is_string($server)) {
|
||||
throw new BadRequest("No `server`");
|
||||
}
|
||||
|
||||
if (!is_int($port)) {
|
||||
throw new BadRequest("No or bad `port`.");
|
||||
}
|
||||
|
||||
if (!is_string($emailAddress)) {
|
||||
throw new BadRequest("No `emailAddress`.");
|
||||
}
|
||||
|
||||
$smtpParams = SmtpParams
|
||||
::create($server, $port)
|
||||
->withSecurity($security)
|
||||
->withFromName($fromName)
|
||||
->withFromAddress($fromAddress)
|
||||
->withAuth($auth);
|
||||
|
||||
if ($auth) {
|
||||
$smtpParams = $smtpParams
|
||||
->withUsername($username)
|
||||
->withPassword($password)
|
||||
->withAuthMechanism($authMechanism);
|
||||
}
|
||||
|
||||
$data = new TestSendData($emailAddress, $type, $id, $userId);
|
||||
|
||||
$this->sendService->sendTestEmail($smtpParams, $data);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\GlobalSearch;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Record\Collection;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityCollection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
use Espo\ORM\Query\Select;
|
||||
use Espo\ORM\Query\Part\Order;
|
||||
use Espo\ORM\Query\Part\Expression as Expr;
|
||||
use Espo\Core\Select\Text\FullTextSearch\DataComposerFactory as FullTextSearchDataComposerFactory;
|
||||
use Espo\Core\Select\Text\FullTextSearch\DataComposer\Params as FullTextSearchDataComposerParams;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\ORM\Query\UnionBuilder;
|
||||
use RuntimeException;
|
||||
|
||||
class Service
|
||||
{
|
||||
public function __construct(
|
||||
private FullTextSearchDataComposerFactory $fullTextSearchDataComposerFactory,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private EntityManager $entityManager,
|
||||
private Metadata $metadata,
|
||||
private Acl $acl,
|
||||
private Config $config,
|
||||
private Config\ApplicationConfig $applicationConfig,
|
||||
private ServiceContainer $serviceContainer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $filter A search query.
|
||||
* @param ?int $offset An offset.
|
||||
* @param ?int $maxSize A limit.
|
||||
* @return Collection<Entity>
|
||||
*/
|
||||
public function find(string $filter, ?int $offset = 0, ?int $maxSize = null): Collection
|
||||
{
|
||||
$entityTypeList = $this->config->get('globalSearchEntityList') ?? [];
|
||||
$offset ??= 0;
|
||||
$maxSize ??= $this->applicationConfig->getRecordsPerPage();
|
||||
|
||||
$hasFullTextSearch = false;
|
||||
|
||||
$queryList = [];
|
||||
|
||||
foreach ($entityTypeList as $i => $entityType) {
|
||||
$query = $this->getEntityTypeQuery(
|
||||
entityType: $entityType,
|
||||
i: $i,
|
||||
filter: $filter,
|
||||
offset: $offset,
|
||||
maxSize: $maxSize,
|
||||
hasFullTextSearch: $hasFullTextSearch,
|
||||
);
|
||||
|
||||
if (!$query) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$queryList[] = $query;
|
||||
}
|
||||
|
||||
if (count($queryList) === 0) {
|
||||
return new Collection(new EntityCollection(), 0);
|
||||
}
|
||||
|
||||
$builder = UnionBuilder::create()
|
||||
->all()
|
||||
->limit($offset, $maxSize + 1);
|
||||
|
||||
foreach ($queryList as $query) {
|
||||
$builder->query($query);
|
||||
}
|
||||
|
||||
if ($hasFullTextSearch) {
|
||||
$builder->order('relevance', Order::DESC);
|
||||
}
|
||||
|
||||
$builder->order('order', Order::DESC);
|
||||
$builder->order(Field::NAME);
|
||||
|
||||
$unionQuery = $builder->build();
|
||||
|
||||
$collection = new EntityCollection();
|
||||
|
||||
$sth = $this->entityManager->getQueryExecutor()->execute($unionQuery);
|
||||
|
||||
while ($row = $sth->fetch()) {
|
||||
$entityType = $row['entityType'] ?? null;
|
||||
|
||||
if (!is_string($entityType)) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
|
||||
$statusField = $this->getStatusField($entityType);
|
||||
|
||||
$select = [
|
||||
Attribute::ID,
|
||||
Field::NAME,
|
||||
];
|
||||
|
||||
if ($statusField) {
|
||||
$select[] = $statusField;
|
||||
}
|
||||
|
||||
$entity = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->select($select)
|
||||
->where([Attribute::ID => $row[Attribute::ID]])
|
||||
->findOne();
|
||||
|
||||
if (!$entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->serviceContainer->get($entityType)->prepareEntityForOutput($entity);
|
||||
|
||||
$collection->append($entity);
|
||||
}
|
||||
|
||||
return Collection::createNoCount($collection, $maxSize);
|
||||
}
|
||||
|
||||
private function getEntityTypeQuery(
|
||||
string $entityType,
|
||||
int $i,
|
||||
string $filter,
|
||||
int $offset,
|
||||
int $maxSize,
|
||||
bool &$hasFullTextSearch,
|
||||
): ?Select {
|
||||
|
||||
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_READ)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->metadata->get("scopes.$entityType")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selectBuilder = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from($entityType)
|
||||
->withStrictAccessControl()
|
||||
->withTextFilter($filter);
|
||||
|
||||
$entityDefs = $this->entityManager->getDefs()->getEntity($entityType);
|
||||
|
||||
$nameAttribute = $entityDefs->hasField(Field::NAME) ? Field::NAME : Attribute::ID;
|
||||
|
||||
$selectList = [
|
||||
Attribute::ID,
|
||||
$nameAttribute,
|
||||
['VALUE:' . $entityType, 'entityType'],
|
||||
[(string) $i, 'order'],
|
||||
];
|
||||
|
||||
$fullTextSearchDataComposer = $this->fullTextSearchDataComposerFactory->create($entityType);
|
||||
|
||||
$fullTextSearchData = $fullTextSearchDataComposer->compose(
|
||||
$filter,
|
||||
FullTextSearchDataComposerParams::create()
|
||||
);
|
||||
|
||||
if ($this->isPerson($entityType)) {
|
||||
$selectList[] = 'firstName';
|
||||
$selectList[] = 'lastName';
|
||||
} else {
|
||||
$selectList[] = ['VALUE:', 'firstName'];
|
||||
$selectList[] = ['VALUE:', 'lastName'];
|
||||
}
|
||||
|
||||
$statusField = $this->getStatusField($entityType);
|
||||
|
||||
if ($statusField) {
|
||||
$selectList[] = [$statusField, 'status'];
|
||||
} else {
|
||||
$selectList[] = ['VALUE:', 'status'];
|
||||
}
|
||||
|
||||
try {
|
||||
$query = $selectBuilder->build();
|
||||
} catch (BadRequest|Forbidden $e) {
|
||||
throw new RuntimeException("", 0, $e);
|
||||
}
|
||||
|
||||
$queryBuilder = $this->entityManager
|
||||
->getQueryBuilder()
|
||||
->select()
|
||||
->clone($query)
|
||||
->limit(0, $offset + $maxSize + 1)
|
||||
->select($selectList)
|
||||
->order([]);
|
||||
|
||||
if ($fullTextSearchData) {
|
||||
$hasFullTextSearch = true;
|
||||
|
||||
$expression = $fullTextSearchData->getExpression();
|
||||
|
||||
$queryBuilder
|
||||
->select($expression, 'relevance')
|
||||
->order($expression, Order::DESC);
|
||||
} else {
|
||||
$queryBuilder->select(Expr::value(1.1), 'relevance');
|
||||
}
|
||||
|
||||
$queryBuilder->order($nameAttribute);
|
||||
|
||||
return $queryBuilder->build();
|
||||
}
|
||||
|
||||
private function isPerson(string $entityType): bool
|
||||
{
|
||||
$fieldDefs = $this->entityManager->getDefs()->getEntity($entityType);
|
||||
|
||||
return $fieldDefs->tryGetField(Field::NAME)?->getType() === FieldType::PERSON_NAME;
|
||||
}
|
||||
|
||||
private function getStatusField(string $entityType): ?string
|
||||
{
|
||||
return $this->metadata->get("scopes.$entityType.statusField");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user