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,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\Modules\Crm\Tools\Activities\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\Record\SearchParamsFetcher;
use Espo\Modules\Crm\Tools\Activities\FetchParams as ActivitiesFetchParams;
use Espo\Modules\Crm\Tools\Activities\Service as Service;
/**
* Activities related to a record.
*/
class Get implements Action
{
public function __construct(
private SearchParamsFetcher $searchParamsFetcher,
private Service $service,
private Acl $acl
) {}
public function process(Request $request): Response
{
if (!$this->acl->check('Activities')) {
throw new Forbidden();
}
$parentType = $request->getRouteParam('parentType');
$id = $request->getRouteParam('id');
$type = $request->getRouteParam('type');
if (
!$parentType ||
!$id ||
!in_array($type, ['activities', 'history'])
) {
throw new BadRequest();
}
$searchParams = $this->searchParamsFetcher->fetch($request);
$offset = $searchParams->getOffset();
$maxSize = $searchParams->getMaxSize();
$targetEntityType = $request->getQueryParam('entityType');
$fetchParams = new ActivitiesFetchParams($maxSize, $offset, $targetEntityType);
$result = $type === 'history' ?
$this->service->getHistory($parentType, $id, $fetchParams) :
$this->service->getActivities($parentType, $id, $fetchParams);
return ResponseComposer::json($result->toApiOutput());
}
}

View File

@@ -0,0 +1,91 @@
<?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\Modules\Crm\Tools\Activities\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\Modules\Crm\Tools\Activities\ComposeEmailService;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @noinspection PhpUnused
*/
class GetComposeAddressList implements Action
{
public function __construct(
private Acl $acl,
private EntityManager $entityManager,
private ComposeEmailService $service
) {}
public function process(Request $request): Response
{
$parentType = $request->getRouteParam('parentType');
$id = $request->getRouteParam('id');
if (!$parentType || !$id) {
throw new BadRequest();
}
$entity = $this->fetchEntity($parentType, $id);
$list = $this->service->getEmailAddressList($entity);
return ResponseComposer::json(
array_map(fn ($item) => $item->getValueMap(), $list)
);
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function fetchEntity(string $parentType, string $id): Entity
{
$entity = $this->entityManager->getEntityById($parentType, $id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden();
}
return $entity;
}
}

View File

@@ -0,0 +1,86 @@
<?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\Modules\Crm\Tools\Activities\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\Record\SearchParamsFetcher;
use Espo\Modules\Crm\Tools\Activities\Service as Service;
/**
* Activities of specific entity type related to a record.
*/
class GetListTyped implements Action
{
public function __construct(
private SearchParamsFetcher $searchParamsFetcher,
private Service $service
) {}
public function process(Request $request): Response
{
$parentType = $request->getRouteParam('parentType');
$id = $request->getRouteParam('id');
$type = $request->getRouteParam('type');
$targetType = $request->getRouteParam('targetType');
if (
!$parentType ||
!$id ||
!$type ||
!$targetType
) {
throw new BadRequest();
}
if ($type === 'activities') {
$isHistory = false;
} else if ($type === 'history') {
$isHistory = true;
} else {
throw new BadRequest("Bad type.");
}
$searchParams = $this->searchParamsFetcher->fetch($request);
$result = $this->service->findActivitiesEntityType(
$parentType,
$id,
$targetType,
$isHistory,
$searchParams
);
return ResponseComposer::json($result->toApiOutput());
}
}

View File

@@ -0,0 +1,106 @@
<?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\Modules\Crm\Tools\Activities\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\Forbidden;
use Espo\Core\Record\SearchParamsFetcher;
use Espo\Entities\User;
use Espo\Modules\Crm\Tools\Activities\Upcoming\Params;
use Espo\Modules\Crm\Tools\Activities\UpcomingService;
/**
* Upcoming activities.
*
* @noinspection PhpUnused
*/
class GetUpcoming implements Action
{
public function __construct(
private User $user,
private SearchParamsFetcher $searchParamsFetcher,
private UpcomingService $service
) {}
public function process(Request $request): Response
{
$userId = $request->getQueryParam('userId') ?? $this->user->getId();
$params = $this->fetchParams($request);
$result = $this->service->get($userId, $params);
return ResponseComposer::json($result->toApiOutput());
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function fetchParams(Request $request): Params
{
$entityTypeList = $this->fetchEntityTypeList($request);
$futureDays = $request->hasQueryParam('futureDays') ? intval($request->getQueryParam('futureDays')) : null;
$searchParams = $this->searchParamsFetcher->fetch($request);
return new Params(
offset: $searchParams->getOffset(),
maxSize: $searchParams->getMaxSize(),
futureDays: $futureDays,
entityTypeList: $entityTypeList,
includeShared: $request->getQueryParam('includeShared') === 'true',
);
}
/**
* @return ?string[]
* @throws BadRequest
*/
private function fetchEntityTypeList(Request $request): ?array
{
$entityTypeList = $request->getQueryParams()['entityTypeList'] ?? null;
if (!is_array($entityTypeList) && $entityTypeList !== null) {
throw new BadRequest("Bad entityTypeList.");
}
foreach ($entityTypeList ?? [] as $it) {
if (!is_string($it)) {
throw new BadRequest("Bad item in entityTypeList.");
}
}
return $entityTypeList;
}
}

View File

@@ -0,0 +1,174 @@
<?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\Modules\Crm\Tools\Activities;
use Espo\Core\Acl;
use Espo\Core\Field\EmailAddress;
use Espo\Core\Templates\Entities\Company;
use Espo\Core\Templates\Entities\Person;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Type\RelationType;
use Espo\Tools\Email\EmailAddressEntityPair;
class ComposeEmailService
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private Acl $acl
) {}
/**
* @return EmailAddressEntityPair[]
*/
public function getEmailAddressList(Entity $entity): array
{
$relations = $this->getRelations($entity);
foreach ($relations as $relation) {
$address = $this->getPersonEmailAddress($entity, $relation->getName());
if ($address) {
return [$address];
}
}
return [];
}
private function getPersonEmailAddress(Entity $entity, string $link): ?EmailAddressEntityPair
{
$foreignEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $link)
->findOne();
if (!$foreignEntity) {
return null;
}
if (!$this->acl->checkEntityRead($foreignEntity)) {
return null;
}
if (!$this->acl->checkField($foreignEntity->getEntityType(), 'emailAddress')) {
return null;
}
/** @var ?string $address */
$address = $foreignEntity->get('emailAddress');
if (!$address) {
return null;
}
$emailAddress = EmailAddress::create($address);
return new EmailAddressEntityPair($emailAddress, $foreignEntity);
}
/**
* @return RelationDefs[]
*/
private function getRelations(Entity $entity): array
{
$relations = $this->entityManager
->getDefs()
->getEntity($entity->getEntityType())
->getRelationList();
$targetRelations = [];
foreach ($relations as $relation) {
if (
$relation->getType() !== RelationType::BELONGS_TO &&
$relation->getType() !== RelationType::HAS_ONE
) {
continue;
}
$foreignEntityType = $relation->getForeignEntityType();
if (
$foreignEntityType !== Account::ENTITY_TYPE &&
$foreignEntityType !== Contact::ENTITY_TYPE &&
$foreignEntityType !== Lead::ENTITY_TYPE &&
$this->metadata->get("scopes.$foreignEntityType.type") !== Person::TEMPLATE_TYPE &&
$this->metadata->get("scopes.$foreignEntityType.type") !== Company::TEMPLATE_TYPE
) {
continue;
}
$targetRelations[] = $relation;
}
return $this->sortRelations($targetRelations);
}
/**
* @param RelationDefs[] $targetRelations
* @return RelationDefs[]
*/
private function sortRelations(array $targetRelations): array
{
usort($targetRelations, function (RelationDefs $a, RelationDefs $b) {
$entityTypeList = [
Account::ENTITY_TYPE,
Contact::ENTITY_TYPE,
Lead::ENTITY_TYPE,
];
$index1 = array_search($a->getForeignEntityType(), $entityTypeList);
$index2 = array_search($b->getForeignEntityType(), $entityTypeList);
if ($index1 !== false && $index2 === false) {
return -1;
}
if ($index1 === false && $index2 !== false) {
return 1;
}
if ($index1 !== false && $index2 !== false && $index1 !== $index2) {
return $index1 - $index2;
}
return 0;
});
return $targetRelations;
}
}

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\Modules\Crm\Tools\Activities;
class FetchParams
{
private ?int $maxSize;
private ?int $offset;
private ?string $entityType;
public function __construct(
?int $maxSize,
?int $offset,
?string $entityType
) {
$this->maxSize = $maxSize;
$this->offset = $offset;
$this->entityType = $entityType;
}
public function getMaxSize(): ?int
{
return $this->maxSize;
}
public function getOffset(): ?int
{
return $this->offset;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
}

View File

@@ -0,0 +1,162 @@
<?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\Modules\Crm\Tools\Activities;
use DateInterval;
use DateTime;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\Modules\Crm\Entities\Task;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Tools\PopupNotification\Item;
use Espo\Tools\PopupNotification\Provider;
use Exception;
class PopupNotificationsProvider implements Provider
{
private const REMINDER_PAST_HOURS = 24;
private Config $config;
private EntityManager $entityManager;
public function __construct(
Config $config,
EntityManager $entityManager
) {
$this->config = $config;
$this->entityManager = $entityManager;
}
/**
* @return Item[]
* @throws Exception
*/
public function get(User $user): array
{
$userId = $user->getId();
$dt = new DateTime();
$pastHours = $this->config->get('reminderPastHours', self::REMINDER_PAST_HOURS);
$now = $dt->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
$nowShifted = $dt
->sub(new DateInterval('PT' . $pastHours . 'H'))
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
/** @var iterable<Reminder> $reminderCollection */
$reminderCollection = $this->entityManager
->getRDBRepositoryByClass(Reminder::class)
->select([
'id',
'entityType',
'entityId',
])
->where([
'type' => Reminder::TYPE_POPUP,
'userId' => $userId,
'remindAt<=' => $now,
'startAt>' => $nowShifted,
])
->find();
$resultList = [];
foreach ($reminderCollection as $reminder) {
$reminderId = $reminder->getId();
$entityType = $reminder->getTargetEntityType();
$entityId = $reminder->getTargetEntityId();
if (!$entityId || !$entityType) {
continue;
}
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
continue;
}
$data = null;
if (
$entity instanceof CoreEntity &&
$entity->hasLinkMultipleField('users') &&
$entity->hasAttribute('usersColumns')
) {
$status = $entity->getLinkMultipleColumn('users', 'status', $userId);
if ($status === Meeting::ATTENDEE_STATUS_DECLINED) {
$this->removeReminder($reminderId);
continue;
}
}
$dateField = $entityType === Task::ENTITY_TYPE ?
'dateEnd' :
'dateStart';
$data = (object) [
'id' => $entity->getId(),
'entityType' => $entityType,
'name' => $entity->get(Field::NAME),
'dateField' => $dateField,
'attributes' => (object) [
$dateField => $entity->get($dateField),
$dateField . 'Date' => $entity->get($dateField . 'Date'),
],
];
$resultList[] = new Item($reminderId, $data);
}
return $resultList;
}
private function removeReminder(string $id): void
{
$deleteQuery = $this->entityManager
->getQueryBuilder()
->delete()
->from(Reminder::ENTITY_TYPE)
->where([Attribute::ID => $id])
->build();
$this->entityManager->getQueryExecutor()->execute($deleteQuery);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<?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\Modules\Crm\Tools\Activities\Upcoming;
class Params
{
/**
* @param ?string[] $entityTypeList
*/
public function __construct(
readonly public ?int $offset = null,
readonly public ?int $maxSize = null,
readonly public ?int $futureDays = null,
readonly public ?array $entityTypeList = null,
readonly public bool $includeShared = false,
) {}
}

View File

@@ -0,0 +1,410 @@
<?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\Modules\Crm\Tools\Activities;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Select\Bool\Filters\OnlyMy;
use Espo\Core\Select\Bool\Filters\Shared;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DateTime as DateTimeUtil;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\Modules\Crm\Classes\Select\Meeting\PrimaryFilters\Planned;
use Espo\Modules\Crm\Classes\Select\Task\PrimaryFilters\Actual;
use Espo\Modules\Crm\Entities\Task;
use Espo\Modules\Crm\Tools\Activities\Upcoming\Params;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
use Espo\ORM\Entity;
use Espo\ORM\Query\Select;
use Espo\Core\Acl\Table;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Select\Where\ConverterFactory as WhereConverterFactory;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\ORM\Query\SelectBuilder;
use Exception;
use PDO;
use DateTime;
use RuntimeException;
class UpcomingService
{
private const UPCOMING_ACTIVITIES_FUTURE_DAYS = 1;
private const UPCOMING_ACTIVITIES_TASK_FUTURE_DAYS = 7;
public function __construct(
private WhereConverterFactory $whereConverterFactory,
private SelectBuilderFactory $selectBuilderFactory,
private Config $config,
private Metadata $metadata,
private Acl $acl,
private EntityManager $entityManager,
private ServiceContainer $serviceContainer,
private Config\ApplicationConfig $applicationConfig,
) {}
/**
* Get upcoming activities.
*
* @return RecordCollection<Entity>
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
public function get(string $userId, Params $params): RecordCollection
{
/** @var ?User $user */
$user = $this->entityManager->getEntityById(User::ENTITY_TYPE, $userId);
if (!$user) {
throw new NotFound();
}
$this->accessCheck($user);
$entityTypeList = $params->entityTypeList ?? $this->config->get('activitiesEntityList', []);
$futureDays = $params->futureDays ??
$this->config->get('activitiesUpcomingFutureDays', self::UPCOMING_ACTIVITIES_FUTURE_DAYS);
$queryList = [];
foreach ($entityTypeList as $entityType) {
if (
!$this->metadata->get(['scopes', $entityType, 'activity']) &&
$entityType !== Task::ENTITY_TYPE
) {
continue;
}
if (
!$this->acl->checkScope($entityType, Table::ACTION_READ) ||
!$this->metadata->get(['entityDefs', $entityType, 'fields', 'dateStart']) ||
!$this->metadata->get(['entityDefs', $entityType, 'fields', 'dateEnd'])
) {
continue;
}
$queryList[] = $this->getEntityTypeQuery($entityType, $user, $futureDays, $params->includeShared);
}
if ($queryList === []) {
return RecordCollection::create(new EntityCollection(), 0);
}
$builder = $this->entityManager
->getQueryBuilder()
->union();
foreach ($queryList as $query) {
$builder->query($query);
}
$unionCountQuery = $builder->build();
$countQuery = $this->entityManager->getQueryBuilder()
->select()
->fromQuery($unionCountQuery, 'c')
->select('COUNT:(c.id)', 'count')
->build();
$countSth = $this->entityManager->getQueryExecutor()->execute($countQuery);
$row = $countSth->fetch(PDO::FETCH_ASSOC);
$totalCount = $row['count'];
$offset = $params->offset ?? 0;
$maxSize = $params->maxSize ?? 0;
$unionQuery = $builder
->order('dateEndIsNull')
->order('order')
->order('dateStart')
->order('dateEnd')
->order('name')
->limit($offset, $maxSize)
->build();
$sth = $this->entityManager->getQueryExecutor()->execute($unionQuery);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC) ?: [];
$collection = new EntityCollection();
foreach ($rows as $row) {
/** @var string $itemEntityType */
$itemEntityType = $row['entityType'];
/** @var string $itemId */
$itemId = $row['id'];
$entity = $this->entityManager->getEntityById($itemEntityType, $itemId);
if (!$entity) {
// @todo Revise.
$entity = $this->entityManager->getNewEntity($itemEntityType);
$entity->set('id', $itemId);
}
if (
$entity instanceof CoreEntity &&
$entity->hasLinkParentField(Field::PARENT)
) {
$entity->loadParentNameField(Field::PARENT);
}
$this->serviceContainer->get($itemEntityType)->prepareEntityForOutput($entity);
$collection->append($entity);
}
/** @var RecordCollection<Entity> */
return RecordCollection::create($collection, $totalCount);
}
/**
* @throws Forbidden
* @throws BadRequest
*/
private function getEntityTypeQuery(string $entityType, User $user, int $futureDays, bool $includeShared): Select
{
try {
$beforeString = (new DateTime())->modify('+' . $futureDays . ' days')
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
} catch (Exception $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
$builder = $this->selectBuilderFactory
->create()
->from($entityType)
->forUser($user)
->withBoolFilter(OnlyMy::NAME)
->withStrictAccessControl();
if ($includeShared && $this->metadata->get("scopes.$entityType.collaborators")) {
$builder->withBoolFilter(Shared::NAME);
}
$orderField = 'dateStart';
$primaryFilter = Planned::NAME;
if ($entityType === Task::ENTITY_TYPE) {
$orderField = 'dateEnd';
$primaryFilter = Actual::NAME;
}
$builder->withPrimaryFilter($primaryFilter);
$queryBuilder = $builder->buildQueryBuilder();
$this->apply($entityType, $user, $queryBuilder, $beforeString);
$queryBuilder->select([
'id',
'name',
'dateStart',
'dateEnd',
['"' . $entityType . '"', 'entityType'],
['IS_NULL:(dateEnd)', 'dateEndIsNull'],
[$orderField, 'order'],
]);
return $queryBuilder->build();
}
private function getUserTimeZone(User $user): string
{
$preferences = $this->entityManager->getEntityById(Preferences::ENTITY_TYPE, $user->getId());
if ($preferences) {
$timeZone = $preferences->get('timeZone');
if ($timeZone) {
return $timeZone;
}
}
return $this->applicationConfig->getTimeZone();
}
/**
* @throws Forbidden
*/
private function accessCheck(Entity $entity): void
{
if ($entity instanceof User) {
if (!$this->acl->checkUserPermission($entity, Acl\Permission::USER_CALENDAR)) {
throw new Forbidden();
}
return;
}
if (!$this->acl->check($entity, Table::ACTION_READ)) {
throw new Forbidden();
}
}
/**
* @throws BadRequest
*/
private function applyTask(
User $user,
SelectBuilder $queryBuilder,
string $beforeString
): void {
$converter = $this->whereConverterFactory->create(Task::ENTITY_TYPE, $user);
$timeZone = $this->getUserTimeZone($user);
$upcomingTaskFutureDays = $this->config->get(
'activitiesUpcomingTaskFutureDays',
self::UPCOMING_ACTIVITIES_TASK_FUTURE_DAYS
);
$taskBeforeString = (new DateTime())
->modify('+' . $upcomingTaskFutureDays . ' days')
->format(DateTimeUtil::SYSTEM_DATE_TIME_FORMAT);
$queryBuilder->where([
'OR' => [
[
'dateStart' => null,
'OR' => [
'dateEnd' => null,
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'before',
'attribute' => 'dateEnd',
'value' => $taskBeforeString,
])
)->getRaw()
]
],
[
'dateStart!=' => null,
'OR' => [
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'past',
'attribute' => 'dateStart',
'dateTime' => true,
'timeZone' => $timeZone,
])
)->getRaw(),
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'today',
'attribute' => 'dateStart',
'dateTime' => true,
'timeZone' => $timeZone,
])
)->getRaw(),
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'before',
'attribute' => 'dateStart',
'value' => $beforeString,
])
)->getRaw(),
]
],
],
]);
}
/**
* @throws BadRequest
*/
private function apply(
string $entityType,
User $user,
SelectBuilder $queryBuilder,
string $beforeString
): void {
if ($entityType === Task::ENTITY_TYPE) {
$this->applyTask($user, $queryBuilder, $beforeString);
return;
}
$converter = $this->whereConverterFactory->create($entityType, $user);
$timeZone = $this->getUserTimeZone($user);
$queryBuilder->where([
'OR' => [
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'today',
'attribute' => 'dateStart',
'dateTime' => true,
'timeZone' => $timeZone,
])
)->getRaw(),
[
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'future',
'attribute' => 'dateEnd',
'dateTime' => true,
'timeZone' => $timeZone,
])
)->getRaw(),
$converter->convert(
$queryBuilder,
WhereItem::fromRaw([
'type' => 'before',
'attribute' => 'dateStart',
'value' => $beforeString,
])
)->getRaw(),
],
],
]);
}
}

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\Modules\Crm\Tools\Calendar\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\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
use Espo\Modules\Crm\Tools\Calendar\Service;
use stdClass;
/**
* Busy-ranges.
*/
class GetBusyRanges implements Action
{
public function __construct(private Service $calendarService) {}
public function process(Request $request): Response
{
$from = $request->getQueryParam('from');
$to = $request->getQueryParam('to');
$userIdListString = $request->getQueryParam('userIdList');
if (!$from || !$to || !$userIdListString) {
throw new BadRequest();
}
$userIdList = explode(',', $userIdListString);
$map = $this->calendarService->fetchBusyRangesForUsers(
$userIdList,
DateTime::fromString($from),
DateTime::fromString($to),
$request->getQueryParam('entityType'),
$request->getQueryParam('entityId')
);
$result = (object) [];
foreach ($map as $userId => $itemList) {
$result->$userId = self::itemListToRaw($itemList);
}
return ResponseComposer::json($result);
}
/**
* @param CalendarItem[] $itemList
* @return stdClass[]
*/
private static function itemListToRaw(array $itemList): array
{
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
}
}

