8889841cREADME.md000064400000000552150514567140006035 0ustar00# MailPoet Automations This is a setup for MailPoet Automation. As this feature intends to be mostly independent on the rest of the MailPoet codebase, we'll try to keep a list of all dependencies and specifics below. ## Dependencies - PHP >= 7.2 - MySQL >= 5.0 - WordPress >= 5.6 - PSR-11 Container Interface - Action Scheduler - MailPoet translations setup Engine/Integration/Action.php000064400000000372150514567140012174 0ustar00 */ public function getConditions(): array; public function getArgsSchema(): ObjectSchema; /** @param mixed $value */ public function matches(FilterData $data, $value): bool; } Engine/Integration/Trigger.php000064400000000457150514567140012366 0ustar00withErrorCode(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 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)); } } Engine/Control/FilterHandler.php000064400000003620150514567140012636 0ustar00registry = $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; } private 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); } } Engine/Control/RootStep.php000064400000001227150514567140011673 0ustar00actionScheduler = $actionScheduler; $this->automationStorage = $automationStorage; $this->automationRunStorage = $automationRunStorage; $this->subjectLoader = $subjectLoader; $this->subjectTransformerHandler = $subjectTransformerHandler; $this->filterHandler = $filterHandler; $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); if (!$this->filterHandler->matchesFilters($stepRunArgs)) { continue; } $createAutomationRun = $trigger->isTriggeredBy($stepRunArgs); $createAutomationRun = $this->wordPress->applyFilters( Hooks::AUTOMATION_RUN_CREATE, $createAutomationRun, $stepRunArgs ); if (!$createAutomationRun) { continue; } $automationRunId = $this->automationRunStorage->createAutomationRun($automationRun); $nextStep = $step->getNextSteps()[0] ?? null; $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, [ [ 'automation_run_id' => $automationRunId, 'step_id' => $nextStep ? $nextStep->getId() : null, ], ]); $this->automationRunStorage->updateNextStep($automationRunId, $nextStep ? $nextStep->getId() : null); } } } Engine/Control/StepHandler.php000064400000021705150514567140012330 0ustar00 */ private $stepRunners = []; /** @var AutomationRunLogStorage */ private $automationRunLogStorage; /** @var Hooks */ private $hooks; /** @var Registry */ private $registry; public function __construct( ActionScheduler $actionScheduler, ActionStepRunner $actionStepRunner, Hooks $hooks, SubjectLoader $subjectLoader, WordPress $wordPress, AutomationRunStorage $automationRunStorage, AutomationRunLogStorage $automationRunLogStorage, AutomationStorage $automationStorage, Registry $registry ) { $this->actionScheduler = $actionScheduler; $this->actionStepRunner = $actionStepRunner; $this->hooks = $hooks; $this->subjectLoader = $subjectLoader; $this->wordPress = $wordPress; $this->automationRunStorage = $automationRunStorage; $this->automationRunLogStorage = $automationRunLogStorage; $this->automationStorage = $automationStorage; $this->registry = $registry; } public function initialize(): void { $this->wordPress->addAction(Hooks::AUTOMATION_STEP, [$this, 'handle']); $this->addStepRunner(Step::TYPE_ACTION, $this->actionStepRunner); $this->wordPress->doAction(Hooks::STEP_RUNNER_INITIALIZE, [$this]); } public function addStepRunner(string $stepType, StepRunner $stepRunner): void { $this->stepRunners[$stepType] = $stepRunner; } public function getStepRunners(): array { return $this->stepRunners; } /** * @param array $stepRunners */ public function setStepRunners(array $stepRunners): void { $this->stepRunners = $stepRunners; } /** @param mixed $args */ public function handle($args): void { // TODO: args validation if (!is_array($args)) { throw new InvalidStateException(); } // Action Scheduler catches only Exception instances, not other errors. // We need to convert them to exceptions to be processed and logged. try { $this->handleStep($args); } 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); $this->postProcessAutomationRun((int)$args['automation_run_id']); if (!$e instanceof Exception) { throw new Exception($e->getMessage(), intval($e->getCode()), $e); } throw $e; } $this->postProcessAutomationRun((int)$args['automation_run_id']); } private function handleStep(array $args): void { $automationRunId = $args['automation_run_id']; $stepId = $args['step_id']; $automationRun = $this->automationRunStorage->getAutomationRun($automationRunId); if (!$automationRun) { throw Exceptions::automationRunNotFound($automationRunId); } if ($automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) { throw Exceptions::automationRunNotRunning($automationRunId, $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()); } // complete automation run if (!$stepId) { $this->automationRunStorage->updateStatus($automationRunId, AutomationRun::STATUS_COMPLETE); return; } $stepData = $automation->getStep($stepId); if (!$stepData) { throw Exceptions::automationStepNotFound($stepId); } $step = $this->registry->getStep($stepData->getKey()); $stepType = $stepData->getType(); if (isset($this->stepRunners[$stepType])) { $log = new AutomationRunLog($automationRun->getId(), $stepData->getId()); try { $requiredSubjects = $step instanceof Action ? $step->getSubjectKeys() : []; $subjectEntries = $this->getSubjectEntries($automationRun, $requiredSubjects); $args = new StepRunArgs($automation, $automationRun, $stepData, $subjectEntries); $validationArgs = new StepValidationArgs($automation, $stepData, array_map(function (SubjectEntry $entry) { return $entry->getSubject(); }, $subjectEntries)); $this->stepRunners[$stepType]->run($args, $validationArgs); $log->markCompletedSuccessfully(); } catch (Throwable $e) { $log->markFailed(); $log->setError($e); throw $e; } finally { try { $this->hooks->doAutomationStepAfterRun($log); } catch (Throwable $e) { // Ignore integration errors } $this->automationRunLogStorage->createAutomationRunLog($log); } } else { throw new InvalidStateException(); } $nextStep = $stepData->getNextSteps()[0] ?? null; $nextStepArgs = [ [ 'automation_run_id' => $automationRunId, 'step_id' => $nextStep ? $nextStep->getId() : null, ], ]; $this->automationRunStorage->updateNextStep($automationRunId, $nextStep ? $nextStep->getId() : null); // next step scheduled by action if ($this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $nextStepArgs)) { return; } // no need to schedule a new step if the next step is null, complete the run if (!$nextStep) { $this->automationRunStorage->updateStatus($automationRunId, AutomationRun::STATUS_COMPLETE); return; } // enqueue next step $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, $nextStepArgs); // TODO: allow long-running steps (that are not done here yet) } /** @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); } } } } Engine/Control/Steps/index.php000064400000000006150514567140012313 0ustar00registry = $registry; } public function run(StepRunArgs $runArgs, StepValidationArgs $validationArgs): void { $action = $this->registry->getAction($runArgs->getStep()->getKey()); if (!$action) { throw new InvalidStateException(); } $action->validate($validationArgs); $action->run($runArgs); } } Engine/Control/index.php000064400000000006150514567140011215 0ustar00registry = $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; } } } 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])) { $all[$newKey] = $transformer->transform($all[$key]); $queue[] = $newKey; } } } return array_values($all); } private function getTransformerMap(): array { $transformerMap = []; foreach ($this->registry->getSubjectTransformers() as $transformer) { $transformerMap[$transformer->accepts()] = array_merge($transformerMap[$transformer->accepts()] ?? [], [$transformer]); } return $transformerMap; } } Engine/Control/ActionScheduler.php000064400000001201150514567140013160 0ustar00registry = $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); } } Engine/Integration.php000064400000000272150514567140010756 0ustar00deleteController = $deleteController; } public function handle(Request $request): Response { $automationId = intval($request->getParam('id')); $this->deleteController->deleteAutomation($automationId); return new Response(null); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Engine/Endpoints/Automations/AutomationsGetEndpoint.php000064400000002277150514567140017414 0ustar00automationMapper = $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()), ]; } } Engine/Endpoints/Automations/AutomationsDuplicateEndpoint.php000064400000002314150514567140020577 0ustar00automationMapper = $automationMapper; $this->duplicateController = $duplicateController; } public function handle(Request $request): Response { $automationId = intval($request->getParam('id')); $duplicate = $this->duplicateController->duplicateAutomation($automationId); return new Response($this->automationMapper->buildAutomation($duplicate)); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Engine/Endpoints/Automations/AutomationTemplatesGetEndpoint.php000064400000001741150514567140021103 0ustar00storage = $storage; } public function handle(Request $request): Response { $templates = $this->storage->getTemplates((int)$request->getParam('category')); return new Response(array_map(function (AutomationTemplate $automation) { return $automation->toArray(); }, $templates)); } public static function getRequestSchema(): array { return [ 'category' => Builder::integer()->nullable(), ]; } } Engine/Endpoints/Automations/AutomationsPutEndpoint.php000064400000002636150514567140017444 0ustar00updateController = $updateController; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { $data = $request->getParams(); $automation = $this->updateController->updateAutomation(intval($request->getParam('id')), $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(), ]; } } Engine/Endpoints/Automations/index.php000064400000000006150514567140014043 0ustar00createAutomationFromTemplateController = $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(), ]; } } Engine/API/Endpoint.php000064400000000564150514567140010670 0ustar00api = $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); } } Engine/API/index.php000064400000000006150514567140010206 0ustar00errorCode = $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; } } Engine/Exceptions/index.php000064400000000006150514567140011716 0ustar00automationsTable = $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->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); } public function getAutomation(int $automationId, int $versionId = null): ?Automation { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $query = !$versionId ? (string)$this->wpdb->prepare( " 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 ", $automationId ) : (string)$this->wpdb->prepare( " 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 = %d ", $versionId ); $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); $query = $status ? (string)$this->wpdb->prepare(" 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 ) AND a.status IN (%s) ORDER BY a.id DESC", implode(",", $status) ) : " 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 ) ORDER BY a.id DESC "; $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function (array $automationData) { 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); $query = (string)$this->wpdb->prepare( " 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 ", 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); $query = (string)$this->wpdb->prepare( " 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 ) ", Automation::STATUS_ACTIVE, $triggerKey ); $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function (array $automationData) { 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 $query = (string)$this->wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber " 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 ", $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(); $runLogsQuery = (string)$this->wpdb->prepare( " DELETE FROM $automationRunLogsTable WHERE automation_run_id IN ( SELECT id FROM $automationRunsTable WHERE automation_id = %d ) ", $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)')); $query = (string)$this->wpdb->prepare( "INSERT IGNORE INTO {$triggersTable} (automation_id, trigger_key) VALUES {$placeholders}", 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')); $query = $triggerKeys ? (string)$this->wpdb->prepare( "DELETE FROM {$triggersTable} WHERE automation_id = %d AND trigger_key NOT IN ({$placeholders})", array_merge([$automationId], $triggerKeys) ) : (string)$this->wpdb->prepare("DELETE FROM {$triggersTable} WHERE automation_id = %d", $automationId); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } } Engine/Storage/AutomationTemplateStorage.php000064400000013503150514567140015241 0ustar00builder = $builder; $this->wp = $wp; } public function getTemplateBySlug(string $slug): ?AutomationTemplate { if (!$this->templates) { $this->templates = $this->createTemplates(); } foreach ($this->templates as $template) { if ($template->getSlug() === $slug) { return $template; } } return null; } /** @return AutomationTemplate[] */ public function getTemplates(int $category = null): array { if (!$this->templates) { $this->templates = $this->createTemplates(); } if (!$category) { return $this->templates; } return array_values( array_filter( $this->templates, function(AutomationTemplate $template) use ($category): bool { return $template->getCategory() === $category; } ) ); } private function createTemplates(): array { $subscriberWelcomeEmail = new AutomationTemplate( 'subscriber-welcome-email', AutomationTemplate::CATEGORY_WELCOME, __( "Send a welcome email when someone subscribes to your list. Optionally, you can choose to send this email after a specified period.", 'mailpoet' ), $this->builder->createFromSequence( __('Welcome new subscribers', 'mailpoet'), [ 'mailpoet:someone-subscribes', 'core:delay', 'mailpoet:send-email', ], [ [], [ 'delay' => 1, 'delay_type' => 'MINUTES', ], [], ], [ 'mailpoet:run-once-per-subscriber' => true, ] ), AutomationTemplate::TYPE_FREE_ONLY ); $userWelcomeEmail = new AutomationTemplate( 'user-welcome-email', AutomationTemplate::CATEGORY_WELCOME, __( "Send a welcome email when a new WordPress user registers to your website. Optionally, you can choose to send this email after a specified period.", 'mailpoet' ), $this->builder->createFromSequence( __('Welcome new WordPress users', 'mailpoet'), [ 'mailpoet:wp-user-registered', 'core:delay', 'mailpoet:send-email', ], [ [], [ 'delay' => 1, 'delay_type' => 'MINUTES', ], [], ], [ 'mailpoet:run-once-per-subscriber' => true, ] ), AutomationTemplate::TYPE_FREE_ONLY ); $subscriberWelcomeSeries = new AutomationTemplate( 'subscriber-welcome-series', AutomationTemplate::CATEGORY_WELCOME, __( "Welcome new subscribers and start building a relationship with them. Send an email immediately after someone subscribes to your list to introduce your brand and a follow-up two days later to keep the conversation going.", 'mailpoet' ), $this->builder->createFromSequence( __('Welcome series for new subscribers', 'mailpoet'), [] ), AutomationTemplate::TYPE_PREMIUM ); $userWelcomeSeries = new AutomationTemplate( 'user-welcome-series', AutomationTemplate::CATEGORY_WELCOME, __( "Welcome new WordPress users to your site. Send an email immediately after a WordPress user registers. Send a follow-up email two days later with more in-depth information.", 'mailpoet' ), $this->builder->createFromSequence( __('Welcome series for new WordPress users', 'mailpoet'), [] ), AutomationTemplate::TYPE_PREMIUM ); $firstPurchase = new AutomationTemplate( 'first-purchase', AutomationTemplate::CATEGORY_WOOCOMMERCE, __( "Welcome your first-time customers by sending an email with a special offer for their next purchase. Make them feel appreciated within your brand.", 'mailpoet' ), $this->builder->createFromSequence( __('Celebrate first-time buyers', 'mailpoet'), [] ), AutomationTemplate::TYPE_COMING_SOON ); $loyalCustomers = new AutomationTemplate( 'loyal-customers', AutomationTemplate::CATEGORY_WOOCOMMERCE, __( "These are your most important customers. Make them feel special by sending a thank you note for supporting your brand.", 'mailpoet' ), $this->builder->createFromSequence( __('Thank loyal customers', 'mailpoet'), [] ), AutomationTemplate::TYPE_COMING_SOON ); $abandonedCart = new AutomationTemplate( 'abandoned-cart', AutomationTemplate::CATEGORY_ABANDONED_CART, __( "Nudge your shoppers to complete the purchase after they added a product to the cart but haven't completed the order. Offer a coupon code as a last resort to convert them to customers.", 'mailpoet' ), $this->builder->createFromSequence( __('Abandoned cart reminder', 'mailpoet'), [] ), AutomationTemplate::TYPE_COMING_SOON ); $templates = $this->wp->applyFilters(Hooks::AUTOMATION_TEMPLATES, [ $subscriberWelcomeEmail, $userWelcomeEmail, $subscriberWelcomeSeries, $userWelcomeSeries, $firstPurchase, $loyalCustomers, $abandonedCart, ]); return is_array($templates) ? $templates : []; } } Engine/Storage/AutomationRunLogStorage.php000064400000004157150514567140014701 0ustar00table = $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 getAutomationRunLog(int $id): ?AutomationRunLog { $table = esc_sql($this->table); $query = $this->wpdb->prepare("SELECT * FROM $table WHERE id = %d", $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; } /** * @param int $automationRunId * @return AutomationRunLog[] * @throws InvalidStateException */ public function getLogsForAutomationRun(int $automationRunId): array { $table = esc_sql($this->table); $query = $this->wpdb->prepare(" SELECT * FROM $table WHERE automation_run_id = %d ORDER BY id ASC ", $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) { return AutomationRunLog::fromArray($data); }, $results); } return []; } public function truncate(): void { $table = esc_sql($this->table); $sql = "TRUNCATE $table"; $this->wpdb->query($sql); } } Engine/Storage/AutomationStatisticsStorage.php000064400000006000150514567140015612 0ustar00table = $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): AutomationStatistics { $data = $this->getStatistics([$automationId], $versionId); 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): array { $totalSubquery = $this->getStatsQuery($automationIds, $versionId); $runningSubquery = $this->getStatsQuery($automationIds, $versionId, 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); return array_combine(array_column($results, 'id'), $results) ?: []; } private function getStatsQuery(array $automationIds, int $versionId = null, string $status = null): string { $table = esc_sql($this->table); $placeholders = implode(',', array_fill(0, count($automationIds), '%d')); $versionCondition = strval($versionId ? $this->wpdb->prepare('AND version_id = %d', $versionId) : ''); $statusCondition = strval($status ? $this->wpdb->prepare('AND status = %s', $status) : ''); $query = $this->wpdb->prepare(" SELECT automation_id AS id, COUNT(*) AS count FROM $table WHERE automation_id IN ($placeholders) $versionCondition $statusCondition GROUP BY automation_id ", $automationIds); return strval($query); } } Engine/Storage/index.php000064400000000006150514567140011201 0ustar00table = $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); $query = (string)$this->wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id); $data = $this->wpdb->get_row($query, ARRAY_A); if (!is_array($data) || !$data) { return null; } $query = (string)$this->wpdb->prepare("SELECT * FROM $subjectTable WHERE automation_run_id = %d", $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); $query = (string)$this->wpdb->prepare("SELECT * FROM $table WHERE automation_id = %d order by id", $automation->getId()); $automationRuns = $this->wpdb->get_results($query, ARRAY_A); if (!is_array($automationRuns) || !$automationRuns) { return []; } $automationRunIds = array_column($automationRuns, 'id'); $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(array $runData) use ($subjects): AutomationRun { $runData['subjects'] = array_values(array_filter( is_array($subjects) ? $subjects : [], function(array $subjectData) use ($runData): bool { 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); $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 { if (!count($status)) { return 0; } $table = esc_sql($this->table); $statusSql = (string)$this->wpdb->prepare(implode(',', array_fill(0, count($status), '%s')), ...$status); $query = (string)$this->wpdb->prepare(" SELECT COUNT(id) as count FROM $table WHERE automation_id = %d AND status IN ($statusSql) ", $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); $query = (string)$this->wpdb->prepare(" UPDATE $table SET status = %s, updated_at = current_timestamp() WHERE id = %d ", $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); $query = (string)$this->wpdb->prepare(" UPDATE $table SET next_step_id = %s, updated_at = current_timestamp() WHERE id = %d ", $nextStepId, $id); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function truncate(): void { $table = esc_sql($this->table); $this->wpdb->query("TRUNCATE $table"); $table = esc_sql($this->subjectTable); $this->wpdb->query("TRUNCATE $table"); } } Engine/Utils/Json.php000064400000001567150514567140010514 0ustar00statisticsStorage = $statisticsStorage; } public function buildAutomation(Automation $automation): array { return [ 'id' => $automation->getId(), 'name' => $automation->getName(), 'status' => $automation->getStatus(), 'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C), 'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C), 'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null, 'author' => [ 'id' => $automation->getAuthor()->ID, 'name' => $automation->getAuthor()->display_name, ], 'stats' => $this->statisticsStorage->getAutomationStats($automation->getId())->toArray(), 'steps' => array_map(function (Step $step) { return [ 'id' => $step->getId(), 'type' => $step->getType(), 'key' => $step->getKey(), 'args' => $step->getArgs(), 'next_steps' => array_map(function (NextStep $nextStep) { return $nextStep->toArray(); }, $step->getNextSteps()), 'filters' => $step->getFilters() ? $step->getFilters()->toArray() : null, ]; }, $automation->getSteps()), 'meta' => (object)$automation->getAllMetas(), ]; } /** @param Automation[] $automations */ public function buildAutomationList(array $automations): array { $statistics = $this->statisticsStorage->getAutomationStatisticsForAutomations(...$automations); return array_map(function (Automation $automation) use ($statistics) { return $this->buildAutomationListItem($automation, $statistics[$automation->getId()]); }, $automations); } private function buildAutomationListItem(Automation $automation, AutomationStatistics $statistics): array { return [ 'id' => $automation->getId(), 'name' => $automation->getName(), 'status' => $automation->getStatus(), 'created_at' => $automation->getCreatedAt()->format(DateTimeImmutable::W3C), 'updated_at' => $automation->getUpdatedAt()->format(DateTimeImmutable::W3C), 'stats' => $statistics->toArray(), 'activated_at' => $automation->getActivatedAt() ? $automation->getActivatedAt()->format(DateTimeImmutable::W3C) : null, 'author' => [ 'id' => $automation->getAuthor()->ID, 'name' => $automation->getAuthor()->display_name, ], ]; } } Engine/Mappers/index.php000064400000000006150514567140011204 0ustar00unknownStepRule = $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(), new NoSplitRule(), $this->unknownStepRule, new AtLeastOneTriggerRule(), new TriggerNeedsToBeFollowedByActionRule(), new ValidStepRule([ $this->validStepArgsRule, $this->validStepFiltersRule, $this->validStepOrderRule, $this->validStepValidationRule, ]), ]); } } Engine/Validation/AutomationRules/ConsistentStepMapRule.php000064400000002146150514567140020175 0ustar00getSteps() 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 { } } Engine/Validation/AutomationRules/ValidStepOrderRule.php000064400000003455150514567140017445 0ustar00registry = $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 { } } Engine/Validation/AutomationRules/TriggerNeedsToBeFollowedByActionRule.php000064400000002704150514567140023033 0ustar00needsFullValidation()) { return; } $step = $node->getStep(); if ($step->getType() !== Step::TYPE_TRIGGER) { return; } $nextSteps = $step->getNextSteps(); if (!count($nextSteps)) { throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID); } foreach ($nextSteps as $step) { $step = $automation->getStep($step->getId()); 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 { } } Engine/Validation/AutomationRules/NoJoinRule.php000064400000002242150514567140015743 0ustar00 */ private $visitedSteps = []; public function initialize(Automation $automation): void { $this->visitedSteps = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); $this->visitedSteps[$step->getId()] = $step; foreach ($step->getNextSteps() as $nextStep) { $nextStepId = $nextStep->getId(); if (isset($this->visitedSteps[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Path join found in automation graph', 'mailpoet'), self::RULE_ID); } } } public function complete(Automation $automation): void { } } Engine/Validation/AutomationRules/NoCycleRule.php000064400000002372150514567140016107 0ustar00getStep(); $parents = $node->getParents(); $parentIdsMap = array_combine( array_map(function (Step $parent) { return $parent->getId(); }, $node->getParents()), $parents ) ?: []; foreach ($step->getNextSteps() as $nextStep) { $nextStepId = $nextStep->getId(); 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 { } } Engine/Validation/AutomationRules/NoSplitRule.php000064400000001575150514567140016147 0ustar00getStep(); if (count($step->getNextSteps()) > 1) { throw Exceptions::automationStructureNotValid(__('Path split found in automation graph', 'mailpoet'), self::RULE_ID); } } public function complete(Automation $automation): void { } } Engine/Validation/AutomationRules/ValidStepArgsRule.php000064400000003772150514567140017270 0ustar00registry = $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 { } } Engine/Validation/AutomationRules/index.php000064400000000006150514567140015022 0ustar00}> */ 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' => []]; } $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' => []]; } $this->errors[$stepId]['fields'] = array_merge( $this->mapErrorCodesToErrorMessages($e->getErrors()), $this->errors[$stepId]['fields'] ); } catch (Throwable $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => __('Unknown error.', 'mailpoet'), 'fields' => []]; } } } } 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); } } } Engine/Validation/AutomationRules/ValidStepFiltersRule.php000064400000002534150514567140017777 0ustar00registry = $registry; $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() : []; foreach ($groups as $group) { foreach ($group->getFilters() as $filter) { $registryFilter = $this->registry->getFilter($filter->getFieldType()); if (!$registryFilter) { continue; } $this->validator->validate($registryFilter->getArgsSchema(), $filter->getArgs()); } } } public function complete(Automation $automation): void { } } Engine/Validation/AutomationRules/ValidStepValidationRule.php000064400000004342150514567140020460 0ustar00registry = $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; } } Engine/Validation/AutomationRules/NoDuplicateEdgesRule.php000064400000002047150514567140017731 0ustar00getStep()->getNextSteps() as $nextStep) { if (isset($visitedNextStepIdsMap[$nextStep->getId()])) { throw Exceptions::automationStructureNotValid(__('Duplicate next step definition found', 'mailpoet'), self::RULE_ID); } $visitedNextStepIdsMap[$nextStep->getId()] = true; } } public function complete(Automation $automation): void { } } Engine/Validation/AutomationRules/TriggersUnderRootRule.php000064400000002611150514567140020177 0ustar00 $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->getNextSteps() as $nextStep) { $nextStepId = $nextStep->getId(); 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 { } } Engine/Validation/AutomationRules/UnknownStepRule.php000064400000004335150514567140017047 0ustar00registry = $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; } } Engine/Validation/AutomationRules/AtLeastOneTriggerRule.php000064400000002257150514567140020100 0ustar00triggerFound = 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); } } Engine/Validation/AutomationRules/NoUnreachableStepsRule.php000064400000002031150514567140020270 0ustar00visitedNodes = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $this->visitedNodes[] = $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); } } } Engine/Validation/index.php000064400000000006150514567140011667 0ustar00 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(), ]) )->maxItems(1); } 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(), ]); } } Engine/Validation/AutomationGraph/AutomationNodeVisitor.php000064400000000643150514567140020177 0ustar00step = $step; $this->parents = $parents; } public function getStep(): Step { return $this->step; } /** @return Step[] */ public function getParents(): array { return $this->parents; } } Engine/Validation/AutomationGraph/index.php000064400000000006150514567140014771 0ustar00getSteps(); $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(); $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' ); } } Engine/Hooks.php000064400000003647150514567140007567 0ustar00wordPress = $wordPress; } public const INITIALIZE = 'mailpoet/automation/initialize'; public const API_INITIALIZE = 'mailpoet/automation/api/initialize'; public const STEP_RUNNER_INITIALIZE = 'mailpoet/automation/step_runner/initialize'; public const TRIGGER = 'mailpoet/automation/trigger'; public const AUTOMATION_STEP = 'mailpoet/automation/step'; public const EDITOR_BEFORE_LOAD = 'mailpoet/automation/editor/before_load'; public const AUTOMATION_BEFORE_SAVE = 'mailpoet/automation/before_save'; public const AUTOMATION_STEP_BEFORE_SAVE = 'mailpoet/automation/step/before_save'; public const AUTOMATION_RUN_LOG_AFTER_STEP_RUN = 'mailpoet/automation/step/after_run'; public const AUTOMATION_RUN_CREATE = 'mailpoet/automation/run/create'; public const AUTOMATION_TEMPLATES = 'mailpoet/automation/templates'; public function doAutomationBeforeSave(Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_BEFORE_SAVE, $automation); } public function doAutomationStepBeforeSave(Step $step, Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE, $step, $automation); } public function doAutomationStepByKeyBeforeSave(Step $step, Automation $automation): void { $this->wordPress->doAction(self::AUTOMATION_STEP_BEFORE_SAVE . '/key=' . $step->getKey(), $step, $automation); } public function doAutomationStepAfterRun(AutomationRunLog $automationRunLog): void { $this->wordPress->doAction(self::AUTOMATION_RUN_LOG_AFTER_STEP_RUN, $automationRunLog); } } Engine/index.php000064400000000006150514567140007575 0ustar00 */ private $steps = []; /** @var array> */ private $subjects = []; /** @var SubjectTransformer[] */ private $subjectTransformers = []; /** @var array */ private $fields = []; /** @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; } /** @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; foreach ($subject->getFields() as $field) { $this->addField($field); } } /** @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 addField(Field $field): void { $key = $field->getKey(); if (isset($this->fields[$key])) { throw new \Exception(); // TODO } $this->fields[$key] = $field; } public function getField(string $key): ?Field { return $this->fields[$key] ?? null; } /** @return array */ public function getFields(): array { 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); } } Engine/Builder/DeleteAutomationController.php000064400000001620150514567140015366 0ustar00automationStorage = $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; } } Engine/Builder/UpdateAutomationController.php000064400000010610150514567140015405 0ustar00hooks = $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; } } Engine/Builder/CreateAutomationFromTemplateController.php000064400000003057150514567140017715 0ustar00storage = $storage; $this->templateStorage = $templateStorage; $this->automationValidator = $automationValidator; } public function createAutomation(string $slug): Automation { $template = $this->templateStorage->getTemplateBySlug($slug); if (!$template) { throw Exceptions::automationTemplateNotFound($slug); } $automation = $template->getAutomation(); $this->automationValidator->validate($automation); $automationId = $this->storage->createAutomation($automation); $savedAutomation = $this->storage->getAutomation($automationId); if (!$savedAutomation) { throw new InvalidStateException('Automation not found.'); } return $savedAutomation; } } Engine/Builder/DuplicateAutomationController.php000064400000005257150514567140016110 0ustar00wordPress = $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 { return new NextStep($newIds[$nextStep->getId()]); }, $step->getNextSteps()) ); } return $newSteps; } private function getId(): string { return Security::generateRandomString(16); } } Engine/Builder/index.php000064400000000006150514567140011163 0ustar00registry = $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); } } Engine/Engine.php000064400000006200150514567140007675 0ustar00api = $api; $this->coreIntegration = $coreIntegration; $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->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); }); } private function registerActiveTriggerHooks(): void { $triggerKeys = $this->automationStorage->getActiveTriggerKeys(); foreach ($triggerKeys as $triggerKey) { $instance = $this->registry->getTrigger($triggerKey); if ($instance) { $instance->registerHooks(); } } } } Engine/Data/AutomationStatistics.php000064400000002243150514567140013537 0ustar00automationId = $automationId; $this->entered = $entered; $this->inProgress = $inProcess; $this->versionId = $versionId; } public function getAutomationId(): int { return $this->automationId; } public function getVersionId(): ?int { return $this->versionId; } public function getEntered(): int { return $this->entered; } public function getInProgress(): int { return $this->inProgress; } public function getExited(): int { return $this->getEntered() - $this->getInProgress(); } public function toArray(): array { return [ 'automation_id' => $this->getAutomationId(), 'totals' => [ 'entered' => $this->getEntered(), 'in_progress' => $this->getInProgress(), 'exited' => $this->getExited(), ], ]; } } Engine/Data/StepRunArgs.php000064400000010421150514567140011556 0ustar00>[]> */ private $subjectEntries = []; /** @var array */ private $subjectKeyClassMap = []; /** @var array */ private $fields = []; /** @var array */ private $fieldToSubjectMap = []; /** @param SubjectEntry>[] $subjectsEntries */ public function __construct( Automation $automation, AutomationRun $automationRun, Step $step, array $subjectsEntries ) { $this->automation = $automation; $this->step = $step; $this->automationRun = $automationRun; 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; } } Engine/Data/AutomationTemplate.php000064400000004110150514567140013153 0ustar00slug = $slug; $this->category = $category; $this->description = $description; $this->automation = $automation; $this->type = $type; } public function getSlug(): string { return $this->slug; } public function getName(): string { return $this->automation->getName(); } public function getCategory(): int { return $this->category; } public function getType(): string { return $this->type; } public function getDescription(): string { return $this->description; } public function getAutomation(): Automation { return $this->automation; } public function toArray(): array { return [ 'slug' => $this->getSlug(), 'name' => $this->getName(), 'category' => $this->getCategory(), 'type' => $this->getType(), 'description' => $this->getDescription(), 'automation' => $this->getAutomation()->toArray(), ]; } } Engine/Data/AutomationRun.php000064400000006102150514567140012147 0ustar00automationId = $automationId; $this->versionId = $versionId; $this->triggerKey = $triggerKey; $this->subjects = $subjects; if ($id) { $this->id = $id; } $now = new DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } public function getId(): int { return $this->id; } public function getAutomationId(): int { return $this->automationId; } public function getVersionId(): int { return $this->versionId; } public function getTriggerKey(): string { return $this->triggerKey; } public function getStatus(): string { return $this->status; } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } /** @return Subject[] */ public function getSubjects(string $key = null): array { if ($key) { return array_values( array_filter($this->subjects, function (Subject $subject) use ($key) { return $subject->getKey() === $key; }) ); } return $this->subjects; } public function toArray(): array { return [ 'automation_id' => $this->automationId, 'version_id' => $this->versionId, 'trigger_key' => $this->triggerKey, 'status' => $this->status, 'created_at' => $this->createdAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'subjects' => array_map(function (Subject $subject): array { return $subject->toArray(); }, $this->subjects), ]; } public static function fromArray(array $data): self { $automationRun = new AutomationRun( (int)$data['automation_id'], (int)$data['version_id'], $data['trigger_key'], array_map(function (array $subject) { return Subject::fromArray($subject); }, $data['subjects']) ); $automationRun->id = (int)$data['id']; $automationRun->status = $data['status']; $automationRun->createdAt = new DateTimeImmutable($data['created_at']); $automationRun->updatedAt = new DateTimeImmutable($data['updated_at']); return $automationRun; } } Engine/Data/Filter.php000064400000002616150514567140010575 0ustar00id = $id; $this->fieldType = $fieldType; $this->fieldKey = $fieldKey; $this->condition = $condition; $this->args = $args; } public function getId(): string { return $this->id; } public function getFieldType(): string { return $this->fieldType; } public function getFieldKey(): string { return $this->fieldKey; } public function getCondition(): string { return $this->condition; } public function getArgs(): array { return $this->args; } public function toArray(): array { return [ 'id' => $this->id, 'field_type' => $this->fieldType, 'field_key' => $this->fieldKey, 'condition' => $this->condition, 'args' => $this->args, ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['field_type'], $data['field_key'], $data['condition'], $data['args'] ); } } Engine/Data/SubjectEntry.php000064400000002413150514567140011764 0ustar00 */ class SubjectEntry { /** @var S */ private $subject; /** @var SubjectData */ private $subjectData; /** @var Payload|null */ private $payloadCache; /** @param S $subject */ public function __construct( Subject $subject, SubjectData $subjectData ) { $this->subject = $subject; $this->subjectData = $subjectData; } /** @return S */ public function getSubject(): Subject { return $this->subject; } public function getSubjectData(): SubjectData { return $this->subjectData; } /** @return Payload */ public function getPayload() { if ($this->payloadCache === null) { try { $this->payloadCache = $this->subject->getPayload($this->subjectData); } catch (Throwable $e) { throw Exceptions::subjectLoadFailed($this->subject->getKey(), $this->subjectData->getArgs()); } } return $this->payloadCache; } } Engine/Data/AutomationRunLog.php000064400000007726150514567140012626 0ustar00automationRunId = $automationRunId; $this->stepId = $stepId; $this->status = self::STATUS_RUNNING; if ($id) { $this->id = $id; } $this->startedAt = new DateTimeImmutable(); $this->error = []; $this->data = []; } public function getId(): int { return $this->id; } public function getAutomationRunId(): int { return $this->automationRunId; } public function getStepId(): string { return $this->stepId; } public function getStatus(): string { return $this->status; } public function getError(): array { return $this->error; } public function getData(): array { return $this->data; } public function getCompletedAt(): ?DateTimeImmutable { return $this->completedAt; } /** * @param string $key * @param mixed $value * @return void */ 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; } public function getStartedAt(): DateTimeImmutable { return $this->startedAt; } public function toArray(): array { return [ 'automation_run_id' => $this->automationRunId, 'step_id' => $this->stepId, 'status' => $this->status, 'started_at' => $this->startedAt->format(DateTimeImmutable::W3C), 'completed_at' => $this->completedAt ? $this->completedAt->format(DateTimeImmutable::W3C) : null, 'error' => Json::encode($this->error), 'data' => Json::encode($this->data), ]; } public function markCompletedSuccessfully(): void { $this->status = self::STATUS_COMPLETED; $this->completedAt = new DateTimeImmutable(); } public function markFailed(): void { $this->status = self::STATUS_FAILED; $this->completedAt = new DateTimeImmutable(); } public function setError(Throwable $error): void { $error = [ 'message' => $error->getMessage(), 'errorClass' => get_class($error), 'code' => $error->getCode(), 'trace' => $error->getTrace(), ]; $this->error = $error; } public static function fromArray(array $data): self { $automationRunLog = new AutomationRunLog((int)$data['automation_run_id'], $data['step_id']); $automationRunLog->id = (int)$data['id']; $automationRunLog->status = $data['status']; $automationRunLog->error = Json::decode($data['error']); $automationRunLog->data = Json::decode($data['data']); $automationRunLog->startedAt = new DateTimeImmutable($data['started_at']); if ($data['completed_at']) { $automationRunLog->completedAt = new DateTimeImmutable($data['completed_at']); } return $automationRunLog; } /** * @param mixed $data * @return bool */ private function isDataStorable($data): bool { if (is_object($data)) { return false; } if (is_scalar($data)) { return true; } if (!is_array($data)) { return false; } foreach ($data as $value) { if (!$this->isDataStorable($value)) { return false; } } return true; } } Engine/Data/Subject.php000064400000001615150514567140010745 0ustar00key = $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'])); } } Engine/Data/FilterGroup.php000064400000002302150514567140011602 0ustar00id = $id; $this->operator = $operator; $this->filters = $filters; } public function getId(): string { return $this->id; } public function getOperator(): string { return $this->operator; } public function getFilters(): array { return $this->filters; } public function toArray(): array { return [ 'id' => $this->id, 'operator' => $this->operator, 'filters' => array_map(function (Filter $filter): array { return $filter->toArray(); }, $this->filters), ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['operator'], array_map(function (array $filter) { return Filter::fromArray($filter); }, $data['filters']) ); } } Engine/Data/Field.php000064400000002555150514567140010375 0ustar00key = $key; $this->type = $type; $this->name = $name; $this->factory = $factory; $this->args = $args; } public function getKey(): string { return $this->key; } public function getType(): string { return $this->type; } public function getName(): string { return $this->name; } public function getFactory(): callable { return $this->factory; } /** @return mixed */ public function getValue(Payload $payload) { return $this->getFactory()($payload); } public function getArgs(): array { return $this->args; } } Engine/Data/Automation.php000064400000014133150514567140011465 0ustar00 */ private $steps; /** @var array */ private $meta = []; /** @param array $steps */ public function __construct( string $name, array $steps, \WP_User $author, int $id = null, int $versionId = null ) { $this->name = $name; $this->steps = $steps; $this->author = $author; $this->id = $id; $this->versionId = $versionId; $now = new DateTimeImmutable(); $this->createdAt = $now; $this->updatedAt = $now; } public function getId(): int { if (!$this->id) { throw InvalidStateException::create()->withMessage('No automation ID was set'); } return $this->id; } public function getVersionId(): int { if (!$this->versionId) { throw InvalidStateException::create()->withMessage('No automation version ID was set'); } return $this->versionId; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; $this->setUpdatedAt(); } public function getStatus(): string { return $this->status; } public function setStatus(string $status): void { if ($status === self::STATUS_ACTIVE && $this->status !== self::STATUS_ACTIVE) { $this->activatedAt = new DateTimeImmutable(); } $this->status = $status; $this->setUpdatedAt(); } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } public function getAuthor(): \WP_User { return $this->author; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } public function getActivatedAt(): ?DateTimeImmutable { return $this->activatedAt; } /** @return array */ public function getSteps(): array { return $this->steps; } /** * @return array */ public function getTriggers(): array { return array_filter( $this->steps, function (Step $step) { return $step->getType() === Step::TYPE_TRIGGER; } ); } /** @param array $steps */ public function setSteps(array $steps): void { $this->steps = $steps; $this->setUpdatedAt(); } public function getStep(string $id): ?Step { return $this->steps[$id] ?? null; } public function getTrigger(string $key): ?Step { foreach ($this->steps as $step) { if ($step->getType() === Step::TYPE_TRIGGER && $step->getKey() === $key) { return $step; } } return null; } public function equals(Automation $compare): bool { $compareArray = $compare->toArray(); $currentArray = $this->toArray(); $ignoreValues = [ 'created_at', 'updated_at', ]; foreach ($ignoreValues as $ignore) { unset($compareArray[$ignore]); unset($currentArray[$ignore]); } return $compareArray === $currentArray; } public function needsFullValidation(): bool { return in_array($this->status, [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true); } public function toArray(): array { return [ 'id' => $this->id, 'name' => $this->name, 'status' => $this->status, 'author' => $this->author->ID, 'created_at' => $this->createdAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'activated_at' => $this->activatedAt ? $this->activatedAt->format(DateTimeImmutable::W3C) : null, 'steps' => Json::encode( array_map(function (Step $step) { return $step->toArray(); }, $this->steps) ), 'meta' => Json::encode($this->meta), ]; } private function setUpdatedAt(): void { $this->updatedAt = new DateTimeImmutable(); } /** * @param string $key * @return mixed|null */ public function getMeta(string $key) { return $this->meta[$key] ?? null; } public function getAllMetas(): array { return $this->meta; } /** * @param string $key * @param mixed $value * @return void */ public function setMeta(string $key, $value): void { $this->meta[$key] = $value; $this->setUpdatedAt(); } public function deleteMeta(string $key): void { unset($this->meta[$key]); $this->setUpdatedAt(); } public function deleteAllMetas(): void { $this->meta = []; $this->setUpdatedAt(); } public static function fromArray(array $data): self { // TODO: validation $automation = new self( $data['name'], array_map(function (array $stepData): Step { return Step::fromArray($stepData); }, Json::decode($data['steps'])), new \WP_User((int)$data['author']) ); $automation->id = (int)$data['id']; $automation->versionId = (int)$data['version_id']; $automation->status = $data['status']; $automation->createdAt = new DateTimeImmutable($data['created_at']); $automation->updatedAt = new DateTimeImmutable($data['updated_at']); $automation->activatedAt = $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null; $automation->meta = $data['meta'] ? Json::decode($data['meta']) : []; return $automation; } } Engine/Data/NextStep.php000064400000000753150514567140011122 0ustar00id = $id; } public function getId(): string { return $this->id; } public function toArray(): array { return [ 'id' => $this->id, ]; } public static function fromArray(array $data): self { return new self($data['id']); } } Engine/Data/Step.php000064400000004245150514567140010263 0ustar00 $args * @param NextStep[] $nextSteps */ public function __construct( string $id, string $type, string $key, array $args, array $nextSteps, Filters $filters = null ) { $this->id = $id; $this->type = $type; $this->key = $key; $this->args = $args; $this->nextSteps = $nextSteps; $this->filters = $filters; } public function getId(): string { return $this->id; } public function getType(): string { return $this->type; } public function getKey(): string { return $this->key; } /** @return NextStep[] */ public function getNextSteps(): array { return $this->nextSteps; } /** @param NextStep[] $nextSteps */ public function setNextSteps(array $nextSteps): void { $this->nextSteps = $nextSteps; } public function getArgs(): array { return $this->args; } public function getFilters(): ?Filters { return $this->filters; } public function toArray(): array { return [ 'id' => $this->id, 'type' => $this->type, 'key' => $this->key, 'args' => $this->args, 'next_steps' => array_map(function (NextStep $nextStep) { return $nextStep->toArray(); }, $this->nextSteps), 'filters' => $this->filters ? $this->filters->toArray() : null, ]; } public static function fromArray(array $data): self { return new self( $data['id'], $data['type'], $data['key'], $data['args'], array_map(function (array $nextStep) { return NextStep::fromArray($nextStep); }, $data['next_steps']), isset($data['filters']) ? Filters::fromArray($data['filters']) : null ); } } Engine/Data/index.php000064400000000006150514567140010446 0ustar00operator = $operator; $this->groups = $groups; } public function getOperator(): string { return $this->operator; } public function getGroups(): array { return $this->groups; } public function toArray(): array { return [ 'operator' => $this->operator, 'groups' => array_map(function (FilterGroup $group): array { return $group->toArray(); }, $this->groups), ]; } public static function fromArray(array $data): self { return new self( $data['operator'], array_map(function (array $group) { return FilterGroup::fromArray($group); }, $data['groups']) ); } } Engine/Data/StepValidationArgs.php000064400000003515150514567140013112 0ustar00> */ private $subjects = []; /** @var array */ private $subjectKeyClassMap = []; /** @param Subject[] $subjects */ public function __construct( Automation $automation, Step $step, array $subjects ) { $this->automation = $automation; $this->step = $step; foreach ($subjects as $subject) { $key = $subject->getKey(); $this->subjects[$key] = $subject; $this->subjectKeyClassMap[get_class($subject)] = $key; } } public function getAutomation(): Automation { return $this->automation; } public function getStep(): Step { return $this->step; } /** @return Subject[] */ public function getSubjects(): array { return array_values($this->subjects); } /** @return Subject */ 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; } } Integrations/MailPoet/ContextFactory.php000064400000001477150514567140014432 0ustar00segmentsRepository = $segmentsRepository; } /** @return mixed[] */ public function getContextData(): array { return [ 'segments' => $this->getSegments(), ]; } private function getSegments(): array { $segments = []; foreach ($this->segmentsRepository->findAll() as $segment) { $segments[] = [ 'id' => $segment->getId(), 'name' => $segment->getName(), 'type' => $segment->getType(), ]; } return $segments; } } Integrations/MailPoet/MailPoetIntegration.php000064400000007356150514567140015376 0ustar00contextFactory = $contextFactory; $this->segmentSubject = $segmentSubject; $this->subscriberSubject = $subscriberSubject; $this->orderToSubscriberTransformer = $orderToSubscriberTransformer; $this->orderToSegmentTransformer = $orderToSegmentTransformer; $this->someoneSubscribesTrigger = $someoneSubscribesTrigger; $this->userRegistrationTrigger = $userRegistrationTrigger; $this->sendEmailAction = $sendEmailAction; $this->automationEditorLoadingHooks = $automationEditorLoadingHooks; $this->createAutomationRunHook = $createAutomationRunHook; } public function register(Registry $registry): void { $registry->addContextFactory('mailpoet', function () { return $this->contextFactory->getContextData(); }); $registry->addSubject($this->segmentSubject); $registry->addSubject($this->subscriberSubject); $registry->addTrigger($this->someoneSubscribesTrigger); $registry->addTrigger($this->userRegistrationTrigger); $registry->addAction($this->sendEmailAction); $registry->addSubjectTransformer($this->orderToSubscriberTransformer); $registry->addSubjectTransformer($this->orderToSegmentTransformer); // sync step args (subject, preheader, etc.) to email settings $registry->onBeforeAutomationStepSave( [$this->sendEmailAction, 'saveEmailSettings'], $this->sendEmailAction->getKey() ); $this->automationEditorLoadingHooks->init(); $this->createAutomationRunHook->init(); } } Integrations/MailPoet/Actions/index.php000064400000000006150514567140014150 0ustar00settings = $settings; $this->newslettersRepository = $newslettersRepository; $this->subscriberSegmentRepository = $subscriberSegmentRepository; $this->subscribersRepository = $subscribersRepository; $this->automationEmailScheduler = $automationEmailScheduler; $this->newsletterOptionsRepository = $newsletterOptionsRepository; $this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository; } public function getKey(): string { return self::KEY; } public function getName(): string { return __('Send email', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { $nameDefault = $this->settings->get('sender.name'); $addressDefault = $this->settings->get('sender.address'); $replyToNameDefault = $this->settings->get('reply_to.name'); $replyToAddressDefault = $this->settings->get('reply_to.address'); $nonEmptyString = Builder::string()->required()->minLength(1); return Builder::object([ // required fields 'email_id' => Builder::integer()->required(), 'name' => $nonEmptyString->default(__('Send email', 'mailpoet')), 'subject' => $nonEmptyString->default(__('Subject', 'mailpoet')), 'preheader' => Builder::string()->required()->default(''), 'sender_name' => $nonEmptyString->default($nameDefault), 'sender_address' => $nonEmptyString->formatEmail()->default($addressDefault), // optional fields 'reply_to_name' => ($replyToNameDefault && $replyToNameDefault !== $nameDefault) ? Builder::string()->minLength(1)->default($replyToNameDefault) : Builder::string()->minLength(1), 'reply_to_address' => ($replyToAddressDefault && $replyToAddressDefault !== $addressDefault) ? Builder::string()->formatEmail()->default($replyToAddressDefault) : Builder::string()->formatEmail(), 'ga_campaign' => Builder::string()->minLength(1), ]); } public function getSubjectKeys(): array { return [ 'mailpoet:segment', 'mailpoet:subscriber', ]; } public function validate(StepValidationArgs $args): void { try { $this->getEmailForStep($args->getStep()); } catch (InvalidStateException $exception) { $emailId = $args->getStep()->getArgs()['email_id'] ?? ''; if (empty($emailId)) { throw ValidationException::create() ->withError('email_id', __("Automation email not found.", 'mailpoet')); } throw ValidationException::create() ->withError( 'email_id', // translators: %s is the ID of email. sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId) ); } } public function run(StepRunArgs $args): void { $newsletter = $this->getEmailForStep($args->getStep()); $segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId(); $subscriberId = $args->getSinglePayloadByClass(SubscriberPayload::class)->getId(); $subscriberSegment = $this->subscriberSegmentRepository->findOneBy([ 'subscriber' => $subscriberId, 'segment' => $segmentId, 'status' => SubscriberEntity::STATUS_SUBSCRIBED, ]); if ($newsletter->getType() !== NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL && !$subscriberSegment) { throw InvalidStateException::create()->withMessage(sprintf("Subscriber ID '%s' is not subscribed to segment ID '%s'.", $subscriberId, $segmentId)); } $subscriber = $subscriberSegment ? $subscriberSegment->getSubscriber() : $this->subscribersRepository->findOneById($subscriberId); if (!$subscriber) { throw InvalidStateException::create(); } $subscriberStatus = $subscriber->getStatus(); if ($newsletter->getType() !== NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL && $subscriberStatus !== SubscriberEntity::STATUS_SUBSCRIBED) { throw InvalidStateException::create()->withMessage(sprintf("Cannot schedule a newsletter for subscriber ID '%s' because their status is '%s'.", $subscriberId, $subscriberStatus)); } if ($subscriberStatus === SubscriberEntity::STATUS_BOUNCED) { throw InvalidStateException::create()->withMessage(sprintf("Cannot schedule an email for subscriber ID '%s' because their status is '%s'.", $subscriberId, $subscriberStatus)); } $meta = $this->getNewsletterMeta($args); try { $this->automationEmailScheduler->createSendingTask($newsletter, $subscriber, $meta); } catch (Throwable $e) { throw InvalidStateException::create()->withMessage('Could not create sending task.'); } } private function getNewsletterMeta(StepRunArgs $args): array { if (!$this->automationHasAbandonedCartTrigger($args->getAutomation())) { return []; } $payload = $args->getSinglePayloadByClass(AbandonedCartPayload::class); return [AbandonedCart::TASK_META_NAME => $payload->getProductIds()]; } public function saveEmailSettings(Step $step, Automation $automation): void { $args = $step->getArgs(); if (!isset($args['email_id']) || !$args['email_id']) { return; } $email = $this->getEmailForStep($step); $email->setType($this->isTransactional($step, $automation) ? NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL : NewsletterEntity::TYPE_AUTOMATION); $email->setStatus(NewsletterEntity::STATUS_ACTIVE); $email->setSubject($args['subject'] ?? ''); $email->setPreheader($args['preheader'] ?? ''); $email->setSenderName($args['sender_name'] ?? ''); $email->setSenderAddress($args['sender_address'] ?? ''); $email->setReplyToName($args['reply_to_name'] ?? ''); $email->setReplyToAddress($args['reply_to_address'] ?? ''); $email->setGaCampaign($args['ga_campaign'] ?? ''); $this->storeNewsletterOption( $email, NewsletterOptionFieldEntity::NAME_GROUP, $this->automationHasWooCommerceTrigger($automation) ? 'woocommerce' : null ); $this->storeNewsletterOption( $email, NewsletterOptionFieldEntity::NAME_EVENT, $this->automationHasAbandonedCartTrigger($automation) ? 'woocommerce_abandoned_shopping_cart' : null ); $this->newslettersRepository->persist($email); $this->newslettersRepository->flush(); } private function storeNewsletterOption(NewsletterEntity $newsletter, string $optionName, string $optionValue = null): void { $options = $newsletter->getOptions()->toArray(); foreach ($options as $key => $option) { if ($option->getName() === $optionName) { if ($optionValue) { $option->setValue($optionValue); return; } $newsletter->getOptions()->remove($key); $this->newsletterOptionsRepository->remove($option); return; } } if (!$optionValue) { return; } $field = $this->newsletterOptionFieldsRepository->findOneBy([ 'name' => $optionName, 'newsletterType' => $newsletter->getType(), ]); if (!$field) { return; } $option = new NewsletterOptionEntity($newsletter, $field); $option->setValue($optionValue); $this->newsletterOptionsRepository->persist($option); $newsletter->getOptions()->add($option); } private function isTransactional(Step $step, Automation $automation): bool { $triggers = $automation->getTriggers(); $transactionalTriggers = array_filter( $triggers, function(Step $step): bool { return in_array($step->getKey(), ['woocommerce:order-status-changed'], true); } ); if (!$triggers || count($transactionalTriggers) !== count($triggers)) { return false; } foreach ($transactionalTriggers as $trigger) { $nextSteps = array_map( function(NextStep $nextStep): string { return $nextStep->getId(); }, $trigger->getNextSteps() ); if (!in_array($step->getId(), $nextSteps, true)) { return false; } } return true; } private function automationHasWooCommerceTrigger(Automation $automation): bool { return (bool)array_filter( $automation->getTriggers(), function(Step $step): bool { return in_array($step->getKey(), ['woocommerce:order-status-changed', 'woocommerce:abandoned-cart'], true); } ); } private function automationHasAbandonedCartTrigger(Automation $automation): bool { return (bool)array_filter( $automation->getTriggers(), function(Step $step): bool { return in_array($step->getKey(), ['woocommerce:abandoned-cart'], true); } ); } private function getEmailForStep(Step $step): NewsletterEntity { $emailId = $step->getArgs()['email_id'] ?? null; if (!$emailId) { throw InvalidStateException::create(); } $email = $this->newslettersRepository->findOneBy([ 'id' => $emailId, ]); if (!$email || !in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) { throw InvalidStateException::create()->withMessage( // translators: %s is the ID of email. sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId) ); } return $email; } } Integrations/MailPoet/Payloads/SubscriberPayload.php000064400000002050150514567140016633 0ustar00subscriber = $subscriber; } public function getId(): int { $id = $this->subscriber->getId(); if (!$id) { throw new InvalidStateException(); } return $id; } public function getEmail(): string { return $this->subscriber->getEmail(); } public function getStatus(): string { return $this->subscriber->getStatus(); } public function isWpUser(): bool { return $this->subscriber->isWPUser(); } public function getWpUserId(): ?int { return $this->subscriber->getWpUserId(); } public function getSubscriber(): SubscriberEntity { return $this->subscriber; } } Integrations/MailPoet/Payloads/index.php000064400000000006150514567140014324 0ustar00segment = $segment; } public function getId(): int { $id = $this->segment->getId(); if (!$id) { throw new InvalidStateException(); } return $id; } public function getName(): string { return $this->segment->getName(); } public function getType(): string { return $this->segment->getType(); } } Integrations/MailPoet/Hooks/CreateAutomationRunHook.php000064400000002535150514567140017307 0ustar00wp = $wp; $this->automationRunStorage = $automationRunStorage; } public function init(): void { $this->wp->addAction(Hooks::AUTOMATION_RUN_CREATE, [$this, 'createAutomationRun'], 5, 2); } public function createAutomationRun(bool $result, StepRunArgs $args): bool { if (!$result) { return $result; } $automation = $args->getAutomation(); $runOnlyOnce = $automation->getMeta('mailpoet:run-once-per-subscriber'); if (!$runOnlyOnce) { return true; } $subscriberSubject = $args->getAutomationRun()->getSubjects(SubscriberSubject::KEY); if (!$subscriberSubject) { return true; } return $this->automationRunStorage->getCountByAutomationAndSubject($automation, current($subscriberSubject)) === 0; } } Integrations/MailPoet/Hooks/AutomationEditorLoadingHooks.php000064400000005004150514567140020320 0ustar00wp = $wp; $this->automationStorage = $automationStorage; $this->newslettersRepository = $newslettersRepository; } public function init(): void { $this->wp->addAction(Hooks::EDITOR_BEFORE_LOAD, [$this, 'beforeEditorLoad']); } public function beforeEditorLoad(int $automationId): void { $automation = $this->automationStorage->getAutomation($automationId); if (!$automation) { return; } $this->disconnectEmptyEmailsFromSendEmailStep($automation); } private function disconnectEmptyEmailsFromSendEmailStep(Automation $automation): void { $sendEmailSteps = array_filter( $automation->getSteps(), function(Step $step): bool { return $step->getKey() === 'mailpoet:send-email'; } ); foreach ($sendEmailSteps as $step) { $emailId = $step->getArgs()['email_id'] ?? 0; if (!$emailId) { continue; } $newsletterEntity = $this->newslettersRepository->findOneById($emailId); if ($newsletterEntity && $newsletterEntity->getBody() !== null) { continue; } $this->newslettersRepository->bulkDelete([$emailId]); $args = $step->getArgs(); unset($args['email_id']); $updatedStep = new Step( $step->getId(), $step->getType(), $step->getKey(), $args, $step->getNextSteps() ); $steps = array_merge( $automation->getSteps(), [$updatedStep->getId() => $updatedStep] ); $automation->setSteps($steps); //To be valid, an email would need to be associated to an active automation. if ($automation->getStatus() === Automation::STATUS_ACTIVE) { $automation->setStatus(Automation::STATUS_DRAFT); } $this->automationStorage->updateAutomation($automation); } } } Integrations/MailPoet/Hooks/index.php000064400000000006150514567140013633 0ustar00segmentRepository = $segmentRepository; } public function accepts(): string { return OrderSubject::KEY; } public function returns(): string { return SegmentSubject::KEY; } public function transform(Subject $data): Subject { if ($this->accepts() !== $data->getKey()) { throw new \InvalidArgumentException('Invalid subject type'); } $wooCommerceSegment = $this->segmentRepository->getWooCommerceSegment(); return new Subject(SegmentSubject::KEY, ['segment_id' => $wooCommerceSegment->getId()]); } } Integrations/MailPoet/SubjectTransformers/index.php000064400000000006150514567140016555 0ustar00subscribersRepository = $subscribersRepository; $this->woocommerce = $woocommerce; $this->woocommerceHelper = $woocommerceHelper; } public function transform(Subject $data): Subject { if ($this->accepts() !== $data->getKey()) { throw new \InvalidArgumentException('Invalid subject type'); } $subscriber = $this->findOrCreateSubscriber($data); if (!$subscriber instanceof SubscriberEntity) { throw new \InvalidArgumentException('Subscriber not found'); } return new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]); } public function accepts(): string { return OrderSubject::KEY; } public function returns(): string { return SubscriberSubject::KEY; } private function findOrCreateSubscriber(Subject $order): ?SubscriberEntity { $subscriber = $this->findSubscriber($order); if ($subscriber) { return $subscriber; } $orderId = $order->getArgs()['order_id'] ?? null; if (!$orderId) { return null; } $this->woocommerce->synchronizeGuestCustomer($orderId); return $this->findSubscriber($order); } private function findSubscriber(Subject $order): ?SubscriberEntity { $orderId = $order->getArgs()['order_id'] ?? null; if (!$orderId) { return null; } $wcOrder = $this->woocommerceHelper->wcGetOrder($orderId); $billingEmail = $wcOrder->get_billing_email(); return $billingEmail ? $this->subscribersRepository->findOneBy(['email' => $billingEmail]) : $this->subscribersRepository->findOneBy(['wpUserId' => $wcOrder->get_user_id()]); } } Integrations/MailPoet/Subjects/SubscriberSubject.php000064400000012215150514567140016653 0ustar00 */ class SubscriberSubject implements Subject { const KEY = 'mailpoet:subscriber'; /** @var SegmentsFinder */ private $segmentsFinder; /** @var SegmentsRepository */ private $segmentsRepository; /** @var SubscribersRepository */ private $subscribersRepository; public function __construct( SegmentsFinder $segmentsFinder, SegmentsRepository $segmentsRepository, SubscribersRepository $subscribersRepository ) { $this->segmentsFinder = $segmentsFinder; $this->segmentsRepository = $segmentsRepository; $this->subscribersRepository = $subscribersRepository; } public function getKey(): string { return self::KEY; } public function getName(): string { return __('MailPoet subscriber', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'subscriber_id' => Builder::integer()->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { $id = $subjectData->getArgs()['subscriber_id']; $subscriber = $this->subscribersRepository->findOneById($id); if (!$subscriber) { // translators: %d is the ID. throw NotFoundException::create()->withMessage(sprintf(__("Subscriber with ID '%d' not found.", 'mailpoet'), $id)); } return new SubscriberPayload($subscriber); } /** @return Field[] */ public function getFields(): array { return [ new Field( 'mailpoet:subscriber:email', Field::TYPE_STRING, __('Subscriber email', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getEmail(); } ), new Field( 'mailpoet:subscriber:engagement-score', Field::TYPE_NUMBER, __('Engagement score', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getSubscriber()->getEngagementScore(); } ), new Field( 'mailpoet:subscriber:is-globally-subscribed', Field::TYPE_BOOLEAN, __('Is globally subscribed', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED; } ), new Field( 'mailpoet:subscriber:last-engaged', Field::TYPE_DATETIME, __('Last engaged', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getSubscriber()->getLastEngagementAt(); } ), new Field( 'mailpoet:subscriber:status', Field::TYPE_ENUM, __('Subscriber status', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getStatus(); }, [ 'options' => [ [ 'id' => SubscriberEntity::STATUS_SUBSCRIBED, 'name' => __('Subscribed', 'mailpoet'), ], [ 'id' => SubscriberEntity::STATUS_UNCONFIRMED, 'name' => __('Unconfirmed', 'mailpoet'), ], [ 'id' => SubscriberEntity::STATUS_UNSUBSCRIBED, 'name' => __('Unsubscribed', 'mailpoet'), ], [ 'id' => SubscriberEntity::STATUS_INACTIVE, 'name' => __('Inactive', 'mailpoet'), ], [ 'id' => SubscriberEntity::STATUS_BOUNCED, 'name' => __('Bounced', 'mailpoet'), ], ], ] ), new Field( 'mailpoet:subscriber:segments', Field::TYPE_ENUM_ARRAY, __('Subscriber segments', 'mailpoet'), function (SubscriberPayload $payload) { $segments = $this->segmentsFinder->findDynamicSegments($payload->getSubscriber()); $value = []; foreach ($segments as $segment) { $value[] = $segment->getId(); } return $value; }, [ 'options' => array_map(function ($segment) { return [ 'id' => $segment->getId(), 'name' => $segment->getName(), ]; }, $this->segmentsRepository->findBy(['type' => SegmentEntity::TYPE_DYNAMIC])), ] ), new Field( 'mailpoet:subscriber:email-sent-count', Field::TYPE_INTEGER, __('Email — sent count', 'mailpoet'), function (SubscriberPayload $payload) { return $payload->getSubscriber()->getEmailCount(); } ), ]; } } Integrations/MailPoet/Subjects/index.php000064400000000006150514567140014332 0ustar00 */ class SegmentSubject implements Subject { const KEY = 'mailpoet:segment'; /** @var SegmentsRepository */ private $segmentsRepository; public function __construct( SegmentsRepository $segmentsRepository ) { $this->segmentsRepository = $segmentsRepository; } public function getKey(): string { return self::KEY; } public function getName(): string { return __('MailPoet segment', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'segment_id' => Builder::integer()->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { $id = $subjectData->getArgs()['segment_id']; $segment = $this->segmentsRepository->findOneById($id); if (!$segment) { // translators: %d is the ID. throw NotFoundException::create()->withMessage(sprintf(__("Segment with ID '%d' not found.", 'mailpoet'), $id)); } return new SegmentPayload($segment); } /** @return Field[] */ public function getFields(): array { return [ // phpcs:disable Squiz.PHP.CommentedOutCode.Found -- temporarily hide those fields /* new Field( 'mailpoet:segment:id', Field::TYPE_INTEGER, __('Segment ID', 'mailpoet'), function (SegmentPayload $payload) { return $payload->getId(); } ), new Field( 'mailpoet:segment:name', Field::TYPE_STRING, __('Segment name', 'mailpoet'), function (SegmentPayload $payload) { return $payload->getName(); } ), */ ]; } } Integrations/MailPoet/index.php000064400000000006150514567140012550 0ustar00wp = $wp; $this->segmentsRepository = $segmentsRepository; } public function getKey(): string { return 'mailpoet:someone-subscribes'; } public function getName(): string { return __('Someone subscribes', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'segment_ids' => Builder::array(Builder::number()), ]); } public function getSubjectKeys(): array { return [ SubscriberSubject::KEY, SegmentSubject::KEY, ]; } public function validate(StepValidationArgs $args): void { } public function registerHooks(): void { $this->wp->addAction('mailpoet_segment_subscribed', [$this, 'handleSubscription'], 10, 2); } public function handleSubscription(SubscriberSegmentEntity $subscriberSegment): void { $segment = $subscriberSegment->getSegment(); $subscriber = $subscriberSegment->getSubscriber(); if (!$segment || !$subscriber) { throw new InvalidStateException(); } $this->wp->doAction(Hooks::TRIGGER, $this, [ new Subject(SegmentSubject::KEY, ['segment_id' => $segment->getId()]), new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]), ]); } public function isTriggeredBy(StepRunArgs $args): bool { $segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId(); $segment = $this->segmentsRepository->findOneById($segmentId); if (!$segment || $segment->getType() !== SegmentEntity::TYPE_DEFAULT) { return false; } // Triggers when no segment IDs defined (= any segment) or the current segment paylo. $triggerArgs = $args->getStep()->getArgs(); $segmentIds = $triggerArgs['segment_ids'] ?? []; return !is_array($segmentIds) || !$segmentIds || in_array($segmentId, $segmentIds, true); } } Integrations/MailPoet/Triggers/UserRegistrationTrigger.php000064400000006232150514567140020073 0ustar00wp = $wp; $this->subscribersRepository = $subscribersRepository; } public function getKey(): string { return self::KEY; } public function getName(): string { return __('WordPress user registers', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'roles' => Builder::array(Builder::string()), ]); } public function getSubjectKeys(): array { return [ SegmentSubject::KEY, SubscriberSubject::KEY, ]; } public function validate(StepValidationArgs $args): void { } public function registerHooks(): void { $this->wp->addAction('mailpoet_segment_subscribed', [$this, 'handleSubscription']); } public function handleSubscription(SubscriberSegmentEntity $subscriberSegment): void { $segment = $subscriberSegment->getSegment(); $subscriber = $subscriberSegment->getSubscriber(); if (!$segment || !$subscriber) { throw new InvalidStateException(); } $this->wp->doAction(Hooks::TRIGGER, $this, [ new Subject(SegmentSubject::KEY, ['segment_id' => $segment->getId()]), new Subject(SubscriberSubject::KEY, ['subscriber_id' => $subscriber->getId()]), ]); } public function isTriggeredBy(StepRunArgs $args): bool { $segmentPayload = $args->getSinglePayloadByClass(SegmentPayload::class); if ($segmentPayload->getType() !== SegmentEntity::TYPE_WP_USERS) { return false; } $subscriberPayload = $args->getSinglePayloadByClass(SubscriberPayload::class); $this->subscribersRepository->refresh($subscriberPayload->getSubscriber()); if (!$subscriberPayload->isWPUser()) { return false; } $user = $this->wp->getUserBy('id', $subscriberPayload->getWpUserId()); if (!$user) { return false; } $triggerArgs = $args->getStep()->getArgs(); $roles = $triggerArgs['roles'] ?? []; return !is_array($roles) || !$roles || count(array_intersect($user->roles, $roles)) > 0; } } Integrations/MailPoet/Triggers/index.php000064400000000006150514567140014336 0ustar00registry = $registry; } public function createFromSequence(string $name, array $sequence, array $sequenceArgs = [], array $meta = []): Automation { $steps = []; $nextSteps = []; foreach (array_reverse($sequence) as $index => $stepKey) { $automationStep = $this->registry->getStep($stepKey); if (!$automationStep) { continue; } $args = array_merge($this->getDefaultArgs($automationStep->getArgsSchema()), array_reverse($sequenceArgs)[$index] ?? []); $step = new Step( $this->uniqueId(), in_array(Trigger::class, (array)class_implements($automationStep)) ? Step::TYPE_TRIGGER : Step::TYPE_ACTION, $stepKey, $args, $nextSteps ); $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; } } Integrations/MailPoet/Templates/index.php000064400000000006150514567140014506 0ustar00actionScheduler = $actionScheduler; } public function getKey(): string { return 'core:delay'; } public function getName(): string { return _x('Delay', 'noun', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'delay' => Builder::integer()->required()->minimum(1), 'delay_type' => Builder::string()->required()->pattern('^(MINUTES|DAYS|HOURS|WEEKS)$')->default('HOURS'), ]); } public function getSubjectKeys(): array { return []; } public function validate(StepValidationArgs $args): void { $seconds = $this->calculateSeconds($args->getStep()); if ($seconds <= 0) { throw ValidationException::create() ->withError('delay', __('A delay must have a positive value', 'mailpoet')); } if ($seconds > 2 * YEAR_IN_SECONDS) { throw ValidationException::create() ->withError('delay', __("A delay can't be longer than two years", 'mailpoet')); } } public function run(StepRunArgs $args): void { $step = $args->getStep(); $nextStep = $step->getNextSteps()[0] ?? null; $this->actionScheduler->schedule(time() + $this->calculateSeconds($step), Hooks::AUTOMATION_STEP, [ [ 'automation_run_id' => $args->getAutomationRun()->getId(), 'step_id' => $nextStep ? $nextStep->getId() : null, ], ]); // TODO: call a step complete ($id) hook instead? } private function calculateSeconds(Step $step): int { $delay = (int)($step->getArgs()['delay'] ?? null); switch ($step->getArgs()['delay_type']) { case "MINUTES": return $delay * MINUTE_IN_SECONDS; case "HOURS": return $delay * HOUR_IN_SECONDS; case "DAYS": return $delay * DAY_IN_SECONDS; case "WEEKS": return $delay * WEEK_IN_SECONDS; default: return 0; } } } Integrations/Core/CoreIntegration.php000064400000002171150514567140013720 0ustar00delayAction = $delayAction; $this->wordPress = $wordPress; } public function register(Registry $registry): void { $registry->addAction($this->delayAction); $registry->addFilter(new Filters\BooleanFilter()); $registry->addFilter(new Filters\NumberFilter()); $registry->addFilter(new Filters\IntegerFilter()); $registry->addFilter(new Filters\StringFilter()); $registry->addFilter(new Filters\DateTimeFilter($this->wordPress->wpTimezone())); $registry->addFilter(new Filters\EnumFilter()); $registry->addFilter(new Filters\EnumArrayFilter()); } } Integrations/Core/Filters/EnumArrayFilter.php000064400000003731150514567140015310 0ustar00 __('matches any of', 'mailpoet'), self::CONDITION_MATCHES_ALL_OF => __('matches all of', 'mailpoet'), self::CONDITION_MATCHES_NONE_OF => __('matches none of', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::oneOf([ Builder::array(Builder::string())->minItems(1), Builder::array(Builder::integer())->minItems(1), ])->required(), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; if (!is_array($value) || !is_array($filterValue)) { return false; } $filterValue = array_unique($filterValue, SORT_REGULAR); $value = array_unique($value, SORT_REGULAR); $filterCount = count($filterValue); $matchedCount = count(array_intersect($value, $filterValue)); switch ($data->getCondition()) { case self::CONDITION_MATCHES_ANY_OF: return $filterCount > 0 && $matchedCount > 0; case self::CONDITION_MATCHES_ALL_OF: return $filterCount > 0 && $matchedCount === count($filterValue); case self::CONDITION_MATCHES_NONE_OF: return $matchedCount === 0; default: return false; } } } Integrations/Core/Filters/NumberFilter.php000064400000010631150514567140014632 0ustar00 __('equals', 'mailpoet'), self::CONDITION_NOT_EQUAL => __('not equal', 'mailpoet'), self::CONDITION_GREATER_THAN => __('greater than', 'mailpoet'), self::CONDITION_LESS_THAN => __('less than', 'mailpoet'), self::CONDITION_BETWEEN => __('between', 'mailpoet'), self::CONDITION_NOT_BETWEEN => __('not between', 'mailpoet'), self::CONDITION_IS_MULTIPLE_OF => __('is multiple of', 'mailpoet'), self::CONDITION_IS_NOT_MULTIPLE_OF => __('is not multiple of', 'mailpoet'), self::CONDITION_IS_SET => __('is set', 'mailpoet'), self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::oneOf([ Builder::number()->required(), Builder::array(Builder::number())->minItems(2)->maxItems(2)->required(), ]), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; $condition = $data->getCondition(); // is between/not between if (in_array($condition, [self::CONDITION_BETWEEN, self::CONDITION_NOT_BETWEEN], true)) { return $this->matchesBetween($condition, $value, $filterValue); } // is set/is not set if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) { return $this->matchesSet($condition, $value); } if (!$this->isNumber($value) || !$this->isNumber($filterValue)) { return false; } $value = floatval($value); $filterValue = floatval($filterValue); switch ($condition) { case self::CONDITION_EQUALS: return $value === $filterValue; case self::CONDITION_NOT_EQUAL: return $value !== $filterValue; case self::CONDITION_GREATER_THAN: return $value > $filterValue; case self::CONDITION_LESS_THAN: return $value < $filterValue; case self::CONDITION_IS_MULTIPLE_OF: return fmod($value, $filterValue) === 0.0; case self::CONDITION_IS_NOT_MULTIPLE_OF: return fmod($value, $filterValue) !== 0.0; default: return false; } } /** * @param mixed $value * @param mixed $filterValue */ private function matchesBetween(string $condition, $value, $filterValue): bool { if (!is_array($filterValue) || count($filterValue) !== 2) { return false; } if (!$this->isNumber($filterValue[0]) || !$this->isNumber($filterValue[1]) || $filterValue[0] >= $filterValue[1]) { return false; } if (!$this->isNumber($value)) { return false; } $value = floatval($value); $from = floatval($filterValue[0]); $to = floatval($filterValue[1]); switch ($condition) { case self::CONDITION_BETWEEN: return $value > $from && $value < $to; case self::CONDITION_NOT_BETWEEN: return $value <= $from || $value >= $to; default: return false; } } /** @param mixed $value */ private function matchesSet(string $condition, $value): bool { switch ($condition) { case self::CONDITION_IS_SET: return $value !== null; case self::CONDITION_IS_NOT_SET: return $value === null; default: return false; } } /** @param mixed $value */ private function isNumber($value): bool { return is_integer($value) || is_float($value); } } Integrations/Core/Filters/DateTimeFilter.php000064400000013600150514567140015075 0ustar00localTimezone = $localTimezone; } public function getFieldType(): string { return Field::TYPE_DATETIME; } public function getConditions(): array { return [ self::CONDITION_BEFORE => __('before', 'mailpoet'), self::CONDITION_AFTER => __('after', 'mailpoet'), self::CONDITION_ON => __('on', 'mailpoet'), self::CONDITION_NOT_ON => __('not on', 'mailpoet'), self::CONDITION_IN_THE_LAST => __('in the last', 'mailpoet'), self::CONDITION_NOT_IN_THE_LAST => __('not in the last', 'mailpoet'), self::CONDITION_IS_SET => __('is set', 'mailpoet'), self::CONDITION_IS_NOT_SET => __('is not set', 'mailpoet'), self::CONDITION_ON_THE_DAYS_OF_THE_WEEK => __('on the day(s) of the week', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::oneOf([ Builder::string()->pattern(self::REGEX_DATETIME), Builder::string()->pattern(self::REGEX_DATE), Builder::array(Builder::integer()->minimum(0)->maximum(6))->minItems(1), Builder::object([ 'number' => Builder::integer()->minimum(1)->required(), 'unit' => Builder::string()->pattern('^days|weeks|months$')->required(), ]), ]), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; $condition = $data->getCondition(); // is set/is not set if (in_array($condition, [self::CONDITION_IS_SET, self::CONDITION_IS_NOT_SET], true)) { return $this->matchesSet($condition, $value); } // in the last/not in the last if (in_array($condition, [self::CONDITION_IN_THE_LAST, self::CONDITION_NOT_IN_THE_LAST], true)) { return $this->matchesInTheLast($condition, $filterValue, $value); } // on the day(s) of the week if ($condition === self::CONDITION_ON_THE_DAYS_OF_THE_WEEK) { return $this->matchesOnTheDaysOfTheWeek($filterValue, $value); } // other conditions if (!is_string($filterValue) || !$value instanceof DateTimeInterface) { return false; } $datetime = $this->convertToLocalTimezone($value); switch ($condition) { case 'before': $ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->localTimezone); return $ref && $datetime < $ref; case 'after': $ref = DateTimeImmutable::createFromFormat(self::FORMAT_DATETIME, $filterValue, $this->localTimezone); return $ref && $datetime > $ref; case 'on': return $datetime->format(self::FORMAT_DATE) === $filterValue; case 'not-on': return $datetime->format(self::FORMAT_DATE) !== $filterValue; default: return false; } } /** @param mixed $value */ private function matchesSet(string $condition, $value): bool { switch ($condition) { case self::CONDITION_IS_SET: return $value !== null; case self::CONDITION_IS_NOT_SET: return $value === null; default: return false; } } /** * @param mixed $filterValue * @param mixed $value */ private function matchesInTheLast(string $condition, $filterValue, $value): bool { if (!is_array($filterValue) || !isset($filterValue['number']) || !isset($filterValue['unit']) || !$value instanceof DateTimeInterface) { return false; } $number = $filterValue['number']; $unit = $filterValue['unit']; if (!is_integer($number) || !in_array($unit, ['days', 'weeks', 'months'], true)) { return false; } $now = new DateTimeImmutable('now', $this->localTimezone); $ref = $now->modify("-$number $unit"); $matches = $ref <= $value && $value <= $now; return $condition === self::CONDITION_IN_THE_LAST ? $matches : !$matches; } /** * @param mixed $filterValue * @param mixed $value */ private function matchesOnTheDaysOfTheWeek($filterValue, $value): bool { if (!is_array($filterValue) || !$value instanceof DateTimeInterface) { return false; } foreach ($filterValue as $day) { if (!is_integer($day) || $day < 0 || $day > 6) { return false; } } $date = $this->convertToLocalTimezone($value); $day = (int)$date->format('w'); return in_array($day, $filterValue, true); } private function convertToLocalTimezone(DateTimeInterface $datetime): DateTimeImmutable { $value = DateTimeImmutable::createFromFormat('U', (string)$datetime->getTimestamp(), $this->localTimezone); if (!$value) { throw new InvalidStateException('Failed to convert datetime to WP timezone'); } return $value; } } Integrations/Core/Filters/BooleanFilter.php000064400000002036150514567140014761 0ustar00 __('is', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::boolean()->required(), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; if (!is_bool($value) || !is_bool($filterValue)) { return false; } return $data->getCondition() === self::CONDITION_IS && $value === $filterValue; } } Integrations/Core/Filters/index.php000064400000000006150514567140013336 0ustar00 __('is', 'mailpoet'), self::CONDITION_IS_NOT => __('is not', 'mailpoet'), self::CONDITION_CONTAINS => __('contains', 'mailpoet'), self::CONDITION_DOES_NOT_CONTAIN => __('does not contain', 'mailpoet'), self::CONDITION_STARTS_WITH => __('starts with', 'mailpoet'), self::CONDITION_ENDS_WITH => __('ends with', 'mailpoet'), self::CONDITION_IS_BLANK => __('is blank', 'mailpoet'), self::CONDITION_IS_NOT_BLANK => __('is not blank', 'mailpoet'), self::CONDITION_MATCHES_REGEX => __('matches regex', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::string()->required(), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; if (!is_string($value) || !is_string($filterValue)) { return false; } // match regex as it is $condition = $data->getCondition(); if ($condition === self::CONDITION_MATCHES_REGEX) { return $this->matchesRegex($filterValue, $value); } // match all other conditions case insensitively $value = mb_strtolower($value); $filterValue = mb_strtolower($filterValue); switch ($data->getCondition()) { case self::CONDITION_IS: return $value === $filterValue; case self::CONDITION_IS_NOT: return $value !== $filterValue; case self::CONDITION_CONTAINS: return str_contains($value, $filterValue); case self::CONDITION_DOES_NOT_CONTAIN: return !str_contains($value, $filterValue); case self::CONDITION_STARTS_WITH: return str_starts_with($value, $filterValue); case self::CONDITION_ENDS_WITH: return str_ends_with($value, $filterValue); case self::CONDITION_IS_BLANK: return strlen($value) === 0; case self::CONDITION_IS_NOT_BLANK: return strlen($value) > 0; default: return false; } } protected function matchesRegex(string $regex, string $value): bool { // add '/' delimiters, if missing if (!@preg_match('#^/.*/[a-z]*$#ui', $regex)) { $regex = '/' . str_replace('/', '\\/', $regex) . '/u'; } // add unicode flag, if not present if (!@preg_match('#/.*u.*$#ui', $regex)) { $regex .= 'u'; } if (@preg_match($regex, '') === false) { throw new InvalidStateException("Invalid regular expression: '$regex'"); } return @preg_match($regex, $value) === 1; } } Integrations/Core/Filters/EnumFilter.php000064400000002733150514567140014312 0ustar00 __('is any of', 'mailpoet'), self::IS_NONE_OF => __('is none of', 'mailpoet'), ]; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'value' => Builder::oneOf([ Builder::array(Builder::string())->minItems(1), Builder::array(Builder::integer())->minItems(1), ])->required(), ]); } public function matches(FilterData $data, $value): bool { $filterValue = $data->getArgs()['value'] ?? null; if (!is_scalar($value) || !is_array($filterValue)) { return false; } $filterValue = array_unique($filterValue, SORT_REGULAR); switch ($data->getCondition()) { case self::IS_ANY_OF: return in_array($value, $filterValue, true); case self::IS_NONE_OF: return !in_array($value, $filterValue, true); default: return false; } } } Integrations/Core/Filters/IntegerFilter.php000064400000002671150514567140015004 0ustar00 Builder::oneOf([ Builder::integer()->required(), Builder::array(Builder::integer())->minItems(2)->maxItems(2)->required(), ]), ]); } public function matches(FilterData $data, $value): bool { $matches = parent::matches($data, $value); if (!$matches) { return false; } if (isset($value) && !$this->isWholeNumber($value)) { return false; } $filterValue = $data->getArgs()['value'] ?? null; if (is_array($filterValue)) { foreach ($filterValue as $filterValueItem) { if (!$this->isWholeNumber($filterValueItem)) { return false; } } return true; } if (isset($filterValue) && !$this->isWholeNumber($filterValue)) { return false; } return true; } /** @param mixed $value */ private function isWholeNumber($value): bool { return is_int($value) || (is_float($value) && $value === floor($value)); } } Integrations/Core/index.php000064400000000006150514567140011726 0ustar00woocommerceHelper = $woocommerceHelper; } /** @return mixed[] */ public function getContextData(): array { if (!$this->woocommerceHelper->isWooCommerceActive()) { return []; } $context = [ 'order_statuses' => $this->woocommerceHelper->getOrderStatuses(), ]; return $context; } } Integrations/WooCommerce/Payloads/OrderStatusChangePayload.php000064400000001067150514567140020631 0ustar00from = $from; $this->to = $to; } public function getFrom(): string { return $this->from; } public function getTo(): string { return $this->to; } } Integrations/WooCommerce/Payloads/OrderPayload.php000064400000001124150514567140016311 0ustar00order = $order; } public function getOrder(): \WC_Order { return $this->order; } public function getEmail(): string { return $this->order->get_billing_email(); } public function getId(): int { return $this->order->get_id(); } } Integrations/WooCommerce/Payloads/AbandonedCartPayload.php000064400000002033150514567140017723 0ustar00customer = $customer; $this->lastActivityAt = $lastActivityAt; $this->productIds = $productIds; } public function getLastActivityAt(): \DateTimeImmutable { return $this->lastActivityAt; } public function getCustomer(): \WC_Customer { return $this->customer; } /** * @return int[] */ public function getProductIds(): array { return $this->productIds; } } Integrations/WooCommerce/Payloads/index.php000064400000000006150514567140015031 0ustar00customer = $customer; } public function getCustomer(): \WC_Customer { return $this->customer; } public function getId(): int { return $this->customer->get_id(); } } Integrations/WooCommerce/Subjects/OrderStatusChangeSubject.php000064400000002333150514567140020642 0ustar00 */ class OrderStatusChangeSubject implements Subject { const KEY = 'woocommerce:order-status-changed'; public function getName(): string { return __('WooCommerce order status change', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'from' => Builder::string()->required(), 'to' => Builder::string()->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { $from = $subjectData->getArgs()['from']; $to = $subjectData->getArgs()['to']; return new OrderStatusChangePayload($from, $to); } public function getKey(): string { return self::KEY; } public function getFields(): array { return []; } } Integrations/WooCommerce/Subjects/CustomerSubject.php000064400000002556150514567140017065 0ustar00 */ class CustomerSubject implements Subject { const KEY = 'woocommerce:customer'; public function getName(): string { return __('WooCommerce customer', 'mailpoet'); } public function getKey(): string { return self::KEY; } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'customer_id' => Builder::integer()->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { $id = $subjectData->getArgs()['customer_id']; $customer = new \WC_Customer($id); if (!$customer->get_id()) { // translators: %d is the ID of the customer. throw NotFoundException::create()->withMessage(sprintf(__("Customer with ID '%d' not found.", 'mailpoet'), $id)); } return new CustomerPayload($customer); } public function getFields(): array { return []; } } Integrations/WooCommerce/Subjects/index.php000064400000000006150514567140015037 0ustar00 */ class OrderSubject implements Subject { const KEY = 'woocommerce:order'; private $woocommerce; public function __construct( Helper $woocommerce ) { $this->woocommerce = $woocommerce; } public function getName(): string { return __('WooCommerce order', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'order_id' => Builder::integer()->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { $id = $subjectData->getArgs()['order_id']; $order = $this->woocommerce->wcGetOrder($id); if (!$order instanceof \WC_Order) { // translators: %d is the order ID. throw NotFoundException::create()->withMessage(sprintf(__("Order with ID '%d' not found.", 'mailpoet'), $id)); } return new OrderPayload($order); } public function getKey(): string { return self::KEY; } public function getFields(): array { return []; } } Integrations/WooCommerce/Subjects/AbandonedCartSubject.php000064400000004024150514567140017741 0ustar00 */ class AbandonedCartSubject implements Subject { const KEY = 'woocommerce:abandoned_cart'; /** @var WooCommerceHelper */ private $woocommerceHelper; public function __construct( WooCommerceHelper $woocommerceHelper ) { $this->woocommerceHelper = $woocommerceHelper; } public function getKey(): string { return self::KEY; } public function getName(): string { return __('MailPoet Abandoned Cart', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'user_id' => Builder::integer()->required(), 'last_activity_at' => Builder::string()->required()->default(30), 'product_ids' => Builder::array(Builder::integer())->required(), ]); } public function getPayload(SubjectData $subjectData): Payload { if (!$this->woocommerceHelper->isWooCommerceActive()) { throw InvalidStateException::create()->withMessage('WooCommerce is not active'); } $lastActivityAt = \DateTimeImmutable::createFromFormat(\DateTime::W3C, $subjectData->getArgs()['last_activity_at']); if (!$lastActivityAt) { throw InvalidStateException::create()->withMessage('Invalid abandoned cart time'); } $customer = new \WC_Customer($subjectData->getArgs()['user_id']); return new AbandonedCartPayload($customer, $lastActivityAt, $subjectData->getArgs()['product_ids']); } public function getFields(): array { return []; } } Integrations/WooCommerce/WooCommerceIntegration.php000064400000004042150514567140016575 0ustar00orderStatusChangedTrigger = $orderStatusChangedTrigger; $this->abandonedCartSubject = $abandonedCartSubject; $this->orderStatusChangeSubject = $orderStatusChangeSubject; $this->orderSubject = $orderSubject; $this->customerSubject = $customerSubject; $this->contextFactory = $contextFactory; } public function register(Registry $registry): void { $registry->addContextFactory('woocommerce', function () { return $this->contextFactory->getContextData(); }); $registry->addSubject($this->abandonedCartSubject); $registry->addSubject($this->orderSubject); $registry->addSubject($this->orderStatusChangeSubject); $registry->addSubject($this->customerSubject); $registry->addTrigger($this->orderStatusChangedTrigger); } } Integrations/WooCommerce/index.php000064400000000006150514567140013255 0ustar00wp = $wp; $this->woocommerceHelper = $woocommerceHelper; } public function getKey(): string { return 'woocommerce:order-status-changed'; } public function getName(): string { return __('Order status changed', 'mailpoet'); } public function getArgsSchema(): ObjectSchema { return Builder::object([ 'from' => Builder::string()->required()->default('any'), 'to' => Builder::string()->required()->default('wc-completed'), ]); } public function getSubjectKeys(): array { return [ OrderSubject::KEY, OrderStatusChangeSubject::KEY, CustomerSubject::KEY, ]; } public function validate(StepValidationArgs $args): void { } public function registerHooks(): void { $this->wp->addAction( 'woocommerce_order_status_changed', [ $this, 'handle', ], 10, 3 ); } public function handle(int $orderId, string $oldStatus, string $newStatus): void { $order = $this->woocommerceHelper->wcGetOrder($orderId); if (!$order instanceof \WC_Order) { return; } $this->wp->doAction(Hooks::TRIGGER, $this, [ new Subject(OrderStatusChangeSubject::KEY, ['from' => $oldStatus, 'to' => $newStatus]), new Subject(OrderSubject::KEY, ['order_id' => $order->get_id()]), new Subject(CustomerSubject::KEY, ['customer_id' => $order->get_customer_id()]), ]); } public function isTriggeredBy(StepRunArgs $args): bool { /** @var OrderStatusChangePayload $orderPayload */ $orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class); $triggerArgs = $args->getStep()->getArgs(); $configuredFrom = $triggerArgs['from'] ? str_replace('wc-', '', $triggerArgs['from']) : null; $configuredTo = $triggerArgs['to'] ? str_replace('wc-', '', $triggerArgs['to']) : null; if ($configuredFrom !== 'any' && $orderPayload->getFrom() !== $configuredFrom) { return false; } if ($configuredTo !== 'any' && $orderPayload->getTo() !== $configuredTo) { return false; } return true; } } Integrations/WooCommerce/Triggers/index.php000064400000000006150514567140015043 0ustar00