PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Automation / Integrations / MailPoet / Actions / SendEmailAction.php
mailpoet / lib / Automation / Integrations / MailPoet / Actions Last commit date
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