View File

@@ -0,0 +1,138 @@
<?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\Modules\Crm\Tools\Calendar\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\Forbidden;
use Espo\Core\Acl;
use Espo\Core\Field\DateTime;
use Espo\Entities\User;
use Espo\Modules\Crm\Tools\Calendar\FetchParams;
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
use Espo\Modules\Crm\Tools\Calendar\Service;
use stdClass;
/**
* Calendar events.
*/
class GetCalendar implements Action
{
private const MAX_CALENDAR_RANGE = 123;
public function __construct(
private Service $calendarService,
private Acl $acl,
private User $user
) {}
public function process(Request $request): Response
{
if (!$this->acl->check('Calendar')) {
throw new Forbidden();
}
$from = $request->getQueryParam('from');
$to = $request->getQueryParam('to');
$isAgenda = $request->getQueryParam('agenda') === 'true';
if (empty($from) || empty($to)) {
throw new BadRequest();
}
if (strtotime($to) - strtotime($from) > self::MAX_CALENDAR_RANGE * 24 * 3600) {
throw new Forbidden('Too long range.');
}
$scopeList = null;
if ($request->getQueryParam('scopeList') !== null) {
$scopeList = explode(',', $request->getQueryParam('scopeList'));
}
$userId = $request->getQueryParam('userId');
$userIdList = $request->getQueryParam('userIdList');
$teamIdList = $request->getQueryParam('teamIdList');
$fetchParams = FetchParams
::create(
DateTime::fromString($from),
DateTime::fromString($to)
)
->withScopeList($scopeList);
if ($teamIdList) {
$teamIdList = explode(',', $teamIdList);
$raw = self::itemListToRaw(
$this->calendarService->fetchForTeams($teamIdList, $fetchParams)
);
return ResponseComposer::json($raw);
}
if ($userIdList) {
$userIdList = explode(',', $userIdList);
$raw = self::itemListToRaw(
$this->calendarService->fetchForUsers($userIdList, $fetchParams)
);
return ResponseComposer::json($raw);
}
if (!$userId) {
$userId = $this->user->getId();
}
$fetchParams = $fetchParams
->withIsAgenda($isAgenda)
->withWorkingTimeRanges();
$raw = self::itemListToRaw(
$this->calendarService->fetch($userId, $fetchParams)
);
return ResponseComposer::json($raw);
}
/**
* @param CalendarItem[] $itemList
* @return stdClass[]
*/
private static function itemListToRaw(array $itemList): array
{
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
}
}

View File

@@ -0,0 +1,116 @@
<?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\Modules\Crm\Tools\Calendar\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\Forbidden;
use Espo\Core\Acl;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\FetchParams;
use Espo\Modules\Crm\Tools\Calendar\Item as CalendarItem;
use Espo\Modules\Crm\Tools\Calendar\Service;
use stdClass;
/**
* Get timeline items.
*/
class GetTimeline implements Action
{
private const MAX_CALENDAR_RANGE = 123;
public function __construct(
private Service $calendarService,
private Acl $acl
) {}
public function process(Request $request): Response
{
if (!$this->acl->check('Calendar')) {
throw new Forbidden();
}
$from = $request->getQueryParam('from');
$to = $request->getQueryParam('to');
if (empty($from) || empty($to)) {
throw new BadRequest();
}
if (strtotime($to) - strtotime($from) > self::MAX_CALENDAR_RANGE * 24 * 3600) {
throw new Forbidden('Too long range.');
}
$scopeList = null;
if ($request->getQueryParam('scopeList') !== null) {
$scopeList = explode(',', $request->getQueryParam('scopeList'));
}
$userId = $request->getQueryParam('userId');
$userIdList = $request->getQueryParam('userIdList');
$userIdList = $userIdList ? explode(',', $userIdList) : [];
if ($userId) {
$userIdList[] = $userId;
}
$fetchParams = FetchParams
::create(
DateTime::fromString($from . ':00'),
DateTime::fromString($to . ':00')
)
->withScopeList($scopeList);
$map = $this->calendarService->fetchTimelineForUsers($userIdList, $fetchParams);
$result = (object) [];
foreach ($map as $userId => $itemList) {
$result->$userId = self::itemListToRaw($itemList);
}
return ResponseComposer::json($result);
}
/**
* @param CalendarItem[] $itemList
* @return stdClass[]
*/
private static function itemListToRaw(array $itemList): array
{
return array_map(fn (CalendarItem $item) => $item->getRaw(), $itemList);
}
}

View File

@@ -0,0 +1,152 @@
<?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\Modules\Crm\Tools\Calendar;
use Espo\Core\Field\DateTime;
class FetchParams
{
private DateTime $from;
private DateTime $to;
private bool $isAgenda = false;
private bool $skipAcl = false;
/** @var ?string[] */
private ?array $scopeList = null;
private bool $workingTimeRanges = false;
private bool $workingTimeRangesInverted = false;
public function __construct(DateTime $from, DateTime $to)
{
$this->from = $from;
$this->to = $to;
}
public static function create(DateTime $from, DateTime $to): self
{
return new self($from, $to);
}
public function withFrom(DateTime $from): self
{
$obj = clone $this;
$obj->from = $from;
return $obj;
}
public function withTo(DateTime $to): self
{
$obj = clone $this;
$obj->to = $to;
return $obj;
}
public function withIsAgenda(bool $isAgenda = true): self
{
$obj = clone $this;
$obj->isAgenda = $isAgenda;
return $obj;
}
public function withSkipAcl(bool $skipAcl = true): self
{
$obj = clone $this;
$obj->skipAcl = $skipAcl;
return $obj;
}
/**
* @param ?string[] $scopeList
*/
public function withScopeList(?array $scopeList): self
{
$obj = clone $this;
$obj->scopeList = $scopeList;
return $obj;
}
public function withWorkingTimeRanges(bool $workingTimeRanges = true): self
{
$obj = clone $this;
$obj->workingTimeRanges = $workingTimeRanges;
return $obj;
}
public function withWorkingTimeRangesInverted(bool $workingTimeRangesInverted = true): self
{
$obj = clone $this;
$obj->workingTimeRangesInverted = $workingTimeRangesInverted;
return $obj;
}
public function getFrom(): DateTime
{
return $this->from;
}
public function getTo(): DateTime
{
return $this->to;
}
public function isAgenda(): bool
{
return $this->isAgenda;
}
public function skipAcl(): bool
{
return $this->skipAcl;
}
/**
* @return ?string[]
*/
public function getScopeList(): ?array
{
return $this->scopeList;
}
public function workingTimeRanges(): bool
{
return $this->workingTimeRanges;
}
public function workingTimeRangesInverted(): bool
{
return $this->workingTimeRangesInverted;
}
}

View File

@@ -0,0 +1,47 @@
<?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\Modules\Crm\Tools\Calendar\FreeBusy;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Items\Event;
readonly class FetchParams
{
/**
* @param bool $accessCheck To user apply access check.
* @param Event[] $ignoreEventList Events not to be included in a result.
*/
public function __construct(
public DateTime $from,
public DateTime $to,
public bool $accessCheck = false,
public array $ignoreEventList = [],
) {}
}

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\Modules\Crm\Tools\Calendar\FreeBusy;
use Espo\Core\Exceptions\Forbidden;
use Espo\Entities\User;
use Espo\Modules\Crm\Tools\Calendar\FetchParams as CalendarFetchParams;
use Espo\Modules\Crm\Tools\Calendar\Items\BusyRange;
use Espo\Modules\Crm\Tools\Calendar\Service as CalendarService;
/**
* @since 9.0.0
*/
class Service
{
public function __construct(
private CalendarService $service,
) {}
/**
* Fetch busy-ranges for user. Access is not checked by default.
*
* @return BusyRange[]
* @throws Forbidden
*/
public function fetchRanges(User $user, FetchParams $params): array
{
$fetchParams = CalendarFetchParams::create($params->from, $params->to);
return $this->service->fetchBusyRanges($user, $params, $fetchParams);
}
}

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\Modules\Crm\Tools\Calendar;
use Espo\Core\Field\DateTime;
use stdClass;
interface Item
{
public function getRaw(): stdClass;
}

View File

@@ -0,0 +1,66 @@
<?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\Modules\Crm\Tools\Calendar\Items;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Item;
use stdClass;
class BusyRange implements Item
{
private DateTime $start;
private DateTime $end;
public function __construct(DateTime $start, DateTime $end)
{
$this->start = $start;
$this->end = $end;
}
public function getStart(): DateTime
{
return $this->start;
}
public function getEnd(): DateTime
{
return $this->end;
}
public function getRaw(): stdClass
{
return (object) [
'dateStart' => $this->start->toString(),
'dateEnd' => $this->end->toString(),
'isBusyRange' => true,
];
}
}

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\Modules\Crm\Tools\Calendar\Items;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Item;
use RuntimeException;
use stdClass;
class Event implements Item
{
private ?DateTime $start;
private ?DateTime $end;
private string $entityType;
/** @var array<string, mixed> */
private array $attributes;
/** @var string[] */
private array $userIdList = [];
/** @var array<string, string> */
private array $userNameMap = [];
/**
* @param array<string, mixed> $attributes
*/
public function __construct(?DateTime $start, ?DateTime $end, string $entityType, array $attributes)
{
$this->start = $start;
$this->end = $end;
$this->entityType = $entityType;
$this->attributes = $attributes;
}
public function getRaw(): stdClass
{
$obj = (object) [
'scope' => $this->entityType,
'dateStart' => $this->start?->toString(),
'dateEnd' => $this->end?->toString(),
];
if ($this->userIdList !== []) {
$obj->userIdList = $this->userIdList;
$obj->userNameMap = (object) $this->userNameMap;
}
foreach ($this->attributes as $key => $value) {
$obj->$key = $obj->$key ?? $value;
}
return $obj;
}
/**
* @param mixed $value
*/
public function withAttribute(string $name, $value): self
{
$obj = clone $this;
$obj->attributes[$name] = $value;
return $obj;
}
public function withId(string $id): self
{
$obj = clone $this;
$obj->attributes['id'] = $id;
return $obj;
}
public function withUserIdAdded(string $userId): self
{
$obj = clone $this;
$obj->userIdList[] = $userId;
return $obj;
}
/**
* @param array<string, string> $userNameMap
*/
public function withUserNameMap(array $userNameMap): self
{
$obj = clone $this;
$obj->userNameMap = $userNameMap;
return $obj;
}
public function getId(): string
{
$id = $this->attributes['id'] ?? null;
if (!$id) {
throw new RuntimeException();
}
return $id;
}
public function getStart(): ?DateTime
{
return $this->start;
}
public function getEnd(): ?DateTime
{
return $this->end;
}
public function getEntityType(): string
{
return $this->entityType;
}
/**
* @return array<string, mixed>
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* @return string[]
*/
public function getUserIdList(): array
{
return $this->userIdList;
}
/**
* @return mixed
*/
public function getAttribute(string $name)
{
return $this->attributes[$name] ?? null;
}
}

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\Modules\Crm\Tools\Calendar\Items;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Item;
use stdClass;
class NonWorkingRange implements Item
{
private DateTime $start;
private DateTime $end;
public function __construct(DateTime $start, DateTime $end)
{
$this->start = $start;
$this->end = $end;
}
public function getStart(): DateTime
{
return $this->start;
}
public function getEnd(): DateTime
{
return $this->end;
}
public function getRaw(): stdClass
{
return (object) [
'dateStart' => $this->start->toString(),
'dateEnd' => $this->end->toString(),
'isNonWorkingRange' => true,
];
}
}

View File

@@ -0,0 +1,66 @@
<?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\Modules\Crm\Tools\Calendar\Items;
use Espo\Core\Field\DateTime;
use Espo\Modules\Crm\Tools\Calendar\Item;
use stdClass;
class WorkingRange implements Item
{
private DateTime $start;
private DateTime $end;
public function __construct(DateTime $start, DateTime $end)
{
$this->start = $start;
$this->end = $end;
}
public function getStart(): DateTime
{
return $this->start;
}
public function getEnd(): DateTime
{
return $this->end;
}
public function getRaw(): stdClass
{
return (object) [
'dateStart' => $this->start->toString(),
'dateEnd' => $this->end->toString(),
'isWorkingRange' => true,
];
}
}

File diff suppressed because it is too large Load Diff

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\Modules\Crm\Tools\Campaign\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\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Tools\Campaign\MailMergeService;
/**
* Generates mail merge PDFs.
*/
class PostGenerateMailMerge implements Action
{
public function __construct(
private MailMergeService $service,
private Acl $acl
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$link = $request->getParsedBody()->link ?? null;
if (!$id) {
throw new BadRequest();
}
if (!$link) {
throw new BadRequest("No `link`.");
}
if (!$this->acl->checkScope(Campaign::ENTITY_TYPE, Table::ACTION_READ)) {
throw new Forbidden();
}
$attachmentId = $this->service->generate($id, $link);
return ResponseComposer::json(['id' => $attachmentId]);
}
}

View File

@@ -0,0 +1,333 @@
<?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\Modules\Crm\Tools\Campaign;
use Espo\Core\Field\DateTime;
use Espo\Core\Utils\Config;
use Espo\Entities\EmailTemplate;
use Espo\Modules\Crm\Entities\CampaignLogRecord;
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
use Espo\Modules\Crm\Entities\EmailQueueItem as QueueItem;
use Espo\Modules\Crm\Entities\Lead;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
class LogService
{
private EntityManager $entityManager;
private Config $config;
public function __construct(
EntityManager $entityManager,
Config $config
) {
$this->entityManager = $entityManager;
$this->config = $config;
}
public function logLeadCreated(string $campaignId, Lead $target): void
{
$actionDate = DateTime::createNow();
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $target->getId(),
'parentType' => $target->getEntityType(),
'action' => CampaignLogRecord::ACTION_LEAD_CREATED,
]);
$this->entityManager->saveEntity($logRecord);
}
public function logSent(string $campaignId, QueueItem $queueItem, ?Entity $emailOrEmailTemplate = null): void
{
$queueItemId = $queueItem->getId();
$isTest = $queueItem->isTest();
$actionDate = DateTime::createNow();
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $queueItem->getTargetId(),
'parentType' => $queueItem->getTargetType(),
'action' => CampaignLogRecord::ACTION_SENT,
'stringData' => $queueItem->getEmailAddress(),
'queueItemId' => $queueItemId,
'isTest' => $isTest,
]);
if ($emailOrEmailTemplate) {
$logRecord->set([
'objectId' => $emailOrEmailTemplate->getId(),
'objectType' => $emailOrEmailTemplate->getEntityType()
]);
}
$this->entityManager->saveEntity($logRecord);
}
public function logBounced(string $campaignId, QueueItem $queueItem, bool $isHard = false): void
{
$queueItemId = $queueItem->getId();
$isTest = $queueItem->isTest();
$emailAddress = $queueItem->getEmailAddress();
if (
$this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItemId,
'action' => CampaignLogRecord::ACTION_BOUNCED,
'isTest' => $isTest,
])
->findOne()
) {
return;
}
$actionDate = DateTime::createNow();
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $queueItem->getTargetId(),
'parentType' => $queueItem->getTargetType(),
'action' => CampaignLogRecord::ACTION_BOUNCED,
'stringData' => $emailAddress,
'queueItemId' => $queueItemId,
'isTest' => $isTest,
]);
$logRecord->set(
'stringAdditionalData',
$isHard ?
CampaignLogRecord::BOUNCED_TYPE_HARD :
CampaignLogRecord::BOUNCED_TYPE_SOFT
);
$this->entityManager->saveEntity($logRecord);
}
public function logOptedIn(
string $campaignId,
?QueueItem $queueItem,
Entity $target,
?string $emailAddress = null
): void {
if (
$queueItem &&
$this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItem->getId(),
'action' => CampaignLogRecord::ACTION_OPTED_IN,
'isTest' => $queueItem->isTest(),
])
->findOne()
) {
return;
}
$actionDate = DateTime::createNow();
$emailAddress = $emailAddress ?? $target->get('emailAddress');
if (!$emailAddress && $queueItem) {
$emailAddress = $queueItem->getEmailAddress();
}
$queueItemId = null;
$isTest = false;
if ($queueItem) {
$queueItemId = $queueItem->getId();
$isTest = $queueItem->isTest();
}
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $target->getId(),
'parentType' => $target->getEntityType(),
'action' => CampaignLogRecord::ACTION_OPTED_IN,
'stringData' => $emailAddress,
'queueItemId' => $queueItemId,
'isTest' => $isTest,
]);
$this->entityManager->saveEntity($logRecord);
}
public function logOptedOut(
string $campaignId,
?QueueItem $queueItem,
Entity $target,
?string $emailAddress = null
): void {
if (
$queueItem &&
$this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItem->getId(),
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
'isTest' => $queueItem->isTest(),
])
->findOne()
) {
return;
}
$actionDate = DateTime::createNow();
$queueItemId = null;
$isTest = false;
if ($queueItem) {
$queueItemId = $queueItem->getId();
$isTest = $queueItem->isTest();
}
if (!$emailAddress && $queueItem) {
$emailAddress = $queueItem->getEmailAddress();
}
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $target->getId(),
'parentType' => $target->getEntityType(),
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
'stringData' => $emailAddress,
'queueItemId' => $queueItemId,
'isTest' => $isTest
]);
$this->entityManager->saveEntity($logRecord);
}
public function logOpened(string $campaignId, QueueItem $queueItem): void
{
$actionDate = DateTime::createNow();
if (
$this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItem->getId(),
'action' => CampaignLogRecord::ACTION_OPENED,
'isTest' => $queueItem->isTest(),
])
->findOne()
) {
return;
}
$massEmail = $queueItem->getMassEmail();
if (!$massEmail) {
return;
}
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $queueItem->getTargetId(),
'parentType' => $queueItem->getTargetType(),
'action' => CampaignLogRecord::ACTION_OPENED,
'objectId' => $massEmail->getEmailTemplateId(),
'objectType' => EmailTemplate::ENTITY_TYPE,
'queueItemId' => $queueItem->getId(),
'isTest' => $queueItem->isTest(),
]);
$this->entityManager->saveEntity($logRecord);
}
public function logClicked(
string $campaignId,
QueueItem $queueItem,
CampaignTrackingUrl $trackingUrl
): void {
$actionDate = DateTime::createNow();
if ($this->config->get('massEmailOpenTracking')) {
$this->logOpened($campaignId, $queueItem);
}
if (
$this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItem->getId(),
'action' => CampaignLogRecord::ACTION_CLICKED,
'objectId' => $trackingUrl->getId(),
'objectType' => $trackingUrl->getEntityType(),
'isTest' => $queueItem->isTest(),
])
->findOne()
) {
return;
}
$logRecord = $this->entityManager->getNewEntity(CampaignLogRecord::ENTITY_TYPE);
$logRecord->set([
'campaignId' => $campaignId,
'actionDate' => $actionDate->toString(),
'parentId' => $queueItem->getTargetId(),
'parentType' => $queueItem->getTargetType(),
'action' => CampaignLogRecord::ACTION_CLICKED,
'objectId' => $trackingUrl->getId(),
'objectType' => $trackingUrl->getEntityType(),
'queueItemId' => $queueItem->getId(),
'isTest' => $queueItem->isTest(),
]);
$this->entityManager->saveEntity($logRecord);
}
}

