WordPress.php000064400000012273150732300560007213 0ustar00|string|WP_Error */ public function wpGetPostTerms(int $postId, string $taxonomy, array $args = []) { return wp_get_post_terms($postId, $taxonomy, $args); } /** * @param int|\WP_Comment $comment * @return false|string */ public function wpGetCommentStatus($comment) { return wp_get_comment_status($comment); } /** * @return string[]|\WP_Post_Type[] */ public function getPostTypes(array $args = [], string $output = 'names', string $operator = 'and'): array { return get_post_types($args, $output, $operator); } public function postTypeSupports(string $type, string $feature): bool { return post_type_supports($type, $feature); } /** * @param 'and'|'or' $operator * @return string[]|\WP_Taxonomy[] */ public function getTaxonomies(array $args = [], string $output = 'names', string $operator = 'and'): array { return get_taxonomies($args, $output, $operator); } /** * @return mixed */ public function getCommentMeta(int $commentId, string $key = '', bool $isSingle = false) { return get_comment_meta($commentId, $key, $isSingle); } /** * @param int|WP_Term|object $term * @param string $taxonomy * @param 'ARRAY_A'|'ARRAY_N'|'OBJECT' $output * @param string $filter * @return WP_Term|array|WP_Error|null */ public function getTerm($term, string $taxonomy = '', string $output = OBJECT, string $filter = 'raw') { return get_term($term, $taxonomy, $output, $filter); } /** @return \WP_Taxonomy|false */ public function getTaxonomy(string $name) { return get_taxonomy($name); } /** @return int|string */ public function currentTime(string $type, bool $gmt = false) { return current_time($type, $gmt); } /** * @param string $field * @param string|int $value * @return false|WP_User */ public function getUserBy(string $field, $value) { return get_user_by($field, $value); } } index.php000064400000000006150732300560006361 0ustar00statisticsStorage = $statisticsStorage; } public function buildAutomation(Automation $automation, AutomationStatistics $statistics = null): 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' => $statistics ? $statistics->toArray() : $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, ], ]; } } Utils/index.php000064400000000006150732300560007461 0ustar00wordPress = $wordPress; } public const INITIALIZE = 'mailpoet/automation/initialize'; public const API_INITIALIZE = 'mailpoet/automation/api/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_STEP_LOG_AFTER_RUN = 'mailpoet/automation/step/log_after_run'; public const AUTOMATION_RUN_CREATE = 'mailpoet/automation/run/create'; 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_STEP_LOG_AFTER_RUN, $automationRunLog); } } Data/NextStep.php000064400000000762150732300560007706 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']); } } Data/index.php000064400000000006150732300560007232 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 === null) { throw InvalidStateException::create()->withMessage('No automation ID was set'); } return $this->id; } public function setId(int $id): void { $this->id = $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 setCreatedAt(DateTimeImmutable $createdAt): void { $this->createdAt = $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; } } Data/Field.php000064400000002555150732300560007161 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; } } Data/AutomationRun.php000064400000006205150732300560010737 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 setId(int $id): void { $this->id = $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; } } Data/AutomationStatistics.php000064400000002243150732300560012323 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(), ], ]; } } Data/AutomationTemplateCategory.php000064400000000746150732300560013450 0ustar00slug = $slug; $this->name = $name; } public function getSlug(): string { return $this->slug; } public function getName(): string { return $this->name; } } Data/Step.php000064400000004630150732300560007045 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; } public function getNextStepIds(): array { $ids = []; foreach ($this->nextSteps as $nextStep) { $nextStepId = $nextStep->getId(); if ($nextStepId) { $ids[] = $nextStep->getId(); } } return $ids; } /** @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 ); } } Data/Filters.php000064400000002013150732300560007533 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']) ); } } Data/SubjectEntry.php000064400000002413150732300560010550 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; } } Data/Filter.php000064400000002616150732300560007361 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'] ); } } Data/AutomationTemplate.php000064400000003326150732300560011747 0ustar00slug = $slug; $this->category = $category; $this->name = $name; $this->description = $description; $this->automationFactory = $automationFactory; $this->type = $type; } public function getSlug(): string { return $this->slug; } public function getName(): string { return $this->name; } public function getCategory(): string { return $this->category; } public function getType(): string { return $this->type; } public function getDescription(): string { return $this->description; } public function createAutomation(): Automation { return ($this->automationFactory)(); } public function toArray(): array { return [ 'slug' => $this->getSlug(), 'name' => $this->getName(), 'category' => $this->getCategory(), 'type' => $this->getType(), 'description' => $this->getDescription(), ]; } } Data/FilterGroup.php000064400000002302150732300560010366 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']) ); } } Data/StepValidationArgs.php000064400000003515150732300560011676 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; } } Data/AutomationRunLog.php000064400000012313150732300560011376 0ustar00automationRunId = $automationRunId; $this->stepId = $stepId; $this->stepType = $stepType; $this->stepKey = self::KEY_UNKNOWN; $this->status = self::STATUS_RUNNING; $now = new DateTimeImmutable(); $this->startedAt = $now; $this->updatedAt = $now; if ($id) { $this->id = $id; } } public function getId(): int { return $this->id; } public function getAutomationRunId(): int { return $this->automationRunId; } public function getStepId(): string { return $this->stepId; } public function getStepType(): string { return $this->stepType; } public function getStepKey(): string { return $this->stepKey; } public function setStepKey(string $stepKey): void { $this->stepKey = $stepKey; $this->updatedAt = new DateTimeImmutable(); } public function getStatus(): string { return $this->status; } public function setStatus(string $status): void { if (!in_array($status, self::STATUS_ALL, true)) { throw new InvalidArgumentException("Invalid status '$status'."); } $this->status = $status; $this->updatedAt = new DateTimeImmutable(); } public function getStartedAt(): DateTimeImmutable { return $this->startedAt; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } public function getRunNumber(): int { return $this->runNumber; } public function setRunNumber(int $runNumber): void { $this->runNumber = $runNumber; } public function setUpdatedAt(DateTimeImmutable $updatedAt): void { $this->updatedAt = $updatedAt; } public function getData(): array { return $this->data; } /** @param mixed $value */ public function setData(string $key, $value): void { if (!$this->isDataStorable($value)) { throw new InvalidArgumentException("Invalid data provided for key '$key'. Only scalar values and arrays of scalar values are allowed."); } $this->data[$key] = $value; $this->updatedAt = new DateTimeImmutable(); } public function getError(): ?array { return $this->error; } public function toArray(): array { return [ 'id' => $this->id, 'automation_run_id' => $this->automationRunId, 'step_id' => $this->stepId, 'step_type' => $this->stepType, 'step_key' => $this->stepKey, 'status' => $this->status, 'started_at' => $this->startedAt->format(DateTimeImmutable::W3C), 'updated_at' => $this->updatedAt->format(DateTimeImmutable::W3C), 'run_number' => $this->runNumber, 'data' => Json::encode($this->data), 'error' => $this->error ? Json::encode($this->error) : null, ]; } public function setError(Throwable $error): void { // Normalize all nested objects in error trace to associative arrays. // Empty objects would then get decoded to "[]" instead of "{}". $trace = Json::decode(Json::encode($error->getTrace())); $this->error = [ 'message' => $error->getMessage(), 'errorClass' => get_class($error), 'code' => $error->getCode(), 'trace' => $trace, ]; $this->updatedAt = new DateTimeImmutable(); } public static function fromArray(array $data): self { $log = new AutomationRunLog((int)$data['automation_run_id'], $data['step_id'], $data['step_type']); $log->id = (int)$data['id']; $log->stepKey = $data['step_key']; $log->status = $data['status']; $log->startedAt = new DateTimeImmutable($data['started_at']); $log->updatedAt = new DateTimeImmutable($data['updated_at']); $log->runNumber = (int)$data['run_number']; $log->data = Json::decode($data['data']); $log->error = isset($data['error']) ? Json::decode($data['error']) : null; return $log; } /** @param mixed $data */ private function isDataStorable($data): bool { if (is_scalar($data)) { return true; } if (!is_array($data)) { return false; } foreach ($data as $value) { if (!$this->isDataStorable($value)) { return false; } } return true; } } Data/Subject.php000064400000001615150732300560007531 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'])); } } Data/StepRunArgs.php000064400000011012150732300560010337 0ustar00>[]> */ private $subjectEntries = []; /** @var array */ private $subjectKeyClassMap = []; /** @var array */ private $fields = []; /** @var array */ private $fieldToSubjectMap = []; /** @var int */ private $runNumber; /** @param SubjectEntry>[] $subjectsEntries */ public function __construct( Automation $automation, AutomationRun $automationRun, Step $step, array $subjectsEntries, int $runNumber ) { $this->automation = $automation; $this->step = $step; $this->automationRun = $automationRun; $this->runNumber = $runNumber; foreach ($subjectsEntries as $entry) { $subject = $entry->getSubject(); $key = $subject->getKey(); $this->subjectEntries[$key] = array_merge($this->subjectEntries[$key] ?? [], [$entry]); $this->subjectKeyClassMap[get_class($subject)] = $key; foreach ($subject->getFields() as $field) { $this->fields[$field->getKey()] = $field; $this->fieldToSubjectMap[$field->getKey()] = $key; } } } public function getAutomation(): Automation { return $this->automation; } public function getAutomationRun(): AutomationRun { return $this->automationRun; } public function getStep(): Step { return $this->step; } /** @return array>[]> */ public function getSubjectEntries(): array { return $this->subjectEntries; } /** @return SubjectEntry> */ public function getSingleSubjectEntry(string $key): SubjectEntry { $subjects = $this->subjectEntries[$key] ?? []; if (count($subjects) === 0) { throw Exceptions::subjectDataNotFound($key, $this->automationRun->getId()); } if (count($subjects) > 1) { throw Exceptions::multipleSubjectsFound($key, $this->automationRun->getId()); } return $subjects[0]; } /** * @template P of Payload * @template S of Subject

* @param class-string $class * @return SubjectEntry> */ public function getSingleSubjectEntryByClass(string $class): SubjectEntry { $key = $this->subjectKeyClassMap[$class] ?? null; if (!$key) { throw Exceptions::subjectClassNotFound($class); } /** @var SubjectEntry> $entry -- for PHPStan */ $entry = $this->getSingleSubjectEntry($key); return $entry; } /** * @template P of Payload * @param class-string

$class * @return P */ public function getSinglePayloadByClass(string $class): Payload { $payloads = []; foreach ($this->subjectEntries as $entries) { if (count($entries) > 1) { throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId()); } $entry = $entries[0]; $payload = $entry->getPayload(); if (get_class($payload) === $class) { $payloads[] = $payload; } } if (count($payloads) === 0) { throw Exceptions::payloadNotFound($class, $this->automationRun->getId()); } if (count($payloads) > 1) { throw Exceptions::multiplePayloadsFound($class, $this->automationRun->getId()); } // ensure PHPStan we're indeed returning an instance of $class $payload = $payloads[0]; if (!$payload instanceof $class) { throw InvalidStateException::create(); } return $payload; } /** @return mixed */ public function getFieldValue(string $key) { $field = $this->fields[$key] ?? null; $subjectKey = $this->fieldToSubjectMap[$key] ?? null; if (!$field || !$subjectKey) { throw Exceptions::fieldNotFound($key); } $entry = $this->getSingleSubjectEntry($subjectKey); try { $value = $field->getValue($entry->getPayload()); } catch (Throwable $e) { throw Exceptions::fieldLoadFailed($field->getKey(), $field->getArgs()); } return $value; } public function getRunNumber(): int { return $this->runNumber; } public function isFirstRun(): bool { return $this->runNumber === 1; } } Endpoints/index.php000064400000000006150732300560010324 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(), ]; } } Endpoints/Automations/index.php000064400000000006150732300560012627 0ustar00registry = $registry; } public function handle(Request $request): Response { /** @var string|null $category */ $category = $request->getParam('category'); $templates = array_values($this->registry->getTemplates($category ? strval($category) : null)); return new Response(array_map(function (AutomationTemplate $automation) { return $automation->toArray(); }, $templates)); } public static function getRequestSchema(): array { return [ 'category' => Builder::string(), ]; } } Endpoints/Automations/AutomationsPutEndpoint.php000064400000002743150732300560016227 0ustar00updateController = $updateController; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { $data = $request->getParams(); /** @var int $automationId */ $automationId = $request->getParam('id'); $automation = $this->updateController->updateAutomation(intval($automationId), $data); return new Response($this->automationMapper->buildAutomation($automation)); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), 'name' => Builder::string()->minLength(1), 'status' => Builder::string(), 'steps' => AutomationSchema::getStepsSchema(), 'meta' => Builder::object(), ]; } } Endpoints/Automations/AutomationsDeleteEndpoint.php000064400000001747150732300560016664 0ustar00deleteController = $deleteController; } public function handle(Request $request): Response { /** @var int $automationId */ $automationId = $request->getParam('id'); $automationId = intval($automationId); $this->deleteController->deleteAutomation($automationId); return new Response(null); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Endpoints/Automations/AutomationTemplateGetEndpoint.php000064400000003345150732300560017506 0ustar00registry = $registry; $this->automationValidator = $automationValidator; $this->automationMapper = $automationMapper; } public function handle(Request $request): Response { /** @var string|null $slug - for PHPStan because strval() doesn't accept a value of mixed */ $slug = $request->getParam('slug'); $slug = strval($slug); $template = $this->registry->getTemplate($slug); if (!$template) { throw Exceptions::automationTemplateNotFound($slug); } $automation = $template->createAutomation(); $automation->setId(0); $this->automationValidator->validate($automation); $data = $template->toArray() + [ 'automation' => $this->automationMapper->buildAutomation($automation), ]; return new Response($data); } public static function getRequestSchema(): array { return [ 'slug' => Builder::string()->required(), ]; } } Endpoints/Automations/AutomationsGetEndpoint.php000064400000002277150732300560016200 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()), ]; } } Endpoints/Automations/AutomationsDuplicateEndpoint.php000064400000002421150732300560017362 0ustar00automationMapper = $automationMapper; $this->duplicateController = $duplicateController; } public function handle(Request $request): Response { /** @var int $automationId */ $automationId = $request->getParam('id'); $automationId = intval($automationId); $duplicate = $this->duplicateController->duplicateAutomation($automationId); return new Response($this->automationMapper->buildAutomation($duplicate)); } public static function getRequestSchema(): array { return [ 'id' => Builder::integer()->required(), ]; } } Builder/UpdateAutomationController.php000064400000010610150732300560014171 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; } } Builder/index.php000064400000000006150732300560007747 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); } } Builder/DeleteAutomationController.php000064400000001620150732300560014152 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; } } Builder/DuplicateAutomationController.php000064400000005351150732300560014667 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 { $nextStepId = $nextStep->getId(); return new NextStep($nextStepId ? $newIds[$nextStepId] : null); }, $step->getNextSteps()) ); } return $newSteps; } private function getId(): string { return Security::generateRandomString(16); } } Builder/CreateAutomationFromTemplateController.php000064400000002715150732300560016501 0ustar00storage = $storage; $this->automationValidator = $automationValidator; $this->registry = $registry; } public function createAutomation(string $slug): Automation { $template = $this->registry->getTemplate($slug); if (!$template) { throw Exceptions::automationTemplateNotFound($slug); } $automation = $template->createAutomation(); $this->automationValidator->validate($automation); $automationId = $this->storage->createAutomation($automation); $savedAutomation = $this->storage->getAutomation($automationId); if (!$savedAutomation) { throw new InvalidStateException('Automation not found.'); } return $savedAutomation; } } Control/StepRunController.php000064400000002142150732300560012341 0ustar00stepScheduler = $stepScheduler; $this->stepRunArgs = $stepRunArgs; } public function scheduleProgress(int $timestamp = null): int { return $this->stepScheduler->scheduleProgress($this->stepRunArgs, $timestamp); } public function scheduleNextStep(int $timestamp = null): int { return $this->stepScheduler->scheduleNextStep($this->stepRunArgs, $timestamp); } public function scheduleNextStepByIndex(int $nextStepIndex, int $timestamp = null): int { return $this->stepScheduler->scheduleNextStepByIndex($this->stepRunArgs, $nextStepIndex, $timestamp); } public function hasScheduledNextStep(): bool { return $this->stepScheduler->hasScheduledNextStep($this->stepRunArgs); } } Control/index.php000064400000000006150732300560010001 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); } } Control/TriggerHandler.php000064400000007441150732300560011605 0ustar00automationStorage = $automationStorage; $this->automationRunStorage = $automationRunStorage; $this->subjectLoader = $subjectLoader; $this->subjectTransformerHandler = $subjectTransformerHandler; $this->filterHandler = $filterHandler; $this->stepScheduler = $stepScheduler; $this->stepRunLoggerFactory = $stepRunLoggerFactory; $this->wordPress = $wordPress; } public function initialize(): void { $this->wordPress->addAction(Hooks::TRIGGER, [$this, 'processTrigger'], 10, 2); } /** @param Subject[] $subjects */ public function processTrigger(Trigger $trigger, array $subjects): void { $automations = $this->automationStorage->getActiveAutomationsByTrigger($trigger); if (!$automations) { return; } // expand all subject transformations and load subject entries $subjects = $this->subjectTransformerHandler->getAllSubjects($subjects); $subjectEntries = $this->subjectLoader->getSubjectsEntries($subjects); foreach ($automations as $automation) { $step = $automation->getTrigger($trigger->getKey()); if (!$step) { throw Exceptions::automationTriggerNotFound($automation->getId(), $trigger->getKey()); } $automationRun = new AutomationRun($automation->getId(), $automation->getVersionId(), $trigger->getKey(), $subjects); $stepRunArgs = new StepRunArgs($automation, $automationRun, $step, $subjectEntries, 1); $match = false; try { $match = $this->filterHandler->matchesFilters($stepRunArgs); } catch (Exceptions\Exception $e) { // failed filter evaluation won't match ; } if (!$match) { continue; } $createAutomationRun = $trigger->isTriggeredBy($stepRunArgs); $createAutomationRun = $this->wordPress->applyFilters( Hooks::AUTOMATION_RUN_CREATE, $createAutomationRun, $stepRunArgs ); if (!$createAutomationRun) { continue; } $automationRunId = $this->automationRunStorage->createAutomationRun($automationRun); $automationRun->setId($automationRunId); $this->stepScheduler->scheduleNextStep($stepRunArgs); $logger = $this->stepRunLoggerFactory->createLogger($automationRunId, $step->getId(), AutomationRunLog::TYPE_TRIGGER, 1); $logger->logStepData($step); $logger->logSuccess(); } } } Control/StepScheduler.php000064400000007756150732300560011467 0ustar00actionScheduler = $actionScheduler; $this->automationRunStorage = $automationRunStorage; } public function scheduleProgress(StepRunArgs $args, int $timestamp = null): int { $runId = $args->getAutomationRun()->getId(); $data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1); return $this->scheduleStepAction($data, $timestamp); } public function scheduleNextStep(StepRunArgs $args, int $timestamp = null): int { $step = $args->getStep(); $nextSteps = $step->getNextSteps(); // complete the automation run if there are no more steps if (count($nextSteps) === 0) { $this->completeAutomationRun($args); return 0; } if (count($nextSteps) > 1) { throw Exceptions::nextStepNotScheduled($step->getId()); } return $this->scheduleNextStepByIndex($args, 0, $timestamp); } public function scheduleNextStepByIndex(StepRunArgs $args, int $nextStepIndex, int $timestamp = null): int { $step = $args->getStep(); $nextStep = $step->getNextSteps()[$nextStepIndex] ?? null; if (!$nextStep) { throw Exceptions::nextStepNotFound($step->getId(), $nextStepIndex); } $runId = $args->getAutomationRun()->getId(); $nextStepId = $nextStep->getId(); if (!$nextStepId) { $this->completeAutomationRun($args); return 0; } $data = $this->getActionData($runId, $nextStepId); $id = $this->scheduleStepAction($data, $timestamp); $this->automationRunStorage->updateNextStep($runId, $nextStepId); return $id; } public function hasScheduledNextStep(StepRunArgs $args): bool { $runId = $args->getAutomationRun()->getId(); foreach ($args->getStep()->getNextStepIds() as $nextStepId) { $data = $this->getActionData($runId, $nextStepId); $hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); if ($hasStep) { return true; } // BC for old steps without run number unset($data[0]['run_number']); $hasStep = $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); if ($hasStep) { return true; } } return false; } public function hasScheduledProgress(StepRunArgs $args): bool { $runId = $args->getAutomationRun()->getId(); $data = $this->getActionData($runId, $args->getStep()->getId(), $args->getRunNumber() + 1); return $this->actionScheduler->hasScheduledAction(Hooks::AUTOMATION_STEP, $data); } public function hasScheduledStep(StepRunArgs $args): bool { return $this->hasScheduledNextStep($args) || $this->hasScheduledProgress($args); } private function scheduleStepAction(array $data, int $timestamp = null): int { return $timestamp === null ? $this->actionScheduler->enqueue(Hooks::AUTOMATION_STEP, $data) : $this->actionScheduler->schedule($timestamp, Hooks::AUTOMATION_STEP, $data); } private function completeAutomationRun(StepRunArgs $args): void { $runId = $args->getAutomationRun()->getId(); $this->automationRunStorage->updateNextStep($runId, null); $this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE); } private function getActionData(int $runId, string $stepId, int $runNumber = 1): array { return [ [ 'automation_run_id' => $runId, 'step_id' => $stepId, 'run_number' => $runNumber, ], ]; } } Control/StepHandler.php000064400000016734150732300560011122 0ustar00subjectLoader = $subjectLoader; $this->wordPress = $wordPress; $this->automationRunStorage = $automationRunStorage; $this->automationStorage = $automationStorage; $this->registry = $registry; $this->stepRunControllerFactory = $stepRunControllerFactory; $this->stepRunLoggerFactory = $stepRunLoggerFactory; $this->stepScheduler = $stepScheduler; } public function initialize(): void { $this->wordPress->addAction(Hooks::AUTOMATION_STEP, [$this, 'handle']); } /** @param mixed $args */ public function handle($args): void { // TODO: better args validation if (!is_array($args) || !isset($args['automation_run_id']) || !array_key_exists('step_id', $args)) { throw new InvalidStateException(); } $runId = (int)$args['automation_run_id']; $stepId = (string)$args['step_id']; $runNumber = (int)($args['run_number'] ?? 1); // BC — complete automation run if "step_id" is empty (was nullable in the past) if (!$stepId) { $this->automationRunStorage->updateStatus($runId, AutomationRun::STATUS_COMPLETE); return; } $logger = $this->stepRunLoggerFactory->createLogger($runId, $stepId, AutomationRunLog::TYPE_ACTION, $runNumber); $logger->logStart(); try { $this->handleStep($runId, $stepId, $runNumber, $logger); } catch (Throwable $e) { $status = $e instanceof InvalidStateException && $e->getErrorCode() === 'mailpoet_automation_not_active' ? AutomationRun::STATUS_CANCELLED : AutomationRun::STATUS_FAILED; $this->automationRunStorage->updateStatus((int)$args['automation_run_id'], $status); $logger->logFailure($e); // Action Scheduler catches only Exception instances, not other errors. // We need to convert them to exceptions to be processed and logged. if (!$e instanceof Exception) { throw new Exception($e->getMessage(), intval($e->getCode()), $e); } throw $e; } finally { $this->postProcessAutomationRun($runId); } } private function handleStep(int $runId, string $stepId, int $runNumber, StepRunLogger $logger): void { $automationRun = $this->automationRunStorage->getAutomationRun($runId); if (!$automationRun) { throw Exceptions::automationRunNotFound($runId); } if ($automationRun->getStatus() !== AutomationRun::STATUS_RUNNING) { throw Exceptions::automationRunNotRunning($runId, $automationRun->getStatus()); } $automation = $this->automationStorage->getAutomation($automationRun->getAutomationId(), $automationRun->getVersionId()); if (!$automation) { throw Exceptions::automationVersionNotFound($automationRun->getAutomationId(), $automationRun->getVersionId()); } if (!in_array($automation->getStatus(), [Automation::STATUS_ACTIVE, Automation::STATUS_DEACTIVATING], true)) { throw Exceptions::automationNotActive($automationRun->getAutomationId()); } $stepData = $automation->getStep($stepId); if (!$stepData) { throw Exceptions::automationStepNotFound($stepId); } $logger->logStepData($stepData); $step = $this->registry->getStep($stepData->getKey()); if (!$step instanceof Action) { throw new InvalidStateException(); } $requiredSubjects = $step->getSubjectKeys(); $subjectEntries = $this->getSubjectEntries($automationRun, $requiredSubjects); $args = new StepRunArgs($automation, $automationRun, $stepData, $subjectEntries, $runNumber); $validationArgs = new StepValidationArgs($automation, $stepData, array_map(function (SubjectEntry $entry) { return $entry->getSubject(); }, $subjectEntries)); $step->validate($validationArgs); $step->run($args, $this->stepRunControllerFactory->createController($args)); // schedule next step if not scheduled by action if (!$this->stepScheduler->hasScheduledStep($args)) { $this->stepScheduler->scheduleNextStep($args); } // logging if ($this->stepScheduler->hasScheduledProgress($args)) { $logger->logProgress(); } else { $logger->logSuccess(); } } /** @return SubjectEntry>[] */ private function getSubjectEntries(AutomationRun $automationRun, array $requiredSubjectKeys): array { $subjectDataMap = []; foreach ($automationRun->getSubjects() as $data) { $subjectDataMap[$data->getKey()] = array_merge($subjectDataMap[$data->getKey()] ?? [], [$data]); } $subjectEntries = []; foreach ($requiredSubjectKeys as $key) { $subjectData = $subjectDataMap[$key] ?? null; if (!$subjectData) { throw Exceptions::subjectDataNotFound($key, $automationRun->getId()); } } foreach ($subjectDataMap as $subjectData) { foreach ($subjectData as $data) { $subjectEntries[] = $this->subjectLoader->getSubjectEntry($data); } } return $subjectEntries; } private function postProcessAutomationRun(int $automationRunId): void { $automationRun = $this->automationRunStorage->getAutomationRun($automationRunId); if (!$automationRun) { return; } $automation = $this->automationStorage->getAutomation($automationRun->getAutomationId()); if (!$automation) { return; } $this->postProcessAutomation($automation); } private function postProcessAutomation(Automation $automation): void { if ($automation->getStatus() === Automation::STATUS_DEACTIVATING) { $activeRuns = $this->automationRunStorage->getCountForAutomation($automation, AutomationRun::STATUS_RUNNING); // Set a deactivating Automation to draft once all automation runs are finished. if ($activeRuns === 0) { $automation->setStatus(Automation::STATUS_DRAFT); $this->automationStorage->updateAutomation($automation); } } } } Control/SubjectTransformerHandler.php000064400000005510150732300560014017 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; } } } sort($all); return $all; } /** * @param Subject[] $subjects * @return Subject[] */ public function getAllSubjects(array $subjects): array { $transformerMap = $this->getTransformerMap(); $all = []; foreach ($subjects as $subject) { $all[$subject->getKey()] = $subject; } $queue = array_keys($all); while ($key = array_shift($queue)) { foreach ($transformerMap[$key] ?? [] as $transformer) { $newKey = $transformer->returns(); if (!isset($all[$newKey])) { $newSubject = $transformer->transform($all[$key]); if (!$newSubject) { continue; } $all[$newKey] = $newSubject; $queue[] = $newKey; } } } return array_values($all); } /** * @return SubjectTransformer[][] */ private function getTransformerMap(): array { $transformerMap = []; foreach ($this->registry->getSubjectTransformers() as $transformer) { $transformerMap[$transformer->accepts()] = array_merge($transformerMap[$transformer->accepts()] ?? [], [$transformer]); } return $transformerMap; } } Control/StepRunLoggerFactory.php000064400000001433150732300560012767 0ustar00automationRunLogStorage = $automationRunLogStorage; $this->hooks = $hooks; } public function createLogger(int $runId, string $stepId, string $stepType, int $runNumber): StepRunLogger { return new StepRunLogger($this->automationRunLogStorage, $this->hooks, $runId, $stepId, $stepType, $runNumber); } } Control/StepRunControllerFactory.php000064400000001002150732300560013663 0ustar00stepScheduler = $stepScheduler; } public function createController(StepRunArgs $args): StepRunController { return new StepRunController($this->stepScheduler, $args); } } Control/ActionScheduler.php000064400000001201150732300560011744 0ustar00automationRunLogStorage = $automationRunLogStorage; $this->hooks = $hooks; $this->runId = $runId; $this->stepId = $stepId; $this->stepType = $stepType; $this->runNumber = $runNumber; $this->isWpDebug = $isWpDebug !== null ? $isWpDebug : $this->getWpDebug(); } private function getWpDebug(): bool { if (!defined('WP_DEBUG')) { return false; } if (!is_bool(WP_DEBUG)) { return in_array(strtolower((string)WP_DEBUG), ['true', '1'], true); } return WP_DEBUG; } public function logStart(): void { $this->getLog(); } public function logStepData(Step $step): void { $log = $this->getLog(); $log->setStepKey($step->getKey()); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logProgress(): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_RUNNING); $log->setUpdatedAt(new DateTimeImmutable()); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logSuccess(): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_COMPLETE); $log->setUpdatedAt(new DateTimeImmutable()); $this->triggerAfterRunHook($log); $this->automationRunLogStorage->updateAutomationRunLog($log); } public function logFailure(Throwable $error): void { $log = $this->getLog(); $log->setStatus(AutomationRunLog::STATUS_FAILED); $log->setError($error); $log->setUpdatedAt(new DateTimeImmutable()); $this->triggerAfterRunHook($log); $this->automationRunLogStorage->updateAutomationRunLog($log); } private function getLog(): AutomationRunLog { if (!$this->log) { $this->log = $this->automationRunLogStorage->getAutomationRunLogByRunAndStepId($this->runId, $this->stepId); } if (!$this->log) { $log = new AutomationRunLog($this->runId, $this->stepId, $this->stepType); $log->setRunNumber($this->runNumber); $id = $this->automationRunLogStorage->createAutomationRunLog($log); $this->log = $this->automationRunLogStorage->getAutomationRunLog($id); } if (!$this->log) { throw new InvalidStateException('Failed to create automation run log'); } $this->log->setRunNumber($this->runNumber); return $this->log; } private function triggerAfterRunHook(AutomationRunLog $log): void { try { $this->hooks->doAutomationStepAfterRun($log); } catch (Throwable $e) { if ($this->isWpDebug) { throw $e; } // ignore integration logging errors } } } Control/FilterHandler.php000064400000003617150732300560011430 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; } public function matchesGroup(FilterGroup $group, StepRunArgs $args): bool { $operator = $group->getOperator(); foreach ($group->getFilters() as $filter) { $value = $args->getFieldValue($filter->getFieldKey()); $matches = $this->matchesFilter($filter, $value); if ($operator === FilterGroup::OPERATOR_AND && !$matches) { return false; } if ($operator === FilterGroup::OPERATOR_OR && $matches) { return true; } } return $operator === FilterGroup::OPERATOR_AND; } /** @param mixed $value */ private function matchesFilter(FilterData $data, $value): bool { $filter = $this->registry->getFilter($data->getFieldType()); if (!$filter) { throw Exceptions::filterNotFound($data->getFieldType()); } return $filter->matches($data, $value); } } Storage/AutomationStorage.php000064400000035777150732300560012352 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->subjectsTable = $wpdb->prefix . 'mailpoet_automation_run_subjects'; $this->wpdb = $wpdb; } public function createAutomation(Automation $automation): int { $automationHeaderData = $this->getAutomationHeaderData($automation); unset($automationHeaderData['id']); $result = $this->wpdb->insert($this->automationsTable, $automationHeaderData); if (!$result) { throw Exceptions::databaseError($this->wpdb->last_error); } $id = $this->wpdb->insert_id; $this->insertAutomationVersion($id, $automation); $this->insertAutomationTriggers($id, $automation); return $id; } public function updateAutomation(Automation $automation): void { $oldRecord = $this->getAutomation($automation->getId()); if ($oldRecord && $oldRecord->equals($automation)) { return; } $result = $this->wpdb->update($this->automationsTable, $this->getAutomationHeaderData($automation), ['id' => $automation->getId()]); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $this->insertAutomationVersion($automation->getId(), $automation); $this->insertAutomationTriggers($automation->getId(), $automation); } /** * @param int $automationId * @return VersionDate[] * @throws \Exception */ public function getAutomationVersionDates(int $automationId): array { $versionsTable = esc_sql($this->versionsTable); /** @var literal-string $sql */ $sql = " SELECT id, created_at FROM $versionsTable WHERE automation_id = %d ORDER BY id DESC "; $query = (string)$this->wpdb->prepare($sql, $automationId); $data = $this->wpdb->get_results($query, ARRAY_A); return is_array($data) ? array_map( function($row): array { /** @var array{id: string, created_at: string} $row */ return [ 'id' => absint($row['id']), 'created_at' => new \DateTimeImmutable($row['created_at']), ]; }, $data ) : []; } /** * @param int[] $versionIds * @return Automation[] */ public function getAutomationWithDifferentVersions(array $versionIds): array { $versionIds = array_map('intval', $versionIds); if (!$versionIds) { return []; } $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable as a, $versionsTable as v WHERE v.automation_id = a.id AND v.id IN (" . implode(',', array_fill(0, count($versionIds), '%d')) . ") ORDER BY v.id DESC "; $query = (string)$this->wpdb->prepare($sql, ...$versionIds); $data = $this->wpdb->get_results($query, ARRAY_A); return is_array($data) ? array_map( function($row): Automation { return Automation::fromArray((array)$row); }, $data ) : []; } public function getAutomation(int $automationId, int $versionId = null): ?Automation { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); if ($versionId) { $automations = $this->getAutomationWithDifferentVersions([$versionId]); return $automations ? $automations[0] : null; } /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable as a, $versionsTable as v WHERE v.automation_id = a.id AND a.id = %d ORDER BY v.id DESC LIMIT 1 "; $query = (string)$this->wpdb->prepare($sql, $automationId); $data = $this->wpdb->get_row($query, ARRAY_A); return $data ? Automation::fromArray((array)$data) : null; } /** @return Automation[] */ public function getAutomations(array $status = null): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $statusFilter = $status ? 'AND a.status IN(' . implode(',', array_fill(0, count($status), '%s')) . ')' : ''; /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable AS a INNER JOIN $versionsTable as v ON (v.automation_id = a.id) WHERE v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) $statusFilter ORDER BY a.id DESC "; $query = $status ? (string)$this->wpdb->prepare($sql, ...$status) : $sql; $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } /** @return Automation[] */ public function getAutomationsBySubject(Subject $subject, array $runStatus = null): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $runsTable = esc_sql($this->runsTable); $subjectTable = esc_sql($this->subjectsTable); $statusFilter = $runStatus ? 'AND r.status IN(' . implode(',', array_fill(0, count($runStatus), '%s')) . ')' : ''; /** @var literal-string $sql */ $sql = " SELECT DISTINCT a.*, v.id AS version_id, v.steps FROM $automationsTable a INNER JOIN $versionsTable v ON v.automation_id = a.id INNER JOIN $runsTable r ON r.automation_id = a.id INNER JOIN $subjectTable s ON s.automation_run_id = r.id WHERE v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) AND s.hash = %s $statusFilter ORDER BY a.id DESC "; $query = (string)$this->wpdb->prepare($sql, ...array_merge([$subject->getHash()], $runStatus ?? [])); $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } public function getAutomationCount(): int { $automationsTable = esc_sql($this->automationsTable); return (int)$this->wpdb->get_var("SELECT COUNT(*) FROM $automationsTable"); } /** @return string[] */ public function getActiveTriggerKeys(): array { $automationsTable = esc_sql($this->automationsTable); $triggersTable = esc_sql($this->triggersTable); /** @var literal-string $sql */ $sql = " SELECT DISTINCT t.trigger_key FROM {$automationsTable} AS a JOIN $triggersTable as t WHERE a.status = %s AND a.id = t.automation_id ORDER BY trigger_key DESC "; $query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE); return $this->wpdb->get_col($query); } /** @return Automation[] */ public function getActiveAutomationsByTrigger(Trigger $trigger): array { return $this->getActiveAutomationsByTriggerKey($trigger->getKey()); } public function getActiveAutomationsByTriggerKey(string $triggerKey): array { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $triggersTable = esc_sql($this->triggersTable); /** @var literal-string $sql */ $sql = " SELECT a.*, v.id AS version_id, v.steps FROM $automationsTable AS a INNER JOIN $triggersTable as t ON (t.automation_id = a.id) INNER JOIN $versionsTable as v ON (v.automation_id = a.id) WHERE a.status = %s AND t.trigger_key = %s AND v.id = ( SELECT MAX(id) FROM $versionsTable WHERE automation_id = v.automation_id ) "; $query = (string)$this->wpdb->prepare($sql, Automation::STATUS_ACTIVE, $triggerKey); $data = $this->wpdb->get_results($query, ARRAY_A); return array_map(function ($automationData) { /** @var array $automationData - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return Automation::fromArray($automationData); }, (array)$data); } public function getCountOfActiveByTriggerKeysAndAction(array $triggerKeys, string $actionKey): int { $automationsTable = esc_sql($this->automationsTable); $versionsTable = esc_sql($this->versionsTable); $triggersTable = esc_sql($this->triggersTable); $triggerKeysPlaceholders = implode(',', array_fill(0, count($triggerKeys), '%s')); $queryArgs = array_merge( $triggerKeys, [ Automation::STATUS_ACTIVE, '%"' . $this->wpdb->esc_like($actionKey) . '"%', ] ); // Using the phpcs:ignore because the query arguments count is dynamic and passed via an array but the code sniffer sees only one argument /** @var literal-string $sql */ $sql = " SELECT count(*) FROM $automationsTable AS a INNER JOIN $triggersTable as t ON (t.automation_id = a.id) AND t.trigger_key IN ({$triggerKeysPlaceholders}) INNER JOIN $versionsTable as v ON v.id = (SELECT MAX(id) FROM $versionsTable WHERE automation_id = a.id) WHERE a.status = %s AND v.steps LIKE %s "; $query = (string)$this->wpdb->prepare($sql, $queryArgs); return (int)$this->wpdb->get_var($query); } public function deleteAutomation(Automation $automation): void { $automationRunsTable = esc_sql($this->runsTable); $automationRunLogsTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_run_logs'); $automationId = $automation->getId(); /** @var literal-string $sql */ $sql = " DELETE FROM $automationRunLogsTable WHERE automation_run_id IN ( SELECT id FROM $automationRunsTable WHERE automation_id = %d ) "; $runLogsQuery = (string)$this->wpdb->prepare($sql, $automationId); $logsDeleted = $this->wpdb->query($runLogsQuery); if ($logsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $runsDeleted = $this->wpdb->delete($this->runsTable, ['automation_id' => $automationId]); if ($runsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $versionsDeleted = $this->wpdb->delete($this->versionsTable, ['automation_id' => $automationId]); if ($versionsDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $triggersDeleted = $this->wpdb->delete($this->triggersTable, ['automation_id' => $automationId]); if ($triggersDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $automationDeleted = $this->wpdb->delete($this->automationsTable, ['id' => $automationId]); if ($automationDeleted === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function truncate(): void { $automationsTable = esc_sql($this->automationsTable); $result = $this->wpdb->query("TRUNCATE {$automationsTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $versionsTable = esc_sql($this->versionsTable); $result = $this->wpdb->query("TRUNCATE {$versionsTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } $triggersTable = esc_sql($this->triggersTable); $result = $this->wpdb->query("TRUNCATE {$triggersTable}"); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getNameColumnLength(): int { $nameColumnLengthInfo = $this->wpdb->get_col_length($this->automationsTable, 'name'); return is_array($nameColumnLengthInfo) ? $nameColumnLengthInfo['length'] ?? 255 : 255; } private function getAutomationHeaderData(Automation $automation): array { $automationHeader = $automation->toArray(); unset($automationHeader['steps']); return $automationHeader; } private function insertAutomationVersion(int $automationId, Automation $automation): void { $dateString = (new DateTimeImmutable())->format(DateTimeImmutable::W3C); $data = [ 'automation_id' => $automationId, 'steps' => $automation->toArray()['steps'], 'created_at' => $dateString, 'updated_at' => $dateString, ]; $result = $this->wpdb->insert($this->versionsTable, $data); if (!$result) { throw Exceptions::databaseError($this->wpdb->last_error); } } private function insertAutomationTriggers(int $automationId, Automation $automation): void { $triggerKeys = []; foreach ($automation->getSteps() as $step) { if ($step->getType() === Step::TYPE_TRIGGER) { $triggerKeys[] = $step->getKey(); } } $triggersTable = esc_sql($this->triggersTable); // insert/update if ($triggerKeys) { $placeholders = implode(',', array_fill(0, count($triggerKeys), '(%d, %s)')); /** @var literal-string $sql */ $sql = "INSERT IGNORE INTO {$triggersTable} (automation_id, trigger_key) VALUES {$placeholders}"; $query = (string)$this->wpdb->prepare( $sql, array_merge( ...array_map(function (string $key) use ($automationId) { return [$automationId, $key]; }, $triggerKeys) ) ); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } // delete $placeholders = implode(',', array_fill(0, count($triggerKeys), '%s')); if ($triggerKeys) { /** @var literal-string $sql */ $sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d AND trigger_key NOT IN ({$placeholders})"; $query = (string)$this->wpdb->prepare($sql, array_merge([$automationId], $triggerKeys)); } else { /** @var literal-string $sql */ $sql = "DELETE FROM {$triggersTable} WHERE automation_id = %d"; $query = (string)$this->wpdb->prepare($sql, $automationId); } $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } } Storage/index.php000064400000000006150732300560007765 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 updateAutomationRunLog(AutomationRunLog $automationRunLog): void { $result = $this->wpdb->update($this->table, $automationRunLog->toArray(), ['id' => $automationRunLog->getId()]); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getAutomationRunStatisticsForAutomationInTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array { $logTable = esc_sql($this->table); $runTable = esc_sql($this->wpdb->prefix . 'mailpoet_automation_runs'); $whereCondition = 'run.automation_id = %d AND log.status = %s AND run.created_at BETWEEN %s AND %s'; if ($versionId !== null) { $whereCondition .= ' AND run.version_id = %d'; } /** @var literal-string $sql */ $sql = "SELECT count(log.id) as `count`, log.step_id FROM $logTable AS log JOIN $runTable AS run ON log.automation_run_id = run.id WHERE $whereCondition GROUP BY log.step_id"; $sql = $versionId ? $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId) : $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')); $sql = is_string($sql) ? $sql : ""; $results = $this->wpdb->get_results($sql, ARRAY_A); return is_array($results) ? $results : []; } public function getAutomationRunLog(int $id): ?AutomationRunLog { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE id = %d"; $query = $this->wpdb->prepare($sql, $id); if (!is_string($query)) { throw InvalidStateException::create(); } $result = $this->wpdb->get_row($query, ARRAY_A); if ($result) { $data = (array)$result; return AutomationRunLog::fromArray($data); } return null; } public function getAutomationRunLogByRunAndStepId(int $runId, string $stepId): ?AutomationRunLog { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE automation_run_id = %d AND step_id = %s"; $query = $this->wpdb->prepare($sql, $runId, $stepId); if (!is_string($query)) { throw InvalidStateException::create(); } $result = $this->wpdb->get_row($query, ARRAY_A); return $result ? AutomationRunLog::fromArray((array)$result) : null; } /** * @param int $automationRunId * @return AutomationRunLog[] * @throws InvalidStateException */ public function getLogsForAutomationRun(int $automationRunId): array { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " SELECT * FROM $table WHERE automation_run_id = %d ORDER BY id ASC "; $query = $this->wpdb->prepare($sql, $automationRunId); if (!is_string($query)) { throw InvalidStateException::create(); } $results = $this->wpdb->get_results($query, ARRAY_A); if (!is_array($results)) { throw InvalidStateException::create(); } if ($results) { return array_map(function($data) { /** @var array $data - for PHPStan because it conflicts with expected callable(mixed): mixed)|null */ return AutomationRunLog::fromArray($data); }, $results); } return []; } public function truncate(): void { $table = esc_sql($this->table); $sql = "TRUNCATE $table"; $this->wpdb->query($sql); } } Storage/AutomationRunStorage.php000064400000017556150732300560013032 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); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE id = %d"; $query = (string)$this->wpdb->prepare($sql, $id); $data = $this->wpdb->get_row($query, ARRAY_A); if (!is_array($data) || !$data) { return null; } /** @var literal-string $sql */ $sql = "SELECT * FROM $subjectTable WHERE automation_run_id = %d"; $query = (string)$this->wpdb->prepare($sql, $id); $subjects = $this->wpdb->get_results($query, ARRAY_A); $data['subjects'] = is_array($subjects) ? $subjects : []; return AutomationRun::fromArray((array)$data); } /** * @param Automation $automation * @return AutomationRun[] */ public function getAutomationRunsForAutomation(Automation $automation): array { $table = esc_sql($this->table); $subjectTable = esc_sql($this->subjectTable); /** @var literal-string $sql */ $sql = "SELECT * FROM $table WHERE automation_id = %d order by id"; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $automationRuns = $this->wpdb->get_results($query, ARRAY_A); if (!is_array($automationRuns) || !$automationRuns) { return []; } $automationRunIds = array_column($automationRuns, 'id'); /** @var literal-string $sql */ $sql = sprintf( "SELECT * FROM $subjectTable WHERE automation_run_id in (%s) order by automation_run_id, id", implode( ',', array_map( function() { return '%d'; }, $automationRunIds ) ) ); $query = (string)$this->wpdb->prepare($sql, ...$automationRunIds); $subjects = $this->wpdb->get_results($query, ARRAY_A); return array_map( function($runData) use ($subjects): AutomationRun { /** @var array $runData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */ $runData['subjects'] = array_values(array_filter( is_array($subjects) ? $subjects : [], function($subjectData) use ($runData): bool { /** @var array $subjectData - PHPStan expects as array_map first parameter (callable(mixed): mixed)|null */ return (int)$subjectData['automation_run_id'] === (int)$runData['id']; } )); return AutomationRun::fromArray($runData); }, $automationRuns ); } /** * @param Automation $automation * @return int */ public function getCountByAutomationAndSubject(Automation $automation, Subject $subject): int { $table = esc_sql($this->table); $subjectTable = esc_sql($this->subjectTable); /** @var literal-string $sql */ $sql = "SELECT count(DISTINCT runs.id) as count from $table as runs JOIN $subjectTable as subjects on runs.id = subjects.automation_run_id WHERE runs.automation_id = %d AND subjects.hash = %s"; $result = $this->wpdb->get_col( (string)$this->wpdb->prepare($sql, $automation->getId(), $subject->getHash()) ); return $result ? (int)current($result) : 0; } public function getCountForAutomation(Automation $automation, string ...$status): int { $table = esc_sql($this->table); if (!count($status)) { /** @var literal-string $sql */ $sql = " SELECT COUNT(id) as count FROM $table WHERE automation_id = %d "; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $result = $this->wpdb->get_col($query); return $result ? (int)current($result) : 0; } $statusSql = (string)$this->wpdb->prepare(implode(',', array_fill(0, count($status), '%s')), ...$status); /** @var literal-string $sql */ $sql = " SELECT COUNT(id) as count FROM $table WHERE automation_id = %d AND status IN ($statusSql) "; $query = (string)$this->wpdb->prepare($sql, $automation->getId()); $result = $this->wpdb->get_col($query); return $result ? (int)current($result) : 0; } public function updateStatus(int $id, string $status): void { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " UPDATE $table SET status = %s, updated_at = current_timestamp() WHERE id = %d "; $query = (string)$this->wpdb->prepare($sql, $status, $id); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function updateNextStep(int $id, ?string $nextStepId): void { $table = esc_sql($this->table); /** @var literal-string $sql */ $sql = " UPDATE $table SET next_step_id = %s, updated_at = current_timestamp() WHERE id = %d "; $query = (string)$this->wpdb->prepare($sql, $nextStepId, $id); $result = $this->wpdb->query($query); if ($result === false) { throw Exceptions::databaseError($this->wpdb->last_error); } } public function getAutomationStepStatisticForTimeFrame(int $automationId, string $status, \DateTimeImmutable $after, \DateTimeImmutable $before, int $versionId = null): array { $table = esc_sql($this->table); $where = "automation_id = %d AND `status` = %s AND created_at BETWEEN %s AND %s"; if ($versionId) { $where .= " AND version_id = %d"; } /** @var literal-string $sql */ $sql = " SELECT COUNT(id) AS `count`, next_step_id FROM $table as log WHERE $where GROUP BY next_step_id "; $sql = $versionId ? $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s'), $versionId) : $this->wpdb->prepare($sql, $automationId, $status, $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')); $sql = is_string($sql) ? $sql : ''; $result = $this->wpdb->get_results($sql, ARRAY_A); return is_array($result) ? $result : []; } public function truncate(): void { $table = esc_sql($this->table); $this->wpdb->query("TRUNCATE $table"); $table = esc_sql($this->subjectTable); $this->wpdb->query("TRUNCATE $table"); } } Storage/AutomationStatisticsStorage.php000064400000007535150732300560014414 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, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): AutomationStatistics { $data = $this->getStatistics([$automationId], $versionId, $after, $before); return new AutomationStatistics( $automationId, (int)($data[$automationId]['total'] ?? 0), (int)($data[$automationId]['running'] ?? 0), $versionId ); } /** * @param int[] $automationIds * @return array */ private function getStatistics(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null): array { $totalSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before); $runningSubquery = $this->getStatsQuery($automationIds, $versionId, $after, $before, AutomationRun::STATUS_RUNNING); // The subqueries are created using $wpdb->prepare(). // phpcs:ignore WordPressDotOrg.sniffs.DirectDB.UnescapedDBParameter $results = (array)$this->wpdb->get_results(" SELECT t.id, t.count AS total, r.count AS running FROM ($totalSubquery) t LEFT JOIN ($runningSubquery) r ON t.id = r.id ", ARRAY_A); /** @var array{id: int, total: int, running: int} $results */ return array_combine(array_column($results, 'id'), $results) ?: []; } private function getStatsQuery(array $automationIds, int $versionId = null, \DateTimeImmutable $after = null, \DateTimeImmutable $before = null, string $status = null): string { $table = esc_sql($this->table); $placeholders = implode(',', array_fill(0, count($automationIds), '%d')); /** @var string $versionCondition */ $versionCondition = $versionId ? $this->wpdb->prepare('AND version_id = %d', $versionId) : ''; $versionCondition = strval($versionCondition); /** @var string $statusCondition */ $statusCondition = $status ? $this->wpdb->prepare('AND status = %s', $status) : ''; $statusCondition = strval($statusCondition); /** @var string $dateCondition */ $dateCondition = $after !== null && $before !== null ? $this->wpdb->prepare('AND created_at BETWEEN %s AND %s', $after->format('Y-m-d H:i:s'), $before->format('Y-m-d H:i:s')) : ''; $dateCondition = strval($dateCondition); /** @var literal-string $sql */ $sql = " SELECT automation_id AS id, COUNT(*) AS count FROM $table WHERE automation_id IN ($placeholders) $versionCondition $statusCondition $dateCondition GROUP BY automation_id "; /** @var string $query */ $query = $this->wpdb->prepare($sql, $automationIds); return strval($query); } } Registry.php000064400000017107150732300560007074 0ustar00 */ private $templates; /** @var array */ private $templateCategories; /** @var array */ private $steps = []; /** @var array> */ private $subjects = []; /** @var SubjectTransformer[] */ private $subjectTransformers = []; /** @var array|null */ private $fields = null; /** @var array */ private $filters = []; /** @var array */ private $triggers = []; /** @var array */ private $actions = []; /** @var array */ private $contextFactories = []; /** @var WordPress */ private $wordPress; public function __construct( RootStep $rootStep, WordPress $wordPress ) { $this->wordPress = $wordPress; $this->steps[$rootStep->getKey()] = $rootStep; $this->templateCategories = [ 'welcome' => new AutomationTemplateCategory('welcome', __('Welcome', 'mailpoet')), 'abandoned-cart' => new AutomationTemplateCategory('abandoned-cart', __('Abandoned Cart', 'mailpoet')), 'reengagement' => new AutomationTemplateCategory('reengagement', __('Re-engagement', 'mailpoet')), 'woocommerce' => new AutomationTemplateCategory('woocommerce', __('WooCommerce', 'mailpoet')), ]; } public function addTemplate(AutomationTemplate $template): void { $category = $template->getCategory(); if (!isset($this->templateCategories[$category])) { throw InvalidStateException::create()->withMessage( sprintf("Category '%s' was not registered", $category) ); } $this->templates[$template->getSlug()] = $template; // keep coming soon templates at the end uasort( $this->templates, function (AutomationTemplate $a, AutomationTemplate $b): int { if ($a->getType() === AutomationTemplate::TYPE_COMING_SOON) { return 1; } if ($b->getType() === AutomationTemplate::TYPE_COMING_SOON) { return -1; } return 0; } ); } public function getTemplate(string $slug): ?AutomationTemplate { return $this->getTemplates()[$slug] ?? null; } /** @return array */ public function getTemplates(string $category = null): array { return $category ? array_filter( $this->templates, function(AutomationTemplate $template) use ($category): bool { return $template->getCategory() === $category; } ) : $this->templates; } public function removeTemplate(string $slug): void { unset($this->templates[$slug]); } /** @return array */ public function getTemplateCategories(): array { return $this->templateCategories; } /** @param Subject $subject */ public function addSubject(Subject $subject): void { $key = $subject->getKey(); if (isset($this->subjects[$key])) { throw new \Exception(); // TODO } $this->subjects[$key] = $subject; // reset fields cache $this->fields = null; } /** @return Subject|null */ public function getSubject(string $key): ?Subject { return $this->subjects[$key] ?? null; } /** @return array> */ public function getSubjects(): array { return $this->subjects; } public function addSubjectTransformer(SubjectTransformer $transformer): void { $this->subjectTransformers[] = $transformer; } public function getSubjectTransformers(): array { return $this->subjectTransformers; } public function getField(string $key): ?Field { return $this->getFields()[$key] ?? null; } /** @return array */ public function getFields(): array { // add fields lazily (on the first call) if ($this->fields === null) { $this->fields = []; foreach ($this->subjects as $subject) { foreach ($subject->getFields() as $field) { $this->addField($field); } } } return $this->fields ?? []; } public function addFilter(Filter $filter): void { $fieldType = $filter->getFieldType(); if (isset($this->filters[$fieldType])) { throw new \Exception(); // TODO } $this->filters[$fieldType] = $filter; } public function getFilter(string $fieldType): ?Filter { return $this->filters[$fieldType] ?? null; } /** @return array */ public function getFilters(): array { return $this->filters; } public function addStep(Step $step): void { if ($step instanceof Trigger) { $this->addTrigger($step); } elseif ($step instanceof Action) { $this->addAction($step); } // TODO: allow adding any other step implementations? } public function getStep(string $key): ?Step { return $this->steps[$key] ?? null; } /** @return array */ public function getSteps(): array { return $this->steps; } public function addTrigger(Trigger $trigger): void { $key = $trigger->getKey(); if (isset($this->steps[$key]) || isset($this->triggers[$key])) { throw new \Exception(); // TODO } $this->steps[$key] = $trigger; $this->triggers[$key] = $trigger; } public function getTrigger(string $key): ?Trigger { return $this->triggers[$key] ?? null; } /** @return array */ public function getTriggers(): array { return $this->triggers; } public function addAction(Action $action): void { $key = $action->getKey(); if (isset($this->steps[$key]) || isset($this->actions[$key])) { throw new \Exception(); // TODO } $this->steps[$key] = $action; $this->actions[$key] = $action; } public function getAction(string $key): ?Action { return $this->actions[$key] ?? null; } /** @return array */ public function getActions(): array { return $this->actions; } public function addContextFactory(string $key, callable $factory): void { $this->contextFactories[$key] = $factory; } /** @return callable[] */ public function getContextFactories(): array { return $this->contextFactories; } public function onBeforeAutomationSave(callable $callback, int $priority = 10): void { $this->wordPress->addAction(Hooks::AUTOMATION_BEFORE_SAVE, $callback, $priority); } public function onBeforeAutomationStepSave(callable $callback, string $key = null, int $priority = 10): void { $keyPart = $key ? "/key=$key" : ''; $this->wordPress->addAction(Hooks::AUTOMATION_STEP_BEFORE_SAVE . $keyPart, $callback, $priority, 2); } /** * This is used only internally. Fields are added lazily from subjects. */ private function addField(Field $field): void { $key = $field->getKey(); if (isset($this->fields[$key])) { throw new \Exception(); // TODO } $this->fields[$key] = $field; } } Exceptions/index.php000064400000000006150732300560010502 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; } } Integration.php000064400000000272150732300560007542 0ustar00api = $api; $this->coreIntegration = $coreIntegration; $this->wordPressIntegration = $wordPressIntegration; $this->registry = $registry; $this->stepHandler = $stepHandler; $this->triggerHandler = $triggerHandler; $this->wordPress = $wordPress; $this->automationStorage = $automationStorage; } public function initialize(): void { $this->registerApiRoutes(); $this->api->initialize(); $this->stepHandler->initialize(); $this->triggerHandler->initialize(); $this->coreIntegration->register($this->registry); $this->wordPressIntegration->register($this->registry); $this->wordPress->doAction(Hooks::INITIALIZE, [$this->registry]); $this->registerActiveTriggerHooks(); } private function registerApiRoutes(): void { $this->wordPress->addAction(Hooks::API_INITIALIZE, function (API $api) { $api->registerGetRoute('automations', AutomationsGetEndpoint::class); $api->registerPutRoute('automations/(?P\d+)', AutomationsPutEndpoint::class); $api->registerDeleteRoute('automations/(?P\d+)', AutomationsDeleteEndpoint::class); $api->registerPostRoute('automations/(?P\d+)/duplicate', AutomationsDuplicateEndpoint::class); $api->registerPostRoute('automations/create-from-template', AutomationsCreateFromTemplateEndpoint::class); $api->registerGetRoute('automation-templates', AutomationTemplatesGetEndpoint::class); $api->registerGetRoute('automation-templates/(?P.+)', AutomationTemplateGetEndpoint::class); }); } private function registerActiveTriggerHooks(): void { $triggerKeys = $this->automationStorage->getActiveTriggerKeys(); foreach ($triggerKeys as $triggerKey) { $instance = $this->registry->getTrigger($triggerKey); if ($instance) { $instance->registerHooks(); } } } } Validation/AutomationRules/index.php000064400000000006150732300560013606 0ustar00needsFullValidation()) { return; } $step = $node->getStep(); if ($step->getType() !== Step::TYPE_TRIGGER) { return; } $nextStepIds = $step->getNextStepIds(); if (!count($nextStepIds)) { throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID); } foreach ($nextStepIds as $nextStepsId) { $step = $automation->getStep($nextStepsId); if ($step && $step->getType() === Step::TYPE_ACTION) { continue; } throw Exceptions::automationStructureNotValid(__('A trigger needs to be followed by an action.', 'mailpoet'), self::RULE_ID); } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepRule.php000064400000012024150732300560015225 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' => [], 'filters' => []]; } $this->errors[$stepId]['fields'] = array_merge( $this->mapErrorCodesToErrorMessages($e->getErrors()), $this->errors[$stepId]['fields'] ); } catch (ValidationException $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => $e->getMessage(), 'fields' => [], 'filters' => []]; } $key = $rule instanceof ValidStepFiltersRule ? 'filters' : 'fields'; /** @phpstan-ignore-next-line - PHPStan detects inconsistency in merged array */ $this->errors[$stepId][$key] = array_merge( $this->mapErrorCodesToErrorMessages($e->getErrors()), $this->errors[$stepId][$key] ); } catch (Throwable $e) { if (!isset($this->errors[$stepId])) { $this->errors[$stepId] = ['step_id' => $stepId, 'message' => __('Unknown error.', 'mailpoet'), 'fields' => [], 'filters' => []]; } } } } private function mapErrorCodesToErrorMessages(array $errorCodes): array { return array_map( function(string $errorCode): string { switch ($errorCode) { case "rest_property_required": return __('This is a required field.', 'mailpoet'); case "rest_additional_properties_forbidden": case "rest_too_few_properties": case "rest_too_many_properties": return ""; case "rest_invalid_type": case "rest_invalid_multiple": case "rest_not_in_enum": return __('This field is not well formed.', 'mailpoet'); case "rest_too_few_items": return __('Please add more items.', 'mailpoet'); case "rest_too_many_items": return __('Please remove some items.', 'mailpoet'); case "rest_duplicate_items": return __('Please remove duplicate items.', 'mailpoet'); case "rest_out_of_bounds": return __('This value is out of bounds.', 'mailpoet'); case "rest_too_short": return __('This value is not long enough.', 'mailpoet'); case "rest_too_long": return __('This value is too long.', 'mailpoet'); case "rest_invalid_pattern": return __('This value is not well formed.', 'mailpoet'); case "rest_no_matching_schema": return __('This value does not match the expected format.', 'mailpoet'); case "rest_one_of_multiple_matches": return __('This value is not matching the correct times.', 'mailpoet'); case "rest_invalid_hex_color": return __('This value is not a hex formatted color.', 'mailpoet'); case "rest_invalid_date": return __('This value is not a date.', 'mailpoet'); case "rest_invalid_email": return __('This value is not an email address.', 'mailpoet'); case "rest_invalid_ip": return __('This value is not an IP address.', 'mailpoet'); case "rest_invalid_uuid": return __('This value is not an UUID.', 'mailpoet'); default: return $errorCode; } }, $errorCodes ); } public function complete(Automation $automation): void { if (!$automation->needsFullValidation()) { return; } foreach ($this->rules as $rule) { $rule->complete($automation); } if ($this->errors) { throw Exceptions::automationNotValid(__('Some steps are not valid', 'mailpoet'), $this->errors); } } } Validation/AutomationRules/NoJoinRule.php000064400000002321150732300560014525 0ustar00 */ private $directParentMap = []; public function initialize(Automation $automation): void { $this->directParentMap = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $step = $node->getStep(); foreach ($step->getNextStepIds() as $nextStepId) { $this->directParentMap[$nextStepId] = array_merge($this->directParentMap[$nextStepId] ?? [], [$step]); } if (count($this->directParentMap[$step->getId()] ?? []) > 1) { throw Exceptions::automationStructureNotValid(__('Path join found in automation graph', 'mailpoet'), self::RULE_ID); } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepArgsRule.php000064400000003772150732300560016054 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 { } } Validation/AutomationRules/ValidStepValidationRule.php000064400000004342150732300560017244 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; } } Validation/AutomationRules/TriggersUnderRootRule.php000064400000002545150732300560016771 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->getNextStepIds() as $nextStepId) { if (isset($this->triggersMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Trigger must be a direct descendant of automation root', 'mailpoet'), self::RULE_ID); } } } public function complete(Automation $automation): void { } } Validation/AutomationRules/ValidStepOrderRule.php000064400000003455150732300560016231 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 { } } Validation/AutomationRules/NoDuplicateEdgesRule.php000064400000002035150732300560016512 0ustar00getStep()->getNextStepIds() as $nextStepId) { if (isset($visitedNextStepIdsMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Duplicate next step definition found', 'mailpoet'), self::RULE_ID); } $visitedNextStepIdsMap[$nextStepId] = true; } } public function complete(Automation $automation): void { } } Validation/AutomationRules/NoUnreachableStepsRule.php000064400000002062150732300560017060 0ustar00visitedNodes = []; } public function visitNode(Automation $automation, AutomationNode $node): void { $this->visitedNodes[$node->getStep()->getId()] = $node; } public function complete(Automation $automation): void { if (count($this->visitedNodes) !== count($automation->getSteps())) { throw Exceptions::automationStructureNotValid(__('Unreachable steps found in automation graph', 'mailpoet'), self::RULE_ID); } } } Validation/AutomationRules/AtLeastOneTriggerRule.php000064400000002257150732300560016664 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); } } Validation/AutomationRules/NoCycleRule.php000064400000002326150732300560014672 0ustar00getStep(); $parents = $node->getParents(); $parentIdsMap = array_combine( array_map(function (Step $parent) { return $parent->getId(); }, $node->getParents()), $parents ) ?: []; foreach ($step->getNextStepIds() as $nextStepId) { if ($nextStepId === $step->getId() || isset($parentIdsMap[$nextStepId])) { throw Exceptions::automationStructureNotValid(__('Cycle found in automation graph', 'mailpoet'), self::RULE_ID); } } } public function complete(Automation $automation): void { } } Validation/AutomationRules/UnknownStepRule.php000064400000004335150732300560015633 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; } } Validation/AutomationRules/ValidStepFiltersRule.php000064400000006343150732300560016565 0ustar00registry = $registry; $this->subjectTransformerHandler = $subjectTransformerHandler; $this->validator = $validator; } public function initialize(Automation $automation): void { } public function visitNode(Automation $automation, AutomationNode $node): void { $filters = $node->getStep()->getFilters(); $groups = $filters ? $filters->getGroups() : []; $errors = []; foreach ($groups as $group) { foreach ($group->getFilters() as $filter) { $registryFilter = $this->registry->getFilter($filter->getFieldType()); if (!$registryFilter) { continue; } try { $this->validator->validate($registryFilter->getArgsSchema($filter->getCondition()), $filter->getArgs()); } catch (ValidationException $e) { $errors[$filter->getId()] = $e->getWpError()->get_error_code(); continue; } // ensure that the field is available with the provided subjects $subjectKeys = $this->subjectTransformerHandler->getSubjectKeysForAutomation($automation); $filterSubject = $this->getFilterSubject($filter); if (!$filterSubject) { $errors[$filter->getId()] = __('Field not found', 'mailpoet'); } elseif (!in_array($filterSubject->getKey(), $subjectKeys, true)) { // translators: %s is the name of a subject (data structure) that provides the field $errors[$filter->getId()] = sprintf(__('A trigger that provides %s is required', 'mailpoet'), $filterSubject->getName()); } } } if ($errors) { $throwable = ValidationException::create()->withMessage('invalid-automation-filters'); foreach ($errors as $errorKey => $errorMsg) { $throwable->withError((string)$errorKey, (string)$errorMsg); } throw $throwable; } } public function complete(Automation $automation): void { } /** @return Subject */ private function getFilterSubject(Filter $filter): ?Subject { foreach ($this->registry->getSubjects() as $subject) { foreach ($subject->getFields() as $field) { if ($field->getKey() === $filter->getFieldKey()) { return $subject; } } } return null; } } Validation/AutomationRules/ConsistentStepMapRule.php000064400000002146150732300560016761 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 { } } Validation/index.php000064400000000006150732300560010453 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()->nullable(), ]) ); } public static function getFiltersSchema(): ObjectSchema { $operatorSchema = Builder::string()->pattern('^and|or$')->required(); $filterSchema = Builder::object([ 'id' => Builder::string()->required(), 'field_type' => Builder::string()->required(), 'field_key' => Builder::string()->required(), 'condition' => Builder::string()->required(), 'args' => Builder::object()->required(), ]); $filterGroupSchema = Builder::object([ 'id' => Builder::string()->required(), 'operator' => $operatorSchema, 'filters' => Builder::array($filterSchema)->minItems(1)->required(), ]); return Builder::object([ 'operator' => $operatorSchema, 'groups' => Builder::array($filterGroupSchema)->minItems(1)->required(), ]); } } Validation/AutomationValidator.php000064400000005675150732300560013353 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(), $this->unknownStepRule, new AtLeastOneTriggerRule(), new TriggerNeedsToBeFollowedByActionRule(), new ValidStepRule([ $this->validStepArgsRule, $this->validStepFiltersRule, $this->validStepOrderRule, $this->validStepValidationRule, ]), ]); } } Validation/AutomationGraph/index.php000064400000000006150732300560013555 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(); if (!$nextStepId) { continue; // empty edge } $nextStep = $steps[$nextStepId] ?? null; if (!$nextStep) { throw $this->createStepNotFoundException($nextStepId, $step->getId()); } $nextStepParents = array_merge($parents, [$step->getId() => $step]); if (isset($nextStepParents[$nextStepId])) { continue; // cycle detected, do not enter the path again } array_push($stack, [$nextStep, $nextStepParents]); } } while (count($stack) > 0); } private function createStepNotFoundException(string $stepId, string $parentStepId): UnexpectedValueException { return Exceptions::automationStructureNotValid( // translators: %1$s is ID of the step not found, %2$s is ID of the step that references it sprintf( __("Step with ID '%1\$s' not found (referenced from '%2\$s')", 'mailpoet'), $stepId, $parentStepId ), 'step-not-found' ); } } Validation/AutomationGraph/AutomationNode.php000064400000001133150732300560015376 0ustar00step = $step; $this->parents = $parents; } public function getStep(): Step { return $this->step; } /** @return Step[] */ public function getParents(): array { return $this->parents; } } Validation/AutomationGraph/AutomationNodeVisitor.php000064400000000643150732300560016763 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 nextStepNotFound(string $stepId, int $nextStepId): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::NEXT_STEP_NOT_FOUND) // translators: %1$d is the ID of the automation step, %2$s is the ID of the next step. ->withMessage(sprintf(__("Automation step with ID '%1\$s' doesn't have a next step with index '%2\$d'.", 'mailpoet'), $stepId, $nextStepId)); } public static function nextStepNotScheduled(string $stepId): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::NEXT_STEP_NOT_SCHEDULED) // translators: %1$d is the ID of the automation step, %2$s is the ID of the next step. ->withMessage(sprintf(__("Automation step with ID '%s' did not schedule a specific next step, even though multiple next steps are possible.", 'mailpoet'), $stepId)); } public static function automationStructureModificationNotSupported(): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STRUCTURE_MODIFICATION_NOT_SUPPORTED) ->withMessage(__('Automation structure modification not supported.', 'mailpoet')); } public static function automationStructureNotValid(string $detail, string $ruleId): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STRUCTURE_NOT_VALID) // translators: %s is a detailed information ->withMessage(sprintf(__("Invalid automation structure: %s", 'mailpoet'), $detail)) ->withErrors(['rule_id' => $ruleId]); } public static function automationStepModifiedWhenUnknown(Step $step): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_STEP_MODIFIED_WHEN_UNKNOWN) // translators: %1$s is the key of the step, %2$s is the type of the step, %3\$s is its ID. ->withMessage( sprintf( __("Modification of step '%1\$s' of type '%2\$s' with ID '%3\$s' is not supported when the related plugin is not active.", 'mailpoet'), $step->getKey(), $step->getType(), $step->getId() ) ); } public static function automationNotValid(string $detail, array $errors): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_NOT_VALID) // translators: %s is a detailed information ->withMessage(sprintf(__("Automation validation failed: %s", 'mailpoet'), $detail)) ->withErrors($errors); } public static function missingRequiredSubjects(Step $step, array $missingSubjectKeys): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::MISSING_REQUIRED_SUBJECTS) // translators: %1$s is the key of the step, %2$s are the missing subject keys. ->withMessage( sprintf( __("Step with ID '%1\$s' is missing required subjects with keys: %2\$s", 'mailpoet'), $step->getId(), implode(', ', $missingSubjectKeys) ) ) ->withErrors( ['general' => __('This step can not be used with the selected trigger.', 'mailpoet')] ); } public static function automationNotTrashed(int $id): UnexpectedValueException { return UnexpectedValueException::create() ->withErrorCode(self::AUTOMATION_NOT_TRASHED) // translators: %d is the ID of the automation. ->withMessage(sprintf(__("Can't delete automation with ID '%d' because it was not trashed.", 'mailpoet'), $id)); } public static function automationTemplateNotFound(string $id): NotFoundException { return NotFoundException::create() ->withErrorCode(self::AUTOMATION_TEMPLATE_NOT_FOUND) // translators: %d is the ID of the automation template. ->withMessage(sprintf(__("Automation template with ID '%d' not found.", 'mailpoet'), $id)); } /** * This is a temporary block, see MAILPOET-4744 */ public static function automationHasActiveRuns(int $id): InvalidStateException { return InvalidStateException::create() ->withErrorCode(self::AUTOMATION_HAS_ACTIVE_RUNS) // translators: %d is the ID of the automation. ->withMessage(sprintf(__("Can not update automation with ID '%d' because users are currently active.", 'mailpoet'), $id)); } } Templates/index.php000064400000000006150732300560010317 0ustar00registry = $registry; } /** * @param string $name * @param array< * array{ * key: string, * args?: array, * filters?: array{ * operator: 'and' | 'or', * groups: array{ * operator: 'and' | 'or', * filters: array{ * field: string, * condition: string, * value: mixed, * }[], * }[], * }, * } * > $sequence * @param array $meta * @return Automation */ public function createFromSequence(string $name, array $sequence, array $meta = []): Automation { $steps = []; $nextSteps = []; foreach (array_reverse($sequence) as $data) { $stepKey = $data['key']; $automationStep = $this->registry->getStep($stepKey); if (!$automationStep) { continue; } $args = array_merge($this->getDefaultArgs($automationStep->getArgsSchema()), $data['args'] ?? []); $filters = isset($data['filters']) ? $this->getFilters($data['filters']) : null; $step = new Step( $this->uniqueId(), in_array(Trigger::class, (array)class_implements($automationStep)) ? Step::TYPE_TRIGGER : Step::TYPE_ACTION, $stepKey, $args, $nextSteps, $filters ); $nextSteps = [new NextStep($step->getId())]; $steps[$step->getId()] = $step; } $steps['root'] = new Step('root', 'root', 'core:root', [], $nextSteps); $steps = array_reverse($steps); $automation = new Automation( $name, $steps, wp_get_current_user() ); foreach ($meta as $key => $value) { $automation->setMeta($key, $value); } return $automation; } private function uniqueId(): string { return Security::generateRandomString(16); } private function getDefaultArgs(ObjectSchema $argsSchema): array { $args = []; foreach ($argsSchema->toArray()['properties'] ?? [] as $name => $schema) { if (array_key_exists('default', $schema)) { $args[$name] = $schema['default']; } } return $args; } /** * @param array{ * operator: 'and' | 'or', * groups: array{ * operator: 'and' | 'or', * filters: array{ * field: string, * condition: string, * value: mixed, * }[], * }[], * } $filters * @return Filters */ private function getFilters(array $filters): Filters { $groups = []; foreach ($filters['groups'] as $group) { $groups[] = new FilterGroup( $this->uniqueId(), $group['operator'], array_map( function (array $filter): Filter { $field = $this->registry->getField($filter['field']); if (!$field) { throw new InvalidStateException(sprintf("Field with key '%s' not found", $filter['field'])); } return new Filter( $this->uniqueId(), $field->getType(), $filter['field'], $filter['condition'], ['value' => $filter['value']] ); }, $group['filters'] ) ); } return new Filters($filters['operator'], $groups); } } Integration/index.php000064400000000006150732300560010644 0ustar00 */ public function getConditions(): array; public function getArgsSchema(string $condition): ObjectSchema; /** @param mixed $value */ public function matches(FilterData $data, $value): bool; } Integration/SubjectTransformer.php000064400000000507150732300560013365 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); } } API/Endpoint.php000064400000000564150732300560007454 0ustar00settingsController = $settingsController; } public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($parsedBlocks as $key => $block) { $block = $this->preprocessParent($block); // Set defaults from theme - this needs to be done on top level blocks only $block = $this->setDefaultsFromTheme($block); $block['innerBlocks'] = $this->copyTypographyFromParent($block['innerBlocks'], $block); $parsedBlocks[$key] = $block; } return $parsedBlocks; } private function copyTypographyFromParent(array $children, array $parent): array { foreach ($children as $key => $child) { $child = $this->preprocessParent($child); $child['email_attrs'] = array_merge($this->filterStyles($parent['email_attrs']), $child['email_attrs']); $child['innerBlocks'] = $this->copyTypographyFromParent($child['innerBlocks'] ?? [], $child); $children[$key] = $child; } return $children; } private function preprocessParent(array $block): array { // Build styles that should be copied to children $emailAttrs = []; if (isset($block['attrs']['style']['color']['text'])) { $emailAttrs['color'] = $block['attrs']['style']['color']['text']; } // In case the fontSize is set via a slug (small, medium, large, etc.) we translate it to a number // The font size slug is set in $block['attrs']['fontSize'] and value in $block['attrs']['style']['typography']['fontSize'] if (isset($block['attrs']['fontSize'])) { $block['attrs']['style']['typography']['fontSize'] = $this->settingsController->translateSlugToFontSize($block['attrs']['fontSize']); } // Pass font size to email_attrs if (isset($block['attrs']['style']['typography']['fontSize'])) { $emailAttrs['font-size'] = $block['attrs']['style']['typography']['fontSize']; } if (isset($block['attrs']['style']['typography']['textDecoration'])) { $emailAttrs['text-decoration'] = $block['attrs']['style']['typography']['textDecoration']; } $block['email_attrs'] = array_merge($emailAttrs, $block['email_attrs'] ?? []); return $block; } private function filterStyles(array $styles): array { return array_intersect_key($styles, array_flip(self::TYPOGRAPHY_STYLES)); } private function setDefaultsFromTheme(array $block): array { $themeData = $this->settingsController->getTheme()->get_data(); if (!($block['email_attrs']['color'] ?? '')) { $block['email_attrs']['color'] = $themeData['styles']['color']['text'] ?? null; } if (!($block['email_attrs']['font-size'] ?? '')) { $block['email_attrs']['font-size'] = $themeData['styles']['typography']['fontSize']; } return $block; } } Renderer/Preprocessors/Preprocessor.php000064400000000354150732300720014363 0ustar00 'core/columns', 'attrs' => [], 'innerBlocks' => [[ 'blockName' => 'core/column', 'attrs' => [], 'innerBlocks' => [], ]], ]; /** * In the editor we allow putting content blocks directly into the root level of the email. * But for rendering purposes it is more convenient to have them wrapped in a single column. * This method walks through the first level of blocks and wraps non column blocks into a single column. */ public function preprocess(array $parsedBlocks, array $layoutStyles): array { $wrappedParsedBlocks = []; $nonColumnsBlocksBuffer = []; foreach ($parsedBlocks as $block) { $blockAlignment = $block['attrs']['align'] ?? null; // The next block is columns so we can flush the buffer and add the columns block if ($block['blockName'] === 'core/columns' || $blockAlignment === 'full') { if ($nonColumnsBlocksBuffer) { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer; $nonColumnsBlocksBuffer = []; $wrappedParsedBlocks[] = $columnsBlock; } // If the block is full width and is not core/columns, we need to wrap it in a single column block, and it the columns block has to contain only the block if ($blockAlignment === 'full' && $block['blockName'] !== 'core/columns') { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['attrs']['align'] = 'full'; $columnsBlock['innerBlocks'][0]['innerBlocks'] = [$block]; $wrappedParsedBlocks[] = $columnsBlock; continue; } $wrappedParsedBlocks[] = $block; continue; } // Non columns block so we add it to the buffer $nonColumnsBlocksBuffer[] = $block; } // Flush the buffer if there are any blocks left if ($nonColumnsBlocksBuffer) { $columnsBlock = self::SINGLE_COLUMN_TEMPLATE; $columnsBlock['innerBlocks'][0]['innerBlocks'] = $nonColumnsBlocksBuffer; $wrappedParsedBlocks[] = $columnsBlock; } return $wrappedParsedBlocks; } } Renderer/Preprocessors/BlocksWidthPreprocessor.php000064400000011547150732300720016527 0ustar00 $block) { // Layout width is recalculated for each block because full-width blocks don't exclude padding $layoutWidth = $this->parseNumberFromStringWithPixels($layoutStyles['width']); $alignment = $block['attrs']['align'] ?? null; // Subtract padding from the block width if it's not full-width if ($alignment !== 'full') { $layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left'] ?? '0px'); $layoutWidth -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right'] ?? '0px'); } $widthInput = $block['attrs']['width'] ?? '100%'; // Currently we support only % and px units in case only the number is provided we assume it's % // because editor saves percent values as a number. $widthInput = is_numeric($widthInput) ? "$widthInput%" : $widthInput; $width = $this->convertWidthToPixels($widthInput, $layoutWidth); if ($block['blockName'] === 'core/columns') { // Calculate width of the columns based on the layout width and padding $columnsWidth = $layoutWidth; $columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $columnsWidth -= $this->parseNumberFromStringWithPixels($block['attrs']['style']['spacing']['padding']['right'] ?? '0px'); $block['innerBlocks'] = $this->addMissingColumnWidths($block['innerBlocks'], $columnsWidth); } // Copy layout styles and update width and padding $modifiedLayoutStyles = $layoutStyles; $modifiedLayoutStyles['width'] = "{$width}px"; $modifiedLayoutStyles['padding']['left'] = $block['attrs']['style']['spacing']['padding']['left'] ?? '0px'; $modifiedLayoutStyles['padding']['right'] = $block['attrs']['style']['spacing']['padding']['right'] ?? '0px'; $block['email_attrs']['width'] = "{$width}px"; $block['innerBlocks'] = $this->preprocess($block['innerBlocks'], $modifiedLayoutStyles); $parsedBlocks[$key] = $block; } return $parsedBlocks; } // TODO: We could add support for other units like em, rem, etc. private function convertWidthToPixels(string $currentWidth, float $layoutWidth): float { $width = $layoutWidth; if (strpos($currentWidth, '%') !== false) { $width = (float)str_replace('%', '', $currentWidth); $width = round($width / 100 * $layoutWidth); } elseif (strpos($currentWidth, 'px') !== false) { $width = $this->parseNumberFromStringWithPixels($currentWidth); } return $width; } private function parseNumberFromStringWithPixels(string $string): float { return (float)str_replace('px', '', $string); } private function addMissingColumnWidths(array $columns, float $columnsWidth): array { $columnsCountWithDefinedWidth = 0; $definedColumnWidth = 0; $columnsCount = count($columns); foreach ($columns as $column) { if (isset($column['attrs']['width']) && !empty($column['attrs']['width'])) { $columnsCountWithDefinedWidth++; $definedColumnWidth += $this->convertWidthToPixels($column['attrs']['width'], $columnsWidth); } else { // When width is not set we need to add padding to the defined column width for better ratio accuracy $definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $definedColumnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px'); } } if ($columnsCount - $columnsCountWithDefinedWidth > 0) { $defaultColumnsWidth = round(($columnsWidth - $definedColumnWidth) / ($columnsCount - $columnsCountWithDefinedWidth), 2); foreach ($columns as $key => $column) { if (!isset($column['attrs']['width']) || empty($column['attrs']['width'])) { // Add padding to the specific column width because it's not included in the default width $columnWidth = $defaultColumnsWidth; $columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['left'] ?? '0px'); $columnWidth += $this->parseNumberFromStringWithPixels($column['attrs']['style']['spacing']['padding']['right'] ?? '0px'); $columns[$key]['attrs']['width'] = "{$columnWidth}px"; } } } return $columns; } } Renderer/Preprocessors/SpacingPreprocessor.php000064400000002156150732300720015672 0ustar00addMarginTopToBlocks($parsedBlocks); return $parsedBlocks; } private function addMarginTopToBlocks(array $parsedBlocks): array { foreach ($parsedBlocks as $key => $block) { // We don't want to add margin-top to the first block in the email or to the first block in the columns block if ($key !== 0) { $block['email_attrs']['margin-top'] = SettingsController::FLEX_GAP; } $block['innerBlocks'] = $this->addMarginTopToBlocks($block['innerBlocks'] ?? []); $parsedBlocks[$key] = $block; } return $parsedBlocks; } } Renderer/Preprocessors/CleanupPreprocessor.php000064400000001312150732300720015666 0ustar00 $block) { // https://core.trac.wordpress.org/ticket/45312 // \WP_Block_Parser::parse_blocks() sometimes add a block with name null that can cause unexpected spaces in rendered content // This behavior was reported as an issue, but it was closed as won't fix if ($block['blockName'] === null) { unset($parsedBlocks[$key]); } } return array_values($parsedBlocks); } } Renderer/BlockRenderer.php000064400000000471150732300720011545 0ustar00 {{newsletter_subject}} {{email_meta_robots}}

Renderer/PreprocessManager.php000064400000003422150732300720012443 0ustar00registerPreprocessor($cleanupPreprocessor); $this->registerPreprocessor($topLevelPreprocessor); $this->registerPreprocessor($blocksWidthPreprocessor); $this->registerPreprocessor($typographyPreprocessor); $this->registerPreprocessor($spacingPreprocessor); } /** * @param array $parsedBlocks * @param array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}} $layoutStyles * @return array */ public function preprocess(array $parsedBlocks, array $layoutStyles): array { foreach ($this->preprocessors as $preprocessor) { $parsedBlocks = $preprocessor->preprocess($parsedBlocks, $layoutStyles); } return $parsedBlocks; } public function registerPreprocessor(Preprocessor $preprocessor): void { $this->preprocessors[] = $preprocessor; } } Renderer/readme.md000064400000003575150732300720010102 0ustar00# MailPoet Email Renderer The renderer is WIP and so is the API for adding support email rendering for new blocks. ## Adding support for a core block 1. Add block into `ALLOWED_BLOCK_TYPES` in `mailpoet/lib/EmailEditor/Engine/Renderer/SettingsController.php`. 2. Make sure the block is registered in the editor. Currently all core blocks are registered in the editor. 3. Add BlockRender class (e.g. Heading) into `mailpoet/lib/EmailEditor/Integration/Core/Renderer/Blocks` folder.
```php addBlockRenderer('core/heading', new Renderer\Blocks\Heading()); } ``` Note: For core blocks this is currently done in `MailPoet\EmailEditor\Integrations\Core\Initializer`. 5. Implement the rendering logic in the renderer class. ## Tips for adding support for block - You can take inspiration on block rendering from MJML in the https://mjml.io/try-it-live - Test the block in different clients [Litmus](https://litmus.com/) - You can take some inspirations from the HTML renderer by the old email editor ## TODO - add universal/fallback renderer for rendering blocks that are not covered by specialized renderers - add support for all core blocks - move the renderer to separate package Renderer/Renderer.php000064400000012150150732300720010567 0ustar00cssInliner = $cssInliner; $this->preprocessManager = $preprocessManager; $this->blocksRegistry = $blocksRegistry; $this->settingsController = $settingsController; } public function render(\WP_Post $post, string $subject, string $preHeader, string $language, $metaRobots = ''): array { $parser = new \WP_Block_Parser(); $parsedBlocks = $parser->parse($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps $layoutStyles = $this->settingsController->getEmailLayoutStyles(); $themeData = $this->settingsController->getTheme()->get_data(); $contentBackground = $themeData['styles']['color']['background'] ?? $layoutStyles['background']; $contentFontFamily = $themeData['styles']['typography']['fontFamily']; $parsedBlocks = $this->preprocessManager->preprocess($parsedBlocks, $layoutStyles); $renderedBody = $this->renderBlocks($parsedBlocks); $styles = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_STYLES_FILE); $styles .= $this->settingsController->getStylesheetForRendering(); $styles = apply_filters('mailpoet_email_renderer_styles', $styles, $post); $template = (string)file_get_contents(dirname(__FILE__) . '/' . self::TEMPLATE_FILE); // Replace style settings placeholders with values $template = str_replace( ['{{width}}', '{{layout_background}}', '{{content_background}}', '{{content_font_family}}', '{{padding_top}}', '{{padding_right}}', '{{padding_bottom}}', '{{padding_left}}'], [$layoutStyles['width'], $layoutStyles['background'], $contentBackground, $contentFontFamily, $layoutStyles['padding']['top'], $layoutStyles['padding']['right'], $layoutStyles['padding']['bottom'], $layoutStyles['padding']['left']], $template ); /** * Replace template variables * {{email_language}} * {{email_subject}} * {{email_meta_robots}} * {{email_template_styles}} * {{email_preheader}} * {{email_body}} */ $templateWithContents = $this->injectContentIntoTemplate( $template, [ $language, esc_html($subject), $metaRobots, $styles, esc_html($preHeader), $renderedBody, ] ); $templateWithContentsDom = $this->inlineCSSStyles($templateWithContents); $templateWithContents = $this->postProcessTemplate($templateWithContentsDom); return [ 'html' => $templateWithContents, 'text' => $this->renderTextVersion($templateWithContents), ]; } public function renderBlocks(array $parsedBlocks): string { do_action('mailpoet_blocks_renderer_initialized', $this->blocksRegistry); $content = ''; foreach ($parsedBlocks as $parsedBlock) { $content .= render_block($parsedBlock); } /** * As we use default WordPress filters, we need to remove them after email rendering * so that we don't interfere with possible post rendering that might happen later. */ $this->blocksRegistry->removeAllBlockRendererFilters(); return $content; } private function injectContentIntoTemplate($template, array $content) { return preg_replace_callback('/{{\w+}}/', function($matches) use (&$content) { return array_shift($content); }, $template); } /** * @param string $template * @return DomNode */ private function inlineCSSStyles($template) { return $this->cssInliner->inlineCSS($template); } /** * @param string $template * @return string */ private function renderTextVersion($template) { $template = (mb_detect_encoding($template, 'UTF-8', true)) ? $template : mb_convert_encoding($template, 'UTF-8', mb_list_encodings()); return @Html2Text::convert($template); } /** * @param DomNode $templateDom * @return string */ private function postProcessTemplate(DomNode $templateDom) { // replace spaces in image tag URLs foreach ($templateDom->query('img') as $image) { $image->src = str_replace(' ', '%20', $image->src); } // because tburry/pquery contains a bug and replaces the opening non mso condition incorrectly we have to replace the opening tag with correct value $template = $templateDom->__toString(); $template = str_replace('', '', $template); return $template; } } Renderer/BlocksRegistry.php000064400000003300150732300720011764 0ustar00settingsController = $settingsController; } public function addBlockRenderer(string $blockName, BlockRenderer $renderer): void { $this->blockRenderersMap[$blockName] = $renderer; add_filter('render_block_' . $blockName, [$this, 'renderBlock'], 10, 2); } public function getBlockRenderer(string $blockName): ?BlockRenderer { return apply_filters('mailpoet_block_renderer_' . $blockName, $this->blockRenderersMap[$blockName] ?? null); } public function removeAllBlockRendererFilters(): void { foreach (array_keys($this->blockRenderersMap) as $blockName) { $this->removeBlockRenderer($blockName); } } public function renderBlock($blockContent, $parsedBlock): string { // Here we could add a default renderer for blocks that don't have a renderer registered if (!isset($this->blockRenderersMap[$parsedBlock['blockName']])) { throw new \InvalidArgumentException('Block renderer not found for block ' . $parsedBlock['name']); } return $this->blockRenderersMap[$parsedBlock['blockName']]->render($blockContent, $parsedBlock, $this->settingsController); } private function removeBlockRenderer(string $blockName): void { unset($this->blockRenderersMap[$blockName]); remove_filter('render_block_' . $blockName, [$this, 'renderBlock']); } } Renderer/styles.css000064400000004577150732300720010363 0ustar00/* Base CSS rules to be applied to all emails */ /* Created based on original MailPoet template for rendering emails */ /* StyleLint is disabled because some rules contain properties that linter marks as unknown, but they are valid for email rendering */ /* stylelint-disable property-no-unknown */ body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */ -ms-text-size-adjust: 100%; /* From MJMJ - Automatic test adjustment on mobile max to 100% */ } .email_layout_wrapper { margin: 0 auto; padding: 20px 0; width: 100%; } table, td { border-collapse: collapse; mso-table-lspace: 0; mso-table-rspace: 0; } img { border: 0; height: auto; -ms-interpolation-mode: bicubic; line-height: 100%; max-width: 100%; outline: none; text-decoration: none; } p { display: block; margin: 0; } h1, h2, h3, h4, h5, h6 { margin-bottom: 0; margin-top: 0; } /* Wa want ensure the same design for all email clients */ ul, ol { /* When margin attribute is set to zero, Outlook doesn't render the list properly. As a possible workaround, we can reset only margin for top and bottom */ margin-bottom: 0; margin-top: 0; padding: 0 0 0 40px; } /* Outlook was adding weird spaces around lists in some versions. Resetting vertical margin for list items solved it */ li { margin-bottom: 0; margin-top: 0; } /* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */ .email_preheader, .email_preheader * { color: #fff; display: none; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; mso-hide: all; opacity: 0; overflow: hidden; visibility: hidden; } @media screen and (max-width: 660px) { .email_column { max-width: 100% !important; } .block { display: block; width: 100% !important; } /* Flex Layout */ .layout-flex-wrapper, .layout-flex-wrapper tbody, .layout-flex-wrapper tr { display: block !important; width: 100% !important; } .layout-flex-item { display: block !important; padding-bottom: 8px !important; /* Half of the flex gap between blocks */ padding-left: 0 !important; width: 100% !important; } .layout-flex-item table, .layout-flex-item td { display: block !important; width: 100% !important; } /* Flex Layout End */ } /* stylelint-enable property-no-unknown */ Renderer/Layout/index.php000064400000000006150732300720011402 0ustar00computeWidthsForFlexLayout($parsedBlock, $settingsController); // MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles $outputHtml = '
'; foreach ($innerBlocks as $key => $block) { $styles = []; if ($block['email_attrs']['layout_width'] ?? null) { $styles['width'] = $block['email_attrs']['layout_width']; } if ($key > 0) { $styles['padding-left'] = SettingsController::FLEX_GAP; } $outputHtml .= ''; } $outputHtml .= '
' . render_block($block) . '
'; $wpGeneratedStyles = wp_style_engine_get_styles($parsedBlock['attrs']['style'] ?? []); $styles = $wpGeneratedStyles['css'] ?? ''; $marginTop = $parsedBlock['email_attrs']['margin-top'] ?? '0px'; $styles .= 'margin-top: ' . $marginTop . ';'; $justify = esc_attr($parsedBlock['attrs']['layout']['justifyContent'] ?? 'left'); $styles .= 'text-align: ' . $justify; $outputHtml = str_replace('{style}', $styles, $outputHtml); $outputHtml = str_replace('{align}', $justify, $outputHtml); return $outputHtml; } private function computeWidthsForFlexLayout(array $parsedBlock, SettingsController $settingsController): array { $blocksCount = count($parsedBlock['innerBlocks']); $totalUsedWidth = 0; // Total width assuming items without set width would consume proportional width $parentWidth = $settingsController->parseNumberFromStringWithPixels($parsedBlock['email_attrs']['width'] ?? SettingsController::EMAIL_WIDTH); $flexGap = $settingsController->parseNumberFromStringWithPixels(SettingsController::FLEX_GAP); $innerBlocks = $parsedBlock['innerBlocks'] ?? []; foreach ($innerBlocks as $key => $block) { $blockWidthPercent = ($block['attrs']['width'] ?? 0) ? intval($block['attrs']['width']) : 0; $blockWidth = floor($parentWidth * ($blockWidthPercent / 100)); // If width is not set, we assume it's 25% of the parent width $totalUsedWidth += $blockWidth ?: floor($parentWidth * (25 / 100)); if (!$blockWidth) { $innerBlocks[$key]['email_attrs']['layout_width'] = null; // Will be rendered as auto continue; } $innerBlocks[$key]['email_attrs']['layout_width'] = $this->getWidthWithoutGap($blockWidth, $flexGap, $blockWidthPercent) . 'px'; } // When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user if ($blocksCount <= 1 || ($totalUsedWidth <= $parentWidth)) { return $innerBlocks; } foreach ($innerBlocks as $key => $block) { $proportionalSpaceOverflow = $parentWidth / $totalUsedWidth; $blockWidth = $block['email_attrs']['layout_width'] ? $settingsController->parseNumberFromStringWithPixels($block['email_attrs']['layout_width']) : 0; $blockProportionalWidth = $blockWidth * $proportionalSpaceOverflow; $blockProportionalPercentage = ($blockProportionalWidth / $parentWidth) * 100; $innerBlocks[$key]['email_attrs']['layout_width'] = $blockWidth ? $this->getWidthWithoutGap($blockProportionalWidth, $flexGap, $blockProportionalPercentage) . 'px' : null; } return $innerBlocks; } /** * How much of width we will strip to keep some space for the gap * This is computed based on CSS rule used in the editor: * For block with width set to X percent * width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100))); */ private function getWidthWithoutGap(float $blockWidth, float $flexGap, float $blockWidthPercent): int { $widthGapReduction = $flexGap * ((100 - $blockWidthPercent) / 100); return intval(floor($blockWidth - $widthGapReduction)); } } EmailEditor.php000064400000003712150732300720007455 0ustar00emailApiController = $emailApiController; } public function initialize(): void { do_action('mailpoet_email_editor_initialized'); $this->registerEmailPostTypes(); $this->extendEmailPostApi(); } /** * Register all custom post types that should be edited via the email editor * The post types are added via mailpoet_email_editor_post_types filter. */ private function registerEmailPostTypes(): void { foreach ($this->getPostTypes() as $postType) { register_post_type( $postType['name'], array_merge($this->getDefaultEmailPostArgs(), $postType['args']) ); } } /** * @phpstan-return EmailPostType[] */ private function getPostTypes(): array { $postTypes = []; return apply_filters('mailpoet_email_editor_post_types', $postTypes); } private function getDefaultEmailPostArgs(): array { return [ 'public' => false, 'hierarchical' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_nav_menus' => false, 'supports' => ['editor', 'title'], 'has_archive' => true, 'show_in_rest' => true, // Important to enable Gutenberg editor ]; } public function extendEmailPostApi() { $emailPostTypes = array_column($this->getPostTypes(), 'name'); register_rest_field($emailPostTypes, 'email_data', [ 'get_callback' => [$this->emailApiController, 'getEmailData'], 'update_callback' => [$this->emailApiController, 'saveEmailData'], 'schema' => $this->emailApiController->getEmailDataSchema(), ]); } } SettingsController.php000064400000013736150732300720011132 0ustar00 ['px', '%'], ]; /** * Width of the email in pixels. * @var string */ const EMAIL_WIDTH = '660px'; /** * Color of email layout background. * @var string */ const EMAIL_LAYOUT_BACKGROUND = '#cccccc'; /** * Gap between blocks in flex layouts * @var string */ const FLEX_GAP = '16px'; private $availableStylesheets = ''; public function getSettings(): array { $coreDefaultSettings = get_default_block_editor_settings(); $editorTheme = $this->getTheme(); $themeSettings = $editorTheme->get_settings(); // body selector is later transformed to .editor-styles-wrapper // setting padding for bottom and top is needed because \WP_Theme_JSON::get_stylesheet() set them only for .wp-site-blocks selector $contentVariables = 'body {'; $contentVariables .= 'padding-bottom: var(--wp--style--root--padding-bottom);'; $contentVariables .= 'padding-top: var(--wp--style--root--padding-top);'; $contentVariables .= '--wp--style--block-gap:' . self::FLEX_GAP . ';'; $contentVariables .= '}'; $settings = array_merge($coreDefaultSettings, self::DEFAULT_SETTINGS); $settings['allowedBlockTypes'] = self::ALLOWED_BLOCK_TYPES; $flexEmailLayoutStyles = file_get_contents(__DIR__ . '/flex-email-layout.css'); $settings['styles'] = [ ['css' => wp_get_global_stylesheet(['base-layout-styles'])], ['css' => $editorTheme->get_stylesheet()], ['css' => $contentVariables], ['css' => $flexEmailLayoutStyles], ]; $settings['styles'] = apply_filters('mailpoet_email_editor_editor_styles', $settings['styles']); $settings['__experimentalFeatures'] = $themeSettings; // Enabling alignWide allows full width for specific blocks such as columns, heading, image, etc. $settings['alignWide'] = true; return $settings; } /** * @return array{contentSize: string, layout: string} */ public function getLayout(): array { return [ 'contentSize' => self::EMAIL_WIDTH, 'layout' => 'constrained', ]; } public function getAvailableStylesheets(): string { if ($this->availableStylesheets) return $this->availableStylesheets; $coreThemeData = \WP_Theme_JSON_Resolver::get_core_data(); $this->availableStylesheets = $coreThemeData->get_stylesheet(); return $this->availableStylesheets; } /** * @return array{width: string, background: string, padding: array{bottom: string, left: string, right: string, top: string}} */ public function getEmailLayoutStyles(): array { return [ 'width' => self::EMAIL_WIDTH, 'background' => self::EMAIL_LAYOUT_BACKGROUND, 'padding' => [ 'bottom' => self::FLEX_GAP, 'left' => self::FLEX_GAP, 'right' => self::FLEX_GAP, 'top' => self::FLEX_GAP, ], ]; } public function getLayoutWidthWithoutPadding(): string { $layoutStyles = $this->getEmailLayoutStyles(); $width = $this->parseNumberFromStringWithPixels($layoutStyles['width']); $width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['left']); $width -= $this->parseNumberFromStringWithPixels($layoutStyles['padding']['right']); return "{$width}px"; } /** * This functions converts an array of styles to a string that can be used in HTML. */ public function convertStylesToString(array $styles): string { $cssString = ''; foreach ($styles as $property => $value) { $cssString .= $property . ':' . $value . ';'; } return trim($cssString); // Remove trailing space and return the formatted string } public function parseStylesToArray(string $styles): array { $styles = explode(';', $styles); $parsedStyles = []; foreach ($styles as $style) { $style = explode(':', $style); if (count($style) === 2) { $parsedStyles[trim($style[0])] = trim($style[1]); } } return $parsedStyles; } public function parseNumberFromStringWithPixels(string $string): float { return (float)str_replace('px', '', $string); } public function getTheme(): \WP_Theme_JSON { $coreThemeData = \WP_Theme_JSON_Resolver::get_core_data(); $themeJson = (string)file_get_contents(dirname(__FILE__) . '/theme.json'); $themeJson = json_decode($themeJson, true); /** @var array $themeJson */ $coreThemeData->merge(new \WP_Theme_JSON($themeJson, 'default')); return apply_filters('mailpoet_email_editor_theme_json', $coreThemeData); } public function getStylesheetForRendering(): string { $emailThemeSettings = $this->getTheme()->get_settings(); $cssPresets = ''; // Font family classes foreach ($emailThemeSettings['typography']['fontFamilies']['default'] as $fontFamily) { $cssPresets .= ".has-{$fontFamily['slug']}-font-family { font-family: {$fontFamily['fontFamily']}; } \n"; } // Font size classes foreach ($emailThemeSettings['typography']['fontSizes']['default'] as $fontSize) { $cssPresets .= ".has-{$fontSize['slug']}-font-size { font-size: {$fontSize['size']}; } \n"; } // Block specific styles $cssBlocks = ''; $blocks = $this->getTheme()->get_styles_block_nodes(); foreach ($blocks as $blockMetadata) { $cssBlocks .= $this->getTheme()->get_styles_for_block($blockMetadata); } return $cssPresets . $cssBlocks; } public function translateSlugToFontSize(string $fontSize): string { $settings = $this->getTheme()->get_settings(); foreach ($settings['typography']['fontSizes']['default'] as $fontSizeDefinition) { if ($fontSizeDefinition['slug'] === $fontSize) { return $fontSizeDefinition['size']; } } return $fontSize; } } theme.md000064400000003117150732300720006171 0ustar00# Theme.json for the email editor We use theme.json to define settings and styles for the email editor and we reuse the definitions also in the rendering engine. The theme is used in combination with the [core's theme.json](https://github.com/WordPress/WordPress/blob/master/wp-includes/theme.json). We load the core's theme.json first and then we merge the email editor's theme.json on top of it. In this file we want to document settings and styles that are specific to the email editor. ## Settings - **color**: We disable gradients, because they are not supported in many email clients. We may add the support later. - **layout**: We set content width to 660px, because it's the most common width for emails. This is meant as a default value. - **spacing**: We allow only px units, because they are the most reliable in email clients. We may add the support for other units later with some sort of conversion to px. We also disable margins because they are not supported in our renderer (margin collapsing might be tricky). - **border**: We want to allow all types of borders and border styles. - **typography**: We disabled fontWeight and dropCap appearance settings, because they are not supported in our renderer. We may add the support later. We also define a set of basic font families that are safe to use with emails. The list was copied from the battle tested legacy editor. ## Styles - **spacing**: We define default padding for the emails. - **color**: We define default colors for text and background of the emails. - **typography**: We define default font family and font size for the emails. flex-email-layout.css000064400000001677150732300720010626 0ustar00.is-layout-email-flex { flex-wrap: nowrap; } :where(body .is-layout-flex) { gap: var(--wp--style--block-gap, 16px); } .is-mobile-preview .is-layout-email-flex { display: block; } .is-mobile-preview .is-layout-email-flex .block-editor-block-list__block { padding: 5px 0; width: 100%; } .is-mobile-preview .is-layout-email-flex .wp-block-button__link { width: 100%; } /* * Email Editor specific styles for vertical gap between blocks in column. * This is needed because we disable layout for core/column and core/columns blocks, and .is-layout-flex is not applied. */ .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block:first-child { margin-top: 0; } .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block { margin: var(--wp--style--block-gap, 16px) 0; } .wp-block-columns:not(.is-not-stacked-on-mobile) > .wp-block-column > .wp-block:last-child { margin-bottom: 0; } EmailApiController.php000064400000002350150732300720011001 0ustar00settingsController = $settingsController; } /** * @return array - Email specific data such styles. */ public function getEmailData(): array { return [ 'layout_styles' => $this->settingsController->getEmailLayoutStyles(), ]; } /** * Update Email specific data we store. */ public function saveEmailData(array $data, \WP_Post $emailPost): void { // Here comes code saving of Email specific data that will be passed on 'email_data' attribute } public function getEmailDataSchema(): array { return Builder::object([ 'layout_styles' => Builder::object([ 'width' => Builder::string(), 'background' => Builder::string(), 'padding' => Builder::object([ 'bottom' => Builder::string(), 'left' => Builder::string(), 'right' => Builder::string(), 'top' => Builder::string(), ]), ]), ])->toArray(); } }