Initial commit

This commit is contained in:
root
2026-01-19 17:44:46 +01:00
commit 823af8b11d
8721 changed files with 1130846 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\ActionHistory;
use Espo\Core\Name\Field;
use Espo\Core\Record\ActionHistory\Action;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Entities\ActionHistoryRecord;
use Espo\Core\FieldProcessing\ListLoadProcessor;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Util;
use Espo\Entities\User;
class Service
{
public function __construct(
private Metadata $metadata,
private EntityManager $entityManager,
private User $user,
private ListLoadProcessor $listLoadProcessor
) {}
/**
* @return RecordCollection<ActionHistoryRecord>
*/
public function getLastViewed(?int $maxSize, ?int $offset): RecordCollection
{
$scopes = $this->metadata->get('scopes');
$targetTypeList = array_filter(
array_keys($scopes),
function ($item) use ($scopes) {
return !empty($scopes[$item]['object']) || !empty($scopes[$item]['lastViewed']);
}
);
$maxSize = $maxSize ?? 0;
$offset = $offset ?? 0;
$collection = $this->entityManager
->getRDBRepositoryByClass(ActionHistoryRecord::class)
->where([
'userId' => $this->user->getId(),
'action' => Action::READ,
'targetType' => $targetTypeList,
])
->order('MAX:' . Field::CREATED_AT, 'DESC')
->select([
'targetId',
'targetType',
'MAX:number',
['MAX:createdAt', Field::CREATED_AT],
])
->group(['targetId', 'targetType'])
->limit($offset, $maxSize + 1)
->find();
foreach ($collection as $entity) {
$this->listLoadProcessor->process($entity);
$entity->set('id', Util::generateId());
}
return RecordCollection::createNoCount($collection, $maxSize);
}
}

View File

@@ -0,0 +1,105 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Address;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\File\Manager;
use Espo\Core\Utils\Id\RecordIdGenerator;
use Espo\Core\Utils\Json;
use Espo\Entities\AddressCountry;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\DeleteBuilder;
use RuntimeException;
class CountryDefaultsPopulator
{
private string $file = 'application/Espo/Resources/data/locale/en_US/countryList.json';
private const CACHE_KEY = 'addressCountryData';
public function __construct(
private Manager $fileManager,
private EntityManager $entityManager,
private DataCache $dataCache,
private RecordIdGenerator $recordIdGenerator
) {}
public function populate(): void
{
if (!$this->fileManager->exists($this->file)) {
throw new RuntimeException("No file '$this->file'.");
}
$contents = $this->fileManager->getContents($this->file);
$dataList = Json::decode($contents, true);
if (!is_array($dataList)) {
throw new RuntimeException("Bad data.");
}
$collection = $this->entityManager->getCollectionFactory()->create(AddressCountry::ENTITY_TYPE);
foreach ($dataList as $data) {
if (!is_array($data)) {
throw new RuntimeException("Bad data.");
}
$name = $data['name'] ?? null;
$code = $data['code'] ?? null;
$isPreferred = $data['isPreferred'] ?? false;
if (!is_string($name) || !is_string($code)) {
throw new RuntimeException("Bad data.");
}
$entity = $this->entityManager->getNewEntity(AddressCountry::ENTITY_TYPE);
$entity->setMultiple([
'id' => $this->recordIdGenerator->generate(),
'name' => $name,
'code' => $code,
'isPreferred' => $isPreferred,
]);
$collection->append($entity);
}
$this->entityManager->getQueryExecutor()->execute(
DeleteBuilder::create()
->from(AddressCountry::ENTITY_TYPE)
->build()
);
$this->entityManager->getMapper()->massInsert($collection);
$this->dataCache->clear(self::CACHE_KEY);
}
}

View File

@@ -0,0 +1,127 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\AdminNotifications\Jobs;
use Espo\Core\Job\JobDataLess;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Entities\Extension;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Tools\AdminNotifications\LatestReleaseDataRequester;
/**
* Checking for new extension versions.
*/
class CheckNewExtensionVersion implements JobDataLess
{
public function __construct(
private Config $config,
private ConfigWriter $configWriter,
private EntityManager $entityManager,
private LatestReleaseDataRequester $requester
) {}
public function run(): void
{
if (
!$this->config->get('adminNotifications') ||
!$this->config->get('adminNotificationsNewExtensionVersion')
) {
return;
}
$query = $this->entityManager
->getQueryBuilder()
->select()
->from(Extension::ENTITY_TYPE)
->select([Attribute::ID, Field::NAME, 'version', 'checkVersionUrl'])
->where([
Attribute::DELETED => false,
'isInstalled' => true,
])
->order([Field::CREATED_AT])
->build();
$sth = $this->entityManager->getQueryExecutor()->execute($query);
$latestReleases = [];
while ($row = $sth->fetch()) {
$url = !empty($row['checkVersionUrl']) ? $row['checkVersionUrl'] : null;
$extensionName = $row['name'];
$latestRelease = $this->requester->request($url, [
'name' => $extensionName,
]);
if (!empty($latestRelease) && !isset($latestRelease['error'])) {
$latestReleases[$extensionName] = $latestRelease;
}
}
$latestExtensionVersions = $this->config->get('latestExtensionVersions', []);
$save = false;
foreach ($latestReleases as $extensionName => $extensionData) {
if (empty($latestExtensionVersions[$extensionName])) {
$latestExtensionVersions[$extensionName] = $extensionData['version'];
$save = true;
continue;
}
if ($latestExtensionVersions[$extensionName] != $extensionData['version']) {
$latestExtensionVersions[$extensionName] = $extensionData['version'];
/*if (!empty($extensionData['notes'])) {
//todo: create notification
}*/
$save = true;
//continue;
}
/*if (!empty($extensionData['notes'])) {
//todo: find and modify notification
}*/
}
if ($save) {
$this->configWriter->set('latestExtensionVersions', $latestExtensionVersions);
$this->configWriter->save();
}
}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\AdminNotifications\Jobs;
use Espo\Core\Job\JobDataLess;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Tools\AdminNotifications\LatestReleaseDataRequester;
/**
* Checking for a new EspoCRM version.
*/
class CheckNewVersion implements JobDataLess
{
public function __construct(
private Config $config,
private ConfigWriter $configWriter,
private LatestReleaseDataRequester $requester
) {}
public function run(): void
{
if (
!$this->config->get('adminNotifications') ||
!$this->config->get('adminNotificationsNewVersion')
) {
return;
}
$latestRelease = $this->requester->request();
if ($latestRelease === null) {
return;
}
if (empty($latestRelease['version'])) {
// @todo Check the logic. WTF?
$this->configWriter->set('latestVersion', $latestRelease['version']);
$this->configWriter->save();
return;
}
if ($this->config->get('latestVersion') != $latestRelease['version']) {
$this->configWriter->set('latestVersion', $latestRelease['version']);
/*if (!empty($latestRelease['notes'])) {
// @todo Create a notification.
}*/
$this->configWriter->save();
return;
}
/*if (!empty($latestRelease['notes'])) {
// @todo Find and modify notification.
}*/
}
}

View File

@@ -0,0 +1,85 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\AdminNotifications;
class LatestReleaseDataRequester
{
/**
* @param array<string, mixed> $requestData
* @return ?array<int|string, mixed>
*/
public function request(
?string $url = null,
array $requestData = [],
string $urlPath = 'release/latest'
): ?array {
if (!function_exists('curl_version')) {
return null;
}
$ch = curl_init();
$requestUrl = $url ? trim($url) : base64_decode('aHR0cHM6Ly9zLmVzcG9jcm0uY29tLw==');
$requestUrl = str_ends_with($requestUrl, '/') ? $requestUrl : $requestUrl . '/';
$requestUrl .= empty($requestData) ?
$urlPath . '/' :
$urlPath . '/?' . http_build_query($requestData);
curl_setopt($ch, CURLOPT_URL, $requestUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 60);
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS | CURLPROTO_HTTP);
/** @var string|false $result */
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($result === false) {
return null;
}
if ($httpCode !== 200) {
return null;
}
$data = json_decode($result, true);
if (!is_array($data)) {
return null;
}
return $data;
}
}

View File

@@ -0,0 +1,278 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\AdminNotifications;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\ScheduledJob;
use Espo\Core\Utils\Util;
use Espo\Entities\Extension;
use Espo\ORM\EntityManager;
/**
* Notifications on the admin panel.
*/
class Manager
{
public function __construct(
private EntityManager $entityManager,
private Config $config,
private Language $language,
private ScheduledJob $scheduledJob,
private Config\SystemConfig $systemConfig,
) {}
/**
* @return array<int, array{id: string, type: string, message: string}>
*/
public function getNotificationList(): array
{
$notificationList = [];
if (!$this->config->get('adminNotifications')) {
return [];
}
if (!$this->systemConfig->useCache()) {
$notificationList[] = [
'id' => 'cacheIsDisabled',
'type' => 'cacheIsDisabled',
'message' => $this->language->translateLabel('cacheIsDisabled', 'messages', 'Admin'),
];
}
if ($this->config->get('adminNotificationsCronIsNotConfigured')) {
if ($this->config->get('cronDisabled')) {
$notificationList[] = [
'id' => 'cronIsDisabled',
'type' => 'cronIsDisabled',
'message' => $this->language->translateLabel('cronIsDisabled', 'messages', 'Admin'),
];
}
if (!$this->isCronConfigured()) {
$notificationList[] = [
'id' => 'cronIsNotConfigured',
'type' => 'cronIsNotConfigured',
'message' => $this->language->translateLabel('cronIsNotConfigured', 'messages', 'Admin'),
];
}
}
if ($this->config->get('adminNotificationsNewVersion')) {
$instanceNeedingUpgrade = $this->getInstanceNeedingUpgrade();
if (!empty($instanceNeedingUpgrade)) {
$message = $this->language->translateLabel('newVersionIsAvailable', 'messages', 'Admin');
$notificationList[] = [
'id' => 'newVersionIsAvailable',
'type' => 'newVersionIsAvailable',
'message' => $this->prepareMessage($message, $instanceNeedingUpgrade),
];
}
}
if ($this->config->get('adminNotificationsNewExtensionVersion')) {
$extensionsNeedingUpgrade = $this->getExtensionsNeedingUpgrade();
foreach ($extensionsNeedingUpgrade as $extensionName => $extensionDetails) {
$label = 'new' . Util::toCamelCase($extensionName, ' ', true) . 'VersionIsAvailable';
$message = $this->language->get(['Admin', 'messages', $label]);
if (!$message) {
$message = $this->language
->translate('newExtensionVersionIsAvailable', 'messages', 'Admin');
}
$notificationList[] = [
'id' => 'newExtensionVersionIsAvailable' . Util::toCamelCase($extensionName, ' ', true),
'type' => 'newExtensionVersionIsAvailable',
'message' => $this->prepareMessage($message, $extensionDetails)
];
}
}
if (!$this->config->get('adminNotificationsExtensionLicenseDisabled')) {
$notificationList = array_merge(
$notificationList,
$this->getExtensionLicenseNotificationList()
);
}
return $notificationList;
}
private function isCronConfigured(): bool
{
return $this->scheduledJob->isCronConfigured();
}
/**
* @return ?array{currentVersion:string,latestVersion:string}
*/
private function getInstanceNeedingUpgrade(): ?array
{
$latestVersion = $this->config->get('latestVersion');
if (!isset($latestVersion)) {
return null;
}
$currentVersion = $this->systemConfig->getVersion();
if ($currentVersion === 'dev') {
return null;
}
if (version_compare($latestVersion, $currentVersion, '>')) {
return [
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
];
}
return null;
}
/**
*
* @return array<string, array{currentVersion: string, latestVersion: string, extensionName: string}>
*/
private function getExtensionsNeedingUpgrade(): array
{
$extensions = [];
$latestExtensionVersions = $this->config->get('latestExtensionVersions');
if (empty($latestExtensionVersions) || !is_array($latestExtensionVersions)) {
return [];
}
foreach ($latestExtensionVersions as $extensionName => $extensionLatestVersion) {
$currentVersion = $this->getExtensionLatestInstalledVersion($extensionName);
if (isset($currentVersion) && version_compare($extensionLatestVersion, $currentVersion, '>')) {
$extensions[$extensionName] = [
'currentVersion' => $currentVersion,
'latestVersion' => $extensionLatestVersion,
'extensionName' => $extensionName,
];
}
}
return $extensions;
}
private function getExtensionLatestInstalledVersion(string $extensionName): ?string
{
$extension = $this->entityManager
->getRDBRepository(Extension::ENTITY_TYPE)
->select(['version'])
->where([
'name' => $extensionName,
'isInstalled' => true,
])
->order(Field::CREATED_AT, true)
->findOne();
if (!$extension) {
return null;
}
return $extension->get('version');
}
/**
* @param array<string, string> $data
*/
private function prepareMessage(string $message, array $data = []): string
{
foreach ($data as $name => $value) {
$message = str_replace('{'.$name.'}', $value, $message);
}
return $message;
}
/**
* @return array<int, array{id: string, type: string, message: string}>
*/
private function getExtensionLicenseNotificationList(): array
{
$extensionList = $this->entityManager
->getRDBRepositoryByClass(Extension::class)
->where([
'licenseStatus' => [
Extension::LICENSE_STATUS_INVALID,
Extension::LICENSE_STATUS_EXPIRED,
Extension::LICENSE_STATUS_SOFT_EXPIRED,
],
])
->find();
$list = [];
foreach ($extensionList as $extension) {
$message =
$extension->getLicenseStatusMessage() ??
$this->getExtensionLicenseMessageLabel($extension);
if (!$message) {
continue;
}
$message = $this->language->translateLabel($message, 'messages');
$name = $extension->getName();
$list[] = [
'id' => 'newExtensionVersionIsAvailable' . Util::toCamelCase($name, ' ', true),
'type' => 'newExtensionVersionIsAvailable',
'message' => $this->prepareMessage($message, ['name' => $name]),
];
}
return $list;
}
private function getExtensionLicenseMessageLabel(Extension $extension): ?string
{
$status = $extension->getLicenseStatus();
if (!$status) {
return null;
}
return 'extensionLicense' . ucfirst(Util::hyphenToCamelCase($status));
}
}

View File