View File

@@ -0,0 +1,159 @@
<?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\Modules\Crm\Tools\Campaign;
use Espo\Core\Exceptions\Error;
use Espo\Core\Field\LinkParent;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Util;
use Espo\Entities\Attachment;
use Espo\Entities\Template;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\ORM\Entity;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
use Espo\Tools\Pdf\Builder;
use Espo\Tools\Pdf\Data\DataLoaderManager;
use Espo\Tools\Pdf\IdDataMap;
use Espo\Tools\Pdf\Params;
use Espo\Tools\Pdf\TemplateWrapper;
use Espo\Tools\Pdf\ZipContents;
class MailMergeGenerator
{
private const DEFAULT_ENGINE = 'Dompdf';
private const ATTACHMENT_MAIL_MERGE_ROLE = 'Mail Merge';
private EntityManager $entityManager;
private DataLoaderManager $dataLoaderManager;
private ServiceContainer $serviceContainer;
private Builder $builder;
private Config $config;
private FileStorageManager $fileStorageManager;
public function __construct(
EntityManager $entityManager,
DataLoaderManager $dataLoaderManager,
ServiceContainer $serviceContainer,
Builder $builder,
Config $config,
FileStorageManager $fileStorageManager
) {
$this->entityManager = $entityManager;
$this->dataLoaderManager = $dataLoaderManager;
$this->serviceContainer = $serviceContainer;
$this->builder = $builder;
$this->config = $config;
$this->fileStorageManager = $fileStorageManager;
}
/**
* Generate a mail-merge PDF.
*
* @return string An attachment ID.
* @param EntityCollection<Entity> $collection
* @throws Error
*/
public function generate(
EntityCollection $collection,
Template $template,
?string $campaignId = null,
?string $name = null
): string {
$entityType = $collection->getEntityType();
if (!$entityType) {
throw new Error("No entity type.");
}
$name = $name ?? $campaignId ?? $entityType;
$params = Params::create()->withAcl();
$idDataMap = IdDataMap::create();
$service = $this->serviceContainer->get($entityType);
foreach ($collection as $entity) {
$service->loadAdditionalFields($entity);
$idDataMap->set(
$entity->getId(),
$this->dataLoaderManager->load($entity, $params)
);
// For bc.
if (method_exists($service, 'loadAdditionalFieldsForPdf')) {
$service->loadAdditionalFieldsForPdf($entity);
}
}
$engine = $this->config->get('pdfEngine') ?? self::DEFAULT_ENGINE;
$templateWrapper = new TemplateWrapper($template);
$printer = $this->builder
->setTemplate($templateWrapper)
->setEngine($engine)
->build();
$contents = $printer->printCollection($collection, $params, $idDataMap);
$type = $contents instanceof ZipContents ?
'application/zip' :
'application/pdf';
$filename = $contents instanceof ZipContents ?
Util::sanitizeFileName($name) . '.zip' :
Util::sanitizeFileName($name) . '.pdf';
/** @var Attachment $attachment */
$attachment = $this->entityManager->getNewEntity(Attachment::ENTITY_TYPE);
$relatedLink = $campaignId ?
LinkParent::create(Campaign::ENTITY_TYPE, $campaignId) : null;
$attachment
->setRelated($relatedLink)
->setSize($contents->getStream()->getSize())
->setRole(self::ATTACHMENT_MAIL_MERGE_ROLE)
->setName($filename)
->setType($type);
$this->entityManager->saveEntity($attachment);
$this->fileStorageManager->putStream($attachment, $contents->getStream());
return $attachment->getId();
}
}

View File

@@ -0,0 +1,231 @@
<?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\Modules\Crm\Tools\Campaign;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Language;
use Espo\Entities\Template;
use Espo\Modules\Crm\Entities\Campaign as CampaignEntity;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\ORM\Collection;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
class MailMergeService
{
/** @var array<string, string[]> */
protected $entityTypeAddressFieldListMap = [
'Account' => ['billingAddress', 'shippingAddress'],
'Contact' => ['address'],
'Lead' => ['address'],
'User' => [],
];
/** @var string[] */
protected $targetLinkList = [
'accounts',
'contacts',
'leads',
'users',
];
private EntityManager $entityManager;
private Acl $acl;
private Language $defaultLanguage;
private MailMergeGenerator $generator;
public function __construct(
EntityManager $entityManager,
Acl $acl,
Language $defaultLanguage,
MailMergeGenerator $generator
) {
$this->entityManager = $entityManager;
$this->acl = $acl;
$this->defaultLanguage = $defaultLanguage;
$this->generator = $generator;
}
/**
* @return string An attachment ID.
* @throws BadRequest
* @throws Error
* @throws Forbidden
*/
public function generate(string $campaignId, string $link, bool $checkAcl = true): string
{
/** @var CampaignEntity $campaign */
$campaign = $this->entityManager->getEntityById(CampaignEntity::ENTITY_TYPE, $campaignId);
if ($checkAcl && !$this->acl->checkEntityRead($campaign)) {
throw new Forbidden();
}
/** @var string $targetEntityType */
$targetEntityType = $campaign->getRelationParam($link, RelationParam::ENTITY);
if ($checkAcl && !$this->acl->check($targetEntityType, Acl\Table::ACTION_READ)) {
throw new Forbidden("Could not mail merge campaign because access to target entity type is forbidden.");
}
if (!in_array($link, $this->targetLinkList)) {
throw new BadRequest();
}
if ($campaign->getType() !== CampaignEntity::TYPE_MAIL) {
throw new Error("Could not mail merge campaign not of Mail type.");
}
$templateId = $campaign->get($link . 'TemplateId');
if (!$templateId) {
throw new Error("Could not mail merge campaign w/o specified template.");
}
/** @var ?Template $template */
$template = $this->entityManager->getEntityById(Template::ENTITY_TYPE, $templateId);
if (!$template) {
throw new Error("Template not found.");
}
if ($template->getTargetEntityType() !== $targetEntityType) {
throw new Error("Template is not of proper entity type.");
}
$campaign->loadLinkMultipleField('targetLists');
$campaign->loadLinkMultipleField('excludingTargetLists');
if (count($campaign->getLinkMultipleIdList('targetLists')) === 0) {
throw new Error("Could not mail merge campaign w/o any specified target list.");
}
$metTargetHash = [];
$targetEntityList = [];
/** @var Collection<TargetList> $excludingTargetListList */
$excludingTargetListList = $this->entityManager
->getRDBRepository(CampaignEntity::ENTITY_TYPE)
->getRelation($campaign, 'excludingTargetLists')
->find();
foreach ($excludingTargetListList as $excludingTargetList) {
$recordList = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($excludingTargetList, $link)
->find();
foreach ($recordList as $excludingTarget) {
$hashId = $excludingTarget->getEntityType() . '-' . $excludingTarget->getId();
$metTargetHash[$hashId] = true;
}
}
$addressFieldList = $this->entityTypeAddressFieldListMap[$targetEntityType];
/** @var Collection<TargetList> $targetListCollection */
$targetListCollection = $this->entityManager
->getRDBRepository(CampaignEntity::ENTITY_TYPE)
->getRelation($campaign, 'targetLists')
->find();
foreach ($targetListCollection as $targetList) {
if (!$campaign->get($link . 'TemplateId')) {
continue;
}
$entityList = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link)
->where([
'@relation.optedOut' => false,
])
->find();
foreach ($entityList as $e) {
$hashId = $e->getEntityType() . '-'. $e->getId();
if (!empty($metTargetHash[$hashId])) {
continue;
}
$metTargetHash[$hashId] = true;
if ($campaign->get('mailMergeOnlyWithAddress')) {
if (empty($addressFieldList)) {
continue;
}
$hasAddress = false;
foreach ($addressFieldList as $addressField) {
if (
$e->get($addressField . 'Street') ||
$e->get($addressField . 'PostalCode')
) {
$hasAddress = true;
break;
}
}
if (!$hasAddress) {
continue;
}
}
$targetEntityList[] = $e;
}
}
if (empty($targetEntityList)) {
throw new Error("No targets available for mail merge.");
}
$filename = $campaign->getName() . ' - ' .
$this->defaultLanguage->translateLabel($targetEntityType, 'scopeNamesPlural');
/** @var EntityCollection<Entity> $collection */
$collection = $this->entityManager
->getCollectionFactory()
->create($targetEntityType, $targetEntityList);
return $this->generator->generate(
$collection,
$template,
$campaign->getId(),
$filename
);
}
}

View File

@@ -0,0 +1,103 @@
<?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\Modules\Crm\Tools\Case\Distribution;
use Espo\Modules\Crm\Entities\CaseObj;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Entities\Team;
class LeastBusy
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata
) {}
public function getUser(Team $team, ?string $targetUserPosition = null): ?User
{
$where = [
'isActive' => true,
];
if (!empty($targetUserPosition)) {
$where['@relation.role'] = $targetUserPosition;
}
$userList = $this->entityManager
->getRDBRepository(Team::ENTITY_TYPE)
->getRelation($team, 'users')
->where($where)
->order('id')
->find();
if (count($userList) === 0) {
return null;
}
$countHash = [];
$notActualStatusList =
$this->metadata->get(['entityDefs', 'Case', 'fields', 'status', 'notActualOptions']) ?? [];
foreach ($userList as $user) {
$count = $this->entityManager
->getRDBRepository(CaseObj::ENTITY_TYPE)
->where([
'assignedUserId' => $user->getId(),
'status!=' => $notActualStatusList,
])
->count();
$countHash[$user->getId()] = $count;
}
$foundUserId = false;
$min = false;
foreach ($countHash as $userId => $count) {
if ($min === false) {
$min = $count;
$foundUserId = $userId;
} else if ($count < $min) {
$min = $count;
$foundUserId = $userId;
}
}
if ($foundUserId !== false) {
/** @var ?User */
return $this->entityManager->getEntityById(User::ENTITY_TYPE, $foundUserId);
}
return null;
}
}

View File

@@ -0,0 +1,95 @@
<?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\Modules\Crm\Tools\Case\Distribution;
use Espo\Entities\User;
use Espo\Entities\Team;
use Espo\Modules\Crm\Entities\CaseObj;
use Espo\ORM\EntityManager;
class RoundRobin
{
public function __construct(private EntityManager $entityManager)
{}
public function getUser(Team $team, ?string $targetUserPosition = null): ?User
{
$where = [
'isActive' => true,
];
if (!empty($targetUserPosition)) {
$where['@relation.role'] = $targetUserPosition;
}
$userList = $this->entityManager
->getRDBRepositoryByClass(Team::class)
->getRelation($team, 'users')
->where($where)
->order('id')
->find();
if (count($userList) === 0) {
return null;
}
$userIdList = [];
foreach ($userList as $user) {
$userIdList[] = $user->getId();
}
/** @var ?CaseObj $case */
$case = $this->entityManager
->getRDBRepository(CaseObj::ENTITY_TYPE)
->where([
'assignedUserId' => $userIdList,
])
->order('number', 'DESC')
->findOne();
if (empty($case)) {
$num = 0;
} else {
$num = array_search($case->getAssignedUser()?->getId(), $userIdList);
if ($num === false || $num == count($userIdList) - 1) {
$num = 0;
} else {
$num++;
}
}
$id = $userIdList[$num];
/** @var User */
return $this->entityManager->getEntityById(User::ENTITY_TYPE, $id);
}
}

View File

@@ -0,0 +1,339 @@
<?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\Modules\Crm\Tools\Case;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Field\EmailAddress;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Templates\Entities\Company;
use Espo\Core\Templates\Entities\Person;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\CaseObj;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\Collection;
use Espo\ORM\EntityManager;
use Espo\ORM\Type\RelationType;
use Espo\Tools\Email\EmailAddressEntityPair;
use RuntimeException;
class Service
{
public function __construct(
private ServiceContainer $serviceContainer,
private Acl $acl,
private EntityManager $entityManager,
private SelectBuilderFactory $selectBuilderFactory,
private Metadata $metadata
) {}
/**
* @throws Forbidden
* @return EmailAddressEntityPair[]
*/
public function getEmailAddressList(string $id): array
{
/** @var CaseObj $entity */
$entity = $this->serviceContainer
->get(CaseObj::ENTITY_TYPE)
->getEntity($id);
$list = [];
if (
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'contacts') &&
$this->acl->checkScope(Contact::ENTITY_TYPE)
) {
foreach ($this->getContactEmailAddressList($entity) as $item) {
$list[] = $item;
}
}
if (
$list === [] &&
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'account') &&
$this->acl->checkScope(Account::ENTITY_TYPE)
) {
$item = $this->getAccountEmailAddress($entity, $list);
if ($item) {
$list[] = $item;
}
}
if (
$list === [] &&
$this->acl->checkField(CaseObj::ENTITY_TYPE, 'lead') &&
$this->acl->checkScope(Lead::ENTITY_TYPE)
) {
$item = $this->getLeadEmailAddress($entity, $list);
if ($item) {
$list[] = $item;
}
}
if ($list === []) {
$item = $this->findPersonEmailAddress($entity);
if ($item) {
$list[] = $item;
}
}
return $list;
}
/**
* @param EmailAddressEntityPair[] $dataList
*/
private function getAccountEmailAddress(CaseObj $entity, array $dataList): ?EmailAddressEntityPair
{
$account = $entity->getAccount();
if (!$account) {
return null;
}
$emailAddress = $account->getEmailAddress();
if (!$emailAddress) {
return null;
}
if (!$this->acl->checkEntity($account)) {
return null;
}
if (!$this->acl->checkField(Account::ENTITY_TYPE, 'emailAddress')) {
return null;
}
foreach ($dataList as $item) {
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
return null;
}
}
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $account);
}
/**
* @param EmailAddressEntityPair[] $dataList
*/
private function getLeadEmailAddress(CaseObj $entity, array $dataList): ?EmailAddressEntityPair
{
$lead = $entity->getLead();
if (!$lead) {
return null;
}
$emailAddress = $lead->getEmailAddress();
if (!$emailAddress) {
return null;
}
if (!$this->acl->checkEntity($lead)) {
return null;
}
if (!$this->acl->checkField(Lead::ENTITY_TYPE, 'emailAddress')) {
return null;
}
foreach ($dataList as $item) {
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
return null;
}
}
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $lead);
}
/**
* @return EmailAddressEntityPair[]
*/
private function getContactEmailAddressList(CaseObj $entity): array
{
$contactsLinkMultiple = $entity->getContacts();
$contactIdList = $contactsLinkMultiple->getIdList();
if (!count($contactIdList)) {
return [];
}
if (!$this->acl->checkField(Contact::ENTITY_TYPE, 'emailAddress')) {
return[];
}
$dataList = [];
$emailAddressList = [];
try {
$query = $this->selectBuilderFactory
->create()
->from(Contact::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder()
->select([
'id',
'emailAddress',
'name',
])
->where([
'id' => $contactIdList,
])
->build();
} catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
/** @var Collection<Contact> $contactCollection */
$contactCollection = $this->entityManager
->getRDBRepositoryByClass(Contact::class)
->clone($query)
->find();
foreach ($contactCollection as $contact) {
$emailAddress = $contact->getEmailAddress();
if (!$emailAddress) {
continue;
}
if (in_array($emailAddress, $emailAddressList)) {
continue;
}
$emailAddressList[] = $emailAddress;
$dataList[] = new EmailAddressEntityPair(EmailAddress::create($emailAddress), $contact);
}
$primaryContact = $entity->getContact();
if (!$primaryContact) {
return $dataList;
}
usort(
$dataList,
function (
EmailAddressEntityPair $o1,
EmailAddressEntityPair $o2
) use ($primaryContact) {
if ($o1->getEntity()->getId() === $primaryContact->getId()) {
return -1;
}
if ($o2->getEntity()->getId() === $primaryContact->getId()) {
return 1;
}
return 0;
}
);
return $dataList;
}
private function findPersonEmailAddress(CaseObj $entity): ?EmailAddressEntityPair
{
$relations = $this->entityManager
->getDefs()
->getEntity(CaseObj::ENTITY_TYPE)
->getRelationList();
foreach ($relations as $relation) {
if (
$relation->getType() !== RelationType::BELONGS_TO &&
$relation->getType() !== RelationType::HAS_ONE
) {
continue;
}
$foreignEntityType = $relation->getForeignEntityType();
if (
$this->metadata->get("scopes.$foreignEntityType.type") !== Person::TEMPLATE_TYPE &&
$this->metadata->get("scopes.$foreignEntityType.type") !== Company::TEMPLATE_TYPE
) {
continue;
}
$address = $this->getPersonEmailAddress($entity, $relation->getName());
if ($address) {
return $address;
}
}
return null;
}
private function getPersonEmailAddress(CaseObj $entity, string $link): ?EmailAddressEntityPair
{
$foreignEntity = $this->entityManager
->getRDBRepositoryByClass(CaseObj::class)
->getRelation($entity, $link)
->findOne();
if (!$foreignEntity) {
return null;
}
if (!$this->acl->checkEntityRead($foreignEntity)) {
return null;
}
if (!$this->acl->checkField($foreignEntity->getEntityType(), 'emailAddress')) {
return null;
}
/** @var ?string $address */
$address = $foreignEntity->get('emailAddress');
if (!$address) {
return null;
}
$emailAddress = EmailAddress::create($address);
return new EmailAddressEntityPair($emailAddress, $foreignEntity);
}
}

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\Modules\Crm\Tools\Document;
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\Modules\Crm\Entities\Document;
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 an attachment for re-using (e.g. in an email).
*
* @throws NotFound
* @throws Forbidden
* @throws Error
*/
public function copyAttachment(string $id, FieldData $fieldData): Attachment
{
/** @var ?Document $entity */
$entity = $this->serviceContainer
->getByClass(Document::class)
->getEntity($id);
if (!$entity) {
throw new NotFound();
}
$this->attachmentAccessChecker->check($fieldData);
$attachmentId = $entity->getFileId();
if (!$attachmentId) {
throw new Error("No file.");
}
$attachment = $this->copyAttachmentById($attachmentId, $fieldData);
if (!$attachment) {
throw new Error("No file.");
}
return $attachment;
}
private function copyAttachmentById(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,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\Modules\Crm\Tools\EmailAddress;
use Espo\Core\Utils\File\Manager as FileManager;
use Espo\Core\Utils\Json;
use RuntimeException;
/**
* @since 9.0.3
*/
class DefaultFreeDomainChecker implements FreeDomainChecker
{
private string $file = 'application/Espo/Modules/Crm/Resources/data/freeEmailProviderDomains.json';
public function __construct(
private FileManager $fileManager
) {}
public function check(string $domain): bool
{
$list = Json::decode($this->fileManager->getContents($this->file));
if (!is_array($list)) {
throw new RuntimeException("Bad data in freeEmailProviderDomains file.");
}
return in_array($domain, $list);
}
}

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\Modules\Crm\Tools\EmailAddress;
/**
* @since 9.0.3
*/
interface FreeDomainChecker
{
/**
* Check whether the domain belongs to a free email provider.
*/
public function check(string $domain): bool;
}

View File

@@ -0,0 +1,341 @@
<?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\Modules\Crm\Tools\KnowledgeBase;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Entities\Attachment;
use Espo\Modules\Crm\Entities\KnowledgeBaseArticle;
use Espo\ORM\EntityManager;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\Tools\Attachment\AccessChecker as AttachmentAccessChecker;
use Espo\Tools\Attachment\FieldData;
class Service
{
public function __construct(
private EntityManager $entityManager,
private AttachmentAccessChecker $attachmentAccessChecker,
private ServiceContainer $serviceContainer,
private SelectBuilderFactory $selectBuilderFactory,
private Acl $acl,
) {}
/**
* Copy article attachments for re-using (e.g. in an email).
*
* @return Attachment[]
* @throws NotFound
* @throws Forbidden
*/
public function copyAttachments(string $id, FieldData $fieldData): array
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->serviceContainer
->get(KnowledgeBaseArticle::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);
}
/**
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveUp(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::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();
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
->withStrictAccessControl()
->withSearchParams($params)
->buildQueryBuilder()
->where([
'order<' => $currentIndex,
])
->order([
['order', 'DESC'],
])
->build();
/** @var ?KnowledgeBaseArticle $previousEntity */
$previousEntity = $this->entityManager
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
->clone($query)
->findOne();
if (!$previousEntity) {
return;
}
$entity->set('order', $previousEntity->getOrder());
$previousEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($previousEntity);
}
/**
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveDown(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::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();
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
->withStrictAccessControl()
->withSearchParams($params)
->buildQueryBuilder()
->where([
'order>' => $currentIndex,
])
->order([
['order', 'ASC'],
])
->build();
/** @var ?KnowledgeBaseArticle $nextEntity */
$nextEntity = $this->entityManager
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
->clone($query)
->findOne();
if (!$nextEntity) {
return;
}
$entity->set('order', $nextEntity->getOrder());
$nextEntity->set('order', $currentIndex);
$this->entityManager->saveEntity($entity);
$this->entityManager->saveEntity($nextEntity);
}
/**
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveToTop(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::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();
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
->withStrictAccessControl()
->withSearchParams($params)
->buildQueryBuilder()
->where([
'order<' => $currentIndex,
])
->order([
['order', 'ASC'],
])
->build();
/** @var ?KnowledgeBaseArticle $previousEntity */
$previousEntity = $this->entityManager
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
->clone($query)
->findOne();
if (!$previousEntity) {
return;
}
$entity->set('order', $previousEntity->getOrder() - 1);
$this->entityManager->saveEntity($entity);
}
/**
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveToBottom(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::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();
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
->withStrictAccessControl()
->withSearchParams($params)
->buildQueryBuilder()
->where([
'order>' => $currentIndex,
])
->order([
['order', 'DESC'],
])
->build();
/** @var ?KnowledgeBaseArticle $nextEntity */
$nextEntity = $this->entityManager
->getRDBRepositoryByClass(KnowledgeBaseArticle::class)
->clone($query)
->findOne();
if (!$nextEntity) {
return;
}
$entity->set('order', $nextEntity->getOrder() + 1);
$this->entityManager->saveEntity($entity);
}
}

