*/
public function getSingleSubject(string $key): Subject {
$subject = $this->subjects[$key] ?? null;
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
return $subject;
}
/**
* @template P of Payload
* @template S of Subject
* @param class-string $class
* @return S
*/
public function getSingleSubjectByClass(string $class): Subject {
$key = $this->subjectKeyClassMap[$class] ?? null;
if (!$key) {
throw Exceptions::subjectClassNotFound($class);
}
/** @var S
$subject -- for PHPStan */
$subject = $this->getSingleSubject($key);
return $subject;
}
}
Data/AutomationRunLog.php 0000644 00000012313 15073230056 0011376 0 ustar 00 automationRunId = $automationRunId;
$this->stepId = $stepId;
$this->stepType = $stepType;
$this->stepKey = self::KEY_UNKNOWN;
$this->status = self::STATUS_RUNNING;
$now = new DateTimeImmutable();
$this->startedAt = $now;
$this->updatedAt = $now;
if ($id) {
$this->id = $id;
}
}
public function getId(): int {
return $this->id;
}
public function getAutomationRunId(): int {
return $this->automationRunId;
}
public function getStepId(): string {
return $this->stepId;
}
public function getStepType(): string {
return $this->stepType;
}
public function getStepKey(): string {
return $this->stepKey;
}
public function setStepKey(string $stepKey): void {
$this->stepKey = $stepKey;
$this->updatedAt = new DateTimeImmutable();
}
public function getStatus(): string {
return $this->status;
}
public function setStatus(string $status): void {
if (!in_array($status, self::STATUS_ALL, true)) {
throw new InvalidArgumentException("Invalid status '$status'.");
}
$this->status = $status;
$this->updatedAt = new DateTimeImmutable();
}
public function getStartedAt(): DateTimeImmutable {
return $this->startedAt;
}
public function getUpdatedAt(): DateTimeImmutable {
return $this->updatedAt;
}
public function getRunNumber(): int {
return $this->runNumber;
}
public function setRunNumber(int $runNumber): void {
$this->runNumber = $runNumber;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getData(): array {
return $this->data;
}
/** @param mixed $value */
public function setData(string $key, $value): void {
if (!$this->isDataStorable($value)) {
throw new InvalidArgumentException("Invalid data provided for key '$key'. Only scalar values and arrays of scalar values are allowed.");
}
$this->data[$key] = $value;
$this->updatedAt = new DateTimeImmutable();
}
public function getError(): ?array {
return $this->error;
}
public function toArray(): array {
return [
'id' => $this->id,
'automation_run_id' => $this->automationRunId,
'step_id' => $this->stepId,
'step_type' => $this->stepType,
'step_key' => $this->stepKey,
'status' => $this->status,
'started_at' => $this->startedAt->format(DateTimeImmutable::W3C),
'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C),
'run_number' => $this->runNumber,
'data' => Json::encode($this->data),
'error' => $this->error ? Json::encode($this->error) : null,
];
}
public function setError(Throwable $error): void {
// Normalize all nested objects in error trace to associative arrays.
// Empty objects would then get decoded to "[]" instead of "{}".
$trace = Json::decode(Json::encode($error->getTrace()));
$this->error = [
'message' => $error->getMessage(),
'errorClass' => get_class($error),
'code' => $error->getCode(),
'trace' => $trace,
];
$this->updatedAt = new DateTimeImmutable();
}
public static function fromArray(array $data): self {
$log = new AutomationRunLog((int)$data['automation_run_id'], $data['step_id'], $data['step_type']);
$log->id = (int)$data['id'];
$log->stepKey = $data['step_key'];
$log->status = $data['status'];
$log->startedAt = new DateTimeImmutable($data['started_at']);
$log->updatedAt = new DateTimeImmutable($data['updated_at']);
$log->runNumber = (int)$data['run_number'];
$log->data = Json::decode($data['data']);
$log->error = isset($data['error']) ? Json::decode($data['error']) : null;
return $log;
}
/** @param mixed $data */
private function isDataStorable($data): bool {
if (is_scalar($data)) {
return true;
}
if (!is_array($data)) {
return false;
}
foreach ($data as $value) {
if (!$this->isDataStorable($value)) {
return false;
}
}
return true;
}
}
Data/Subject.php 0000644 00000001615 15073230056 0007531 0 ustar 00 key = $key;
$this->args = $args;
}
public function getKey(): string {
return $this->key;
}
public function getArgs(): array {
return $this->args;
}
public function getHash(): string {
return md5($this->getKey() . serialize($this->getArgs()));
}
public function toArray(): array {
return [
'key' => $this->getKey(),
'args' => Json::encode($this->getArgs()),
'hash' => $this->getHash(),
];
}
public static function fromArray(array $data): self {
return new self($data['key'], Json::decode($data['args']));
}
}
Data/StepRunArgs.php 0000644 00000011012 15073230056 0010337 0 ustar 00 >[]> */
private $subjectEntries = [];
/** @var array */
private $subjectKeyClassMap = [];
/** @var array */
private $fields = [];
/** @var array */
private $fieldToSubjectMap = [];
/** @var int */
private $runNumber;
/** @param SubjectEntry>[] $subjectsEntries */
public function __construct(
Automation $automation,
AutomationRun $automationRun,
Step $step,
array $subjectsEntries,
int $runNumber
) {
$this->automation = $automation;
$this->step = $step;
$this->automationRun = $automationRun;
$this->runNumber = $runNumber;
foreach ($subjectsEntries as $entry) {
$subject = $entry->getSubject();
$key = $subject->getKey();
$this->subjectEntries[$key] = array_merge($this->subjectEntries[$key] ?? [], [$entry]);
$this->subjectKeyClassMap[get_class($subject)] = $key;
foreach ($subject->getFields() as $field) {
$this->fields[$field->getKey()] = $field;
$this->fieldToSubjectMap[$field->getKey()] = $key;
}
}
}
public function getAutomation(): Automation {
return $this->automation;
}
public function getAutomationRun(): AutomationRun {
return $this->automationRun;
}
public function getStep(): Step {
return $this->step;
}
/** @return array>[]> */
public function getSubjectEntries(): array {
return $this->subjectEntries;
}
/** @return SubjectEntry> */
public function getSingleSubjectEntry(string $key): SubjectEntry {
$subjects = $this->subjectEntries[$key] ?? [];
if (count($subjects) === 0) {
throw Exceptions::subjectDataNotFound($key, $this->automationRun->getId());
}
if (count($subjects) > 1) {
throw Exceptions::multipleSubjectsFound($key, $this->automationRun->getId());
}
return $subjects[0];
}
/**
* @template P of Payload
* @template S of Subject
* @param class-string $class
* @return SubjectEntry>
*/
public function getSingleSubjectEntryByClass(string $class): SubjectEntry {
$key = $this->subjectKeyClassMap[$class] ?? null;
if (!$key) {
throw Exceptions::subjectClassNotFound($class);
}
/** @var SubjectEntry> $entry -- for PHPStan */
$entry = $this->getSingleSubjectEntry($key);
return $entry;
}
/**
* @template P of Payload
* @param class-string
$class
* @return P
*/
public function getSinglePayloadByClass(string $class): Payload {
$payloads = [];
foreach ($this->subjectEntries as $entries) {
if (count($entries) > 1) {
throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId());
}
$entry = $entries[0];
$payload = $entry->getPayload();
if (get_class($payload) === $class) {
$payloads[] = $payload;
}
}
if (count($payloads) === 0) {
throw Exceptions::payloadNotFound($class, $this->automationRun->getId());
}
if (count($payloads) > 1) {
throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId());
}
// ensure PHPStan we're indeed returning an instance of $class
$payload = $payloads[0];
if (!$payload instanceof $class) {
throw InvalidStateException::create();
}
return $payload;
}
/** @return mixed */
public function getFieldValue(string $key) {
$field = $this->fields[$key] ?? null;
$subjectKey = $this->fieldToSubjectMap[$key] ?? null;
if (!$field || !$subjectKey) {
throw Exceptions::fieldNotFound($key);
}
$entry = $this->getSingleSubjectEntry($subjectKey);
try {
$value = $field->getValue($entry->getPayload());
} catch (Throwable $e) {
throw Exceptions::fieldLoadFailed($field->getKey(), $field->getArgs());
}
return $value;
}
public function getRunNumber(): int {
return $this->runNumber;
}
public function isFirstRun(): bool {
return $this->runNumber === 1;
}
}
Endpoints/index.php 0000644 00000000006 15073230056 0010324 0 ustar 00 createAutomationFromTemplateController = $createAutomationFromTemplateController;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
$automation = $this->createAutomationFromTemplateController->createAutomation((string)$request->getParam('slug'));
return new Response($this->automationMapper->buildAutomation($automation));
}
public static function getRequestSchema(): array {
return [
'slug' => Builder::string()->required(),
];
}
}
Endpoints/Automations/index.php 0000644 00000000006 15073230056 0012627 0 ustar 00 registry = $registry;
}
public function handle(Request $request): Response {
/** @var string|null $category */
$category = $request->getParam('category');
$templates = array_values($this->registry->getTemplates($category ? strval($category) : null));
return new Response(array_map(function (AutomationTemplate $automation) {
return $automation->toArray();
}, $templates));
}
public static function getRequestSchema(): array {
return [
'category' => Builder::string(),
];
}
}
Endpoints/Automations/AutomationsPutEndpoint.php 0000644 00000002743 15073230056 0016227 0 ustar 00 updateController = $updateController;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
$data = $request->getParams();
/** @var int $automationId */
$automationId = $request->getParam('id');
$automation = $this->updateController->updateAutomation(intval($automationId), $data);
return new Response($this->automationMapper->buildAutomation($automation));
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
'name' => Builder::string()->minLength(1),
'status' => Builder::string(),
'steps' => AutomationSchema::getStepsSchema(),
'meta' => Builder::object(),
];
}
}
Endpoints/Automations/AutomationsDeleteEndpoint.php 0000644 00000001747 15073230056 0016664 0 ustar 00 deleteController = $deleteController;
}
public function handle(Request $request): Response {
/** @var int $automationId */
$automationId = $request->getParam('id');
$automationId = intval($automationId);
$this->deleteController->deleteAutomation($automationId);
return new Response(null);
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
];
}
}
Endpoints/Automations/AutomationTemplateGetEndpoint.php 0000644 00000003345 15073230056 0017506 0 ustar 00 registry = $registry;
$this->automationValidator = $automationValidator;
$this->automationMapper = $automationMapper;
}
public function handle(Request $request): Response {
/** @var string|null $slug - for PHPStan because strval() doesn't accept a value of mixed */
$slug = $request->getParam('slug');
$slug = strval($slug);
$template = $this->registry->getTemplate($slug);
if (!$template) {
throw Exceptions::automationTemplateNotFound($slug);
}
$automation = $template->createAutomation();
$automation->setId(0);
$this->automationValidator->validate($automation);
$data = $template->toArray() + [
'automation' => $this->automationMapper->buildAutomation($automation),
];
return new Response($data);
}
public static function getRequestSchema(): array {
return [
'slug' => Builder::string()->required(),
];
}
}
Endpoints/Automations/AutomationsGetEndpoint.php 0000644 00000002277 15073230056 0016200 0 ustar 00 automationMapper = $automationMapper;
$this->automationStorage = $automationStorage;
}
public function handle(Request $request): Response {
$status = $request->getParam('status') ? (array)$request->getParam('status') : null;
$automations = $this->automationStorage->getAutomations($status);
return new Response($this->automationMapper->buildAutomationList($automations));
}
public static function getRequestSchema(): array {
return [
'status' => Builder::array(Builder::string()),
];
}
}
Endpoints/Automations/AutomationsDuplicateEndpoint.php 0000644 00000002421 15073230056 0017362 0 ustar 00 automationMapper = $automationMapper;
$this->duplicateController = $duplicateController;
}
public function handle(Request $request): Response {
/** @var int $automationId */
$automationId = $request->getParam('id');
$automationId = intval($automationId);
$duplicate = $this->duplicateController->duplicateAutomation($automationId);
return new Response($this->automationMapper->buildAutomation($duplicate));
}
public static function getRequestSchema(): array {
return [
'id' => Builder::integer()->required(),
];
}
}
Builder/UpdateAutomationController.php 0000644 00000010610 15073230056 0014171 0 ustar 00 hooks = $hooks;
$this->storage = $storage;
$this->statisticsStorage = $statisticsStorage;
$this->automationValidator = $automationValidator;
$this->updateStepsController = $updateStepsController;
}
public function updateAutomation(int $id, array $data): Automation {
$automation = $this->storage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$this->validateIfAutomationCanBeUpdated($automation, $data);
if (array_key_exists('name', $data)) {
$automation->setName($data['name']);
}
if (array_key_exists('status', $data)) {
$this->checkAutomationStatus($data['status']);
$automation->setStatus($data['status']);
}
if (array_key_exists('steps', $data)) {
$this->validateAutomationSteps($automation, $data['steps']);
$this->updateStepsController->updateSteps($automation, $data['steps']);
foreach ($automation->getSteps() as $step) {
$this->hooks->doAutomationStepBeforeSave($step, $automation);
$this->hooks->doAutomationStepByKeyBeforeSave($step, $automation);
}
}
if (array_key_exists('meta', $data)) {
$automation->deleteAllMetas();
foreach ($data['meta'] as $key => $value) {
$automation->setMeta($key, $value);
}
}
$this->hooks->doAutomationBeforeSave($automation);
$this->automationValidator->validate($automation);
$this->storage->updateAutomation($automation);
$automation = $this->storage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
return $automation;
}
/**
* This is a temporary validation, see MAILPOET-4744
*/
private function validateIfAutomationCanBeUpdated(Automation $automation, array $data): void {
if (
!in_array(
$automation->getStatus(),
[
Automation::STATUS_ACTIVE,
Automation::STATUS_DEACTIVATING,
],
true
)
) {
return;
}
$statistics = $this->statisticsStorage->getAutomationStats($automation->getId());
if ($statistics->getInProgress() === 0) {
return;
}
if (!isset($data['status']) || $data['status'] === $automation->getStatus()) {
throw Exceptions::automationHasActiveRuns($automation->getId());
}
}
private function checkAutomationStatus(string $status): void {
if (!in_array($status, Automation::STATUS_ALL, true)) {
// translators: %s is the status.
throw UnexpectedValueException::create()->withMessage(sprintf(__('Invalid status: %s', 'mailpoet'), $status));
}
}
protected function validateAutomationSteps(Automation $automation, array $steps): void {
$existingSteps = $automation->getSteps();
if (count($steps) !== count($existingSteps)) {
throw Exceptions::automationStructureModificationNotSupported();
}
foreach ($steps as $id => $data) {
$existingStep = $existingSteps[$id] ?? null;
if (!$existingStep || !$this->stepChanged(Step::fromArray($data), $existingStep)) {
throw Exceptions::automationStructureModificationNotSupported();
}
}
}
private function stepChanged(Step $a, Step $b): bool {
$aData = $a->toArray();
$bData = $b->toArray();
unset($aData['args']);
unset($bData['args']);
return $aData === $bData;
}
}
Builder/index.php 0000644 00000000006 15073230056 0007747 0 ustar 00 registry = $registry;
}
public function updateSteps(Automation $automation, array $data): Automation {
$steps = [];
foreach ($data as $index => $stepData) {
$step = $this->processStep($stepData, $automation->getStep($stepData['id']));
$steps[$index] = $step;
}
$automation->setSteps($steps);
return $automation;
}
private function processStep(array $data, ?Step $existingStep): Step {
$key = $data['key'];
$step = $this->registry->getStep($key);
if (!$step && $existingStep && $data !== $existingStep->toArray()) {
throw Exceptions::automationStepNotFound($key);
}
return Step::fromArray($data);
}
}
Builder/DeleteAutomationController.php 0000644 00000001620 15073230056 0014152 0 ustar 00 automationStorage = $automationStorage;
}
public function deleteAutomation(int $id): Automation {
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
if ($automation->getStatus() !== Automation::STATUS_TRASH) {
throw Exceptions::automationNotTrashed($id);
}
$this->automationStorage->deleteAutomation($automation);
return $automation;
}
}
Builder/DuplicateAutomationController.php 0000644 00000005351 15073230056 0014667 0 ustar 00 wordPress = $wordPress;
$this->automationStorage = $automationStorage;
}
public function duplicateAutomation(int $id): Automation {
$automation = $this->automationStorage->getAutomation($id);
if (!$automation) {
throw Exceptions::automationNotFound($id);
}
$duplicate = new Automation(
$this->getName($automation->getName()),
$this->getSteps($automation->getSteps()),
$this->wordPress->wpGetCurrentUser()
);
$duplicate->setStatus(Automation::STATUS_DRAFT);
$automationId = $this->automationStorage->createAutomation($duplicate);
$savedAutomation = $this->automationStorage->getAutomation($automationId);
if (!$savedAutomation) {
throw new InvalidStateException('Automation not found.');
}
return $savedAutomation;
}
private function getName(string $name): string {
// translators: %s is the original automation name.
$newName = sprintf(__('Copy of %s', 'mailpoet'), $name);
$maxLength = $this->automationStorage->getNameColumnLength();
if (strlen($newName) > $maxLength) {
$append = '…';
return substr($newName, 0, $maxLength - strlen($append)) . $append;
}
return $newName;
}
/**
* @param Step[] $steps
* @return Step[]
*/
private function getSteps(array $steps): array {
$newIds = [];
foreach ($steps as $step) {
$id = $step->getId();
$newIds[$id] = $id === 'root' ? 'root' : $this->getId();
}
$newSteps = [];
foreach ($steps as $step) {
$newId = $newIds[$step->getId()];
$newSteps[$newId] = new Step(
$newId,
$step->getType(),
$step->getKey(),
$step->getArgs(),
array_map(function (NextStep $nextStep) use ($newIds): NextStep {
$nextStepId = $nextStep->getId();
return new NextStep($nextStepId ? $newIds[$nextStepId] : null);
}, $step->getNextSteps())
);
}
return $newSteps;
}
private function getId(): string {
return Security::generateRandomString(16);
}
}
Builder/CreateAutomationFromTemplateController.php 0000644 00000002715 15073230056 0016501 0 ustar 00 storage = $storage;
$this->automationValidator = $automationValidator;
$this->registry = $registry;
}
public function createAutomation(string $slug): Automation {
$template = $this->registry->getTemplate($slug);
if (!$template) {
throw Exceptions::automationTemplateNotFound($slug);
}
$automation = $template->createAutomation();
$this->automationValidator->validate($automation);
$automationId = $this->storage->createAutomation($automation);
$savedAutomation = $this->storage->getAutomation($automationId);
if (!$savedAutomation) {
throw new InvalidStateException('Automation not found.');
}
return $savedAutomation;
}
}
Control/StepRunController.php 0000644 00000002142 15073230056 0012341 0 ustar 00 stepScheduler = $stepScheduler;
$this->stepRunArgs = $stepRunArgs;
}
public function scheduleProgress(int $timestamp = null): int {
return $this->stepScheduler->scheduleProgress($this->stepRunArgs, $timestamp);
}
public function scheduleNextStep(int $timestamp = null): int {
return $this->stepScheduler->scheduleNextStep($this->stepRunArgs, $timestamp);
}
public function scheduleNextStepByIndex(int $nextStepIndex, int $timestamp = null): int {
return $this->stepScheduler->scheduleNextStepByIndex($this->stepRunArgs, $nextStepIndex, $timestamp);
}
public function hasScheduledNextStep(): bool {
return $this->stepScheduler->hasScheduledNextStep($this->stepRunArgs);
}
}
Control/index.php 0000644 00000000006 15073230056 0010001 0 ustar 00 registry = $registry;
}
/**
* @param SubjectData[] $subjectData
* @return SubjectEntry>[]
*/
public function getSubjectsEntries(array $subjectData): array {
$subjectEntries = [];
foreach ($subjectData as $data) {
$subjectEntries[] = $this->getSubjectEntry($data);
}
return $subjectEntries;
}
/**
* @param SubjectData $subjectData
* @return SubjectEntry>
*/
public function getSubjectEntry(SubjectData $subjectData): SubjectEntry {
$key = $subjectData->getKey();
$subject = $this->registry->getSubject($key);
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
return new SubjectEntry($subject, $subjectData);
}
}
Control/TriggerHandler.php 0000644 00000007441 15073230056 0011605 0 ustar 00 automationStorage = $automationStorage;
$this->automationRunStorage = $automationRunStorage;
$this->subjectLoader = $subjectLoader;
$this->subjectTransformerHandler = $subjectTransformerHandler;
$this->filterHandler = $filterHandler;
$this->stepScheduler = $stepScheduler;
$this->stepRunLoggerFactory = $stepRunLoggerFactory;
$this->wordPress = $wordPress;
}
public function initialize(): void {
$this->wordPress->addAction(Hooks::TRIGGER, [$this, 'processTrigger'], 10, 2);
}
/** @param Subject[] $subjects */
public function processTrigger(Trigger $trigger, array $subjects): void {
$automations = $this->automationStorage->getActiveAutomationsByTrigger($trigger);
if (!$automations) {
return;
}
// expand all subject transformations and load subject entries
$subjects = $this->subjectTransformerHandler->getAllSubjects($subjects);
$subjectEntries = $this->subjectLoader->getSubjectsEntries($subjects);
foreach ($automations as $automation) {
$step = $automation->getTrigger($trigger->getKey());
if (!$step) {
throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey());
}
$automationRun = new AutomationRun($automation->getId(), $automation->getVersionId(), $trigger->getKey(), $subjects);
$stepRunArgs = new StepRunArgs($automation, $automationRun, $step, $subjectEntries, 1);
$match = false;
try {
$match = $this->filterHandler->matchesFilters($stepRunArgs);
} catch (Exceptions\Exception $e) {
// failed filter evaluation won't match
;
}
if (!$match) {
continue;
}
$createAutomationRun = $trigger->isTriggeredBy($stepRunArgs);
$createAutomationRun = $this->wordPress->applyFilters(
Hooks::AUTOMATION_RUN_CREATE,
$createAutomationRun,
$stepRunArgs
);
if (!$createAutomationRun) {
continue;
}
$automationRunId = $this->automationRunStorage->createAutomationRun($automationRun);
$automationRun->setId($automationRunId);
$this->stepScheduler->scheduleNextStep($stepRunArgs);
$logger = $this->stepRunLoggerFactory->createLogger($automationRunId, $step->getId(), AutomationRunLog::TYPE_TRIGGER, 1);
$logger->logStepData($step);
$logger->logSuccess();
}
}
}
Control/StepScheduler.php 0000644 00000007756 15073230056 0011467 0 ustar 00 actionScheduler = $actionScheduler;
$this->automationRunStorage = $automationRunStorage;
}
public function scheduleProgress(StepRunArgs $args, int $timestamp = null): int {
$runId = $args->getAutomationRun()->getId();
$data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1);
return $this->scheduleStepAction($data, $timestamp);
}
public function scheduleNextStep(StepRunArgs $args, int $timestamp = null): int {
$step = $args->getStep();
$nextSteps = $step->getNextSteps();
// complete the automation run if there are no more steps
if (count($nextSteps) === 0) {
$this->completeAutomationRun($args);
return 0;
}
if (count($nextSteps) > 1) {
throw Exceptions::nextStepNotScheduled($step->getId());
}
return $this->scheduleNextStepByIndex($args, 0, $timestamp);
}
public function scheduleNextStepByIndex(StepRunArgs $args, int $nextStepIndex, int $timestamp = null): int {
$step = $args->getStep();
$nextStep = $step->getNextSteps()[$nextStepIndex] ?? null;
if (!$nextStep) {
throw Exceptions::nextStepNotFound($step->getId(), $nextStepIndex);
}
$runId = $args->getAutomationRun()->getId();
$nextStepId = $nextStep->getId();
if (!$nextStepId) {
$this->completeAutomationRun($args);
return 0;
}
$data = $this->getActionData($runId, $nextStepId);
$id = $this->scheduleStepAction($data, $timestamp);
$this->automationRunStorage->updateNextStep($runId, $nextStepId);
return $id;
}
public function hasScheduledNextStep(StepRunArgs $args): bool {
$runId = $args->getAutomationRun()->getId();
foreach ($args->getStep()->getNextStepIds() as $nextStepId) {
$data = $this->getActionData($runId, $nextStepId);
$hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
if ($hasStep) {
return true;
}
// BC for old steps without run number
unset($data[0]['run_number']);
$hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
if ($hasStep) {
return true;
}
}
return false;
}
public function hasScheduledProgress(StepRunArgs $args): bool {
$runId = $args->getAutomationRun()->getId();
$data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1);
return $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data);
}
public function hasScheduledStep(StepRunArgs $args): bool {
return $this->hasScheduledNextStep($args) || $this->hasScheduledProgress($args);
}
private function scheduleStepAction(array $data, int $timestamp = null): int {
return $timestamp === null
? $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, $data)
: $this->actionScheduler->schedule($timestamp, Hooks::AUTOMATION_STEP, $data);
}
private function completeAutomationRun(StepRunArgs $args): void {
$runId = $args->getAutomationRun()->getId();
$this->automationRunStorage->updateNextStep($runId, null);
$this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE);
}
private function getActionData(int $runId, string $stepId, int $runNumber = 1): array {
return [
[
'automation_run_id' => $runId,
'step_id' => $stepId,
'run_number' => $runNumber,
],
];
}
}
Control/StepHandler.php 0000644 00000016734 15073230056 0011122 0 ustar 00 subjectLoader = $subjectLoader;
$this->wordPress = $wordPress;
$this->automationRunStorage = $automationRunStorage;
$this->automationStorage = $automationStorage;
$this->registry = $registry;
$this->stepRunControllerFactory = $stepRunControllerFactory;
$this->stepRunLoggerFactory = $stepRunLoggerFactory;
$this->stepScheduler = $stepScheduler;
}
public function initialize(): void {
$this->wordPress->addAction(Hooks::AUTOMATION_STEP, [$this, 'handle']);
}
/** @param mixed $args */
public function handle($args): void {
// TODO: better args validation
if (!is_array($args) || !isset($args['automation_run_id']) || !array_key_exists('step_id', $args)) {
throw new InvalidStateException();
}
$runId = (int)$args['automation_run_id'];
$stepId = (string)$args['step_id'];
$runNumber = (int)($args['run_number'] ?? 1);
// BC — complete automation run if "step_id" is empty (was nullable in the past)
if (!$stepId) {
$this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE);
return;
}
$logger = $this->stepRunLoggerFactory->createLogger($runId, $stepId, AutomationRunLog::TYPE_ACTION, $runNumber);
$logger->logStart();
try {
$this->handleStep($runId, $stepId, $runNumber, $logger);
} catch (Throwable $e) {
$status = $e instanceof InvalidStateException && $e->getErrorCode() === 'mailpoet_automation_not_active'
? AutomationRun::STATUS_CANCELLED
: AutomationRun::STATUS_FAILED;
$this->automationRunStorage->updateStatus((int)$args['automation_run_id'], $status);
$logger->logFailure($e);
// Action Scheduler catches only Exception instances, not other errors.
// We need to convert them to exceptions to be processed and logged.
if (!$e instanceof Exception) {
throw new Exception($e->getMessage(), intval($e->getCode()), $e);
}
throw $e;
} finally {
$this->postProcessAutomationRun($runId);
}
}
private function handleStep(int $runId, string $stepId, int $runNumber, StepRunLogger $logger): void {
$automationRun = $this->automationRunStorage->getAutomationRun($runId);
if (!$automationRun) {
throw Exceptions::automationRunNotFound($runId);
}
if ($automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) {
throw Exceptions::automationRunNotRunning($runId, $automationRun->getStatus());
}
$automation = $this->automationStorage->getAutomation($automationRun->getAutomationId(), $automationRun->getVersionId());
if (!$automation) {
throw Exceptions::automationVersionNotFound($automationRun->getAutomationId(), $automationRun->getVersionId());
}
if (!in_array($automation->getStatus(), [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true)) {
throw Exceptions::automationNotActive($automationRun->getAutomationId());
}
$stepData = $automation->getStep($stepId);
if (!$stepData) {
throw Exceptions::automationStepNotFound($stepId);
}
$logger->logStepData($stepData);
$step = $this->registry->getStep($stepData->getKey());
if (!$step instanceof Action) {
throw new InvalidStateException();
}
$requiredSubjects = $step->getSubjectKeys();
$subjectEntries = $this->getSubjectEntries($automationRun, $requiredSubjects);
$args = new StepRunArgs($automation, $automationRun, $stepData, $subjectEntries, $runNumber);
$validationArgs = new StepValidationArgs($automation, $stepData, array_map(function (SubjectEntry $entry) {
return $entry->getSubject();
}, $subjectEntries));
$step->validate($validationArgs);
$step->run($args, $this->stepRunControllerFactory->createController($args));
// schedule next step if not scheduled by action
if (!$this->stepScheduler->hasScheduledStep($args)) {
$this->stepScheduler->scheduleNextStep($args);
}
// logging
if ($this->stepScheduler->hasScheduledProgress($args)) {
$logger->logProgress();
} else {
$logger->logSuccess();
}
}
/** @return SubjectEntry>[] */
private function getSubjectEntries(AutomationRun $automationRun, array $requiredSubjectKeys): array {
$subjectDataMap = [];
foreach ($automationRun->getSubjects() as $data) {
$subjectDataMap[$data->getKey()] = array_merge($subjectDataMap[$data->getKey()] ?? [], [$data]);
}
$subjectEntries = [];
foreach ($requiredSubjectKeys as $key) {
$subjectData = $subjectDataMap[$key] ?? null;
if (!$subjectData) {
throw Exceptions::subjectDataNotFound($key, $automationRun->getId());
}
}
foreach ($subjectDataMap as $subjectData) {
foreach ($subjectData as $data) {
$subjectEntries[] = $this->subjectLoader->getSubjectEntry($data);
}
}
return $subjectEntries;
}
private function postProcessAutomationRun(int $automationRunId): void {
$automationRun = $this->automationRunStorage->getAutomationRun($automationRunId);
if (!$automationRun) {
return;
}
$automation = $this->automationStorage->getAutomation($automationRun->getAutomationId());
if (!$automation) {
return;
}
$this->postProcessAutomation($automation);
}
private function postProcessAutomation(Automation $automation): void {
if ($automation->getStatus() === Automation::STATUS_DEACTIVATING) {
$activeRuns = $this->automationRunStorage->getCountForAutomation($automation, AutomationRun::STATUS_RUNNING);
// Set a deactivating Automation to draft once all automation runs are finished.
if ($activeRuns === 0) {
$automation->setStatus(Automation::STATUS_DRAFT);
$this->automationStorage->updateAutomation($automation);
}
}
}
}
Control/SubjectTransformerHandler.php 0000644 00000005510 15073230056 0014017 0 ustar 00 registry = $registry;
}
public function getSubjectKeysForAutomation(Automation $automation): array {
$triggerData = array_values(array_filter(
$automation->getSteps(),
function (StepData $step): bool {
return $step->getType() === StepData::TYPE_TRIGGER;
}
));
$triggers = array_filter(array_map(
function (StepData $step): ?Trigger {
return $this->registry->getTrigger($step->getKey());
},
$triggerData
));
$all = [];
foreach ($triggers as $trigger) {
$all[] = $this->getSubjectKeysForTrigger($trigger);
}
$all = count($all) > 1 ? array_intersect(...$all) : $all[0] ?? [];
return array_values(array_unique($all));
}
public function getSubjectKeysForTrigger(Trigger $trigger): array {
$transformerMap = $this->getTransformerMap();
$all = $trigger->getSubjectKeys();
$queue = $all;
while ($key = array_shift($queue)) {
foreach ($transformerMap[$key] ?? [] as $transformer) {
$newKey = $transformer->returns();
if (!in_array($newKey, $all, true)) {
$all[] = $newKey;
$queue[] = $newKey;
}
}
}
sort($all);
return $all;
}
/**
* @param Subject[] $subjects
* @return Subject[]
*/
public function getAllSubjects(array $subjects): array {
$transformerMap = $this->getTransformerMap();
$all = [];
foreach ($subjects as $subject) {
$all[$subject->getKey()] = $subject;
}
$queue = array_keys($all);
while ($key = array_shift($queue)) {
foreach ($transformerMap[$key] ?? [] as $transformer) {
$newKey = $transformer->returns();
if (!isset($all[$newKey])) {
$newSubject = $transformer->transform($all[$key]);
if (!$newSubject) {
continue;
}
$all[$newKey] = $newSubject;
$queue[] = $newKey;
}
}
}
return array_values($all);
}
/**
* @return SubjectTransformer[][]
*/
private function getTransformerMap(): array {
$transformerMap = [];
foreach ($this->registry->getSubjectTransformers() as $transformer) {
$transformerMap[$transformer->accepts()] = array_merge($transformerMap[$transformer->accepts()] ?? [], [$transformer]);
}
return $transformerMap;
}
}
Control/StepRunLoggerFactory.php 0000644 00000001433 15073230056 0012767 0 ustar 00 automationRunLogStorage = $automationRunLogStorage;
$this->hooks = $hooks;
}
public function createLogger(int $runId, string $stepId, string $stepType, int $runNumber): StepRunLogger {
return new StepRunLogger($this->automationRunLogStorage, $this->hooks, $runId, $stepId, $stepType, $runNumber);
}
}
Control/StepRunControllerFactory.php 0000644 00000001002 15073230056 0013663 0 ustar 00 stepScheduler = $stepScheduler;
}
public function createController(StepRunArgs $args): StepRunController {
return new StepRunController($this->stepScheduler, $args);
}
}
Control/ActionScheduler.php 0000644 00000001201 15073230056 0011744 0 ustar 00 automationRunLogStorage = $automationRunLogStorage;
$this->hooks = $hooks;
$this->runId = $runId;
$this->stepId = $stepId;
$this->stepType = $stepType;
$this->runNumber = $runNumber;
$this->isWpDebug = $isWpDebug !== null ? $isWpDebug : $this->getWpDebug();
}
private function getWpDebug(): bool {
if (!defined('WP_DEBUG')) {
return false;
}
if (!is_bool(WP_DEBUG)) {
return in_array(strtolower((string)WP_DEBUG), ['true', '1'], true);
}
return WP_DEBUG;
}
public function logStart(): void {
$this->getLog();
}
public function logStepData(Step $step): void {
$log = $this->getLog();
$log->setStepKey($step->getKey());
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logProgress(): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_RUNNING);
$log->setUpdatedAt(new DateTimeImmutable());
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logSuccess(): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_COMPLETE);
$log->setUpdatedAt(new DateTimeImmutable());
$this->triggerAfterRunHook($log);
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
public function logFailure(Throwable $error): void {
$log = $this->getLog();
$log->setStatus(AutomationRunLog::STATUS_FAILED);
$log->setError($error);
$log->setUpdatedAt(new DateTimeImmutable());
$this->triggerAfterRunHook($log);
$this->automationRunLogStorage->updateAutomationRunLog($log);
}
private function getLog(): AutomationRunLog {
if (!$this->log) {
$this->log = $this->automationRunLogStorage->getAutomationRunLogByRunAndStepId($this->runId, $this->stepId);
}
if (!$this->log) {
$log = new AutomationRunLog($this->runId, $this->stepId, $this->stepType);
$log->setRunNumber($this->runNumber);
$id = $this->automationRunLogStorage->createAutomationRunLog($log);
$this->log = $this->automationRunLogStorage->getAutomationRunLog($id);
}
if (!$this->log) {
throw new InvalidStateException('Failed to create automation run log');
}
$this->log->setRunNumber($this->runNumber);
return $this->log;
}
private function triggerAfterRunHook(AutomationRunLog $log): void {
try {
$this->hooks->doAutomationStepAfterRun($log);
} catch (Throwable $e) {
if ($this->isWpDebug) {
throw $e;
}
// ignore integration logging errors
}
}
}
Control/FilterHandler.php 0000644 00000003617 15073230056 0011430 0 ustar 00 registry = $registry;
}
public function matchesFilters(StepRunArgs $args): bool {
$filters = $args->getStep()->getFilters();
if (!$filters) {
return true;
}
$operator = $filters->getOperator();
foreach ($filters->getGroups() as $group) {
$matches = $this->matchesGroup($group, $args);
if ($operator === Filters::OPERATOR_AND && !$matches) {
return false;
}
if ($operator === Filters::OPERATOR_OR && $matches) {
return true;
}
}
return $operator === Filters::OPERATOR_AND;
}
public function matchesGroup(FilterGroup $group, StepRunArgs $args): bool {
$operator = $group->getOperator();
foreach ($group->getFilters() as $filter) {
$value = $args->getFieldValue($filter->getFieldKey());
$matches = $this->matchesFilter($filter, $value);
if ($operator === FilterGroup::OPERATOR_AND && !$matches) {
return false;
}
if ($operator === FilterGroup::OPERATOR_OR && $matches) {
return true;
}
}
return $operator === FilterGroup::OPERATOR_AND;
}
/** @param mixed $value */
private function matchesFilter(FilterData $data, $value): bool {
$filter = $this->registry->getFilter($data->getFieldType());
if (!$filter) {
throw Exceptions::filterNotFound($data->getFieldType());
}
return $filter->matches($data, $value);
}
}
Storage/AutomationStorage.php 0000644 00000035777 15073230056 0012352 0 ustar 00 automationsTable = $wpdb->prefix . 'mailpoet_automations';
$this->versionsTable = $wpdb->prefix . 'mailpoet_automation_versions';
$this->triggersTable = $wpdb->prefix . 'mailpoet_automation_triggers';
$this->runsTable = $wpdb->prefix . 'mailpoet_automation_runs';
$this->subjectsTable = $wpdb->prefix . 'mailpoet_automation_run_subjects';
$this->wpdb = $wpdb;
}
public function createAutomation(Automation $automation): int {
$automationHeaderData = $this->getAutomationHeaderData($automation);
unset($automationHeaderData['id']);
$result = $this->wpdb->insert($this->automationsTable, $automationHeaderData);
if (!$result) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$id = $this->wpdb->insert_id;
$this->insertAutomationVersion($id, $automation);
$this->insertAutomationTriggers($id, $automation);
return $id;
}
public function updateAutomation(Automation $automation): void {
$oldRecord = $this->getAutomation($automation->getId());
if ($oldRecord && $oldRecord->equals($automation)) {
return;
}
$result = $this->wpdb->update($this->automationsTable, $this->getAutomationHeaderData($automation), ['id' => $automation->getId()]);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$this->insertAutomationVersion($automation->getId(), $automation);
$this->insertAutomationTriggers($automation->getId(), $automation);
}
/**
* @param int $automationId
* @return VersionDate[]
* @throws \Exception
*/
public function getAutomationVersionDates(int $automationId): array {
$versionsTable = esc_sql($this->versionsTable);
/** @var literal-string $sql */
$sql = "
SELECT id, created_at
FROM $versionsTable
WHERE automation_id = %d
ORDER BY id DESC
";
$query = (string)$this->wpdb->prepare($sql, $automationId);
$data = $this->wpdb->get_results($query, ARRAY_A);
return is_array($data) ? array_map(
function($row): array {
/** @var array{id: string, created_at: string} $row */
return [
'id' => absint($row['id']),
'created_at' => new \DateTimeImmutable($row['created_at']),
];
},
$data
) : [];
}
/**
* @param int[] $versionIds
* @return Automation[]
*/
public function getAutomationWithDifferentVersions(array $versionIds): array {
$versionIds = array_map('intval', $versionIds);
if (!$versionIds) {
return [];
}
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
/** @var literal-string $sql */
$sql = "
SELECT a.*, v.id AS version_id, v.steps
FROM $automationsTable as a, $versionsTable as v
WHERE v.automation_id = a.id AND v.id IN (" . implode(',', array_fill(0, count($versionIds), '%d')) . ")
ORDER BY v.id DESC
";
$query = (string)$this->wpdb->prepare($sql, ...$versionIds);
$data = $this->wpdb->get_results($query, ARRAY_A);
return is_array($data) ? array_map(
function($row): Automation {
return Automation::fromArray((array)$row);
},
$data
) : [];
}
public function getAutomation(int $automationId, int $versionId = null): ?Automation {
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
if ($versionId) {
$automations = $this->getAutomationWithDifferentVersions([$versionId]);
return $automations ? $automations[0] : null;
}
/** @var literal-string $sql */
$sql = "
SELECT a.*, v.id AS version_id, v.steps
FROM $automationsTable as a, $versionsTable as v
WHERE v.automation_id = a.id AND a.id = %d
ORDER BY v.id DESC
LIMIT 1
";
$query = (string)$this->wpdb->prepare($sql, $automationId);
$data = $this->wpdb->get_row($query, ARRAY_A);
return $data ? Automation::fromArray((array)$data) : null;
}
/** @return Automation[] */
public function getAutomations(array $status = null): array {
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
$statusFilter = $status ? 'AND a.status IN(' . implode(',', array_fill(0, count($status), '%s')) . ')' : '';
/** @var literal-string $sql */
$sql = "
SELECT a.*, v.id AS version_id, v.steps
FROM $automationsTable AS a
INNER JOIN $versionsTable as v ON (v.automation_id = a.id)
WHERE v.id = (
SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id
)
$statusFilter
ORDER BY a.id DESC
";
$query = $status ? (string)$this->wpdb->prepare($sql, ...$status) : $sql;
$data = $this->wpdb->get_results($query, ARRAY_A);
return array_map(function ($automationData) {
/** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return Automation::fromArray($automationData);
}, (array)$data);
}
/** @return Automation[] */
public function getAutomationsBySubject(Subject $subject, array $runStatus = null): array {
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
$runsTable = esc_sql($this->runsTable);
$subjectTable = esc_sql($this->subjectsTable);
$statusFilter = $runStatus ? 'AND r.status IN(' . implode(',', array_fill(0, count($runStatus), '%s')) . ')' : '';
/** @var literal-string $sql */
$sql = "
SELECT DISTINCT a.*, v.id AS version_id, v.steps
FROM $automationsTable a
INNER JOIN $versionsTable v ON v.automation_id = a.id
INNER JOIN $runsTable r ON r.automation_id = a.id
INNER JOIN $subjectTable s ON s.automation_run_id = r.id
WHERE v.id = (
SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id
)
AND s.hash = %s
$statusFilter
ORDER BY a.id DESC
";
$query = (string)$this->wpdb->prepare($sql, ...array_merge([$subject->getHash()], $runStatus ?? []));
$data = $this->wpdb->get_results($query, ARRAY_A);
return array_map(function ($automationData) {
/** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return Automation::fromArray($automationData);
}, (array)$data);
}
public function getAutomationCount(): int {
$automationsTable = esc_sql($this->automationsTable);
return (int)$this->wpdb->get_var("SELECT COUNT(*) FROM $automationsTable");
}
/** @return string[] */
public function getActiveTriggerKeys(): array {
$automationsTable = esc_sql($this->automationsTable);
$triggersTable = esc_sql($this->triggersTable);
/** @var literal-string $sql */
$sql = "
SELECT DISTINCT t.trigger_key
FROM {$automationsTable} AS a
JOIN $triggersTable as t
WHERE a.status = %s AND a.id = t.automation_id
ORDER BY trigger_key DESC
";
$query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE);
return $this->wpdb->get_col($query);
}
/** @return Automation[] */
public function getActiveAutomationsByTrigger(Trigger $trigger): array {
return $this->getActiveAutomationsByTriggerKey($trigger->getKey());
}
public function getActiveAutomationsByTriggerKey(string $triggerKey): array {
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
$triggersTable = esc_sql($this->triggersTable);
/** @var literal-string $sql */
$sql = "
SELECT a.*, v.id AS version_id, v.steps
FROM $automationsTable AS a
INNER JOIN $triggersTable as t ON (t.automation_id = a.id)
INNER JOIN $versionsTable as v ON (v.automation_id = a.id)
WHERE a.status = %s
AND t.trigger_key = %s
AND v.id = (
SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id
)
";
$query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE, $triggerKey);
$data = $this->wpdb->get_results($query, ARRAY_A);
return array_map(function ($automationData) {
/** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return Automation::fromArray($automationData);
}, (array)$data);
}
public function getCountOfActiveByTriggerKeysAndAction(array $triggerKeys, string $actionKey): int {
$automationsTable = esc_sql($this->automationsTable);
$versionsTable = esc_sql($this->versionsTable);
$triggersTable = esc_sql($this->triggersTable);
$triggerKeysPlaceholders = implode(',', array_fill(0, count($triggerKeys), '%s'));
$queryArgs = array_merge(
$triggerKeys,
[
Automation::STATUS_ACTIVE,
'%"' . $this->wpdb->esc_like($actionKey) . '"%',
]
);
// Using the phpcs:ignore because the query arguments count is dynamic and passed via an array but the code sniffer sees only one argument
/** @var literal-string $sql */
$sql = "
SELECT count(*)
FROM $automationsTable AS a
INNER JOIN $triggersTable as t ON (t.automation_id = a.id) AND t.trigger_key IN ({$triggerKeysPlaceholders})
INNER JOIN $versionsTable as v ON v.id = (SELECT MAX(id) FROM $versionsTable WHERE automation_id = a.id)
WHERE a.status = %s
AND v.steps LIKE %s
";
$query = (string)$this->wpdb->prepare($sql, $queryArgs);
return (int)$this->wpdb->get_var($query);
}
public function deleteAutomation(Automation $automation): void {
$automationRunsTable = esc_sql($this->runsTable);
$automationRunLogsTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_run_logs');
$automationId = $automation->getId();
/** @var literal-string $sql */
$sql = "
DELETE FROM $automationRunLogsTable
WHERE automation_run_id IN (
SELECT id
FROM $automationRunsTable
WHERE automation_id = %d
)
";
$runLogsQuery = (string)$this->wpdb->prepare($sql, $automationId);
$logsDeleted = $this->wpdb->query($runLogsQuery);
if ($logsDeleted === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$runsDeleted = $this->wpdb->delete($this->runsTable, ['automation_id' => $automationId]);
if ($runsDeleted === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$versionsDeleted = $this->wpdb->delete($this->versionsTable, ['automation_id' => $automationId]);
if ($versionsDeleted === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$triggersDeleted = $this->wpdb->delete($this->triggersTable, ['automation_id' => $automationId]);
if ($triggersDeleted === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$automationDeleted = $this->wpdb->delete($this->automationsTable, ['id' => $automationId]);
if ($automationDeleted === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
public function truncate(): void {
$automationsTable = esc_sql($this->automationsTable);
$result = $this->wpdb->query("TRUNCATE {$automationsTable}");
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$versionsTable = esc_sql($this->versionsTable);
$result = $this->wpdb->query("TRUNCATE {$versionsTable}");
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$triggersTable = esc_sql($this->triggersTable);
$result = $this->wpdb->query("TRUNCATE {$triggersTable}");
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
public function getNameColumnLength(): int {
$nameColumnLengthInfo = $this->wpdb->get_col_length($this->automationsTable, 'name');
return is_array($nameColumnLengthInfo)
? $nameColumnLengthInfo['length'] ?? 255
: 255;
}
private function getAutomationHeaderData(Automation $automation): array {
$automationHeader = $automation->toArray();
unset($automationHeader['steps']);
return $automationHeader;
}
private function insertAutomationVersion(int $automationId, Automation $automation): void {
$dateString = (new DateTimeImmutable())->format(DateTimeImmutable::W3C);
$data = [
'automation_id' => $automationId,
'steps' => $automation->toArray()['steps'],
'created_at' => $dateString,
'updated_at' => $dateString,
];
$result = $this->wpdb->insert($this->versionsTable, $data);
if (!$result) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
private function insertAutomationTriggers(int $automationId, Automation $automation): void {
$triggerKeys = [];
foreach ($automation->getSteps() as $step) {
if ($step->getType() === Step::TYPE_TRIGGER) {
$triggerKeys[] = $step->getKey();
}
}
$triggersTable = esc_sql($this->triggersTable);
// insert/update
if ($triggerKeys) {
$placeholders = implode(',', array_fill(0, count($triggerKeys), '(%d, %s)'));
/** @var literal-string $sql */
$sql = "INSERT IGNORE INTO {$triggersTable} (automation_id, trigger_key) VALUES {$placeholders}";
$query = (string)$this->wpdb->prepare(
$sql,
array_merge(
...array_map(function (string $key) use ($automationId) {
return [$automationId, $key];
}, $triggerKeys)
)
);
$result = $this->wpdb->query($query);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
// delete
$placeholders = implode(',', array_fill(0, count($triggerKeys), '%s'));
if ($triggerKeys) {
/** @var literal-string $sql */
$sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d AND trigger_key NOT IN ({$placeholders})";
$query = (string)$this->wpdb->prepare($sql, array_merge([$automationId], $triggerKeys));
} else {
/** @var literal-string $sql */
$sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d";
$query = (string)$this->wpdb->prepare($sql, $automationId);
}
$result = $this->wpdb->query($query);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
}
Storage/index.php 0000644 00000000006 15073230056 0007765 0 ustar 00 table = $wpdb->prefix . 'mailpoet_automation_run_logs';
$this->wpdb = $wpdb;
}
public function createAutomationRunLog(AutomationRunLog $automationRunLog): int {
$result = $this->wpdb->insert($this->table, $automationRunLog->toArray());
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
return $this->wpdb->insert_id;
}
public function updateAutomationRunLog(AutomationRunLog $automationRunLog): void {
$result = $this->wpdb->update($this->table, $automationRunLog->toArray(), ['id' => $automationRunLog->getId()]);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
public function getAutomationRunStatisticsForAutomationInTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array {
$logTable = esc_sql($this->table);
$runTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_runs');
$whereCondition = 'run.automation_id = %d
AND log.status = %s
AND run.created_at BETWEEN %s AND %s';
if ($versionId !== null) {
$whereCondition .= ' AND run.version_id = %d';
}
/** @var literal-string $sql */
$sql = "SELECT count(log.id) as `count`, log.step_id FROM $logTable AS log
JOIN $runTable AS run ON log.automation_run_id = run.id
WHERE $whereCondition
GROUP BY log.step_id";
$sql = $versionId ? $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId)
: $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'));
$sql = is_string($sql) ? $sql : "";
$results = $this->wpdb->get_results($sql, ARRAY_A);
return is_array($results) ? $results : [];
}
public function getAutomationRunLog(int $id): ?AutomationRunLog {
$table = esc_sql($this->table);
/** @var literal-string $sql */
$sql = "SELECT * FROM $table WHERE id = %d";
$query = $this->wpdb->prepare($sql, $id);
if (!is_string($query)) {
throw InvalidStateException::create();
}
$result = $this->wpdb->get_row($query, ARRAY_A);
if ($result) {
$data = (array)$result;
return AutomationRunLog::fromArray($data);
}
return null;
}
public function getAutomationRunLogByRunAndStepId(int $runId, string $stepId): ?AutomationRunLog {
$table = esc_sql($this->table);
/** @var literal-string $sql */
$sql = "SELECT * FROM $table WHERE automation_run_id = %d AND step_id = %s";
$query = $this->wpdb->prepare($sql, $runId, $stepId);
if (!is_string($query)) {
throw InvalidStateException::create();
}
$result = $this->wpdb->get_row($query, ARRAY_A);
return $result ? AutomationRunLog::fromArray((array)$result) : null;
}
/**
* @param int $automationRunId
* @return AutomationRunLog[]
* @throws InvalidStateException
*/
public function getLogsForAutomationRun(int $automationRunId): array {
$table = esc_sql($this->table);
/** @var literal-string $sql */
$sql = "
SELECT *
FROM $table
WHERE automation_run_id = %d
ORDER BY id ASC
";
$query = $this->wpdb->prepare($sql, $automationRunId);
if (!is_string($query)) {
throw InvalidStateException::create();
}
$results = $this->wpdb->get_results($query, ARRAY_A);
if (!is_array($results)) {
throw InvalidStateException::create();
}
if ($results) {
return array_map(function($data) {
/** @var array $data - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */
return AutomationRunLog::fromArray($data);
}, $results);
}
return [];
}
public function truncate(): void {
$table = esc_sql($this->table);
$sql = "TRUNCATE $table";
$this->wpdb->query($sql);
}
}
Storage/AutomationRunStorage.php 0000644 00000017556 15073230056 0013032 0 ustar 00 table = $wpdb->prefix . 'mailpoet_automation_runs';
$this->subjectTable = $wpdb->prefix . 'mailpoet_automation_run_subjects';
$this->wpdb = $wpdb;
}
public function createAutomationRun(AutomationRun $automationRun): int {
$automationTableData = $automationRun->toArray();
$subjectTableData = $automationTableData['subjects'];
unset($automationTableData['subjects']);
$result = $this->wpdb->insert($this->table, $automationTableData);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
$automationRunId = $this->wpdb->insert_id;
if (!$subjectTableData) {
//We allow for AutomationRuns with no subjects.
return $automationRunId;
}
$sql = 'insert into ' . esc_sql($this->subjectTable) . ' (`automation_run_id`, `key`, `args`, `hash`) values %s';
$values = [];
foreach ($subjectTableData as $entry) {
$values[] = (string)$this->wpdb->prepare("(%d,%s,%s,%s)", $automationRunId, $entry['key'], $entry['args'], $entry['hash']);
}
$sql = sprintf($sql, implode(',', $values));
$result = $this->wpdb->query($sql);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
return $automationRunId;
}
public function getAutomationRun(int $id): ?AutomationRun {
$table = esc_sql($this->table);
$subjectTable = esc_sql($this->subjectTable);
/** @var literal-string $sql */
$sql = "SELECT * FROM $table WHERE id = %d";
$query = (string)$this->wpdb->prepare($sql, $id);
$data = $this->wpdb->get_row($query, ARRAY_A);
if (!is_array($data) || !$data) {
return null;
}
/** @var literal-string $sql */
$sql = "SELECT * FROM $subjectTable WHERE automation_run_id = %d";
$query = (string)$this->wpdb->prepare($sql, $id);
$subjects = $this->wpdb->get_results($query, ARRAY_A);
$data['subjects'] = is_array($subjects) ? $subjects : [];
return AutomationRun::fromArray((array)$data);
}
/**
* @param Automation $automation
* @return AutomationRun[]
*/
public function getAutomationRunsForAutomation(Automation $automation): array {
$table = esc_sql($this->table);
$subjectTable = esc_sql($this->subjectTable);
/** @var literal-string $sql */
$sql = "SELECT * FROM $table WHERE automation_id = %d order by id";
$query = (string)$this->wpdb->prepare($sql, $automation->getId());
$automationRuns = $this->wpdb->get_results($query, ARRAY_A);
if (!is_array($automationRuns) || !$automationRuns) {
return [];
}
$automationRunIds = array_column($automationRuns, 'id');
/** @var literal-string $sql */
$sql = sprintf(
"SELECT * FROM $subjectTable WHERE automation_run_id in (%s) order by automation_run_id, id",
implode(
',',
array_map(
function() {
return '%d';
},
$automationRunIds
)
)
);
$query = (string)$this->wpdb->prepare($sql, ...$automationRunIds);
$subjects = $this->wpdb->get_results($query, ARRAY_A);
return array_map(
function($runData) use ($subjects): AutomationRun {
/** @var array $runData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */
$runData['subjects'] = array_values(array_filter(
is_array($subjects) ? $subjects : [],
function($subjectData) use ($runData): bool {
/** @var array $subjectData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */
return (int)$subjectData['automation_run_id'] === (int)$runData['id'];
}
));
return AutomationRun::fromArray($runData);
},
$automationRuns
);
}
/**
* @param Automation $automation
* @return int
*/
public function getCountByAutomationAndSubject(Automation $automation, Subject $subject): int {
$table = esc_sql($this->table);
$subjectTable = esc_sql($this->subjectTable);
/** @var literal-string $sql */
$sql = "SELECT count(DISTINCT runs.id) as count from $table as runs
JOIN $subjectTable as subjects on runs.id = subjects.automation_run_id
WHERE runs.automation_id = %d
AND subjects.hash = %s";
$result = $this->wpdb->get_col(
(string)$this->wpdb->prepare($sql, $automation->getId(), $subject->getHash())
);
return $result ? (int)current($result) : 0;
}
public function getCountForAutomation(Automation $automation, string ...$status): int {
$table = esc_sql($this->table);
if (!count($status)) {
/** @var literal-string $sql */
$sql = "
SELECT COUNT(id) as count
FROM $table
WHERE automation_id = %d
";
$query = (string)$this->wpdb->prepare($sql, $automation->getId());
$result = $this->wpdb->get_col($query);
return $result ? (int)current($result) : 0;
}
$statusSql = (string)$this->wpdb->prepare(implode(',', array_fill(0, count($status), '%s')), ...$status);
/** @var literal-string $sql */
$sql = "
SELECT COUNT(id) as count
FROM $table
WHERE automation_id = %d
AND status IN ($statusSql)
";
$query = (string)$this->wpdb->prepare($sql, $automation->getId());
$result = $this->wpdb->get_col($query);
return $result ? (int)current($result) : 0;
}
public function updateStatus(int $id, string $status): void {
$table = esc_sql($this->table);
/** @var literal-string $sql */
$sql = "
UPDATE $table
SET status = %s, updated_at = current_timestamp()
WHERE id = %d
";
$query = (string)$this->wpdb->prepare($sql, $status, $id);
$result = $this->wpdb->query($query);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
public function updateNextStep(int $id, ?string $nextStepId): void {
$table = esc_sql($this->table);
/** @var literal-string $sql */
$sql = "
UPDATE $table
SET next_step_id = %s, updated_at = current_timestamp()
WHERE id = %d
";
$query = (string)$this->wpdb->prepare($sql, $nextStepId, $id);
$result = $this->wpdb->query($query);
if ($result === false) {
throw Exceptions::databaseError($this->wpdb->last_error);
}
}
public function getAutomationStepStatisticForTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array {
$table = esc_sql($this->table);
$where = "automation_id = %d
AND `status` = %s
AND created_at BETWEEN %s AND %s";
if ($versionId) {
$where .= " AND version_id = %d";
}
/** @var literal-string $sql */
$sql = "
SELECT
COUNT(id) AS `count`,
next_step_id
FROM $table as log
WHERE $where
GROUP BY next_step_id
";
$sql = $versionId ?
$this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId) :
$this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'));
$sql = is_string($sql) ? $sql : '';
$result = $this->wpdb->get_results($sql, ARRAY_A);
return is_array($result) ? $result : [];
}
public function truncate(): void {
$table = esc_sql($this->table);
$this->wpdb->query("TRUNCATE $table");
$table = esc_sql($this->subjectTable);
$this->wpdb->query("TRUNCATE $table");
}
}
Storage/AutomationStatisticsStorage.php 0000644 00000007535 15073230056 0014414 0 ustar 00 table = $wpdb->prefix . 'mailpoet_automation_runs';
$this->wpdb = $wpdb;
}
/**
* @param Automation ...$automations
* @return AutomationStatistics[]
*/
public function getAutomationStatisticsForAutomations(Automation ...$automations): array {
if (empty($automations)) {
return [];
}
$automationIds = array_map(
function(Automation $automation): int {
return $automation->getId();
},
$automations
);
$data = $this->getStatistics($automationIds);
$statistics = [];
foreach ($automationIds as $id) {
$statistics[$id] = new AutomationStatistics(
$id,
(int)($data[$id]['total'] ?? 0),
(int)($data[$id]['running'] ?? 0)
);
}
return $statistics;
}
public function getAutomationStats(int $automationId, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): AutomationStatistics {
$data = $this->getStatistics([$automationId], $versionId, $after, $before);
return new AutomationStatistics(
$automationId,
(int)($data[$automationId]['total'] ?? 0),
(int)($data[$automationId]['running'] ?? 0),
$versionId
);
}
/**
* @param int[] $automationIds
* @return array
*/
private function getStatistics(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): array {
$totalSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before);
$runningSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before, AutomationRun::STATUS_RUNNING);
// The subqueries are created using $wpdb->prepare().
// phpcs:ignore WordPressDotOrg.sniffs.DirectDB.UnescapedDBParameter
$results = (array)$this->wpdb->get_results("
SELECT t.id, t.count AS total, r.count AS running
FROM ($totalSubquery) t
LEFT JOIN ($runningSubquery) r ON t.id = r.id
", ARRAY_A);
/** @var array{id: int, total: int, running: int} $results */
return array_combine(array_column($results, 'id'), $results) ?: [];
}
private function getStatsQuery(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null, string $status = null): string {
$table = esc_sql($this->table);
$placeholders = implode(',', array_fill(0, count($automationIds), '%d'));
/** @var string $versionCondition */
$versionCondition = $versionId ? $this->wpdb->prepare('AND version_id = %d', $versionId) : '';
$versionCondition = strval($versionCondition);
/** @var string $statusCondition */
$statusCondition = $status ? $this->wpdb->prepare('AND status = %s', $status) : '';
$statusCondition = strval($statusCondition);
/** @var string $dateCondition */
$dateCondition = $after !== null && $before !== null ? $this->wpdb->prepare('AND created_at BETWEEN %s AND %s', $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')) : '';
$dateCondition = strval($dateCondition);
/** @var literal-string $sql */
$sql = "
SELECT automation_id AS id, COUNT(*) AS count
FROM $table
WHERE automation_id IN ($placeholders)
$versionCondition
$statusCondition
$dateCondition
GROUP BY automation_id
";
/** @var string $query */
$query = $this->wpdb->prepare($sql, $automationIds);
return strval($query);
}
}
Registry.php 0000644 00000017107 15073230056 0007074 0 ustar 00 */
private $templates;
/** @var array */
private $templateCategories;
/** @var array */
private $steps = [];
/** @var array> */
private $subjects = [];
/** @var SubjectTransformer[] */
private $subjectTransformers = [];
/** @var array|null */
private $fields = null;
/** @var array */
private $filters = [];
/** @var array */
private $triggers = [];
/** @var array */
private $actions = [];
/** @var array */
private $contextFactories = [];
/** @var WordPress */
private $wordPress;
public function __construct(
RootStep $rootStep,
WordPress $wordPress
) {
$this->wordPress = $wordPress;
$this->steps[$rootStep->getKey()] = $rootStep;
$this->templateCategories = [
'welcome' => new AutomationTemplateCategory('welcome', __('Welcome', 'mailpoet')),
'abandoned-cart' => new AutomationTemplateCategory('abandoned-cart', __('Abandoned Cart', 'mailpoet')),
'reengagement' => new AutomationTemplateCategory('reengagement', __('Re-engagement', 'mailpoet')),
'woocommerce' => new AutomationTemplateCategory('woocommerce', __('WooCommerce', 'mailpoet')),
];
}
public function addTemplate(AutomationTemplate $template): void {
$category = $template->getCategory();
if (!isset($this->templateCategories[$category])) {
throw InvalidStateException::create()->withMessage(
sprintf("Category '%s' was not registered", $category)
);
}
$this->templates[$template->getSlug()] = $template;
// keep coming soon templates at the end
uasort(
$this->templates,
function (AutomationTemplate $a, AutomationTemplate $b): int {
if ($a->getType() === AutomationTemplate::TYPE_COMING_SOON) {
return 1;
}
if ($b->getType() === AutomationTemplate::TYPE_COMING_SOON) {
return -1;
}
return 0;
}
);
}
public function getTemplate(string $slug): ?AutomationTemplate {
return $this->getTemplates()[$slug] ?? null;
}
/** @return array */
public function getTemplates(string $category = null): array {
return $category
? array_filter(
$this->templates,
function(AutomationTemplate $template) use ($category): bool {
return $template->getCategory() === $category;
}
)
: $this->templates;
}
public function removeTemplate(string $slug): void {
unset($this->templates[$slug]);
}
/** @return array */
public function getTemplateCategories(): array {
return $this->templateCategories;
}
/** @param Subject $subject */
public function addSubject(Subject $subject): void {
$key = $subject->getKey();
if (isset($this->subjects[$key])) {
throw new \Exception(); // TODO
}
$this->subjects[$key] = $subject;
// reset fields cache
$this->fields = null;
}
/** @return Subject|null */
public function getSubject(string $key): ?Subject {
return $this->subjects[$key] ?? null;
}
/** @return array> */
public function getSubjects(): array {
return $this->subjects;
}
public function addSubjectTransformer(SubjectTransformer $transformer): void {
$this->subjectTransformers[] = $transformer;
}
public function getSubjectTransformers(): array {
return $this->subjectTransformers;
}
public function getField(string $key): ?Field {
return $this->getFields()[$key] ?? null;
}
/** @return array */
public function getFields(): array {
// add fields lazily (on the first call)
if ($this->fields === null) {
$this->fields = [];
foreach ($this->subjects as $subject) {
foreach ($subject->getFields() as $field) {
$this->addField($field);
}
}
}
return $this->fields ?? [];
}
public function addFilter(Filter $filter): void {
$fieldType = $filter->getFieldType();
if (isset($this->filters[$fieldType])) {
throw new \Exception(); // TODO
}
$this->filters[$fieldType] = $filter;
}
public function getFilter(string $fieldType): ?Filter {
return $this->filters[$fieldType] ?? null;
}
/** @return array */
public function getFilters(): array {
return $this->filters;
}
public function addStep(Step $step): void {
if ($step instanceof Trigger) {
$this->addTrigger($step);
} elseif ($step instanceof Action) {
$this->addAction($step);
}
// TODO: allow adding any other step implementations?
}
public function getStep(string $key): ?Step {
return $this->steps[$key] ?? null;
}
/** @return array */
public function getSteps(): array {
return $this->steps;
}
public function addTrigger(Trigger $trigger): void {
$key = $trigger->getKey();
if (isset($this->steps[$key]) || isset($this->triggers[$key])) {
throw new \Exception(); // TODO
}
$this->steps[$key] = $trigger;
$this->triggers[$key] = $trigger;
}
public function getTrigger(string $key): ?Trigger {
return $this->triggers[$key] ?? null;
}
/** @return array */
public function getTriggers(): array {
return $this->triggers;
}
public function addAction(Action $action): void {
$key = $action->getKey();
if (isset($this->steps[$key]) || isset($this->actions[$key])) {
throw new \Exception(); // TODO
}
$this->steps[$key] = $action;
$this->actions[$key] = $action;
}
public function getAction(string $key): ?Action {
return $this->actions[$key] ?? null;
}
/** @return array */
public function getActions(): array {
return $this->actions;
}
public function addContextFactory(string $key, callable $factory): void {
$this->contextFactories[$key] = $factory;
}
/** @return callable[] */
public function getContextFactories(): array {
return $this->contextFactories;
}
public function onBeforeAutomationSave(callable $callback, int $priority = 10): void {
$this->wordPress->addAction(Hooks::AUTOMATION_BEFORE_SAVE, $callback, $priority);
}
public function onBeforeAutomationStepSave(callable $callback, string $key = null, int $priority = 10): void {
$keyPart = $key ? "/key=$key" : '';
$this->wordPress->addAction(Hooks::AUTOMATION_STEP_BEFORE_SAVE . $keyPart, $callback, $priority, 2);
}
/**
* This is used only internally. Fields are added lazily from subjects.
*/
private function addField(Field $field): void {
$key = $field->getKey();
if (isset($this->fields[$key])) {
throw new \Exception(); // TODO
}
$this->fields[$key] = $field;
}
}
Exceptions/index.php 0000644 00000000006 15073230056 0010502 0 ustar 00 errorCode = $errorCode ?? 'mailpoet_automation_unknown_error';
}
/** @return static */
public static function create(Throwable $previous = null) {
return new static(null, null, $previous);
}
/** @return static */
public function withStatusCode(int $statusCode) {
$this->statusCode = $statusCode;
return $this;
}
/** @return static */
public function withError(string $id, string $error) {
$this->errors[$id] = $error;
return $this;
}
/** @return static */
public function withErrorCode(string $errorCode) {
$this->errorCode = $errorCode;
return $this;
}
/** @return static */
public function withMessage(string $message) {
$this->message = $message;
return $this;
}
/** @return static */
public function withErrors(array $errors) {
$this->errors = $errors;
return $this;
}
public function getStatusCode(): int {
return $this->statusCode;
}
public function getErrorCode(): string {
return $this->errorCode;
}
public function getErrors(): array {
return $this->errors;
}
}
Integration.php 0000644 00000000272 15073230056 0007542 0 ustar 00 api = $api;
$this->coreIntegration = $coreIntegration;
$this->wordPressIntegration = $wordPressIntegration;
$this->registry = $registry;
$this->stepHandler = $stepHandler;
$this->triggerHandler = $triggerHandler;
$this->wordPress = $wordPress;
$this->automationStorage = $automationStorage;
}
public function initialize(): void {
$this->registerApiRoutes();
$this->api->initialize();
$this->stepHandler->initialize();
$this->triggerHandler->initialize();
$this->coreIntegration->register($this->registry);
$this->wordPressIntegration->register($this->registry);
$this->wordPress->doAction(Hooks::INITIALIZE, [$this->registry]);
$this->registerActiveTriggerHooks();
}
private function registerApiRoutes(): void {
$this->wordPress->addAction(Hooks::API_INITIALIZE, function (API $api) {
$api->registerGetRoute('automations', AutomationsGetEndpoint::class);
$api->registerPutRoute('automations/(?P\d+)', AutomationsPutEndpoint::class);
$api->registerDeleteRoute('automations/(?P\d+)', AutomationsDeleteEndpoint::class);
$api->registerPostRoute('automations/(?P\d+)/duplicate', AutomationsDuplicateEndpoint::class);
$api->registerPostRoute('automations/create-from-template', AutomationsCreateFromTemplateEndpoint::class);
$api->registerGetRoute('automation-templates', AutomationTemplatesGetEndpoint::class);
$api->registerGetRoute('automation-templates/(?P.+)', AutomationTemplateGetEndpoint::class);
});
}
private function registerActiveTriggerHooks(): void {
$triggerKeys = $this->automationStorage->getActiveTriggerKeys();
foreach ($triggerKeys as $triggerKey) {
$instance = $this->registry->getTrigger($triggerKey);
if ($instance) {
$instance->registerHooks();
}
}
}
}
Validation/AutomationRules/index.php 0000644 00000000006 15073230056 0013606 0 ustar 00 needsFullValidation()) {
return;
}
$step = $node->getStep();
if ($step->getType() !== Step::TYPE_TRIGGER) {
return;
}
$nextStepIds = $step->getNextStepIds();
if (!count($nextStepIds)) {
throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID);
}
foreach ($nextStepIds as $nextStepsId) {
$step = $automation->getStep($nextStepsId);
if ($step && $step->getType() === Step::TYPE_ACTION) {
continue;
}
throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID);
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/ValidStepRule.php 0000644 00000012024 15073230056 0015225 0 ustar 00 }> */
private $errors = [];
/** @param AutomationNodeVisitor[] $rules */
public function __construct(
array $rules
) {
$this->rules = $rules;
}
public function initialize(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$rule->initialize($automation);
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$stepId = $node->getStep()->getId();
try {
$rule->visitNode($automation, $node);
} catch (UnexpectedValueException $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []];
}
$this->errors[$stepId]['fields'] = array_merge(
$this->mapErrorCodesToErrorMessages($e->getErrors()),
$this->errors[$stepId]['fields']
);
} catch (ValidationException $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []];
}
$key = $rule instanceof ValidStepFiltersRule ? 'filters' : 'fields';
/** @phpstan-ignore-next-line - PHPStan detects inconsistency in merged array */
$this->errors[$stepId][$key] = array_merge(
$this->mapErrorCodesToErrorMessages($e->getErrors()),
$this->errors[$stepId][$key]
);
} catch (Throwable $e) {
if (!isset($this->errors[$stepId])) {
$this->errors[$stepId] = ['step_id' => $stepId, 'message' => __('Unknown error.', 'mailpoet'), 'fields' => [], 'filters' => []];
}
}
}
}
private function mapErrorCodesToErrorMessages(array $errorCodes): array {
return array_map(
function(string $errorCode): string {
switch ($errorCode) {
case "rest_property_required":
return __('This is a required field.', 'mailpoet');
case "rest_additional_properties_forbidden":
case "rest_too_few_properties":
case "rest_too_many_properties":
return "";
case "rest_invalid_type":
case "rest_invalid_multiple":
case "rest_not_in_enum":
return __('This field is not well formed.', 'mailpoet');
case "rest_too_few_items":
return __('Please add more items.', 'mailpoet');
case "rest_too_many_items":
return __('Please remove some items.', 'mailpoet');
case "rest_duplicate_items":
return __('Please remove duplicate items.', 'mailpoet');
case "rest_out_of_bounds":
return __('This value is out of bounds.', 'mailpoet');
case "rest_too_short":
return __('This value is not long enough.', 'mailpoet');
case "rest_too_long":
return __('This value is too long.', 'mailpoet');
case "rest_invalid_pattern":
return __('This value is not well formed.', 'mailpoet');
case "rest_no_matching_schema":
return __('This value does not match the expected format.', 'mailpoet');
case "rest_one_of_multiple_matches":
return __('This value is not matching the correct times.', 'mailpoet');
case "rest_invalid_hex_color":
return __('This value is not a hex formatted color.', 'mailpoet');
case "rest_invalid_date":
return __('This value is not a date.', 'mailpoet');
case "rest_invalid_email":
return __('This value is not an email address.', 'mailpoet');
case "rest_invalid_ip":
return __('This value is not an IP address.', 'mailpoet');
case "rest_invalid_uuid":
return __('This value is not an UUID.', 'mailpoet');
default:
return $errorCode;
}
},
$errorCodes
);
}
public function complete(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
foreach ($this->rules as $rule) {
$rule->complete($automation);
}
if ($this->errors) {
throw Exceptions::automationNotValid(__('Some steps are not valid', 'mailpoet'), $this->errors);
}
}
}
Validation/AutomationRules/NoJoinRule.php 0000644 00000002321 15073230056 0014525 0 ustar 00 */
private $directParentMap = [];
public function initialize(Automation $automation): void {
$this->directParentMap = [];
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
foreach ($step->getNextStepIds() as $nextStepId) {
$this->directParentMap[$nextStepId] = array_merge($this->directParentMap[$nextStepId] ?? [], [$step]);
}
if (count($this->directParentMap[$step->getId()] ?? []) > 1) {
throw Exceptions::automationStructureNotValid(__('Path join found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/ValidStepArgsRule.php 0000644 00000003772 15073230056 0016054 0 ustar 00 registry = $registry;
$this->validator = $validator;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
$schema = $registryStep->getArgsSchema();
$properties = $schema->toArray()['properties'] ?? null;
if (!$properties) {
$this->validator->validate($schema, $step->getArgs());
return;
}
$errors = [];
foreach ($properties as $property => $propertySchema) {
$schemaToValidate = array_merge(
$schema->toArray(),
['properties' => [$property => $propertySchema]]
);
try {
$this->validator->validateSchemaArray(
$schemaToValidate,
$step->getArgs(),
$property
);
} catch (ValidationException $e) {
$errors[$property] = $e->getWpError()->get_error_code();
}
}
if ($errors) {
$throwable = ValidationException::create();
foreach ($errors as $errorKey => $errorMsg) {
$throwable->withError((string)$errorKey, (string)$errorMsg);
}
throw $throwable;
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/ValidStepValidationRule.php 0000644 00000004342 15073230056 0017244 0 ustar 00 registry = $registry;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
$subjects = $this->collectSubjects($automation, $node->getParents());
$args = new StepValidationArgs($automation, $step, $subjects);
$registryStep->validate($args);
}
public function complete(Automation $automation): void {
}
/**
* @param Step[] $parents
* @return Subject[]
*/
private function collectSubjects(Automation $automation, array $parents): array {
$triggers = array_filter($parents, function (Step $step) {
return $step->getType() === Step::TYPE_TRIGGER;
});
$subjectKeys = [];
foreach ($triggers as $trigger) {
$registryTrigger = $this->registry->getTrigger($trigger->getKey());
if (!$registryTrigger) {
throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey());
}
$subjectKeys = array_merge($subjectKeys, $registryTrigger->getSubjectKeys());
}
$subjects = [];
foreach (array_unique($subjectKeys) as $key) {
$subject = $this->registry->getSubject($key);
if (!$subject) {
throw Exceptions::subjectNotFound($key);
}
$subjects[] = $subject;
}
return $subjects;
}
}
Validation/AutomationRules/TriggersUnderRootRule.php 0000644 00000002545 15073230056 0016771 0 ustar 00 $triggersMap */
private $triggersMap = [];
public function initialize(Automation $automation): void {
$this->triggersMap = [];
foreach ($automation->getSteps() as $step) {
if ($step->getType() === 'trigger') {
$this->triggersMap[$step->getId()] = $step;
}
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
if ($step->getType() === Step::TYPE_ROOT) {
return;
}
foreach ($step->getNextStepIds() as $nextStepId) {
if (isset($this->triggersMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Trigger must be a direct descendant of automation root', 'mailpoet'), self::RULE_ID);
}
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/ValidStepOrderRule.php 0000644 00000003455 15073230056 0016231 0 ustar 00 registry = $registry;
$this->subjectTransformerHandler = $subjectTransformerHandler;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
if (!$registryStep) {
return;
}
// triggers don't require any subjects (they provide them)
if ($step->getType() === Step::TYPE_TRIGGER) {
return;
}
$requiredSubjectKeys = $registryStep->getSubjectKeys();
if (!$requiredSubjectKeys) {
return;
}
$subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation);
$missingSubjectKeys = array_diff($requiredSubjectKeys, $subjectKeys);
if (count($missingSubjectKeys) > 0) {
throw Exceptions::missingRequiredSubjects($step, $missingSubjectKeys);
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/NoDuplicateEdgesRule.php 0000644 00000002035 15073230056 0016512 0 ustar 00 getStep()->getNextStepIds() as $nextStepId) {
if (isset($visitedNextStepIdsMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Duplicate next step definition found', 'mailpoet'), self::RULE_ID);
}
$visitedNextStepIdsMap[$nextStepId] = true;
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/NoUnreachableStepsRule.php 0000644 00000002062 15073230056 0017060 0 ustar 00 visitedNodes = [];
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$this->visitedNodes[$node->getStep()->getId()] = $node;
}
public function complete(Automation $automation): void {
if (count($this->visitedNodes) !== count($automation->getSteps())) {
throw Exceptions::automationStructureNotValid(__('Unreachable steps found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
}
Validation/AutomationRules/AtLeastOneTriggerRule.php 0000644 00000002257 15073230056 0016664 0 ustar 00 triggerFound = false;
}
public function visitNode(Automation $automation, AutomationNode $node): void {
if ($node->getStep()->getType() === Step::TYPE_TRIGGER) {
$this->triggerFound = true;
}
}
public function complete(Automation $automation): void {
if (!$automation->needsFullValidation()) {
return;
}
if ($this->triggerFound) {
return;
}
throw Exceptions::automationStructureNotValid(__('There must be at least one trigger in the automation.', 'mailpoet'), self::RULE_ID);
}
}
Validation/AutomationRules/NoCycleRule.php 0000644 00000002326 15073230056 0014672 0 ustar 00 getStep();
$parents = $node->getParents();
$parentIdsMap = array_combine(
array_map(function (Step $parent) {
return $parent->getId();
}, $node->getParents()),
$parents
) ?: [];
foreach ($step->getNextStepIds() as $nextStepId) {
if ($nextStepId === $step->getId() || isset($parentIdsMap[$nextStepId])) {
throw Exceptions::automationStructureNotValid(__('Cycle found in automation graph', 'mailpoet'), self::RULE_ID);
}
}
}
public function complete(Automation $automation): void {
}
}
Validation/AutomationRules/UnknownStepRule.php 0000644 00000004335 15073230056 0015633 0 ustar 00 registry = $registry;
$this->automationStorage = $automationStorage;
}
public function initialize(Automation $automation): void {
$this->cachedExistingAutomation = false;
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$step = $node->getStep();
$registryStep = $this->registry->getStep($step->getKey());
// step not registered (e.g. plugin was deactivated) - allow saving it only if it hasn't changed
if (!$registryStep) {
$currentAutomation = $this->getCurrentAutomation($automation);
$currentStep = $currentAutomation ? ($currentAutomation->getSteps()[$step->getId()] ?? null) : null;
if (!$currentStep || $step->toArray() !== $currentStep->toArray()) {
throw Exceptions::automationStepModifiedWhenUnknown($step);
}
}
}
public function complete(Automation $automation): void {
}
private function getCurrentAutomation(Automation $automation): ?Automation {
try {
$id = $automation->getId();
if ($this->cachedExistingAutomation === false) {
$this->cachedExistingAutomation = $this->automationStorage->getAutomation($id);
}
} catch (InvalidStateException $e) {
// for new automations, no automation ID is set
$this->cachedExistingAutomation = null;
}
return $this->cachedExistingAutomation;
}
}
Validation/AutomationRules/ValidStepFiltersRule.php 0000644 00000006343 15073230056 0016565 0 ustar 00 registry = $registry;
$this->subjectTransformerHandler = $subjectTransformerHandler;
$this->validator = $validator;
}
public function initialize(Automation $automation): void {
}
public function visitNode(Automation $automation, AutomationNode $node): void {
$filters = $node->getStep()->getFilters();
$groups = $filters ? $filters->getGroups() : [];
$errors = [];
foreach ($groups as $group) {
foreach ($group->getFilters() as $filter) {
$registryFilter = $this->registry->getFilter($filter->getFieldType());
if (!$registryFilter) {
continue;
}
try {
$this->validator->validate($registryFilter->getArgsSchema($filter->getCondition()), $filter->getArgs());
} catch (ValidationException $e) {
$errors[$filter->getId()] = $e->getWpError()->get_error_code();
continue;
}
// ensure that the field is available with the provided subjects
$subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation);
$filterSubject = $this->getFilterSubject($filter);
if (!$filterSubject) {
$errors[$filter->getId()] = __('Field not found', 'mailpoet');
} elseif (!in_array($filterSubject->getKey(), $subjectKeys, true)) {
// translators: %s is the name of a subject (data structure) that provides the field
$errors[$filter->getId()] = sprintf(__('A trigger that provides %s is required', 'mailpoet'), $filterSubject->getName());
}
}
}
if ($errors) {
$throwable = ValidationException::create()->withMessage('invalid-automation-filters');
foreach ($errors as $errorKey => $errorMsg) {
$throwable->withError((string)$errorKey, (string)$errorMsg);
}
throw $throwable;
}
}
public function complete(Automation $automation): void {
}
/** @return Subject */
private function getFilterSubject(Filter $filter): ?Subject {
foreach ($this->registry->getSubjects() as $subject) {
foreach ($subject->getFields() as $field) {
if ($field->getKey() === $filter->getFieldKey()) {
return $subject;
}
}
}
return null;
}
}
Validation/AutomationRules/ConsistentStepMapRule.php 0000644 00000002146 15073230056 0016761 0 ustar 00 getSteps() as $id => $step) {
if ((string)$id !== $step->getId()) {
// translators: %1$s is the ID of the step, %2$s is its index in the steps object.
throw Exceptions::automationStructureNotValid(
sprintf(__("Step with ID '%1\$s' stored under a mismatched index '%2\$s'.", 'mailpoet'), $step->getId(), $id),
self::RULE_ID
);
}
}
}
public function visitNode(Automation $automation, AutomationNode $node): void {
}
public function complete(Automation $automation): void {
}
}
Validation/index.php 0000644 00000000006 15073230056 0010453 0 ustar 00 Builder::integer()->required(),
'name' => Builder::string()->minLength(1)->required(),
'status' => Builder::string()->required(),
'steps' => self::getStepsSchema()->required(),
]);
}
public static function getStepsSchema(): ObjectSchema {
return Builder::object()
->properties(['root' => self::getRootStepSchema()->required()])
->additionalProperties(self::getStepSchema());
}
public static function getStepSchema(): ObjectSchema {
return Builder::object([
'id' => Builder::string()->required(),
'type' => Builder::string()->required(),
'key' => Builder::string()->required(),
'args' => Builder::object()->required(),
'next_steps' => self::getNextStepsSchema()->required(),
'filters' => self::getFiltersSchema()->nullable()->required(),
]);
}
public static function getRootStepSchema(): ObjectSchema {
return Builder::object([
'id' => Builder::string()->pattern('^root$'),
'type' => Builder::string()->pattern('^root$'),
'key' => Builder::string()->pattern('^core:root$'),
'args' => Builder::object()->disableAdditionalProperties(),
'next_steps' => self::getNextStepsSchema()->required(),
]);
}
public static function getNextStepsSchema(): ArraySchema {
return Builder::array(
Builder::object([
'id' => Builder::string()->required()->nullable(),
])
);
}
public static function getFiltersSchema(): ObjectSchema {
$operatorSchema = Builder::string()->pattern('^and|or$')->required();
$filterSchema = Builder::object([
'id' => Builder::string()->required(),
'field_type' => Builder::string()->required(),
'field_key' => Builder::string()->required(),
'condition' => Builder::string()->required(),
'args' => Builder::object()->required(),
]);
$filterGroupSchema = Builder::object([
'id' => Builder::string()->required(),
'operator' => $operatorSchema,
'filters' => Builder::array($filterSchema)->minItems(1)->required(),
]);
return Builder::object([
'operator' => $operatorSchema,
'groups' => Builder::array($filterGroupSchema)->minItems(1)->required(),
]);
}
}
Validation/AutomationValidator.php 0000644 00000005675 15073230056 0013353 0 ustar 00 unknownStepRule = $unknownStepRule;
$this->validStepArgsRule = $validStepArgsRule;
$this->validStepFiltersRule = $validStepFiltersRule;
$this->validStepOrderRule = $validStepOrderRule;
$this->validStepValidationRule = $validStepValidationRule;
$this->automationWalker = $automationWalker;
}
public function validate(Automation $automation): void {
$this->automationWalker->walk($automation, [
new NoUnreachableStepsRule(),
new ConsistentStepMapRule(),
new NoDuplicateEdgesRule(),
new TriggersUnderRootRule(),
new NoCycleRule(),
new NoJoinRule(),
$this->unknownStepRule,
new AtLeastOneTriggerRule(),
new TriggerNeedsToBeFollowedByActionRule(),
new ValidStepRule([
$this->validStepArgsRule,
$this->validStepFiltersRule,
$this->validStepOrderRule,
$this->validStepValidationRule,
]),
]);
}
}
Validation/AutomationGraph/index.php 0000644 00000000006 15073230056 0013555 0 ustar 00 getSteps();
$root = $steps['root'] ?? null;
if (!$root) {
throw Exceptions::automationStructureNotValid(__("Automation must contain a 'root' step", 'mailpoet'), 'no-root');
}
foreach ($visitors as $visitor) {
$visitor->initialize($automation);
}
foreach ($this->walkStepsDepthFirstPreOrder($steps, $root) as $record) {
[$step, $parents] = $record;
foreach ($visitors as $visitor) {
$visitor->visitNode($automation, new AutomationNode($step, array_values($parents)));
}
}
foreach ($visitors as $visitor) {
$visitor->complete($automation);
}
}
/**
* @param array $steps
* @return Generator}>
*/
private function walkStepsDepthFirstPreOrder(array $steps, Step $root): Generator {
/** @var array{0: Step, 1: array}[] $stack */
$stack = [
[$root, []],
];
do {
$record = array_pop($stack);
if (!$record) {
throw new InvalidStateException();
}
yield $record;
[$step, $parents] = $record;
foreach (array_reverse($step->getNextSteps()) as $nextStepData) {
$nextStepId = $nextStepData->getId();
if (!$nextStepId) {
continue; // empty edge
}
$nextStep = $steps[$nextStepId] ?? null;
if (!$nextStep) {
throw $this->createStepNotFoundException($nextStepId, $step->getId());
}
$nextStepParents = array_merge($parents, [$step->getId() => $step]);
if (isset($nextStepParents[$nextStepId])) {
continue; // cycle detected, do not enter the path again
}
array_push($stack, [$nextStep, $nextStepParents]);
}
} while (count($stack) > 0);
}
private function createStepNotFoundException(string $stepId, string $parentStepId): UnexpectedValueException {
return Exceptions::automationStructureNotValid(
// translators: %1$s is ID of the step not found, %2$s is ID of the step that references it
sprintf(
__("Step with ID '%1\$s' not found (referenced from '%2\$s')", 'mailpoet'),
$stepId,
$parentStepId
),
'step-not-found'
);
}
}
Validation/AutomationGraph/AutomationNode.php 0000644 00000001133 15073230056 0015376 0 ustar 00 step = $step;
$this->parents = $parents;
}
public function getStep(): Step {
return $this->step;
}
/** @return Step[] */
public function getParents(): array {
return $this->parents;
}
}
Validation/AutomationGraph/AutomationNodeVisitor.php 0000644 00000000643 15073230056 0016763 0 ustar 00 withErrorCode(self::DATABASE_ERROR)
// translators: %s is the error message.
->withMessage(sprintf(__('Database error: %s', 'mailpoet'), $error));
}
public static function jsonNotObject(string $json): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::JSON_NOT_OBJECT)
// translators: %s is the mentioned JSON string.
->withMessage(sprintf(__("JSON string '%s' doesn't encode an object.", 'mailpoet'), $json));
}
public static function automationNotFound(int $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_NOT_FOUND)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Automation with ID '%d' not found.", 'mailpoet'), $id));
}
public static function automationVersionNotFound(int $automation, int $version): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_VERSION_NOT_FOUND)
// translators: %1$s is the ID of the automation, %2$s the version.
->withMessage(sprintf(__('Automation with ID "%1$s" in version "%2$s" not found.', 'mailpoet'), $automation, $version));
}
public static function automationNotActive(int $automation): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_NOT_ACTIVE)
// translators: %1$s is the ID of the automation.
->withMessage(sprintf(__('Automation with ID "%1$s" in no longer active.', 'mailpoet'), $automation));
}
public static function automationRunNotFound(int $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_RUN_NOT_FOUND)
// translators: %d is the ID of the automation run.
->withMessage(sprintf(__("Automation run with ID '%d' not found.", 'mailpoet'), $id));
}
public static function automationStepNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_STEP_NOT_FOUND)
// translators: %s is the key of the automation step.
->withMessage(sprintf(__("Automation step with key '%s' not found.", 'mailpoet'), $key));
}
public static function automationTriggerNotFound(int $automationId, string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_TRIGGER_NOT_FOUND)
// translators: %1$s is the key, %2$d is the automation ID.
->withMessage(sprintf(__('Automation trigger with key "%1$s" not found in automation ID "%2$d".', 'mailpoet'), $key, $automationId));
}
public static function automationRunNotRunning(int $id, string $status): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_RUN_NOT_RUNNING)
// translators: %1$d is the ID of the automation run, %2$s its current status.
->withMessage(sprintf(__('Automation run with ID "%1$d" is not running. Status: %2$s', 'mailpoet'), $id, $status));
}
public static function subjectNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_NOT_FOUND)
// translators: %s is the key of the subject not found.
->withMessage(sprintf(__("Subject with key '%s' not found.", 'mailpoet'), $key));
}
public static function subjectClassNotFound(string $class): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_NOT_FOUND)
// translators: %s is the class name of the subject not found.
->withMessage(sprintf(__("Subject of class '%s' not found.", 'mailpoet'), $class));
}
public static function subjectLoadFailed(string $key, array $args): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::SUBJECT_LOAD_FAILED)
// translators: %1$s is the name of the key, %2$s the arguments.
->withMessage(sprintf(__('Subject with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args)));
}
public static function subjectDataNotFound(string $key, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::SUBJECT_DATA_NOT_FOUND)
// translators: %1$s is the key of the subject, %2$d is automation run ID.
->withMessage(
sprintf(__("Subject data for subject with key '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $key, $automationRunId)
);
}
public static function multipleSubjectsFound(string $key, int $automationRunId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::MULTIPLE_SUBJECTS_FOUND)
// translators: %1$s is the key of the subject, %2$d is automation run ID.
->withMessage(
sprintf(__("Multiple subjects with key '%1\$s' found for automation run with ID '%2\$d', only one expected.", 'mailpoet'), $key, $automationRunId)
);
}
public static function payloadNotFound(string $class, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::PAYLOAD_NOT_FOUND)
// translators: %1$s is the class of the payload, %2$d is automation run ID.
->withMessage(
sprintf(__("Payload of class '%1\$s' not found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId)
);
}
public static function multiplePayloadsFound(string $class, int $automationRunId): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::MULTIPLE_PAYLOADS_FOUND)
// translators: %1$s is the class of the payloads, %2$d is automation run ID.
->withMessage(
sprintf(__("Multiple payloads of class '%1\$s' found for automation run with ID '%2\$d'.", 'mailpoet'), $class, $automationRunId)
);
}
public static function fieldNotFound(string $key): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::FIELD_NOT_FOUND)
// translators: %s is the key of the field not found.
->withMessage(sprintf(__("Field with key '%s' not found.", 'mailpoet'), $key));
}
public static function fieldLoadFailed(string $key, array $args): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::FIELD_LOAD_FAILED)
// translators: %1$s is the key of the field, %2$s its arguments.
->withMessage(sprintf(__('Field with key "%1$s" and args "%2$s" failed to load.', 'mailpoet'), $key, Json::encode($args)));
}
public static function filterNotFound(string $fieldType): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::FILTER_NOT_FOUND)
// translators: %s is the type of the field for which a filter was not found.
->withMessage(sprintf(__("Filter for field of type '%s' not found.", 'mailpoet'), $fieldType));
}
public static function nextStepNotFound(string $stepId, int $nextStepId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::NEXT_STEP_NOT_FOUND)
// translators: %1$d is the ID of the automation step, %2$s is the ID of the next step.
->withMessage(sprintf(__("Automation step with ID '%1\$s' doesn't have a next step with index '%2\$d'.", 'mailpoet'), $stepId, $nextStepId));
}
public static function nextStepNotScheduled(string $stepId): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::NEXT_STEP_NOT_SCHEDULED)
// translators: %1$d is the ID of the automation step, %2$s is the ID of the next step.
->withMessage(sprintf(__("Automation step with ID '%s' did not schedule a specific next step, even though multiple next steps are possible.", 'mailpoet'), $stepId));
}
public static function automationStructureModificationNotSupported(): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED)
->withMessage(__('Automation structure modification not supported.', 'mailpoet'));
}
public static function automationStructureNotValid(string $detail, string $ruleId): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STRUCTURE_NOT_VALID)
// translators: %s is a detailed information
->withMessage(sprintf(__("Invalid automation structure: %s", 'mailpoet'), $detail))
->withErrors(['rule_id' => $ruleId]);
}
public static function automationStepModifiedWhenUnknown(Step $step): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN)
// translators: %1$s is the key of the step, %2$s is the type of the step, %3\$s is its ID.
->withMessage(
sprintf(
__("Modification of step '%1\$s' of type '%2\$s' with ID '%3\$s' is not supported when the related plugin is not active.", 'mailpoet'),
$step->getKey(),
$step->getType(),
$step->getId()
)
);
}
public static function automationNotValid(string $detail, array $errors): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_NOT_VALID)
// translators: %s is a detailed information
->withMessage(sprintf(__("Automation validation failed: %s", 'mailpoet'), $detail))
->withErrors($errors);
}
public static function missingRequiredSubjects(Step $step, array $missingSubjectKeys): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::MISSING_REQUIRED_SUBJECTS)
// translators: %1$s is the key of the step, %2$s are the missing subject keys.
->withMessage(
sprintf(
__("Step with ID '%1\$s' is missing required subjects with keys: %2\$s", 'mailpoet'),
$step->getId(),
implode(', ', $missingSubjectKeys)
)
)
->withErrors(
['general' => __('This step can not be used with the selected trigger.', 'mailpoet')]
);
}
public static function automationNotTrashed(int $id): UnexpectedValueException {
return UnexpectedValueException::create()
->withErrorCode(self::AUTOMATION_NOT_TRASHED)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Can't delete automation with ID '%d' because it was not trashed.", 'mailpoet'), $id));
}
public static function automationTemplateNotFound(string $id): NotFoundException {
return NotFoundException::create()
->withErrorCode(self::AUTOMATION_TEMPLATE_NOT_FOUND)
// translators: %d is the ID of the automation template.
->withMessage(sprintf(__("Automation template with ID '%d' not found.", 'mailpoet'), $id));
}
/**
* This is a temporary block, see MAILPOET-4744
*/
public static function automationHasActiveRuns(int $id): InvalidStateException {
return InvalidStateException::create()
->withErrorCode(self::AUTOMATION_HAS_ACTIVE_RUNS)
// translators: %d is the ID of the automation.
->withMessage(sprintf(__("Can not update automation with ID '%d' because users are currently active.", 'mailpoet'), $id));
}
}
Templates/index.php 0000644 00000000006 15073230056 0010317 0 ustar 00 registry = $registry;
}
/**
* @param string $name
* @param array<
* array{
* key: string,
* args?: array,
* filters?: array{
* operator: 'and' | 'or',
* groups: array{
* operator: 'and' | 'or',
* filters: array{
* field: string,
* condition: string,
* value: mixed,
* }[],
* }[],
* },
* }
* > $sequence
* @param array $meta
* @return Automation
*/
public function createFromSequence(string $name, array $sequence, array $meta = []): Automation {
$steps = [];
$nextSteps = [];
foreach (array_reverse($sequence) as $data) {
$stepKey = $data['key'];
$automationStep = $this->registry->getStep($stepKey);
if (!$automationStep) {
continue;
}
$args = array_merge($this->getDefaultArgs($automationStep->getArgsSchema()), $data['args'] ?? []);
$filters = isset($data['filters']) ? $this->getFilters($data['filters']) : null;
$step = new Step(
$this->uniqueId(),
in_array(Trigger::class, (array)class_implements($automationStep)) ? Step::TYPE_TRIGGER : Step::TYPE_ACTION,
$stepKey,
$args,
$nextSteps,
$filters
);
$nextSteps = [new NextStep($step->getId())];
$steps[$step->getId()] = $step;
}
$steps['root'] = new Step('root', 'root', 'core:root', [], $nextSteps);
$steps = array_reverse($steps);
$automation = new Automation(
$name,
$steps,
wp_get_current_user()
);
foreach ($meta as $key => $value) {
$automation->setMeta($key, $value);
}
return $automation;
}
private function uniqueId(): string {
return Security::generateRandomString(16);
}
private function getDefaultArgs(ObjectSchema $argsSchema): array {
$args = [];
foreach ($argsSchema->toArray()['properties'] ?? [] as $name => $schema) {
if (array_key_exists('default', $schema)) {
$args[$name] = $schema['default'];
}
}
return $args;
}
/**
* @param array{
* operator: 'and' | 'or',
* groups: array{
* operator: 'and' | 'or',
* filters: array{
* field: string,
* condition: string,
* value: mixed,
* }[],
* }[],
* } $filters
* @return Filters
*/
private function getFilters(array $filters): Filters {
$groups = [];
foreach ($filters['groups'] as $group) {
$groups[] = new FilterGroup(
$this->uniqueId(),
$group['operator'],
array_map(
function (array $filter): Filter {
$field = $this->registry->getField($filter['field']);
if (!$field) {
throw new InvalidStateException(sprintf("Field with key '%s' not found", $filter['field']));
}
return new Filter(
$this->uniqueId(),
$field->getType(),
$filter['field'],
$filter['condition'],
['value' => $filter['value']]
);
},
$group['filters']
)
);
}
return new Filters($filters['operator'], $groups);
}
}
Integration/index.php 0000644 00000000006 15073230056 0010644 0 ustar 00 */
public function getConditions(): array;
public function getArgsSchema(string $condition): ObjectSchema;
/** @param mixed $value */
public function matches(FilterData $data, $value): bool;
}
Integration/SubjectTransformer.php 0000644 00000000507 15073230056 0013365 0 ustar 00 api = $api;
$this->wordPress = $wordPress;
}
public function initialize(): void {
$this->wordPress->addAction(MailPoetApi::REST_API_INIT_ACTION, function () {
$this->wordPress->doAction(Hooks::API_INITIALIZE, [$this]);
});
}
public function registerGetRoute(string $route, string $endpoint): void {
$this->api->registerGetRoute($route, $endpoint);
}
public function registerPostRoute(string $route, string $endpoint): void {
$this->api->registerPostRoute($route, $endpoint);
}
public function registerPutRoute(string $route, string $endpoint): void {
$this->api->registerPutRoute($route, $endpoint);
}
public function registerPatchRoute(string $route, string $endpoint): void {
$this->api->registerPatchRoute($route, $endpoint);
}
public function registerDeleteRoute(string $route, string $endpoint): void {
$this->api->registerDeleteRoute($route, $endpoint);
}
}
API/Endpoint.php 0000644 00000000564 15073230056 0007454 0 ustar 00 settingsController = $settingsController;
}
public function preprocess(array $parsedBlocks, array $layoutStyles): array {
foreach ($parsedBlocks as $key => $block) {
$block = $this->preprocessParent($block);
// Set defaults from theme - this needs to be done on top level blocks only
$block = $this->setDefaultsFromTheme($block);
$block['innerBlocks'] = $this->copyTypographyFromParent($block['innerBlocks'], $block);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
private function copyTypographyFromParent(array $children, array $parent): array {
foreach ($children as $key => $child) {
$child = $this->preprocessParent($child);
$child['email_attrs'] = array_merge($this->filterStyles($parent['email_attrs']), $child['email_attrs']);
$child['innerBlocks'] = $this->copyTypographyFromParent($child['innerBlocks'] ?? [], $child);
$children[$key] = $child;
}
return $children;
}
private function preprocessParent(array $block): array {
// Build styles that should be copied to children
$emailAttrs = [];
if (isset($block['attrs']['style']['color']['text'])) {
$emailAttrs['color'] = $block['attrs']['style']['color']['text'];
}
// In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number
// The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize']
if (isset($block['attrs']['fontSize'])) {
$block['attrs']['style']['typography']['fontSize'] = $this->settingsController->translateSlugToFontSize($block['attrs']['fontSize']);
}
// Pass font size to email_attrs
if (isset($block['attrs']['style']['typography']['fontSize'])) {
$emailAttrs['font-size'] = $block['attrs']['style']['typography']['fontSize'];
}
if (isset($block['attrs']['style']['typography']['textDecoration'])) {
$emailAttrs['text-decoration'] = $block['attrs']['style']['typography']['textDecoration'];
}
$block['email_attrs'] = array_merge($emailAttrs, $block['email_attrs'] ?? []);
return $block;
}
private function filterStyles(array $styles): array {
return array_intersect_key($styles, array_flip(self::TYPOGRAPHY_STYLES));
}
private function setDefaultsFromTheme(array $block): array {
$themeData = $this->settingsController->getTheme()->get_data();
if (!($block['email_attrs']['color'] ?? '')) {
$block['email_attrs']['color'] = $themeData['styles']['color']['text'] ?? null;
}
if (!($block['email_attrs']['font-size'] ?? '')) {
$block['email_attrs']['font-size'] = $themeData['styles']['typography']['fontSize'];
}
return $block;
}
}
Renderer/Preprocessors/Preprocessor.php 0000644 00000000354 15073230072 0014363 0 ustar 00 'core/columns',
'attrs' => [],
'innerBlocks' => [[
'blockName' => 'core/column',
'attrs' => [],
'innerBlocks' => [],
]],
];
/**
* In the editor we allow putting content blocks directly into the root level of the email.
* But for rendering purposes it is more convenient to have them wrapped in a single column.
* This method walks through the first level of blocks and wraps non column blocks into a single column.
*/
public function preprocess(array $parsedBlocks, array $layoutStyles): array {
$wrappedParsedBlocks = [];
$nonColumnsBlocksBuffer = [];
foreach ($parsedBlocks as $block) {
$blockAlignment = $block['attrs']['align'] ?? null;
// The next block is columns so we can flush the buffer and add the columns block
if ($block['blockName'] === 'core/columns' || $blockAlignment === 'full') {
if ($nonColumnsBlocksBuffer) {
$columnsBlock = self::SINGLE_COLUMN_TEMPLATE;
$columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer;
$nonColumnsBlocksBuffer = [];
$wrappedParsedBlocks[] = $columnsBlock;
}
// If the block is full width and is not core/columns, we need to wrap it in a single column block, and it the columns block has to contain only the block
if ($blockAlignment === 'full' && $block['blockName'] !== 'core/columns') {
$columnsBlock = self::SINGLE_COLUMN_TEMPLATE;
$columnsBlock['attrs']['align'] = 'full';
$columnsBlock['innerBlocks'][0]['innerBlocks'] = [$block];
$wrappedParsedBlocks[] = $columnsBlock;
continue;
}
$wrappedParsedBlocks[] = $block;
continue;
}
// Non columns block so we add it to the buffer
$nonColumnsBlocksBuffer[] = $block;
}
// Flush the buffer if there are any blocks left
if ($nonColumnsBlocksBuffer) {
$columnsBlock = self::SINGLE_COLUMN_TEMPLATE;
$columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer;
$wrappedParsedBlocks[] = $columnsBlock;
}
return $wrappedParsedBlocks;
}
}
Renderer/Preprocessors/BlocksWidthPreprocessor.php 0000644 00000011547 15073230072 0016527 0 ustar 00 $block) {
// Layout width is recalculated for each block because full-width blocks don't exclude padding
$layoutWidth = $this->parseNumberFromStringWithPixels($layoutStyles['width']);
$alignment = $block['attrs']['align'] ?? null;
// Subtract padding from the block width if it's not full-width
if ($alignment !== 'full') {
$layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left'] ?? '0px');
$layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right'] ?? '0px');
}
$widthInput = $block['attrs']['width'] ?? '100%';
// Currently we support only % and px units in case only the number is provided we assume it's %
// because editor saves percent values as a number.
$widthInput = is_numeric($widthInput) ? "$widthInput%" : $widthInput;
$width = $this->convertWidthToPixels($widthInput, $layoutWidth);
if ($block['blockName'] === 'core/columns') {
// Calculate width of the columns based on the layout width and padding
$columnsWidth = $layoutWidth;
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['right'] ?? '0px');
$block['innerBlocks'] = $this->addMissingColumnWidths($block['innerBlocks'], $columnsWidth);
}
// Copy layout styles and update width and padding
$modifiedLayoutStyles = $layoutStyles;
$modifiedLayoutStyles['width'] = "{$width}px";
$modifiedLayoutStyles['padding']['left'] = $block['attrs']['style']['spacing']['padding']['left'] ?? '0px';
$modifiedLayoutStyles['padding']['right'] = $block['attrs']['style']['spacing']['padding']['right'] ?? '0px';
$block['email_attrs']['width'] = "{$width}px";
$block['innerBlocks'] = $this->preprocess($block['innerBlocks'], $modifiedLayoutStyles);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
// TODO: We could add support for other units like em, rem, etc.
private function convertWidthToPixels(string $currentWidth, float $layoutWidth): float {
$width = $layoutWidth;
if (strpos($currentWidth, '%') !== false) {
$width = (float)str_replace('%', '', $currentWidth);
$width = round($width / 100 * $layoutWidth);
} elseif (strpos($currentWidth, 'px') !== false) {
$width = $this->parseNumberFromStringWithPixels($currentWidth);
}
return $width;
}
private function parseNumberFromStringWithPixels(string $string): float {
return (float)str_replace('px', '', $string);
}
private function addMissingColumnWidths(array $columns, float $columnsWidth): array {
$columnsCountWithDefinedWidth = 0;
$definedColumnWidth = 0;
$columnsCount = count($columns);
foreach ($columns as $column) {
if (isset($column['attrs']['width']) && !empty($column['attrs']['width'])) {
$columnsCountWithDefinedWidth++;
$definedColumnWidth += $this->convertWidthToPixels($column['attrs']['width'], $columnsWidth);
} else {
// When width is not set we need to add padding to the defined column width for better ratio accuracy
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px');
}
}
if ($columnsCount - $columnsCountWithDefinedWidth > 0) {
$defaultColumnsWidth = round(($columnsWidth - $definedColumnWidth) / ($columnsCount - $columnsCountWithDefinedWidth), 2);
foreach ($columns as $key => $column) {
if (!isset($column['attrs']['width']) || empty($column['attrs']['width'])) {
// Add padding to the specific column width because it's not included in the default width
$columnWidth = $defaultColumnsWidth;
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px');
$columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px');
$columns[$key]['attrs']['width'] = "{$columnWidth}px";
}
}
}
return $columns;
}
}
Renderer/Preprocessors/SpacingPreprocessor.php 0000644 00000002156 15073230072 0015672 0 ustar 00 addMarginTopToBlocks($parsedBlocks);
return $parsedBlocks;
}
private function addMarginTopToBlocks(array $parsedBlocks): array {
foreach ($parsedBlocks as $key => $block) {
// We don't want to add margin-top to the first block in the email or to the first block in the columns block
if ($key !== 0) {
$block['email_attrs']['margin-top'] = SettingsController::FLEX_GAP;
}
$block['innerBlocks'] = $this->addMarginTopToBlocks($block['innerBlocks'] ?? []);
$parsedBlocks[$key] = $block;
}
return $parsedBlocks;
}
}
Renderer/Preprocessors/CleanupPreprocessor.php 0000644 00000001312 15073230072 0015666 0 ustar 00 $block) {
// https://core.trac.wordpress.org/ticket/45312
// \WP_Block_Parser::parse_blocks() sometimes add a block with name null that can cause unexpected spaces in rendered content
// This behavior was reported as an issue, but it was closed as won't fix
if ($block['blockName'] === null) {
unset($parsedBlocks[$key]);
}
}
return array_values($parsedBlocks);
}
}
Renderer/BlockRenderer.php 0000644 00000000471 15073230072 0011545 0 ustar 00
{{newsletter_subject}}
{{email_meta_robots}}
Renderer/PreprocessManager.php 0000644 00000003422 15073230072 0012443 0 ustar 00 registerPreprocessor($cleanupPreprocessor);
$this->registerPreprocessor($topLevelPreprocessor);
$this->registerPreprocessor($blocksWidthPreprocessor);
$this->registerPreprocessor($typographyPreprocessor);
$this->registerPreprocessor($spacingPreprocessor);
}
/**
* @param array $parsedBlocks
* @param array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}} $layoutStyles
* @return array
*/
public function preprocess(array $parsedBlocks, array $layoutStyles): array {
foreach ($this->preprocessors as $preprocessor) {
$parsedBlocks = $preprocessor->preprocess($parsedBlocks, $layoutStyles);
}
return $parsedBlocks;
}
public function registerPreprocessor(Preprocessor $preprocessor): void {
$this->preprocessors[] = $preprocessor;
}
}
Renderer/readme.md 0000644 00000003575 15073230072 0010102 0 ustar 00 # MailPoet Email Renderer
The renderer is WIP and so is the API for adding support email rendering for new blocks.
## Adding support for a core block
1. Add block into `ALLOWED_BLOCK_TYPES` in `mailpoet/lib/EmailEditor/Engine/Renderer/SettingsController.php`.
2. Make sure the block is registered in the editor. Currently all core blocks are registered in the editor.
3. Add BlockRender class (e.g. Heading) into `mailpoet/lib/EmailEditor/Integration/Core/Renderer/Blocks` folder.
```php
addBlockRenderer('core/heading', new Renderer\Blocks\Heading());
}
```
Note: For core blocks this is currently done in `MailPoet\EmailEditor\Integrations\Core\Initializer`.
5. Implement the rendering logic in the renderer class.
## Tips for adding support for block
- You can take inspiration on block rendering from MJML in the https://mjml.io/try-it-live
- Test the block in different clients [Litmus](https://litmus.com/)
- You can take some inspirations from the HTML renderer by the old email editor
## TODO
- add universal/fallback renderer for rendering blocks that are not covered by specialized renderers
- add support for all core blocks
- move the renderer to separate package
Renderer/Renderer.php 0000644 00000012150 15073230072 0010567 0 ustar 00 cssInliner = $cssInliner;
$this->preprocessManager = $preprocessManager;
$this->blocksRegistry = $blocksRegistry;
$this->settingsController = $settingsController;
}
public function render(\WP_Post $post, string $subject, string $preHeader, string $language, $metaRobots = ''): array {
$parser = new \WP_Block_Parser();
$parsedBlocks = $parser->parse($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$layoutStyles = $this->settingsController->getEmailLayoutStyles();
$themeData = $this->settingsController->getTheme()->get_data();
$contentBackground = $themeData['styles']['color']['background'] ?? $layoutStyles['background'];
$contentFontFamily = $themeData['styles']['typography']['fontFamily'];
$parsedBlocks = $this->preprocessManager->preprocess($parsedBlocks, $layoutStyles);
$renderedBody = $this->renderBlocks($parsedBlocks);
$styles = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_STYLES_FILE);
$styles .= $this->settingsController->getStylesheetForRendering();
$styles = apply_filters('mailpoet_email_renderer_styles', $styles, $post);
$template = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_FILE);
// Replace style settings placeholders with values
$template = str_replace(
['{{width}}', '{{layout_background}}', '{{content_background}}', '{{content_font_family}}', '{{padding_top}}', '{{padding_right}}', '{{padding_bottom}}', '{{padding_left}}'],
[$layoutStyles['width'], $layoutStyles['background'], $contentBackground, $contentFontFamily, $layoutStyles['padding']['top'], $layoutStyles['padding']['right'], $layoutStyles['padding']['bottom'], $layoutStyles['padding']['left']],
$template
);
/**
* Replace template variables
* {{email_language}}
* {{email_subject}}
* {{email_meta_robots}}
* {{email_template_styles}}
* {{email_preheader}}
* {{email_body}}
*/
$templateWithContents = $this->injectContentIntoTemplate(
$template,
[
$language,
esc_html($subject),
$metaRobots,
$styles,
esc_html($preHeader),
$renderedBody,
]
);
$templateWithContentsDom = $this->inlineCSSStyles($templateWithContents);
$templateWithContents = $this->postProcessTemplate($templateWithContentsDom);
return [
'html' => $templateWithContents,
'text' => $this->renderTextVersion($templateWithContents),
];
}
public function renderBlocks(array $parsedBlocks): string {
do_action('mailpoet_blocks_renderer_initialized', $this->blocksRegistry);
$content = '';
foreach ($parsedBlocks as $parsedBlock) {
$content .= render_block($parsedBlock);
}
/**
* As we use default WordPress filters, we need to remove them after email rendering
* so that we don't interfere with possible post rendering that might happen later.
*/
$this->blocksRegistry->removeAllBlockRendererFilters();
return $content;
}
private function injectContentIntoTemplate($template, array $content) {
return preg_replace_callback('/{{\w+}}/', function($matches) use (&$content) {
return array_shift($content);
}, $template);
}
/**
* @param string $template
* @return DomNode
*/
private function inlineCSSStyles($template) {
return $this->cssInliner->inlineCSS($template);
}
/**
* @param string $template
* @return string
*/
private function renderTextVersion($template) {
$template = (mb_detect_encoding($template, 'UTF-8', true)) ? $template : mb_convert_encoding($template, 'UTF-8', mb_list_encodings());
return @Html2Text::convert($template);
}
/**
* @param DomNode $templateDom
* @return string
*/
private function postProcessTemplate(DomNode $templateDom) {
// replace spaces in image tag URLs
foreach ($templateDom->query('img') as $image) {
$image->src = str_replace(' ', '%20', $image->src);
}
// because tburry/pquery contains a bug and replaces the opening non mso condition incorrectly we have to replace the opening tag with correct value
$template = $templateDom->__toString();
$template = str_replace('', '', $template);
return $template;
}
}
Renderer/BlocksRegistry.php 0000644 00000003300 15073230072 0011764 0 ustar 00 settingsController = $settingsController;
}
public function addBlockRenderer(string $blockName, BlockRenderer $renderer): void {
$this->blockRenderersMap[$blockName] = $renderer;
add_filter('render_block_' . $blockName, [$this, 'renderBlock'], 10, 2);
}
public function getBlockRenderer(string $blockName): ?BlockRenderer {
return apply_filters('mailpoet_block_renderer_' . $blockName, $this->blockRenderersMap[$blockName] ?? null);
}
public function removeAllBlockRendererFilters(): void {
foreach (array_keys($this->blockRenderersMap) as $blockName) {
$this->removeBlockRenderer($blockName);
}
}
public function renderBlock($blockContent, $parsedBlock): string {
// Here we could add a default renderer for blocks that don't have a renderer registered
if (!isset($this->blockRenderersMap[$parsedBlock['blockName']])) {
throw new \InvalidArgumentException('Block renderer not found for block ' . $parsedBlock['name']);
}
return $this->blockRenderersMap[$parsedBlock['blockName']]->render($blockContent, $parsedBlock, $this->settingsController);
}
private function removeBlockRenderer(string $blockName): void {
unset($this->blockRenderersMap[$blockName]);
remove_filter('render_block_' . $blockName, [$this, 'renderBlock']);
}
}
Renderer/styles.css 0000644 00000004577 15073230072 0010363 0 ustar 00 /* Base CSS rules to be applied to all emails */
/* Created based on original MailPoet template for rendering emails */
/* StyleLint is disabled because some rules contain properties that linter marks as unknown, but they are valid for email rendering */
/* stylelint-disable property-no-unknown */
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
-ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */
}
.email_layout_wrapper {
margin: 0 auto;
padding: 20px 0;
width: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
img {
border: 0;
height: auto;
-ms-interpolation-mode: bicubic;
line-height: 100%;
max-width: 100%;
outline: none;
text-decoration: none;
}
p {
display: block;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0;
margin-top: 0;
}
/* Wa want ensure the same design for all email clients */
ul,
ol {
/* When margin attribute is set to zero, Outlook doesn't render the list properly. As a possible workaround, we can reset only margin for top and bottom */
margin-bottom: 0;
margin-top: 0;
padding: 0 0 0 40px;
}
/* Outlook was adding weird spaces around lists in some versions. Resetting vertical margin for list items solved it */
li {
margin-bottom: 0;
margin-top: 0;
}
/* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */
.email_preheader,
.email_preheader * {
color: #fff;
display: none;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
mso-hide: all;
opacity: 0;
overflow: hidden;
visibility: hidden;
}
@media screen and (max-width: 660px) {
.email_column {
max-width: 100% !important;
}
.block {
display: block;
width: 100% !important;
}
/* Flex Layout */
.layout-flex-wrapper,
.layout-flex-wrapper tbody,
.layout-flex-wrapper tr {
display: block !important;
width: 100% !important;
}
.layout-flex-item {
display: block !important;
padding-bottom: 8px !important; /* Half of the flex gap between blocks */
padding-left: 0 !important;
width: 100% !important;
}
.layout-flex-item table,
.layout-flex-item td {
display: block !important;
width: 100% !important;
}
/* Flex Layout End */
}
/* stylelint-enable property-no-unknown */
Renderer/Layout/index.php 0000644 00000000006 15073230072 0011402 0 ustar 00 computeWidthsForFlexLayout($parsedBlock, $settingsController);
// MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles
$outputHtml = '
';
foreach ($innerBlocks as $key => $block) {
$styles = [];
if ($block['email_attrs']['layout_width'] ?? null) {
$styles['width'] = $block['email_attrs']['layout_width'];
}
if ($key > 0) {
$styles['padding-left'] = SettingsController::FLEX_GAP;
}
$outputHtml .= '| ' . render_block($block) . ' | ';
}
$outputHtml .= '
';
$wpGeneratedStyles = wp_style_engine_get_styles($parsedBlock['attrs']['style'] ?? []);
$styles = $wpGeneratedStyles['css'] ?? '';
$marginTop = $parsedBlock['email_attrs']['margin-top'] ?? '0px';
$styles .= 'margin-top: ' . $marginTop . ';';
$justify = esc_attr($parsedBlock['attrs']['layout']['justifyContent'] ?? 'left');
$styles .= 'text-align: ' . $justify;
$outputHtml = str_replace('{style}', $styles, $outputHtml);
$outputHtml = str_replace('{align}', $justify, $outputHtml);
return $outputHtml;
}
private function computeWidthsForFlexLayout(array $parsedBlock, SettingsController $settingsController): array {
$blocksCount = count($parsedBlock['innerBlocks']);
$totalUsedWidth = 0; // Total width assuming items without set width would consume proportional width
$parentWidth = $settingsController->parseNumberFromStringWithPixels($parsedBlock['email_attrs']['width'] ?? SettingsController::EMAIL_WIDTH);
$flexGap = $settingsController->parseNumberFromStringWithPixels(SettingsController::FLEX_GAP);
$innerBlocks = $parsedBlock['innerBlocks'] ?? [];
foreach ($innerBlocks as $key => $block) {
$blockWidthPercent = ($block['attrs']['width'] ?? 0) ? intval($block['attrs']['width']) : 0;
$blockWidth = floor($parentWidth * ($blockWidthPercent / 100));
// If width is not set, we assume it's 25% of the parent width
$totalUsedWidth += $blockWidth ?: floor($parentWidth * (25 / 100));
if (!$blockWidth) {
$innerBlocks[$key]['email_attrs']['layout_width'] = null; // Will be rendered as auto
continue;
}
$innerBlocks[$key]['email_attrs']['layout_width'] = $this->getWidthWithoutGap($blockWidth, $flexGap, $blockWidthPercent) . 'px';
}
// When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user
if ($blocksCount <= 1 || ($totalUsedWidth <= $parentWidth)) {
return $innerBlocks;
}
foreach ($innerBlocks as $key => $block) {
$proportionalSpaceOverflow = $parentWidth / $totalUsedWidth;
$blockWidth = $block['email_attrs']['layout_width'] ? $settingsController->parseNumberFromStringWithPixels($block['email_attrs']['layout_width']) : 0;
$blockProportionalWidth = $blockWidth * $proportionalSpaceOverflow;
$blockProportionalPercentage = ($blockProportionalWidth / $parentWidth) * 100;
$innerBlocks[$key]['email_attrs']['layout_width'] = $blockWidth ? $this->getWidthWithoutGap($blockProportionalWidth, $flexGap, $blockProportionalPercentage) . 'px' : null;
}
return $innerBlocks;
}
/**
* How much of width we will strip to keep some space for the gap
* This is computed based on CSS rule used in the editor:
* For block with width set to X percent
* width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100)));
*/
private function getWidthWithoutGap(float $blockWidth, float $flexGap, float $blockWidthPercent): int {
$widthGapReduction = $flexGap * ((100 - $blockWidthPercent) / 100);
return intval(floor($blockWidth - $widthGapReduction));
}
}
EmailEditor.php 0000644 00000003712 15073230072 0007455 0 ustar 00 emailApiController = $emailApiController;
}
public function initialize(): void {
do_action('mailpoet_email_editor_initialized');
$this->registerEmailPostTypes();
$this->extendEmailPostApi();
}
/**
* Register all custom post types that should be edited via the email editor
* The post types are added via mailpoet_email_editor_post_types filter.
*/
private function registerEmailPostTypes(): void {
foreach ($this->getPostTypes() as $postType) {
register_post_type(
$postType['name'],
array_merge($this->getDefaultEmailPostArgs(), $postType['args'])
);
}
}
/**
* @phpstan-return EmailPostType[]
*/
private function getPostTypes(): array {
$postTypes = [];
return apply_filters('mailpoet_email_editor_post_types', $postTypes);
}
private function getDefaultEmailPostArgs(): array {
return [
'public' => false,
'hierarchical' => false,
'show_ui' => true,
'show_in_menu' => false,
'show_in_nav_menus' => false,
'supports' => ['editor', 'title'],
'has_archive' => true,
'show_in_rest' => true, // Important to enable Gutenberg editor
];
}
public function extendEmailPostApi() {
$emailPostTypes = array_column($this->getPostTypes(), 'name');
register_rest_field($emailPostTypes, 'email_data', [
'get_callback' => [$this->emailApiController, 'getEmailData'],
'update_callback' => [$this->emailApiController, 'saveEmailData'],
'schema' => $this->emailApiController->getEmailDataSchema(),
]);
}
}
SettingsController.php 0000644 00000013736 15073230072 0011132 0 ustar 00 ['px', '%'],
];
/**
* Width of the email in pixels.
* @var string
*/
const EMAIL_WIDTH = '660px';
/**
* Color of email layout background.
* @var string
*/
const EMAIL_LAYOUT_BACKGROUND = '#cccccc';
/**
* Gap between blocks in flex layouts
* @var string
*/
const FLEX_GAP = '16px';
private $availableStylesheets = '';
public function getSettings(): array {
$coreDefaultSettings = get_default_block_editor_settings();
$editorTheme = $this->getTheme();
$themeSettings = $editorTheme->get_settings();
// body selector is later transformed to .editor-styles-wrapper
// setting padding for bottom and top is needed because \WP_Theme_JSON::get_stylesheet() set them only for .wp-site-blocks selector
$contentVariables = 'body {';
$contentVariables .= 'padding-bottom: var(--wp--style--root--padding-bottom);';
$contentVariables .= 'padding-top: var(--wp--style--root--padding-top);';
$contentVariables .= '--wp--style--block-gap:' . self::FLEX_GAP . ';';
$contentVariables .= '}';
$settings = array_merge($coreDefaultSettings, self::DEFAULT_SETTINGS);
$settings['allowedBlockTypes'] = self::ALLOWED_BLOCK_TYPES;
$flexEmailLayoutStyles = file_get_contents(__DIR__ . '/flex-email-layout.css');
$settings['styles'] = [
['css' => wp_get_global_stylesheet(['base-layout-styles'])],
['css' => $editorTheme->get_stylesheet()],
['css' => $contentVariables],
['css' => $flexEmailLayoutStyles],
];
$settings['styles'] = apply_filters('mailpoet_email_editor_editor_styles', $settings['styles']);
$settings['__experimentalFeatures'] = $themeSettings;
// Enabling alignWide allows full width for specific blocks such as columns, heading, image, etc.
$settings['alignWide'] = true;
return $settings;
}
/**
* @return array{contentSize: string, layout: string}
*/
public function getLayout(): array {
return [
'contentSize' => self::EMAIL_WIDTH,
'layout' => 'constrained',
];
}
public function getAvailableStylesheets(): string {
if ($this->availableStylesheets) return $this->availableStylesheets;
$coreThemeData = \WP_Theme_JSON_Resolver::get_core_data();
$this->availableStylesheets = $coreThemeData->get_stylesheet();
return $this->availableStylesheets;
}
/**
* @return array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}}
*/
public function getEmailLayoutStyles(): array {
return [
'width' => self::EMAIL_WIDTH,
'background' => self::EMAIL_LAYOUT_BACKGROUND,
'padding' => [
'bottom' => self::FLEX_GAP,
'left' => self::FLEX_GAP,
'right' => self::FLEX_GAP,
'top' => self::FLEX_GAP,
],
];
}
public function getLayoutWidthWithoutPadding(): string {
$layoutStyles = $this->getEmailLayoutStyles();
$width = $this->parseNumberFromStringWithPixels($layoutStyles['width']);
$width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left']);
$width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right']);
return "{$width}px";
}
/**
* This functions converts an array of styles to a string that can be used in HTML.
*/
public function convertStylesToString(array $styles): string {
$cssString = '';
foreach ($styles as $property => $value) {
$cssString .= $property . ':' . $value . ';';
}
return trim($cssString); // Remove trailing space and return the formatted string
}
public function parseStylesToArray(string $styles): array {
$styles = explode(';', $styles);
$parsedStyles = [];
foreach ($styles as $style) {
$style = explode(':', $style);
if (count($style) === 2) {
$parsedStyles[trim($style[0])] = trim($style[1]);
}
}
return $parsedStyles;
}
public function parseNumberFromStringWithPixels(string $string): float {
return (float)str_replace('px', '', $string);
}
public function getTheme(): \WP_Theme_JSON {
$coreThemeData = \WP_Theme_JSON_Resolver::get_core_data();
$themeJson = (string)file_get_contents(dirname(__FILE__) . '/theme.json');
$themeJson = json_decode($themeJson, true);
/** @var array $themeJson */
$coreThemeData->merge(new \WP_Theme_JSON($themeJson, 'default'));
return apply_filters('mailpoet_email_editor_theme_json', $coreThemeData);
}
public function getStylesheetForRendering(): string {
$emailThemeSettings = $this->getTheme()->get_settings();
$cssPresets = '';
// Font family classes
foreach ($emailThemeSettings['typography']['fontFamilies']['default'] as $fontFamily) {
$cssPresets .= ".has-{$fontFamily['slug']}-font-family { font-family: {$fontFamily['fontFamily']}; } \n";
}
// Font size classes
foreach ($emailThemeSettings['typography']['fontSizes']['default'] as $fontSize) {
$cssPresets .= ".has-{$fontSize['slug']}-font-size { font-size: {$fontSize['size']}; } \n";
}
// Block specific styles
$cssBlocks = '';
$blocks = $this->getTheme()->get_styles_block_nodes();
foreach ($blocks as $blockMetadata) {
$cssBlocks .= $this->getTheme()->get_styles_for_block($blockMetadata);
}
return $cssPresets . $cssBlocks;
}
public function translateSlugToFontSize(string $fontSize): string {
$settings = $this->getTheme()->get_settings();
foreach ($settings['typography']['fontSizes']['default'] as $fontSizeDefinition) {
if ($fontSizeDefinition['slug'] === $fontSize) {
return $fontSizeDefinition['size'];
}
}
return $fontSize;
}
}
theme.md 0000644 00000003117 15073230072 0006171 0 ustar 00 # Theme.json for the email editor
We use theme.json to define settings and styles for the email editor and we reuse the definitions also in the rendering engine.
The theme is used in combination with the [core's theme.json](https://github.com/WordPress/WordPress/blob/master/wp-includes/theme.json). We load the core's theme.json first and then we merge the email editor's theme.json on top of it.
In this file we want to document settings and styles that are specific to the email editor.
## Settings
- **color**: We disable gradients, because they are not supported in many email clients. We may add the support later.
- **layout**: We set content width to 660px, because it's the most common width for emails. This is meant as a default value.
- **spacing**: We allow only px units, because they are the most reliable in email clients. We may add the support for other units later with some sort of conversion to px. We also disable margins because they are not supported in our renderer (margin collapsing might be tricky).
- **border**: We want to allow all types of borders and border styles.
- **typography**: We disabled fontWeight and dropCap appearance settings, because they are not supported in our renderer. We may add the support later. We also define a set of basic font families that are safe to use with emails. The list was copied from the battle tested legacy editor.
## Styles
- **spacing**: We define default padding for the emails.
- **color**: We define default colors for text and background of the emails.
- **typography**: We define default font family and font size for the emails.
flex-email-layout.css 0000644 00000001677 15073230072 0010626 0 ustar 00 .is-layout-email-flex {
flex-wrap: nowrap;
}
:where(body .is-layout-flex) {
gap: var(--wp--style--block-gap, 16px);
}
.is-mobile-preview .is-layout-email-flex {
display: block;
}
.is-mobile-preview .is-layout-email-flex .block-editor-block-list__block {
padding: 5px 0;
width: 100%;
}
.is-mobile-preview .is-layout-email-flex .wp-block-button__link {
width: 100%;
}
/*
* Email Editor specific styles for vertical gap between blocks in column.
* This is needed because we disable layout for core/column and core/columns blocks, and .is-layout-flex is not applied.
*/
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:first-child {
margin-top: 0;
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block {
margin: var(--wp--style--block-gap, 16px) 0;
}
.wp-block-columns:not(.is-not-stacked-on-mobile)
> .wp-block-column
> .wp-block:last-child {
margin-bottom: 0;
}
EmailApiController.php 0000644 00000002350 15073230072 0011001 0 ustar 00 settingsController = $settingsController;
}
/**
* @return array - Email specific data such styles.
*/
public function getEmailData(): array {
return [
'layout_styles' => $this->settingsController->getEmailLayoutStyles(),
];
}
/**
* Update Email specific data we store.
*/
public function saveEmailData(array $data, \WP_Post $emailPost): void {
// Here comes code saving of Email specific data that will be passed on 'email_data' attribute
}
public function getEmailDataSchema(): array {
return Builder::object([
'layout_styles' => Builder::object([
'width' => Builder::string(),
'background' => Builder::string(),
'padding' => Builder::object([
'bottom' => Builder::string(),
'left' => Builder::string(),
'right' => Builder::string(),
'top' => Builder::string(),
]),
]),
])->toArray();
}
}