@@ -0,0 +1,88 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Api\Cors;
use Espo\Core\Utils\Config;
use Psr\Http\Message\RequestInterface as Request;
class DefaultHelper implements Helper
{
public function __construct(private Config $config) {}
public function isCredentialsAllowed(Request $request): bool
{
return true;
}
public function getAllowedOrigin(Request $request): ?string
{
$origin = $request->getHeaderLine('Origin');
if (!$origin) {
return null;
}
return in_array($origin, $this->getAllowedOrigins()) ?
$origin :
null;
}
public function getAllowedMethods(Request $request): array
{
return $this->config->get('apiCorsAllowedMethodList') ?? [];
}
public function getAllowedHeaders(Request $request): array
{
if (!$request->hasHeader('Access-Control-Request-Headers')) {
return [];
}
return $this->config->get('apiCorsAllowedHeaderList') ?? [];
}
public function getSuccessStatus(): ?int
{
return null;
}
public function getMaxAge(): ?int
{
return $this->config->get('apiCorsMaxAge');
}
/**
* @return string[]
*/
private function getAllowedOrigins(): array
{
return $this->config->get('apiCorsAllowedOriginList') ?? [];
}
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Api\Cors;
use Psr\Http\Message\RequestInterface as Request;
interface Helper
{
public function isCredentialsAllowed(Request $request): bool;
public function getAllowedOrigin(Request $request): ?string;
/**
* @return string[]
*/
public function getAllowedMethods(Request $request): array;
/**
* @return string[]
*/
public function getAllowedHeaders(Request $request): array;
public function getSuccessStatus(): ?int;
public function getMaxAge(): ?int;
}

View File

@@ -0,0 +1,89 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Api\Cors;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Psr7\Factory\ResponseFactory;
class Middleware implements MiddlewareInterface
{
private const DEFAULT_SUCCESS_STATUS = 204;
private const DEFAULT_MAX_AGE = 86400;
public function __construct(private Helper $helper)
{}
public function process(ServerRequest $request, RequestHandler $handler): Response
{
$isPreFlight = $request->getMethod() === RequestMethod::METHOD_OPTIONS;
$response = $isPreFlight ?
(new ResponseFactory)->createResponse() :
$handler->handle($request);
$allowedOrigin = $this->helper->getAllowedOrigin($request);
if (!$allowedOrigin) {
return $response;
}
$status = $this->helper->getSuccessStatus() ?? self::DEFAULT_SUCCESS_STATUS;
$allowedMethods = $this->helper->getAllowedMethods($request);
$allowedHeaders = $this->helper->getAllowedHeaders($request);
$maxAge = $this->helper->getMaxAge() ?? self::DEFAULT_MAX_AGE;
$credentialsAllowed = $this->helper->isCredentialsAllowed($request);
$response = $response
->withHeader('Access-Control-Allow-Origin', $allowedOrigin)
->withHeader('Access-Control-Max-Age', (string) $maxAge);
if ($credentialsAllowed) {
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
}
if (!$isPreFlight) {
return $response;
}
if ($allowedMethods !== []) {
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $allowedMethods));
}
if ($allowedHeaders !== []) {
$response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', $allowedHeaders));
}
return $response->withStatus($status);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Resource\FileReader;
/**
* @noinspection PhpUnused
*/
class GetAbout implements Action
{
public function __construct(
private FileReader $fileReader,
private Config\SystemConfig $systemConfig,
) {}
public function process(Request $request): Response
{
$text = $this->fileReader->read('texts/about.md', FileReader\Params::create());
return ResponseComposer::json([
'text' => $text,
'version' => $this->systemConfig->getVersion(),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\InjectableFactory;
use Espo\Tools\App\AppService as Service;
/**
* Gets user data.
*/
class GetUser implements Action
{
public function __construct(private InjectableFactory $injectableFactory) {}
public function process(Request $request): Response
{
$data = $this->injectableFactory
->create(Service::class)
->getUserData();
return ResponseComposer::json($data);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Authentication\AuthenticationFactory;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Json;
class PostDestroyAuthToken implements Action
{
public function __construct(private AuthenticationFactory $authenticationFactory) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$token = $data->token ?? null;
if (!$token || !is_string($token)) {
throw new BadRequest("No `token`.");
}
$authentication = $this->authenticationFactory->create();
$response = ResponseComposer::empty();
try {
$authentication->destroyAuthToken($token, $request, $response);
} catch (NotFound) {
return $response->writeBody(Json::encode(false));
}
return $response->writeBody(Json::encode(true));
}
}

View File

@@ -0,0 +1,40 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App;
/**
* App parameter to be passed to the frontend.
*
* @see https://docs.espocrm.com/development/app-params/
*/
interface AppParam
{
public function get(): mixed;
}

View File

@@ -0,0 +1,515 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App;
use Espo\Core\Authentication\Util\MethodProvider as AuthenticationMethodProvider;
use Espo\Core\Mail\ConfigDataProvider as EmailConfigDataProvider;
use Espo\Core\Name\Field;
use Espo\Core\Name\Link;
use Espo\Core\Utils\SystemUser;
use Espo\Entities\DashboardTemplate;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailAddress;
use Espo\Entities\InboundEmail;
use Espo\Entities\Settings;
use Espo\ORM\Name\Attribute;
use Espo\Core\Acl;
use Espo\Core\Authentication\Logins\Espo;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Entities\Preferences;
use Espo\ORM\EntityManager;
use stdClass;
use Throwable;
class AppService
{
/** @var string[] */
private array $forbiddenUserAttributeList = [
'apiKey',
'authTokenId',
'password',
'rolesIds',
'rolesNames',
];
/** @var string[] */
private array $allowedUserAttributeList = [
'type',
];
/** @var string[] */
private array $allowedInternalUserAttributeList = [
'teamsIds',
'defaultTeamId',
'defaultTeamName',
];
/** @var string[] */
private array $allowedPortalUserAttributeList = [
'contactId',
'contactName',
'accountId',
'accountsIds',
];
public function __construct(
private Config $config,
private EntityManager $entityManager,
private Metadata $metadata,
private Acl $acl,
private InjectableFactory $injectableFactory,
private SettingsService $settingsService,
private User $user,
private Preferences $preferences,
private FieldUtil $fieldUtil,
private Log $log,
private AuthenticationMethodProvider $authenticationMethodProvider,
private SystemUser $systemUser,
private EmailConfigDataProvider $emailConfigDataProvider,
) {}
/**
* @return array<string, mixed>
*/
public function getUserData(): array
{
$preferencesData = $this->preferences->getValueMap();
$this->filterPreferencesData($preferencesData);
$user = $this->user;
if (!$user->has('teamsIds')) {
$user->loadLinkMultipleField(Field::TEAMS);
}
if ($user->isPortal()) {
$user->loadAccountField();
$user->loadLinkMultipleField('accounts');
}
$settings = $this->settingsService->getConfigData();
$dashboardTemplateId = $user->get('dashboardTemplateId');
if ($dashboardTemplateId) {
$dashboardTemplate = $this->entityManager
->getEntityById(DashboardTemplate::ENTITY_TYPE, $dashboardTemplateId);
if ($dashboardTemplate) {
$settings->forcedDashletsOptions = $dashboardTemplate->get('dashletsOptions') ?? (object) [];
$settings->forcedDashboardLayout = $dashboardTemplate->get('layout') ?? [];
}
}
$language = Language::detectLanguage($this->config, $this->preferences);
return [
'user' => $this->getUserDataForFrontend(),
'acl' => $this->getAclDataForFrontend(),
'preferences' => $preferencesData,
'token' => $this->user->get('token'),
'settings' => $settings,
'language' => $language,
'appParams' => $this->getAppParams(),
];
}
/**
* @return array<string, mixed>
*/
private function getAppParams(): array
{
$user = $this->user;
$auth2FARequired =
$user->isRegular() &&
$this->config->get('auth2FA') &&
$this->config->get('auth2FAForced') &&
!$user->get('auth2FA');
$authenticationMethod = $this->authenticationMethodProvider->get();
$passwordChangeForNonAdminDisabled = $authenticationMethod !== Espo::NAME;
$logoutWait = (bool) $this->metadata->get(['authenticationMethods', $authenticationMethod, 'logoutClassName']);
$timeZoneList = $this->metadata
->get(['entityDefs', Settings::ENTITY_TYPE, 'fields', 'timeZone', 'options']) ?? [];
$appParams = [
'maxUploadSize' => $this->getMaxUploadSize() / 1024.0 / 1024.0,
'isRestrictedMode' => $this->config->get('restrictedMode'),
'passwordChangeForNonAdminDisabled' => $passwordChangeForNonAdminDisabled,
'timeZoneList' => $timeZoneList,
'auth2FARequired' => $auth2FARequired,
'logoutWait' => $logoutWait,
'systemUserId' => $this->systemUser->getId(),
];
/** @var array<string, array<string, mixed>> $map */
$map = $this->metadata->get(['app', 'appParams']) ?? [];
foreach ($map as $paramKey => $item) {
/** @var ?class-string<AppParam> $className */
$className = $item['className'] ?? null;
if (!$className) {
continue;
}
try {
/** @var AppParam $obj */
$obj = $this->injectableFactory->create($className);
$itemParams = $obj->get();
} catch (Throwable $e) {
$this->log->error("AppParam $paramKey: " . $e->getMessage(), ['exception' => $e]);
continue;
}
$appParams[$paramKey] = $itemParams;
}
return $appParams;
}
private function getUserDataForFrontend(): stdClass
{
$user = $this->user;
$data = $user->getValueMap();
$emailAddressData = $this->getEmailAddressData();
$data->emailAddressList = $emailAddressData['emailAddressList'];
$data->userEmailAddressList = $emailAddressData['userEmailAddressList'];
$data->excludeFromReplyEmailAddressList = $emailAddressData['excludeFromReplyEmailAddressList'];
foreach ($this->forbiddenUserAttributeList as $attribute) {
unset($data->$attribute);
}
$forbiddenAttributeList = $this->acl->getScopeForbiddenAttributeList(User::ENTITY_TYPE);
$isPortal = $user->isPortal();
foreach ($forbiddenAttributeList as $attribute) {
if (in_array($attribute, $this->allowedUserAttributeList)) {
continue;
}
if ($isPortal && in_array($attribute, $this->allowedPortalUserAttributeList)) {
continue;
}
if (!$isPortal && in_array($attribute, $this->allowedInternalUserAttributeList)) {
continue;
}
unset($data->$attribute);
}
return $data;
}
private function getAclDataForFrontend(): stdClass
{
$data = $this->acl->getMapData();
if (!$this->user->isAdmin()) {
$data = unserialize(serialize($data));
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['scopes'], []));
foreach ($scopeList as $scope) {
if (!$this->acl->check($scope)) {
unset($data->table->$scope);
unset($data->fieldTable->$scope);
unset($data->fieldTableQuickAccess->$scope);
}
}
}
return $data;
}
/**
* @return array{
* emailAddressList: string[],
* userEmailAddressList: string[],
* excludeFromReplyEmailAddressList: string[],
* }
*/
private function getEmailAddressData(): array
{
$user = $this->user;
$systemIsShared = $this->emailConfigDataProvider->isSystemOutboundAddressShared();
$systemAddress = $this->emailConfigDataProvider->getSystemOutboundAddress();
$addressList = [];
$userAddressList = [];
/** @var iterable<EmailAddress> $emailAddresses */
$emailAddresses = $this->entityManager
->getRelation($user, Link::EMAIL_ADDRESSES)
->find();
foreach ($emailAddresses as $emailAddress) {
if ($emailAddress->isInvalid()) {
continue;
}
$userAddressList[] = $emailAddress->getAddress();
if ($user->getEmailAddress() === $emailAddress->getAddress()) {
continue;
}
$addressList[] = $emailAddress->getAddress();
}
if ($user->getEmailAddress()) {
array_unshift($addressList, $user->getEmailAddress());
}
if (!$systemIsShared) {
$addressList = $this->filterUserEmailAddressList($user, $addressList);
}
$addressList = array_merge($addressList, $this->getUserGroupEmailAddressList($user));
if ($systemIsShared && $systemAddress) {
$addressList[] = $systemAddress;
}
$addressList = array_values(array_unique($addressList));
return [
'emailAddressList' => $addressList,
'userEmailAddressList' => $userAddressList,
'excludeFromReplyEmailAddressList' => $this->getExcludeFromReplyAddressList(),
];
}
/**
* @param string[] $emailAddressList
* @return string[]
*/
private function filterUserEmailAddressList(User $user, array $emailAddressList): array
{
$emailAccountCollection = $this->entityManager
->getRDBRepositoryByClass(EmailAccount::class)
->select([
Attribute::ID,
Field::EMAIL_ADDRESS,
])
->where([
'assignedUserId' => $user->getId(),
'useSmtp' => true,
'status' => EmailAccount::STATUS_ACTIVE,
])
->find();
$inAccountList = array_map(
fn (EmailAccount $e) => $e->getEmailAddress(),
[...$emailAccountCollection]
);
return array_values(array_filter(
$emailAddressList,
fn (string $item) => in_array($item, $inAccountList)
));
}
/**
* @return string[]
*/
private function getUserGroupEmailAddressList(User $user): array
{
$groupEmailAccountPermission = $this->acl->getPermissionLevel(Acl\Permission::GROUP_EMAIL_ACCOUNT);
if (!$groupEmailAccountPermission || $groupEmailAccountPermission === Acl\Table::LEVEL_NO) {
return [];
}
if ($groupEmailAccountPermission === Acl\Table::LEVEL_TEAM) {
$teamIdList = $user->getLinkMultipleIdList(Field::TEAMS);
if (!count($teamIdList)) {
return [];
}
$inboundEmailList = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
'teamsMiddle.teamId' => $teamIdList,
])
->join(Field::TEAMS)
->distinct()
->find();
$list = [];
foreach ($inboundEmailList as $inboundEmail) {
if (!$inboundEmail->getEmailAddress()) {
continue;
}
$list[] = $inboundEmail->getEmailAddress();
}
return $list;
}
if ($groupEmailAccountPermission === Acl\Table::LEVEL_ALL) {
$inboundEmailList = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpIsShared' => true,
])
->find();
$list = [];
foreach ($inboundEmailList as $inboundEmail) {
if (!$inboundEmail->getEmailAddress()) {
continue;
}
$list[] = $inboundEmail->getEmailAddress();
}
return $list;
}
return [];
}
/**
* @return int
*/
private function getMaxUploadSize()
{
$maxSize = 0;
$postMaxSize = $this->convertPHPSizeToBytes(ini_get('post_max_size'));
if ($postMaxSize > 0) {
$maxSize = $postMaxSize;
}
return $maxSize;
}
/**
* @param string|false $size
* @return int
*/
private function convertPHPSizeToBytes($size)
{
if (is_numeric($size)) {
return (int) $size;
}
if ($size === false) {
return 0;
}
$suffix = strtoupper(substr($size, -1));
$value = (int) substr($size, 0, -1);
if ($suffix == 'P') {
$value *= pow(1024, 5);
} else if ($suffix == 'T') {
$value *= pow(1024, 4);
} else if ($suffix == 'G') {
$value *= pow(1024, 3);
} else if ($suffix == 'M') {
$value *= pow(1024, 2);
} elseif ($suffix == 'K') {
$value *= 1024;
}
return $value;
}
private function filterPreferencesData(stdClass $data): void
{
$passwordFieldList = $this->fieldUtil->getFieldByTypeList(Preferences::ENTITY_TYPE, 'password');
foreach ($passwordFieldList as $field) {
unset($data->$field);
}
}
/**
* @return string[]
*/
private function getExcludeFromReplyAddressList(): array
{
if (!$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
return [];
}
/** @var iterable<InboundEmail> $accounts */
$accounts = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->select('emailAddress')
->where(['excludeFromReply' => true])
->find();
$list = [];
foreach ($accounts as $account) {
if (!$account->getEmailAddress()) {
continue;
}
$list[] = $account->getEmailAddress();
}
return $list;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Jobs;
use Espo\Core\DataManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\JobDataLess;
class ClearCache implements JobDataLess
{
private DataManager $dataManager;
public function __construct(DataManager $dataManager)
{
$this->dataManager = $dataManager;
}
/**
* @throws Error
*/
public function run(): void
{
$this->dataManager->clearCache();
}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Jobs;
use Espo\Core\DataManager;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\JobDataLess;
class Rebuild implements JobDataLess
{
private DataManager $dataManager;
public function __construct(DataManager $dataManager)
{
$this->dataManager = $dataManager;
}
/**
* @throws Error
*/
public function run(): void
{
$this->dataManager->rebuild();
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Language;
class AclDependencyItem
{
/**
* @param ?string[] $anyScopeList
*/
public function __construct(
private string $target,
private ?array $anyScopeList,
private ?string $scope,
private ?string $field
) {}
/**
* A language path to be allowed if a user has access to a specific scope/field.
*/
public function getTarget(): string
{
return $this->target;
}
/**
* @return ?string[]
*/
public function getAnyScopeList(): ?array
{
return $this->anyScopeList;
}
public function getScope(): ?string
{
return $this->scope;
}
public function getField(): ?string
{
return $this->field;
}
}

View File

@@ -0,0 +1,216 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Language;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\SystemConfig;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
class AclDependencyProvider
{
private const CACHE_KEY = 'languageAclDependency';
/** @var string[] */
private array $enumFieldTypeList = [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
];
/** @var ?AclDependencyItem[] */
private ?array $data = null;
private bool $useCache;
public function __construct(
private DataCache $dataCache,
private Metadata $metadata,
private Defs $ormDefs,
SystemConfig $systemConfig,
) {
$this->useCache = $systemConfig->useCache();
}
/**
* @return AclDependencyItem[]
*/
public function get(): array
{
if ($this->data === null) {
$this->data = $this->loadData();
}
return $this->data;
}
/**
* @return AclDependencyItem[]
*/
private function loadData(): array
{
if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) {
/** @var array<string, mixed>[] $raw */
$raw = $this->dataCache->get(self::CACHE_KEY);
return $this->buildFromRaw($raw);
}
return $this->buildData();
}
/**
* @return AclDependencyItem[]
*/
private function buildData(): array
{
$data = [];
foreach (($this->metadata->get(['app', 'language', 'aclDependencies']) ?? []) as $target => $item) {
$anyScopeList = $item['anyScopeList'] ?? null;
$scope = $item['scope'] ?? null;
$field = $item['field'] ?? null;
$data[] = [
'target' => $target,
'anyScopeList' => $anyScopeList,
'scope' => $scope,
'field' => $field,
];
}
foreach ($this->ormDefs->getEntityList() as $entityDefs) {
if (!$this->metadata->get(['scopes', $entityDefs->getName(), 'object'])) {
continue;
}
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$item = $this->getDataFromField($entityDefs->getName(), $fieldDefs);
if ($item) {
$data[] = $item;
}
}
}
if ($this->useCache) {
$this->dataCache->store(self::CACHE_KEY, $data);
}
return $this->buildFromRaw($data);
}
/**
* @return ?array<string, mixed>
*/
private function getDataFromField(string $entityType, Defs\FieldDefs $fieldDefs): ?array
{
if ($fieldDefs->getType() === FieldType::FOREIGN) {
$refEntityType = $fieldDefs->getParam('link') ?
$this->ormDefs
->getEntity($entityType)
->tryGetRelation($fieldDefs->getParam('link'))
?->tryGetForeignEntityType() :
null;
$refField = $fieldDefs->getParam('field');
if (!$refEntityType || !$refField) {
return null;
}
$foreignFieldType = $this->ormDefs
->tryGetEntity($refEntityType)
?->tryGetField($refField)
?->getType();
if (
!in_array($foreignFieldType, [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
])
) {
return null;
}
return [
'target' => "$refEntityType.options.$refField",
'anyScopeList' => null,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
if (!in_array($fieldDefs->getType(), $this->enumFieldTypeList)) {
return null;
}
$optionsReference = $fieldDefs->getParam('optionsReference');
if (!$optionsReference || !str_contains($optionsReference, '.')) {
return null;
}
[$refEntityType, $refField] = explode('.', $optionsReference);
$target = "$refEntityType.options.$refField";
return [
'target' => $target,
'anyScopeList' => null,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
/**
* @param array<string, mixed>[] $raw
* @return AclDependencyItem[]
*/
private function buildFromRaw(array $raw): array
{
$list = [];
foreach ($raw as $rawItem) {
$target = $rawItem['target'] ?? null;
$anyScopeList = $rawItem['anyScopeList'] ?? null;
$scope = $rawItem['scope'] ?? null;
$field = $rawItem['field'] ?? null;
$list[] = new AclDependencyItem($target, $anyScopeList, $scope, $field);
}
return $list;
}
}

View File

@@ -0,0 +1,272 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App;
use Espo\Core\Utils\Language as LanguageUtil;
use Espo\Core\Acl;
use Espo\Core\Container;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Tools\App\Language\AclDependencyProvider;
class LanguageService
{
public function __construct(
private Metadata $metadata,
private Acl $acl,
private User $user,
private AclDependencyProvider $aclDependencyProvider,
private Container $container
) {}
// @todo Use proxy.
protected function getDefaultLanguage(): LanguageUtil
{
/** @var LanguageUtil */
return $this->container->get('defaultLanguage');
}
protected function getLanguage(): LanguageUtil
{
/** @var LanguageUtil */
return $this->container->get('language');
}
/**
* @return array<string, mixed>
*/
public function getDataForFrontendFromLanguage(LanguageUtil $language): array
{
$data = $language->getAll();
if ($this->user->isSystem()) {
unset($data['Global']['scopeNames']);
unset($data['Global']['scopeNamesPlural']);
unset($data['Global']['dashlets']);
unset($data['Global']['links']);
foreach ($data as $k => $item) {
if (
in_array($k, ['Global', 'User', 'Campaign']) ||
$this->metadata->get(['scopes', $k, 'languageIsGlobal'])
) {
continue;
}
unset($data[$k]);
}
unset($data['User']['fields']);
unset($data['User']['links']);
unset($data['User']['options']);
unset($data['User']['filters']);
unset($data['User']['presetFilters']);
unset($data['User']['boolFilters']);
unset($data['User']['tooltips']);
unset($data['Campaign']['fields']);
unset($data['Campaign']['links']);
unset($data['Campaign']['options']);
unset($data['Campaign']['tooltips']);
unset($data['Campaign']['presetFilters']);
} else if (!$this->user->isAdmin()) {
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['scopes'], []));
foreach ($scopeList as $scope) {
if (!$this->metadata->get(['scopes', $scope, 'entity'])) {
continue;
}
if ($this->metadata->get(['scopes', $scope, 'languageAclDisabled'])) {
continue;
}
if (!$this->acl->tryCheck($scope)) {
unset($data[$scope]);
unset($data['Global']['scopeNames'][$scope]);
unset($data['Global']['scopeNamesPlural'][$scope]);
} else {
if (in_array($scope, ['EmailAccount', 'InboundEmail'])) {
continue;
}
foreach ($this->acl->getScopeForbiddenFieldList($scope) as $field) {
if (isset($data[$scope]['fields'])) {
unset($data[$scope]['fields'][$field]);
}
if (isset($data[$scope]['options'])) {
unset($data[$scope]['options'][$field]);
}
if (isset($data[$scope]['links'])) {
unset($data[$scope]['links'][$field]);
}
}
$this->unsetEmpty($data, $scope);
}
}
if (!$this->user->isAdmin()) {
$this->prepareDataNonAdmin($data, $language);
}
}
$data['User']['fields'] = $data['User']['fields'] ?? [];
$data['User']['fields']['password'] = $language->translate('password', 'fields', 'User');
$data['User']['fields']['passwordConfirm'] = $language->translate('passwordConfirm', 'fields', 'User');
$data['User']['fields']['newPassword'] = $language->translate('newPassword', 'fields', 'User');
$data['User']['fields']['newPasswordConfirm'] = $language->translate('newPasswordConfirm', 'fields', 'User');
return $data;
}
/**
* @return array<string, mixed>
*/
public function getDataForFrontend(bool $default = false): array
{
if ($default) {
$languageObj = $this->getDefaultLanguage();
} else {
$languageObj = $this->getLanguage();
}
return $this->getDataForFrontendFromLanguage($languageObj);
}
/**
* @param array<string, mixed> $data
*/
private function unsetEmpty(array &$data, string $scope): void
{
if (($data[$scope]['options'] ?? null) === []) {
unset($data[$scope]['options']);
}
if (($data[$scope]['fields'] ?? null) === []) {
unset($data[$scope]['fields']);
}
if (($data[$scope]['links'] ?? null) === []) {
unset($data[$scope]['links']);
}
}
/**
* @param array<string, mixed> $data
*/
private function prepareDataNonAdmin(array &$data, LanguageUtil $languageObj): void
{
unset($data['Admin']);
unset($data['LayoutManager']);
unset($data['EntityManager']);
unset($data['FieldManager']);
unset($data['Settings']);
unset($data['ApiUser']);
unset($data['DynamicLogic']);
$data['Settings'] = [
'options' => [
'auth2FAMethodList' => $languageObj->get(['Settings', 'options', 'auth2FAMethodList']),
],
];
$data['Admin'] = [
'messages' => [
'userHasNoEmailAddress' => $languageObj->translate('userHasNoEmailAddress', 'messages', 'Admin'),
],
];
foreach ($this->aclDependencyProvider->get() as $dependencyItem) {
$target = $dependencyItem->getTarget();
$aclScope = $dependencyItem->getScope();
$aclField = $dependencyItem->getField();
$anyScopeList = $dependencyItem->getAnyScopeList();
$targetArr = explode('.', $target);
$isFullScope = !str_contains($target, '.');
if ($isFullScope && isset($data[$target])) {
continue;
}
if ($anyScopeList) {
$skip = true;
foreach ($anyScopeList as $itemScope) {
if ($this->acl->tryCheck($itemScope)) {
$skip = false;
break;
}
}
if ($skip) {
continue;
}
}
if ($aclScope) {
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if ($aclField && in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))) {
continue;
}
}
$pointer =& $data;
foreach ($targetArr as $i => $k) {
if ($i === count($targetArr) - 1) {
$pointer[$k] = $languageObj->get($targetArr);
break;
}
if (!isset($pointer[$k])) {
$pointer[$k] = [];
}
$pointer =& $pointer[$k];
}
if ($isFullScope) {
$this->unsetEmpty($data, $target);
}
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Metadata;
class AclDependencyItem
{
/**
* @param ?string[] $anyScopeList
*/
public function __construct(
private string $target,
private ?string $scope,
private ?string $field,
private ?array $anyScopeList = null,
) {}
/**
* A metadata path to be allowed if a user has access to a specific scope/field.
*/
public function getTarget(): string
{
return $this->target;
}
public function getScope(): ?string
{
return $this->scope;
}
public function getField(): ?string
{
return $this->field;
}
/**
* @return ?string[]
* @since 9.2.5
*/
public function getAnyScopeList(): ?array
{
return $this->anyScopeList;
}
}

View File

@@ -0,0 +1,209 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App\Metadata;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
class AclDependencyProvider
{
private const CACHE_KEY = 'metadataAclDependency';
/** @var string[] */
private array $enumFieldTypeList = [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
];
/** @var ?AclDependencyItem[] */
private ?array $data = null;
private bool $useCache;
public function __construct(
private DataCache $dataCache,
private Metadata $metadata,
private Defs $ormDefs,
Config\SystemConfig $systemConfig,
) {
$this->useCache = $systemConfig->useCache();
}
/**
* @return AclDependencyItem[]
*/
public function get(): array
{
if ($this->data === null) {
$this->data = $this->loadData();
}
return $this->data;
}
/**
* @return AclDependencyItem[]
*/
private function loadData(): array
{
if ($this->useCache && $this->dataCache->has(self::CACHE_KEY)) {
/** @var array<string, mixed>[] $raw */
$raw = $this->dataCache->get(self::CACHE_KEY);
return $this->buildFromRaw($raw);
}
return $this->buildData();
}
/**
* @return AclDependencyItem[]
*/
private function buildData(): array
{
$data = [];
foreach (($this->metadata->get(['app', 'metadata', 'aclDependencies']) ?? []) as $target => $item) {
$anyScopeList = $item['anyScopeList'] ?? null;
$scope = $item['scope'] ?? null;
$field = $item['field'] ?? null;
$data[] = [
'target' => $target,
'anyScopeList' => $anyScopeList,
'scope' => $scope,
'field' => $field,
];
}
foreach ($this->ormDefs->getEntityList() as $entityDefs) {
if (!$this->metadata->get(['scopes', $entityDefs->getName(), 'object'])) {
continue;
}
foreach ($entityDefs->getFieldList() as $fieldDefs) {
$item = $this->getDataFromField($entityDefs->getName(), $fieldDefs);
if ($item) {
$data[] = $item;
}
}
}
if ($this->useCache) {
$this->dataCache->store(self::CACHE_KEY, $data);
}
return $this->buildFromRaw($data);
}
/**
* @return ?array<string, mixed>
*/
private function getDataFromField(string $entityType, Defs\FieldDefs $fieldDefs): ?array
{
if ($fieldDefs->getType() === FieldType::FOREIGN) {
$refEntityType = $fieldDefs->getParam('link') ?
$this->ormDefs
->getEntity($entityType)
->tryGetRelation($fieldDefs->getParam('link'))
?->tryGetForeignEntityType() :
null;
$refField = $fieldDefs->getParam('field');
if (!$refEntityType || !$refField) {
return null;
}
return [
'target' => "entityDefs.$refEntityType.fields.$refField",
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
if (!in_array($fieldDefs->getType(), $this->enumFieldTypeList)) {
return null;
}
$optionsPath = $fieldDefs->getParam('optionsPath');
$optionsReference = $fieldDefs->getParam('optionsReference');
if (
!$optionsPath &&
$optionsReference &&
str_contains($optionsReference, '.')
) {
[$refEntityType, $refField] = explode('.', $optionsReference);
$optionsPath = "entityDefs.$refEntityType.fields.$refField.options";
}
if (!$optionsPath) {
return null;
}
return [
'target' => $optionsPath,
'scope' => $entityType,
'field' => $fieldDefs->getName(),
];
}
/**
* @param array<string, mixed>[] $raw
* @return AclDependencyItem[]
*/
private function buildFromRaw(array $raw): array
{
$list = [];
foreach ($raw as $rawItem) {
$target = $rawItem['target'] ?? null;
$scope = $rawItem['scope'] ?? null;
$field = $rawItem['field'] ?? null;
$anyScopeList = $rawItem['anyScopeList'] ?? null;
$list[] = new AclDependencyItem(
target: $target,
scope: $scope,
field: $field,
anyScopeList: $anyScopeList,
);
}
return $list;
}
}

View File

@@ -0,0 +1,296 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App;
use Espo\Core\Acl;
use Espo\Core\Utils\Metadata as MetadataUtil;
use Espo\Core\Utils\ObjectUtil;
use Espo\Core\Utils\Util;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\Tools\App\Metadata\AclDependencyProvider;
use stdClass;
class MetadataService
{
private const ANY_KEY = '__ANY__';
public function __construct(
private Acl $acl,
private MetadataUtil $metadata,
private User $user,
private AclDependencyProvider $aclDependencyProvider
) {}
public function getDataForFrontend(): stdClass
{
$data = $this->metadata->getAll();
$hiddenPathList = $this->metadata->get(['app', 'metadata', 'frontendHiddenPathList'], []);
foreach ($hiddenPathList as $row) {
$this->removeDataByPath($row, $data);
}
if ($this->user->isAdmin()) {
return $data;
}
$data = ObjectUtil::clone($data);
$hiddenPathList = $this->metadata->get(['app', 'metadata', 'frontendNonAdminHiddenPathList'], []);
foreach ($hiddenPathList as $row) {
$this->removeDataByPath($row, $data);
}
/** @var string[] $scopeList */
$scopeList = array_keys($this->metadata->get(['entityDefs'], []));
foreach ($scopeList as $scope) {
$isEntity = $this->metadata->get(['scopes', $scope, 'entity']);
if ($isEntity === false) {
continue;
}
if ($scope === Reminder::ENTITY_TYPE) {
continue;
}
$isAllowed = $isEntity !== null && $this->acl->tryCheck($scope);
if (!$isAllowed) {
unset($data->entityDefs->$scope);
unset($data->clientDefs->$scope);
unset($data->entityAcl->$scope);
unset($data->scopes->$scope);
unset($data->logicDefs->$scope);
}
}
$entityTypeList = array_keys(get_object_vars($data->entityDefs));
foreach ($entityTypeList as $entityType) {
$linksDefs = $this->metadata->get(['entityDefs', $entityType, 'links'], []);
$forbiddenFieldList = $this->acl->getScopeForbiddenFieldList($entityType);
foreach ($linksDefs as $link => $defs) {
$type = $defs['type'] ?? null;
$hasField = (bool) $this->metadata->get(['entityDefs', $entityType, 'fields', $link]);
if ($type === 'belongsToParent') {
if ($hasField) {
$parentEntityList = $this->metadata
->get(['entityDefs', $entityType, 'fields', $link, 'entityList']);
if (is_array($parentEntityList)) {
foreach ($parentEntityList as $i => $e) {
if (!$this->acl->tryCheck($e)) {
unset($parentEntityList[$i]);
}
}
$parentEntityList = array_values($parentEntityList);
$data->entityDefs->$entityType->fields->$link->entityList = $parentEntityList;
}
}
continue;
}
$foreignEntityType = $defs['entity'] ?? null;
if ($foreignEntityType) {
if ($this->acl->tryCheck($foreignEntityType)) {
continue;
}
if ($this->user->isPortal()) {
if ($foreignEntityType === 'Account' || $foreignEntityType === 'Contact') {
continue;
}
}
}
if ($hasField) {
if (!in_array($link, $forbiddenFieldList)) {
continue;
}
unset($data->entityDefs->$entityType->fields->$link);
}
unset($data->entityDefs->$entityType->links->$link);
if (isset($data->clientDefs->$entityType->relationshipPanels)) {
unset($data->clientDefs->$entityType->relationshipPanels->$link);
}
}
}
unset($data->entityDefs->Settings);
/** @var string[] $dashletList */
$dashletList = array_keys($this->metadata->get(['dashlets'], []));
foreach ($dashletList as $item) {
$aclScope = $this->metadata->get(['dashlets', $item, 'aclScope']);
if ($aclScope && !$this->acl->tryCheck($aclScope)) {
unset($data->dashlets->$item);
}
}
unset($data->authenticationMethods);
unset($data->formula);
foreach ($this->aclDependencyProvider->get() as $dependencyItem) {
$aclScope = $dependencyItem->getScope();
$aclField = $dependencyItem->getField();
$anyScopeList = $dependencyItem->getAnyScopeList();
if ($anyScopeList) {
$skip = true;
foreach ($anyScopeList as $itemScope) {
if ($this->acl->tryCheck($itemScope)) {
$skip = false;
break;
}
}
if ($skip) {
continue;
}
}
if ($aclScope) {
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if ($aclField && in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))) {
continue;
}
}
$targetArr = explode('.', $dependencyItem->getTarget());
$pointer = $data;
$value = $this->metadata->getObjects($targetArr);
if ($value === null) {
// Important.
continue;
}
foreach ($targetArr as $i => $k) {
if ($i === count($targetArr) - 1) {
$pointer->$k = $value;
break;
}
if (!isset($pointer->$k)) {
$pointer->$k = (object) [];
}
$pointer = $pointer->$k;
}
}
return $data;
}
/**
*
* @param string[] $row
* @param stdClass $data
*/
private function removeDataByPath($row, &$data): void
{
$p = &$data;
$path = [&$p];
foreach ($row as $i => $item) {
if (is_array($item)) {
break;
}
if ($item === self::ANY_KEY) {
foreach (get_object_vars($p) as &$v) {
$this->removeDataByPath(
array_slice($row, $i + 1),
$v
);
}
return;
}
if (!property_exists($p, $item)) {
break;
}
if ($i == count($row) - 1) {
unset($p->$item);
$o = &$p;
for ($j = $i - 1; $j > 0; $j--) {
if (is_object($o) && !count(get_object_vars($o))) {
$o = &$path[$j];
$k = $row[$j];
unset($o->$k);
} else {
break;
}
}
} else {
$p = &$p->$item;
$path[] = &$p;
}
}
}
public function getDataForFrontendByKey(?string $key): mixed
{
$data = $this->getDataForFrontend();
return Util::getValueByKey($data, $key);
}
}

View File

@@ -0,0 +1,232 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\App;
use Espo\Core\Name\Field;
use Espo\ORM\EntityManager;
use Espo\Repositories\Preferences as Repository;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\Utils\Config;
use stdClass;
class PreferencesService
{
private EntityManager $entityManager;
private User $user;
private Acl $acl;
private Config $config;
private FieldValidationManager $fieldValidationManager;
public function __construct(
EntityManager $entityManager,
User $user,
Acl $acl,
Config $config,
FieldValidationManager $fieldValidationManager
) {
$this->entityManager = $entityManager;
$this->user = $user;
$this->acl = $acl;
$this->config = $config;
$this->fieldValidationManager = $fieldValidationManager;
}
/**
* @throws Forbidden
*/
protected function processAccessCheck(string $userId): void
{
if (!$this->user->isAdmin()) {
if ($this->user->getId() !== $userId) {
throw new Forbidden();
}
}
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function read(string $userId): Preferences
{
$this->processAccessCheck($userId);
/** @var ?Preferences $entity */
$entity = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$entity || !$user) {
throw new NotFound();
}
$entity->set(Field::NAME, $user->getName());
$entity->set('isPortalUser', $user->isPortal());
// @todo Remove.
$entity->clear('smtpPassword');
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_READ);
foreach ($forbiddenAttributeList as $attribute) {
$entity->clear($attribute);
}
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
public function update(string $userId, stdClass $data): Preferences
{
$this->processAccessCheck($userId);
if ($this->acl->getLevel(Preferences::ENTITY_TYPE, Table::ACTION_EDIT) === Table::LEVEL_NO) {
throw new Forbidden();
}
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_EDIT);
foreach ($forbiddenAttributeList as $attribute) {
unset($data->$attribute);
}
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
/** @var ?Preferences $entity */
$entity = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$entity || !$user) {
throw new NotFound();
}
$entity->set($data);
$this->fieldValidationManager->process($entity, $data);
$this->entityManager->saveEntity($entity);
$entity->set(Field::NAME, $user->getName());
// @todo Remove.
$entity->clear('smtpPassword');
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function resetToDefaults(string $userId): void
{
$this->processAccessCheck($userId);
$result = $this->getRepository()->resetToDefaults($userId);
if (!$result) {
throw new NotFound();
}
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function resetDashboard(string $userId): stdClass
{
$this->processAccessCheck($userId);
if ($this->acl->getLevel(Preferences::ENTITY_TYPE, Table::ACTION_EDIT) === Table::LEVEL_NO) {
throw new Forbidden();
}
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$user) {
throw new NotFound();
}
if (!$preferences) {
throw new NotFound();
}
if ($user->isPortal()) {
throw new Forbidden();
}
$forbiddenAttributeList = $this->acl
->getScopeForbiddenAttributeList(Preferences::ENTITY_TYPE, Table::ACTION_EDIT);
if (in_array('dashboardLayout', $forbiddenAttributeList)) {
throw new Forbidden();
}
$dashboardLayout = $this->config->get('dashboardLayout');
$dashletsOptions = $this->config->get('dashletsOptions');
$preferences->set([
'dashboardLayout' => $dashboardLayout,
'dashletsOptions' => $dashletsOptions,
]);
$this->entityManager->saveEntity($preferences);
return (object) [
'dashboardLayout' => $preferences->get('dashboardLayout'),
'dashletsOptions' => $preferences->get('dashletsOptions'),
];
}
private function getRepository(): Repository
{
/** @var Repository */
return $this->entityManager->getRepository(Preferences::ENTITY_TYPE);
}
}

View File

@@ -0,0 +1,404 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\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 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,
) {}
/**
* 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->defaultCurrency) || isset($data->baseCurrency) || isset($data->currencyRates)) {
$this->populateDatabaseWithCurrencyRates();
}
}
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 populateDatabaseWithCurrencyRates(): void
{
$this->injectableFactory->create(CurrencyDatabasePopulator::class)->process();
}
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,72 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\AppSecret;
use Espo\Core\Utils\Crypt;
use Espo\Entities\AppSecret;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
/**
* @since 9.0.0
* @noinspection PhpUnused
*/
class SecretProvider
{
public function __construct(
private Crypt $crypt,
private EntityManager $entityManager,
) {}
/**
* Get an app secret value.
*
* @param string $name A secret name.
*/
public function get(string $name): ?string
{
$secret = $this->entityManager
->getRDBRepositoryByClass(AppSecret::class)
->where(
Condition::equal(
Expression::binary(Expression::column('name')),
$name
)
)
->findOne();
if (!$secret) {
return null;
}
return $this->crypt->decrypt($secret->getValue());
}
}

View File

@@ -0,0 +1,125 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
use Espo\Entities\Settings;
use Espo\Entities\User;
use Espo\Core\ORM\Type\FieldType;
class AccessChecker
{
/** @var string[] */
private $adminOnlyHavingInlineAttachmentsEntityTypeList = ['TemplateManager'];
/** @var string[] */
private $attachmentFieldTypeList = [
FieldType::FILE,
FieldType::IMAGE,
FieldType::ATTACHMENT_MULTIPLE,
];
/** @var string[] */
private $inlineAttachmentFieldTypeList = [
FieldType::WYSIWYG,
];
/** @var string[] */
private $allowedRoleList = [
Attachment::ROLE_ATTACHMENT,
Attachment::ROLE_INLINE_ATTACHMENT,
];
public function __construct(
private User $user,
private Acl $acl,
private Metadata $metadata
) {}
/**
* Check access to a field and role allowance.
*
* @throws Forbidden
*/
public function check(FieldData $fieldData, string $role = Attachment::ROLE_ATTACHMENT): void
{
if (!in_array($role, $this->allowedRoleList)) {
throw new Forbidden("Role not allowed.");
}
$relatedEntityType = $fieldData->getParentType() ?? $fieldData->getRelatedType();
$field = $fieldData->getField();
if (!$relatedEntityType) {
throw new Forbidden();
}
if (
$this->user->isAdmin() &&
$role === Attachment::ROLE_INLINE_ATTACHMENT &&
in_array($relatedEntityType, $this->adminOnlyHavingInlineAttachmentsEntityTypeList)
) {
return;
}
$fieldType = $this->metadata->get(['entityDefs', $relatedEntityType, 'fields', $field, 'type']);
if (!$fieldType) {
throw new Forbidden("Field '$field' does not exist.");
}
$fieldTypeList = $role === Attachment::ROLE_INLINE_ATTACHMENT ?
$this->inlineAttachmentFieldTypeList :
$this->attachmentFieldTypeList;
if (!in_array($fieldType, $fieldTypeList)) {
throw new Forbidden("Field type '$fieldType' is not allowed for $role.");
}
if ($this->user->isAdmin() && $relatedEntityType === Settings::ENTITY_TYPE) {
return;
}
if (
!$this->acl->checkScope($relatedEntityType, Table::ACTION_CREATE) &&
!$this->acl->checkScope($relatedEntityType, Table::ACTION_EDIT)
) {
throw new Forbidden("No access to " . $relatedEntityType . ".");
}
if (!$this->acl->checkField($relatedEntityType, $field, Table::ACTION_EDIT)) {
throw new Forbidden("No access to field '$field'.");
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Api;
use Espo\Core\Api\Action as ActionAlias;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Tools\Attachment\Service;
/**
* Download a file.
*/
class GetFile implements ActionAlias
{
public function __construct(private Service $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest();
}
$fileData = $this->service->getFileData($id);
$response = ResponseComposer::empty()
->setHeader('Content-Disposition', 'attachment; filename="' . $fileData->getName() . '"')
->setHeader('Content-Length', (string) $fileData->getSize())
->setBody($fileData->getStream());
if ($fileData->getType()) {
$response->setHeader('Content-Type', $fileData->getType());
}
return $response;
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Api;
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\Tools\Attachment\UploadService;
/**
* Uploads attachment chunks.
*/
class PostChunk implements Action
{
public function __construct(private UploadService $uploadService) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$body = $request->getBodyContents();
if (!$id || !$body) {
throw new BadRequest();
}
$this->uploadService->uploadChunk($id, $body);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Api;
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\Tools\Attachment\FieldData;
use Espo\Tools\Attachment\Service;
/**
* Copies attachments.
*/
class PostCopy implements Action
{
public function __construct(private Service $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest();
}
$data = $request->getParsedBody();
$field = $data->field ?? null;
$parentType = $data->parentType ?? null;
$relatedType = $data->relatedType ?? null;
if (!$field) {
throw new BadRequest("No `field`.");
}
try {
$fieldData = new FieldData($field, $parentType, $relatedType);
} catch (Error $e) {
throw new BadRequest($e->getMessage());
}
$attachment = $this->service->copy($id, $fieldData);
return ResponseComposer::json($attachment->getValueMap());
}
}

View File

@@ -0,0 +1,71 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Api;
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\Tools\Attachment\FieldData;
use Espo\Tools\Attachment\UploadUrlService;
/**
* Crates attachments from image URLs.
*/
class PostFromImageUrl implements Action
{
public function __construct(private UploadUrlService $uploadUrlService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$url = $data->url ?? null;
$field = $data->field ?? null;
$parentType = $data->parentType ?? null;
$relatedType = $data->relatedType ?? null;
if (!$url || !$field) {
throw new BadRequest("No `url` or `field`.");
}
try {
$fieldData = new FieldData($field, $parentType, $relatedType);
} catch (Error $e) {
throw new BadRequest($e->getMessage());
}
$attachment = $this->uploadUrlService->uploadImage($url, $fieldData);
return ResponseComposer::json($attachment->getValueMap());
}
}

View File

@@ -0,0 +1,161 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Utils\File\MimeType;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
use Espo\Core\ORM\Type\FieldType;
class Checker
{
public function __construct(
private Metadata $metadata,
private MimeType $mimeType,
private DetailsObtainer $detailsObtainer
) {}
/**
* Check a mine-type for allowance.
*
* @throws Forbidden
*/
public function checkType(Attachment $attachment): void
{
$field = $attachment->getTargetField();
$entityType = $attachment->getParentType() ?? $attachment->getRelatedType();
if (!$field || !$entityType) {
return;
}
if (
$this->detailsObtainer->getFieldType($attachment) === FieldType::IMAGE ||
$attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT
) {
$this->checkTypeImage($attachment);
return;
}
$extension = strtolower(DetailsObtainer::getFileExtension($attachment) ?? '');
$mimeType = $this->mimeType->getMimeTypeByExtension($extension) ??
$attachment->getType();
/** @var string[] $accept */
$accept = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'accept']) ?? [];
if ($accept === []) {
return;
}
$found = false;
foreach ($accept as $token) {
if (strtolower($token) === '.' . $extension) {
$found = true;
break;
}
if ($mimeType && MimeType::matchMimeTypeToAcceptToken($mimeType, $token)) {
$found = true;
break;
}
}
if (!$found) {
throw new ForbiddenSilent("Not allowed file type.");
}
}
/**
* Check a mime-time for allowance for an image.
*
* @throws Forbidden
*/
public function checkTypeImage(Attachment $attachment, ?string $filePath = null): void
{
$extension = DetailsObtainer::getFileExtension($attachment) ?? '';
$mimeType = $this->mimeType->getMimeTypeByExtension($extension);
/** @var string[] $imageTypeList */
$imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? [];
if (!in_array($mimeType, $imageTypeList)) {
throw new ForbiddenSilent("Not allowed file type.");
}
$setMimeType = $attachment->getType();
if (strtolower($setMimeType ?? '') !== $mimeType) {
throw new ForbiddenSilent("Passed type does not correspond to extension.");
}
$this->checkDetectedMimeType($attachment, $filePath);
}
/**
* @throws Forbidden
*/
private function checkDetectedMimeType(Attachment $attachment, ?string $filePath = null): void
{
// ext-fileinfo required, otherwise bypass.
if (!class_exists('\finfo') || !defined('FILEINFO_MIME_TYPE')) {
return;
}
/** @var ?string $contents */
$contents = $attachment->get('contents');
if (!$contents && !$filePath) {
return;
}
$extension = DetailsObtainer::getFileExtension($attachment) ?? '';
$mimeTypeList = $this->mimeType->getMimeTypeListByExtension($extension);
$fileInfo = new \finfo(FILEINFO_MIME_TYPE);
$detectedMimeType = $filePath ?
$fileInfo->file($filePath) :
$fileInfo->buffer($contents);
if (!in_array($detectedMimeType, $mimeTypeList)) {
throw new ForbiddenSilent("Detected mime type does not correspond to extension.");
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
class DetailsObtainer
{
private Metadata $metadata;
private Config $config;
public function __construct(
Metadata $metadata,
Config $config
) {
$this->metadata = $metadata;
$this->config = $config;
}
/**
* Get a file extension.
*/
public static function getFileExtension(Attachment $attachment): ?string
{
$name = $attachment->getName() ?? '';
return array_slice(explode('.', $name), -1)[0] ?? null;
}
/**
* Get an upload max size allowed for an attachment (depending on a field it's related to).
*
* @return int A size in bytes.
*/
public function getUploadMaxSize(Attachment $attachment): int
{
if ($attachment->getRole() === Attachment::ROLE_INLINE_ATTACHMENT) {
return $this->config->get('inlineAttachmentUploadMaxSize') * 1024 * 1024;
}
$field = $attachment->getTargetField();
$parentType = $attachment->getParentType() ?? $attachment->getRelatedType();
if ($field && $parentType) {
$maxSize = ($this->metadata
->get(['entityDefs', $parentType, 'fields', $field, 'maxFileSize']) ?? 0) * 1024 * 1024;
if ($maxSize) {
return $maxSize;
}
}
return (int) $this->config->get('attachmentUploadMaxSize', 0) * 1024 * 1024;
}
/**
* Get a field type (an attachment if related to another record through the field).
*/
public function getFieldType(Attachment $attachment): ?string
{
$field = $attachment->getTargetField();
$entityType = $attachment->getParentType() ?? $attachment->getRelatedType();
if (!$field || !$entityType) {
return null;
}
return $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
}
}

View File

@@ -0,0 +1,74 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Exceptions\Error;
/**
* Immutable.
*/
class FieldData
{
private string $field;
private ?string $parentType;
private ?string $relatedType;
/**
* @throws Error
*/
public function __construct(
string $field,
?string $parentType,
?string $relatedType
) {
$this->field = $field;
$this->parentType = $parentType;
$this->relatedType = $relatedType;
if (!$parentType && !$relatedType) {
throw new Error("No parentType and relatedType.");
}
}
public function getField(): string
{
return $this->field;
}
public function getParentType(): ?string
{
return $this->parentType;
}
public function getRelatedType(): ?string
{
return $this->relatedType;
}
}

View File

@@ -0,0 +1,75 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Psr\Http\Message\StreamInterface;
/**
* Immutable.
*/
class FileData
{
private ?string $name;
private ?string $type;
private StreamInterface $stream;
private int $size;
public function __construct(
?string $name,
?string $type,
StreamInterface $stream,
int $size
) {
$this->name = $name;
$this->type = $type;
$this->stream = $stream;
$this->size = $size;
}
public function getName(): ?string
{
return $this->name;
}
public function getType(): ?string
{
return $this->type;
}
public function getStream(): StreamInterface
{
return $this->stream;
}
public function getSize(): int
{
return $this->size;
}
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Core\Field\DateTime;
use Espo\Core\FileStorage\Storages\EspoUploadDir;
use Espo\Core\Utils\Config;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\ORM\EntityManager;
use Espo\Entities\Attachment;
use LogicException;
class MoveToStorage implements Job
{
private const REMOVE_FILE_PERIOD = '3 hours';
private EntityManager $entityManager;
private Config $config;
private FileStorageManager $fileStorageManager;
private JobSchedulerFactory $jobSchedulerFactory;
public function __construct(
EntityManager $entityManager,
Config $config,
FileStorageManager $fileStorageManager,
JobSchedulerFactory $jobSchedulerFactory
) {
$this->entityManager = $entityManager;
$this->config = $config;
$this->fileStorageManager = $fileStorageManager;
$this->jobSchedulerFactory = $jobSchedulerFactory;
}
public function run(Data $data): void
{
$id = $data->getTargetId();
if (!$id) {
throw new LogicException();
}
/** @var Attachment|null $attachment */
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
if (!$attachment) {
return;
}
if ($attachment->getStorage() !== EspoUploadDir::NAME) {
return;
}
$defaultFileStorage = $this->config->get('defaultFileStorage');
if (!$defaultFileStorage || $defaultFileStorage === EspoUploadDir::NAME) {
return;
}
$stream = $this->fileStorageManager->getStream($attachment);
$attachment->set('storage', $defaultFileStorage);
$this->fileStorageManager->putStream($attachment, $stream);
$this->entityManager->saveEntity($attachment);
$this->jobSchedulerFactory->create()
->setClassName(RemoveUploadDirFile::class)
->setData(
Data::create()
->withTargetId($attachment->getId())
)
->setTime(
DateTime::createNow()
->modify('+' . self::REMOVE_FILE_PERIOD)
->toDateTime()
)
->schedule();
}
}

View File

@@ -0,0 +1,102 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\FileStorage\Factory as FileStorageFactory;
use Espo\Core\FileStorage\Storages\EspoUploadDir;
use Espo\Core\FileStorage\Local;
use Espo\Entities\Attachment;
use Espo\Core\FileStorage\AttachmentEntityWrapper;
use Espo\ORM\EntityManager;
use LogicException;
class RemoveUploadDirFile implements Job
{
private FileManager $fileManager;
private FileStorageFactory $fileStorageFactory;
private EntityManager $entityManager;
public function __construct(
FileManager $fileManager,
FileStorageFactory $fileStorageFactory,
EntityManager $entityManager
) {
$this->fileManager = $fileManager;
$this->fileStorageFactory = $fileStorageFactory;
$this->entityManager = $entityManager;
}
public function run(Data $data): void
{
$id = $data->getTargetId();
if (!$id) {
throw new LogicException();
}
/** @var Attachment|null $attachment */
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id);
if (!$attachment) {
return;
}
if ($attachment->getStorage() === EspoUploadDir::NAME) {
return;
}
$storage = $this->fileStorageFactory->create(EspoUploadDir::NAME);
if (!$storage instanceof Local) {
throw new LogicException();
}
$filePath = $storage->getLocalFilePath(
new AttachmentEntityWrapper($attachment)
);
if (!$this->fileManager->isFile($filePath)) {
return;
}
$this->fileManager->remove($filePath);
}
}

View File

@@ -0,0 +1,117 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Record\ServiceContainer;
use Espo\Entities\Attachment;
use Espo\ORM\EntityManager;
use Espo\Repositories\Attachment as AttachmentRepository;
class Service
{
private ServiceContainer $recordServiceContainer;
private EntityManager $entityManager;
private AccessChecker $accessChecker;
public function __construct(
ServiceContainer $recordServiceContainer,
EntityManager $entityManager,
AccessChecker $accessChecker
) {
$this->recordServiceContainer = $recordServiceContainer;
$this->entityManager = $entityManager;
$this->accessChecker = $accessChecker;
}
/**
* Get file data (for downloading).
*
* @throws NotFound
* @throws Forbidden
*/
public function getFileData(string $id): FileData
{
/** @var ?Attachment $attachment */
$attachment = $this->recordServiceContainer
->get(Attachment::ENTITY_TYPE)
->getEntity($id);
if (!$attachment) {
throw new NotFound();
}
return new FileData(
$attachment->getName(),
$attachment->getType(),
$this->getAttachmentRepository()->getStream($attachment),
$this->getAttachmentRepository()->getSize($attachment)
);
}
/**
* Copy an attachment record (to reuse the same file w/o copying it in the storage).
*
* @throws Forbidden
* @throws NotFound
*/
public function copy(string $id, FieldData $data): Attachment
{
$this->accessChecker->check($data);
/** @var ?Attachment $attachment */
$attachment = $this->recordServiceContainer
->get(Attachment::ENTITY_TYPE)
->getEntity($id);
if (!$attachment) {
throw new NotFound();
}
$copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
$copied->set('parentType', $data->getParentType());
$copied->set('relatedType', $data->getRelatedType());
$copied->setTargetField($data->getField());
$copied->setRole(Attachment::ROLE_ATTACHMENT);
$this->getAttachmentRepository()->save($copied);
return $copied;
}
private function getAttachmentRepository(): AttachmentRepository
{
/** @var AttachmentRepository */
return $this->entityManager->getRepositoryByClass(Attachment::class);
}
}

View File

@@ -0,0 +1,179 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FileStorage\Storages\EspoUploadDir;
use Espo\Core\Job\Job\Data as JobData;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Entities\Attachment;
use Espo\ORM\EntityManager;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\Tools\Attachment\Jobs\MoveToStorage;
use Espo\Core\ORM\Type\FieldType;
class UploadService
{
private JobSchedulerFactory $jobSchedulerFactory;
private ServiceContainer $recordServiceContainer;
private Acl $acl;
private EntityManager $entityManager;
private FileManager $fileManager;
private DetailsObtainer $detailsObtainer;
private Checker $checker;
public function __construct(
JobSchedulerFactory $jobSchedulerFactory,
ServiceContainer $recordServiceContainer,
Acl $acl,
EntityManager $entityManager,
FileManager $fileManager,
DetailsObtainer $detailsObtainer,
Checker $checker
) {
$this->jobSchedulerFactory = $jobSchedulerFactory;
$this->recordServiceContainer = $recordServiceContainer;
$this->acl = $acl;
$this->entityManager = $entityManager;
$this->fileManager = $fileManager;
$this->detailsObtainer = $detailsObtainer;
$this->checker = $checker;
}
/**
* Upload a chunk.
*
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function uploadChunk(string $id, string $fileData): void
{
if (!$this->acl->checkScope(Attachment::ENTITY_TYPE, Table::ACTION_CREATE)) {
throw new Forbidden();
}
/** @var ?Attachment $attachment */
$attachment = $this->recordServiceContainer
->get(Attachment::ENTITY_TYPE)
->getEntity($id);
if (!$attachment) {
throw new NotFound();
}
if (!$attachment->isBeingUploaded()) {
throw new Forbidden("Attachment is not being-uploaded.");
}
if ($attachment->getStorage() !== EspoUploadDir::NAME) {
throw new Forbidden("Attachment storage is not 'EspoUploadDir'.");
}
$arr = explode(';base64,', $fileData);
if (count($arr) < 2) {
throw new BadRequest("Bad file data.");
}
$contents = base64_decode($arr[1]);
$filePath = $this->getAttachmentRepository()->getFilePath($attachment);
$chunkSize = strlen($contents);
$actualFileSize = 0;
if ($this->fileManager->isFile($filePath)) {
$actualFileSize = $this->fileManager->getSize($filePath);
}
$maxFileSize = $this->detailsObtainer->getUploadMaxSize($attachment);
if ($actualFileSize + $chunkSize > $maxFileSize) {
throw new Forbidden("Max attachment size exceeded.");
}
$this->fileManager->appendContents($filePath, $contents);
if ($actualFileSize + $chunkSize > $attachment->getSize()) {
throw new Error("File size mismatch.");
}
$isLastChunk = $actualFileSize + $chunkSize === $attachment->getSize();
if (!$isLastChunk) {
return;
}
if ($this->detailsObtainer->getFieldType($attachment) === FieldType::IMAGE) {
try {
$this->checker->checkTypeImage($attachment, $filePath);
} catch (Forbidden $e) {
$this->entityManager->removeEntity($attachment);
throw new ForbiddenSilent($e->getMessage());
}
}
$attachment->set('isBeingUploaded', false);
$this->entityManager->saveEntity($attachment);
$this->createJobMoveToStorage($attachment);
}
private function getAttachmentRepository(): AttachmentRepository
{
/** @var AttachmentRepository */
return $this->entityManager->getRepositoryByClass(Attachment::class);
}
private function createJobMoveToStorage(Attachment $attachment): void
{
$this->jobSchedulerFactory
->create()
->setClassName(MoveToStorage::class)
->setData(
JobData::create()
->withTargetId($attachment->getId())
)
->schedule();
}
}

View File

@@ -0,0 +1,208 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Attachment;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Utils\File\MimeType;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Security\UrlCheck;
use Espo\Entities\Attachment as Attachment;
use Espo\ORM\EntityManager;
use Espo\Repositories\Attachment as AttachmentRepository;
class UploadUrlService
{
private AccessChecker $accessChecker;
private Metadata $metadata;
private EntityManager $entityManager;
private MimeType $mimeType;
private DetailsObtainer $detailsObtainer;
public function __construct(
AccessChecker $accessChecker,
Metadata $metadata,
EntityManager $entityManager,
MimeType $mimeType,
DetailsObtainer $detailsObtainer,
private UrlCheck $urlCheck
) {
$this->accessChecker = $accessChecker;
$this->metadata = $metadata;
$this->entityManager = $entityManager;
$this->mimeType = $mimeType;
$this->detailsObtainer = $detailsObtainer;
}
/**
* Upload an image from and URL and store as attachment.
*
* @throws Forbidden
* @throws Error
*/
public function uploadImage(string $url, FieldData $data): Attachment
{
if (!$this->urlCheck->isNotInternalUrl($url)) {
throw new ForbiddenSilent("Not allowed URL.");
}
$attachment = $this->getAttachmentRepository()->getNew();
$this->accessChecker->check($data);
[$type, $contents] = $this->getImageDataByUrl($url) ?? [null, null];
if (!$type || !$contents) {
throw new ErrorSilent("Bad image data.");
}
$attachment->set([
'name' => $url,
'type' => $type,
'contents' => $contents,
'role' => Attachment::ROLE_ATTACHMENT,
]);
$attachment->set('parentType', $data->getParentType());
$attachment->set('relatedType', $data->getRelatedType());
$attachment->set('field', $data->getField());
$size = mb_strlen($contents, '8bit');
$maxSize = $this->detailsObtainer->getUploadMaxSize($attachment);
if ($maxSize && $size > $maxSize) {
throw new Error("File size should not exceed {$maxSize}Mb.");
}
$this->getAttachmentRepository()->save($attachment);
$attachment->clear('contents');
return $attachment;
}
/**
* @param string $url
* @return ?array{string, string} A type and contents.
*/
private function getImageDataByUrl(string $url): ?array
{
$type = null;
if (!function_exists('curl_init')) {
return null;
}
$opts = [];
$httpHeaders = [];
$httpHeaders[] = 'Expect:';
$opts[\CURLOPT_URL] = $url;
$opts[\CURLOPT_HTTPHEADER] = $httpHeaders;
$opts[\CURLOPT_CONNECTTIMEOUT] = 10;
$opts[\CURLOPT_TIMEOUT] = 10;
$opts[\CURLOPT_HEADER] = true;
$opts[\CURLOPT_VERBOSE] = true;
$opts[\CURLOPT_SSL_VERIFYPEER] = true;
$opts[\CURLOPT_SSL_VERIFYHOST] = 2;
$opts[\CURLOPT_RETURNTRANSFER] = true;
// Prevents Server Side Request Forgery by redirecting to an internal host.
$opts[\CURLOPT_FOLLOWLOCATION] = false;
$opts[\CURLOPT_MAXREDIRS] = 2;
$opts[\CURLOPT_IPRESOLVE] = \CURL_IPRESOLVE_V4;
$opts[\CURLOPT_PROTOCOLS] = \CURLPROTO_HTTPS | \CURLPROTO_HTTP;
$opts[\CURLOPT_REDIR_PROTOCOLS] = \CURLPROTO_HTTPS;
$ch = curl_init();
curl_setopt_array($ch, $opts);
/** @var string|false $response */
$response = curl_exec($ch);
if ($response === false) {
curl_close($ch);
return null;
}
$headerSize = curl_getinfo($ch, \CURLINFO_HEADER_SIZE);
$header = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
$headLineList = explode("\n", $header);
foreach ($headLineList as $i => $line) {
if ($i === 0) {
continue;
}
if (strpos(strtolower($line), strtolower('Content-Type:')) === 0) {
$part = trim(substr($line, 13));
if ($part) {
$type = trim(explode(";", $part)[0]);
}
}
}
if (!$type) {
/** @var string $extension */
$extension = preg_replace('#\?.*#', '', pathinfo($url, \PATHINFO_EXTENSION));
$type = $this->mimeType->getMimeTypeByExtension($extension);
}
curl_close($ch);
if (!$type) {
return null;
}
/** @var string[] $imageTypeList */
$imageTypeList = $this->metadata->get(['app', 'image', 'allowedFileTypeList']) ?? [];
if (!in_array($type, $imageTypeList)) {
return null;
}
return [$type, $body];
}
private function getAttachmentRepository(): AttachmentRepository
{
/** @var AttachmentRepository */
return $this->entityManager->getRepositoryByClass(Attachment::class);
}
}

View File

@@ -0,0 +1,147 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Captcha;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log;
use Espo\Entities\Integration;
use Espo\ORM\EntityManager;
use RuntimeException;
class Checker
{
private const URL = 'https://www.google.com/recaptcha/api/siteverify';
private const SCORE_THRESHOLD = 0.2;
private const TIMEOUT = 20;
public function __construct(
private EntityManager $entityManager,
private Log $log,
) {}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function check(string $token, string $action): void
{
[$secret, $scoreThreshold] = $this->getCaptchaSecretKey();
if ($secret && $token === '') {
throw new BadRequest("No captcha token.");
}
if (!$secret) {
throw new Forbidden("Captcha not configured.");
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::URL);
curl_setopt($ch, CURLOPT_POST, true);
$data = [
'secret' => $secret,
'response' => $token,
];
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
$response = curl_exec($ch);
curl_close($ch);
if (!is_string($response)) {
throw new RuntimeException("Bad CURL response.");
}
$responseData = Json::decode($response, true);
if (!is_array($responseData)) {
throw new RuntimeException("Bad response from ReCaptcha.");
}
$success = $responseData['success'] ?? null;
$score = $responseData['score'] ?? null;
$resultAction = $responseData['action'] ?? null;
if (!$success) {
$this->log->error("Captcha error; action: {action}; response: {response}", [
'action' => $action,
'response' => $response,
]);
throw new Forbidden("ReCaptcha error.");
}
if (!is_string($resultAction)) {
throw new RuntimeException("No or bad action in ReCaptcha response.");
}
if (!is_int($score) && !is_float($score)) {
throw new RuntimeException("No score in ReCaptcha response.");
}
if ($action !== $resultAction) {
throw new Forbidden("ReCaptcha action mismatch.");
}
if ($score < $scoreThreshold) {
throw new Forbidden("ReCaptcha low score.");
}
}
/**
* @return array{?string, ?int}
*/
private function getCaptchaSecretKey(): array
{
$entity = $this->entityManager
->getRepositoryByClass(Integration::class)
->getById('GoogleReCaptcha');
if (!$entity) {
return [null, null];
}
$secretKey = $entity->get('secretKey');
$scoreThreshold = $entity->get('scoreThreshold') ?? self::SCORE_THRESHOLD;
if (!$secretKey) {
return [null, null];
}
return [$secretKey, $scoreThreshold];
}
}

View File

@@ -0,0 +1,68 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\CategoryTree\Move;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Templates\Entities\CategoryTree;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
class LoopReferenceChecker
{
private const ATTR_PARENT_ID = 'parentId';
public function __construct(
private EntityManager $entityManager,
) {}
/**
* @throws Forbidden
*/
public function check(CategoryTree $entity, Entity $reference): void
{
$parentId = $reference->get(self::ATTR_PARENT_ID);
if (!$parentId) {
return;
}
if ($parentId === $entity->getId()) {
throw new Forbidden("Cannot move. Circle reference.");
}
$parent = $this->entityManager->getEntityById($entity->getEntityType(), $parentId);
if (!$parent) {
return;
}
$this->check($entity, $parent);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\CategoryTree\Move;
readonly class MoveParams
{
public const TYPE_INTO = 0;
public const TYPE_BEFORE = 1;
public const TYPE_AFTER = 2;
public function __construct(
public int $type,
public ?string $referenceId = null,
) {}
}

View File

@@ -0,0 +1,219 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\CategoryTree;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Templates\Entities\CategoryTree;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\UpdateBuilder;
use Espo\ORM\Repository\Option\SaveOption;
use Espo\Tools\CategoryTree\Move\LoopReferenceChecker;
use Espo\Tools\CategoryTree\Move\MoveParams;
class MoveService
{
private const ATTR_PARENT_ID = 'parentId';
private const ATTR_ORDER = 'order';
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private LoopReferenceChecker $loopReferenceChecker,
) {}
/**
* @throws NotFound
* @throws Forbidden
* @throws Error
*/
public function move(CategoryTree $entity, MoveParams $params): void
{
$hasOrder = $entity->hasAttribute(self::ATTR_ORDER);
if (!$hasOrder && $params->type !== MoveParams::TYPE_INTO) {
throw new Error("Order not supported.");
}
$entityType = $entity->getEntityType();
$reference = null;
if ($params->referenceId) {
$reference = $this->entityManager->getEntityById($entityType, $params->referenceId);
if (!$reference) {
throw new NotFound("No reference record found.");
}
}
if ($params->type === MoveParams::TYPE_INTO) {
$this->processInto($reference, $entity, $params);
return;
}
if (!$reference) {
throw new Error("No reference.");
}
$parentId = $reference->get(self::ATTR_PARENT_ID);
if ($parentId !== $entity->get(self::ATTR_PARENT_ID) && $parentId) {
$parent = $this->entityManager->getEntityById($entityType, $parentId);
if ($parent && !$this->acl->checkEntityEdit($parent)) {
throw new Forbidden("No edit access to target category.");
}
if ($parent) {
$this->checkReferenceNoLoop($parent, $entity);
}
}
if ($params->type === MoveParams::TYPE_AFTER) {
$this->processAfter($reference, $entity);
return;
}
$this->processBefore($reference, $entity);
}
private function incrementAfter(Entity $reference): void
{
$update = UpdateBuilder::create()
->in($reference->getEntityType())
->where([
self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID),
self::ATTR_ORDER . '>' => $reference->get(self::ATTR_ORDER),
])
->set([
self::ATTR_ORDER => Expr::add(Expr::column(self::ATTR_ORDER), 2)
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
private function decrementBefore(Entity $reference): void
{
$update = UpdateBuilder::create()
->in($reference->getEntityType())
->where([
self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID),
self::ATTR_ORDER . '<' => $reference->get(self::ATTR_ORDER),
])
->set([
self::ATTR_ORDER => Expr::subtract(Expr::column(self::ATTR_ORDER), 2)
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
private function rearrange(Entity $reference): void
{
$entities = $this->entityManager
->getRDBRepository($reference->getEntityType())
->where([
self::ATTR_PARENT_ID => $reference->get(self::ATTR_PARENT_ID),
])
->order(self::ATTR_ORDER)
->find();
foreach ($entities as $i => $entity) {
$entity->set(self::ATTR_ORDER, $i + 1);
$this->entityManager->saveEntity($entity, [SaveOption::SKIP_ALL => true]);
}
}
private function processAfter(Entity $reference, CategoryTree $entity): void
{
$this->incrementAfter($reference);
$order = ($reference->get(self::ATTR_ORDER) ?? 0) + 1;
$entity->set(self::ATTR_ORDER, $order);
$entity->set(self::ATTR_PARENT_ID, $reference->get(self::ATTR_PARENT_ID));
$this->entityManager->saveEntity($entity);
$this->rearrange($reference);
}
private function processBefore(Entity $reference, CategoryTree $entity): void
{
$this->decrementBefore($reference);
$order = ($reference->get(self::ATTR_ORDER) ?? 0) - 1;
$entity->set(self::ATTR_ORDER, $order);
$entity->set(self::ATTR_PARENT_ID, $reference->get(self::ATTR_PARENT_ID));
$this->entityManager->saveEntity($entity);
$this->rearrange($reference);
}
/**
* @throws Forbidden
*/
private function processInto(?Entity $reference, CategoryTree $entity, MoveParams $params): void
{
if ($reference && !$this->acl->checkEntityEdit($reference)) {
throw new Forbidden("No edit access to target category.");
}
if ($reference) {
$this->checkReferenceNoLoop($reference, $entity);
}
$entity->setMultiple([
self::ATTR_PARENT_ID => $params->referenceId,
self::ATTR_ORDER => null,
]);
$this->entityManager->saveEntity($entity);
}
/**
* @throws Forbidden
*/
private function checkReferenceNoLoop(Entity $reference, CategoryTree $entity): void
{
$this->loopReferenceChecker->check($entity, $reference);
}
}

View File

@@ -0,0 +1,132 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\CategoryTree;
use Espo\Core\Exceptions\Error;
use Espo\ORM\Entity;
use Espo\Core\Repositories\CategoryTree;
use Espo\ORM\EntityManager;
/**
* Rebuild category tree paths.
*/
class RebuildPaths
{
private EntityManager $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @throws Error
*/
public function run(string $entityType): void
{
if (
!$this->entityManager->hasRepository($entityType) ||
!$this->entityManager->getRepository($entityType) instanceof CategoryTree
) {
throw new Error("Bad entity type.");
}
$this->clearTable($entityType);
$this->processBranch($entityType, null);
}
private function clearTable(string $entityType): void
{
$query = $this->entityManager
->getQueryBuilder()
->delete()
->from($entityType . 'Path')
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
private function processBranch(string $entityType, ?string $parentId): void
{
$collection = $this->entityManager
->getRDBRepository($entityType)
->sth()
->where(['parentId' => $parentId])
->find();
foreach ($collection as $entity) {
$this->processEntity($entity);
}
}
private function processEntity(Entity $entity): void
{
$parentId = $entity->get('parentId');
$pathEntityType = $entity->getEntityType() . 'Path';
if ($parentId) {
$subSelect1 = $this->entityManager
->getQueryBuilder()
->select()
->from($pathEntityType)
->select(['ascendorId', "'" . $entity->getId() . "'"])
->where([
'descendorId' => $parentId,
])
->build();
$insert = $this->entityManager
->getQueryBuilder()
->insert()
->into($pathEntityType)
->columns(['ascendorId', 'descendorId'])
->valuesQuery($subSelect1)
->build();
$this->entityManager->getQueryExecutor()->execute($insert);
}
$insert = $this->entityManager
->getQueryBuilder()
->insert()
->into($pathEntityType)
->columns(['ascendorId', 'descendorId'])
->values([
'ascendorId' => $entity->getId(),
'descendorId' => $entity->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($insert);
$this->processBranch($entity->getEntityType(), $entity->getId());
}
}

View File

@@ -0,0 +1,43 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\CategoryTree\Record;
use Espo\Core\Select\Where\Item;
readonly class ReadTreeParams
{
public function __construct(
public ?Item $where = null,
public bool $onlyNotEmpty = false,
public ?string $currentId = null,
public ?int $maxDepth = null,
public ?string $parentId = null,
) {}
}

View File

@@ -0,0 +1,52 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Tools\Currency\RateService as Service;
/**
* Gets rates.
*/
class Get implements Action
{
public function __construct(private Service $service)
{}
public function process(Request $request): Response
{
$result = $this->service->get()->toAssoc();
return ResponseComposer::json($result);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Currency\Rates;
use Espo\Tools\Currency\RateService as Service;
/**
* Updates rates.
*/
class PutUpdate implements Action
{
public function __construct(private Service $service)
{}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$rates = Rates::fromAssoc(get_object_vars($data), '___');
$this->service->set($rates);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,129 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Conversion;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Currency\Converter;
use Espo\Core\Currency\Rates;
use Espo\Core\Field\Currency;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use LogicException;
/**
* @implements EntityConverter<CoreEntity>
*/
class DefaultEntityConverter implements EntityConverter
{
public function __construct(
private Converter $converter,
private EntityManager $entityManager,
private Metadata $metadata,
private Acl $acl
) {}
/**
* @param CoreEntity $entity
*/
public function convert(Entity $entity, string $targetCurrency, Rates $rates): void
{
$entityDefs = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType());
foreach ($this->getFieldList($entity->getEntityType()) as $field) {
$disabled = $entityDefs->getField($field)->getParam('conversionDisabled');
if ($disabled) {
continue;
}
$value = $entity->getValueObject($field);
if (!$value) {
continue;
}
if (!$value instanceof Currency) {
throw new LogicException();
}
if ($targetCurrency === $value->getCode()) {
continue;
}
$convertedValue = $this->converter->convertWithRates($value, $targetCurrency, $rates);
$entity->setValueObject($field, $convertedValue);
}
}
/**
* @return string[]
*/
private function getFieldList(string $entityType): array
{
$resultList = [];
/** @var string[] $requiredFieldList */
$requiredFieldList = $this->metadata->get(['scopes', $entityType, 'currencyConversionAccessRequiredFieldList']);
$allFields = $requiredFieldList !== null;
$fieldDefsList = $this->entityManager
->getDefs()
->getEntity($entityType)
->getFieldList();
foreach ($fieldDefsList as $fieldDefs) {
$field = $fieldDefs->getName();
$type = $fieldDefs->getType();
if ($type !== FieldType::CURRENCY) {
continue;
}
if (
!$allFields &&
!$this->acl->checkField($entityType, $field, Table::ACTION_EDIT)
) {
continue;
}
$resultList[] = $field;
}
return $resultList;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Conversion;
use Espo\Core\Currency\Rates;
use Espo\Core\Exceptions\Forbidden;
use Espo\ORM\Entity;
/**
* Converts entity currency values. Is not supposed to save the entity.
*
* @template TEntity of Entity
*/
interface EntityConverter
{
/**
* @param TEntity $entity
* @throws Forbidden
*/
public function convert(Entity $entity, string $targetCurrency, Rates $rates): void;
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Conversion;
use Espo\Core\Acl;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
class EntityConverterFactory
{
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
private User $user,
private Acl $acl
) {}
/**
* @return EntityConverter<Entity>
*/
public function create(string $entityType): EntityConverter
{
/** @var class-string<EntityConverter<Entity>> $className */
$className = $this->metadata
->get(['app', 'currencyConversion', 'entityConverterClassNameMap', $entityType]) ??
DefaultEntityConverter::class;
$binding = BindingContainerBuilder::create()
->bindInstance(User::class, $this->user)
->bindInstance(Acl::class, $this->acl)
->build();
return $this->injectableFactory->createWithBinding($className, $binding);
}
}

View File

@@ -0,0 +1,122 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency;
use Espo\Core\Acl\Table;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\Currency\Rates;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Acl;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Core\Utils\Currency\DatabasePopulator;
class RateService
{
private const SCOPE = 'Currency';
public function __construct(
private ConfigWriter $configWriter,
private Acl $acl,
private DatabasePopulator $databasePopulator,
private ConfigDataProvider $configDataProvider
) {}
/**
* @throws Forbidden
*/
public function get(): Rates
{
if (!$this->acl->check(self::SCOPE)) {
throw new Forbidden();
}
if ($this->acl->getLevel(self::SCOPE, Table::ACTION_READ) !== Table::LEVEL_YES) {
throw new Forbidden();
}
$rates = Rates::create($this->configDataProvider->getBaseCurrency());
foreach ($this->configDataProvider->getCurrencyList() as $code) {
$rates = $rates->withRate($code, $this->configDataProvider->getCurrencyRate($code));
}
return $rates;
}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function set(Rates $rates): void
{
if (!$this->acl->check(self::SCOPE)) {
throw new Forbidden();
}
if ($this->acl->getLevel(self::SCOPE, Table::ACTION_EDIT) !== Table::LEVEL_YES) {
throw new Forbidden();
}
$currencyList = $this->configDataProvider->getCurrencyList();
$baseCurrency = $this->configDataProvider->getBaseCurrency();
$set = [];
foreach ($rates->toAssoc() as $key => $value) {
if ($value < 0) {
throw new BadRequest("Bad value.");
}
if (!in_array($key, $currencyList)) {
continue;
}
if ($key === $baseCurrency) {
continue;
}
$set[$key] = $value;
}
foreach ($currencyList as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$set[$currency] ??= $this->configDataProvider->getCurrencyRate($currency);
}
$this->configWriter->set('currencyRates', $set);
$this->configWriter->save();
$this->databasePopulator->process();
}
}

View File

@@ -0,0 +1,177 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Dashboard;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Entities\DashboardTemplate;
use Espo\Entities\Preferences;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
class Service
{
private EntityManager $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param string[] $userIdList
* @throws NotFound
* @throws Forbidden
*/
public function deployTemplateToUsers(string $id, array $userIdList, bool $append = false): void
{
$template = $this->entityManager->getEntityById(DashboardTemplate::ENTITY_TYPE, $id);
if (!$template) {
throw new NotFound();
}
foreach ($userIdList as $userId) {
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
throw new NotFound("User not found.");
}
if ($user->isPortal() || $user->isApi()) {
throw new Forbidden("Not allowed user type.");
}
}
foreach ($userIdList as $userId) {
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$preferences) {
continue;
}
$this->applyTemplate($preferences, $template, $append);
$this->entityManager->saveEntity($preferences);
}
}
/**
* @throws NotFound
*/
public function deployTemplateToTeam(string $id, string $teamId, bool $append = false): void
{
/** @var ?DashboardTemplate $template */
$template = $this->entityManager->getEntityById(DashboardTemplate::ENTITY_TYPE, $id);
if (!$template) {
throw new NotFound();
}
$team = $this->entityManager->getEntityById(Team::ENTITY_TYPE, $teamId);
if (!$team) {
throw new NotFound();
}
$userList = $this->entityManager
->getRDBRepository(User::ENTITY_TYPE)
->join(Field::TEAMS)
->distinct()
->where([
Field::TEAMS . '.id' => $teamId,
])
->find();
foreach ($userList as $user) {
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId());
if (!$preferences) {
continue;
}
$this->applyTemplate($preferences, $template, $append);
$this->entityManager->saveEntity($preferences);
}
}
private function applyTemplate(Entity $preferences, DashboardTemplate $template, bool $append): void
{
if (!$append) {
$preferences->set([
'dashboardLayout' => $template->get('layout'),
'dashletsOptions' => $template->get('dashletsOptions'),
]);
} else {
$dashletsOptions = $preferences->get('dashletsOptions');
if (!$dashletsOptions) {
$dashletsOptions = (object) [];
}
$dashboardLayout = $preferences->get('dashboardLayout');
if (!$dashboardLayout) {
$dashboardLayout = [];
}
foreach ($template->get('layout') as $item) {
$exists = false;
foreach ($dashboardLayout as $k => $item2) {
if (isset($item->id) && isset($item2->id)) {
if ($item->id === $item2->id) {
$exists = true;
$dashboardLayout[$k] = $item;
}
}
}
if (!$exists) {
$dashboardLayout[] = $item;
}
}
foreach ($template->get('dashletsOptions') as $id => $item) {
$dashletsOptions->$id = $item;
}
$preferences->set([
'dashboardLayout' => $dashboardLayout,
'dashletsOptions' => $dashletsOptions,
]);
}
}
}

View File

@@ -0,0 +1,177 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DataPrivacy;
use Espo\Core\Acl\Permission;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldProcessing\EmailAddress\AccessChecker as EmailAddressAccessChecker;
use Espo\Core\FieldProcessing\PhoneNumber\AccessChecker as PhoneNumberAccessChecker;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\ServiceContainer as RecordServiceContainer;
use Espo\Core\Di;
use Espo\Entities\Attachment;
class Erasor implements
Di\AclAware,
Di\AclManagerAware,
Di\MetadataAware,
Di\ServiceFactoryAware,
Di\EntityManagerAware,
Di\FieldUtilAware,
Di\UserAware
{
use Di\AclSetter;
use Di\AclManagerSetter;
use Di\MetadataSetter;
use Di\ServiceFactorySetter;
use Di\EntityManagerSetter;
use Di\FieldUtilSetter;
use Di\UserSetter;
public function __construct(
private RecordServiceContainer $recordServiceContainer,
private EmailAddressAccessChecker $emailAddressAccessChecker,
private PhoneNumberAccessChecker $phoneNumberAccessChecker
) {}
/**
* @param string[] $fieldList
* @throws Forbidden
* @throws NotFound
*/
public function erase(string $entityType, string $id, array $fieldList): void
{
if ($this->acl->getPermissionLevel(Permission::DATA_PRIVACY) === Table::LEVEL_NO) {
throw new Forbidden();
}
$service = $this->recordServiceContainer->get($entityType);
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->check($entity, Table::ACTION_EDIT)) {
throw new Forbidden("No edit access.");
}
$forbiddenFieldList = $this->acl->getScopeForbiddenFieldList($entityType, Table::ACTION_EDIT);
foreach ($fieldList as $field) {
if (in_array($field, $forbiddenFieldList)) {
throw new Forbidden("Field '$field' is forbidden to edit.");
}
}
$service->loadAdditionalFields($entity);
$fieldUtil = $this->fieldUtil;
foreach ($fieldList as $field) {
$type = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
$attributeList = $fieldUtil->getActualAttributeList($entityType, $field);
if ($type === FieldType::EMAIL) {
$emailAddressList = $entity->get('emailAddresses');
foreach ($emailAddressList as $emailAddress) {
if (
$this->emailAddressAccessChecker
->checkEdit($this->user, $emailAddress, $entity)
) {
$emailAddress->set(Field::NAME, 'ERASED:' . $emailAddress->getId());
$emailAddress->set('optOut', true);
$this->entityManager->saveEntity($emailAddress);
}
}
$entity->clear($field);
$entity->clear($field . 'Data');
continue;
} else if ($type === FieldType::PHONE) {
$phoneNumberList = $entity->get('phoneNumbers');
foreach ($phoneNumberList as $phoneNumber) {
if (
$this->phoneNumberAccessChecker
->checkEdit($this->user, $phoneNumber, $entity)
) {
$phoneNumber->set(Field::NAME, 'ERASED:' . $phoneNumber->getId());
$this->entityManager->saveEntity($phoneNumber);
}
}
$entity->clear($field);
$entity->clear($field . 'Data');
continue;
} else if ($type === FieldType::FILE || $type === FieldType::IMAGE) {
$attachmentId = $entity->get($field . 'Id');
if ($attachmentId) {
$attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $attachmentId);
if ($attachment) {
$this->entityManager->removeEntity($attachment);
}
}
} else if ($type === FieldType::ATTACHMENT_MULTIPLE) {
$attachmentList = $entity->get($field);
foreach ($attachmentList as $attachment) {
$this->entityManager->removeEntity($attachment);
}
}
foreach ($attributeList as $attribute) {
if (
in_array($entity->getAttributeType($attribute), [$entity::VARCHAR, $entity::TEXT]) &&
$entity->get($attribute)
) {
$entity->set($attribute, null);
} else {
$entity->set($attribute, null);
}
}
}
$this->entityManager->saveEntity($entity);
}
}

View File

@@ -0,0 +1,349 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic;
use Espo\Core\Field\Date;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\DateTime\SystemClock;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\Tools\DynamicLogic\ConditionChecker\Options;
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
use Psr\Clock\ClockInterface;
use Exception;
use RuntimeException;
use DateTimeImmutable;
use DateTimeZone;
/**
* @since 9.1.0
*/
class ConditionChecker
{
/**
* Use `ConditionCheckerFactory` instead.
*/
public function __construct(
private Entity $entity,
private ?User $user = null,
private Options $options = new Options(),
private ClockInterface $clock = new SystemClock(),
) {}
/**
* @throws BadCondition
*/
public function check(Item $item): bool
{
$type = $item->type;
$value = $item->value;
if ($type === Type::And) {
if (!is_array($value)) {
throw new BadCondition();
}
foreach ($value as $subItem) {
if (!$subItem instanceof Item) {
throw new BadCondition();
}
if (!$this->check($subItem)) {
return false;
}
}
return true;
}
if ($type === Type::Or) {
if (!is_array($value)) {
throw new BadCondition();
}
foreach ($value as $subItem) {
if (!$subItem instanceof Item) {
throw new BadCondition();
}
if ($this->check($subItem)) {
return true;
}
}
return false;
}
if ($type === Type::Not) {
if (!$value instanceof Item) {
throw new BadCondition();
}
return !$this->check($value);
}
if (!$item->attribute) {
throw new BadCondition("No attribute.");
}
$setValue = $this->getAttributeValue($item->attribute);
if ($type === Type::Equals) {
return $setValue === $value;
}
if ($type === Type::NotEquals) {
return $setValue !== $value;
}
if ($type === Type::IsEmpty) {
return $setValue === [] ||
$setValue === null ||
$setValue === false ||
$setValue === '';
}
if ($type === Type::IsNotEmpty) {
return !(
$setValue === [] ||
$setValue === null ||
$setValue === false ||
$setValue === ''
);
}
if ($type === Type::IsTrue) {
return (bool) $setValue;
}
if ($type === Type::IsFalse) {
return !$setValue;
}
if ($type === Type::Contains) {
if (is_string($setValue) && is_string($value)) {
return str_contains($setValue, $value);
}
if (is_array($setValue)) {
return in_array($value, $setValue);
}
return false;
}
if ($type === Type::NotContains) {
if (is_string($setValue) && is_string($value)) {
return !str_contains($setValue, $value);
}
if (is_array($setValue)) {
return !in_array($value, $setValue);
}
return true;
}
if ($type === Type::Has) {
if (is_array($setValue)) {
return in_array($value, $setValue);
}
return false;
}
if ($type === Type::NotHas) {
if (is_array($setValue)) {
return !in_array($value, $setValue);
}
return true;
}
if ($type === Type::StartsWith) {
if (is_string($setValue) && is_string($value)) {
return str_starts_with($setValue, $value);
}
return false;
}
if ($type === Type::EndsWith) {
if (is_string($setValue) && is_string($value)) {
return str_ends_with($setValue, $value);
}
return false;
}
if ($type === Type::Matches) {
if (is_string($setValue) && is_string($value)) {
return (bool) preg_match($value, $setValue);
}
return false;
}
if ($type === Type::GreaterThan) {
return $setValue > $value;
}
if ($type === Type::LessThan) {
return $setValue < $value;
}
if ($type === Type::GreaterThanOrEquals) {
return $setValue >= $value;
}
if ($type === Type::LessThanOrEquals) {
return $setValue <= $value;
}
if ($type === Type::In) {
if (is_array($value)) {
return in_array($setValue, $value);
}
return false;
}
if ($type === Type::NotIn) {
if (is_array($value)) {
return !in_array($setValue, $value);
}
return true;
}
if (!$setValue || !is_string($setValue)) {
return false;
}
if ($type === Type::IsToday) {
if (strlen($setValue) > 10) {
$setDateTime = DateTime::fromDateTime($this->createDateTime($setValue));
$todayStart = $this->createNow()
->withTimezone($this->getTimeZone())
->withTime(0, 0);
$todayEnd = $todayStart->addDays(1);
return $todayStart->isLessThanOrEqualTo($setDateTime) && $setDateTime->isLessThan($todayEnd);
}
$setDate = Date::fromDateTime($this->createDateTime($setValue));
$today = $this->createToday();
return $setDate->isEqualTo($today);
}
if ($type === Type::InFuture) {
if (strlen($setValue) > 10) {
$setDateTime = DateTime::fromDateTime($this->createDateTime($setValue));
return $setDateTime->isGreaterThan($this->createNow());
}
$setDate = Date::fromDateTime($this->createDateTime($setValue));
$today = $this->createToday();
return $setDate->isGreaterThan($today);
}
if ($type === Type::InPast) {
if (strlen($setValue) > 10) {
$setDateTime = DateTime::fromDateTime($this->createDateTime($setValue));
return $setDateTime->isLessThan($this->createNow());
}
$setDate = Date::fromDateTime($this->createDateTime($setValue));
$today = $this->createToday();
return $setDate->isLessThan($today);
}
/** @phpstan-ignore-next-line deadCode.unreachable */
throw new BadCondition("Unimplemented type '$type->value'.");
}
private function getAttributeValue(string $attribute): mixed
{
if (str_starts_with($attribute, '$')) {
if (!$this->user) {
return null;
}
if ($attribute === '$user.id') {
return $this->user->getId();
}
if ($attribute === '$user.teamsIds') {
return $this->user->getTeamIdList();
}
}
return $this->entity->get($attribute);
}
private function getTimeZone(): DateTimeZone
{
return $this->options->timezone;
}
private function createDateTime(string $value): DateTimeImmutable
{
try {
$setDateTime = new DateTimeImmutable($value, $this->getTimeZone());
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
return $setDateTime;
}
private function createNow(): DateTime
{
return DateTime::fromDateTime($this->clock->now());
}
private function createToday(): Date
{
$dateTime = $this->clock
->now()
->setTimezone($this->getTimeZone());
return Date::fromDateTime($dateTime);
}
}

View File

@@ -0,0 +1,39 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic\ConditionChecker;
use DateTimeZone;
readonly class Options
{
public function __construct(
public DateTimeZone $timezone = new DateTimeZone('UTC'),
) {}
}

View File

@@ -0,0 +1,70 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic;
use DateTimeZone;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\Tools\DynamicLogic\ConditionChecker\Options;
use Exception;
use RuntimeException;
/**
* @since 9.1.0
* @noinspection PhpUnused
*/
class ConditionCheckerFactory
{
public function __construct(
private User $user,
private ApplicationConfig $applicationConfig,
) {}
/**
* @param Entity $entity An entity to check.
*/
public function create(Entity $entity): ConditionChecker
{
try {
$timezone = new DateTimeZone($this->applicationConfig->getTimeZone());
} catch (Exception $e) {
throw new RuntimeException('', 0, $e);
}
return new ConditionChecker(
entity: $entity,
user: $this->user,
options: new Options(
timezone: $timezone,
),
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic\Exceptions;
use Exception;
class BadCondition extends Exception
{}

View File

@@ -0,0 +1,110 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic;
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
use stdClass;
readonly class Item
{
public function __construct(
public Type $type,
public mixed $value,
public ?string $attribute = null,
) {}
/**
* @param stdClass[] $rawItems
* @throws BadCondition
*/
public static function fromGroupDefinition(array $rawItems): Item
{
return new Item(
type: Type::And,
value: array_map(fn ($it) => self::fromItemDefinition($it), $rawItems),
);
}
/**
* @throws BadCondition
*/
public static function fromItemDefinition(stdClass $rawItem): Item
{
$type = $rawItem->type ?? null;
$attribute = $rawItem->attribute ?? null;
$value = $rawItem->value ?? null;
if (!$type || !is_string($type)) {
throw new BadCondition("No type.");
}
if ($type === 'has') {
$type = 'contains';
}
if ($type === Type::And->value || $type === Type::Or->value) {
if (!is_array($value)) {
throw new BadCondition("Non-array value.");
}
foreach ($value as $it) {
if (!$it instanceof stdClass) {
throw new BadCondition("Bad group item value.");
}
}
return new Item(
type: Type::from($type),
value: array_map(fn ($it) => self::fromItemDefinition($it), $value),
);
}
if ($type === Type::Not->value) {
if (!$value instanceof stdClass) {
throw new BadCondition("Bad not item value.");
}
return new Item(
type: Type::from($type),
value: self::fromItemDefinition($value),
);
}
if ($attribute !== null && !is_string($attribute)) {
throw new BadCondition("No attribute.");
}
return new Item(
type: Type::from($type),
value: $value,
attribute: $attribute,
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\DynamicLogic;
enum Type: string
{
case And = 'and';
case Or = 'or';
case Not = 'not';
case Equals = 'equals';
case NotEquals = 'notEquals';
case IsEmpty = 'isEmpty';
case IsNotEmpty = 'isNotEmpty';
case IsTrue = 'isTrue';
case IsFalse = 'isFalse';
case Contains = 'contains';
case Has = 'has';
case NotContains = 'notContains';
case NotHas = 'notHas';
case StartsWith = 'startsWith';
case EndsWith = 'endsWith';
case Matches = 'matches';
case GreaterThan = 'greaterThan';
case LessThan = 'lessThan';
case GreaterThanOrEquals = 'greaterThanOrEquals';
case LessThanOrEquals = 'lessThanOrEquals';
case In = 'in';
case NotIn = 'notIn';
case IsToday = 'isToday';
case InFuture = 'inFuture';
case InPast = 'inPast';
}

View File

@@ -0,0 +1,399 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Text\MetadataProvider as TextMetadataProvider;
use Espo\Core\Templates\Entities\Company;
use Espo\Core\Templates\Entities\Person;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\EmailAddress;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\SelectBuilder;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use RuntimeException;
class AddressService
{
private const ERASED_PREFIX = 'ERASED:';
private const ATTR_EMAIL_ADDRESS = 'emailAddress';
public function __construct(
private Config $config,
private Acl $acl,
private Metadata $metadata,
private SelectBuilderFactory $selectBuilderFactory,
private EntityManager $entityManager,
private User $user,
private TextMetadataProvider $textMetadataProvider
) {}
/**
* @return array<int, array<string, mixed>>
* @throws NotFound
* @throws Forbidden
*/
public function searchInEntityType(string $entityType, string $query, int $limit): array
{
if (!in_array($entityType, $this->getHavingEmailAddressEntityTypeList())) {
throw new NotFound("No 'email' field.");
}
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_READ)) {
throw new Forbidden("No access to $entityType.");
}
if (!$this->acl->checkField($entityType, 'email')) {
throw new Forbidden("No access to field 'email' in $entityType.");
}
$result = [];
$this->findInAddressBookByEntityType($query, $limit, $entityType, $result);
return $result;
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchInAddressBook(string $query, int $limit, bool $onlyActual = false): array
{
$result = [];
$entityTypeList = $this->config->get('emailAddressLookupEntityTypeList') ?? [];
$allEntityTypeList = $this->getHavingEmailAddressEntityTypeList();
foreach ($entityTypeList as $entityType) {
if (!in_array($entityType, $allEntityTypeList)) {
continue;
}
if (!$this->acl->checkScope($entityType)) {
continue;
}
$this->findInAddressBookByEntityType($query, $limit, $entityType, $result, $onlyActual);
}
$this->findInInboundEmail($query, $result);
$finalResult = [];
foreach ($result as $item) {
foreach ($finalResult as $item1) {
if ($item['emailAddress'] == $item1['emailAddress']) {
continue 2;
}
}
$finalResult[] = $item;
}
usort($finalResult, function ($item1, $item2) use ($query) {
if (!str_contains($query, '@')) {
return 0;
}
$p1 = strpos($item1['emailAddress'], $query);
$p2 = strpos($item2['emailAddress'], $query);
if ($p1 === 0 && $p2 !== 0) {
return -1;
}
if ($p1 !== 0 && $p2 !== 0) {
return 0;
}
if ($p1 !== 0 && $p2 === 0) {
return 1;
}
return 0;
});
return $finalResult;
}
/**
* @return string[]
*/
private function getHavingEmailAddressEntityTypeList(): array
{
$list = [
Account::ENTITY_TYPE,
Contact::ENTITY_TYPE,
Lead::ENTITY_TYPE,
User::ENTITY_TYPE,
];
$scopeDefs = $this->metadata->get(['scopes']);
foreach ($scopeDefs as $scope => $defs) {
if (
empty($defs['disabled']) &&
!empty($defs['type']) &&
(
$defs['type'] === Person::TEMPLATE_TYPE ||
$defs['type'] === Company::TEMPLATE_TYPE
)
) {
$list[] = $scope;
}
}
return $list;
}
/**
* @param array<int, array<string, mixed>> $result
*/
private function findInAddressBookByEntityType(
string $filter,
int $limit,
string $entityType,
array &$result,
bool $onlyActual = false
): void {
$textFilter = null;
$whereClause = [];
$byEmailAddress = false;
if (str_contains($filter, '@')) {
$byEmailAddress = true;
}
if (
!$byEmailAddress &&
mb_strlen($filter) < (int) $this->config->get('fullTextSearchMinLength') &&
$this->hasFullTextSearch($entityType)
) {
$byEmailAddress = true;
}
if ($byEmailAddress) {
$whereClause = [
'emailAddress*' => $filter . '%',
];
} else {
$textFilter = $filter;
}
$selectBuilder = $this->selectBuilderFactory
->create()
->from($entityType)
->withAccessControlFilter();
if ($textFilter) {
$selectBuilder->withTextFilter($textFilter);
}
try {
$builder = $selectBuilder
->buildQueryBuilder()
->where($whereClause)
->order('name')
->limit(0, $limit);
} catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
if ($entityType === User::ENTITY_TYPE) {
$this->handleQueryBuilderUser($builder);
}
$select = [
Field::ID,
'emailAddress',
Field::NAME,
];
if (
$this->metadata->get(['entityDefs', $entityType, 'fields', Field::NAME, 'type']) === FieldType::PERSON_NAME
) {
$select[] = 'firstName';
$select[] = 'lastName';
}
$builder->select($select);
$collection = $this->entityManager
->getRDBRepository($entityType)
->clone($builder->build())
->find();
foreach ($collection as $entity) {
$emailAddress = $entity->get(self::ATTR_EMAIL_ADDRESS);
$emailAddressData = $this->getEmailAddressRepository()->getEmailAddressData($entity);
$skipPrimaryEmailAddress = false;
if (!$emailAddress) {
continue;
}
if (str_starts_with($emailAddress, self::ERASED_PREFIX)) {
$skipPrimaryEmailAddress = true;
}
if ($onlyActual) {
if ($entity->get('emailAddressIsOptedOut')) {
$skipPrimaryEmailAddress = true;
}
foreach ($emailAddressData as $item) {
if ($emailAddress !== $item->emailAddress) {
continue;
}
if (!empty($item->invalid)) {
$skipPrimaryEmailAddress = true;
}
}
}
if (!$skipPrimaryEmailAddress) {
$result[] = [
'emailAddress' => $emailAddress,
'entityName' => $entity->get(Field::NAME),
'entityType' => $entityType,
'entityId' => $entity->getId(),
];
}
foreach ($emailAddressData as $item) {
if ($emailAddress === $item->emailAddress) {
continue;
}
if (str_starts_with($item->emailAddress, self::ERASED_PREFIX)) {
continue;
}
if ($onlyActual) {
if (!empty($item->invalid)) {
continue;
}
if (!empty($item->optOut)) {
continue;
}
}
$result[] = [
'emailAddress' => $item->emailAddress,
'entityName' => $entity->get(Field::NAME),
'entityType' => $entityType,
'entityId' => $entity->getId(),
];
}
}
}
/**
* @param array<int, array<string, mixed>> $result
*/
private function findInInboundEmail(string $query, array &$result): void
{
if ($this->user->isPortal()) {
return;
}
$list = $this->entityManager
->getRDBRepository(InboundEmail::ENTITY_TYPE)
->select([
'id',
'name',
'emailAddress',
])
->where([
'emailAddress*' => $query . '%',
])
->order('name')
->find();
foreach ($list as $item) {
$result[] = [
'emailAddress' => $item->getEmailAddress(),
'entityName' => $item->getName(),
'entityType' => InboundEmail::ENTITY_TYPE,
'entityId' => $item->getId(),
];
}
}
private function hasFullTextSearch(string $entityType): bool
{
return $this->textMetadataProvider->hasFullTextSearch($entityType);
}
private function handleQueryBuilderUser(SelectBuilder $queryBuilder): void
{
/*if ($this->acl->getPermissionLevel('portalPermission') === Table::LEVEL_NO) {
$queryBuilder->where([
'type!=' => User::TYPE_PORTAL,
]);
}*/
$queryBuilder->where([
'isActive' => true,
'type!=' => [
User::TYPE_PORTAL,
User::TYPE_API,
User::TYPE_SYSTEM,
User::TYPE_SUPER_ADMIN,
],
]);
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Unmark emails as important.
*/
class DeleteInboxImportant implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->markAsNotImportantIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Retrieves emails from trash.
*/
class DeleteInboxInTrash implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->retrieveFromTrashIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Unmark emails as read.
*/
class DeleteInboxRead implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->markAsNotReadIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\Forbidden;
use Espo\Entities\Email as EmailEntity;
use Espo\Tools\EmailTemplate\InsertField\Service as InsertFieldService;
class GetInsertFieldData implements Action
{
public function __construct(
private InsertFieldService $service,
private Acl $acl
) {}
public function process(Request $request): Response
{
if (!$this->acl->checkScope(EmailEntity::ENTITY_TYPE, Table::ACTION_CREATE)) {
throw new Forbidden();
}
$data = $this->service->getData(
$request->getQueryParam('parentType'),
$request->getQueryParam('parentId'),
$request->getQueryParam('to')
);
return ResponseComposer::json($data);
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Tools\Email\InboxService;
class GetNotReadCounts implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $this->inboxService->getFoldersNotReadCounts();
return ResponseComposer::json($data);
}
}

View File

@@ -0,0 +1,87 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Attachment\FieldData;
use Espo\Tools\Email\Service;
/**
* Copies email attachments.
*/
class PostAttachmentsCopy implements Action
{
public function __construct(private Service $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest();
}
$data = $request->getParsedBody();
$field = $data->field ?? null;
$parentType = $data->parentType ?? null;
$relatedType = $data->relatedType ?? null;
if (!$field) {
throw new BadRequest("No `field`.");
}
try {
$fieldData = new FieldData($field, $parentType, $relatedType);
} catch (Error $e) {
throw new BadRequest($e->getMessage());
}
$list = $this->service->copyAttachments($id, $fieldData);
$ids = array_map(fn ($item) => $item->getId(), $list);
$names = (object) [];
foreach ($list as $item) {
$names->{$item->getId()} = $item->getName();
}
return ResponseComposer::json([
'ids' => $ids,
'names' => $names,
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Moves emails to a folder.
*/
class PostFolder implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$folderId = $request->getRouteParam('folderId');
if (!$folderId) {
throw new BadRequest();
}
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
if (count($ids) === 1) {
$this->inboxService->moveToFolder($ids[0], $folderId);
}
$this->inboxService->moveToFolderIdList($ids, $folderId);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,82 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\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\Forbidden;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\Tools\Email\ImportEmlService;
/**
* @noinspection PhpUnused
*/
class PostImportEml implements Action
{
public function __construct(
private Acl $acl,
private User $user,
private ImportEmlService $service,
) {}
public function process(Request $request): Response
{
$this->checkAccess();
$fileId = $request->getParsedBody()->fileId ?? null;
if (!is_string($fileId)) {
throw new BadRequest("No 'fileId'.");
}
$email = $this->service->import($fileId, $this->user->getId());
return ResponseComposer::json(['id' => $email->getId()]);
}
/**
* @throws Forbidden
*/
private function checkAccess(): void
{
if (!$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
throw new Forbidden("No 'create' access.");
}
if (!$this->acl->checkScope('Import')) {
throw new Forbidden("No access to 'Import'.");
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Marks emails as important.
*/
class PostInboxImportant implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->markAsImportantIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,65 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Moves emails to trash.
*/
class PostInboxInTrash implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->moveToTrashIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email\Api;
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\Tools\Email\InboxService;
/**
* Marks emails as read.
*/
class PostInboxRead implements Action
{
public function __construct(private InboxService $inboxService) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
if (!empty($data->all)) {
$this->inboxService->markAllAsRead();
return ResponseComposer::json(true);
}
$ids = $data->ids ?? null;
$id = $data->id ?? null;
if ($ids === null && is_string($id)) {
$ids = [$id];
}
if (!is_array($ids)) {
throw new BadRequest("No `ids`.");
}
$this->inboxService->markAsReadIdList($ids);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,118 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\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,177 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\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\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Field\LinkParent;
use Espo\Core\Notification\UserEnabledChecker;
use Espo\Core\Record\EntityProvider;
use Espo\Entities\Email;
use Espo\Entities\Notification;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class PostUsers implements Action
{
public function __construct(
private EntityProvider $entityProvider,
private Acl $acl,
private EntityManager $entityManager,
private UserEnabledChecker $userEnabledChecker,
private User $user,
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id') ?? throw new RuntimeException();
$data = $request->getParsedBody();
$email = $this->getEmail($id);
$foreignIds = [];
if (isset($data->id)) {
$foreignIds[] = $data->id;
}
if (isset($data->ids) && is_array($data->ids)) {
foreach ($data->ids as $foreignId) {
$foreignIds[] = $foreignId;
}
}
foreach ($foreignIds as $foreignId) {
if (!is_string($foreignId)) {
throw new BadRequest("Bad ID.");
}
}
$relation = $this->entityManager->getRelation($email, 'users');
foreach ($this->getUsers($foreignIds) as $user) {
if ($relation->isRelated($user)) {
continue;
}
$relation->relate($user);
if ($this->user->getId() === $user->getId()) {
continue;
}
$this->processNotify($email, $user);
}
return ResponseComposer::json(true);
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function getEmail(string $id): Email
{
$email = $this->entityProvider->getByClass(Email::class, $id);
if (!$this->acl->checkEntityEdit($email)) {
throw new Forbidden("No edit access to email.");
}
return $email;
}
/**
* @param string[] $foreignIds
* @return User[]
* @throws Forbidden
* @throws NotFound
*/
private function getUsers(array $foreignIds): iterable
{
/** @var iterable<User> $users */
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->where([Attribute::ID => $foreignIds])
->find();
if (is_countable($users) && count($users) !== count($foreignIds)) {
throw new NotFound("Users not found.");
}
foreach ($users as $user) {
if (!$this->acl->checkAssignmentPermission($user)) {
throw new Forbidden("No assignment permission to user.");
}
if (!$this->acl->checkEntityRead($user)) {
throw new Forbidden("No access to user.");
}
if (!$user->isRegular() && !$user->isAdmin()) {
throw new Forbidden("Only regular and admin users allowed.");
}
}
return $users;
}
private function processNotify(Email $email, User $user): void
{
if (!$this->userEnabledChecker->checkAssignment(Email::ENTITY_TYPE, $user->getId())) {
return;
}
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
$notification
->setType('EmailInbox')
->setRelated(LinkParent::createFromEntity($email))
->setUserId($user->getId())
->setData([
'emailName' => $email->getSubject(),
'userId' => $this->user->getId(),
'userName' => $this->user->getName(),
]);
$this->entityManager->saveEntity($notification);
}
}

View File

@@ -0,0 +1,69 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\Field\EmailAddress;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use stdClass;
class EmailAddressEntityPair
{
private EmailAddress $emailAddress;
private Entity $entity;
public function __construct(
EmailAddress $emailAddress,
Entity $entity
) {
$this->emailAddress = $emailAddress;
$this->entity = $entity;
}
public function getEmailAddress(): EmailAddress
{
return $this->emailAddress;
}
public function getEntity(): Entity
{
return $this->entity;
}
public function getValueMap(): stdClass
{
return (object) [
'emailAddress' => $this->emailAddress->getAddress(),
'name' => $this->entity->get(Field::NAME),
'entityId' => $this->entity->getId(),
'entityType' => $this->entity->getEntityType(),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
class Folder
{
public const ALL = 'all';
public const INBOX = 'inbox';
public const SENT = 'sent';
public const DRAFTS = 'drafts';
public const IMPORTANT = 'important';
public const ARCHIVE = 'archive';
public const TRASH = 'trash';
}

View File

@@ -0,0 +1,132 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FileStorage\Manager;
use Espo\Core\Mail\Importer;
use Espo\Core\Mail\Importer\Data;
use Espo\Core\Mail\MessageWrapper;
use Espo\Core\Mail\Parsers\MailMimeParser;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
class ImportEmlService
{
public function __construct(
private Importer $importer,
private Importer\DuplicateFinder $duplicateFinder,
private EntityManager $entityManager,
private Manager $fileStorageManager,
private MailMimeParser $parser,
) {}
/**
* Import an EML.
*
* @param string $fileId An attachment ID.
* @param ?string $userId A user ID to relate an email with.
* @return Email An Email.
* @throws NotFound
* @throws Error
* @throws Conflict
*/
public function import(string $fileId, ?string $userId = null): Email
{
$attachment = $this->getAttachment($fileId);
$contents = $this->fileStorageManager->getContents($attachment);
$message = new MessageWrapper(1, null, $this->parser, $contents);
$this->checkDuplicate($message);
$email = $this->importer->import($message, Data::create());
if (!$email) {
throw new Error("Could not import.");
}
if ($userId) {
$this->entityManager->getRDBRepositoryByClass(Email::class)
->getRelation($email, 'users')
->relateById($userId);
}
$this->entityManager->removeEntity($attachment);
return $email;
}
/**
* @throws NotFound
*/
private function getAttachment(string $fileId): Attachment
{
$attachment = $this->entityManager->getRDBRepositoryByClass(Attachment::class)->getById($fileId);
if (!$attachment) {
throw new NotFound("Attachment not found.");
}
return $attachment;
}
/**
* @throws Conflict
*/
private function checkDuplicate(MessageWrapper $message): void
{
$messageId = $this->parser->getMessageId($message);
if (!$messageId) {
return;
}
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->setMessageId($messageId);
$duplicate = $this->duplicateFinder->find($email, $message);
if (!$duplicate) {
return;
}
throw Conflict::createWithBody(
'Email is already imported.',
Error\Body::create()->withMessageTranslation('alreadyImported', Email::ENTITY_TYPE, [
'id' => $duplicate->getId(),
'link' => '#Email/view/' . $duplicate->getId(),
])
);
}
}

View File

@@ -0,0 +1,719 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\AclManager;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error\Body;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Log;
use Espo\Core\WebSocket\Submission as WebSocketSubmission;
use Espo\Entities\Email;
use Espo\Entities\EmailFolder;
use Espo\Entities\GroupEmailFolder;
use Espo\Entities\Notification;
use Espo\Entities\Team;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\SelectBuilder;
use Exception;
use RuntimeException;
class InboxService
{
public function __construct(
private User $user,
private EntityManager $entityManager,
private AclManager $aclManager,
private Log $log,
private SelectBuilderFactory $selectBuilderFactory,
private WebSocketSubmission $webSocketSubmission,
) {}
/**
* @param string[] $idList
*/
public function moveToFolderIdList(array $idList, ?string $folderId, ?string $userId = null): void
{
foreach ($idList as $id) {
try {
$this->moveToFolder($id, $folderId, $userId);
} catch (Exception) {}
}
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function moveToFolder(string $id, ?string $folderId, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
if ($folderId === Folder::INBOX) {
$folderId = null;
}
$email = $this->getEmail($id);
$user = $this->getUser($userId);
if ($email->getGroupFolder()) {
$this->checkCurrentGroupFolder($email->getGroupFolder()->getId(), $user);
}
if ($folderId && str_starts_with($folderId, 'group:')) {
try {
$this->moveToGroupFolder($email, substr($folderId, 6), $user);
} catch (Exception $e) {
$this->log->debug("Could not move email to group folder. {message}", ['message' => $e->getMessage()]);
throw $e;
}
return;
}
if ($folderId === Folder::ARCHIVE) {
$this->moveToArchive($email, $user);
return;
}
if ($email->getGroupFolder()) {
if (!$this->aclManager->checkEntityEdit($user, $email)) {
throw Forbidden::createWithBody(
"Cannot move out from group folder. No edit access to email.",
Body::create()->withMessageTranslation('groupMoveOutNoEditAccess', 'Email')
);
}
$email
->setGroupFolder(null)
->setGroupStatusFolder(null);
$this->entityManager->saveEntity($email);
}
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set([
'folderId' => $folderId,
'inTrash' => false,
'inArchive' => false,
])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function moveToGroupFolder(Email $email, string $folderId, User $user): void
{
$folder = $this->getGroupFolder($folderId);
if (!$this->aclManager->checkEntityRead($user, $folder)) {
throw new Forbidden("Cannot move to group folder. No access to folder.");
}
if (!$this->aclManager->checkEntityEdit($user, $email)) {
throw Forbidden::createWithBody(
"Cannot move to group folder. No edit access to email.",
Body::create()->withMessageTranslation('groupMoveToNoEditAccess', 'Email')
);
}
$email
->setGroupFolder($folder)
->setGroupStatusFolder(null);
$this->applyGroupFolder($email, $folder);
$this->entityManager->saveEntity($email);
$this->retrieveFromArchive($email, $user);
}
/**
* @param string[] $idList
*/
public function moveToTrashIdList(array $idList, ?string $userId = null): bool
{
foreach ($idList as $id) {
try {
$this->moveToTrash($id, $userId);
} catch (Exception) {}
}
return true;
}
/**
* @param string[] $idList
*/
public function retrieveFromTrashIdList(array $idList, ?string $userId = null): void
{
foreach ($idList as $id) {
try {
$this->retrieveFromTrash($id, $userId);
} catch (Exception) {}
}
}
/**
* @throws NotFound
* @throws Forbidden
*/
public function moveToTrash(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$email = $this->getEmail($id);
$user = $this->getUser($userId);
if ($email->getGroupFolder()) {
$folder = $this->getGroupFolder($email->getGroupFolder()->getId());
if (!$this->aclManager->checkEntityRead($user, $folder)) {
throw Forbidden::createWithBody(
"Cannot move email from group folder to trash. No access to group folder.",
Body::create()->withMessageTranslation('groupFolderNoAccess', 'Email')
);
}
if (!$this->aclManager->checkEntityEdit($user, $email)) {
throw Forbidden::createWithBody(
"Cannot move email from group folder to trash.",
Body::create()->withMessageTranslation('groupMoveToTrashNoEditAccess', 'Email')
);
}
$email->setGroupStatusFolder(Email::GROUP_STATUS_FOLDER_TRASH);
$this->entityManager->saveEntity($email);
return;
}
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['inTrash' => true])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
$this->markNotificationAsRead($id, $userId);
}
/**
* @throws Forbidden
* @throws NotFound
*/
public function retrieveFromTrash(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$email = $this->getEmail($id);
$user = $this->getUser($userId);
if ($email->getGroupFolder()) {
$folder = $this->getGroupFolder($email->getGroupFolder()->getId());
if (!$this->aclManager->checkEntityEdit($user, $email)) {
throw Forbidden::createWithBody(
"Cannot retrieve group folder email from trash. No edit to email.",
Body::create()->withMessageTranslation('notEditAccess', 'Email')
);
}
if (!$this->aclManager->checkEntityRead($user, $folder)) {
throw Forbidden::createWithBody(
"Cannot retrieve group folder email from trash. No access to group folder.",
Body::create()->withMessageTranslation('groupFolderNoAccess', 'Email')
);
}
$email->setGroupStatusFolder(null);
$this->entityManager->saveEntity($email);
return;
}
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['inTrash' => false])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
/**
* @param string[] $idList
*/
public function markAsReadIdList(array $idList, ?string $userId = null): void
{
foreach ($idList as $id) {
$this->markAsRead($id, $userId);
}
}
/**
* @param string[] $idList
*/
public function markAsNotReadIdList(array $idList, ?string $userId = null): void
{
foreach ($idList as $id) {
$this->markAsNotRead($id, $userId);
}
}
public function markAsRead(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['isRead' => true])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
$this->markNotificationAsRead($id, $userId);
}
public function markAsNotRead(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['isRead' => false])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
/**
* @param string[] $idList
*/
public function markAsImportantIdList(array $idList, ?string $userId = null): void
{
foreach ($idList as $id) {
$this->markAsImportant($id, $userId);
}
}
/**
* @param string[] $idList
*/
public function markAsNotImportantIdList(array $idList, ?string $userId = null): void
{
foreach ($idList as $id) {
$this->markAsNotImportant($id, $userId);
}
}
public function markAsImportant(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['isImportant' => true])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
public function markAsNotImportant(string $id, ?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['isImportant' => false])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'emailId' => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
public function markAllAsRead(?string $userId = null): void
{
$userId = $userId ?? $this->user->getId();
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set(['isRead' => true])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'isRead' => false,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
$update = $this
->entityManager
->getQueryBuilder()
->update()
->in(Notification::ENTITY_TYPE)
->set(['read' => true])
->where([
Attribute::DELETED => false,
'userId' => $userId,
'relatedType' => Email::ENTITY_TYPE,
'read' => false,
'type' => Notification::TYPE_EMAIL_RECEIVED,
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
$this->submitNotificationWebSocket($userId);
}
public function markNotificationAsRead(string $id, string $userId): void
{
$notification = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->where([
'userId' => $userId,
'relatedType' => Email::ENTITY_TYPE,
'relatedId' => $id,
'read' => false,
'type' => Notification::TYPE_EMAIL_RECEIVED,
])
->findOne();
if (!$notification) {
return;
}
$notification->setRead();
$this->entityManager->saveEntity($notification);
$this->submitNotificationWebSocket($userId);
}
private function submitNotificationWebSocket(string $userId): void
{
$this->webSocketSubmission->submit('newNotification', $userId);
}
/**
* @return array<string, int>
*/
public function getFoldersNotReadCounts(): array
{
$data = [];
$selectBuilder = $this->selectBuilderFactory
->create()
->from(Email::ENTITY_TYPE)
->withAccessControlFilter();
$draftsSelectBuilder = clone $selectBuilder;
$selectBuilder->withWhere(
WhereItem::fromRaw([
'type' => 'isTrue',
'attribute' => 'isNotRead',
])
);
$folderIdList = [Folder::INBOX, Folder::DRAFTS];
$emailFolderList = $this->entityManager
->getRDBRepository(EmailFolder::ENTITY_TYPE)
->where([
'assignedUserId' => $this->user->getId(),
])
->find();
foreach ($emailFolderList as $folder) {
$folderIdList[] = $folder->getId();
}
$groupFolderList = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->distinct()
->leftJoin(Field::TEAMS)
->where(
$this->user->isAdmin() ?
['id!=' => null] :
['teams.id' => $this->user->getTeamIdList()]
)
->find();
foreach ($groupFolderList as $folder) {
$folderIdList[] = 'group:' . $folder->getId();
}
foreach ($folderIdList as $folderId) {
$itemSelectBuilder = clone $selectBuilder;
if ($folderId === Folder::DRAFTS) {
$itemSelectBuilder = clone $draftsSelectBuilder;
}
$itemSelectBuilder->withWhere(
WhereItem::fromRaw([
'type' => 'inFolder',
'attribute' => 'folderId',
'value' => $folderId,
])
);
try {
$data[$folderId] = $this->entityManager
->getRDBRepository(Email::ENTITY_TYPE)
->clone($itemSelectBuilder->build())
->count();
} catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
}
return $data;
}
/**
* @throws Forbidden
*/
public function moveToArchive(Email $email, User $user): void
{
if (!$this->aclManager->checkEntityRead($user, $email)) {
throw new Forbidden("No read access to email.");
}
if ($email->getGroupFolder()) {
if (!$this->aclManager->checkEntityEdit($user, $email)) {
throw Forbidden::createWithBody(
"Cannot move from group folder to Archive. No edit access to email.",
Body::create()->withMessageTranslation('groupMoveToArchiveNoEditAccess', 'Email')
);
}
$email->setGroupStatusFolder(Email::GROUP_STATUS_FOLDER_ARCHIVE);
$this->entityManager->saveEntity($email);
return;
}
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set([
'folderId' => null,
'inArchive' => true,
'inTrash' => false,
])
->where([
Attribute::DELETED => false,
'userId' => $user->getId(),
'emailId' => $email->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
public function retrieveFromArchive(Email $email, User $user): void
{
$update = $this->entityManager
->getQueryBuilder()
->update()
->in(Email::RELATIONSHIP_EMAIL_USER)
->set([
'folderId' => null,
'inArchive' => false,
])
->where([
Attribute::DELETED => false,
'userId' => $user->getId(),
'emailId' => $email->getId(),
])
->build();
$this->entityManager->getQueryExecutor()->execute($update);
}
/**
* @throws Forbidden
*/
private function checkCurrentGroupFolder(string $folderId, User $user): void
{
$folder = $this->entityManager->getEntityById(GroupEmailFolder::ENTITY_TYPE, $folderId);
if ($folder && !$this->aclManager->checkEntityRead($user, $folder)) {
throw new Forbidden("No access to current group folder.");
}
}
/**
* @throws NotFound
*/
private function getUser(string $userId): User
{
$user = $userId === $this->user->getId() ?
$this->user :
$this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user) {
throw new NotFound("User not found.");
}
return $user;
}
/**
* @throws NotFound
*/
private function getEmail(string $id): Email
{
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getById($id);
if (!$email) {
throw new NotFound();
}
return $email;
}
/**
* @throws NotFound
*/
private function getGroupFolder(string $folderId): GroupEmailFolder
{
$folder = $this->entityManager->getRDBRepositoryByClass(GroupEmailFolder::class)->getById($folderId);
if (!$folder) {
throw new NotFound("Group folder not found.");
}
return $folder;
}
private function applyGroupFolder(Email $email, GroupEmailFolder $folder): void
{
if (!$folder->getTeams()->getCount()) {
return;
}
foreach ($folder->getTeams()->getIdList() as $teamId) {
$email->addTeamId($teamId);
}
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->select([Attribute::ID])
->where([
'type' => [User::TYPE_REGULAR, User::TYPE_ADMIN],
'isActive' => true,
])
->where(
Condition::in(
Expression::column(Attribute::ID),
SelectBuilder::create()
->from(Team::RELATIONSHIP_TEAM_USER)
->select('userId')
->where(['teamId' => $folder->getTeams()->getIdList()])
->build()
)
)
->find();
foreach ($users as $user) {
$email->addUserId($user->getId());
}
}
}

View File

@@ -0,0 +1,604 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\ErrorSilent;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\Mail\Account\Account;
use Espo\Core\Mail\Account\GroupAccount\Account as GroupAccount;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\Account\GroupAccount\Service as GroupAccountService;
use Espo\Core\Mail\Account\PersonalAccount\Account as PersonalAccount;
use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory;
use Espo\Core\Mail\Account\PersonalAccount\Service as PersonalAccountService;
use Espo\Core\Mail\Account\SendingAccountProvider;
use Espo\Core\Mail\ConfigDataProvider;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Mail\Sender;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\Smtp\HandlerProcessor;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Name\Link;
use Espo\Core\ORM\Repository\Option\SaveOption;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailAddress;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\CaseObj;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\Stream\Service as StreamService;
use Exception;
use LogicException;
use const FILTER_VALIDATE_EMAIL;
/**
* Email sending service.
*/
class SendService
{
private const LINK_EMAIL_ADDRESSES = Link::EMAIL_ADDRESSES;
/** @var string[] */
private array $notAllowedStatusList = [
Email::STATUS_ARCHIVED,
Email::STATUS_SENT,
Email::STATUS_BEING_IMPORTED,
];
public function __construct(
private User $user,
private EntityManager $entityManager,
private FieldValidationManager $fieldValidationManager,
private EmailSender $emailSender,
private StreamService $streamService,
private Config $config,
private Log $log,
private Acl $acl,
private SendingAccountProvider $accountProvider,
private PersonalAccountService $personalAccountService,
private GroupAccountService $groupAccountService,
private HandlerProcessor $handlerProcessor,
private PersonalAccountFactory $personalAccountFactory,
private GroupAccountFactory $groupAccountFactory,
private ConfigDataProvider $configDataProvider,
) {}
/**
* Send an email entity.
*
* @params Email $entity An email entity.
* @params ?User $user A user from what to send.
*
* @throws BadRequest If not valid.
* @throws SendingError On error while sending.
* @throws NoSmtp No SMTP settings.
* @throws Error An error.
*/
public function send(Email $entity, ?User $user = null): void
{
if (in_array($entity->getStatus(), $this->notAllowedStatusList)) {
throw new Error("Can't send email with status `{$entity->getStatus()}`.");
}
if (!$this->fieldValidationManager->check($entity, 'to', 'required')) {
$entity->setStatus(Email::STATUS_DRAFT);
$this->entityManager->saveEntity($entity, [SaveOption::SILENT => true]);
throw new BadRequest("Empty To address.");
}
$systemIsShared = $this->configDataProvider->isSystemOutboundAddressShared();
$systemFromName = $this->config->get('outboundEmailFromName');
$systemFromAddress = $this->configDataProvider->getSystemOutboundAddress();
$sender = $this->emailSender->create();
$userAddressList = [];
if ($user) {
// @todo Use getEmailAddressGroup.
/** @var Collection<EmailAddress> $emailAddressCollection */
$emailAddressCollection = $this->entityManager
->getRelation($user, self::LINK_EMAIL_ADDRESSES)
->find();
foreach ($emailAddressCollection as $ea) {
$userAddressList[] = $ea->getLower();
}
}
$originalFromAddress = $entity->getFromAddress();
if (!$originalFromAddress) {
throw new Error("Email sending: Can't send with empty 'from' address.");
}
$fromAddress = strtolower($originalFromAddress);
$isUserAddress = in_array($fromAddress, $userAddressList);
$isSystemAddress = $fromAddress === strtolower($systemFromAddress ?? '');
$smtpParams = null;
$personalAccount = null;
$groupAccount = null;
$params = SenderParams::create();
if ($user && $isUserAddress) {
[$smtpParams, $personalAccount] = $this->getPersonalAccount($user, $originalFromAddress);
}
if ($user && $smtpParams) {
$sender->withSmtpParams($smtpParams);
}
if (!$smtpParams) {
[$smtpParams, $groupAccount] = $this->getGroupAccount($user, $originalFromAddress);
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
}
if (!$smtpParams && $isSystemAddress) {
$params = $params->withFromName($systemFromName);
}
// Otherwise, allow users to send from the system SMTP with their own from-address.
if (!$smtpParams && !$systemIsShared) {
if ($isSystemAddress) {
throw new NoSmtp("Can not use system SMTP. System SMTP is not shared.");
}
throw new NoSmtp("No SMTP params for $fromAddress.");
}
if (
!$smtpParams &&
$user &&
in_array($fromAddress, $userAddressList)
) {
$params = $params->withFromName($user->getName());
}
$parent = null;
$parentId = $entity->getParentId();
$parentType = $entity->getParentType();
if ($parentType && $parentId) {
$parent = $this->entityManager->getEntityById($parentType, $parentId);
$params = $this->applyParent($parent, $params);
}
$this->validateEmailAddresses($entity);
$messageContainer = new Sender\MessageContainer();
if (
$groupAccount instanceof GroupAccount && $groupAccount->storeSentEmails() ||
$personalAccount instanceof PersonalAccount && $personalAccount->storeSentEmails()
) {
$sender->withMessageContainer($messageContainer);
}
$this->applyReplied($entity, $sender);
$sender->withParams($params);
try {
$sender->send($entity);
} catch (Exception $e) {
$entity->setStatus(Email::STATUS_DRAFT);
$this->entityManager->saveEntity($entity, [SaveOption::SILENT => true]);
$this->log->error("Email sending: " . $e->getMessage(), ['exception' => $e]);
$errorData = [
'id' => $entity->getId(),
'message' => $e->getMessage(),
];
throw ErrorSilent::createWithBody('sendingFail', Json::encode($errorData));
}
if ($groupAccount) {
$groupAccountId = $groupAccount->getId();
if ($groupAccountId) {
$entity->addLinkMultipleId('inboundEmails', $groupAccountId);
}
}
if ($personalAccount) {
$personalAccountId = $personalAccount->getId();
if ($personalAccountId) {
$entity->addLinkMultipleId('emailAccounts', $personalAccountId);
}
}
$this->entityManager->saveEntity($entity, [Email::SAVE_OPTION_IS_JUST_SENT => true]);
$this->store($messageContainer, $groupAccount, $personalAccount);
if ($parent) {
$this->streamService->noteEmailSent($parent, $entity);
}
}
private function applyParent(?Entity $parent, SenderParams $params): SenderParams
{
// @todo Refactor. Move to a separate class? Make extensible?
if ($parent instanceof CaseObj) {
$inboundEmailId = $parent->getInboundEmailId();
if (!$inboundEmailId) {
return $params;
}
$inboundEmail = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->getById($inboundEmailId);
if (!$inboundEmail || !$inboundEmail->getReplyToAddress()) {
return $params;
}
$params = $params->withReplyToAddress($inboundEmail->getReplyToAddress());
}
return $params;
}
private function store(
Sender\MessageContainer $messageContainer,
?Account $groupAccount,
?Account $personalAccount,
): void {
$message = $messageContainer->message;
if (!$message) {
return;
}
if ($groupAccount instanceof GroupAccount && $groupAccount->storeSentEmails()) {
$id = $groupAccount->getId() ?? null;
if (!$id) {
throw new LogicException();
}
try {
$this->groupAccountService->storeSentMessage($id, $message);
} catch (Exception $e) {
$text = "Could not store sent email; group account {$groupAccount->getId()}); " .
$e->getMessage() . ".";
$this->log->error($text, ['exception' => $e]);
}
}
if ($personalAccount instanceof PersonalAccount && $personalAccount->storeSentEmails()) {
$id = $personalAccount->getId() ?? null;
if (!$id) {
throw new LogicException();
}
try {
$this->personalAccountService->storeSentMessage($id, $message);
} catch (Exception $e) {
$text = "Could not store sent email; personal account {$personalAccount->getId()}; " .
$e->getMessage() . ".";
$this->log->error($text, ['exception' => $e]);
}
}
}
/**
* @return array{?SmtpParams, ?Account}
*/
private function getPersonalAccount(User $user, string $emailAddress): array
{
$personalAccount = $this->accountProvider->getPersonal($user, $emailAddress);
if (!$personalAccount) {
return [null, null];
}
if (!$personalAccount->isAvailableForSending()) {
return [null, null];
}
$smtpParams = $personalAccount->getSmtpParams();
if (!$smtpParams) {
return [null, null];
}
$smtpParams = $smtpParams->withFromName($user->getName());
return [$smtpParams, $personalAccount];
}
/**
* @return array{?SmtpParams, ?Account}
*/
private function getGroupAccount(?User $user, string $emailAddress): array
{
$groupAccount = $user ?
$this->accountProvider->getShared($user, $emailAddress) :
$this->accountProvider->getGroup($emailAddress);
if (!$groupAccount) {
return [null, null];
}
$smtpParams = $groupAccount->getSmtpParams();
if (!$smtpParams) {
return [null, null];
}
return [$smtpParams, $groupAccount];
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
* @throws NoSmtp
*/
public function sendTestEmail(SmtpParams $params, TestSendData $data): void
{
$emailAddress = $data->getEmailAddress();
$userId = $data->getUserId();
$type = $data->getType();
$id = $data->getId();
if ($params->getPassword() === null) {
$params = $params->withPassword(
$this->obtainSendTestEmailPassword($type, $id)
);
}
if (
$userId &&
$userId !== $this->user->getId() &&
!$this->user->isAdmin()
) {
throw new Forbidden();
}
/** @var ?User $user */
$user = $userId ?
$this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId) :
null;
if ($userId && !$user) {
throw new NotFound("User not found.");
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email
->setSubject('EspoCRM: Test Email')
->setIsHtml(false)
->addToAddress($emailAddress);
$handlerClassName = null;
if ($type === 'emailAccount' && $id) {
/** @var ?EmailAccount $emailAccount */
$emailAccount = $this->entityManager->getEntityById(EmailAccount::ENTITY_TYPE, $id);
$handlerClassName = $emailAccount?->getSmtpHandlerClassName();
}
if ($type === 'inboundEmail' && $id) {
/** @var ?InboundEmail $inboundEmail */
$inboundEmail = $this->entityManager->getEntityById(InboundEmail::ENTITY_TYPE, $id);
if ($inboundEmail) {
$handlerClassName = $inboundEmail->getSmtpHandlerClassName();
}
}
if ($handlerClassName && $id) {
$params = $this->handlerProcessor->handle($handlerClassName, $params, $id);
}
try {
$this->emailSender
->withSmtpParams($params)
->send($email);
} catch (Exception $e) {
$this->log->warning("Email sending:" . $e->getMessage() . "; " . $e->getCode());
if ($e instanceof SendingError) {
throw ErrorSilent::createWithBody(
'sendingFail',
Error\Body::create()
->withMessageTranslation($e->getMessage(), Email::ENTITY_TYPE)
);
}
$errorData = ['message' => $e->getMessage()];
throw ErrorSilent::createWithBody('sendingFail', Json::encode($errorData));
}
}
/**
* @throws Error
*/
public function validateEmailAddresses(Email $entity): void
{
$from = $entity->getFromAddress();
if ($from) {
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
throw new Error('From email address is not valid.');
}
}
foreach ($entity->getToAddressList() as $address) {
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new Error('To email address is not valid.');
}
}
foreach ($entity->getCcAddressList() as $address) {
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new Error('CC email address is not valid.');
}
}
foreach ($entity->getBccAddressList() as $address) {
if (!filter_var($address, FILTER_VALIDATE_EMAIL)) {
throw new Error('BCC email address is not valid.');
}
}
}
/**
* Get a user personal SMTP params.
*/
public function getUserSmtpParams(string $userId): ?SmtpParams
{
/** @var ?User $user */
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user) {
return null;
}
$address = $user->getEmailAddress();
if (!$address) {
return null;
}
$account = $this->accountProvider->getPersonal($user, $address);
if (!$account) {
return null;
}
$smtpParams = $account->getSmtpParams();
return $smtpParams
?->withFromName($user->getName())
->withFromAddress($address);
}
/**
* @throws Forbidden
* @throws Error
* @throws NoSmtp
*/
private function obtainSendTestEmailPassword(?string $type, ?string $id): ?string
{
if ($type === 'emailAccount') {
if (!$this->acl->checkScope(EmailAccount::ENTITY_TYPE)) {
throw new Forbidden();
}
if (!$id) {
return null;
}
$personalAccount = $this->personalAccountFactory->create($id);
if (
!$this->user->isAdmin() &&
$personalAccount->getUser()->getId() !== $this->user->getId()
) {
throw new Forbidden();
}
$smtpParams = $personalAccount->getSmtpParams();
return $smtpParams?->getPassword();
}
if (!$this->user->isAdmin()) {
throw new Forbidden();
}
if ($type === 'inboundEmail') {
if (!$id) {
return null;
}
$smtpParams = $this->groupAccountFactory
->create($id)
->getSmtpParams();
return $smtpParams?->getPassword();
}
return $this->config->get('smtpPassword');
}
private function applyReplied(Email $entity, Sender $sender): void
{
$replied = $entity->getReplied();
if ($replied && $replied->getMessageId()) {
$sender->withAddedHeader('In-Reply-To', $replied->getMessageId());
$sender->withAddedHeader('References', $replied->getMessageId());
}
if ($replied && $replied->getGroupFolder()) {
$entity->setGroupFolder($replied->getGroupFolder());
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Record\ServiceContainer;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\Tools\Attachment\AccessChecker as AttachmentAccessChecker;
use Espo\Tools\Attachment\FieldData;
class Service
{
private EntityManager $entityManager;
private AttachmentAccessChecker $attachmentAccessChecker;
private ServiceContainer $serviceContainer;
public function __construct(
EntityManager $entityManager,
AttachmentAccessChecker $attachmentAccessChecker,
ServiceContainer $serviceContainer
) {
$this->entityManager = $entityManager;
$this->attachmentAccessChecker = $attachmentAccessChecker;
$this->serviceContainer = $serviceContainer;
}
/**
* Copy email attachments for re-using (e.g. in a forward email).
*
* @return Attachment[]
* @throws NotFound
* @throws Forbidden
*/
public function copyAttachments(string $id, FieldData $fieldData): array
{
/** @var ?Email $entity */
$entity = $this->serviceContainer
->get(Email::ENTITY_TYPE)
->getEntity($id);
if (!$entity) {
throw new NotFound();
}
$this->attachmentAccessChecker->check($fieldData);
$list = [];
foreach ($entity->getAttachmentIdList() as $attachmentId) {
$attachment = $this->copyAttachment($attachmentId, $fieldData);
if ($attachment) {
$list[] = $attachment;
}
}
return $list;
}
private function copyAttachment(string $attachmentId, FieldData $fieldData): ?Attachment
{
/** @var ?Attachment $attachment */
$attachment = $this->entityManager
->getRDBRepositoryByClass(Attachment::class)
->getById($attachmentId);
if (!$attachment) {
return null;
}
$copied = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
$copied->set('parentType', $fieldData->getParentType());
$copied->set('relatedType', $fieldData->getRelatedType());
$copied->setTargetField($fieldData->getField());
$copied->setRole(Attachment::ROLE_ATTACHMENT);
$this->getAttachmentRepository()->save($copied);
return $copied;
}
private function getAttachmentRepository(): AttachmentRepository
{
/** @var AttachmentRepository */
return $this->entityManager->getRepositoryByClass(Attachment::class);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
class TestSendData
{
private string $emailAddress;
private ?string $type;
private ?string $id;
private ?string $userId;
public function __construct(
string $emailAddress,
?string $type,
?string $id,
?string $userId
) {
$this->emailAddress = $emailAddress;
$this->type = $type;
$this->id = $id;
$this->userId = $userId;
}
public function getEmailAddress(): string
{
return $this->emailAddress;
}
public function getType(): ?string
{
return $this->type;
}
public function getId(): ?string
{
return $this->id;
}
public function getUserId(): ?string
{
return $this->userId;
}
}

View File

@@ -0,0 +1,168 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Email;
use League\HTMLToMarkdown\HtmlConverter;
class Util
{
static public function parseFromName(string $string): string
{
$fromName = '';
if ($string && stripos($string, '<') !== false) {
/** @var string $replacedString */
$replacedString = preg_replace('/(<.*>)/', '', $string);
$fromName = trim($replacedString, '" ');
}
return $fromName;
}
static public function parseFromAddress(string $string): string
{
if (!$string) {
return '';
}
if (stripos($string, '<') !== false) {
$fromAddress = '';
if (preg_match('/<(.*)>/', $string, $matches)) {
$fromAddress = trim($matches[1]);
}
return $fromAddress;
}
return $string;
}
/**
* Strip HTML.
*
* @since 9.1.0
*/
static public function stripHtml(string $string): string
{
if (!$string) {
return '';
}
$converter = new HtmlConverter();
$converter->setOptions([
'remove_nodes' => 'img',
'strip_tags' => true,
]);
$string = $converter->convert($string) ?: '';
$string = (string) preg_replace('~\R~u', "\r\n", $string);
$reList = [
'&(quot|#34);',
'&(amp|#38);',
'&(lt|#60);',
'&(gt|#62);',
'&(nbsp|#160);',
'&(iexcl|#161);',
'&(cent|#162);',
'&(pound|#163);',
'&(copy|#169);',
'&(reg|#174);',
];
$replaceList = [
'',
'&',
'<',
'>',
' ',
'¡',
'¢',
'£',
'©',
'®',
];
foreach ($reList as $i => $re) {
$string = (string) mb_ereg_replace($re, $replaceList[$i], $string, 'i');
}
return $string;
}
/**
* Strip a quote part in a plain text.
*
* @since 9.0.0
*/
static public function stripPlainTextQuotePart(string $string): string
{
if (!$string) {
return '';
}
$lines = preg_split("/\r\n|\n|\r/", $string);
if (!is_array($lines)) {
return '';
}
$endIndex = count($lines) - 1;
for ($i = count($lines) - 1; $i >= 0; $i--) {
$line = $lines[$i];
if (str_starts_with($line, '>') || $line === '') {
$endIndex = $i;
continue;
}
break;
}
$lines = array_slice($lines, 0, $endIndex);
if (count($lines) > 2) {
$lastIndex = count($lines) - 1;
if (str_ends_with($lines[$lastIndex], ':') && $lines[$lastIndex - 1] === '') {
$lines = array_slice($lines, 0, count($lines) - 2);
}
}
return implode("\r\n", $lines);
}
}

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailAddress\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\Forbidden;
use Espo\Core\Utils\Config;
use Espo\Entities\Email;
use Espo\Tools\Email\AddressService;
/**
* Searches email addresses.
* @noinspection PhpUnused
*/
class GetSearch implements Action
{
private const ADDRESS_MAX_SIZE = 50;
public function __construct(
private AddressService $service,
private Acl $acl,
private Config\ApplicationConfig $applicationConfig,
) {}
public function process(Request $request): Response
{
if (!$this->acl->checkScope(Email::ENTITY_TYPE)) {
throw new Forbidden();
}
$entityType = $request->getQueryParam('entityType');
$q = $request->getQueryParam('q');
$onlyActual = $request->getQueryParam('onlyActual') === 'true';
$maxSize = intval($request->getQueryParam('maxSize'));
if (!$entityType && !$this->acl->checkScope(Email::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
throw new Forbidden("No 'create' access for Email.");
}
if ($entityType && !$this->acl->checkScope($entityType, Acl\Table::ACTION_READ)) {
throw new Forbidden("No 'read' access for entity type.");
}
if (is_string($q)) {
$q = trim($q);
}
if (!$q) {
throw new BadRequest("No `q` parameter.");
}
if (!$maxSize || $maxSize > self::ADDRESS_MAX_SIZE) {
$maxSize = $this->applicationConfig->getRecordsPerPage();
}
if ($entityType) {
$result = $this->service->searchInEntityType($entityType, $q, $maxSize);
return ResponseComposer::json($result);
}
$result = $this->service->searchInAddressBook($q, $maxSize, $onlyActual);
return ResponseComposer::json($result);
}
}

View File

@@ -0,0 +1,90 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailAddress;
use Espo\Entities\EmailAddress;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use RuntimeException;
/**
* Entity lookup by an email address.
*/
class EntityLookup
{
private EmailAddressRepository $internalRepository;
public function __construct(
private Repository $repository,
EntityManager $entityManager
) {
$repository = $entityManager->getRDBRepository(EmailAddress::ENTITY_TYPE);
if (!$repository instanceof EmailAddressRepository) {
throw new RuntimeException();
}
$this->internalRepository = $repository;
}
/**
* Find entities by an email address.
*
* @param string $address An email address.
* @return Entity[]
*/
public function find(string $address): array
{
$emailAddress = $this->repository->getByAddress($address);
if (!$emailAddress) {
return [];
}
return $this->internalRepository->getEntityListByAddressId($emailAddress->getId());
}
/**
* Find a first entity by an email address.
*
* @param string $address An email address.
* @param string[] $order An order entity type list.
*/
public function findOne(string $address, ?array $order = null): ?Entity
{
if ($order) {
$this->internalRepository->getEntityByAddress($address, null, $order);
}
return $this->internalRepository->getEntityByAddress($address);
}
}

View File

@@ -0,0 +1,62 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailAddress;
use Espo\Entities\EmailAddress;
use Espo\ORM\EntityManager;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use RuntimeException;
/**
* An email address repository.
*/
class Repository
{
private EmailAddressRepository $repository;
public function __construct(EntityManager $entityManager)
{
$repository = $entityManager->getRDBRepository(EmailAddress::ENTITY_TYPE);
if (!$repository instanceof EmailAddressRepository) {
throw new RuntimeException();
}
$this->repository = $repository;
}
/**
* Get an email address entity by a string address.
*/
public function getByAddress(string $address): ?EmailAddress
{
return $this->repository->getByAddress($address);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailFolder;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Entities\GroupEmailFolder;
use Espo\ORM\EntityManager;
class GroupFolderService
{
private EntityManager $entityManager;
private Acl $acl;
public function __construct(EntityManager $entityManager, Acl $acl)
{
$this->entityManager = $entityManager;
$this->acl = $acl;
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function moveUp(string $id): void
{
/** @var ?GroupEmailFolder $entity */
$entity = $this->entityManager->getEntityById(GroupEmailFolder::ENTITY_TYPE, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden();
}
$currentIndex = $entity->getOrder();
if (!is_int($currentIndex)) {
throw new Error();
}
$previousEntity = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->where([
'order<' => $currentIndex,
])
->order('order', true)
->findOne();
if (!$previousEntity) {
return;
}
$entity->set('order', $previousEntity->getOrder());
$previousEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($previousEntity);
}
/**
* @throws Forbidden
* @throws NotFound
* @throws Error
*/
public function moveDown(string $id): void
{
/** @var ?GroupEmailFolder $entity */
$entity = $this->entityManager->getEntityById(GroupEmailFolder::ENTITY_TYPE, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden();
}
$currentIndex = $entity->getOrder();
if (!is_int($currentIndex)) {
throw new Error();
}
$nextEntity = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->where([
'order>' => $currentIndex,
])
->order('order', false)
->findOne();
if (!$nextEntity) {
return;
}
$entity->set('order', $nextEntity->getOrder());
$nextEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($nextEntity);
}
}

View File

@@ -0,0 +1,250 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailFolder;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Entities\Email;
use Espo\Entities\EmailFolder;
use Espo\Entities\GroupEmailFolder;
use Espo\Entities\User;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
use Espo\Tools\Email\Folder;
class Service
{
/** @var string[] */
protected $systemFolderList = [
Folder::INBOX,
Folder::IMPORTANT,
Folder::SENT,
];
/** @var string[] */
protected $systemFolderEndList = [
Folder::ARCHIVE,
Folder::DRAFTS,
Folder::TRASH,
];
private const FOLDER_MAX_COUNT = 100;
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private Config $config,
private User $user,
private Language $language
) {}
/**
* @return array<array<string, mixed>>
* @throws ForbiddenSilent
* @throws NotFound
*/
public function listAll(?string $userId = null)
{
if (
$userId &&
$userId !== $this->user->getId() &&
!$this->user->isAdmin()
) {
throw new ForbiddenSilent();
}
$userId ??= $this->user->getId();
/** @var ?User $user */
$user = $userId === $this->user->getId() ?
$this->user :
$this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
throw new NotFound();
}
$limit = $this->config->get('emailFolderMaxCount') ?? self::FOLDER_MAX_COUNT;
$folderList = $this->entityManager
->getRDBRepositoryByClass(EmailFolder::class)
->where(['assignedUserId' => $userId])
->order('order')
->limit(0, $limit)
->find();
$groupFolderList = $this->entityManager
->getRDBRepositoryByClass(GroupEmailFolder::class)
->distinct()
->leftJoin(Field::TEAMS)
->where(
$user->isAdmin() ?
['id!=' => null] :
['teams.id' => $user->getTeamIdList()]
)
->order('order')
->limit(0, $limit)
->find();
/** @var EntityCollection<GroupEmailFolder|EmailFolder> $list */
$list = new EntityCollection();
foreach ($this->systemFolderList as $name) {
$folder = $this->entityManager->getNewEntity(EmailFolder::ENTITY_TYPE);
$folder->set(Field::NAME, $this->language->translate($name, 'presetFilters', Email::ENTITY_TYPE));
$folder->set(Field::ID, $name);
$list[] = $folder;
}
foreach ($folderList as $folder) {
$list[] = $folder;
}
foreach ($groupFolderList as $folder) {
$list[] = $folder;
}
foreach ($this->systemFolderEndList as $name) {
$folder = $this->entityManager->getNewEntity(EmailFolder::ENTITY_TYPE);
$folder->set(Field::NAME, $this->language->translate($name, 'presetFilters', Email::ENTITY_TYPE));
$folder->set(Field::ID, $name);
$list[] = $folder;
}
$finalList = [];
foreach ($list as $item) {
$attributes = get_object_vars($item->getValueMap());
if ($item instanceof GroupEmailFolder) {
$attributes['id'] = 'group:' . $item->getId();
}
$finalList[] = $attributes;
}
return $finalList;
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function moveUp(string $id): void
{
$entity = $this->entityManager->getEntityById(EmailFolder::ENTITY_TYPE, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->check($entity, 'edit')) {
throw new Forbidden();
}
$currentIndex = $entity->get('order');
if (!is_int($currentIndex)) {
throw new Error();
}
$previousEntity = $this->entityManager
->getRDBRepositoryByClass(EmailFolder::class)
->where([
'order<' => $currentIndex,
'assignedUserId' => $entity->get('assignedUserId'),
])
->order('order', true)
->findOne();
if (!$previousEntity) {
return;
}
$entity->set('order', $previousEntity->get('order'));
$previousEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($previousEntity);
}
/**
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function moveDown(string $id): void
{
$entity = $this->entityManager->getEntityById(EmailFolder::ENTITY_TYPE, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden();
}
$currentIndex = $entity->get('order');
if (!is_int($currentIndex)) {
throw new Error();
}
$nextEntity = $this->entityManager
->getRDBRepositoryByClass(EmailFolder::class)
->where([
'order>' => $currentIndex,
'assignedUserId' => $entity->get('assignedUserId'),
])
->order('order', false)
->findOne();
if (!$nextEntity) {
return;
}
$entity->set('order', $nextEntity->get('order'));
$nextEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($nextEntity);
}
}

View File

@@ -0,0 +1,211 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailNotification;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity;
use Espo\Core\ORM\Type\FieldType;
use Espo\Entities\Email;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\Core\Htmlizer\Htmlizer;
use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory;
use Espo\Core\Mail\EmailSender as EmailSender;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\TemplateFileManager;
use Espo\Core\Utils\Util;
use Exception;
use LogicException;
class AssignmentProcessor
{
private ?Htmlizer $htmlizer = null;
public function __construct(
private EntityManager $entityManager,
private HtmlizerFactory $htmlizerFactory,
private EmailSender $emailSender,
private Config $config,
private TemplateFileManager $templateFileManager,
private Metadata $metadata,
private Language $language,
private Log $log
) {}
public function process(AssignmentProcessorData $data): void
{
$userId = $data->getUserId();
$assignerUserId = $data->getAssignerUserId();
$entityId = $data->getEntityId();
$entityType = $data->getEntityType();
if (
!$userId ||
!$assignerUserId ||
!$entityId ||
!$entityType
) {
throw new LogicException();
}
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
return;
}
if ($user->isPortal()) {
return;
}
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$preferences) {
return;
}
if (!$preferences->get('receiveAssignmentEmailNotifications')) {
return;
}
$ignoreList = $preferences->get('assignmentEmailNotificationsIgnoreEntityTypeList') ?? [];
if (in_array($entityType, $ignoreList)) {
return;
}
$assignerUser = $this->entityManager->getEntityById(User::ENTITY_TYPE, $assignerUserId);
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
return;
}
if (!$assignerUser) {
return;
}
if (!$entity instanceof Entity) {
return;
}
$this->loadParentNameFields($entity);
if (!$entity->hasLinkMultipleField(Field::ASSIGNED_USERS)) {
if ($entity->get('assignedUserId') !== $userId) {
return;
}
}
$emailAddress = $user->get('emailAddress');
if (!$emailAddress) {
return;
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$subjectTpl = $this->templateFileManager->getTemplate('assignment', 'subject', $entity->getEntityType());
$bodyTpl = $this->templateFileManager->getTemplate('assignment', 'body', $entity->getEntityType());
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$recordUrl = rtrim($this->config->get('siteUrl'), '/') .
'/#' . $entity->getEntityType() . '/view/' . $entity->getId();
$templateData = [
'userName' => $user->get(Field::NAME),
'assignerUserName' => $assignerUser->get(Field::NAME),
'recordUrl' => $recordUrl,
'entityType' => $this->language->translateLabel($entity->getEntityType(), 'scopeNames'),
];
$templateData['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($templateData['entityType']);
$subject = $this->getHtmlizer()->render(
$entity,
$subjectTpl,
'assignment-email-subject-' . $entity->getEntityType(),
$templateData,
true
);
$body = $this->getHtmlizer()->render(
$entity,
$bodyTpl,
'assignment-email-body-' . $entity->getEntityType(),
$templateData,
true
);
$email->set([
'subject' => $subject,
'body' => $body,
'isHtml' => true,
'to' => $emailAddress,
'isSystem' => true,
'parentId' => $entity->getId(),
'parentType' => $entity->getEntityType(),
]);
try {
$this->emailSender->send($email);
} catch (Exception $e) {
$this->log->error('EmailNotification: [' . $e->getCode() . '] ' . $e->getMessage());
}
}
private function getHtmlizer(): Htmlizer
{
if (!$this->htmlizer) {
$this->htmlizer = $this->htmlizerFactory->create(true);
}
return $this->htmlizer;
}
private function loadParentNameFields(Entity $entity): void
{
$fieldDefs = $this->metadata->get(['entityDefs', $entity->getEntityType(), 'fields'], []);
foreach ($fieldDefs as $field => $defs) {
if (isset($defs['type']) && $defs['type'] == FieldType::LINK_PARENT) {
$entity->loadParentNameField($field);
}
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailNotification;
class AssignmentProcessorData
{
private ?string $userId = null;
private ?string $assignerUserId = null;
private ?string $entityId = null;
private ?string $entityType = null;
public function getUserId(): ?string
{
return $this->userId;
}
public function getAssignerUserId(): ?string
{
return $this->assignerUserId;
}
public function getEntityId(): ?string
{
return $this->entityId;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public static function create(): self
{
return new self();
}
public function withUserId(string $userId): self
{
$obj = clone $this;
$obj->userId = $userId;
return $obj;
}
public function withAssignerUserId(string $assignerUserId): self
{
$obj = clone $this;
$obj->assignerUserId = $assignerUserId;
return $obj;
}
public function withEntityId(string $entityId): self
{
$obj = clone $this;
$obj->entityId = $entityId;
return $obj;
}
public function withEntityType(string $entityType): self
{
$obj = clone $this;
$obj->entityType = $entityType;
return $obj;
}
}

View File

@@ -0,0 +1,143 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailNotification;
use Espo\Core\Name\Field;
use Espo\ORM\Entity;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Utils\Config;
use Espo\Core\ApplicationState;
use Espo\Core\Job\QueueName;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Tools\EmailNotification\Jobs\NotifyAboutAssignment;
class HookProcessor
{
public function __construct(
private Config $config,
private ApplicationState $applicationState,
private JobSchedulerFactory $jobSchedulerFactory
) {}
public function afterSave(Entity $entity): void
{
if (!$entity instanceof CoreEntity) {
return;
}
if (!$this->checkToProcess($entity)) {
return;
}
if ($entity->has('assignedUsersIds')) {
$this->processMultiple($entity);
return;
}
$userId = $entity->get('assignedUserId');
if (
!$userId ||
!$entity->isAttributeChanged('assignedUserId') ||
!$this->isNotSelfAssignment($entity, $userId)
) {
return;
}
$this->createJob($entity, $userId);
}
private function processMultiple(CoreEntity $entity): void
{
$userIdList = $entity->getLinkMultipleIdList(Field::ASSIGNED_USERS);
$fetchedAssignedUserIdList = $entity->getFetched(Field::ASSIGNED_USERS . 'Ids') ?? [];
foreach ($userIdList as $userId) {
if (
in_array($userId, $fetchedAssignedUserIdList) ||
!$this->isNotSelfAssignment($entity, $userId)
) {
continue;
}
$this->createJob($entity, $userId);
}
}
private function checkToProcess(CoreEntity $entity): bool
{
if (!$this->config->get('assignmentEmailNotifications')) {
return false;
}
$hasAssignedUserField =
$entity->has('assignedUserId') ||
$entity->hasLinkMultipleField(Field::ASSIGNED_USERS) &&
$entity->has('assignedUsersIds');
if (!$hasAssignedUserField) {
return false;
}
return in_array(
$entity->getEntityType(),
$this->config->get('assignmentEmailNotificationsEntityList') ?? []
);
}
private function isNotSelfAssignment(Entity $entity, string $assignedUserId): bool
{
if ($entity->hasAttribute('createdById') && $entity->hasAttribute('modifiedById')) {
if ($entity->isNew()) {
return $assignedUserId !== $entity->get('createdById');
}
return $assignedUserId !== $entity->get('modifiedById');
}
return $assignedUserId !== $this->applicationState->getUserId();
}
private function createJob(Entity $entity, string $userId): void
{
$this->jobSchedulerFactory
->create()
->setClassName(NotifyAboutAssignment::class)
->setQueue(QueueName::E0)
->setData([
'userId' => $userId,
'assignerUserId' => $this->applicationState->getUserId(),
'entityId' => $entity->getId(),
'entityType' => $entity->getEntityType(),
])
->schedule();
}
}

View File

@@ -0,0 +1,53 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailNotification\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Tools\EmailNotification\AssignmentProcessor;
use Espo\Tools\EmailNotification\AssignmentProcessorData;
class NotifyAboutAssignment implements Job
{
public function __construct(private AssignmentProcessor $assignmentProcessor)
{}
public function run(Data $data): void
{
$this->assignmentProcessor->process(
AssignmentProcessorData::create()
->withAssignerUserId($data->get('assignerUserId'))
->withEntityId($data->get('entityId'))
->withEntityType($data->get('entityType'))
->withUserId($data->get('userId'))
);
}
}

View File

@@ -0,0 +1,896 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailNotification;
use Espo\Core\Field\LinkParent;
use Espo\Core\Name\Field;
use Espo\Core\Name\Link;
use Espo\Core\Notification\EmailNotificationHandler;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Utils\Config\ApplicationConfig;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Entities\Note;
use Espo\ORM\Collection;
use Espo\Repositories\Portal as PortalRepository;
use Espo\Entities\Email;
use Espo\Entities\Notification;
use Espo\Entities\Portal;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\SelectBuilder as SelectBuilder;
use Espo\Core\Htmlizer\Htmlizer;
use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\EmailSender as EmailSender;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\TemplateFileManager;
use Espo\Core\Utils\Util;
use Espo\Tools\Stream\NoteAccessControl;
use Michelf\Markdown;
use Exception;
use DateTime;
use Throwable;
class Processor
{
private const HOURS_THRESHOLD = 5;
private const PROCESS_MAX_COUNT = 200;
private const TYPE_STATUS = 'Status';
private ?Htmlizer $htmlizer = null;
/** @var array<string,?EmailNotificationHandler> */
private $emailNotificationEntityHandlerHash = [];
/** @var array<string,?Portal> */
private $userIdPortalCacheMap = [];
public function __construct(
private EntityManager $entityManager,
private HtmlizerFactory $htmlizerFactory,
private EmailSender $emailSender,
private Config $config,
private InjectableFactory $injectableFactory,
private TemplateFileManager $templateFileManager,
private Metadata $metadata,
private Language $language,
private Log $log,
private NoteAccessControl $noteAccessControl,
private ApplicationConfig $applicationConfig,
) {}
public function process(): void
{
$mentionEmailNotifications = $this->config->get('mentionEmailNotifications');
$streamEmailNotifications = $this->config->get('streamEmailNotifications');
$portalStreamEmailNotifications = $this->config->get('portalStreamEmailNotifications');
$typeList = [];
if ($mentionEmailNotifications) {
$typeList[] = Notification::TYPE_MENTION_IN_POST;
}
if ($streamEmailNotifications || $portalStreamEmailNotifications) {
$typeList[] = Notification::TYPE_NOTE;
}
if (empty($typeList)) {
return;
}
$fromDt = new DateTime();
$fromDt->modify('-' . self::HOURS_THRESHOLD . ' hours');
$where = [
'createdAt>' => $fromDt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT),
'read' => false,
'emailIsProcessed' => false,
];
$delay = $this->config->get('emailNotificationsDelay');
if ($delay) {
$delayDt = new DateTime();
$delayDt->modify('-' . $delay . ' seconds');
$where[] = ['createdAt<' => $delayDt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT)];
}
$queryList = [];
foreach ($typeList as $type) {
$itemBuilder = null;
if ($type === Notification::TYPE_MENTION_IN_POST) {
$itemBuilder = $this->getNotificationQueryBuilderMentionInPost();
}
if ($type === Notification::TYPE_NOTE) {
$itemBuilder = $this->getNotificationQueryBuilderNote();
}
if (!$itemBuilder) {
continue;
}
$itemBuilder->where($where);
$queryList[] = $itemBuilder->build();
}
$builder = $this->entityManager
->getQueryBuilder()
->union()
->order('number')
->limit(0, self::PROCESS_MAX_COUNT);
foreach ($queryList as $query) {
$builder->query($query);
}
$unionQuery = $builder->build();
$sql = $this->entityManager
->getQueryComposer()
->compose($unionQuery);
/** @var Collection<Notification> $notifications */
$notifications = $this->entityManager
->getRDBRepository(Notification::ENTITY_TYPE)
->findBySql($sql);
foreach ($notifications as $notification) {
$notification->set('emailIsProcessed', true);
$type = $notification->getType();
try {
if ($type === Notification::TYPE_NOTE) {
$this->processNotificationNote($notification);
} else if ($type === Notification::TYPE_MENTION_IN_POST) {
$this->processNotificationMentionInPost($notification);
} else {
// For bc.
$methodName = 'processNotification' . ucfirst($type ?? 'Dummy');
if (method_exists($this, $methodName)) {
$this->$methodName($notification);
}
}
} catch (Throwable $e) {
$this->log->error("Email notification: {$e->getMessage()}", ['exception' => $e]);
}
$this->entityManager->saveEntity($notification);
}
}
protected function getNotificationQueryBuilderMentionInPost(): SelectBuilder
{
return $this->entityManager
->getQueryBuilder()
->select()
->from(Notification::ENTITY_TYPE)
->where([
'type' => Notification::TYPE_MENTION_IN_POST,
]);
}
protected function getNotificationQueryBuilderNote(): SelectBuilder
{
$builder = $this->entityManager
->getQueryBuilder()
->select()
->from(Notification::ENTITY_TYPE)
->join(Note::ENTITY_TYPE, 'note', ['note.id:' => 'relatedId'])
->join('user')
->where([
'type' => Notification::TYPE_NOTE,
'relatedType' => Note::ENTITY_TYPE,
'note.type' => $this->getNoteNotificationTypeList(),
]);
$entityList = $this->config->get('streamEmailNotificationsEntityList');
if (empty($entityList)) {
$builder->where([
'relatedParentType' => null,
]);
} else {
$builder->where([
'OR' => [
[
'relatedParentType' => $entityList,
],
[
'relatedParentType' => null,
],
],
]);
}
$forInternal = $this->config->get('streamEmailNotifications');
$forPortal = $this->config->get('portalStreamEmailNotifications');
if ($forInternal && !$forPortal) {
$builder->where([
'user.type!=' => User::TYPE_PORTAL,
]);
} else if (!$forInternal && $forPortal) {
$builder->where([
'user.type' => User::TYPE_PORTAL,
]);
}
return $builder;
}
protected function processNotificationMentionInPost(Notification $notification): void
{
if (!$notification->get('userId')) {
return;
}
$userId = $notification->get('userId');
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
return;
}
$emailAddress = $user->get('emailAddress');
if (!$emailAddress) {
return;
}
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$preferences) {
return;
}
if (!$preferences->get('receiveMentionEmailNotifications')) {
return;
}
if (!$notification->getRelated() || $notification->getRelated()->getEntityType() !== Note::ENTITY_TYPE) {
return;
}
/** @var ?Note $note */
$note = $this->entityManager->getEntityById(Note::ENTITY_TYPE, $notification->getRelated()->getId());
if (!$note) {
return;
}
$parent = null;
$parentId = $note->getParentId();
$parentType = $note->getParentType();
$data = [];
if ($parentId && $parentType) {
$parent = $this->entityManager->getEntityById($parentType, $parentId);
if (!$parent) {
return;
}
$data['url'] = "{$this->getSiteUrl($user)}/#$parentType/view/$parentId";
$data['parentName'] = $parent->get(Field::NAME);
$data['parentType'] = $parentType;
$data['parentId'] = $parentId;
} else {
$data['url'] = $this->getSiteUrl($user) . '/#Notification';
}
$data['userName'] = $note->get('createdByName');
$post = Markdown::defaultTransform(
$note->get('post') ?? ''
);
$data['post'] = $post;
$subjectTpl = $this->templateFileManager->getTemplate('mention', 'subject');
$bodyTpl = $this->templateFileManager->getTemplate('mention', 'body');
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$subject = $this->getHtmlizer()->render($note, $subjectTpl, 'mention-email-subject', $data, true);
$body = $this->getHtmlizer()->render($note, $bodyTpl, 'mention-email-body', $data, true);
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email
->setSubject($subject)
->setBody($body)
->setIsHtml()
->addToAddress($emailAddress);
$email->set('isSystem', true);
if ($parentId && $parentType) {
$email->setParent(LinkParent::create($parentType, $parentId));
}
$senderParams = SenderParams::create();
if ($parent && $parentType) {
$handler = $this->getHandler('mention', $parentType);
if ($handler) {
$handler->prepareEmail($email, $parent, $user);
$senderParams = $handler->getSenderParams($parent, $user) ?? $senderParams;
}
}
$sender = $this->emailSender
->withParams($senderParams);
if ($note->getType() !== Note::TYPE_POST) {
$sender = $sender->withAddedHeader('Auto-Submitted', 'auto-generated');
}
try {
$sender->send($email);
} catch (Exception $e) {
$this->log->error("Email notification: {$e->getMessage()}", ['exception' => $e]);
}
}
protected function processNotificationNote(Notification $notification): void
{
if (
!$notification->getRelated() ||
$notification->getRelated()->getEntityType() !== Note::ENTITY_TYPE
) {
return;
}
$noteId = $notification->getRelated()->getId();
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($noteId);
if (
!$note ||
!in_array($note->getType(), $this->getNoteNotificationTypeList()) ||
!$notification->getUserId()
) {
return;
}
$userId = $notification->getUserId();
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user) {
return;
}
if (!$user->getEmailAddress()) {
return;
}
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $userId);
if (!$preferences) {
return;
}
if (!$preferences->get('receiveStreamEmailNotifications')) {
return;
}
$type = $note->getType();
if ($type === Note::TYPE_POST) {
$this->processNotificationNotePost($note, $user);
return;
}
if ($type === Note::TYPE_UPDATE && isset($note->getData()->value)) {
$this->processNotificationNoteStatus($note, $user);
return;
}
if ($type === Note::TYPE_EMAIL_RECEIVED) {
$this->processNotificationNoteEmailReceived($note, $user);
return;
}
/** For bc. */
$methodName = 'processNotificationNote' . $type;
if (method_exists($this, $methodName)) {
$this->$methodName($note, $user);
}
}
protected function getHandler(string $type, string $entityType): ?EmailNotificationHandler
{
$key = $type . '-' . $entityType;
if (!array_key_exists($key, $this->emailNotificationEntityHandlerHash)) {
$this->emailNotificationEntityHandlerHash[$key] = null;
/** @var ?class-string<EmailNotificationHandler> $className */
$className = $this->metadata
->get(['notificationDefs', $entityType, 'emailNotificationHandlerClassNameMap', $type]);
if ($className && class_exists($className)) {
$handler = $this->injectableFactory->create($className);
$this->emailNotificationEntityHandlerHash[$key] = $handler;
}
}
/** @noinspection PhpExpressionAlwaysNullInspection */
return $this->emailNotificationEntityHandlerHash[$key];
}
protected function processNotificationNotePost(Note $note, User $user): void
{
$parentId = $note->getParentId();
$parentType = $note->getParentType();
$emailAddress = $user->getEmailAddress();
if (!$emailAddress) {
return;
}
$data = [];
$data['userName'] = $note->get('createdByName');
$post = Markdown::defaultTransform($note->getPost() ?? '');
$data['post'] = $post;
$parent = null;
if ($parentId && $parentType) {
$parent = $this->entityManager->getEntityById($parentType, $parentId);
if (!$parent) {
return;
}
$data['url'] = "{$this->getSiteUrl($user)}/#$parentType/view/$parentId";
$data['parentName'] = $parent->get(Field::NAME);
$data['parentType'] = $parentType;
$data['parentId'] = $parentId;
$data['name'] = $data['parentName'];
$data['entityType'] = $this->language->translateLabel($parentType, 'scopeNames');
$data['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($data['entityType']);
$subjectTpl = $this->templateFileManager->getTemplate('notePost', 'subject', $parentType);
$bodyTpl = $this->templateFileManager->getTemplate('notePost', 'body', $parentType);
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$subject = $this->getHtmlizer()->render(
$note,
$subjectTpl,
'note-post-email-subject-' . $parentType,
$data,
true
);
$body = $this->getHtmlizer()->render(
$note,
$bodyTpl,
'note-post-email-body-' . $parentType,
$data,
true
);
} else {
$data['url'] = "{$this->getSiteUrl($user)}/#Notification";
$subjectTpl = $this->templateFileManager->getTemplate('notePostNoParent', 'subject');
$bodyTpl = $this->templateFileManager->getTemplate('notePostNoParent', 'body');
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$subject = $this->getHtmlizer()->render($note, $subjectTpl, 'note-post-email-subject', $data, true);
$body = $this->getHtmlizer()->render($note, $bodyTpl, 'note-post-email-body', $data, true);
}
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email
->setSubject($subject)
->setBody($body)
->setIsHtml()
->addToAddress($emailAddress);
$email->set('isSystem', true);
if ($parentId && $parentType) {
$email->setParent(LinkParent::create($parentType, $parentId));
}
$senderParams = SenderParams::create();
if ($parent) {
$handler = $this->getHandler('notePost', $parent->getEntityType());
if ($handler) {
$handler->prepareEmail($email, $parent, $user);
$senderParams = $handler->getSenderParams($parent, $user) ?? $senderParams;
}
}
try {
$this->emailSender
->withParams($senderParams)
->send($email);
} catch (Exception $e) {
$this->log->error("Email notification: {$e->getMessage()}", ['exception' => $e]);
}
}
private function getSiteUrl(User $user): string
{
$portal = null;
if (!$user->isPortal()) {
return $this->applicationConfig->getSiteUrl();
}
if (!array_key_exists($user->getId(), $this->userIdPortalCacheMap)) {
$this->userIdPortalCacheMap[$user->getId()] = null;
$portalIdList = $user->getLinkMultipleIdList('portals');
$defaultPortalId = $this->config->get('defaultPortalId');
$portalId = null;
if (in_array($defaultPortalId, $portalIdList)) {
$portalId = $defaultPortalId;
} else if (count($portalIdList)) {
$portalId = $portalIdList[0];
}
if ($portalId) {
/** @var ?Portal $portal */
$portal = $this->entityManager->getEntityById(Portal::ENTITY_TYPE, $portalId);
}
if ($portal) {
$this->getPortalRepository()->loadUrlField($portal);
$this->userIdPortalCacheMap[$user->getId()] = $portal;
}
} else {
$portal = $this->userIdPortalCacheMap[$user->getId()];
}
if ($portal) {
return rtrim($portal->get('url'), '/');
}
return $this->applicationConfig->getSiteUrl();
}
protected function processNotificationNoteStatus(Note $note, User $user): void
{
$this->noteAccessControl->apply($note, $user);
$parentId = $note->getParentId();
$parentType = $note->getParentType();
$emailAddress = $user->getEmailAddress();
if (!$emailAddress) {
return;
}
$data = [];
if (!$parentId || !$parentType) {
return;
}
$parent = $this->entityManager->getEntityById($parentType, $parentId);
if (!$parent) {
return;
}
$note->loadParentNameField('superParent');
$data['url'] = "{$this->getSiteUrl($user)}/#$parentType/view/$parentId";
$data['parentName'] = $parent->get(Field::NAME);
$data['parentType'] = $parentType;
$data['parentId'] = $parentId;
$data['superParentName'] = $note->get('superParentName');
$data['superParentType'] = $note->getSuperParentType();
$data['superParentId'] = $note->getSuperParentId();
$data['name'] = $data['parentName'];
$data['entityType'] = $this->language->translateLabel($parentType, 'scopeNames');
$data['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($data['entityType']);
$noteData = $note->getData();
$value = $noteData->value ?? null;
$field = $this->metadata->get("scopes.$parentType.statusField");
if ($value === null || !$field || !is_string($field)) {
return;
}
$data['value'] = $value;
$data['field'] = $field;
$data['valueTranslated'] = $this->language->translateOption($value, $field, $parentType);
$data['fieldTranslated'] = $this->language->translateLabel($field, 'fields', $parentType);
$data['fieldTranslatedLowerCase'] = Util::mbLowerCaseFirst($data['fieldTranslated']);
$data['userName'] = $note->get('createdByName');
$subjectTpl = $this->templateFileManager->getTemplate('noteStatus', 'subject', $parentType);
$bodyTpl = $this->templateFileManager->getTemplate('noteStatus', 'body', $parentType);
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$subject = $this->getHtmlizer()->render(
entity: $note,
template: $subjectTpl,
cacheId: 'note-status-email-subject',
additionalData: $data,
skipLinks: true,
);
$body = $this->getHtmlizer()->render(
entity: $note,
template: $bodyTpl,
cacheId: 'note-status-email-body',
additionalData: $data,
skipLinks: true,
);
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email
->setSubject($subject)
->setBody($body)
->setIsHtml()
->addToAddress($emailAddress)
->setParent(LinkParent::create($parentType, $parentId));
$email->set('isSystem', true);
$senderParams = SenderParams::create();
$handler = $this->getHandler('status', $parentType);
if ($handler) {
$handler->prepareEmail($email, $parent, $user);
$senderParams = $handler->getSenderParams($parent, $user) ?? $senderParams;
}
$sender = $this->emailSender->withParams($senderParams);
try {
$sender->send($email);
} catch (Exception $e) {
$this->log->error("Email notification: {$e->getMessage()}", ['exception' => $e]);
}
}
protected function processNotificationNoteEmailReceived(Note $note, User $user): void
{
$parentId = $note->get('parentId');
$parentType = $note->getParentType();
$allowedEntityTypeList = $this->config->get('streamEmailNotificationsEmailReceivedEntityTypeList');
if (
is_array($allowedEntityTypeList) &&
!in_array($parentType, $allowedEntityTypeList)
) {
return;
}
$emailAddress = $user->getEmailAddress();
if (!$emailAddress) {
return;
}
$noteData = $note->getData();
if (!isset($noteData->emailId)) {
return;
}
$emailSubject = $this->entityManager->getEntityById(Email::ENTITY_TYPE, $noteData->emailId);
if (!$emailSubject) {
return;
}
$emailAddresses = $this->entityManager
->getRelation($user, Link::EMAIL_ADDRESSES)
->find();
foreach ($emailAddresses as $ea) {
if (
$this->entityManager->getRelation($emailSubject, 'toEmailAddresses')->isRelated($ea) ||
$this->entityManager->getRelation($emailSubject, 'ccEmailAddresses')->isRelated($ea)
) {
return;
}
}
$data = [];
$data['fromName'] = '';
if (isset($noteData->personEntityName)) {
$data['fromName'] = $noteData->personEntityName;
} else if (isset($noteData->fromString)) {
$data['fromName'] = $noteData->fromString;
}
$data['subject'] = '';
if (isset($noteData->emailName)) {
$data['subject'] = $noteData->emailName;
}
$data['post'] = nl2br($note->get('post'));
if (!$parentId || !$parentType) {
return;
}
$parent = $this->entityManager->getEntityById($parentType, $parentId);
if (!$parent) {
return;
}
$data['url'] = "{$this->getSiteUrl($user)}/#$parentType/view/$parentId";
$data['parentName'] = $parent->get(Field::NAME);
$data['parentType'] = $parentType;
$data['parentId'] = $parentId;
$data['name'] = $data['parentName'];
$data['entityType'] = $this->language->translateLabel($parentType, 'scopeNames');
$data['entityTypeLowerFirst'] = Util::mbLowerCaseFirst($data['entityType']);
$subjectTpl = $this->templateFileManager->getTemplate('noteEmailReceived', 'subject', $parentType);
$bodyTpl = $this->templateFileManager->getTemplate('noteEmailReceived', 'body', $parentType);
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$subject = $this->getHtmlizer()->render(
$note,
$subjectTpl,
'note-email-received-email-subject-' . $parentType,
$data,
true
);
$body = $this->getHtmlizer()->render(
$note,
$bodyTpl,
'note-email-received-email-body-' . $parentType,
$data,
true
);
/** @var Email $email */
$email = $this->entityManager->getNewEntity(Email::ENTITY_TYPE);
$email
->setSubject($subject)
->setBody($body)
->setIsHtml()
->addToAddress($emailAddress)
->setParent(LinkParent::create($parentType, $parentId));
$email->set('isSystem', true);
$senderParams = SenderParams::create();
$handler = $this->getHandler('emailReceived', $parentType);
if ($handler) {
$handler->prepareEmail($email, $parent, $user);
$senderParams = $handler->getSenderParams($parent, $user) ?? $senderParams;
}
try {
$this->emailSender
->withParams($senderParams)
->send($email);
} catch (Exception $e) {
$this->log->error("Email notification: {$e->getMessage()}", ['exception' => $e]);
}
}
private function getHtmlizer(): Htmlizer
{
if (!$this->htmlizer) {
$this->htmlizer = $this->htmlizerFactory->create(true);
}
return $this->htmlizer;
}
private function getPortalRepository(): PortalRepository
{
/** @var PortalRepository */
return $this->entityManager->getRepository(Portal::ENTITY_TYPE);
}
/**
* @return string[]
*/
private function getNoteNotificationTypeList(): array
{
/** @var string[] $output */
$output = $this->config->get('streamEmailNotificationsTypeList', []);
if (in_array(self::TYPE_STATUS, $output)) {
$output[] = Note::TYPE_UPDATE;
$output = array_values(array_filter($output, fn ($v) => $v !== self::TYPE_STATUS));
}
return $output;
}
}

View File

@@ -0,0 +1,68 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate\Api;
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\Tools\EmailTemplate\Data;
use Espo\Tools\EmailTemplate\Service;
/**
* Prepares an email data with an email template applied.
*/
class PostPrepare implements Action
{
public function __construct(private Service $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
if ($id === null) {
throw new BadRequest();
}
$body = $request->getParsedBody();
$data = Data::create()
->withRelatedType($body->relatedType ?? null)
->withRelatedId($body->relatedId ?? null)
->withParentType($body->parentType ?? null)
->withParentId($body->parentId ?? null)
->withEmailAddress($body->emailAddress ?? null);
$result = $this->service->process($id, $data);
return ResponseComposer::json($result->getValueMap());
}
}

View File

@@ -0,0 +1,169 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate;
use Espo\ORM\Entity;
use Espo\Entities\User;
class Data
{
/** @var array<string, Entity> */
private $entityHash = [];
private ?string $emailAddress = null;
private ?Entity $parent = null;
private ?string $parentId = null;
private ?string $parentType = null;
private ?string $relatedId = null;
private ?string $relatedType = null;
private ?User $user = null;
/**
* @return array<string,Entity> $entityHash
*/
public function getEntityHash(): array
{
return $this->entityHash;
}
public function getEmailAddress(): ?string
{
return $this->emailAddress;
}
public function getParent(): ?Entity
{
return $this->parent;
}
public function getParentId(): ?string
{
return $this->parentId;
}
public function getParentType(): ?string
{
return $this->parentType;
}
public function getRelatedId(): ?string
{
return $this->relatedId;
}
public function getRelatedType(): ?string
{
return $this->relatedType;
}
public function getUser(): ?User
{
return $this->user;
}
/**
* An entity hash.
*
* @param array<string,Entity> $entityHash
*/
public function withEntityHash(array $entityHash): self
{
$obj = clone $this;
$obj->entityHash = $entityHash;
return $obj;
}
/**
* An email address.
*/
public function withEmailAddress(?string $emailAddress): self
{
$obj = clone $this;
$obj->emailAddress = $emailAddress;
return $obj;
}
public function withParent(?Entity $parent): self
{
$obj = clone $this;
$obj->parent = $parent;
return $obj;
}
public function withParentId(?string $parentId): self
{
$obj = clone $this;
$obj->parentId = $parentId;
return $obj;
}
public function withParentType(?string $parentType): self
{
$obj = clone $this;
$obj->parentType = $parentType;
return $obj;
}
public function withRelatedId(?string $relatedId): self
{
$obj = clone $this;
$obj->relatedId = $relatedId;
return $obj;
}
public function withRelatedType(?string $relatedType): self
{
$obj = clone $this;
$obj->relatedType = $relatedType;
return $obj;
}
public static function create(): self
{
return new self();
}
/**
* A user to apply ACL for.
*/
public function withUser(?User $user): self
{
$obj = clone $this;
$obj->user = $user;
return $obj;
}
}

View File

@@ -0,0 +1,131 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate;
use Espo\Core\Acl\GlobalRestriction;
use Espo\Core\AclManager;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @since 9.2.0
* @internal
*/
class EntityMapProvider
{
public function __construct(
private EntityManager $entityManager,
private AclManager $aclManager,
private ServiceContainer $serviceContainer,
private Metadata $metadata,
) {}
/**
* @return array<string, Entity>
*/
public function get(Entity $entity, User $user, bool $applyAcl): array
{
/** @var array<string, string> $map */
$map = $this->metadata->get("app.emailTemplate.entityLinkMapping.{$entity->getEntityType()}") ?? [];
$output = [];
foreach ($map as $entityType => $link) {
$related = $this->getRelated(
entity: $entity,
link: $link,
user: $user,
applyAcl: $applyAcl,
);
if ($related) {
$output[$entityType] = $related;
}
}
return $output;
}
private function getRelated(
Entity $entity,
string $link,
User $user,
bool $applyAcl,
): ?Entity {
$entityDefs = $this->entityManager->getDefs()->getEntity($entity->getEntityType());
$forbiddenLinkList = $this->aclManager->getScopeRestrictedLinkList(
$entity->getEntityType(),
[
GlobalRestriction::TYPE_FORBIDDEN,
GlobalRestriction::TYPE_INTERNAL,
GlobalRestriction::TYPE_ONLY_ADMIN,
]
);
if ($applyAcl) {
if (
$entityDefs->hasField($link) &&
!$this->aclManager->checkField($user, $entity->getEntityType(), $link)
) {
return null;
}
if (in_array($link, $forbiddenLinkList)) {
return null;
}
}
$related = $this->entityManager
->getRelation($entity, $link)
->findOne();
if (!$related) {
return null;
}
if (
$applyAcl &&
!$this->aclManager->checkEntityRead($user, $related)
) {
return null;
}
$this->serviceContainer
->get($related->getEntityType())
->loadAdditionalFields($related);
return $related;
}
}

View File

@@ -0,0 +1,178 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate;
use Espo\Core\ORM\Type\FieldType;
use Espo\ORM\Entity;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\NumberUtil;
use Espo\Core\Utils\Language;
use Espo\ORM\Type\AttributeType;
use Stringable;
class Formatter
{
public function __construct(
private Metadata $metadata,
private Config $config,
private DateTimeUtil $dateTime,
private NumberUtil $number,
private Language $language
) {}
public function formatAttributeValue(Entity $entity, string $attribute, bool $isPlainText = false): ?string
{
$value = $entity->get($attribute);
$fieldType = $this->metadata
->get(['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'type']);
$attributeType = $entity->getAttributeType($attribute);
if ($fieldType === FieldType::ENUM) {
if ($value === null) {
return '';
}
$label = $this->language->translateOption($value, $attribute, $entity->getEntityType());
$translationPath = $this->metadata->get(
['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'translation']
);
if ($translationPath) {
$label = $this->language->get($translationPath . '.' . $value, $label);
}
return $label;
}
if (
$fieldType === FieldType::ARRAY ||
$fieldType === FieldType::MULTI_ENUM ||
$fieldType === FieldType::CHECKLIST
) {
$valueList = [];
if (!is_array($value)) {
return '';
}
foreach ($value as $v) {
$valueList[] = $this->language->translateOption($v, $attribute, $entity->getEntityType());
}
return implode(', ', $valueList);
}
if ($attributeType === AttributeType::DATE) {
if (!$value) {
return '';
}
return $this->dateTime->convertSystemDate($value);
}
if ($attributeType === AttributeType::DATETIME) {
if (!$value) {
return '';
}
return $this->dateTime->convertSystemDateTime($value);
}
if ($attributeType === AttributeType::TEXT) {
if (!is_string($value)) {
return '';
}
if ($fieldType === FieldType::WYSIWYG) {
return $value;
}
if ($isPlainText) {
return $value;
}
return nl2br($value);
}
if ($attributeType === AttributeType::FLOAT) {
if (!is_float($value)) {
return '';
}
$decimalPlaces = 2;
if ($fieldType === FieldType::CURRENCY) {
$decimalPlaces = $this->config->get('currencyDecimalPlaces');
}
return $this->number->format($value, $decimalPlaces);
}
if ($attributeType === AttributeType::INT) {
if (!is_int($value)) {
return '';
}
if (
$fieldType === FieldType::AUTOINCREMENT ||
$fieldType === FieldType::INT &&
$this->metadata
->get(['entityDefs', $entity->getEntityType(), 'fields', $attribute, 'disableFormatting'])
) {
return (string) $value;
}
return $this->number->format($value);
}
if (
!is_string($value) && is_scalar($value) ||
$value instanceof Stringable
) {
return strval($value);
}
if ($value === null) {
return '';
}
if (!is_string($value)) {
return null;
}
return $value;
}
}

View File

@@ -0,0 +1,182 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate\InsertField;
use Espo\Core\Acl;
use Espo\Core\Acl\Table;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\FieldUtil;
use Espo\Entities\Email;
use Espo\Entities\EmailAddress;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\EntityManager;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Tools\EmailTemplate\Formatter;
use stdClass;
class Service
{
private EntityManager $entityManager;
private Acl $acl;
private Formatter $formatter;
private FieldUtil $fieldUtil;
private ServiceContainer $recordServiceContainer;
public function __construct(
EntityManager $entityManager,
Acl $acl,
Formatter $formatter,
FieldUtil $fieldUtil,
ServiceContainer $recordServiceContainer
) {
$this->entityManager = $entityManager;
$this->acl = $acl;
$this->formatter = $formatter;
$this->fieldUtil = $fieldUtil;
$this->recordServiceContainer = $recordServiceContainer;
}
/**
* @throws Forbidden
*/
public function getData(?string $parentType, ?string $parentId, ?string $to): stdClass
{
if (!$this->acl->checkScope(Email::ENTITY_TYPE, Table::ACTION_CREATE)) {
throw new Forbidden();
}
$result = (object) [];
$dataList = [];
if ($parentId && $parentType) {
$e = $this->entityManager->getEntityById($parentType, $parentId);
if ($e && $this->acl->check($e)) {
$dataList[] = [
'type' => 'parent',
'entity' => $e,
];
}
}
if ($to) {
$e = $this->getEmailAddressRepository()
->getEntityByAddress($to, null,
[Contact::ENTITY_TYPE, Lead::ENTITY_TYPE, Account::ENTITY_TYPE]);
if ($e && $e->getEntityType() !== User::ENTITY_TYPE && $this->acl->check($e)) {
$dataList[] = [
'type' => 'to',
'entity' => $e,
];
}
}
$fm = $this->fieldUtil;
$formatter = $this->formatter;
foreach ($dataList as $item) {
$type = $item['type'];
$e = $item['entity'];
$entityType = $e->getEntityType();
$recordService = $this->recordServiceContainer->get($entityType);
$recordService->loadAdditionalFields($e);
$recordService->prepareEntityForOutput($e);
$ignoreTypeList = [
FieldType::IMAGE,
FieldType::FILE,
FieldType::WYSIWYG,
FieldType::LINK_MULTIPLE,
FieldType::ATTACHMENT_MULTIPLE,
FieldType::BOOL,
'map',
];
foreach ($fm->getEntityTypeFieldList($entityType) as $field) {
$fieldType = $fm->getEntityTypeFieldParam($entityType, $field, 'type');
$fieldAttributeList = $fm->getAttributeList($entityType, $field);
if (
$fm->getEntityTypeFieldParam($entityType, $field, 'disabled') ||
$fm->getEntityTypeFieldParam($entityType, $field, 'directAccessDisabled') ||
$fm->getEntityTypeFieldParam($entityType, $field, 'templatePlaceholderDisabled') ||
in_array($fieldType, $ignoreTypeList)
) {
foreach ($fieldAttributeList as $a) {
$e->clear($a);
}
}
}
$attributeList = $fm->getEntityTypeAttributeList($entityType);
$values = (object) [];
foreach ($attributeList as $a) {
if (!$e->has($a)) {
continue;
}
$value = $formatter->formatAttributeValue($e, $a);
if ($value !== null && $value !== '') {
$values->$a = $value;
}
}
$result->$type = (object) [
'entityType' => $e->getEntityType(),
'id' => $e->getId(),
'values' => $values,
'name' => $e->get(Field::NAME),
];
}
return $result;
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate;
/**
* Immutable.
*/
class Params
{
private bool $applyAcl = false;
private bool $copyAttachments = false;
public function applyAcl(): bool
{
return $this->applyAcl;
}
public function copyAttachments(): bool
{
return $this->copyAttachments;
}
/**
* To apply ACL.
*/
public function withApplyAcl(bool $applyAcl = true): self
{
$obj = clone $this;
$obj->applyAcl = $applyAcl;
return $obj;
}
/**
* To copy template attachments records. Not needed if an email not supposed to be stored.
*/
public function withCopyAttachments(bool $copyAttachments = true): self
{
$obj = clone $this;
$obj->copyAttachments = $copyAttachments;
return $obj;
}
public static function create(): self
{
return new self();
}
}

View File

@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate;
interface Placeholder
{
public function get(Data $data): string;
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate\Placeholders;
use DateTime;
use DateTimezone;
use Espo\Core\Utils\Config;
use Espo\Tools\EmailTemplate\Data;
use Espo\Tools\EmailTemplate\Placeholder;
use Exception;
use RuntimeException;
/**
* @noinspection PhpUnused
*/
class CurrentYear implements Placeholder
{
public function __construct(
private Config\ApplicationConfig $applicationConfig,
) {}
public function get(Data $data): string
{
try {
$now = new DateTime('now', new DateTimezone($this->applicationConfig->getTimeZone()));
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
return $now->format('Y');
}
}

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\EmailTemplate\Placeholders;
use Espo\Core\Utils\DateTime;
use Espo\Tools\EmailTemplate\Data;
use Espo\Tools\EmailTemplate\Placeholder;
/**
* @noinspection PhpUnused
*/
class Now implements Placeholder
{
public function __construct(
private DateTime $dateTime
) {}
public function get(Data $data): string
{
return $this->dateTime->getNowString();
}
}

Some files were not shown because too many files have changed in this diff Show More