View File

@@ -0,0 +1,46 @@
<?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\Modules\Crm\Tools\Lead\Convert;
class Params
{
private bool $skipDuplicateCheck;
public function __construct(
bool $skipDuplicateCheck
) {
$this->skipDuplicateCheck = $skipDuplicateCheck;
}
public function skipDuplicateCheck(): bool
{
return $this->skipDuplicateCheck;
}
}

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\Modules\Crm\Tools\Lead\Convert;
use Espo\Core\Utils\ObjectUtil;
use stdClass;
use UnexpectedValueException;
/**
* Raw attribute values of multiple records.
*
* Immutable.
*/
class Values
{
/** @var array<string, stdClass> */
private array $data = [];
public static function create(): self
{
return new self();
}
public function has(string $entityType): bool
{
return array_key_exists($entityType, $this->data);
}
public function get(string $entityType): stdClass
{
$data = $this->data[$entityType] ?? null;
if ($data === null) {
throw new UnexpectedValueException();
}
return ObjectUtil::clone($data);
}
public function with(string $entityType, stdClass $data): self
{
$obj = clone $this;
$obj->data[$entityType] = ObjectUtil::clone($data);
return $obj;
}
public function getRaw(): stdClass
{
$data = (object) [];
foreach ($this->data as $entityType => $item) {
$data->$entityType = ObjectUtil::clone($item);
}
return $data;
}
}

View File

@@ -0,0 +1,608 @@
<?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\Modules\Crm\Tools\Lead;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\ConflictSilent;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Record\CreateParams;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Call;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Document;
use Espo\Modules\Crm\Entities\Lead;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\Modules\Crm\Tools\Lead\Convert\Params;
use Espo\Modules\Crm\Tools\Lead\Convert\Values;
use Espo\ORM\Collection;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Repositories\Attachment as AttachmentRepository;
use Espo\Tools\Stream\Service as StreamService;
class ConvertService
{
public function __construct(
private Acl $acl,
private ServiceContainer $recordServiceContainer,
private EntityManager $entityManager,
private User $user,
private StreamService $streamService,
private Metadata $metadata,
private FieldUtil $fieldUtil,
) {}
/**
* Convert a lead.
*
* @throws Forbidden
* @throws Conflict
* @throws NotFound
* @throws BadRequest
*/
public function convert(string $id, Values $records, Params $params): Lead
{
$lead = $this->getLead($id);
if (!$params->skipDuplicateCheck()) {
$this->processDuplicateCheck($records);
}
$account = $this->processAccount(
lead: $lead,
records: $records,
);
$contact = $this->processContact(
lead: $lead,
records: $records,
account: $account,
);
$account ??= $this->getSelectedAccount($contact);
$opportunity = $this->processOpportunity(
lead: $lead,
records: $records,
account: $account,
contact: $contact,
);
$lead->setStatus(Lead::STATUS_CONVERTED);
$this->entityManager->saveEntity($lead);
$this->processLinks($lead, $account, $contact, $opportunity);
$this->processStream($lead, $account, $contact, $opportunity);
return $lead;
}
/**
* Get values for the conversion form.
*
* @throws Forbidden
* @throws NotFound
*/
public function getValues(string $id): Values
{
$lead = $this->getLead($id);
$values = Values::create();
/** @var string[] $entityList */
$entityList = $this->metadata->get('entityDefs.Lead.convertEntityList') ?? [];
$ignoreAttributeList = [
Field::CREATED_AT,
Field::MODIFIED_AT,
Field::MODIFIED_BY . 'Id',
Field::MODIFIED_BY . 'Name',
Field::CREATED_BY . 'Id',
Field::MODIFIED_BY . 'Name',
];
/** @var array<string, array<string, string>> $convertFieldsDefs */
$convertFieldsDefs = $this->metadata->get('entityDefs.Lead.convertFields') ?? [];
foreach ($entityList as $entityType) {
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_CREATE)) {
continue;
}
$attributes = [];
$fieldMap = [];
/** @var string[] $fieldList */
$fieldList = array_keys($this->metadata->get('entityDefs.Lead.fields', []));
foreach ($fieldList as $field) {
if (!$this->metadata->get("entityDefs.$entityType.fields.$field")) {
continue;
}
if (
$this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type'])
!==
$this->metadata->get(['entityDefs', 'Lead', 'fields', $field, 'type'])
) {
continue;
}
$fieldMap[$field] = $field;
}
if (array_key_exists($entityType, $convertFieldsDefs)) {
foreach ($convertFieldsDefs[$entityType] as $field => $leadField) {
$fieldMap[$field] = $leadField;
}
}
foreach ($fieldMap as $field => $leadField) {
$type = $this->metadata->get(['entityDefs', $entityType, 'fields', $field, 'type']);
if (in_array($type, [FieldType::FILE, FieldType::IMAGE])) {
$attachment = $lead->get($leadField);
if ($attachment) {
$attachment = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
$idAttribute = $field . 'Id';
$nameAttribute = $field . 'Name';
$attributes[$idAttribute] = $attachment->getId();
$attributes[$nameAttribute] = $attachment->getName();
}
continue;
}
if ($type === FieldType::ATTACHMENT_MULTIPLE) {
$attachmentList = $lead->get($leadField);
if (count($attachmentList)) {
$idList = [];
$nameHash = (object) [];
$typeHash = (object) [];
foreach ($attachmentList as $attachment) {
$attachment = $this->getAttachmentRepository()->getCopiedAttachment($attachment);
$idList[] = $attachment->getId();
$nameHash->{$attachment->getId()} = $attachment->getName();
$typeHash->{$attachment->getId()} = $attachment->getType();
}
$attributes[$field . 'Ids'] = $idList;
$attributes[$field . 'Names'] = $nameHash;
$attributes[$field . 'Types'] = $typeHash;
}
continue;
}
if ($type === FieldType::LINK_MULTIPLE) {
$attributes[$field . 'Ids'] = $lead->get($leadField . 'Ids');
$attributes[$field . 'Names'] = $lead->get($leadField . 'Names');
$attributes[$field . 'Columns'] = $lead->get($leadField . 'Columns');
continue;
}
$leadAttributeList = $this->fieldUtil->getAttributeList(Lead::ENTITY_TYPE, $leadField);
$attributeList = $this->fieldUtil->getAttributeList($entityType, $field);
if (count($attributeList) !== count($leadAttributeList)) {
continue;
}
foreach ($attributeList as $i => $attribute) {
if (in_array($attribute, $ignoreAttributeList)) {
continue;
}
$leadAttribute = $leadAttributeList[$i] ?? null;
if (!$leadAttribute || !$lead->has($leadAttribute)) {
continue;
}
$attributes[$attribute] = $lead->get($leadAttribute);
}
}
$values = $values->with($entityType, (object) $attributes);
}
return $values;
}
private function getAttachmentRepository(): AttachmentRepository
{
/** @var AttachmentRepository */
return $this->entityManager->getRepository(Attachment::ENTITY_TYPE);
}
/**
* @throws Forbidden
* @throws BadRequest
* @throws Conflict
*/
private function processAccount(Lead $lead, Values $records): ?Account
{
if (!$records->has(Account::ENTITY_TYPE)) {
return null;
}
if (!$this->acl->checkScope(Account::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
throw new Forbidden("No 'create' access for Account.");
}
$values = $records->get(Account::ENTITY_TYPE);
$service = $this->recordServiceContainer->getByClass(Account::class);
$account = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
$lead->setCreatedAccount($account);
return $account;
}
/**
* @throws Forbidden
* @throws BadRequest
* @throws Conflict
*/
private function processContact(
Lead $lead,
Values $records,
?Account $account,
): ?Contact {
if (!$records->has(Contact::ENTITY_TYPE)) {
return null;
}
if (!$this->acl->checkScope(Contact::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
throw new Forbidden("No 'create' access for Contact.");
}
$values = $records->get(Contact::ENTITY_TYPE);
if ($account) {
$values->accountId = $account->getId();
}
$service = $this->recordServiceContainer->getByClass(Contact::class);
$contact = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
$lead->set('createdContactId', $contact->getId());
if (
!$account &&
$contact->getAccount() &&
!$contact->getAccount()->get('originalLeadId')
) {
$lead->setCreatedAccount($contact->getAccount());
}
return $contact;
}
/**
* @throws Forbidden
* @throws BadRequest
* @throws Conflict
*/
private function processOpportunity(
Lead $lead,
Values $records,
?Account $account,
?Contact $contact,
): ?Opportunity {
if (!$records->has(Opportunity::ENTITY_TYPE)) {
return null;
}
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_CREATE)) {
throw new Forbidden("No 'create' access for Opportunity.");
}
$values = $records->get(Opportunity::ENTITY_TYPE);
if ($account) {
$values->accountId = $account->getId();
}
if ($contact) {
$values->contactId = $contact->getId();
}
$service = $this->recordServiceContainer->getByClass(Opportunity::class);
$opportunity = $service->create($values, CreateParams::create()->withSkipDuplicateCheck());
if ($contact) {
$this->entityManager
->getRelation($contact, 'opportunities')
->relate($opportunity);
}
$lead->set('createdOpportunityId', $opportunity->getId());
return $opportunity;
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function getLead(string $id): Lead
{
$lead = $this->recordServiceContainer
->getByClass(Lead::class)
->getEntity($id);
if (!$lead) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($lead)) {
throw new Forbidden("No edit access.");
}
return $lead;
}
private function processLinks(
Lead $lead,
?Account $account,
?Contact $contact,
?Opportunity $opportunity,
): void {
$leadRepository = $this->entityManager->getRDBRepositoryByClass(Lead::class);
/** @var Collection<Meeting> $meetings */
$meetings = $leadRepository
->getRelation($lead, 'meetings')
->select([Attribute::ID, 'parentId', 'parentType'])
->find();
foreach ($meetings as $meeting) {
if ($contact && $contact->hasId()) {
$this->entityManager
->getRDBRepository(Meeting::ENTITY_TYPE)
->getRelation($meeting, 'contacts')
->relate($contact);
}
if ($opportunity && $opportunity->hasId()) {
$meeting->set('parentId', $opportunity->getId());
$meeting->set('parentType', Opportunity::ENTITY_TYPE);
$this->entityManager->saveEntity($meeting);
} else if ($account && $account->hasId()) {
$meeting->set('parentId', $account->getId());
$meeting->set('parentType', Account::ENTITY_TYPE);
$this->entityManager->saveEntity($meeting);
}
}
/** @var Collection<Call> $calls */
$calls = $leadRepository
->getRelation($lead, 'calls')
->select([Attribute::ID, 'parentId', 'parentType'])
->find();
foreach ($calls as $call) {
if ($contact && $contact->hasId()) {
$this->entityManager
->getRelation($call, 'contacts')
->relate($contact);
}
if ($opportunity && $opportunity->hasId()) {
$call->set('parentId', $opportunity->getId());
$call->set('parentType', Opportunity::ENTITY_TYPE);
$this->entityManager->saveEntity($call);
} else if ($account && $account->hasId()) {
$call->set('parentId', $account->getId());
$call->set('parentType', Account::ENTITY_TYPE);
$this->entityManager->saveEntity($call);
}
}
/** @var Collection<Email> $emails */
$emails = $leadRepository
->getRelation($lead, 'emails')
->select([Attribute::ID, 'parentId', 'parentType'])
->find();
foreach ($emails as $email) {
if ($opportunity && $opportunity->hasId()) {
$email->set('parentId', $opportunity->getId());
$email->set('parentType', Opportunity::ENTITY_TYPE);
$this->entityManager->saveEntity($email);
} else if ($account && $account->hasId()) {
$email->set('parentId', $account->getId());
$email->set('parentType', Account::ENTITY_TYPE);
$this->entityManager->saveEntity($email);
}
}
/** @var Collection<Document> $documents */
$documents = $leadRepository
->getRelation($lead, 'documents')
->select([Attribute::ID])
->find();
foreach ($documents as $document) {
if ($account && $account->hasId()) {
$this->entityManager
->getRelation($document, 'accounts')
->relate($account);
}
if ($opportunity && $opportunity->hasId()) {
$this->entityManager
->getRelation($document, 'opportunities')
->relate($opportunity);
}
}
}
private function processStream(
Lead $lead,
?Account $account,
?Contact $contact,
?Opportunity $opportunity
): void {
if (!$this->streamService->checkIsFollowed($lead, $this->user->getId())) {
return;
}
if ($opportunity && $opportunity->hasId()) {
$this->streamService->followEntity($opportunity, $this->user->getId());
}
if ($account && $account->hasId()) {
$this->streamService->followEntity($account, $this->user->getId());
}
if ($contact && $contact->hasId()) {
$this->streamService->followEntity($contact, $this->user->getId());
}
}
private function getSelectedAccount(?Contact $contact): ?Account
{
if (!$contact) {
return null;
}
if (!$contact->getAccount()) {
return null;
}
$account = $this->entityManager
->getRDBRepositoryByClass(Account::class)
->getById($contact->getAccount()->getId());
if ($account && !$this->acl->checkEntityRead($account)) {
return null;
}
return $account;
}
/**
* @throws Conflict
*/
private function processDuplicateCheck(Values $records): void
{
$duplicateList = [];
if ($records->has(Account::ENTITY_TYPE)) {
$accountService = $this->recordServiceContainer->getByClass(Account::class);
$account = $this->entityManager->getRDBRepositoryByClass(Account::class)->getNew();
$account->set($records->get(Account::ENTITY_TYPE));
foreach ($accountService->findDuplicates($account) ?? [] as $e) {
$duplicateList[] = (object) [
'id' => $e->getId(),
'name' => $e->getName(),
'_entityType' => $e->getEntityType(),
];
}
}
if ($records->has(Contact::ENTITY_TYPE)) {
$contactService = $this->recordServiceContainer->getByClass(Contact::class);
$contact = $this->entityManager->getRDBRepositoryByClass(Contact::class)->getNew();
$contact->set($records->get(Contact::ENTITY_TYPE));
foreach ($contactService->findDuplicates($contact) ?? [] as $e) {
$duplicateList[] = (object) [
'id' => $e->getId(),
'name' => $e->getName(),
'_entityType' => $e->getEntityType(),
];
}
}
if ($records->has(Opportunity::ENTITY_TYPE)) {
$opportunityService = $this->recordServiceContainer->getByClass(Opportunity::class);
$opportunity = $this->entityManager->getRDBRepositoryByClass(Opportunity::class)->getNew();
$opportunity->set($records->get(Opportunity::ENTITY_TYPE));
foreach ($opportunityService->findDuplicates($opportunity) ?? [] as $e) {
$duplicateList[] = (object) [
'id' => $e->getId(),
'name' => $e->getName(),
'_entityType' => $e->getEntityType(),
];
}
}
if (!count($duplicateList)) {
return;
}
throw ConflictSilent::createWithBody('duplicate', Json::encode($duplicateList));
}
}

