AutomationSendEmailSubjectResolver.php
1 month ago
SendEmailAction.php
1 month ago
SendLatestNewsletterAction.php
2 months ago
index.php
3 years ago
SendEmailAction.php
726 lines
| 1 | <?php declare(strict_types = 1); |
| 2 | |
| 3 | namespace MailPoet\Automation\Integrations\MailPoet\Actions; |
| 4 | |
| 5 | if (!defined('ABSPATH')) exit; |
| 6 | |
| 7 | |
| 8 | use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart; |
| 9 | use MailPoet\Automation\Engine\Control\AutomationController; |
| 10 | use MailPoet\Automation\Engine\Control\StepRunController; |
| 11 | use MailPoet\Automation\Engine\Data\Automation; |
| 12 | use MailPoet\Automation\Engine\Data\Step; |
| 13 | use MailPoet\Automation\Engine\Data\StepRunArgs; |
| 14 | use MailPoet\Automation\Engine\Data\StepValidationArgs; |
| 15 | use MailPoet\Automation\Engine\Exceptions\NotFoundException; |
| 16 | use MailPoet\Automation\Engine\Integration\Action; |
| 17 | use MailPoet\Automation\Engine\Integration\ValidationException; |
| 18 | use MailPoet\Automation\Engine\WordPress; |
| 19 | use MailPoet\Automation\Integrations\Core\Actions\DelayAction; |
| 20 | use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload; |
| 21 | use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload; |
| 22 | use MailPoet\Automation\Integrations\WooCommerce\Payloads\AbandonedCartPayload; |
| 23 | use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderPayload; |
| 24 | use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject; |
| 25 | use MailPoet\EmailEditor\Integrations\MailPoet\BlockEmailContentDetector; |
| 26 | use MailPoet\EmailEditor\Integrations\MailPoet\PersonalizationTags\OrderReviewUrl; |
| 27 | use MailPoet\Entities\NewsletterEntity; |
| 28 | use MailPoet\Entities\NewsletterOptionEntity; |
| 29 | use MailPoet\Entities\NewsletterOptionFieldEntity; |
| 30 | use MailPoet\Entities\ScheduledTaskSubscriberEntity; |
| 31 | use MailPoet\Entities\SubscriberEntity; |
| 32 | use MailPoet\InvalidStateException; |
| 33 | use MailPoet\Newsletter\NewsletterSaveController; |
| 34 | use MailPoet\Newsletter\NewslettersRepository; |
| 35 | use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository; |
| 36 | use MailPoet\Newsletter\Options\NewsletterOptionsRepository; |
| 37 | use MailPoet\Newsletter\Renderer\Blocks\DynamicProductsBlock; |
| 38 | use MailPoet\Newsletter\Scheduler\AutomationEmailScheduler; |
| 39 | use MailPoet\Segments\SegmentsRepository; |
| 40 | use MailPoet\Settings\SettingsController; |
| 41 | use MailPoet\Subscribers\SubscriberSegmentRepository; |
| 42 | use MailPoet\Subscribers\SubscribersRepository; |
| 43 | use MailPoet\Validator\Builder; |
| 44 | use MailPoet\Validator\Schema\ObjectSchema; |
| 45 | use Throwable; |
| 46 | |
| 47 | class SendEmailAction implements Action { |
| 48 | const KEY = 'mailpoet:send-email'; |
| 49 | |
| 50 | // Intervals to poll for email status after sending. These are only |
| 51 | // used when immediate status sync fails or the email is never sent. |
| 52 | private const POLL_INTERVALS = [ |
| 53 | 5 * MINUTE_IN_SECONDS, // ~5 minutes |
| 54 | 10 * MINUTE_IN_SECONDS, // ~15 minutes |
| 55 | 45 * MINUTE_IN_SECONDS, // ~1 hour |
| 56 | 4 * HOUR_IN_SECONDS, // ~5 hours ...from email scheduling |
| 57 | 19 * HOUR_IN_SECONDS, // ~1 day |
| 58 | 4 * DAY_IN_SECONDS, // ~5 days |
| 59 | 25 * DAY_IN_SECONDS, // ~1 month |
| 60 | ]; |
| 61 | |
| 62 | // Retry intervals for sending. These are used when the email address |
| 63 | // is not confirmed, and we need send non-transactional emails. |
| 64 | private const OPTIN_RETRY_INTERVALS = [ |
| 65 | 1 * MINUTE_IN_SECONDS, // ~1 minute |
| 66 | 5 * MINUTE_IN_SECONDS, // ~5 minutes |
| 67 | 20 * MINUTE_IN_SECONDS, // ~20 minutes |
| 68 | 1 * HOUR_IN_SECONDS, // ~1 hour |
| 69 | 12 * HOUR_IN_SECONDS, // ~12 hours |
| 70 | 1 * DAY_IN_SECONDS, // ~1 day |
| 71 | ]; |
| 72 | private const WAIT_OPTIN = 'wait_optin'; |
| 73 | private const OPTIN_RETRIES = 'optin_retries'; |
| 74 | private const ORDER_REVIEW_URL_TOKEN = '[woocommerce/order-review-url]'; |
| 75 | |
| 76 | public const TRANSACTIONAL_TRIGGERS = [ |
| 77 | 'mailpoet:custom-trigger', |
| 78 | 'woocommerce:order-status-changed', |
| 79 | 'woocommerce:order-created', |
| 80 | 'woocommerce:order-paid', |
| 81 | 'woocommerce:order-completed', |
| 82 | 'woocommerce:order-cancelled', |
| 83 | 'woocommerce:abandoned-cart', |
| 84 | 'woocommerce-subscriptions:subscription-created', |
| 85 | 'woocommerce-subscriptions:subscription-expired', |
| 86 | 'woocommerce-subscriptions:subscription-payment-failed', |
| 87 | 'woocommerce-subscriptions:subscription-renewed', |
| 88 | 'woocommerce-subscriptions:subscription-status-changed', |
| 89 | 'woocommerce-subscriptions:trial-ended', |
| 90 | 'woocommerce-subscriptions:trial-started', |
| 91 | 'woocommerce:buys-from-a-tag', |
| 92 | 'woocommerce:buys-from-a-category', |
| 93 | 'woocommerce:buys-a-product', |
| 94 | 'woocommerce-bookings:booking-created', |
| 95 | 'woocommerce-bookings:booking-status-changed', |
| 96 | 'woocommerce-bookings:booking-starts', |
| 97 | ]; |
| 98 | |
| 99 | private AutomationController $automationController; |
| 100 | |
| 101 | private SettingsController $settings; |
| 102 | |
| 103 | private NewslettersRepository $newslettersRepository; |
| 104 | |
| 105 | private SubscriberSegmentRepository $subscriberSegmentRepository; |
| 106 | |
| 107 | private SubscribersRepository $subscribersRepository; |
| 108 | |
| 109 | private SegmentsRepository $segmentsRepository; |
| 110 | |
| 111 | private AutomationEmailScheduler $automationEmailScheduler; |
| 112 | |
| 113 | private NewsletterOptionsRepository $newsletterOptionsRepository; |
| 114 | |
| 115 | private NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository; |
| 116 | |
| 117 | private WordPress $wp; |
| 118 | |
| 119 | private NewsletterSaveController $newsletterSaveController; |
| 120 | |
| 121 | private BlockEmailContentDetector $blockEmailContentDetector; |
| 122 | private AutomationSendEmailSubjectResolver $subjectResolver; |
| 123 | private OrderReviewUrl $orderReviewUrl; |
| 124 | |
| 125 | public function __construct( |
| 126 | AutomationController $automationController, |
| 127 | SettingsController $settings, |
| 128 | NewslettersRepository $newslettersRepository, |
| 129 | SubscriberSegmentRepository $subscriberSegmentRepository, |
| 130 | SubscribersRepository $subscribersRepository, |
| 131 | SegmentsRepository $segmentsRepository, |
| 132 | AutomationEmailScheduler $automationEmailScheduler, |
| 133 | NewsletterOptionsRepository $newsletterOptionsRepository, |
| 134 | NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository, |
| 135 | WordPress $wp, |
| 136 | NewsletterSaveController $newsletterSaveController, |
| 137 | BlockEmailContentDetector $blockEmailContentDetector, |
| 138 | AutomationSendEmailSubjectResolver $subjectResolver, |
| 139 | OrderReviewUrl $orderReviewUrl |
| 140 | ) { |
| 141 | $this->automationController = $automationController; |
| 142 | $this->settings = $settings; |
| 143 | $this->newslettersRepository = $newslettersRepository; |
| 144 | $this->subscriberSegmentRepository = $subscriberSegmentRepository; |
| 145 | $this->subscribersRepository = $subscribersRepository; |
| 146 | $this->segmentsRepository = $segmentsRepository; |
| 147 | $this->automationEmailScheduler = $automationEmailScheduler; |
| 148 | $this->newsletterOptionsRepository = $newsletterOptionsRepository; |
| 149 | $this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository; |
| 150 | $this->wp = $wp; |
| 151 | $this->newsletterSaveController = $newsletterSaveController; |
| 152 | $this->blockEmailContentDetector = $blockEmailContentDetector; |
| 153 | $this->subjectResolver = $subjectResolver; |
| 154 | $this->orderReviewUrl = $orderReviewUrl; |
| 155 | } |
| 156 | |
| 157 | public function getKey(): string { |
| 158 | return self::KEY; |
| 159 | } |
| 160 | |
| 161 | public function getName(): string { |
| 162 | // translators: automation action title |
| 163 | return __('Send email', 'mailpoet'); |
| 164 | } |
| 165 | |
| 166 | public function getArgsSchema(): ObjectSchema { |
| 167 | $nameDefault = $this->settings->get('sender.name'); |
| 168 | $addressDefault = $this->settings->get('sender.address'); |
| 169 | $replyToNameDefault = $this->settings->get('reply_to.name'); |
| 170 | $replyToAddressDefault = $this->settings->get('reply_to.address'); |
| 171 | |
| 172 | $nonEmptyString = Builder::string()->required()->minLength(1); |
| 173 | return Builder::object([ |
| 174 | // required fields |
| 175 | 'email_id' => Builder::integer()->required(), |
| 176 | 'name' => $nonEmptyString->default(__('Send email', 'mailpoet')), |
| 177 | 'subject' => $nonEmptyString->default(__('Subject', 'mailpoet')), |
| 178 | 'preheader' => Builder::string()->required()->default(''), |
| 179 | 'sender_name' => $nonEmptyString->default($nameDefault), |
| 180 | 'sender_address' => $nonEmptyString->formatEmail()->default($addressDefault), |
| 181 | |
| 182 | // optional fields |
| 183 | 'reply_to_name' => ($replyToNameDefault && $replyToNameDefault !== $nameDefault) |
| 184 | ? Builder::string()->minLength(1)->default($replyToNameDefault) |
| 185 | : Builder::string()->minLength(1), |
| 186 | 'reply_to_address' => ($replyToAddressDefault && $replyToAddressDefault !== $addressDefault) |
| 187 | ? Builder::string()->formatEmail()->default($replyToAddressDefault) |
| 188 | : Builder::string()->formatEmail(), |
| 189 | 'ga_campaign' => Builder::string()->minLength(1), |
| 190 | 'email_wp_post_id' => Builder::integer(), |
| 191 | ]); |
| 192 | } |
| 193 | |
| 194 | public function getSubjectKeys(): array { |
| 195 | return [ |
| 196 | 'mailpoet:subscriber', |
| 197 | ]; |
| 198 | } |
| 199 | |
| 200 | public function validate(StepValidationArgs $args): void { |
| 201 | try { |
| 202 | $email = $this->getEmailForStep($args->getStep()); |
| 203 | } catch (InvalidStateException $exception) { |
| 204 | $exception = ValidationException::create() |
| 205 | ->withMessage(__('Cannot send the email because it was not found. Please, go to the automation editor and update the email contents.', 'mailpoet')); |
| 206 | |
| 207 | $emailId = $args->getStep()->getArgs()['email_id'] ?? ''; |
| 208 | if (empty($emailId)) { |
| 209 | $exception->withError('email_id', __("Automation email not found.", 'mailpoet')); |
| 210 | } else { |
| 211 | $exception->withError( |
| 212 | 'email_id', |
| 213 | // translators: %s is the ID of email. |
| 214 | sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId) |
| 215 | ); |
| 216 | } |
| 217 | throw $exception; |
| 218 | } |
| 219 | |
| 220 | if ($args->getAutomation()->getStatus() !== Automation::STATUS_ACTIVE) { |
| 221 | return; |
| 222 | } |
| 223 | |
| 224 | $wpPostId = $email->getWpPostId(); |
| 225 | if (!$wpPostId) { |
| 226 | return; |
| 227 | } |
| 228 | |
| 229 | $wpPost = $this->wp->getPost($wpPostId); |
| 230 | $subjectKeys = $this->subjectResolver->getGuaranteedSubjectKeysForStep($args->getAutomation(), $args->getStep()); |
| 231 | if ($this->newsletterContainsOrderReviewUrlToken($email)) { |
| 232 | if (!in_array(OrderSubject::KEY, $subjectKeys, true)) { |
| 233 | throw ValidationException::create() |
| 234 | ->withMessage(__('Cannot activate the automation because order review links require a WooCommerce order trigger.', 'mailpoet')) |
| 235 | ->withError('email_id', __('Use this email in an automation with a WooCommerce order subject or remove the order review link.', 'mailpoet')); |
| 236 | } |
| 237 | |
| 238 | if (!$this->orderReviewUrl->isSupported()) { |
| 239 | throw ValidationException::create() |
| 240 | ->withMessage(__('Cannot activate the automation because WooCommerce cannot generate order review links.', 'mailpoet')) |
| 241 | ->withError('email_id', __('Update WooCommerce or remove the order review link from this email.', 'mailpoet')); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | if ( |
| 246 | $wpPost instanceof \WP_Post |
| 247 | && $this->blockEmailContentDetector->hasMeaningfulContent($wpPost) |
| 248 | ) { |
| 249 | return; |
| 250 | } |
| 251 | |
| 252 | throw ValidationException::create() |
| 253 | ->withMessage(__('Cannot activate the automation because an email has no content.', 'mailpoet')) |
| 254 | ->withError('email_id', __('Add email content before activating the automation.', 'mailpoet')); |
| 255 | } |
| 256 | |
| 257 | public function run(StepRunArgs $args, StepRunController $controller): void { |
| 258 | $newsletter = $this->getEmailForStep($args->getStep()); |
| 259 | $subscriber = $this->getSubscriber($args); |
| 260 | $state = null; |
| 261 | |
| 262 | if ($args->isFirstRun()) { |
| 263 | $subscriberStatus = $subscriber->getStatus(); |
| 264 | if ($subscriberStatus === SubscriberEntity::STATUS_BOUNCED) { |
| 265 | // translators: %s is the subscriber's status. |
| 266 | throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus)); |
| 267 | } |
| 268 | |
| 269 | if ($this->isOptInRequired($newsletter, $subscriber)) { |
| 270 | $controller->getRunLog()->saveLogData([self::WAIT_OPTIN => 1]); |
| 271 | $this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber); |
| 272 | return; |
| 273 | } |
| 274 | |
| 275 | $this->scheduleEmail($args, $newsletter, $subscriber); |
| 276 | } else { |
| 277 | // Re-running for opt-in? |
| 278 | $state = $this->getRunLogData($controller); |
| 279 | |
| 280 | if (array_key_exists(self::WAIT_OPTIN, $state) && $state[self::WAIT_OPTIN] === 1) { |
| 281 | if ($this->isOptInRequired($newsletter, $subscriber)) { |
| 282 | $this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber); |
| 283 | return; |
| 284 | } |
| 285 | |
| 286 | // Subscriber is now confirmed, so we can schedule an email. |
| 287 | $controller->getRunLog()->saveLogData([ |
| 288 | self::WAIT_OPTIN => 0, |
| 289 | self::OPTIN_RETRIES => $args->getRunNumber(), |
| 290 | ]); |
| 291 | $this->scheduleEmail($args, $newsletter, $subscriber); |
| 292 | } |
| 293 | |
| 294 | // Check/sync sending status with the automation step |
| 295 | $success = $this->checkSendingStatus($args, $newsletter, $subscriber); |
| 296 | if ($success) { |
| 297 | return; |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // At this point, we're re-running to check sending status. We need |
| 302 | // to offset opt-in reruns count from sending reruns. |
| 303 | $runNumber = $args->getRunNumber(); |
| 304 | $state = $state ?? $this->getRunLogData($controller); |
| 305 | $optinRetryCount = $state[self::OPTIN_RETRIES] ?? 0; |
| 306 | $runNumber -= $optinRetryCount; |
| 307 | $this->rerunLater($runNumber, $controller, $newsletter, $subscriber); |
| 308 | } |
| 309 | |
| 310 | private function scheduleEmail(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void { |
| 311 | $this->validateOrderReviewUrlToken($args, $newsletter); |
| 312 | |
| 313 | $meta = $this->getNewsletterMeta($args); |
| 314 | try { |
| 315 | $this->automationEmailScheduler->createSendingTask($newsletter, $subscriber, $meta); |
| 316 | } catch (Throwable $e) { |
| 317 | throw InvalidStateException::create()->withMessage(__('Could not create sending task.', 'mailpoet')); |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | private function validateOrderReviewUrlToken(StepRunArgs $args, NewsletterEntity $newsletter): void { |
| 322 | if (!$this->newsletterContainsOrderReviewUrlToken($newsletter)) { |
| 323 | return; |
| 324 | } |
| 325 | |
| 326 | try { |
| 327 | $orderPayload = $args->getSinglePayloadByClass(OrderPayload::class); |
| 328 | } catch (NotFoundException $e) { |
| 329 | throw InvalidStateException::create()->withMessage(__('Cannot send the email because an order is required to generate the review link.', 'mailpoet')); |
| 330 | } |
| 331 | |
| 332 | if ($this->orderReviewUrl->getUrl(['order' => $orderPayload->getOrder()]) !== '') { |
| 333 | return; |
| 334 | } |
| 335 | |
| 336 | throw InvalidStateException::create()->withMessage(__('Cannot send the email because WooCommerce cannot generate an order review link for this order.', 'mailpoet')); |
| 337 | } |
| 338 | |
| 339 | private function newsletterContainsOrderReviewUrlToken(NewsletterEntity $newsletter): bool { |
| 340 | $wpPostId = $newsletter->getWpPostId(); |
| 341 | if ($wpPostId) { |
| 342 | $wpPost = $this->wp->getPost($wpPostId); |
| 343 | if ($wpPost instanceof \WP_Post && $this->contentContainsOrderReviewUrlToken($wpPost->post_content)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps |
| 344 | return true; |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | $body = json_encode($newsletter->getBody(), JSON_UNESCAPED_SLASHES); |
| 349 | return is_string($body) && $this->contentContainsOrderReviewUrlToken($body); |
| 350 | } |
| 351 | |
| 352 | private function contentContainsOrderReviewUrlToken(string $content): bool { |
| 353 | $normalizedContent = rawurldecode(str_replace('\\/', '/', $content)); |
| 354 | return strpos($normalizedContent, self::ORDER_REVIEW_URL_TOKEN) !== false; |
| 355 | } |
| 356 | |
| 357 | private function getRunLogData(StepRunController $controller): array { |
| 358 | $runLog = $controller->getRunLog()->getLog(); |
| 359 | return $runLog->getData(); |
| 360 | } |
| 361 | |
| 362 | /** |
| 363 | * Schedule a progress run to sync the email sending status to the automation step. |
| 364 | * Normally, a progress run is executed immediately after sending; we're scheduling |
| 365 | * these runs to poll for the status if sync fails or email never sends (timeout), |
| 366 | * or if we need to wait for subscriber opt-in. |
| 367 | */ |
| 368 | private function rerunLater(int $runNumber, StepRunController $controller, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void { |
| 369 | $nextInterval = self::POLL_INTERVALS[$runNumber - 1] ?? 0; |
| 370 | |
| 371 | // Use different intervals when retrying for opt-in. |
| 372 | if ($this->isOptInRequired($newsletter, $subscriber)) { |
| 373 | if ($runNumber > count(self::OPTIN_RETRY_INTERVALS)) { |
| 374 | $subscriberStatus = $subscriber->getStatus(); |
| 375 | // translators: %s is the subscriber's status. |
| 376 | throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus)); |
| 377 | } |
| 378 | $nextInterval = self::OPTIN_RETRY_INTERVALS[$runNumber - 1]; |
| 379 | } |
| 380 | |
| 381 | $controller->scheduleProgress(time() + $nextInterval); |
| 382 | } |
| 383 | |
| 384 | private function isOptInRequired(NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool { |
| 385 | $subscriberStatus = $subscriber->getStatus(); |
| 386 | if ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL) return false; |
| 387 | return $subscriberStatus !== SubscriberEntity::STATUS_SUBSCRIBED; |
| 388 | } |
| 389 | |
| 390 | /** @param mixed $data */ |
| 391 | public function handleEmailSent($data): void { |
| 392 | if (!is_array($data)) { |
| 393 | throw InvalidStateException::create()->withMessage( |
| 394 | // translators: %s is the type of $data. |
| 395 | sprintf(__('Invalid automation step data. Array expected, got: %s', 'mailpoet'), gettype($data)) |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | $runId = $data['run_id'] ?? null; |
| 400 | if (!is_int($runId)) { |
| 401 | throw InvalidStateException::create()->withMessage( |
| 402 | // translators: %s is the type of $runId. |
| 403 | sprintf(__("Invalid automation step data. Expected 'run_id' to be an integer, got: %s", 'mailpoet'), gettype($runId)) |
| 404 | ); |
| 405 | } |
| 406 | |
| 407 | $stepId = $data['step_id'] ?? null; |
| 408 | if (!is_string($stepId)) { |
| 409 | throw InvalidStateException::create()->withMessage( |
| 410 | // translators: %s is the type of $runId. |
| 411 | sprintf(__("Invalid automation step data. Expected 'step_id' to be a string, got: %s", 'mailpoet'), gettype($runId)) |
| 412 | ); |
| 413 | } |
| 414 | |
| 415 | $this->automationController->enqueueProgress($runId, $stepId); |
| 416 | } |
| 417 | |
| 418 | private function checkSendingStatus(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool { |
| 419 | $scheduledTaskSubscriber = $this->automationEmailScheduler->getScheduledTaskSubscriber($newsletter, $subscriber, $args->getAutomationRun()); |
| 420 | if (!$scheduledTaskSubscriber) { |
| 421 | throw InvalidStateException::create()->withMessage(__('Email failed to schedule.', 'mailpoet')); |
| 422 | } |
| 423 | |
| 424 | // email sending failed |
| 425 | if ($scheduledTaskSubscriber->getFailed() === ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED) { |
| 426 | throw InvalidStateException::create()->withMessage( |
| 427 | // translators: %s is the error message. |
| 428 | sprintf(__('Email failed to send. Error: %s', 'mailpoet'), $scheduledTaskSubscriber->getError() ?: 'Unknown error') |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | $wasSent = $scheduledTaskSubscriber->getProcessed() === ScheduledTaskSubscriberEntity::STATUS_PROCESSED; |
| 433 | $isLastRun = $args->getRunNumber() >= 1 + count(self::POLL_INTERVALS); |
| 434 | |
| 435 | // email was never sent |
| 436 | if (!$wasSent && $isLastRun) { |
| 437 | $error = __('Email sending process timed out.', 'mailpoet'); |
| 438 | $this->automationEmailScheduler->saveError($scheduledTaskSubscriber, $error); |
| 439 | throw InvalidStateException::create()->withMessage($error); |
| 440 | } |
| 441 | |
| 442 | return $wasSent; |
| 443 | } |
| 444 | |
| 445 | private function getNewsletterMeta(StepRunArgs $args): array { |
| 446 | $meta = [ |
| 447 | 'automation' => [ |
| 448 | 'id' => $args->getAutomation()->getId(), |
| 449 | 'run_id' => $args->getAutomationRun()->getId(), |
| 450 | 'step_id' => $args->getStep()->getId(), |
| 451 | 'run_number' => $args->getRunNumber(), |
| 452 | ], |
| 453 | ]; |
| 454 | |
| 455 | if ($this->automationHasAbandonedCartTrigger($args->getAutomation())) { |
| 456 | $payload = $args->getSinglePayloadByClass(AbandonedCartPayload::class); |
| 457 | $meta[AbandonedCart::TASK_META_NAME] = $payload->getProductIds(); |
| 458 | } |
| 459 | |
| 460 | if ($this->automationHasWooCommerceTrigger($args->getAutomation())) { |
| 461 | try { |
| 462 | // Handle Order payload - get product IDs and cross-sell IDs |
| 463 | $orderPayload = $args->getSinglePayloadByClass(OrderPayload::class); |
| 464 | $orderProductIds = $orderPayload->getProductIds(); |
| 465 | $crossSellIds = $orderPayload->getCrossSellIds(); |
| 466 | |
| 467 | if (!empty($orderProductIds)) { |
| 468 | $meta[DynamicProductsBlock::ORDER_PRODUCTS_META_NAME] = array_unique($orderProductIds); |
| 469 | } |
| 470 | |
| 471 | if (!empty($crossSellIds)) { |
| 472 | $meta[DynamicProductsBlock::ORDER_CROSS_SELL_PRODUCTS_META_NAME] = array_unique($crossSellIds); |
| 473 | } |
| 474 | } catch (NotFoundException $e) { |
| 475 | // No OrderPayload found, continue |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | // Allow premium features to modify meta data |
| 480 | return (array)$this->wp->applyFilters('mailpoet_automation_send_email_action_meta', $meta, $args); |
| 481 | } |
| 482 | |
| 483 | private function getSubscriber(StepRunArgs $args): SubscriberEntity { |
| 484 | $subscriberId = $args->getSinglePayloadByClass(SubscriberPayload::class)->getId(); |
| 485 | try { |
| 486 | $segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId(); |
| 487 | } catch (NotFoundException $e) { |
| 488 | $segmentId = null; |
| 489 | } |
| 490 | |
| 491 | // Without segment, fetch subscriber by ID (needed e.g. for "mailpoet:custom-trigger"). |
| 492 | // Transactional emails don't need to be checked against segment, no matter if it's set. |
| 493 | if (!$segmentId || $this->isTransactional($args->getStep(), $args->getAutomation())) { |
| 494 | $subscriber = $this->subscribersRepository->findOneById($subscriberId); |
| 495 | if (!$subscriber) { |
| 496 | throw InvalidStateException::create(); |
| 497 | } |
| 498 | return $subscriber; |
| 499 | } |
| 500 | |
| 501 | // With segment, fetch subscriber segment and check if they are subscribed. |
| 502 | $subscriberSegment = $this->subscriberSegmentRepository->findOneBy([ |
| 503 | 'subscriber' => $subscriberId, |
| 504 | 'segment' => $segmentId, |
| 505 | 'status' => SubscriberEntity::STATUS_SUBSCRIBED, |
| 506 | ]); |
| 507 | |
| 508 | if (!$subscriberSegment) { |
| 509 | $segment = $this->segmentsRepository->findOneById($segmentId); |
| 510 | if (!$segment) { // This state should not happen because it is checked in the validation. |
| 511 | throw InvalidStateException::create()->withMessage(__('Cannot send the email because the list was not found.', 'mailpoet')); |
| 512 | } |
| 513 | // translators: %s is the name of the list. |
| 514 | throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber is not subscribed to the '%s' list.", 'mailpoet'), $segment->getName())); |
| 515 | } |
| 516 | |
| 517 | $subscriber = $subscriberSegment->getSubscriber(); |
| 518 | if (!$subscriber) { |
| 519 | throw InvalidStateException::create(); |
| 520 | } |
| 521 | return $subscriber; |
| 522 | } |
| 523 | |
| 524 | public function saveEmailSettings(Step $step, Automation $automation): void { |
| 525 | $args = $step->getArgs(); |
| 526 | if (!isset($args['email_id']) || !$args['email_id']) { |
| 527 | return; |
| 528 | } |
| 529 | |
| 530 | $email = $this->getEmailForStep($step); |
| 531 | $email->setType($this->isTransactional($step, $automation) ? NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL : NewsletterEntity::TYPE_AUTOMATION); |
| 532 | $email->setStatus(NewsletterEntity::STATUS_ACTIVE); |
| 533 | $email->setSubject($args['subject'] ?? ''); |
| 534 | $email->setPreheader($args['preheader'] ?? ''); |
| 535 | $email->setSenderName($args['sender_name'] ?? ''); |
| 536 | $email->setSenderAddress($args['sender_address'] ?? ''); |
| 537 | $email->setReplyToName($args['reply_to_name'] ?? ''); |
| 538 | $email->setReplyToAddress($args['reply_to_address'] ?? ''); |
| 539 | $email->setGaCampaign($args['ga_campaign'] ?? ''); |
| 540 | $this->storeNewsletterOption( |
| 541 | $email, |
| 542 | NewsletterOptionFieldEntity::NAME_GROUP, |
| 543 | $this->automationHasWooCommerceTrigger($automation) ? 'woocommerce' : null |
| 544 | ); |
| 545 | $this->storeNewsletterOption( |
| 546 | $email, |
| 547 | NewsletterOptionFieldEntity::NAME_EVENT, |
| 548 | $this->automationHasAbandonedCartTrigger($automation) ? 'woocommerce_abandoned_shopping_cart' : null |
| 549 | ); |
| 550 | |
| 551 | $this->newslettersRepository->persist($email); |
| 552 | $this->newslettersRepository->flush(); |
| 553 | } |
| 554 | |
| 555 | private function storeNewsletterOption(NewsletterEntity $newsletter, string $optionName, ?string $optionValue = null): void { |
| 556 | $options = $newsletter->getOptions()->toArray(); |
| 557 | foreach ($options as $key => $option) { |
| 558 | if ($option->getName() === $optionName) { |
| 559 | if ($optionValue) { |
| 560 | $option->setValue($optionValue); |
| 561 | return; |
| 562 | } |
| 563 | $newsletter->getOptions()->remove($key); |
| 564 | $this->newsletterOptionsRepository->remove($option); |
| 565 | return; |
| 566 | } |
| 567 | } |
| 568 | |
| 569 | if (!$optionValue) { |
| 570 | return; |
| 571 | } |
| 572 | |
| 573 | $field = $this->newsletterOptionFieldsRepository->findOneBy([ |
| 574 | 'name' => $optionName, |
| 575 | 'newsletterType' => $newsletter->getType(), |
| 576 | ]); |
| 577 | if (!$field) { |
| 578 | return; |
| 579 | } |
| 580 | $option = new NewsletterOptionEntity($newsletter, $field); |
| 581 | $option->setValue($optionValue); |
| 582 | $this->newsletterOptionsRepository->persist($option); |
| 583 | $newsletter->getOptions()->add($option); |
| 584 | } |
| 585 | |
| 586 | private function isTransactional(Step $step, Automation $automation): bool { |
| 587 | $triggers = $automation->getTriggers(); |
| 588 | $transactionalTriggers = array_filter( |
| 589 | $triggers, |
| 590 | function(Step $triggerStep): bool { |
| 591 | return in_array($triggerStep->getKey(), self::TRANSACTIONAL_TRIGGERS, true); |
| 592 | } |
| 593 | ); |
| 594 | |
| 595 | if (!$triggers || count($transactionalTriggers) !== count($triggers)) { |
| 596 | return false; |
| 597 | } |
| 598 | |
| 599 | foreach ($transactionalTriggers as $trigger) { |
| 600 | if (!$this->hasDelayFreePathToStep($trigger, $step, $automation)) { |
| 601 | return false; |
| 602 | } |
| 603 | } |
| 604 | return true; |
| 605 | } |
| 606 | |
| 607 | /** |
| 608 | * Checks if there exists at least one path from $from step to $to step |
| 609 | * that doesn't contain a delay action. |
| 610 | */ |
| 611 | private function hasDelayFreePathToStep(Step $from, Step $to, Automation $automation): bool { |
| 612 | $steps = $automation->getSteps(); |
| 613 | $stack = [[$from, false]]; // [step, hasDelayOnPath] |
| 614 | $visited = []; |
| 615 | |
| 616 | while (!empty($stack)) { |
| 617 | [$current, $hasDelayOnPath] = array_pop($stack); |
| 618 | |
| 619 | $stateKey = $current->getId() . ':' . ($hasDelayOnPath ? '1' : '0'); |
| 620 | if (isset($visited[$stateKey])) { |
| 621 | continue; |
| 622 | } |
| 623 | $visited[$stateKey] = true; |
| 624 | |
| 625 | // Only delay actions convert transactional emails to marketing, as they introduce |
| 626 | // a significant time gap between the trigger event and email delivery. |
| 627 | // If new delay-like actions are added (e.g., "wait until date"), they should also be checked here. |
| 628 | if ($current->getKey() === DelayAction::KEY) { |
| 629 | $hasDelayOnPath = true; |
| 630 | } |
| 631 | |
| 632 | if ($current->getId() === $to->getId()) { |
| 633 | if (!$hasDelayOnPath) { |
| 634 | return true; |
| 635 | } |
| 636 | continue; |
| 637 | } |
| 638 | |
| 639 | foreach ($current->getNextStepIds() as $nextStepId) { |
| 640 | $nextStep = $steps[$nextStepId] ?? null; |
| 641 | if ($nextStep !== null) { |
| 642 | $stack[] = [$nextStep, $hasDelayOnPath]; |
| 643 | } |
| 644 | } |
| 645 | } |
| 646 | return false; |
| 647 | } |
| 648 | |
| 649 | private function automationHasWooCommerceTrigger(Automation $automation): bool { |
| 650 | return (bool)array_filter( |
| 651 | $automation->getTriggers(), |
| 652 | function(Step $step): bool { |
| 653 | return strpos($step->getKey(), 'woocommerce:') === 0; |
| 654 | } |
| 655 | ); |
| 656 | } |
| 657 | |
| 658 | private function automationHasAbandonedCartTrigger(Automation $automation): bool { |
| 659 | return (bool)array_filter( |
| 660 | $automation->getTriggers(), |
| 661 | function(Step $step): bool { |
| 662 | return in_array($step->getKey(), ['woocommerce:abandoned-cart'], true); |
| 663 | } |
| 664 | ); |
| 665 | } |
| 666 | |
| 667 | private function getEmailForStep(Step $step): NewsletterEntity { |
| 668 | $emailId = $step->getArgs()['email_id'] ?? null; |
| 669 | if (!$emailId) { |
| 670 | throw InvalidStateException::create(); |
| 671 | } |
| 672 | |
| 673 | $email = $this->newslettersRepository->findOneBy([ |
| 674 | 'id' => $emailId, |
| 675 | ]); |
| 676 | if (!$email || !in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) { |
| 677 | throw InvalidStateException::create()->withMessage( |
| 678 | // translators: %s is the ID of email. |
| 679 | sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId) |
| 680 | ); |
| 681 | } |
| 682 | return $email; |
| 683 | } |
| 684 | |
| 685 | public function onDuplicate(Step $step): Step { |
| 686 | $args = $step->getArgs(); |
| 687 | $emailId = (int)($args['email_id'] ?? 0); |
| 688 | if (!$emailId) { |
| 689 | // if the email is not yet designed, we don't need to duplicate it |
| 690 | return $step; |
| 691 | } |
| 692 | |
| 693 | $email = $this->newslettersRepository->findOneBy([ |
| 694 | 'id' => $emailId, |
| 695 | ]); |
| 696 | if (!$email) { |
| 697 | throw new \MailPoet\Automation\Engine\Exceptions\InvalidStateException('Automation email entity not found for duplication.'); |
| 698 | } |
| 699 | try { |
| 700 | $duplicatedNewsletter = $this->newsletterSaveController->duplicate($email); |
| 701 | } catch (\Throwable $e) { |
| 702 | throw new \MailPoet\Automation\Engine\Exceptions\InvalidStateException('Failed to duplicate automation email: ' . $e->getMessage()); |
| 703 | } |
| 704 | $duplicatedNewsletter->setStatus($email->getStatus()); |
| 705 | $this->newslettersRepository->flush(); |
| 706 | |
| 707 | $args['email_id'] = $duplicatedNewsletter->getId(); |
| 708 | $args['subject'] = $duplicatedNewsletter->getSubject(); |
| 709 | $wpPostId = $duplicatedNewsletter->getWpPostId(); |
| 710 | if ($wpPostId) { |
| 711 | $args['email_wp_post_id'] = $wpPostId; |
| 712 | } else { |
| 713 | unset($args['email_wp_post_id']); |
| 714 | } |
| 715 | |
| 716 | return new Step( |
| 717 | $step->getId(), |
| 718 | $step->getType(), |
| 719 | $step->getKey(), |
| 720 | $args, |
| 721 | $step->getNextSteps(), |
| 722 | $step->getFilters() |
| 723 | ); |
| 724 | } |
| 725 | } |
| 726 |