some big beautiful update

This commit is contained in:
2026-03-08 19:18:17 +01:00
parent 845a55d170
commit 218b6e0d97
96 changed files with 171864 additions and 465 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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"]
}
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

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

View File

@@ -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"
]
}

View File

@@ -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"
]
}

View File

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

View File

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

View File

@@ -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");
}
}