View File

@@ -0,0 +1,64 @@
<?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\Modules\Crm\Tools\MassEmail\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\Modules\Crm\Tools\MassEmail\UnsubscribeService;
/** @noinspection PhpUnused */
class DeleteUnsubscribe implements Action
{
public function __construct(private UnsubscribeService $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
if ($hash && $emailAddress) {
$this->service->subscribeAgainWithHash($emailAddress, $hash);
return ResponseComposer::json(true);
}
if (!$id) {
throw new BadRequest();
}
$this->service->subscribeAgain($id);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Modules\Crm\Tools\MassEmail\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\Modules\Crm\Tools\MassEmail\UnsubscribeService;
/** @noinspection PhpUnused */
class PostUnsubscribe implements Action
{
public function __construct(private UnsubscribeService $service) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$hash = $request->getRouteParam('hash');
$emailAddress = $request->getRouteParam('emailAddress');
if ($hash && $emailAddress) {
$this->service->unsubscribeWithHash($emailAddress, $hash);
return ResponseComposer::json(true);
}
if (!$id) {
throw new BadRequest();
}
$this->service->unsubscribe($id);
return ResponseComposer::json(true);
}
}

View File

@@ -0,0 +1,91 @@
<?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\Modules\Crm\Tools\MassEmail;
use Espo\Core\Utils\Config;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
class DefaultMessageHeadersPreparator implements MessageHeadersPreparator
{
public function __construct(
private Config $config,
private Config\ApplicationConfig $applicationConfig,
) {}
public function prepare(Headers $headers, Data $data): void
{
$headers->addTextHeader('X-Queue-Item-Id', $data->getId());
$headers->addTextHeader('Precedence', 'bulk');
$campaignType = $this->getCampaignType($data);
if (
$campaignType === Campaign::TYPE_INFORMATIONAL_EMAIL ||
$campaignType === Campaign::TYPE_NEWSLETTER
) {
$headers->addTextHeader('Auto-Submitted', 'auto-generated');
$headers->addTextHeader('X-Auto-Response-Suppress', 'AutoReply');
}
$this->addMandatoryOptOut($headers, $data);
}
private function getSiteUrl(): string
{
$url = $this->config->get('massEmailSiteUrl') ?? $this->applicationConfig->getSiteUrl();
return rtrim($url, '/');
}
private function addMandatoryOptOut(Headers $headers, Data $data): void
{
if ($this->getCampaignType($data) === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return;
}
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
return;
}
$id = $data->getId();
$url = "{$this->getSiteUrl()}/api/v1/Campaign/unsubscribe/$id";
$headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
$headers->addTextHeader('List-Unsubscribe', "<$url>");
}
private function getCampaignType(Data $data): ?string
{
return $data->getQueueItem()->getMassEmail()?->getCampaign()?->getType();
}
}

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\Modules\Crm\Tools\MassEmail;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
/**
* Applies additional headers to a mass email message.
*/
interface MessageHeadersPreparator
{
public function prepare(Headers $headers, Data $data): void;
}

View File

@@ -0,0 +1,61 @@
<?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\Modules\Crm\Tools\MassEmail\MessagePreparator;
use Espo\Core\Mail\SenderParams;
use Espo\Modules\Crm\Entities\EmailQueueItem;
class Data
{
public function __construct(
private string $id,
private SenderParams $senderParams,
private EmailQueueItem $queueItem,
) {}
public function getId(): string
{
return $this->id;
}
/** @noinspection PhpUnused */
public function getSenderParams(): SenderParams
{
return $this->senderParams;
}
/**
* @since 9.1.0
*/
public function getQueueItem(): EmailQueueItem
{
return $this->queueItem;
}
}

View File

@@ -0,0 +1,44 @@
<?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\Modules\Crm\Tools\MassEmail\MessagePreparator;
use Espo\Core\Mail\Sender;
class Headers
{
public function __construct(
private Sender $sender,
) {}
public function addTextHeader(string $name, string $value): void
{
$this->sender->withAddedHeader($name, $value);
}
}

View File

@@ -0,0 +1,255 @@
<?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\Modules\Crm\Tools\MassEmail;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Log;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\ORM\Entity;
use Espo\ORM\Name\Attribute;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Entities\EmailAddress;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Core\Exceptions\Error;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use stdClass;
class QueueCreator
{
private const ERASED_PREFIX = 'ERASED:';
/** @var string[] */
protected array $targetLinkList;
public function __construct(
protected EntityManager $entityManager,
private Metadata $metadata,
private Log $log,
) {
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
}
private function cleanupQueueItems(MassEmail $massEmail): void
{
$delete = $this->entityManager
->getQueryBuilder()
->delete()
->from(EmailQueueItem::ENTITY_TYPE)
->where([
'massEmailId' => $massEmail->getId(),
'status' => [
EmailQueueItem::STATUS_PENDING,
EmailQueueItem::STATUS_FAILED,
],
])
->build();
$this->entityManager->getQueryExecutor()->execute($delete);
}
/**
* @param iterable<Entity> $additionalTargetList
* @throws Error
*/
public function create(MassEmail $massEmail, bool $isTest = false, iterable $additionalTargetList = []): void
{
if (!$isTest && $massEmail->getStatus() !== MassEmail::STATUS_PENDING) {
throw new Error("Mass Email {$massEmail->getId()} should has status 'Pending'.");
}
if ($this->toSkipAsInactive($massEmail, $isTest)) {
$this->log->notice("Skipping mass email {id} queue creation for inactive campaign.", [
'id' => $massEmail->getId(),
]);
return;
}
$withOptedOut = $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
if (!$isTest) {
$this->cleanupQueueItems($massEmail);
}
$itemList = [];
if (!$isTest) {
$itemList = $this->getItemList($massEmail, $withOptedOut);
}
foreach ($additionalTargetList as $record) {
$item = $record->getValueMap();
$item->entityType = $record->getEntityType();
$itemList[] = $item;
}
foreach ($itemList as $item) {
$emailAddress = $item->emailAddress ?? null;
if (!$emailAddress) {
continue;
}
if (str_starts_with($emailAddress, self::ERASED_PREFIX)) {
continue;
}
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($emailAddressRecord && !$withOptedOut && $emailAddressRecord->isOptedOut()) {
continue;
}
if ($emailAddressRecord && $emailAddressRecord->isInvalid()) {
continue;
}
$queueItem = $this->entityManager->getNewEntity(EmailQueueItem::ENTITY_TYPE);
$queueItem->set([
'massEmailId' => $massEmail->getId(),
'status' => EmailQueueItem::STATUS_PENDING,
'targetId' => $item->id,
'targetType' => $item->entityType,
'isTest' => $isTest,
]);
$this->entityManager->saveEntity($queueItem);
}
if ($isTest) {
return;
}
$massEmail->setStatus(MassEmail::STATUS_IN_PROCESS);
if ($itemList === []) {
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
}
$this->entityManager->saveEntity($massEmail);
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
{
return !$isTest &&
$massEmail->getCampaign() &&
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
}
/**
* @return stdClass[]
*/
private function getItemList(MassEmail $massEmail, bool $withOptedOut): array
{
$metTargetHash = [];
$metEmailAddressHash = [];
$itemList = [];
foreach ($massEmail->getExcludingTargetLists() as $excludingTargetList) {
foreach ($this->targetLinkList as $link) {
$targets = $this->entityManager
->getRelation($excludingTargetList, $link)
->sth()
->select([Attribute::ID, Field::EMAIL_ADDRESS])
->find();
foreach ($targets as $target) {
$hashId = $target->getEntityType() . '-' . $target->getId();
$metTargetHash[$hashId] = true;
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if ($emailAddress) {
$metEmailAddressHash[$emailAddress] = true;
}
}
}
}
foreach ($massEmail->getTargetLists() as $targetList) {
foreach ($this->targetLinkList as $link) {
$where = [];
if (!$withOptedOut) {
$where = ['@relation.optedOut' => false];
}
$records = $this->entityManager
->getRelation($targetList, $link)
->select([Attribute::ID, Field::EMAIL_ADDRESS])
->sth()
->where($where)
->find();
foreach ($records as $record) {
$hashId = $record->getEntityType() . '-' . $record->getId();
$emailAddress = $record->get(Field::EMAIL_ADDRESS);
if (!$emailAddress) {
continue;
}
if (!empty($metEmailAddressHash[$emailAddress])) {
continue;
}
if (!empty($metTargetHash[$hashId])) {
continue;
}
$item = $record->getValueMap();
$item->entityType = $record->getEntityType();
$itemList[] = $item;
$metTargetHash[$hashId] = true;
$metEmailAddressHash[$emailAddress] = true;
}
}
}
return $itemList;
}
}

View File

@@ -0,0 +1,710 @@
<?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\Modules\Crm\Tools\MassEmail;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Headers;
use Laminas\Mail\Message;
use Espo\Core\Field\DateTime;
use Espo\Core\Mail\ConfigDataProvider;
use Espo\ORM\EntityCollection;
use Espo\Core\Name\Field;
use Espo\Tools\EmailTemplate\Result;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\SmtpParams;
use Espo\Entities\Attachment;
use Espo\Modules\Crm\Tools\MassEmail\MessagePreparator\Data;
use Espo\ORM\Collection;
use Espo\Entities\EmailTemplate;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
use Espo\Entities\EmailAddress;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Mail\Sender;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\Entities\Email;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignTrackingUrl;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
use Espo\ORM\Entity;
use Espo\Tools\EmailTemplate\Data as TemplateData;
use Espo\Tools\EmailTemplate\Params as TemplateParams;
use Espo\Tools\EmailTemplate\Processor as TemplateProcessor;
use Exception;
class SendingProcessor
{
private const MAX_ATTEMPT_COUNT = 3;
private const MAX_PER_HOUR_COUNT = 10000;
public function __construct(
private Config $config,
private EntityManager $entityManager,
private Language $defaultLanguage,
private EmailSender $emailSender,
private Log $log,
private AccountFactory $accountFactory,
private CampaignService $campaignService,
private MessageHeadersPreparator $headersPreparator,
private TemplateProcessor $templateProcessor,
private ConfigDataProvider $configDataProvider,
private Config\ApplicationConfig $applicationConfig,
) {}
/**
* @throws Error
* @throws NoSmtp
*/
public function process(MassEmail $massEmail, bool $isTest = false): void
{
if ($this->toSkipAsInactive($massEmail, $isTest)) {
$this->log->notice("Skipping mass email {id} queue for inactive campaign.", [
'id' => $massEmail->getId(),
]);
return;
}
$maxSize = 0;
if ($this->handleMaxSize($isTest, $maxSize)) {
return;
}
$emailTemplate = $massEmail->getEmailTemplate();
if (!$emailTemplate) {
$this->setFailed($massEmail);
return;
}
$attachmentList = $emailTemplate->getAttachments();
[$smtpParams, $senderParams] = $this->getSenderParams($massEmail);
$queueItemList = $this->getQueueItems($massEmail, $isTest, $maxSize);
foreach ($queueItemList as $queueItem) {
$this->sendQueueItem(
queueItem: $queueItem,
massEmail: $massEmail,
emailTemplate: $emailTemplate,
attachmentList: $attachmentList,
isTest: $isTest,
smtpParams: $smtpParams,
senderParams: $senderParams,
);
}
if ($isTest) {
return;
}
if ($this->getCountLeft($massEmail) !== 0) {
return;
}
$this->setComplete($massEmail);
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function getPreparedEmail(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
Entity $target,
iterable $trackingUrlList = []
): ?Email {
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if (!$emailAddress) {
return null;
}
$emailData = $this->templateProcessor->process(
$emailTemplate,
TemplateParams::create()
->withApplyAcl(false) // @todo Revise.
->withCopyAttachments(false), // @todo Revise.
TemplateData::create()
->withParent($target)
);
$body = $this->prepareBody($massEmail, $queueItem, $emailData, $trackingUrlList);
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email
->addToAddress($emailAddress)
->setSubject($emailData->getSubject())
->setBody($body)
->setIsHtml($emailData->isHtml())
->setAttachmentIdList($emailData->getAttachmentIdList());
if ($massEmail->getFromAddress()) {
$email->setFromAddress($massEmail->getFromAddress());
}
$replyToAddress = $massEmail->getReplyToAddress();
if ($replyToAddress) {
$email->addReplyToAddress($replyToAddress);
}
return $email;
}
private function prepareQueueItemMessage(
EmailQueueItem $queueItem,
Sender $sender,
SenderParams $senderParams,
): void {
$id = $queueItem->getId();
$headers = new Headers($sender);
$this->headersPreparator->prepare($headers, new Data($id, $senderParams, $queueItem));
$fromAddress = $senderParams->getFromAddress();
if (
$this->config->get('massEmailVerp') &&
$fromAddress &&
strpos($fromAddress, '@')
) {
$bounceAddress = explode('@', $fromAddress)[0] . '+bounce-qid-' . $id .
'@' . explode('@', $fromAddress)[1];
$sender->withEnvelopeFromAddress($bounceAddress);
}
}
private function setFailed(MassEmail $massEmail): void
{
$massEmail->setStatus(MassEmail::STATUS_FAILED);
$this->entityManager->saveEntity($massEmail);
$queueItemList = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
])
->find();
foreach ($queueItemList as $queueItem) {
$this->setItemFailed($queueItem);
}
}
/**
* @param EntityCollection<Attachment> $attachmentList
*/
private function sendQueueItem(
EmailQueueItem $queueItem,
MassEmail $massEmail,
EmailTemplate $emailTemplate,
EntityCollection $attachmentList,
bool $isTest,
?SmtpParams $smtpParams,
SenderParams $senderParams,
): void {
if ($this->isNotPending($queueItem)) {
return;
}
$this->setItemSending($queueItem);
$target = $this->entityManager->getEntityById($queueItem->getTargetType(), $queueItem->getTargetId());
$emailAddress = $target?->get(Field::EMAIL_ADDRESS);
if (
!$target ||
!$target->hasId() ||
!$emailAddress
) {
$this->setItemFailed($queueItem);
return;
}
$emailAddressRecord = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($emailAddressRecord) {
if ($emailAddressRecord->isInvalid()) {
$this->setItemFailed($queueItem);
return;
}
if (
$emailAddressRecord->isOptedOut() &&
$massEmail->getCampaign()?->getType() !== Campaign::TYPE_INFORMATIONAL_EMAIL
) {
$this->setItemFailed($queueItem);
return;
}
}
$email = $this->getPreparedEmail(
queueItem: $queueItem,
massEmail: $massEmail,
emailTemplate: $emailTemplate,
target: $target,
trackingUrlList: $this->getTrackingUrls($massEmail->getCampaign()),
);
if (!$email) {
return;
}
$senderParams = $this->prepareItemSenderParams($email, $senderParams, $massEmail);
$queueItem->incrementAttemptCount();
$sender = $this->emailSender->create();
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
try {
$this->prepareQueueItemMessage($queueItem, $sender, $senderParams);
$sender
->withParams($senderParams)
->withAttachments($attachmentList)
->send($email);
} catch (Exception $e) {
$this->processException($queueItem, $e);
return;
}
$emailObject = $emailTemplate;
if ($massEmail->storeSentEmails() && !$isTest) {
$this->entityManager->saveEntity($email);
$emailObject = $email;
}
$this->setItemSent($queueItem, $emailAddress);
if ($massEmail->getCampaign()) {
$this->campaignService->logSent($massEmail->getCampaign()->getId(), $queueItem, $emailObject);
}
}
private function getSiteUrl(): string
{
return $this->config->get('massEmailSiteUrl') ??
$this->applicationConfig->getSiteUrl();
}
/**
* @throws Error
* @throws NoSmtp
* @return array{?SmtpParams, SenderParams}
*/
private function getSenderParams(MassEmail $massEmail): array
{
$smtpParams = null;
$senderParams = SenderParams::create();
$inboundEmailId = $massEmail->getInboundEmailId();
if (!$inboundEmailId) {
return [$smtpParams, $senderParams];
}
$account = $this->accountFactory->create($inboundEmailId);
$smtpParams = $account->getSmtpParams();
if (
!$account->isAvailableForSending() ||
!$account->getEntity()->smtpIsForMassEmail() ||
!$smtpParams
) {
throw new Error("Mass Email: Group email account $inboundEmailId can't be used for mass email.");
}
if ($account->getEntity()->getReplyToAddress()) {
$senderParams = $senderParams
->withReplyToAddress($account->getEntity()->getReplyToAddress());
}
return [$smtpParams, $senderParams];
}
private function getCountLeft(MassEmail $massEmail): int
{
return $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => false,
])
->count();
}
private function processException(EmailQueueItem $queueItem, Exception $e): void
{
$maxAttemptCount = $this->config->get('massEmailMaxAttemptCount', self::MAX_ATTEMPT_COUNT);
$queueItem->getAttemptCount() >= $maxAttemptCount ?
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED) :
$queueItem->setStatus(EmailQueueItem::STATUS_PENDING);
$this->entityManager->saveEntity($queueItem);
$this->log->error("Mass Email, send item: {$e->getCode()}, {$e->getMessage()}");
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
private function isNotPending(EmailQueueItem $queueItem): bool
{
/** @var ?EmailQueueItem $queueItemFetched */
$queueItemFetched = $this->entityManager->getEntityById(EmailQueueItem::ENTITY_TYPE, $queueItem->getId());
if (!$queueItemFetched) {
return true;
}
return $queueItemFetched->getStatus() !== EmailQueueItem::STATUS_PENDING;
}
/**
* @return Collection<EmailQueueItem>
*/
private function getQueueItems(MassEmail $massEmail, bool $isTest, int $maxSize): Collection
{
return $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->sth()
->where([
'status' => EmailQueueItem::STATUS_PENDING,
'massEmailId' => $massEmail->getId(),
'isTest' => $isTest,
])
->limit(0, $maxSize)
->find();
}
/**
* @return bool Whether to skip.
*/
private function handleMaxSize(bool $isTest, int &$maxSize): bool
{
$hourMaxSize = $this->config->get('massEmailMaxPerHourCount', self::MAX_PER_HOUR_COUNT);
$batchMaxSize = $this->config->get('massEmailMaxPerBatchCount');
if (!$isTest) {
$threshold = DateTime::createNow()->addHours(-1);
$sentLastHourCount = $this->entityManager
->getRDBRepositoryByClass(EmailQueueItem::class)
->where([
'status' => EmailQueueItem::STATUS_SENT,
'sentAt>' => $threshold->toString(),
])
->count();
if ($sentLastHourCount >= $hourMaxSize) {
return true;
}
$hourMaxSize = $hourMaxSize - $sentLastHourCount;
}
$maxSize = $hourMaxSize;
if ($batchMaxSize) {
$maxSize = min($batchMaxSize, $maxSize);
}
return false;
}
private function setComplete(MassEmail $massEmail): void
{
$massEmail->setStatus(MassEmail::STATUS_COMPLETE);
$this->entityManager->saveEntity($massEmail);
}
private function setItemSending(EmailQueueItem $queueItem): void
{
$queueItem->setStatus(EmailQueueItem::STATUS_SENDING);
$this->entityManager->saveEntity($queueItem);
}
private function setItemFailed(EmailQueueItem $queueItem): void
{
$queueItem->setStatus(EmailQueueItem::STATUS_FAILED);
$this->entityManager->saveEntity($queueItem);
}
private function setItemSent(EmailQueueItem $queueItem, string $emailAddress): void
{
$queueItem->setEmailAddress($emailAddress);
$queueItem->setStatus(EmailQueueItem::STATUS_SENT);
$queueItem->setSentAtNow();
$this->entityManager->saveEntity($queueItem);
}
/**
* @return iterable<CampaignTrackingUrl>
*/
private function getTrackingUrls(?Campaign $campaign): iterable
{
if (!$campaign || $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return [];
}
/** @var Collection<CampaignTrackingUrl> */
return $this->entityManager
->getRelation($campaign, 'trackingUrls')
->find();
}
private function prepareItemSenderParams(
Email $email,
SenderParams $senderParams,
MassEmail $massEmail
): SenderParams {
$campaign = $massEmail->getCampaign();
if ($email->get('replyToAddress')) { // @todo Revise.
$senderParams = $senderParams->withReplyToAddress(null);
}
if ($campaign) {
$email->setLinkMultipleIdList(Field::TEAMS, $campaign->getLinkMultipleIdList(Field::TEAMS));
}
$senderParams = $senderParams->withFromAddress(
$massEmail->getFromAddress() ??
$this->configDataProvider->getSystemOutboundAddress()
);
if ($massEmail->getFromName()) {
$senderParams = $senderParams->withFromName($massEmail->getFromName());
}
if ($massEmail->getReplyToName()) {
$senderParams = $senderParams->withReplyToName($massEmail->getReplyToName());
}
return $senderParams;
}
private function getOptOutUrl(EmailQueueItem $queueItem): string
{
return "{$this->getSiteUrl()}?entryPoint=unsubscribe&id={$queueItem->getId()}";
}
private function getOptOutLink(string $optOutUrl): string
{
$label = $this->defaultLanguage->translateLabel('Unsubscribe', 'labels', Campaign::ENTITY_TYPE);
return "<a href=\"$optOutUrl\">$label</a>";
}
private function getTrackUrl(mixed $trackingUrl, EmailQueueItem $queueItem): string
{
$siteUrl = $this->getSiteUrl();
$id1 = $trackingUrl->getId();
$id2 = $queueItem->getId();
return "$siteUrl?entryPoint=campaignUrl&id=$id1&queueItemId=$id2";
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function prepareBody(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
iterable $trackingUrlList,
): string {
$body = $this->addBodyLinks(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $emailData->getBody(),
trackingUrlList: $trackingUrlList,
);
return $this->addBodyTracking(
massEmail: $massEmail,
queueItem: $queueItem,
emailData: $emailData,
body: $body,
);
}
private function toSkipAsInactive(MassEmail $massEmail, bool $isTest): bool
{
return !$isTest &&
$massEmail->getCampaign() &&
$massEmail->getCampaign()->getStatus() === Campaign::STATUS_INACTIVE;
}
/**
* @param iterable<CampaignTrackingUrl> $trackingUrlList
*/
private function addBodyLinks(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body,
iterable $trackingUrlList,
): string {
$optOutUrl = $this->getOptOutUrl($queueItem);
$optOutLink = $this->getOptOutLink($optOutUrl);
if (!$this->isInformational($massEmail)) {
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
}
$body = str_replace('{queueItemId}', $queueItem->getId(), $body);
foreach ($trackingUrlList as $trackingUrl) {
$url = $this->getTrackUrl($trackingUrl, $queueItem);
$body = str_replace($trackingUrl->getUrlToUse(), $url, $body);
}
return $this->addMandatoryBodyOptOutLink($massEmail, $queueItem, $emailData, $body);
}
private function addMandatoryBodyOptOutLink(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body,
): string {
if ($this->config->get('massEmailDisableMandatoryOptOutLink')) {
return $body;
}
if ($this->isInformational($massEmail)) {
return $body;
}
if (stripos($body, '?entryPoint=unsubscribe&id') !== false) {
return $body;
}
$optOutUrl = $this->getOptOutUrl($queueItem);
$optOutLink = $this->getOptOutLink($optOutUrl);
if ($emailData->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
return $body;
}
private function addBodyTracking(
MassEmail $massEmail,
EmailQueueItem $queueItem,
Result $emailData,
string $body
): string {
if (!$massEmail->getCampaign()) {
return $body;
}
if ($massEmail->getCampaign()->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
return $body;
}
if (!$this->config->get('massEmailOpenTracking')) {
return $body;
}
if (!$emailData->isHtml()) {
return $body;
}
$alt = $this->defaultLanguage->translateLabel('Campaign', 'scopeNames');
$url = "{$this->getSiteUrl()}?entryPoint=campaignTrackOpened&id={$queueItem->getId()}";
/** @noinspection HtmlDeprecatedAttribute */
$trackOpenedHtml = "<img alt=\"$alt\" width=\"1\" height=\"1\" border=\"0\" src=\"$url\">";
$body .= '<br>' . $trackOpenedHtml;
return $body;
}
private function isInformational(MassEmail $massEmail): bool
{
return $massEmail->getCampaign()?->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL;
}
}

View File

@@ -0,0 +1,171 @@
<?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\Modules\Crm\Tools\MassEmail;
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\NotFound;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Entities\InboundEmail;
use Espo\Modules\Crm\Entities\MassEmail as MassEmailEntity;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use stdClass;
class Service
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private QueueCreator $queueCreator,
private SendingProcessor $sendingProcessor
) {}
/**
* SMTP data for the front-end.
*
* @return stdClass[]
* @throws Forbidden
*/
public function getSmtpAccountDataList(): array
{
if (
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_CREATE) &&
!$this->acl->checkScope(MassEmailEntity::ENTITY_TYPE, Table::ACTION_EDIT)
) {
throw new Forbidden();
}
$dataList = [];
/** @var Collection<InboundEmail> $inboundEmailList */
$inboundEmailList = $this->entityManager
->getRDBRepository(InboundEmail::ENTITY_TYPE)
->where([
'useSmtp' => true,
'status' => InboundEmail::STATUS_ACTIVE,
'smtpIsForMassEmail' => true,
['emailAddress!=' => ''],
['emailAddress!=' => null],
])
->find();
foreach ($inboundEmailList as $inboundEmail) {
$item = (object) [];
$key = 'inboundEmail:' . $inboundEmail->getId();
$item->key = $key;
$item->emailAddress = $inboundEmail->getEmailAddress();
$item->fromName = $inboundEmail->getFromName();
$dataList[] = $item;
}
return $dataList;
}
/**
* Send test.
*
* @param stdClass[] $targetDataList
* @throws BadRequest
* @throws Error
* @throws Forbidden
* @throws NotFound
* @throws NoSmtp
*/
public function processTest(string $id, array $targetDataList): void
{
$targetList = [];
if (count($targetDataList) === 0) {
throw new BadRequest("Empty target list.");
}
foreach ($targetDataList as $item) {
if (empty($item->id) || empty($item->type)) {
throw new BadRequest();
}
$targetId = $item->id;
$targetType = $item->type;
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new Error("Target not found.");
}
if (!$this->acl->check($target, Table::ACTION_READ)) {
throw new Forbidden();
}
$targetList[] = $target;
}
/** @var ?MassEmailEntity $massEmail */
$massEmail = $this->entityManager->getEntityById(MassEmailEntity::ENTITY_TYPE, $id);
if (!$massEmail) {
throw new NotFound();
}
if (!$this->acl->check($massEmail, Table::ACTION_READ)) {
throw new Forbidden();
}
$this->createTestQueue($massEmail, $targetList);
$this->processTestSending($massEmail);
}
/**
* @param iterable<Entity> $targetList
* @throws Error
*/
private function createTestQueue(MassEmailEntity $massEmail, iterable $targetList): void
{
$this->queueCreator->create($massEmail, true, $targetList);
}
/**
* @throws Error
* @throws NoSmtp
*/
private function processTestSending(MassEmailEntity $massEmail): void
{
$this->sendingProcessor->process($massEmail, true);
}
}

