PluginProbe
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.1
MailPoet – Newsletters, Email Marketing, and Automation v5.34.1
5.38.0 5.37.0 5.36.1 5.36.0 5.35.1 5.35.0 5.34.3 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 All 542 releases
mailpoet / lib / Cron / Workers / SendingQueue / Tasks / Newsletter.php
Newsletter.php
611 lines 23.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use Automattic\WooCommerce\EmailEditor\Email_Editor_Container;
9 use Automattic\WooCommerce\EmailEditor\Engine\Personalizer;
10 use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
11 use MailPoet\Cron\Workers\SendingQueue\Tasks\Links as LinksTask;
12 use MailPoet\Cron\Workers\SendingQueue\Tasks\Posts as PostsTask;
13 use MailPoet\Cron\Workers\SendingQueue\Tasks\Shortcodes as ShortcodesTask;
14 use MailPoet\DI\ContainerWrapper;
15 use MailPoet\EmailEditor\Integrations\MailPoet\Coupons\CouponBlockDetector;
16 use MailPoet\EmailEditor\Integrations\MailPoet\PersonalizationTags\OrderReviewUrl;
17 use MailPoet\Entities\NewsletterEntity;
18 use MailPoet\Entities\ScheduledTaskEntity;
19 use MailPoet\Entities\SegmentEntity;
20 use MailPoet\Entities\SendingQueueEntity;
21 use MailPoet\Entities\SubscriberEntity;
22 use MailPoet\Logging\LoggerFactory;
23 use MailPoet\Mailer\MailerLog;
24 use MailPoet\Newsletter\Links\Links as NewsletterLinks;
25 use MailPoet\Newsletter\NewsletterDeleteController;
26 use MailPoet\Newsletter\NewslettersRepository;
27 use MailPoet\Newsletter\Renderer\PostProcess\OpenTracking;
28 use MailPoet\Newsletter\Renderer\Renderer;
29 use MailPoet\Newsletter\Sending\NewsletterReplayMetadata;
30 use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
31 use MailPoet\Newsletter\Sending\SendingQueuesRepository;
32 use MailPoet\NewsletterProcessingException;
33 use MailPoet\RuntimeException;
34 use MailPoet\Segments\SegmentsRepository;
35 use MailPoet\Settings\TrackingConfig;
36 use MailPoet\Statistics\GATracking;
37 use MailPoet\Subscribers\TrackingConsentController;
38 use MailPoet\Util\Helpers;
39 use MailPoet\Util\pQuery\pQuery;
40 use MailPoet\WP\Emoji;
41 use MailPoet\WP\Functions as WPFunctions;
42 use MailPoetVendor\Carbon\Carbon;
43
44 class Newsletter {
45 public $trackingEnabled;
46 public $trackingImageInserted;
47
48 /** @var WPFunctions */
49 private $wp;
50
51 /** @var PostsTask */
52 private $postsTask;
53
54 /** @var GATracking */
55 private $gaTracking;
56
57 /** @var LoggerFactory */
58 private $loggerFactory;
59
60 /** @var Renderer */
61 private $renderer;
62
63 /** @var NewslettersRepository */
64 private $newslettersRepository;
65
66 /** @var NewsletterDeleteController */
67 private $newsletterDeleteController;
68
69 /** @var Emoji */
70 private $emoji;
71
72 /** @var LinksTask */
73 private $linksTask;
74
75 /** @var NewsletterLinks */
76 private $newsletterLinks;
77
78 /** @var SendingQueuesRepository */
79 private $sendingQueuesRepository;
80
81 /** @var SegmentsRepository */
82 private $segmentsRepository;
83
84 /** @var ScheduledTasksRepository */
85 private $scheduledTasksRepository;
86
87 /** @var Personalizer */
88 private $personalizer;
89
90 /** @var AutomationRunStorage */
91 private $automationRunStorage;
92
93 private CouponBlockDetector $couponBlockDetector;
94 private OrderReviewUrl $orderReviewUrl;
95
96 private TrackingConsentController $trackingConsentController;
97
98 public function __construct(
99 ?WPFunctions $wp = null,
100 ?PostsTask $postsTask = null,
101 ?GATracking $gaTracking = null,
102 ?Emoji $emoji = null
103 ) {
104 $trackingConfig = ContainerWrapper::getInstance()->get(TrackingConfig::class);
105 $this->trackingEnabled = $trackingConfig->isEmailTrackingEnabled();
106 if ($wp === null) {
107 $wp = new WPFunctions;
108 }
109 $this->wp = $wp;
110 if ($postsTask === null) {
111 $postsTask = new PostsTask;
112 }
113 $this->postsTask = $postsTask;
114 if ($gaTracking === null) {
115 $gaTracking = ContainerWrapper::getInstance()->get(GATracking::class);
116 }
117 $this->gaTracking = $gaTracking;
118 $this->loggerFactory = LoggerFactory::getInstance();
119 if ($emoji === null) {
120 $emoji = new Emoji();
121 }
122 $this->emoji = $emoji;
123 $this->renderer = ContainerWrapper::getInstance()->get(Renderer::class);
124 $this->newslettersRepository = ContainerWrapper::getInstance()->get(NewslettersRepository::class);
125 $this->newsletterDeleteController = ContainerWrapper::getInstance()->get(NewsletterDeleteController::class);
126 $this->linksTask = ContainerWrapper::getInstance()->get(LinksTask::class);
127 $this->newsletterLinks = ContainerWrapper::getInstance()->get(NewsletterLinks::class);
128 $this->sendingQueuesRepository = ContainerWrapper::getInstance()->get(SendingQueuesRepository::class);
129 $this->segmentsRepository = ContainerWrapper::getInstance()->get(SegmentsRepository::class);
130 $this->scheduledTasksRepository = ContainerWrapper::getInstance()->get(ScheduledTasksRepository::class);
131 $this->personalizer = Email_Editor_Container::container()->get(Personalizer::class);
132 $this->automationRunStorage = ContainerWrapper::getInstance()->get(AutomationRunStorage::class);
133 $this->couponBlockDetector = ContainerWrapper::getInstance()->get(CouponBlockDetector::class);
134 $this->orderReviewUrl = ContainerWrapper::getInstance()->get(OrderReviewUrl::class);
135 $this->trackingConsentController = ContainerWrapper::getInstance()->get(TrackingConsentController::class);
136 }
137
138 public function getNewsletterFromQueue(ScheduledTaskEntity $task): ?NewsletterEntity {
139 // get existing active or sending newsletter
140 $queue = $task->getSendingQueue();
141 $newsletter = $queue ? $queue->getNewsletter() : null;
142
143 $allowedStatuses = [NewsletterEntity::STATUS_ACTIVE, NewsletterEntity::STATUS_SENDING];
144 if (
145 $queue
146 && NewsletterReplayMetadata::isLatestNewsletterReplayMeta($queue->getMeta())
147 && $newsletter
148 && $newsletter->getType() === NewsletterEntity::TYPE_STANDARD
149 ) {
150 $allowedStatuses[] = NewsletterEntity::STATUS_SENT;
151 }
152
153 if (
154 is_null($newsletter)
155 || $newsletter->getDeletedAt() !== null
156 || !in_array($newsletter->getStatus(), $allowedStatuses, true)
157 ) {
158 $this->recoverFromInvalidState($task);
159 return null;
160 }
161
162 // if this is a notification history, get existing active or sending parent newsletter
163 if ($newsletter->getType() == NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
164 $parentNewsletter = $newsletter->getParent();
165
166 if (
167 is_null($parentNewsletter)
168 || $parentNewsletter->getDeletedAt() !== null
169 || !in_array($parentNewsletter->getStatus(), [NewsletterEntity::STATUS_ACTIVE, NewsletterEntity::STATUS_SENDING])
170 ) {
171 return null;
172 }
173 }
174
175 return $newsletter;
176 }
177
178 /**
179 * Pre-processes the newsletter before sending.
180 * - Renders the newsletter
181 * - Adds tracking
182 * - Extracts links
183 * - Checks if the newsletter is a post notification and if it contains at least 1 ALC post.
184 * If not it deletes the notification history record and all associate entities.
185 *
186 * @return NewsletterEntity|false - Returns false only if the newsletter is a post notification history and was deleted.
187 *
188 */
189 public function preProcessNewsletter(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
190 // return the newsletter if it was previously rendered
191 $queue = $task->getSendingQueue();
192 if (!$queue) {
193 throw new RuntimeException('Can‘t pre-process newsletter without queue.');
194 }
195 if ($queue->getNewsletterRenderedBody() !== null) {
196 return $newsletter;
197 }
198 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
199 'pre-processing newsletter',
200 ['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
201 );
202
203 $campaignId = null;
204 $this->preflightCouponBlockGeneration($newsletter, $queue);
205
206 // if tracking is enabled, do additional processing
207 if ($this->trackingEnabled) {
208 // hook to the newsletter post-processing filter and add tracking image
209 $this->trackingImageInserted = OpenTracking::addTrackingImage();
210 // render newsletter
211 $renderedNewsletter = $this->renderNewsletterOrStop($newsletter, $queue);
212 $renderedNewsletter = $this->wp->applyFilters(
213 'mailpoet_sending_newsletter_render_after_pre_process',
214 $renderedNewsletter,
215 $newsletter
216 );
217 if (is_array($renderedNewsletter)) {
218 $campaignId = $this->calculateCampaignId($newsletter, $renderedNewsletter);
219 }
220 $renderedNewsletter = $this->gaTracking->applyGATracking($renderedNewsletter, $newsletter);
221 // hash and save all links
222 $renderedNewsletter = $this->linksTask->process($renderedNewsletter, $newsletter, $queue);
223 } else {
224 // render newsletter
225 $renderedNewsletter = $this->renderNewsletterOrStop($newsletter, $queue);
226 $renderedNewsletter = $this->wp->applyFilters(
227 'mailpoet_sending_newsletter_render_after_pre_process',
228 $renderedNewsletter,
229 $newsletter
230 );
231 if (is_array($renderedNewsletter)) {
232 $campaignId = $this->calculateCampaignId($newsletter, $renderedNewsletter);
233 }
234 $renderedNewsletter = $this->gaTracking->applyGATracking($renderedNewsletter, $newsletter);
235 }
236
237 // check if this is a post notification and if it contains at least 1 ALC post
238 if (
239 $newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION_HISTORY &&
240 $this->postsTask->getAlcPostsCount($renderedNewsletter, $newsletter) === 0
241 ) {
242 // delete notification history record since it will never be sent
243 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
244 'no posts in post notification, deleting it',
245 ['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
246 );
247 $this->newsletterDeleteController->bulkDelete([(int)$newsletter->getId()]);
248 return false;
249 }
250 // extract and save newsletter posts
251 $this->postsTask->extractAndSave($renderedNewsletter, $newsletter);
252
253 if ($campaignId !== null) {
254 $this->sendingQueuesRepository->saveCampaignId($queue, $campaignId);
255 }
256
257 $filterSegmentId = $newsletter->getFilterSegmentId();
258 if ($filterSegmentId) {
259 $filterSegment = $this->segmentsRepository->findOneById($filterSegmentId);
260 if ($filterSegment instanceof SegmentEntity && $filterSegment->getType() === SegmentEntity::TYPE_DYNAMIC) {
261 $this->sendingQueuesRepository->saveFilterSegmentMeta($queue, $filterSegment);
262 }
263 }
264
265 // update queue with the rendered and pre-processed newsletter
266 $queue->setNewsletterRenderedSubject(
267 ShortcodesTask::process(
268 $newsletter->getSubject(),
269 $renderedNewsletter['html'],
270 $newsletter,
271 null,
272 $queue
273 )
274 );
275
276 // if the rendered subject is empty, use a default subject,
277 // having no subject in a newsletter is considered spammy
278 if (empty(trim((string)$queue->getNewsletterRenderedSubject()))) {
279 $queue->setNewsletterRenderedSubject(__('No subject', 'mailpoet'));
280 }
281 $renderedNewsletter = $this->emoji->encodeEmojisInBody($renderedNewsletter);
282 $queue->setNewsletterRenderedBody($renderedNewsletter);
283
284 try {
285 $this->sendingQueuesRepository->flush();
286 } catch (\Throwable $e) {
287 $this->stopNewsletterPreProcessing(sprintf('QUEUE-%d-SAVE', $queue->getId()));
288 }
289 return $newsletter;
290 }
291
292 private function preflightCouponBlockGeneration(NewsletterEntity $newsletter, SendingQueueEntity $queue): void {
293 $wpPostEntity = $newsletter->getWpPost();
294 $wpPost = $wpPostEntity ? $wpPostEntity->getWpPostInstance() : null;
295 if (!$wpPost instanceof \WP_Post) {
296 return;
297 }
298
299 // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
300 if (!$this->couponBlockDetector->hasCreateNewCouponBlock($wpPost->post_content)) {
301 return;
302 }
303
304 $isAutomationSingleRecipient = $this->isAutomationType($newsletter) && $this->getTaskSubscriberCount($queue) === 1;
305 // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
306 $hasRecipientRestriction = $this->couponBlockDetector->hasRecipientRestrictedCreateNewCouponBlock($wpPost->post_content);
307 if ($isAutomationSingleRecipient || ($newsletter->getType() === NewsletterEntity::TYPE_STANDARD && !$hasRecipientRestriction)) {
308 return;
309 }
310
311 $message = $hasRecipientRestriction
312 ? __('Recipient-restricted generated coupons are only supported in automation emails sent to one subscriber at a time. Disable recipient restriction, remove the generated coupon block, or use an existing coupon before sending this email.', 'mailpoet')
313 : __('Auto-generated coupon codes are only supported in regular newsletters and automation emails sent to one subscriber at a time. Remove the generated coupon block or use an existing coupon before sending this email.', 'mailpoet');
314 $this->failCouponBlockSend($newsletter, $queue, $message);
315 throw NewsletterProcessingException::create()->withMessage($message);
316 }
317
318 private function renderNewsletterOrStop(NewsletterEntity $newsletter, SendingQueueEntity $queue): array {
319 try {
320 return $this->renderer->render($newsletter, $queue);
321 } catch (NewsletterProcessingException $e) {
322 $this->failCouponBlockSend($newsletter, $queue, $e->getMessage());
323 throw NewsletterProcessingException::create($e)->withMessage($e->getMessage());
324 }
325 }
326
327 private function failCouponBlockSend(
328 NewsletterEntity $newsletter,
329 SendingQueueEntity $queue,
330 string $message
331 ): void {
332 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_COUPONS)->error(
333 $message,
334 [
335 'newsletter_id' => $newsletter->getId(),
336 'queue_id' => $queue->getId(),
337 ]
338 );
339 if (!NewsletterReplayMetadata::isLatestNewsletterReplayMeta($queue->getMeta())) {
340 $this->newslettersRepository->setAsCorrupt($newsletter);
341 }
342 $this->sendingQueuesRepository->pause($queue);
343 }
344
345 private function isAutomationType(NewsletterEntity $newsletter): bool {
346 return in_array($newsletter->getType(), [
347 NewsletterEntity::TYPE_AUTOMATION,
348 NewsletterEntity::TYPE_AUTOMATION_NOTIFICATION,
349 NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL,
350 ], true);
351 }
352
353 private function getTaskSubscriberCount(SendingQueueEntity $queue): int {
354 $task = $queue->getTask();
355 $subscribers = $task ? $task->getSubscribers() : null;
356 return $subscribers ? count($subscribers) : 0;
357 }
358
359 /**
360 * Shortcodes and links will be replaced in the subject, html and text body
361 * to speed the processing, join content into a continuous string.
362 */
363 public function prepareNewsletterForSending(NewsletterEntity $newsletter, SubscriberEntity $subscriber, SendingQueueEntity $queue): array {
364 $renderedNewsletter = $queue->getNewsletterRenderedBody();
365 $renderedNewsletter = $this->emoji->decodeEmojisInBody($renderedNewsletter);
366 $preparedNewsletter = Helpers::joinObject(
367 [
368 $queue->getNewsletterRenderedSubject(),
369 $renderedNewsletter['html'],
370 $renderedNewsletter['text'],
371 ]
372 );
373
374 $preparedNewsletter = ShortcodesTask::process(
375 $preparedNewsletter,
376 null,
377 $newsletter,
378 $subscriber,
379 $queue
380 );
381 if ($this->trackingEnabled) {
382 if (!$this->trackingConsentController->isTrackingAllowed($subscriber)) {
383 // CNIL/Garante: withdrawal must stop the reading operation on future
384 // emails, not merely the recording. Remove the pixel before it is
385 // given a real tracking URL below. Click links are still rewritten —
386 // Clicks::track suppresses their recording (see follow-up note).
387 $preparedNewsletter = OpenTracking::removeTrackingImage($preparedNewsletter);
388 }
389 $preparedNewsletter = $this->newsletterLinks->replaceSubscriberData(
390 $subscriber->getId(),
391 $queue->getId(),
392 $preparedNewsletter
393 );
394 }
395 $preparedNewsletter = Helpers::splitObject($preparedNewsletter);
396 if ($newsletter->getWpPostId() !== null) {
397 $context = [
398 'recipient_email' => $subscriber->getEmail(),
399 'newsletter_id' => $newsletter->getId(),
400 'queue_id' => $queue->getId(),
401 ];
402
403 $queueMeta = $queue->getMeta();
404 if (isset($queueMeta['automation']['run_id'])) {
405 $runId = (int)$queueMeta['automation']['run_id'];
406 $automationRun = $this->automationRunStorage->getAutomationRun($runId);
407
408 if ($automationRun) {
409 // Convert automation run subjects to array format for filter
410 $subjectsArray = [];
411 foreach ($automationRun->getSubjects() as $subject) {
412 $subjectsArray[$subject->getKey()] = [
413 'key' => $subject->getKey(),
414 'args' => $subject->getArgs(),
415 ];
416 }
417
418 // Load WooCommerce order if present
419 if (
420 isset($subjectsArray['woocommerce:order'])
421 && isset($subjectsArray['woocommerce:order']['args']['order_id'])
422 && function_exists('wc_get_order')
423 ) {
424 $orderId = (int)$subjectsArray['woocommerce:order']['args']['order_id'];
425 $order = wc_get_order($orderId);
426 if ($order instanceof \WC_Order) {
427 $context['order'] = $order;
428 }
429 }
430
431 // Load WooCommerce customer if present
432 if (
433 isset($subjectsArray['woocommerce:customer'])
434 && isset($subjectsArray['woocommerce:customer']['args']['customer_id'])
435 && class_exists(\WC_Customer::class)
436 ) {
437 $customerId = (int)$subjectsArray['woocommerce:customer']['args']['customer_id'];
438 $customer = new \WC_Customer($customerId);
439 if ($customer->get_id()) {
440 $context['customer'] = $customer;
441 }
442 }
443
444 // Allow extensions to add their own subject context
445 /** @var array<string, mixed> $context */
446 $context = $this->wp->applyFilters('mailpoet_automation_email_personalization_context', $context, $subjectsArray);
447
448 // Get available subject keys for extending personalization tags
449 $availableSubjects = array_keys($subjectsArray);
450
451 // Allow extensions to register additional personalization tags based on available subjects
452 $this->wp->doAction('mailpoet_automation_email_extend_personalization_tags_for_sending', $availableSubjects);
453 }
454 }
455
456 $this->guardOrderReviewUrlPersonalization($newsletter, $queue, $preparedNewsletter, $context);
457
458 $this->personalizer->set_context($context);
459 foreach ($preparedNewsletter as $key => $content) {
460 $preparedNewsletter[$key] = $this->personalizer->personalize_content($content);
461 }
462 $personalizedHtml = $this->wp->applyFilters('mailpoet_automation_email_personalize_html_after', $preparedNewsletter[1], $context);
463 if (is_string($personalizedHtml)) {
464 $preparedNewsletter[1] = $personalizedHtml;
465 }
466 $personalizedText = $this->wp->applyFilters('mailpoet_automation_email_personalize_text_after', $preparedNewsletter[2], $context);
467 if (is_string($personalizedText)) {
468 $preparedNewsletter[2] = $personalizedText;
469 }
470 }
471 return [
472 'id' => $newsletter->getId(),
473 'subject' => $preparedNewsletter[0],
474 'body' => [
475 'html' => $preparedNewsletter[1],
476 'text' => $preparedNewsletter[2],
477 ],
478 ];
479 }
480
481 /**
482 * @param array<int|string, mixed> $contentParts
483 * @param array<string, mixed> $context
484 */
485 private function guardOrderReviewUrlPersonalization(
486 NewsletterEntity $newsletter,
487 SendingQueueEntity $queue,
488 array $contentParts,
489 array $context
490 ): void {
491 if (!$this->contentContainsOrderReviewUrlToken($contentParts)) {
492 return;
493 }
494
495 if ($this->orderReviewUrl->getUrl($context) !== '') {
496 return;
497 }
498
499 $message = __('Cannot send the email because WooCommerce cannot generate an order review link for this order.', 'mailpoet');
500 $this->failOrderReviewUrlSend($newsletter, $queue, $message);
501 throw NewsletterProcessingException::create()->withMessage($message);
502 }
503
504 /** @param mixed $content */
505 private function contentContainsOrderReviewUrlToken($content): bool {
506 if (is_array($content)) {
507 foreach ($content as $contentPart) {
508 if ($this->contentContainsOrderReviewUrlToken($contentPart)) {
509 return true;
510 }
511 }
512 return false;
513 }
514
515 if (!is_string($content)) {
516 return false;
517 }
518
519 $normalizedContent = rawurldecode(str_replace('\\/', '/', $content));
520 return strpos($normalizedContent, '[woocommerce/order-review-url]') !== false;
521 }
522
523 private function failOrderReviewUrlSend(
524 NewsletterEntity $newsletter,
525 SendingQueueEntity $queue,
526 string $message
527 ): void {
528 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->error(
529 $message,
530 [
531 'newsletter_id' => $newsletter->getId(),
532 'queue_id' => $queue->getId(),
533 ]
534 );
535 $this->sendingQueuesRepository->pause($queue);
536 }
537
538 public function markNewsletterAsSent(NewsletterEntity $newsletter) {
539 // if it's a standard or notification history newsletter, update its status
540 if (
541 $newsletter->getType() === NewsletterEntity::TYPE_STANDARD ||
542 $newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION_HISTORY
543 ) {
544 $newsletter->setStatus(NewsletterEntity::STATUS_SENT);
545 $newsletter->setSentAt(Carbon::now()->millisecond(0));
546 $this->newslettersRepository->persist($newsletter);
547 $this->newslettersRepository->flush();
548 }
549 }
550
551 public function stopNewsletterPreProcessing($errorCode = null) {
552 MailerLog::processError(
553 'queue_save',
554 __('There was an error processing your newsletter during sending. If possible, please contact us and report this issue.', 'mailpoet'),
555 $errorCode
556 );
557 }
558
559 /**
560 * @param NewsletterEntity $newsletter
561 * @param array $renderedNewsletters - The pre-processed renderered newsletters, before link tracking has been added or shortcodes have been processed.
562 *
563 * @return string
564 */
565 public function calculateCampaignId(NewsletterEntity $newsletter, array $renderedNewsletters): string {
566 $relevantContent = [
567 $newsletter->getId(),
568 $newsletter->getSubject(),
569 ];
570
571 if (isset($renderedNewsletters['text'])) {
572 $relevantContent[] = $renderedNewsletters['text'];
573 }
574
575 // The text version of emails contains just the alt text of images, which could be the same for multiple images. In order to ensure
576 // campaign IDs change when images change, we should consider all image URLs.
577 if (isset($renderedNewsletters['html'])) {
578 $html = pQuery::parseStr($renderedNewsletters['html']);
579 foreach ($html->query('img') as $imageNode) {
580 $src = $imageNode->getAttribute('src');
581 if (is_string($src)) {
582 $relevantContent[] = $src;
583 }
584 }
585 }
586 return substr(md5(implode('|', $relevantContent)), 0, 16);
587 }
588
589 /**
590 * This method recovers the scheduled task and newsletter from a state when sending cannot proceed.
591 */
592 private function recoverFromInvalidState(ScheduledTaskEntity $task): void {
593 // When newsletter does not exist, we need to remove the scheduled task and sending queue.
594 $queue = $task->getSendingQueue();
595 $newsletter = $queue ? $queue->getNewsletter() : null;
596 if (!$newsletter) {
597 $this->scheduledTasksRepository->remove($task);
598 if ($queue) {
599 $this->sendingQueuesRepository->remove($queue);
600 }
601 $this->sendingQueuesRepository->flush();
602 return;
603 }
604
605 // Only deleted newsletter or newsletter with unexpected state should pass here.
606 // Because this state cannot proceed with sending, we need to pause the scheduled task.
607 $task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
608 $this->scheduledTasksRepository->flush();
609 }
610 }
611