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,32 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Action\RunAction;
use Espo\ORM\Entity;
/**
* @template TEntity of Entity
*/
interface ServiceAction
{
/**
* @param Entity $entity
*/
public function run(Entity $entity, mixed $data): mixed;
}

View File

@@ -0,0 +1,28 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow;
class Alert
{
public function __construct(
public string $message,
public ?string $type = null,
public bool $autoClose = false,
) {}
}

View File

@@ -0,0 +1,464 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\Container;
use Espo\Core\FieldProcessing\SpecificFieldLoader;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\FieldUtil;
use Espo\Core\Utils\Metadata;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Exception;
use stdClass;
class EntityHelper
{
/**
* For bc the type is in the docblock.
*
* @var ?SpecificFieldLoader
*/
private $specificFieldLoader = null;
public function __construct(
private Container $container,
private EntityManager $entityManager,
private ServiceContainer $serviceContainer,
private Metadata $metadata,
private FieldUtil $fieldUtil,
) {}
private function getSpecificFieldLoader(): ?SpecificFieldLoader
{
if (!class_exists("Espo\\Core\\FieldProcessing\\SpecificFieldLoader")) {
return null;
}
if (!$this->specificFieldLoader) {
$this->specificFieldLoader = $this->container
->getByClass(InjectableFactory::class)
->create(SpecificFieldLoader::class);
}
return $this->specificFieldLoader;
}
/**
* @param string $fieldName
* @return string
*/
private function normalizeRelatedFieldName(CoreEntity $entity, $fieldName)
{
if ($entity->hasRelation($fieldName)) {
$type = $entity->getRelationType($fieldName);
$key = $entity->getRelationParam($fieldName, 'key');
$foreignKey = $entity->getRelationParam($fieldName, 'foreignKey');
switch ($type) {
case Entity::HAS_CHILDREN:
if ($foreignKey) {
$fieldName = $foreignKey;
}
break;
case Entity::BELONGS_TO:
if ($key) {
$fieldName = $key;
}
break;
case Entity::HAS_MANY:
case Entity::MANY_MANY:
$fieldName .= 'Ids';
break;
}
}
return $fieldName;
}
/**
* Get actual attribute list w/o additional.
*
* @param Entity $entity
* @param string $field
* @return string[]
*/
public function getActualAttributes(Entity $entity, string $field): array
{
$entityType = $entity->getEntityType();
$list = [];
$actualList = $this->fieldUtil->getActualAttributeList($entityType, $field);
$additionalList = $this->fieldUtil->getAdditionalActualAttributeList($entityType, $field);
foreach ($actualList as $item) {
if (!in_array($item, $additionalList)) {
$list[] = $item;
}
}
return $list;
}
/**
* Get field value for a field/related field. If this field has a relation, get value from the relation.
*/
public function getFieldValues(
CoreEntity $fromEntity,
CoreEntity $toEntity,
string $fromField,
string $toField
): stdClass {
$entity = $fromEntity;
$field = $fromField;
$values = (object) [];
if (str_contains($field, '.')) {
[$relation, $foreignField] = explode('.', $field);
$relatedEntity = $this->getRelatedEntity($entity, $relation);
if (!$relatedEntity instanceof CoreEntity) {
$GLOBALS['log']->debug(
"Workflow EntityHelper:getFieldValues: No related record for '$field', entity " .
"{$entity->getEntityType()}.");
return (object) [];
}
$entity = $relatedEntity;
$field = $foreignField;
}
if ($entity->hasRelation($field) && !$entity->isNew()) {
$this->loadLink($entity, $field);
}
$fromType = $this->getFieldType($entity, $field);
$toType = $this->getFieldType($toEntity, $toField);
if (
$fromType === 'link' &&
$toType === 'linkParent'
) {
return $this->getFieldValuesLinkToLinkParent($entity, $field, $toField);
}
if (
$fromField === 'id' &&
$toType === 'linkParent'
) {
return $this->getFieldValuesIdToLinkParent($entity, $toField);
}
$attributeMap = $this->getRelevantAttributeMap($entity, $toEntity, $field, $toField);
$service = $this->serviceContainer->get($entity->getEntityType());
$toAttribute = null;
$this->loadFieldForAttributes($entity, $field, array_keys($attributeMap));
foreach ($attributeMap as $fromAttribute => $toAttribute) {
// @todo Revise.
$getCopiedMethodName = 'getCopied' . ucfirst($fromAttribute);
if (method_exists($entity, $getCopiedMethodName)) {
$values->$toAttribute = $entity->$getCopiedMethodName();
continue;
}
// @todo Revise.
$getCopiedMethodName = 'getCopiedEntityAttribute' . ucfirst($fromAttribute);
if (method_exists($service, $getCopiedMethodName)) {
$values->$toAttribute = $service->$getCopiedMethodName($entity);
continue;
}
$values->$toAttribute = $entity->get($fromAttribute);
}
$toFieldType = $this->getFieldType($toEntity, $toField);
if ($toFieldType === 'personName' && $toAttribute) {
$this->handlePersonName($toAttribute, $values, $toField);
}
// Correct field types. E.g. set teamsIds from defaultTeamId.
if ($toEntity->hasRelation($toField)) {
$normalizedFieldName = $this->normalizeRelatedFieldName($toEntity, $toField);
if (
$toEntity->getRelationType($toField) === Entity::MANY_MANY &&
isset($values->$normalizedFieldName) &&
!is_array($values->$normalizedFieldName)
) {
$values->$normalizedFieldName = (array) $values->$normalizedFieldName;
}
}
return $values;
}
/**
* @return array<string, string>
*/
private function getRelevantAttributeMap(
Entity $fromEntity,
Entity $toEntity,
string $fromField,
string $toField
): array {
$fromAttributeList = $this->getActualAttributes($fromEntity, $fromField);
$toAttributeList = $this->getActualAttributes($toEntity, $toField);
$fromType = $this->getFieldType($fromEntity, $fromField);
$toType = $this->getFieldType($toEntity, $toField);
$ignoreActualAttributesOnValueCopyFieldList = $this->metadata
->get(['entityDefs', 'Workflow', 'ignoreActualAttributesOnValueCopyFieldList'], []);
if (in_array($fromType, $ignoreActualAttributesOnValueCopyFieldList)) {
$fromAttributeList = [$fromField];
}
if (in_array($toType, $ignoreActualAttributesOnValueCopyFieldList)) {
$toAttributeList = [$toField];
}
$attributeMap = [];
if (count($fromAttributeList) == count($toAttributeList)) {
if (
$fromType === 'datetimeOptional' &&
$toType === 'datetimeOptional'
) {
if ($fromEntity->get($fromAttributeList[1])) {
$attributeMap[$fromAttributeList[1]] = $toAttributeList[1];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
return $attributeMap;
}
foreach ($fromAttributeList as $key => $name) {
$attributeMap[$name] = $toAttributeList[$key];
}
return $attributeMap;
}
if (
$fromType === 'datetimeOptional' ||
$toType === 'datetimeOptional'
) {
if (count($toAttributeList) > count($fromAttributeList)) {
if ($fromType === 'date') {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[1];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
return $attributeMap;
}
if ($toType === 'date') {
if ($fromEntity->get($fromAttributeList[1])) {
$attributeMap[$fromAttributeList[1]] = $toAttributeList[0];
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
} else {
$attributeMap[$fromAttributeList[0]] = $toAttributeList[0];
}
}
return $attributeMap;
}
private function handlePersonName(string $toAttribute, stdClass $values, string $toField): void
{
if (empty($values->$toAttribute)) {
return;
}
$fullNameValue = trim($values->$toAttribute);
$firstNameAttribute = 'first' . ucfirst($toField);
$lastNameAttribute = 'last' . ucfirst($toField);
if (!str_contains($fullNameValue, ' ')) {
$lastNameValue = $fullNameValue;
$firstNameValue = null;
} else {
$index = strrpos($fullNameValue, ' ');
$firstNameValue = substr($fullNameValue, 0, $index ?: 0);
$lastNameValue = substr($fullNameValue, $index + 1);
}
$values->$firstNameAttribute = $firstNameValue;
$values->$lastNameAttribute = $lastNameValue;
}
private function loadLink(Entity $entity, string $field): void
{
if (!$entity instanceof CoreEntity) {
return;
}
switch ($entity->getRelationType($field)) { // ORM types
case Entity::MANY_MANY:
case Entity::HAS_CHILDREN:
try {
$entity->loadLinkMultipleField($field);
} catch (Exception) {}
break;
case Entity::BELONGS_TO:
case Entity::HAS_ONE:
try {
$entity->loadLinkField($field);
} catch (Exception) {}
break;
}
}
public function getFieldType(Entity $entity, string $field): ?string
{
return $this->metadata->get(['entityDefs', $entity->getEntityType(), 'fields', $field, 'type']);
}
private function getRelatedEntity(CoreEntity $entity, string $relation): ?Entity
{
if (!$entity->hasRelation($relation)) {
return null;
}
$relatedEntity = null;
if ($entity->hasId()) {
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if ($relatedEntity) {
return $relatedEntity;
}
}
// If the entity is just created and doesn't have relations yet.
$foreignEntityType = $entity->getRelationParam($relation, 'entity');
$idAttribute = $this->normalizeRelatedFieldName($entity, $relation);
if (
$foreignEntityType &&
$entity->hasAttribute($idAttribute) &&
$entity->get($idAttribute)
) {
$relatedEntity = $this->entityManager->getEntityById($foreignEntityType, $entity->get($idAttribute));
}
return $relatedEntity;
}
private function getFieldValuesLinkToLinkParent(
CoreEntity $fromEntity,
string $fromField,
string $toField
): stdClass {
$sourceRecordId = $fromEntity->get($fromField . 'Id');
$foreignEntityType = $fromEntity->getRelationParam($fromField, 'entity');
if (!$sourceRecordId || !$foreignEntityType) {
return (object) [
$toField . 'Id' => null,
$toField . 'Type' => null,
$toField . 'Name' => null,
];
}
return (object) [
$toField . 'Id' => $sourceRecordId,
$toField . 'Type' => $foreignEntityType,
$toField . 'Name' => $fromEntity->get($fromField . 'Name'),
];
}
private function getFieldValuesIdToLinkParent(CoreEntity $fromEntity, string $toField): stdClass
{
return (object) [
$toField . 'Id' => $fromEntity->getId(),
$toField . 'Type' => $fromEntity->getEntityType(),
$toField . 'Name' => $fromEntity->get('name'),
];
}
/**
* @param string[] $attributes
*/
private function loadFieldForAttributes(CoreEntity $entity, string $field, array $attributes): void
{
$hasNotSet = $this->hasNotSetAttribute($entity, $attributes);
if (!$hasNotSet) {
return;
}
$this->getSpecificFieldLoader()->process($entity, $field);
}
/**
* @param string[] $attributes
*/
private function hasNotSetAttribute(CoreEntity $entity, array $attributes): bool
{
$hasNotSet = false;
foreach ($attributes as $it) {
if (!$entity->has($it)) {
$hasNotSet = true;
break;
}
}
return $hasNotSet;
}
}

View File

@@ -0,0 +1,74 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\FieldProcessing\SpecificFieldLoader;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\FieldUtil;
use Espo\ORM\Entity;
class FieldLoaderHelper
{
/**
* For bc the type is in the docblock.
*
* @var ?SpecificFieldLoader
*/
private $specificFieldLoader = null;
public function __construct(
private InjectableFactory $injectableFactory,
private FieldUtil $fieldUtil,
) {}
public function load(Entity $entity, string $path): void
{
/** @phpstan-ignore-next-line function.alreadyNarrowedType */
if (!method_exists($this->fieldUtil, 'getFieldOfAttribute')) {
return;
}
$field = $this->fieldUtil->getFieldOfAttribute($entity->getEntityType(), $path);
if (!$field) {
return;
}
$loader = $this->getSpecificFieldLoader();
if (!$loader) {
return;
}
$loader->process($entity, $field);
}
private function getSpecificFieldLoader(): ?SpecificFieldLoader
{
if (!class_exists("Espo\\Core\\FieldProcessing\\SpecificFieldLoader")) {
return null;
}
if (!$this->specificFieldLoader) {
$this->specificFieldLoader = $this->injectableFactory->create(SpecificFieldLoader::class);
}
return $this->specificFieldLoader;
}
}

View File

@@ -0,0 +1,268 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Utils\Log;
use Espo\Modules\Advanced\Core\Workflow\Utils;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use RuntimeException;
use stdClass;
class FieldValueHelper
{
public function __construct(
private EntityManager $entityManager,
private Log $log,
private FieldLoaderHelper $fieldLoaderHelper,
) {}
/**
* Get field value for a field/related field. If this field has a relation, get the value from the relation.
*
* @param ?string $path A field path.
*/
public function getValue(
CoreEntity $entity,
?string $path,
bool $returnEntity = false,
?stdClass $createdEntitiesData = null
): mixed {
if (str_starts_with($path, 'created:')) {
[$alias, $field] = explode('.', substr($path, 8));
if (!$createdEntitiesData || !isset($createdEntitiesData->$alias)) {
return null;
}
$entityTypeValue = $createdEntitiesData->$alias->entityType ?? null;
$entityIdValue = $createdEntitiesData->$alias->entityId ?? null;
if (!$entityTypeValue || !$entityIdValue) {
return null;
}
$entity = $this->entityManager->getEntityById($entityTypeValue, $entityIdValue);
if (!$entity) {
return null;
}
$path = $field;
} else if (str_contains($path, '.')) {
[$first, $foreignName] = explode('.', $path);
$relatedEntity = $this->getRelatedEntity($entity, $first);
if ($relatedEntity instanceof CoreEntity) {
$entity = $relatedEntity;
$path = $foreignName;
} else {
$this->log->warning("Workflow: Could not get related entity by path '$path'.");
return null;
}
}
if (!$entity instanceof CoreEntity) {
throw new RuntimeException();
}
if ($path && $entity->hasRelation($path)) {
$relatedEntity = $this->getRelatedEntityForRelation($entity, $path);
if ($relatedEntity instanceof CoreEntity) {
$foreignKey = $entity->getRelationParam($path, 'foreignKey') ?? 'id';
return $returnEntity ? $relatedEntity : $relatedEntity->get($foreignKey);
}
if (!$relatedEntity) {
$normalizedFieldName = Utils::normalizeFieldName($entity, $path);
if (!$entity->isNew() && $entity->hasLinkMultipleField($path)) {
$entity->loadLinkMultipleField($path);
}
if ($entity->getRelationType($path) === Entity::BELONGS_TO_PARENT && !$returnEntity) {
return null;
}
$fieldValue = $returnEntity ?
$this->getParentEntity($entity, $path) :
$this->getParentValue($entity, $normalizedFieldName);
if (isset($fieldValue)) {
return $fieldValue;
}
}
if ($entity->hasLinkMultipleField($path)) {
$entity->loadLinkMultipleField($path);
}
if ($relatedEntity) {
return null;
}
return $entity->get($path . 'Ids');
}
switch ($entity->getAttributeType($path)) {
// @todo Revise.
case 'linkParent':
$path .= 'Id';
break;
}
if ($returnEntity) {
return $entity;
}
if (!$entity->hasAttribute($path)) {
return null;
}
if (!$entity->has($path)) {
$this->fieldLoaderHelper->load($entity, $path);
}
return $entity->get($path);
}
/**
* @return CoreEntity|Entity|null
*/
private function getParentEntity(CoreEntity $entity, string $fieldName)
{
if (!$entity->hasRelation($fieldName)) {
return $entity;
}
$normalizedFieldName = Utils::normalizeFieldName($entity, $fieldName);
$fieldValue = $this->getParentValue($entity, $normalizedFieldName);
if (isset($fieldValue) && is_string($fieldValue)) {
$fieldEntityDefs = $this->entityManager->getMetadata()->get($entity->getEntityType());
if (isset($fieldEntityDefs['relations'][$fieldName]['entity'])) {
$fieldEntity = $fieldEntityDefs['relations'][$fieldName]['entity'];
return $this->entityManager->getEntityById($fieldEntity, $fieldValue);
}
}
return null;
}
/**
* Get parent field value. Works for parent and regular fields,
*
* @param string|string[] $normalizedFieldName
* @return mixed
*/
private function getParentValue(Entity $entity, $normalizedFieldName)
{
if (is_array($normalizedFieldName)) {
$value = [];
foreach ($normalizedFieldName as $fieldName) {
if ($entity->hasAttribute($fieldName)) {
$value[$fieldName] = $entity->get($fieldName);
}
}
return $value;
}
if ($entity->hasAttribute($normalizedFieldName)) {
return $entity->get($normalizedFieldName);
}
return null;
}
private function getRelatedEntityForRelation(CoreEntity $entity, string $relation): ?Entity
{
if ($entity->getRelationType($relation) === Entity::BELONGS_TO_PARENT) {
$valueType = $entity->get($relation . 'Type');
$valueId = $entity->get($relation . 'Id');
if ($valueType && $valueId) {
return $this->entityManager->getEntityById($valueType, $valueId);
}
return null;
}
if (in_array($entity->getRelationType($relation), [Entity::BELONGS_TO, Entity::HAS_ONE])) {
return $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
}
return null;
}
private function getRelatedEntity(CoreEntity $entity, string $relation): ?Entity
{
if (!$entity->hasRelation($relation)) {
return null;
}
if (
in_array($entity->getRelationType($relation), [
Entity::BELONGS_TO,
Entity::HAS_ONE,
Entity::BELONGS_TO_PARENT,
])
) {
$relatedEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $relation)
->findOne();
if ($relatedEntity) {
return $relatedEntity;
}
}
// If the entity is just created and doesn't have added relations.
$foreignEntityType = $entity->getRelationParam($relation, 'entity');
$idAttribute = Utils::normalizeFieldName($entity, $relation);
if (
!$foreignEntityType ||
!$entity->hasAttribute($idAttribute) ||
!$entity->get($idAttribute)
) {
return null;
}
return $this->entityManager->getEntityById($foreignEntityType, $entity->get($idAttribute));
}
}

View File

@@ -0,0 +1,37 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
class PlaceholderHelper
{
public function __construct(
private SecretProvider $secretProvider,
) {}
public function applySecrets(string $content): string
{
return preg_replace_callback('/{#secrets\.([A-Za-z0-9_]+)}/', function ($matches) {
$name = trim($matches[1]);
$secret = $this->secretProvider->get($name);
return $secret ?? '';
}, $content);
}
}

View File

@@ -0,0 +1,49 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
class RecipientIds
{
/**
* @param string[] $ids
*/
public function __construct(
private ?string $entityType = null,
private array $ids = [],
private bool $isOne = false,
) {}
/**
* @return string[]
*/
public function getIds(): array
{
return $this->ids;
}
public function getEntityType(): ?string
{
return $this->entityType;
}
public function isOne(): bool
{
return $this->isOne;
}
}

View File

@@ -0,0 +1,146 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceFactory;
use Espo\Entities\User;
use Espo\Modules\Advanced\Tools\Workflow\Core\FieldValueHelper;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\Stream\Service;
use RuntimeException;
class RecipientProvider
{
public function __construct(
private EntityManager $entityManager,
private InjectableFactory $injectableFactory,
private ServiceFactory $serviceFactory,
private FieldValueHelper $fieldValueHelper,
) {}
public function get(Entity $entity, string $target): RecipientIds
{
if (!$entity instanceof CoreEntity) {
return new RecipientIds();
}
$link = $target;
$targetEntity = $entity;
if (str_starts_with($link, 'link:')) {
$link = substr($link, 5);
}
if (strpos($link, '.')) {
[$firstLink, $link] = explode('.', $link);
$relationType = $entity->getRelationType($firstLink);
if (in_array($relationType, [Entity::HAS_MANY, Entity::MANY_MANY])) {
$collection = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $firstLink)
->sth()
->find();
$ids = [];
$entityType = null;
foreach ($collection as $targetEntity) {
$entityType ??= $targetEntity->getEntityType();
$itemIds = $this->get($targetEntity, "link:$link")->getIds();
$ids = array_merge($ids, $itemIds);
}
return new RecipientIds($entityType, array_unique($ids));
}
$targetEntity = $this->entityManager
->getRDBRepository($entity->getEntityType())
->getRelation($entity, $firstLink)
->findOne();
if (!$targetEntity) {
return new RecipientIds();
}
}
if ($link === 'followers') {
if (!class_exists("Espo\\Tools\\Stream\\Service")) {
/** @noinspection PhpUndefinedMethodInspection */
return new RecipientIds(
User::ENTITY_TYPE,
/** @phpstan-ignore-next-line */
$this->serviceFactory->create('Stream')->getEntityFolowerIdList($targetEntity)
);
}
/** @var Service $streamService */
$streamService = $this->injectableFactory->create("Espo\\Tools\\Stream\\Service");
return new RecipientIds(
User::ENTITY_TYPE,
$streamService->getEntityFollowerIdList($targetEntity)
);
}
if (
$targetEntity->hasRelation($link) &&
(
$targetEntity->getRelationType($link) === Entity::HAS_MANY ||
$targetEntity->getRelationType($link) === Entity::MANY_MANY
)
) {
$collection = $this->entityManager
->getRDBRepository($targetEntity->getEntityType())
->getRelation($targetEntity, $link)
->select(['id'])
->sth()
->find();
$ids = [];
$entityType = null;
foreach ($collection as $e) {
$ids[] = $e->getId();
$entityType ??= $e->getEntityType();
}
return new RecipientIds($entityType, $ids);
}
if (!$targetEntity instanceof CoreEntity) {
throw new RuntimeException();
}
$fieldEntity = $this->fieldValueHelper->getValue($targetEntity, $link, true);
if ($fieldEntity instanceof Entity) {
return new RecipientIds($fieldEntity->getEntityType(), [$fieldEntity->getId()], true);
}
return new RecipientIds();
}
}

View File

@@ -0,0 +1,68 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\ORM\Repository\Option\SaveContext;
class SaveContextHelper
{
/**
* @param array<string, mixed> $options
* @return ?SaveContext
*/
public static function createDerived(array $options)
{
if (!class_exists("Espo\\Core\\ORM\\Repository\\Option\\SaveContext")) {
return null;
}
$newSaveContext = null;
$saveContext = $options[SaveContext::NAME] ?? null;
if (
$saveContext instanceof SaveContext &&
/** @phpstan-ignore-next-line function.alreadyNarrowedType */
method_exists($saveContext, 'getActionId')
) {
$newSaveContext = new SaveContext($saveContext->getActionId());
}
return $newSaveContext;
}
/**
* @param array<string, mixed> $options
* @return ?SaveContext
*/
public static function obtainFromRawOptions(array $options)
{
if (!class_exists("Espo\\Core\\ORM\\Repository\\Option\\SaveContext")) {
return null;
}
$saveContext = $options[SaveContext::NAME] ?? null;
if (!$saveContext instanceof SaveContext) {
return null;
}
return $saveContext;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\Utils\Crypt;
use Espo\Entities\AppSecret;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Condition;
use Espo\ORM\Query\Part\Expression;
class SecretProvider
{
public function __construct(
private Crypt $crypt,
private EntityManager $entityManager,
) {}
public function get(string $name): ?string
{
if (!$this->entityManager->hasRepository('AppSecret')) {
return null;
}
$secret = $this->entityManager
->getRDBRepositoryByClass(AppSecret::class)
->where(
Condition::equal(
Expression::binary(Expression::column('name')),
$name
)
)
->findOne();
if (!$secret) {
return null;
}
return $this->crypt->decrypt($secret->getValue());
}
}

View File

@@ -0,0 +1,133 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Core;
use Espo\Core\Exceptions\Error;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use stdClass;
class TargetProvider
{
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private User $user
) {}
/**
* @return iterable<Entity>
*/
public function get(Entity $entity, ?string $target, ?stdClass $createdEntitiesData = null): iterable
{
if (!$target || $target === 'targetEntity') {
if (!$entity->hasId()) {
return [];
}
$targetEntity = $this->entityManager->getEntityById($entity->getEntityType(), $entity->getId());
return self::wrapEntityIntoArray($targetEntity);
}
if (str_starts_with($target, 'created:')) {
return self::wrapEntityIntoArray(
$this->getCreated($target, $createdEntitiesData)
);
}
if (str_starts_with($target, 'link:')) {
$path = explode('.', substr($target, 5));
$pointerEntity = $entity;
foreach ($path as $i => $link) {
$type = $this->metadata->get(['entityDefs', $pointerEntity->getEntityType(), 'links', $link, 'type']);
if (!$type) {
throw new Error("Workflow action: Bad target $target. Not existing link.");
}
$isLast = $i === count($path) - 1;
$relation = $this->entityManager
->getRDBRepository($pointerEntity->getEntityType())
->getRelation($pointerEntity, $link);
if ($isLast) {
return $relation->sth()->find();
}
$pointerEntity = $this->entityManager
->getRDBRepository($pointerEntity->getEntityType())
->getRelation($pointerEntity, $link)
->findOne();
if (!$pointerEntity instanceof Entity) {
return [];
}
}
return [];
}
if ($target == 'currentUser') {
return [$this->user];
}
return [];
}
public function getCreated(string $target, ?stdClass $createdEntitiesData): ?Entity
{
$alias = str_starts_with($target, 'created:') ? substr($target, 8) : $target;
if (!$createdEntitiesData) {
return null;
}
if (!property_exists($createdEntitiesData, $alias)) {
return null;
}
$id = $createdEntitiesData->$alias->entityId ?? null;
$entityType = $createdEntitiesData->$alias->entityType ?? null;
if (!$id || !$entityType) {
return null;
}
return $this->entityManager->getEntityById($entityType, $id);
}
/**
* @param Entity|null $entity
* @return Entity[]
*/
private static function wrapEntityIntoArray(?Entity $entity): array
{
if (!$entity) {
return [];
}
return [$entity];
}
}

View File

@@ -0,0 +1,125 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Jobs;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Job\JobSchedulerFactory;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\Modules\Advanced\Tools\Report\ListType\RunParams as ListRunParams;
use Espo\Modules\Advanced\Tools\Report\Service as ReportService;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use Exception;
use RuntimeException;
class RunScheduledWorkflow implements Job
{
public function __construct(
private ReportService $reportService,
private EntityManager $entityManager,
private Service $service,
private JobSchedulerFactory $jobSchedulerFactory,
) {}
/**
* @throws BadRequest
* @throws Forbidden
* @throws Error
* @throws NotFound
*/
public function run(Data $data): void
{
$workflowId = $data->getTargetId() ?? $data->get('workflowId');
if (!$workflowId) {
throw new RuntimeException();
}
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($workflowId);
if (!$workflow) {
throw new RuntimeException("Workflow $workflowId not found.");
}
if (!$workflow->isActive()) {
return;
}
$targetReport = $this->entityManager
->getRDBRepository(Workflow::ENTITY_TYPE)
->getRelation($workflow, 'targetReport')
->findOne();
if (!$targetReport) {
throw new RuntimeException("Workflow $workflowId: Target report not found.");
}
$result = $this->reportService->runList(
id: $targetReport->getId(),
runParams: ListRunParams::create()->withReturnSthCollection(),
);
foreach ($result->getCollection() as $entity) {
try {
$this->runScheduledWorkflowForEntity(
$workflow->getId(),
$entity->getEntityType(),
$entity->getId()
);
} catch (Exception) {
// @todo Revise.
$this->jobSchedulerFactory
->create()
->setClassName(RunScheduledWorkflowForEntity::class)
->setGroup('scheduled-workflows')
->setData([
'workflowId' => $workflow->getId(),
'entityType' => $entity->getEntityType(),
'entityId' => $entity->getId(),
])
->schedule();
}
}
}
/**
* @throws FormulaError
* @throws Error
*/
private function runScheduledWorkflowForEntity(string $workflowId, string $entityType, string $id): void
{
// @todo Create jobs if a parameter is enabled.
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new RuntimeException("Workflow $workflowId: Entity $entityType $id not found.");
}
$this->service->triggerWorkflow($entity, $workflowId);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use RuntimeException;
class RunScheduledWorkflowForEntity implements Job
{
private EntityManager $entityManager;
private Service $service;
public function __construct(
EntityManager $entityManager,
Service $service
) {
$this->entityManager = $entityManager;
$this->service = $service;
}
public function run(Data $data): void
{
$data = $data->getRaw();
$entityType = $data->entityType;
$id = $data->entityId;
$workflowId = $data->workflowId;
$entity = $this->entityManager->getEntityById($entityType, $id);
if (!$entity) {
throw new RuntimeException("Workflow $workflowId: Entity $entityType $id not found.");
}
$this->service->triggerWorkflow($entity, $workflowId);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Modules\Advanced\Tools\Workflow\SendEmailService;
class SendEmail implements Job
{
private SendEmailService $service;
public function __construct(SendEmailService $service)
{
$this->service = $service;
}
/**
* @throws Error
* @throws NoSmtp
*/
public function run(Data $data): void
{
$this->service->send($data->getRaw());
}
}

View File

@@ -0,0 +1,67 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
class TriggerWorkflow implements Job
{
public function __construct(
private Service $service,
private EntityManager $entityManager
) {}
public function run(Data $data): void
{
$data = $data->getRaw();
if (
empty($data->entityId) ||
empty($data->entityType) ||
empty($data->nextWorkflowId)
) {
throw new Error("Workflow[$data->workflowId][triggerWorkflow]: Not sufficient job data.");
}
$entityId = $data->entityId;
$entityType = $data->entityType;
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
throw new Error("Workflow[$data->workflowId][triggerWorkflow]: Entity not found.");
}
$values = $data->values ?? null;
if (is_object($values)) {
$values = get_object_vars($values);
foreach ($values as $attribute => $value) {
$entity->setFetched($attribute, $value);
}
}
$this->service->triggerWorkflow($entity, $data->nextWorkflowId, true);
}
}

View File

@@ -0,0 +1,90 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow\Jobs;
use Espo\Core\Exceptions\Error;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
use Espo\Core\Utils\Log;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\Modules\Advanced\Tools\Workflow\Core\TargetProvider;
use Espo\Modules\Advanced\Tools\Workflow\Service;
use Espo\ORM\EntityManager;
use Exception;
use RuntimeException;
class TriggerWorkflowMany implements Job
{
public function __construct(
private TargetProvider $targetProvider,
private EntityManager $entityManager,
private Service $service,
private Log $log
) {}
/**
* @throws Error
*/
public function run(Data $data): void
{
$workflowId = $data->get('nextWorkflowId');
$entityId = $data->get('entityId');
$entityType = $data->get('entityType');
$target = $data->get('target');
if (!is_string($target)) {
throw new RuntimeException("No target.");
}
if (!is_string($workflowId)) {
throw new RuntimeException("No nextWorkflowId.");
}
if (!is_string($entityId)) {
throw new RuntimeException("No entityId.");
}
if (!is_string($entityType)) {
throw new RuntimeException("No entityType.");
}
$entity = $this->entityManager->getEntityById($entityType, $entityId);
if (!$entity) {
return;
}
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($workflowId);
if (!$workflow) {
throw new RuntimeException("No workflow $workflowId.");
}
$targetEntityList = $this->targetProvider->get($entity, $target);
foreach ($targetEntityList as $targetEntity) {
try {
$this->service->triggerWorkflow($targetEntity, $workflowId);
}
catch (Exception $e) {
$this->log->error("Trigger workflow $workflowId for entity $entityId: " . $e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,661 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow;
use Espo\Core\Mail\Exceptions\NoSmtp;
use Espo\Core\Mail\Sender;
use Espo\Core\Mail\SenderParams;
use Espo\Core\Mail\SmtpParams;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Tools\EmailTemplate\Result;
use Laminas\Mail\Message;
use Espo\Core\Mail\Account\GroupAccount\AccountFactory as GroupAccountFactory;
use Espo\Core\Mail\Account\PersonalAccount\AccountFactory as PersonalAccountFactory;
use Espo\Core\InjectableFactory;
use Espo\Core\Exceptions\Error;
use Espo\Core\Mail\EmailSender;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Hasher;
use Espo\Core\Utils\Language;
use Espo\Entities\Attachment;
use Espo\Entities\Email;
use Espo\Entities\EmailAccount;
use Espo\Entities\EmailTemplate;
use Espo\Entities\InboundEmail;
use Espo\Entities\User;
use Espo\Modules\Advanced\Core\Workflow\Helper;
use Espo\Modules\Advanced\Entities\BpmnProcess as BpmnProcessEntity;
use Espo\Modules\Advanced\Entities\Workflow as WorkflowEntity;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\EmailTemplate\Processor as EmailTemplateProcessor;
use Espo\Tools\EmailTemplate\Data as EmailTemplateData;
use Espo\Tools\EmailTemplate\Params as EmailTemplateParams;
use RuntimeException;
use Exception;
use stdClass;
class SendEmailService
{
public function __construct(
private EntityManager $entityManager,
private ServiceContainer $recordServiceContainer,
private Config $config,
private Helper $workflowHelper,
private EmailSender $emailSender,
private Hasher $hasher,
private Language $defaultLanguage,
private EmailTemplateProcessor $emailTemplateProcessor,
private InjectableFactory $injectableFactory
) {}
/**
* Send email for a workflow.
* @return bool|string
* @throws Error
* @throws NoSmtp
* @todo Introduce SendEmailData class.
*/
public function send(stdClass $data)
{
$workflowId = $data->workflowId;
if (!$this->validateSendEmailData($data)) {
throw new Error("Workflow[$workflowId][sendEmail]: Email data is invalid.");
}
$data->doNotStore ??= false;
$data->returnEmailId ??= false;
$data->from ??= (object) [];
$data->to ??= (object) [];
$data->cc ??= null;
$data->replyTo ??= null;
$data->attachmentIds ??= [];
/**
* @var object{
* variables?: stdClass,
* optOutLink?: bool,
* attachmentIds: string[],
* entityType?: string|null,
* entityId?: string|null,
* from: stdClass,
* to: stdClass,
* cc: stdClass|null,
* replyTo: stdClass|null,
* doNotStore: bool,
* returnEmailId: bool,
* } & stdClass $data
*/
if ($workflowId) {
$workflow = $this->entityManager->getRDBRepositoryByClass(WorkflowEntity::class)->getById($workflowId);
if (!$workflow || !$workflow->isActive()) {
return false;
}
}
$entity = null;
if (!empty($data->entityType) && !empty($data->entityId)) {
$entity = $this->entityManager->getEntityById($data->entityType, $data->entityId);
}
if (!$entity) {
throw new Error("Workflow[$workflowId][sendEmail]: Target Entity is not found.");
}
$this->recordServiceContainer->get($entity->getEntityType())
->loadAdditionalFields($entity);
$fromAddress = $this->getEmailAddress($data->from);
$toAddress = $this->getEmailAddress($data->to);
$replyToAddress = !empty($data->replyTo) ? $this->getEmailAddress($data->replyTo) : null;
$ccAddress = !empty($data->cc) ? $this->getEmailAddress($data->cc) : null;
if (!$fromAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: From email address is empty or could not be obtained.");
}
if (!$toAddress) {
throw new Error("Workflow[$workflowId][sendEmail]: To email address is empty.");
}
/** @var array<string, Entity> $entityHash */
$entityHash = [$data->entityType => $entity];
if (
isset($data->to->entityType) &&
isset($data->to->entityId) &&
$data->to->entityType !== $data->entityType
) {
/** @var string $toEntityType */
$toEntityType = $data->to->entityType;
$toEntity = $this->entityManager->getEntityById($toEntityType, $data->to->entityId);
if ($toEntity) {
$entityHash[$toEntityType] = $toEntity;
}
}
$fromName = null;
if (
isset($data->from->entityType) &&
isset($data->from->entityId) &&
$data->from->entityType === User::ENTITY_TYPE
) {
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($data->from->entityId);
if ($user) {
$entityHash[User::ENTITY_TYPE] = $user;
$fromName = $user->getName();
}
}
$sender = $this->emailSender->create();
$templateResult = $this->getTemplateResult(
data: $data,
entityHash: $entityHash,
toEmailAddress: $toAddress,
entity: $entity,
);
[$subject, $body] = $this->prepareSubjectBody(
templateResult: $templateResult,
data: $data,
toEmailAddress: $toAddress,
sender: $sender,
);
$emailData = [
'from' => $fromAddress,
'to' => $toAddress,
'cc' => $ccAddress,
'replyTo' => $replyToAddress,
'subject' => $subject,
'body' => $body,
'isHtml' => $templateResult->isHtml(),
'parentId' => $entity->getId(),
'parentType' => $entity->getEntityType(),
];
if ($fromName !== null) {
$emailData['fromName'] = $fromName;
}
$email = $this->entityManager->getRDBRepositoryByClass(Email::class)->getNew();
$email->setMultiple($emailData);
$attachmentList = $this->getAttachmentList($templateResult, $data->attachmentIds);
if (!$data->doNotStore) {
// Additional attachments not added intentionally?
$email->set('attachmentsIds', $templateResult->getAttachmentIdList());
}
$smtpParams = $this->prepareSmtpParams($data, $fromAddress);
if ($smtpParams) {
$sender->withSmtpParams($smtpParams);
}
$sender->withAttachments($attachmentList);
if ($replyToAddress) {
$senderParams = SenderParams::create()->withReplyToAddress($replyToAddress);
$sender->withParams($senderParams);
}
try {
$sender->send($email);
} catch (Exception $e) {
$sendExceptionMessage = $e->getMessage();
throw new Error("Workflow[$workflowId][sendEmail]: $sendExceptionMessage.", 0, $e);
}
if ($data->doNotStore) {
return true;
}
$this->storeEmail($email, $data);
if ($data->returnEmailId) {
return $email->getId();
}
return true;
}
private function validateSendEmailData(stdClass $data): bool
{
if (
!isset($data->entityId) ||
!(isset($data->entityType)) ||
!isset($data->emailTemplateId) ||
!isset($data->from) ||
!isset($data->to)
) {
return false;
}
return true;
}
private function getEmailAddress(stdClass $data): ?string
{
if (isset($data->email)) {
return $data->email;
}
$entityType = $data->entityType ?? $data->entityName ?? null;
$entity = null;
if (isset($entityType) && isset($data->entityId)) {
$entity = $this->entityManager->getEntityById($entityType, $data->entityId);
}
$workflowHelper = $this->workflowHelper;
if (isset($data->type)) {
switch ($data->type) {
case 'specifiedTeams':
$userIds = $workflowHelper->getUserIdsByTeamIds($data->entityIds);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'teamUsers':
if (!$entity instanceof CoreEntity) {
return null;
}
$entity->loadLinkMultipleField('teams');
$userIds = $workflowHelper->getUserIdsByTeamIds($entity->get('teamsIds'));
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followers':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIds($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'followersExcludingAssignedUser':
if (!$entity) {
return null;
}
$userIds = $workflowHelper->getFollowerUserIdsExcludingAssignedUser($entity);
return implode('; ', $workflowHelper->getUsersEmailAddress($userIds));
case 'system':
return $this->config->get('outboundEmailFromAddress');
case 'specifiedUsers':
return implode('; ', $workflowHelper->getUsersEmailAddress($data->entityIds));
case 'specifiedContacts':
return implode('; ', $workflowHelper->getEmailAddressesForEntity('Contact', $data->entityIds));
}
}
if ($entity instanceof Entity && $entity->hasAttribute('emailAddress')) {
return $entity->get('emailAddress');
}
if (
isset($data->type) &&
isset($entityType) &&
isset($data->entityIds) &&
is_array($data->entityIds)
) {
return implode('; ', $workflowHelper->getEmailAddressesForEntity($entityType, $data->entityIds));
}
return null;
}
private function applyTrackingUrlsToEmailBody(string $body, string $toEmailAddress): string
{
$siteUrl = $this->config->get('siteUrl');
if (!str_contains($body, '{trackingUrl:')) {
return $body;
}
$hash = $this->hasher->hash($toEmailAddress);
preg_match_all('/\{trackingUrl:(.*?)}/', $body, $matches);
/** @phpstan-ignore-next-line */
if (!$matches || !count($matches)) {
return $body;
}
foreach ($matches[0] as $item) {
$id = explode(':', trim($item, '{}'), 2)[1] ?? null;
if (!$id) {
continue;
}
if (strpos($id, '.')) {
[$id, $uid] = explode('.', $id);
$uidHash = $this->hasher->hash($uid);
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&uid=$uid&hash=$uidHash";
} else {
$url = "$siteUrl?entryPoint=campaignUrl&id=$id&emailAddress=$toEmailAddress&hash=$hash";
}
$body = str_replace($item, $url, $body);
}
return $body;
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getUserSmtpParams(string $emailAddress, string $userId): ?SmtpParams
{
$user = $this->entityManager->getRDBRepositoryByClass(User::class)->getById($userId);
if (!$user || !$user->isActive()) {
return null;
}
$emailAccount = $this->entityManager
->getRDBRepositoryByClass(EmailAccount::class)
->where([
'emailAddress' => $emailAddress,
'assignedUserId' => $userId,
'useSmtp' => true,
'status' => EmailAccount::STATUS_ACTIVE,
])
->findOne();
if (!$emailAccount) {
return null;
}
$factory = $this->injectableFactory->create(PersonalAccountFactory::class);
$params = $factory->create($emailAccount->getId())
->getSmtpParams();
if (!$params) {
return null;
}
return $params->withFromName($user->getName());
}
/**
* @throws Error
* @throws NoSmtp
*/
private function getGroupSmtpParams(string $emailAddress): ?SmtpParams
{
$inboundEmail = $this->entityManager
->getRDBRepositoryByClass(InboundEmail::class)
->where([
'status' => InboundEmail::STATUS_ACTIVE,
'useSmtp' => true,
'smtpHost!=' => null,
'emailAddress' => $emailAddress,
])
->findOne();
if (!$inboundEmail) {
return null;
}
return $this->injectableFactory
->create(GroupAccountFactory::class)
->create($inboundEmail->getId())
->getSmtpParams();
}
/**
* @param Result $templateResult
* @param string[] $attachmentIds
* @return Attachment[]
*/
private function getAttachmentList(Result $templateResult, array $attachmentIds): array
{
$attachmentList = [];
foreach (array_merge($templateResult->getAttachmentIdList(), $attachmentIds) as $attachmentId) {
$attachment = $this->entityManager
->getRDBRepositoryByClass(Attachment::class)
->getById($attachmentId);
if ($attachment) {
$attachmentList[] = $attachment;
}
}
return $attachmentList;
}
private function storeEmail(Email $email, stdClass $data): void
{
$processId = $data->processId ?? null;
$emailTemplateId = $data->emailTemplateId ?? null;
$teamsIds = [];
if ($processId) {
$process = $this->entityManager
->getRDBRepositoryByClass(BpmnProcessEntity::class)
->getById($processId);
if ($process) {
$teamsIds = $process->getLinkMultipleIdList('teams');
}
} else if ($emailTemplateId) {
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if ($emailTemplate) {
$teamsIds = $emailTemplate->getLinkMultipleIdList('teams');
}
}
if (count($teamsIds)) {
$email->set('teamsIds', $teamsIds);
}
$this->entityManager->saveEntity($email, ['createdById' => 'system']);
}
/**
* @throws Error
* @throws NoSmtp
*/
private function prepareSmtpParams(stdClass $data, string $fromEmailAddress): ?SmtpParams
{
if (
isset($data->from->entityType) &&
$data->from->entityType === User::ENTITY_TYPE &&
isset($data->from->entityId)
) {
return $this->getUserSmtpParams($fromEmailAddress, $data->from->entityId);
}
if (isset($data->from->email)) {
return $this->getGroupSmtpParams($fromEmailAddress);
}
return null;
}
private function getEmailTemplate(stdClass $data): EmailTemplate
{
$emailTemplateId = $data->emailTemplateId ?? null;
if (!$emailTemplateId) {
throw new RuntimeException("No email template.");
}
$emailTemplate = $this->entityManager
->getRDBRepositoryByClass(EmailTemplate::class)
->getById($emailTemplateId);
if (!$emailTemplate) {
throw new RuntimeException("Email template $emailTemplateId not found.");
}
return $emailTemplate;
}
/**
* @param array<string, Entity> $entityHash
* @return Result
*/
private function getTemplateResult(
stdClass $data,
array $entityHash,
string $toEmailAddress,
Entity $entity
): Result {
$emailTemplate = $this->getEmailTemplate($data);
$emailTemplateData = EmailTemplateData::create()
->withEntityHash($entityHash)
->withEmailAddress($toEmailAddress)
->withParentId($entity->getId())
->withParentType($entity->getEntityType());
if (
$entity->hasAttribute('parentId') &&
$entity->hasAttribute('parentType')
) {
$emailTemplateData = $emailTemplateData
->withRelatedId($entity->get('parentId'))
->withRelatedType($entity->get('parentType'));
}
return $this->emailTemplateProcessor->process(
$emailTemplate,
EmailTemplateParams::create()->withCopyAttachments(),
$emailTemplateData
);
}
private function applyOptOutLink(
string $toEmailAddress,
string $body,
Result $templateResult,
Sender $sender,
): string {
$siteUrl = $this->config->get('siteUrl');
$hash = $this->hasher->hash($toEmailAddress);
$optOutUrl = "$siteUrl?entryPoint=unsubscribe&emailAddress=$toEmailAddress&hash=$hash";
$optOutLink = "<a href=\"$optOutUrl\">" .
"{$this->defaultLanguage->translateLabel('Unsubscribe', 'labels', 'Campaign')}</a>";
$body = str_replace('{optOutUrl}', $optOutUrl, $body);
$body = str_replace('{optOutLink}', $optOutLink, $body);
if (stripos($body, '?entryPoint=unsubscribe') === false) {
if ($templateResult->isHtml()) {
$body .= "<br><br>" . $optOutLink;
} else {
$body .= "\n\n" . $optOutUrl;
}
}
if (method_exists($sender, 'withAddedHeader')) { /** @phpstan-ignore-line */
$sender->withAddedHeader('List-Unsubscribe', '<' . $optOutUrl . '>');
} else {
$message = new Message();
$message->getHeaders()->addHeaderLine('List-Unsubscribe', '<' . $optOutUrl . '>');
$sender->withMessage($message);
}
return $body;
}
/**
* @param Result $templateResult
* @param object{variables?: stdClass, optOutLink?: bool}&stdClass $data
* @return array{?string, ?string}
*/
private function prepareSubjectBody(
Result $templateResult,
stdClass $data,
string $toEmailAddress,
Sender $sender
): array {
$subject = $templateResult->getSubject();
$body = $templateResult->getBody();
if (isset($data->variables)) {
foreach (get_object_vars($data->variables) as $key => $value) {
if (!is_string($value) && !is_int($value) && !is_float($value)) {
continue;
}
if (is_int($value) || is_float($value)) {
$value = strval($value);
} else if (!$value) {
continue;
}
$subject = str_replace('{$$' . $key . '}', $value, $subject);
$body = str_replace('{$$' . $key . '}', $value, $body);
}
}
$body = $this->applyTrackingUrlsToEmailBody($body, $toEmailAddress);
if ($data->optOutLink ?? false) {
$body = $this->applyOptOutLink($toEmailAddress, $body, $templateResult, $sender);
}
return [$subject, $body];
}
}

View File

@@ -0,0 +1,248 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow;
use Espo\Core\Acl;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Formula\Exceptions\Error as FormulaError;
use Espo\Core\InjectableFactory;
use Espo\Core\ORM\Entity as CoreEntity;
use Espo\Core\Record\ServiceContainer;
use Espo\Core\Utils\Log;
use Espo\Entities\User;
use Espo\Modules\Advanced\Controllers\WorkflowLogRecord;
use Espo\Modules\Advanced\Core\WorkflowManager;
use Espo\Modules\Advanced\Entities\Workflow;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Tools\DynamicLogic\ConditionCheckerFactory;
use Espo\Tools\DynamicLogic\Exceptions\BadCondition;
use Espo\Tools\DynamicLogic\Item as LogicItem;
use RuntimeException;
use stdClass;
class Service
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private User $user,
private WorkflowManager $workflowManager,
private Log $log,
private InjectableFactory $injectableFactory,
private ServiceContainer $serviceContainer,
) {}
/**
* @throws Error
* @throws Forbidden
* @throws NotFound
*/
public function runManual(string $id, string $targetId): TriggerResult
{
$workflow = $this->getManualWorkflow($id);
$entity = $this->getEntityForManualWorkflow($workflow, $targetId);
$this->processManualWorkflowAccess($workflow, $entity);
$this->processCheckManualWorkflowConditions($workflow, $entity);
try {
$result = $this->triggerWorkflow($entity, $workflow->getId(), true);
} catch (FormulaError $e) {
throw new Error("Formula error.", 500, $e);
}
if (!$result) {
throw new RuntimeException("No result.");
}
return $result;
}
/**
* @throws FormulaError
* @throws Error
*/
public function triggerWorkflow(Entity $entity, string $workflowId, bool $mandatory = false): ?TriggerResult
{
/** @var ?Workflow $workflow */
$workflow = $this->entityManager->getEntityById(Workflow::ENTITY_TYPE, $workflowId);
if (!$workflow) {
throw new Error("Workflow $workflowId does not exist.");
}
if (!$workflow->isActive()) {
if (!$mandatory) {
$this->log->debug("Workflow $workflowId not triggerred as it's not active.");
return null;
}
throw new Error("Workflow $workflowId is not active.");
}
if (!$this->workflowManager->checkConditions($workflow, $entity)) {
$this->log->debug("Workflow $workflowId not triggerred as conditions are not met.");
return null;
}
$workflowLogRecord = $this->entityManager->getNewEntity(WorkflowLogRecord::ENTITY_TYPE);
$workflowLogRecord->set([
'workflowId' => $workflowId,
'targetId' => $entity->getId(),
'targetType' => $entity->getEntityType()
]);
$this->entityManager->saveEntity($workflowLogRecord);
$alertObject = new stdClass();
$variables = ['__alert' => $alertObject];
$this->workflowManager->runActions($workflow, $entity, $variables);
return $this->prepareTriggerResult($alertObject);
}
/**
* @throws Forbidden
* @throws Error
*/
private function processCheckManualWorkflowConditions(Workflow $workflow, CoreEntity $entity): void
{
$conditionGroup = $workflow->getManualDynamicLogicConditionGroup();
if (
!$conditionGroup ||
!class_exists("Espo\\Tools\\DynamicLogic\\ConditionCheckerFactory")
) {
return;
}
$conditionCheckerFactory = $this->injectableFactory->create(ConditionCheckerFactory::class);
$checker = $conditionCheckerFactory->create($entity);
try {
$item = LogicItem::fromGroupDefinition($conditionGroup);
$isTrue = $checker->check($item);
} catch (BadCondition $e) {
throw new Error($e->getMessage(), 500, $e);
}
if (!$isTrue) {
throw new Forbidden("Workflow conditions are not met.");
}
}
/**
* @throws NotFound
*/
private function getEntityForManualWorkflow(Workflow $workflow, string $targetId): CoreEntity
{
$targetEntityType = $workflow->getTargetEntityType();
$entity = $this->entityManager->getRDBRepository($targetEntityType)->getById($targetId);
if (!$entity) {
throw new NotFound();
}
$this->serviceContainer->get($targetEntityType)->loadAdditionalFields($entity);
if (!$entity instanceof CoreEntity) {
throw new RuntimeException();
}
return $entity;
}
/**
* @throws Forbidden
* @throws NotFound
*/
private function getManualWorkflow(string $id): Workflow
{
$workflow = $this->entityManager->getRDBRepositoryByClass(Workflow::class)->getById($id);
if (!$workflow) {
throw new NotFound("Workflow $id not found.");
}
if ($workflow->getType() !== Workflow::TYPE_MANUAL) {
throw new Forbidden();
}
return $workflow;
}
/**
* @throws Forbidden
*/
private function processManualWorkflowAccess(Workflow $workflow, CoreEntity $entity): void
{
if ($this->user->isPortal()) {
throw new Forbidden();
}
$accessRequired = $workflow->getManualAccessRequired();
if ($accessRequired === Workflow::MANUAL_ACCESS_ADMIN) {
if (!$this->user->isAdmin()) {
throw new Forbidden("No admin access.");
}
} else if ($accessRequired === Workflow::MANUAL_ACCESS_READ) {
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden("No read access.");
}
} else if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden("No edit access.");
}
if (!$this->user->isAdmin()) {
$teamIdList = $workflow->getLinkMultipleIdList('manualTeams');
if (array_intersect($teamIdList, $this->user->getTeamIdList()) === []) {
throw new Forbidden("User is not from allowed team.");
}
}
}
private function prepareTriggerResult(stdClass $alertObject): TriggerResult
{
$alert = null;
if (property_exists($alertObject, 'message') && is_string($alertObject->message)) {
$alert = new Alert(
message: $alertObject->message,
type: $alertObject->type ?? null,
autoClose: $alertObject->autoClose ?? false,
);
}
return new TriggerResult(
alert: $alert,
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/***********************************************************************************
* The contents of this file are subject to the Extension License Agreement
* ("Agreement") which can be viewed at
* https://www.espocrm.com/extension-license-agreement/.
* By copying, installing downloading, or using this file, You have unconditionally
* agreed to the terms and conditions of the Agreement, and You may not use this
* file except in compliance with the Agreement. Under the terms of the Agreement,
* You shall not license, sublicense, sell, resell, rent, lease, lend, distribute,
* redistribute, market, publish, commercialize, or otherwise transfer rights or
* usage to the software or any modified version or derivative work of the software
* created by or for you.
*
* Copyright (C) 2015-2025 EspoCRM, Inc.
*
* License ID: 19bc86a68a7bb01f458cb391d43a9212
************************************************************************************/
namespace Espo\Modules\Advanced\Tools\Workflow;
class TriggerResult
{
public function __construct(
public ?Alert $alert = null,
) {}
}