View File

@@ -0,0 +1,332 @@
<?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\Modules\Crm\Tools\MassEmail;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\HookManager;
use Espo\Core\Name\Field;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Hasher;
use Espo\Entities\EmailAddress;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\CampaignLogRecord;
use Espo\Modules\Crm\Entities\EmailQueueItem;
use Espo\Modules\Crm\Entities\MassEmail;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\Modules\Crm\Tools\Campaign\LogService;
use Espo\Modules\Crm\Tools\MassEmail\Util as MassEmailUtil;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\Repositories\EmailAddress as EmailAddressRepository;
class UnsubscribeService
{
public function __construct(
private EntityManager $entityManager,
private HookManager $hookManager,
private LogService $service,
private MassEmailUtil $util,
private Hasher $hasher,
) {}
/**
* @throws NotFound
*/
public function unsubscribe(string $queueItemId): void
{
[$queueItem, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address) {
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if ($relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => true]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager->process(
TargetList::ENTITY_TYPE,
'afterOptOut',
$targetList,
[],
$hookData
);
}
$this->hookManager->process($target->getEntityType(), 'afterOptOut', $target);
if ($campaign) {
$this->service->logOptedOut($campaign->getId(), $queueItem, $target);
}
}
/**
* @throws NotFound
*/
public function subscribeAgain(string $queueItemId): void
{
[, $campaign, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get('emailAddress');
if ($emailAddress) {
$ea = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($ea) {
$ea->setOptedOut(false);
$this->entityManager->saveEntity($ea);
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
/** @var Collection<TargetList> $targetListList */
$targetListList = $this->entityManager
->getRDBRepository(MassEmail::ENTITY_TYPE)
->getRelation($massEmail, 'targetLists')
->find();
foreach ($targetListList as $targetList) {
$relation = $this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
continue;
}
$relation->updateColumnsById($target->getId(), ['optedOut' => false]);
$hookData = [
'link' => $link,
'targetId' => $target->getId(),
'targetType' => $target->getEntityType(),
];
$this->hookManager
->process(TargetList::ENTITY_TYPE, 'afterCancelOptOut', $targetList, [], $hookData);
}
$this->hookManager->process($target->getEntityType(), 'afterCancelOptOut', $target);
if ($campaign) {
$logRecord = $this->entityManager
->getRDBRepository(CampaignLogRecord::ENTITY_TYPE)
->where([
'queueItemId' => $queueItemId,
'action' => CampaignLogRecord::ACTION_OPTED_OUT,
])
->order(Field::CREATED_AT, true)
->findOne();
if ($logRecord) {
$this->entityManager->removeEntity($logRecord);
}
}
}
/**
* @throws NotFound
*/
public function unsubscribeWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if ($address->isOptedOut()) {
return;
}
$address->setOptedOut(true);
$this->entityManager->saveEntity($address);
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function subscribeAgainWithHash(string $emailAddress, string $hash): void
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
if (!$address->isOptedOut()) {
return;
}
$entityList = $this->getEmailAddressRepository()->getEntityListByAddressId($address->getId());
$address->setOptedOut(false);
$this->entityManager->saveEntity($address);
foreach ($entityList as $entity) {
$this->hookManager->process($entity->getEntityType(), 'afterCancelOptOut', $entity);
}
}
/**
* @throws NotFound
*/
public function isSubscribed(string $queueItemId): bool
{
[,, $massEmail, $target] = $this->getRecords($queueItemId);
if ($massEmail->optOutEntirely()) {
$emailAddress = $target->get(Field::EMAIL_ADDRESS);
if ($emailAddress) {
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if ($address && !$address->isOptedOut()) {
return true;
}
}
}
$link = $this->util->getLinkByEntityType($target->getEntityType());
foreach ($massEmail->getTargetLists() as $targetList) {
$relation = $this->entityManager->getRelation($targetList, $link);
if (!$relation->getColumn($target, 'optedOut')) {
return true;
}
}
return false;
}
/**
* @throws NotFound
*/
public function isSubscribedWithHash(string $emailAddress, string $hash): bool
{
$address = $this->getEmailAddressWithHash($emailAddress, $hash);
return !$address->isOptedOut();
}
private function getEmailAddressRepository(): EmailAddressRepository
{
/** @var EmailAddressRepository */
return $this->entityManager->getRepository(EmailAddress::ENTITY_TYPE);
}
/**
* @return array{EmailQueueItem, ?Campaign, MassEmail, Entity}
* @throws NotFound
*/
private function getRecords(string $queueItemId): array
{
$queueItem = $this->entityManager->getRDBRepositoryByClass(EmailQueueItem::class)->getById($queueItemId);
if (!$queueItem) {
throw new NotFound("No item.");
}
$massEmail = $queueItem->getMassEmail();
if (!$massEmail) {
throw new NotFound("Mass Email not found or not set.");
}
$campaign = $massEmail->getCampaign();
$targetType = $queueItem->getTargetType();
$targetId = $queueItem->getTargetId();
if ($campaign && $campaign->getType() === Campaign::TYPE_INFORMATIONAL_EMAIL) {
throw new NotFound("Campaign is informational.");
}
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new NotFound();
}
return [$queueItem, $campaign, $massEmail, $target];
}
/**
* @throws NotFound
*/
private function getEmailAddressWithHash(string $emailAddress, string $hash): EmailAddress
{
$hash2 = $this->hasher->hash($emailAddress);
if ($hash2 !== $hash) {
throw new NotFound();
}
$address = $this->getEmailAddressRepository()->getByAddress($emailAddress);
if (!$address) {
throw new NotFound();
}
return $address;
}
}

View File

@@ -0,0 +1,64 @@
<?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\Modules\Crm\Tools\MassEmail;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Defs;
use Espo\Modules\Crm\Entities\TargetList;
use RuntimeException;
class Util
{
/** @var string[] */
private array $targetLinkList;
public function __construct(
private Defs $ormDefs,
private Metadata $metadata
) {
$this->targetLinkList = $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
}
public function getLinkByEntityType(string $entityType): string
{
foreach ($this->targetLinkList as $link) {
$itemEntityType = $this->ormDefs
->getEntity(TargetList::ENTITY_TYPE)
->getRelation($link)
->getForeignEntityType();
if ($itemEntityType === $entityType) {
return $link;
}
}
throw new RuntimeException("No link for $entityType.");
}
}

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\Modules\Crm\Tools\Meeting\Invitation;
readonly class Invitee
{
public function __construct(
private string $entityType,
private string $id,
private ?string $emailAddress = null,
) {}
public function getEntityType(): string
{
return $this->entityType;
}
public function getId(): string
{
return $this->id;
}
public function getEmailAddress(): ?string
{
return $this->emailAddress;
}
}

View File

@@ -0,0 +1,223 @@
<?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\Modules\Crm\Tools\Meeting\Invitation;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\Name\Field;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\Modules\Crm\Business\Event\Invitations;
use Espo\Modules\Crm\Entities\Call;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\Email\SendService;
/**
* @since 9.0.0
*/
class Sender
{
private const TYPE_INVITATION = 'invitation';
private const TYPE_CANCELLATION = 'cancellation';
public function __construct(
private SendService $sendService,
private User $user,
private InjectableFactory $injectableFactory,
private EntityManager $entityManager,
private Config $config,
private Metadata $metadata,
) {}
/**
* @param Meeting|Call $entity
* @param ?Invitee[] $targets
* @return Entity[] Entities an invitation was sent to.
* @throws SendingError
* @throws Forbidden
*/
public function sendInvitation(Meeting|Call $entity, ?array $targets = null): array
{
return $this->sendInternal($entity, self::TYPE_INVITATION, $targets);
}
/**
* @param Meeting|Call $entity
* @param ?Invitee[] $targets
* @return Entity[] Entities an invitation was sent to.
* @throws SendingError
* @throws Forbidden
*/
public function sendCancellation(Meeting|Call $entity, ?array $targets = null): array
{
return $this->sendInternal($entity, self::TYPE_CANCELLATION, $targets);
}
/**
* @param ?Invitee[] $targets
* @return Entity[]
* @throws SendingError
* @throws Forbidden
*/
private function sendInternal(Meeting|Call $entity, string $type, ?array $targets): array
{
$this->checkStatus($entity, $type);
$linkList = [
Meeting::LINK_USERS,
Meeting::LINK_CONTACTS,
Meeting::LINK_LEADS,
];
$sender = $this->getSender();
$sentAddressList = [];
$resultEntityList = [];
foreach ($linkList as $link) {
$builder = $this->entityManager->getRelation($entity, $link);
if ($targets === null && $type === self::TYPE_INVITATION) {
$builder->where(['@relation.status=' => Meeting::ATTENDEE_STATUS_NONE]);
}
$collection = $builder->find();
foreach ($collection as $attendee) {
$emailAddress = $attendee->get(Field::EMAIL_ADDRESS);
if ($targets) {
$target = self::findTarget($attendee, $targets);
if (!$target) {
continue;
}
if ($target->getEmailAddress()) {
$emailAddress = $target->getEmailAddress();
}
}
if (!$emailAddress || in_array($emailAddress, $sentAddressList)) {
continue;
}
if ($type === self::TYPE_INVITATION) {
$sender->sendInvitation($entity, $attendee, $link, $emailAddress);
}
if ($type === self::TYPE_CANCELLATION) {
$sender->sendCancellation($entity, $attendee, $link, $emailAddress);
}
$sentAddressList[] = $emailAddress;
$resultEntityList[] = $attendee;
$this->entityManager
->getRelation($entity, $link)
->updateColumns($attendee, ['status' => Meeting::ATTENDEE_STATUS_NONE]);
}
}
return $resultEntityList;
}
/**
* @param Invitee[] $targets
*/
private static function findTarget(Entity $entity, array $targets): ?Invitee
{
foreach ($targets as $target) {
if (
$entity->getEntityType() === $target->getEntityType() &&
$entity->getId() === $target->getId()
) {
return $target;
}
}
return null;
}
private function getSender(): Invitations
{
$smtpParams = !$this->config->get('eventInvitationForceSystemSmtp') ?
$this->sendService->getUserSmtpParams($this->user->getId()) :
null;
$builder = BindingContainerBuilder::create();
if ($smtpParams) {
$builder->bindInstance(SmtpParams::class, $smtpParams);
}
return $this->injectableFactory->createWithBinding(Invitations::class, $builder->build());
}
/**
* @throws Forbidden
*/
private function checkStatus(Meeting|Call $entity, string $type): void
{
$entityType = $entity->getEntityType();
if ($type === self::TYPE_CANCELLATION) {
if (!in_array($entity->getStatus(), $this->getCanceledStatusList($entityType))) {
throw new Forbidden("Can't send invitation for not canceled event.");
}
return;
}
$notActualStatusList = [
...($this->metadata->get("scopes.$entityType.completedStatusList") ?? []),
...$this->getCanceledStatusList($entityType),
];
if (in_array($entity->getStatus(), $notActualStatusList)) {
throw new Forbidden("Can't send invitation for not actual event.");
}
}
/**
* @return string[]
*/
private function getCanceledStatusList(string $entityType): array
{
return $this->metadata->get("scopes.$entityType.canceledStatusList") ?? [];
}
}

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\Modules\Crm\Tools\Meeting\Invitation;
use Espo\Core\Binding\BindingContainerBuilder;
use Espo\Core\InjectableFactory;
use Espo\Entities\User;
/**
* @since 9.0.0
*/
class SenderFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
) {}
/**
* Create a sender for a user. The SMTP of the user will be used (if not disabled in the config).
*/
public function createForUser(User $user): Sender
{
return $this->injectableFactory->createWithBinding(
Sender::class,
BindingContainerBuilder::create()
->bindInstance(User::class, $user)
->build()
);
}
}

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\Modules\Crm\Tools\Meeting;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Core\Record\ServiceContainer as RecordServiceContainer;
use Espo\Modules\Crm\Entities\Call;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\Modules\Crm\Tools\Meeting\Invitation\Sender;
use Espo\Modules\Crm\Tools\Meeting\Invitation\Invitee;
use Espo\ORM\Entity;
class InvitationService
{
private const TYPE_INVITATION = 'invitation';
private const TYPE_CANCELLATION = 'cancellation';
public function __construct(
private RecordServiceContainer $recordServiceContainer,
private Acl $acl,
private Sender $invitationSender,
) {}
/**
* Send invitation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
*
* @param ?Invitee[] $targets
* @return Entity[] Entities an invitation was sent to.
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws SendingError
*/
public function send(string $entityType, string $id, ?array $targets = null): array
{
return $this->sendInternal($entityType, $id, $targets, self::TYPE_INVITATION);
}
/**
* Send cancellation emails for a meeting (or call). Checks access. Uses user's SMTP if available.
*
* @param ?Invitee[] $targets
* @return Entity[] Entities a cancellation was sent to.
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws SendingError
*/
public function sendCancellation(string $entityType, string $id, ?array $targets = null): array
{
return $this->sendInternal($entityType, $id, $targets, self::TYPE_CANCELLATION);
}
/**
* @param ?Invitee[] $targets
* @return Entity[]
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws SendingError
*/
private function sendInternal(
string $entityType,
string $id,
?array $targets,
string $type,
): array {
$entity = $this->recordServiceContainer
->get($entityType)
->getEntity($id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden("No edit access.");
}
if (!$entity instanceof Meeting && !$entity instanceof Call) {
throw new Error("Not supported entity type.");
}
if ($type === self::TYPE_CANCELLATION) {
return $this->invitationSender->sendCancellation($entity, $targets);
}
return $this->invitationSender->sendInvitation($entity, $targets);
}
}

