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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
210
data/.backup/upgrades/69aa99386935de541/files/client/lib/espo.js
Normal file
210
data/.backup/upgrades/69aa99386935de541/files/client/lib/espo.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,7 @@ return [
|
||||
0 => 'youtube.com',
|
||||
1 => 'google.com'
|
||||
],
|
||||
'microtime' => 1772528441.682592,
|
||||
'microtime' => 1772993835.111309,
|
||||
'siteUrl' => 'https://crm.bitbylaw.com',
|
||||
'fullTextSearchMinLength' => 4,
|
||||
'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
return [
|
||||
'cacheTimestamp' => 1772528753,
|
||||
'microtimeState' => 1772528753.903734,
|
||||
'cacheTimestamp' => 1772993835,
|
||||
'microtimeState' => 1772993835.316556,
|
||||
'currencyRates' => [
|
||||
'EUR' => 1.0
|
||||
],
|
||||
'appTimestamp' => 1771505822,
|
||||
'version' => '9.3.1',
|
||||
'latestVersion' => '9.3.1',
|
||||
'appTimestamp' => 1772788027,
|
||||
'version' => '9.3.2',
|
||||
'latestVersion' => '9.3.2',
|
||||
'latestExtensionVersions' => [
|
||||
'Advanced Pack' => '3.12.0'
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user