View File

@@ -0,0 +1,268 @@
<?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\Modules\Crm\Tools\Meeting;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\HookManager;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Core\Utils\Metadata;
use Espo\Entities\Note;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Call;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\ORM\Collection;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use LogicException;
class Service
{
private const NOTE_TYPE_EVENT_CONFIRMATION = 'EventConfirmation';
public function __construct(
private User $user,
private EntityManager $entityManager,
private HookManager $hookManager,
private Acl $acl,
private Metadata $metadata
) {}
/**
* Set an acceptance for a current user.
*
* @throws BadRequest
* @throws NotFound
* @throws Forbidden
*/
public function setAcceptance(string $entityType, string $id, string $status): void
{
/** @var string[] $statusList */
$statusList = $this->entityManager
->getDefs()
->getEntity($entityType)
->getField('acceptanceStatus')
->getParam('options') ?? [];
if (!in_array($status, $statusList) || $status === Meeting::ATTENDEE_STATUS_NONE) {
throw new BadRequest("Acceptance status not allowed.");
}
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new NotFound();
}
if (!$entity instanceof CoreEntity) {
throw new LogicException();
}
if (!$entity->hasLinkMultipleId('users', $this->user->getId())) {
throw new Forbidden();
}
$currentStatus = $this->entityManager
->getRDBRepository($entityType)
->getRelation($entity, 'users')
->getColumn($this->user, 'status');
if ($currentStatus === $status) {
return;
}
$this->entityManager
->getRDBRepository($entityType)
->getRelation($entity, 'users')
->updateColumnsById($this->user->getId(), ['status' => $status]);
if ($this->metadata->get(['scopes', $entityType, 'stream'])) {
$this->createEventConfirmationNote($entity, $status);
}
$actionData = [
'eventName' => $entity->get(Field::NAME),
'eventType' => $entity->getEntityType(),
'eventId' => $entity->getId(),
'dateStart' => $entity->get('dateStart'),
'status' => $status,
'link' => 'users',
'inviteeType' => User::ENTITY_TYPE,
'inviteeId' => $this->user->getId(),
];
$this->hookManager->process($entityType, 'afterConfirmation', $entity, [], $actionData);
}
private function createEventConfirmationNote(CoreEntity $entity, string $status): void
{
$options = ['createdById' => $this->user->getId()];
$style = $this->metadata
->get(['entityDefs', $entity->getEntityType(), 'fields', 'acceptanceStatus', 'style', $status]);
$this->entityManager->createEntity(Note::ENTITY_TYPE, [
'type' => self::NOTE_TYPE_EVENT_CONFIRMATION,
'parentId' => $entity->getId(),
'parentType' => $entity->getEntityType(),
'relatedId' => $this->user->getId(),
'relatedType' => $this->user->getEntityType(),
'data' => [
'status' => $status,
'style' => $style,
],
], $options);
}
/**
* @param string[] $ids
* @throws Forbidden
*/
public function massSetHeld(string $entityType, array $ids): void
{
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) {
throw new Forbidden();
}
if (!$this->acl->checkField($entityType, 'status', Acl\Table::ACTION_EDIT)) {
throw new Forbidden("No edit access to 'status' field.");
}
foreach ($ids as $id) {
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity || !$this->acl->checkEntityEdit($entity)) {
continue;
}
$entity->set('status', Meeting::STATUS_HELD);
$this->entityManager->saveEntity($entity);
}
}
/**
* @param string[] $ids
* @throws Forbidden
*/
public function massSetNotHeld(string $entityType, array $ids): void
{
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) {
throw new Forbidden();
}
if (!$this->acl->checkField($entityType, 'status', Acl\Table::ACTION_EDIT)) {
throw new Forbidden("No edit access to 'status' field.");
}
foreach ($ids as $id) {
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity || !$this->acl->checkEntityEdit($entity)) {
continue;
}
$entity->set('status', Meeting::STATUS_NOT_HELD);
$this->entityManager->saveEntity($entity);
}
}
/**
* Get all attendees.
*
* @throws Forbidden
* @throws NotFound
* @return RecordCollection<Entity>
*/
public function getAttendees(string $entityType, string $id): RecordCollection
{
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!in_array($entityType, [Meeting::ENTITY_TYPE, Call::ENTITY_TYPE])) {
throw new LogicException();
}
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden();
}
$linkList = [
'users',
'contacts',
'leads',
];
$linkList = array_filter($linkList, function ($item) use ($entityType) {
return $this->acl->checkField($item, $entityType);
});
$linkList = array_values($linkList);
$list = [];
foreach ($linkList as $link) {
$itemCollection = $this->entityManager
->getRDBRepository($entityType)
->getRelation($entity, $link)
->select([Attribute::ID, Field::NAME, 'acceptanceStatus', 'emailAddress'])
->order('name')
->find();
$list = array_merge($list, [...$itemCollection]);
}
/** @var Collection<Entity> $collection */
$collection = $this->entityManager->getCollectionFactory()->create(null, $list);
foreach ($collection as $e) {
if ($this->acl->checkEntityRead($e) && $this->acl->checkField($entityType, 'emailAddress')) {
continue;
}
if (!$e->get('emailAddress')) {
continue;
}
$e->set('emailAddress', 'dummy@dummy.dummy');
}
return RecordCollection::create($collection);
}
}

View File

@@ -0,0 +1,144 @@
<?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\Modules\Crm\Tools\Opportunity\Report;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Order;
use RuntimeException;
use stdClass;
class ByLeadSource
{
public function __construct(
private Acl $acl,
private Config $config,
private Metadata $metadata,
private EntityManager $entityManager,
private SelectBuilderFactory $selectBuilderFactory,
private Util $util
) {}
/**
* @throws Forbidden
*/
public function run(DateRange $range): stdClass
{
$range = $range->withFiscalYearShift(
$this->config->get('fiscalYearShift') ?? 0
);
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
throw new Forbidden();
}
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
throw new Forbidden("No access to 'amount' field.");
}
[$from, $to] = $range->getRange();
$options = $this->metadata->get('entityDefs.Lead.fields.source.options', []);
try {
$queryBuilder = $this->selectBuilderFactory
->create()
->from(Opportunity::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder();
} catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
$whereClause = [
['stage!=' => $this->util->getLostStageList()],
['leadSource!=' => ''],
['leadSource!=' => null],
];
if ($from && $to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
'closeDate<' => $to->toString(),
];
}
if ($from && !$to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
];
}
if (!$from && $to) {
$whereClause[] = [
'closeDate<' => $to->toString(),
];
}
$queryBuilder
->select([
'leadSource',
['SUM:amountWeightedConverted', 'amount'],
])
->order(
Order::createByPositionInList(
Expression::column('leadSource'),
$options
)
)
->group('leadSource')
->where($whereClause);
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
$sth = $this->entityManager
->getQueryExecutor()
->execute($queryBuilder->build());
$rowList = $sth->fetchAll() ?: [];
$result = [];
foreach ($rowList as $row) {
$leadSource = $row['leadSource'];
$result[$leadSource] = floatval($row['amount']);
}
return (object) $result;
}
}

View File

@@ -0,0 +1,171 @@
<?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\Modules\Crm\Tools\Opportunity\Report;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Order;
use stdClass;
class ByStage
{
private Acl $acl;
private Config $config;
private Metadata $metadata;
private EntityManager $entityManager;
private SelectBuilderFactory $selectBuilderFactory;
private Util $util;
public function __construct(
Acl $acl,
Config $config,
Metadata $metadata,
EntityManager $entityManager,
SelectBuilderFactory $selectBuilderFactory,
Util $util
) {
$this->acl = $acl;
$this->config = $config;
$this->metadata = $metadata;
$this->entityManager = $entityManager;
$this->selectBuilderFactory = $selectBuilderFactory;
$this->util = $util;
}
/**
* @throws Forbidden
*/
public function run(DateRange $range): stdClass
{
$range = $range->withFiscalYearShift(
$this->config->get('fiscalYearShift') ?? 0
);
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
throw new Forbidden();
}
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
throw new Forbidden("No access to 'amount' field.");
}
[$from, $to] = $range->getRange();
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options') ?? [];
$queryBuilder = $this->selectBuilderFactory
->create()
->from(Opportunity::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder();
$whereClause = [
['stage!=' => $this->util->getLostStageList()],
['stage!=' => $this->util->getWonStageList()],
];
if ($from && $to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
'closeDate<' => $to->toString(),
];
}
if ($from && !$to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
];
}
if (!$from && $to) {
$whereClause[] = [
'closeDate<' => $to->toString(),
];
}
$queryBuilder
->select([
'stage',
['SUM:amountConverted', 'amount'],
])
->order(
Order::createByPositionInList(
Expression::column('stage'),
$options
)
)
->group('stage')
->where($whereClause);
$stageIgnoreList = array_merge(
$this->util->getLostStageList(),
$this->util->getWonStageList()
);
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
$sth = $this->entityManager
->getQueryExecutor()
->execute($queryBuilder->build());
$rowList = $sth->fetchAll() ?: [];
$result = [];
foreach ($rowList as $row) {
$stage = $row['stage'];
if (in_array($stage, $stageIgnoreList)) {
continue;
}
$result[$stage] = floatval($row['amount']);
}
foreach ($options as $stage) {
if (in_array($stage, $stageIgnoreList)) {
continue;
}
if (array_key_exists($stage, $result)) {
continue;
}
$result[$stage] = 0.0;
}
return (object) $result;
}
}

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\Modules\Crm\Tools\Opportunity\Report;
use DateInterval;
use Espo\Core\Field\Date;
use InvalidArgumentException;
use UnexpectedValueException;
/**
* Immutable.
*/
class DateRange
{
public const TYPE_BETWEEN = 'between';
public const TYPE_EVER = 'ever';
public const TYPE_CURRENT_YEAR = 'currentYear';
public const TYPE_CURRENT_QUARTER = 'currentQuarter';
public const TYPE_CURRENT_MONTH = 'currentMonth';
public const TYPE_CURRENT_FISCAL_YEAR = 'currentFiscalYear';
public const TYPE_CURRENT_FISCAL_QUARTER = 'currentFiscalQuarter';
private string $type;
private ?Date $from;
private ?Date $to;
private int $fiscalYearShift;
public function __construct(
string $type,
?Date $from = null,
?Date $to = null,
int $fiscalYearShift = 0
) {
if ($type === self::TYPE_BETWEEN && (!$from || !$to)) {
throw new InvalidArgumentException("Missing range dates.");
}
$this->type = $type;
$this->from = $from;
$this->to = $to;
$this->fiscalYearShift = $fiscalYearShift;
}
public function getType(): string
{
return $this->type;
}
public function getFrom(): ?Date
{
return $this->from;
}
public function getTo(): ?Date
{
return $this->to;
}
public function withFiscalYearShift(int $fiscalYearShift): self
{
$obj = clone $this;
$obj->fiscalYearShift = $fiscalYearShift;
return $obj;
}
/**
* @return array{?Date, ?Date}
*/
public function getRange(): array
{
if ($this->type === self::TYPE_EVER) {
return [null, null];
}
if ($this->type === self::TYPE_BETWEEN) {
return [$this->from, $this->to];
}
$fiscalYearShift = $this->fiscalYearShift;
switch ($this->type) {
case self::TYPE_CURRENT_YEAR:
$dt = Date::createToday()
->modify('first day of January this year');
return [
$dt,
$dt->addYears(1)
];
case self::TYPE_CURRENT_QUARTER:
$dt = Date::createToday();
$quarter = (int) ceil($dt->getMonth() / 3);
$dt = $dt
->modify('first day of January this year')
->addMonths(($quarter - 1) * 3);
return [
$dt,
$dt->addMonths(3),
];
case self::TYPE_CURRENT_MONTH:
$dt = Date::createToday()
->modify('first day of this month');
return [
$dt,
$dt->addMonths(1),
];
case self::TYPE_CURRENT_FISCAL_YEAR:
$dt = Date::createToday()
->modify('first day of January this year')
->modify('+' . $fiscalYearShift . ' months');
if (Date::createToday()->getMonth() < $fiscalYearShift + 1) {
$dt = $dt->addYears(-1);
}
return [
$dt,
$dt->addYears(1)
];
case self::TYPE_CURRENT_FISCAL_QUARTER:
$dt = Date::createToday()
->modify('first day of January this year')
->addMonths($fiscalYearShift);
$month = Date::createToday()->getMonth();
$quarterShift = (int) floor(($month - $fiscalYearShift - 1) / 3);
if ($quarterShift) {
$dt = $dt->addMonths($quarterShift * 3);
}
return [
$dt,
$dt->add(new DateInterval('P3M'))
];
}
throw new UnexpectedValueException("Not supported range type");
}
}

View File

@@ -0,0 +1,174 @@
<?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\Modules\Crm\Tools\Opportunity\Report;
use DateTime;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Config;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\EntityManager;
use Exception;
use InvalidArgumentException;
use LogicException;
use stdClass;
class SalesByMonth
{
public function __construct(
private Acl $acl,
private Config $config,
private EntityManager $entityManager,
private SelectBuilderFactory $selectBuilderFactory,
private Util $util
) {}
/**
* @throws Forbidden
*/
public function run(DateRange $range): stdClass
{
$range = $range->withFiscalYearShift(
$this->config->get('fiscalYearShift') ?? 0
);
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
throw new Forbidden();
}
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
throw new Forbidden("No access to 'amount' field.");
}
[$from, $to] = $range->getRange();
if (!$from || !$to) {
throw new InvalidArgumentException();
}
$queryBuilder = $this->selectBuilderFactory
->create()
->from(Opportunity::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder();
$whereClause = [
'stage' => $this->util->getWonStageList(),
];
$whereClause[] = [
'closeDate>=' => $from->toString(),
'closeDate<' => $to->toString(),
];
$queryBuilder
->select([
['MONTH:closeDate', 'month'],
['SUM:amountConverted', 'amount'],
])
->order('MONTH:closeDate')
->group('MONTH:closeDate')
->where($whereClause);
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
$sth = $this->entityManager
->getQueryExecutor()
->execute($queryBuilder->build());
$result = [];
$rowList = $sth->fetchAll() ?: [];
foreach ($rowList as $row) {
$month = $row['month'];
$result[$month] = floatval($row['amount']);
}
$dt = $from;
$dtTo = $to;
if ($dtTo->getDay() > 1) {
$dtTo = $dtTo
->addDays(1 - $dtTo->getDay()) // First day of month.
->addMonths(1);
} else {
$dtTo = $dtTo->addDays(1 - $dtTo->getDay());
}
while ($dt->toTimestamp() < $dtTo->toTimestamp()) {
$month = $dt->toDateTime()->format('Y-m');
if (!array_key_exists($month, $result)) {
$result[$month] = 0;
}
$dt = $dt->addMonths(1);
}
$keyList = array_keys($result);
sort($keyList);
$today = new DateTime();
$endPosition = count($keyList);
for ($i = count($keyList) - 1; $i >= 0; $i--) {
$key = $keyList[$i];
try {
$dt = new DateTime($key . '-01');
} catch (Exception $e) {
throw new LogicException();
}
if ($dt->getTimestamp() < $today->getTimestamp()) {
break;
}
if (empty($result[$key])) {
$endPosition = $i;
continue;
}
break;
}
$keyListSliced = array_slice($keyList, 0, $endPosition);
return (object) [
'keyList' => $keyListSliced,
'dataMap' => $result,
];
}
}

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\Modules\Crm\Tools\Opportunity\Report;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Name\Field;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Expression;
use Espo\ORM\Query\Part\Order;
use stdClass;
class SalesPipeline
{
public function __construct(
private Acl $acl,
private Config $config,
private Metadata $metadata,
private EntityManager $entityManager,
private SelectBuilderFactory $selectBuilderFactory,
private Util $util
) {}
/**
* @throws Forbidden
*/
public function run(DateRange $range, bool $useLastStage = false, ?string $teamId = null): stdClass
{
$range = $range->withFiscalYearShift(
$this->config->get('fiscalYearShift') ?? 0
);
if (!$this->acl->checkScope(Opportunity::ENTITY_TYPE, Acl\Table::ACTION_READ)) {
throw new Forbidden();
}
if (!$this->acl->checkField(Opportunity::ENTITY_TYPE, 'amount')) {
throw new Forbidden("No access to 'amount' field.");
}
[$from, $to] = $range->getRange();
$lostStageList = $this->util->getLostStageList();
$options = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
$queryBuilder = $this->selectBuilderFactory
->create()
->from(Opportunity::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder();
$stageField = 'stage';
if ($useLastStage) {
$stageField = 'lastStage';
}
$whereClause = [
[$stageField . '!=' => $lostStageList],
[$stageField . '!=' => null],
];
if ($from && $to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
'closeDate<' => $to->toString(),
];
}
if ($from && !$to) {
$whereClause[] = [
'closeDate>=' => $from->toString(),
];
}
if (!$from && $to) {
$whereClause[] = [
'closeDate<' => $to->toString(),
];
}
if ($teamId) {
$whereClause[] = [
'teamsFilter.id' => $teamId,
];
}
$queryBuilder
->select([
$stageField,
['SUM:amountConverted', 'amount'],
])
->order(
Order::createByPositionInList(
Expression::column($stageField),
$options
)
)
->group($stageField)
->where($whereClause);
if ($teamId) {
$queryBuilder->join(Field::TEAMS, 'teamsFilter');
}
$this->util->handleDistinctReportQueryBuilder($queryBuilder, $whereClause);
$sth = $this->entityManager
->getQueryExecutor()
->execute($queryBuilder->build());
$rowList = $sth->fetchAll() ?: [];
$data = [];
foreach ($rowList as $row) {
$stage = $row[$stageField];
$data[$stage] = floatval($row['amount']);
}
$dataList = [];
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
foreach ($stageList as $stage) {
if (in_array($stage, $lostStageList)) {
continue;
}
if (!in_array($stage, $lostStageList) && !isset($data[$stage])) {
$data[$stage] = 0.0;
}
$dataList[] = [
'stage' => $stage,
'value' => $data[$stage],
];
}
return (object) [
'dataList' => $dataList,
];
}
}

View File

@@ -0,0 +1,120 @@
<?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\Modules\Crm\Tools\Opportunity\Report;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\Opportunity as OpportunityEntity;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\SelectBuilder;
class Util
{
private Metadata $metadata;
private EntityManager $entityManager;
public function __construct(
Metadata $metadata,
EntityManager $entityManager
) {
$this->metadata = $metadata;
$this->entityManager = $entityManager;
}
/**
* A grouping-by with distinct will give wrong results. Need to use sub-query.
*
* @param array<string|int, mixed> $whereClause
*/
public function handleDistinctReportQueryBuilder(SelectBuilder $queryBuilder, array $whereClause): void
{
if (!$queryBuilder->build()->isDistinct()) {
return;
}
$subQuery = $this->entityManager
->getQueryBuilder()
->select()
->from(OpportunityEntity::ENTITY_TYPE)
->select(Attribute::ID)
->where($whereClause)
->build();
$queryBuilder->where([
'id=s' => $subQuery,
]);
}
/**
* @return string[]
*/
public function getLostStageList(): array
{
$list = [];
$probabilityMap = $this->metadata
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
foreach ($stageList as $stage) {
$value = $probabilityMap[$stage] ?? 0;
if (!$value) {
$list[] = $stage;
}
}
return $list;
}
/**
* @return string[]
*/
public function getWonStageList(): array
{
$list = [];
$probabilityMap = $this->metadata
->get(['entityDefs', OpportunityEntity::ENTITY_TYPE, 'fields', 'stage', 'probabilityMap']) ?? [];
$stageList = $this->metadata->get('entityDefs.Opportunity.fields.stage.options', []);
foreach ($stageList as $stage) {
$value = $probabilityMap[$stage] ?? 0;
if ($value == 100) {
$list[] = $stage;
}
}
return $list;
}
}

View File

@@ -0,0 +1,218 @@
<?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\Modules\Crm\Tools\Opportunity;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Field\EmailAddress;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Opportunity;
use Espo\ORM\Collection;
use Espo\ORM\EntityManager;
use Espo\Tools\Email\EmailAddressEntityPair;
use RuntimeException;
class Service
{
public function __construct(
private ServiceContainer $serviceContainer,
private Acl $acl,
private EntityManager $entityManager,
private SelectBuilderFactory $selectBuilderFactory
) {}
/**
* @return EmailAddressEntityPair[]
* @throws Forbidden
*/
public function getEmailAddressList(string $id): array
{
/** @var Opportunity $entity */
$entity = $this->serviceContainer
->get(Opportunity::ENTITY_TYPE)
->getEntity($id);
$list = [];
if (
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'contacts') &&
$this->acl->checkScope(Contact::ENTITY_TYPE)
) {
foreach ($this->getContactEmailAddressList($entity) as $item) {
$list[] = $item;
}
}
if (
$list === [] &&
$this->acl->checkField(Opportunity::ENTITY_TYPE, 'account') &&
$this->acl->checkScope(Account::ENTITY_TYPE)
) {
$item = $this->getAccountEmailAddress($entity, $list);
if ($item) {
$list[] = $item;
}
}
return $list;
}
/**
* @param EmailAddressEntityPair[] $dataList
*/
private function getAccountEmailAddress(Opportunity $entity, array $dataList): ?EmailAddressEntityPair
{
$accountLink = $entity->getAccount();
if (!$accountLink) {
return null;
}
/** @var ?Account $account */
$account = $this->entityManager->getEntityById(Account::ENTITY_TYPE, $accountLink->getId());
if (!$account) {
return null;
}
$emailAddress = $account->getEmailAddress();
if (!$emailAddress) {
return null;
}
if (!$this->acl->checkEntity($account)) {
return null;
}
foreach ($dataList as $item) {
if ($item->getEmailAddress()->getAddress() === $emailAddress) {
return null;
}
}
return new EmailAddressEntityPair(EmailAddress::create($emailAddress), $account);
}
/**
* @return EmailAddressEntityPair[]
*/
private function getContactEmailAddressList(Opportunity $entity): array
{
$contactsLinkMultiple = $entity->getContacts();
$contactIdList = $contactsLinkMultiple->getIdList();
if (!count($contactIdList)) {
return [];
}
if (!$this->acl->checkField(Contact::ENTITY_TYPE, 'emailAddress')) {
return [];
}
$dataList = [];
$emailAddressList = [];
try {
$query = $this->selectBuilderFactory
->create()
->from(Contact::ENTITY_TYPE)
->withStrictAccessControl()
->buildQueryBuilder()
->select([
'id',
'emailAddress',
'name',
])
->where([
'id' => $contactIdList,
])
->build();
} catch (BadRequest|Forbidden $e) {
throw new RuntimeException($e->getMessage());
}
/** @var Collection<Contact> $contactCollection */
$contactCollection = $this->entityManager
->getRDBRepositoryByClass(Contact::class)
->clone($query)
->find();
foreach ($contactCollection as $contact) {
$emailAddress = $contact->getEmailAddress();
if (!$emailAddress) {
continue;
}
if (in_array($emailAddress, $emailAddressList)) {
continue;
}
$emailAddressList[] = $emailAddress;
$dataList[] = new EmailAddressEntityPair(EmailAddress::create($emailAddress), $contact);
}
$contact = $entity->getContact();
if (!$contact) {
return $dataList;
}
usort(
$dataList,
function (
EmailAddressEntityPair $o1,
EmailAddressEntityPair $o2
) use ($contact) {
if ($o1->getEntity()->getId() === $contact->getId()) {
return -1;
}
if ($o2->getEntity()->getId() === $contact->getId()) {
return 1;
}
return 0;
}
);
return $dataList;
}
}

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\Modules\Crm\Tools\Reminder\Sender;
use Espo\Core\Mail\Exceptions\SendingError;
use Espo\Entities\Email;
use Espo\Entities\User;
use Espo\Modules\Crm\Entities\Meeting;
use Espo\Modules\Crm\Entities\Reminder;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Utils\Util;
use Espo\Core\Htmlizer\HtmlizerFactory as HtmlizerFactory;
use Espo\Core\Mail\EmailSender;
use Espo\Core\ORM\EntityManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\TemplateFileManager;
use RuntimeException;
class EmailReminder
{
public function __construct(
private EntityManager $entityManager,
private TemplateFileManager $templateFileManager,
private EmailSender $emailSender,
private Config $config,
private HtmlizerFactory $htmlizerFactory,
private Language $language
) {}
/**
* @throws SendingError
*/
public function send(Reminder $reminder): void
{
$entityType = $reminder->getTargetEntityType();
$entityId = $reminder->getTargetEntityId();
$userId = $reminder->getUserId();
if (!$entityType || !$entityId || !$userId) {
throw new RuntimeException("Bad reminder.");
}
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (
!$user ||
!$entity instanceof CoreEntity ||
!$user->getEmailAddress()
) {
return;
}
if (
$entity->hasLinkMultipleField('users') &&
$entity->hasAttribute('usersColumns')
) {
$status = $entity->getLinkMultipleColumn('users', 'status', $user->getId());
if ($status === Meeting::ATTENDEE_STATUS_DECLINED) {
return;
}
}
[$subject, $body] = $this->getSubjectBody($entity, $user);
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->addToAddress($user->getEmailAddress());
$email->setSubject($subject);
$email->setBody($body);
$email->setIsHtml();
$this->emailSender->send($email);
}
/**
* @return array{string, string}
*/
private function getTemplates(CoreEntity $entity): array
{
$subjectTpl = $this->templateFileManager
->getTemplate('reminder', 'subject', $entity->getEntityType(), 'Crm');
$bodyTpl = $this->templateFileManager
->getTemplate('reminder', 'body', $entity->getEntityType(), 'Crm');
return [$subjectTpl, $bodyTpl];
}
/**
* @return array{string, string}
*/
private function getSubjectBody(CoreEntity $entity, User $user): array
{
$entityType = $entity->getEntityType();
$entityId = $entity->getId();
[$subjectTpl, $bodyTpl] = $this->getTemplates($entity);
$subjectTpl = str_replace(["\n", "\r"], '', $subjectTpl);
$siteUrl = rtrim($this->config->get('siteUrl'), '/');
$translatedEntityType = $this->language->translateLabel($entityType, 'scopeNames');
$data = [
'recordUrl' => "$siteUrl/#$entityType/view/$entityId",
'entityType' => $translatedEntityType,
'entityTypeLowerFirst' => Util::mbLowerCaseFirst($translatedEntityType),
'userName' => $user->getName(),
];
$htmlizer = $this->htmlizerFactory->createForUser($user);
$subject = $htmlizer->render(
$entity,
$subjectTpl,
'reminder-email-subject-' . $entityType,
$data,
true
);
$body = $htmlizer->render(
$entity,
$bodyTpl,
'reminder-email-body-' . $entityType,
$data,
false
);
return [$subject, $body];
}
}

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\Modules\Crm\Tools\TargetList\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\Record\SearchParamsFetcher;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\Modules\Crm\Tools\TargetList\OptOutService;
/**
* @noinspection PhpUnused
*/
class GetOptedOut implements Action
{
public function __construct(
private SearchParamsFetcher $searchParamsFetcher,
private Acl $acl,
private OptOutService $service
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest();
}
if (!$this->acl->check(TargetList::ENTITY_TYPE)) {
throw new Forbidden();
}
$searchParams = $this->searchParamsFetcher->fetch($request);
$result = $this->service->find($id, $searchParams);
return ResponseComposer::json($result->toApiOutput());
}
}

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\Modules\Crm\Tools\TargetList;
use Espo\Core\Utils\Metadata;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\ORM\Defs;
class MetadataProvider
{
public function __construct(
private Metadata $metadata,
private Defs $defs
) {}
/**
* @return string[]
*/
public function getTargetLinkList(): array
{
return $this->metadata->get(['scopes', 'TargetList', 'targetLinkList']) ?? [];
}
/**
* @return array<string, string>
*/
public function getEntityTypeLinkMap(): array
{
$map = [];
foreach ($this->getTargetLinkList() as $link) {
$entityType = $this->defs
->getEntity(TargetList::ENTITY_TYPE)
->getRelation($link)
->getForeignEntityType();
$map[$entityType] = $link;
}
return $map;
}
}

View File

@@ -0,0 +1,258 @@
<?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\Modules\Crm\Tools\TargetList;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\HookManager;
use Espo\Core\Name\Field;
use Espo\Core\Record\Collection;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Core\Record\EntityProvider;
use Espo\Core\Select\SearchParams;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Query\Select;
use PDO;
use RuntimeException;
class OptOutService
{
public function __construct(
private EntityManager $entityManager,
private MetadataProvider $metadataProvider,
private EntityProvider $entityProvider,
private HookManager $hookManager
) {}
/**
* Opt out a target.
*
* @throws Forbidden
* @throws NotFound
*/
public function optOut(string $id, string $targetType, string $targetId): void
{
$targetList = $this->entityProvider->getByClass(TargetList::class, $id);
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new NotFound();
}
$map = $this->metadataProvider->getEntityTypeLinkMap();
if (empty($map[$targetType])) {
throw new Forbidden("Not supported target type.");
}
$link = $map[$targetType];
$this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link)
->relateById($targetId, ['optedOut' => true]);
$hookData = [
'link' => $link,
'targetId' => $targetId,
'targetType' => $targetType,
];
$this->hookManager->process(TargetList::ENTITY_TYPE, 'afterOptOut', $targetList, [], $hookData);
}
/**
* Cancel opt-out for a target.
*
* @throws Forbidden
* @throws NotFound
*/
public function cancelOptOut(string $id, string $targetType, string $targetId): void
{
$targetList = $this->entityProvider->getByClass(TargetList::class, $id);
$target = $this->entityManager->getEntityById($targetType, $targetId);
if (!$target) {
throw new NotFound();
}
$map = $this->metadataProvider->getEntityTypeLinkMap();
if (empty($map[$targetType])) {
throw new Forbidden("Not supported target type.");
}
$link = $map[$targetType];
$this->entityManager
->getRDBRepository(TargetList::ENTITY_TYPE)
->getRelation($targetList, $link)
->updateColumnsById($targetId, ['optedOut' => false]);
$hookData = [
'link' => $link,
'targetId' => $targetId,
'targetType' => $targetType,
];
$this->hookManager->process('TargetList', TargetList::ENTITY_TYPE, $targetList, [], $hookData);
}
/**
* Find opted out targets in a target list.
*
* @return Collection<Entity>
* @throws Forbidden
* @throws NotFound
*/
public function find(string $id, SearchParams $params): Collection
{
$this->checkEntity($id);
$offset = $params->getOffset() ?? 0;
$maxSize = $params->getMaxSize() ?? 0;
$em = $this->entityManager;
$queryBuilder = $em->getQueryBuilder();
$queryList = [];
$targetLinkList = $this->metadataProvider->getTargetLinkList();
foreach ($targetLinkList as $link) {
$queryList[] = $this->getSelectQueryForLink($id, $link);
}
$builder = $queryBuilder
->union()
->all();
foreach ($queryList as $query) {
$builder->query($query);
}
$countQuery = $queryBuilder
->select()
->fromQuery($builder->build(), 'c')
->select('COUNT:(c.id)', 'count')
->build();
$row = $em->getQueryExecutor()
->execute($countQuery)
->fetch(PDO::FETCH_ASSOC);
$totalCount = $row['count'];
$unionQuery = $builder
->limit($offset, $maxSize)
->order(Field::CREATED_AT, 'DESC')
->build();
$sth = $em->getQueryExecutor()->execute($unionQuery);
$collection = $this->entityManager
->getCollectionFactory()
->create();
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$itemEntity = $this->entityManager->getNewEntity($row['entityType']);
$itemEntity->set($row);
$itemEntity->setAsFetched();
$collection[] = $itemEntity;
}
/** @var RecordCollection<Entity> */
return new RecordCollection($collection, $totalCount);
}
private function getSelectQueryForLink(string $id, string $link): Select
{
$seed = $this->entityManager->getRDBRepositoryByClass(TargetList::class)->getNew();
$entityType = $seed->getRelationParam($link, RelationParam::ENTITY);
if (!$entityType) {
throw new RuntimeException();
}
$linkEntityType = ucfirst(
$seed->getRelationParam($link, RelationParam::RELATION_NAME) ?? ''
);
if ($linkEntityType === '') {
throw new RuntimeException();
}
$key = $seed->getRelationParam($link, RelationParam::MID_KEYS)[1] ?? null;
if (!$key) {
throw new RuntimeException();
}
return $this->entityManager->getQueryBuilder()
->select()
->from($entityType)
->select([
'id',
'name',
Field::CREATED_AT,
["'$entityType'", 'entityType'],
])
->join(
$linkEntityType,
'j',
[
"j.$key:" => 'id',
'j.deleted' => false,
'j.optedOut' => true,
'j.targetListId' => $id,
]
)
->order(Field::CREATED_AT, Order::DESC)
->build();
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function checkEntity(string $id): void
{
$this->entityProvider->getByClass(TargetList::class, $id);
}
}

View File

@@ -0,0 +1,120 @@
<?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\Modules\Crm\Tools\TargetList;
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\HookManager;
use Espo\Modules\Crm\Entities\TargetList;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use RuntimeException;
class RecordService
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private HookManager $hookManager,
private MetadataProvider $metadataProvider
) {}
/**
* Unlink all targets.
*
* @throws Forbidden
* @throws NotFound
* @throws BadRequest
*/
public function unlinkAll(string $id, string $link): void
{
$entity = $this->getEntity($id);
$linkEntityType = $this->getLinkEntityType($entity, $link);
$updateQuery = $this->entityManager->getQueryBuilder()
->update()
->in($linkEntityType)
->set([Attribute::DELETED => true])
->where(['targetListId' => $entity->getId()])
->build();
$this->entityManager->getQueryExecutor()->execute($updateQuery);
$this->hookManager->process(TargetList::ENTITY_TYPE, 'afterUnlinkAll', $entity, [], ['link' => $link]);
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function getEntity(string $id): TargetList
{
$entity = $this->entityManager->getRDBRepositoryByClass(TargetList::class)->getById($id);
if (!$entity) {
throw new NotFound();
}
if (!$this->acl->check($entity, Table::ACTION_EDIT)) {
throw new Forbidden();
}
return $entity;
}
/**
* @throws BadRequest
*/
private function getLinkEntityType(TargetList $entity, string $link): string
{
if (!in_array($link, $this->metadataProvider->getTargetLinkList())) {
throw new BadRequest("Not supported link.");
}
$foreignEntityType = $entity->getRelationParam($link, RelationParam::ENTITY);
if (!$foreignEntityType) {
throw new RuntimeException();
}
$linkEntityType = ucfirst($entity->getRelationParam($link, RelationParam::RELATION_NAME) ?? '');
if ($linkEntityType === '') {
throw new RuntimeException();
}
return $linkEntityType;
}
}