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 / Cron / Workers / Bounce.php
mailpoet / lib / Cron / Workers Last commit date
Automations 1 year ago KeyCheck 1 year ago SendingQueue 4 days ago StatsNotifications 2 months ago AuthorizedSendingEmailsCheck.php 3 years ago BackfillEngagementData.php 1 year ago Bounce.php 4 days ago BounceTaskSubscribersCleanup.php 1 month ago BulkConfirmationEmailResend.php 2 months ago ExportFilesCleanup.php 2 months ago InactiveSubscribersMaintenance.php 2 weeks ago LogCleanup.php 10 months ago Mixpanel.php 9 months ago NewsletterTemplateThumbnails.php 1 year ago ReEngagementEmailsScheduler.php 1 year ago Scheduler.php 2 months ago SendingQueueBodyCleanup.php 2 months ago SendingTaskSubscribersCleanup.php 2 months ago SimpleWorker.php 1 year ago StatisticsExport.php 2 months ago SubscriberLimitNotificationWorker.php 2 months ago SubscriberLinkTokens.php 1 year ago SubscribersCountCacheRecalculation.php 2 weeks ago SubscribersEngagementScore.php 3 days ago SubscribersLastEngagement.php 2 months ago SubscribersSegmentsCountSync.php 2 weeks ago SubscribersStatsReport.php 1 year ago Tracks.php 9 months ago UnconfirmedSubscribersCleanup.php 2 months ago UnsubscribeTokens.php 1 month ago WooCommercePastOrders.php 1 year ago WooCommerceSync.php 3 years ago WorkersFactory.php 2 weeks ago index.php 3 years ago
Bounce.php
285 lines
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\Cron\Workers;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Config\ServicesChecker;
9 use MailPoet\Cron\Workers\KeyCheck\SendingServiceKeyCheck;
10 use MailPoet\Entities\NewsletterEntity;
11 use MailPoet\Entities\ScheduledTaskEntity;
12 use MailPoet\Entities\StatisticsBounceEntity;
13 use MailPoet\Entities\SubscriberEntity;
14 use MailPoet\Mailer\Mailer;
15 use MailPoet\Newsletter\Sending\SendingQueuesRepository;
16 use MailPoet\Services\Bridge;
17 use MailPoet\Services\Bridge\API;
18 use MailPoet\Services\Bridge\BouncesReportException;
19 use MailPoet\Settings\SettingsController;
20 use MailPoet\Statistics\StatisticsBouncesRepository;
21 use MailPoet\Subscribers\SubscribersRepository;
22 use MailPoetVendor\Carbon\Carbon;
23
24 class Bounce extends SimpleWorker {
25 const TASK_TYPE = 'bounce';
26 const SUPPORT_MULTIPLE_INSTANCES = false;
27
28 // The sending service never reports bounces older than this. Requests with a
29 // `from` further back are rejected, so the range is clamped to stay within it.
30 const MAX_LOOKBACK_DAYS = 14;
31
32 // Stores the `to` of the last fully-processed report so the next daily run
33 // starts its range exactly there, giving gap-free coverage without querying
34 // previous tasks.
35 const LAST_REPORT_TO_SETTING_KEY = 'bounce.last_report_to';
36
37 // Keys under which the in-progress report range and pagination cursor are
38 // persisted on the task meta, so a run that hits the execution limit resumes
39 // the same range from the next page instead of restarting at page 1.
40 const META_FROM = 'report_from';
41 const META_TO = 'report_to';
42 const META_PAGE = 'report_page';
43
44 public $api;
45
46 /** @var SettingsController */
47 private $settings;
48
49 /** @var Bridge */
50 private $bridge;
51
52 /** @var SubscribersRepository */
53 private $subscribersRepository;
54
55 /** @var SendingQueuesRepository */
56 private $sendingQueuesRepository;
57
58 /** @var StatisticsBouncesRepository */
59 private $statisticsBouncesRepository;
60
61 /** @var ServicesChecker */
62 private $servicesChecker;
63
64 public function __construct(
65 SettingsController $settings,
66 SubscribersRepository $subscribersRepository,
67 SendingQueuesRepository $sendingQueuesRepository,
68 StatisticsBouncesRepository $statisticsBouncesRepository,
69 Bridge $bridge,
70 ServicesChecker $servicesChecker
71 ) {
72 $this->settings = $settings;
73 $this->bridge = $bridge;
74 parent::__construct();
75 $this->subscribersRepository = $subscribersRepository;
76 $this->sendingQueuesRepository = $sendingQueuesRepository;
77 $this->statisticsBouncesRepository = $statisticsBouncesRepository;
78 $this->servicesChecker = $servicesChecker;
79 }
80
81 public function init() {
82 if (!$this->api) {
83 $this->api = new API($this->settings->get(Mailer::MAILER_CONFIG_SETTING_NAME)['mailpoet_api_key']);
84 }
85 }
86
87 public function checkProcessingRequirements() {
88 // A key the service rejects can never produce a report, so stop the worker
89 // instead of requesting one on every cron tick. SendingServiceKeyCheck owns
90 // this state and flips it back once the key works again, which re-enables
91 // the worker without any bounce-specific recovery path.
92 //
93 // Returning false makes CronWorkerRunner delete the due and running tasks
94 // rather than pause them, so the in-progress range and page cursor on the
95 // task meta are lost. That costs nothing: LAST_REPORT_TO_SETTING_KEY only
96 // advances once a range is fully consumed, so the next task re-derives the
97 // same `from` and replays the range. The real cost is time — a key left
98 // rejected for longer than MAX_LOOKBACK_DAYS pushes `from` past what the
99 // service will report on, and the bounces in that gap are never recovered.
100 return $this->bridge->isMailpoetSendingServiceEnabled()
101 && $this->servicesChecker->isMailPoetAPIKeyValid(false) === true;
102 }
103
104 public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
105 [$from, $to] = $this->getReportRange($task);
106 $page = $this->getReportPage($task);
107
108 do {
109 // abort if execution limit is reached
110 $this->cronHelper->enforceExecutionLimit($timer);
111
112 try {
113 $report = $this->api->getBouncesReport($from, $to, $page);
114 } catch (BouncesReportException $e) {
115 return $this->handleReportFailure($task, $e);
116 }
117
118 $recipients = isset($report['recipients']) && is_array($report['recipients']) ? $report['recipients'] : [];
119 $this->processRecipients($task, $recipients);
120
121 $hasMore = !empty($report['has_more']);
122 $page++;
123 // Persist the cursor so a subsequent execution-limit timeout resumes from
124 // the next unprocessed page instead of replaying the whole range.
125 $this->saveReportPage($task, $page);
126 } while ($hasMore);
127
128 // The whole range is consumed; record its `to` as the basis for the next
129 // daily run's `from` so coverage stays continuous.
130 $existing = $this->settings->get(self::LAST_REPORT_TO_SETTING_KEY);
131 $existingTo = null;
132 if (is_string($existing) && $existing !== '') {
133 try {
134 $existingTo = Carbon::parse($existing);
135 } catch (\Exception $e) {
136 $existingTo = null;
137 }
138 }
139 if (!$existingTo || $to->greaterThan($existingTo)) {
140 $this->settings->set(self::LAST_REPORT_TO_SETTING_KEY, $to->format(\DateTimeInterface::ATOM));
141 }
142 return true;
143 }
144
145 /**
146 * Backs the task off instead of letting it retry on the very next cron tick.
147 * The report range is frozen on the task meta, so the delayed retry still
148 * covers exactly the same window.
149 */
150 private function handleReportFailure(ScheduledTaskEntity $task, BouncesReportException $e): bool {
151 $code = $e->getCode();
152 if ($code === API::RESPONSE_CODE_KEY_INVALID || $code === API::RESPONSE_CODE_CAN_NOT_SEND) {
153 // The report endpoint rejected the key, but it is not the authority on key
154 // state: it is registered on WPCOM, while keys are issued and validated by
155 // bridge.mailpoet.com. So ask the authority to re-check now rather than
156 // waiting up to a day for the scheduled check, and let the key state it
157 // stores decide (via checkProcessingRequirements) whether this worker keeps
158 // running. Writing the state from here instead would let a WPCOM-side fault
159 // pause all sending on evidence from a service that does not issue keys.
160 $this->cronWorkerScheduler->scheduleImmediatelyIfNotRunning(SendingServiceKeyCheck::TASK_TYPE);
161 }
162 $this->cronWorkerScheduler->rescheduleProgressively($task);
163 return false;
164 }
165
166 /**
167 * @return array{0: Carbon, 1: Carbon}
168 */
169 private function getReportRange(ScheduledTaskEntity $task): array {
170 $meta = $task->getMeta();
171 if (is_array($meta) && isset($meta[self::META_FROM], $meta[self::META_TO])) {
172 return [Carbon::parse($meta[self::META_FROM]), Carbon::parse($meta[self::META_TO])];
173 }
174
175 $to = Carbon::now()->millisecond(0);
176 $from = $this->getReportFromDate($to);
177
178 $meta = is_array($meta) ? $meta : [];
179 $meta[self::META_FROM] = $from->format(\DateTimeInterface::ATOM);
180 $meta[self::META_TO] = $to->format(\DateTimeInterface::ATOM);
181 $meta[self::META_PAGE] = $meta[self::META_PAGE] ?? 1;
182 $task->setMeta($meta);
183 $this->scheduledTasksRepository->persist($task);
184 $this->scheduledTasksRepository->flush();
185
186 return [$from, $to];
187 }
188
189 private function getReportPage(ScheduledTaskEntity $task): int {
190 $meta = $task->getMeta();
191 $page = is_array($meta) && isset($meta[self::META_PAGE]) ? (int)$meta[self::META_PAGE] : 1;
192 return $page > 0 ? $page : 1;
193 }
194
195 private function saveReportPage(ScheduledTaskEntity $task, int $page): void {
196 $meta = $task->getMeta();
197 $meta = is_array($meta) ? $meta : [];
198 $meta[self::META_PAGE] = $page;
199 $task->setMeta($meta);
200 $this->scheduledTasksRepository->persist($task);
201 $this->scheduledTasksRepository->flush();
202 }
203
204 public function processRecipients(ScheduledTaskEntity $task, array $recipients): void {
205 $emails = array_values(array_unique(array_filter(
206 $recipients,
207 function ($email): bool {
208 return is_string($email) && $email !== '';
209 }
210 )));
211 if (empty($emails)) {
212 return;
213 }
214
215 // Only subscribers currently subscribed/unconfirmed transition to bounced,
216 // preserving prior behavior. Loading them in one query (instead of one
217 // lookup per recipient) is the batching this task needed; the status change
218 // itself stays on the managed entities so the Doctrine lifecycle listeners
219 // (status-change notifications, subscriber counts) still fire.
220 $subscribers = $this->subscribersRepository->findBy([
221 'email' => $emails,
222 'status' => [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNCONFIRMED],
223 'deletedAt' => null,
224 ]);
225 if (empty($subscribers)) {
226 return;
227 }
228
229 $previousTask = $this->scheduledTasksRepository->findPreviousTask($task);
230 foreach ($subscribers as $subscriber) {
231 $subscriber->setStatus(SubscriberEntity::STATUS_BOUNCED);
232 $this->saveBouncedStatistics($subscriber, $task, $previousTask);
233 }
234 // A single flush commits the status changes and the new statistics together
235 // in one transaction, so a failure cannot record statistics without the
236 // matching status change. A replayed page is then a no-op: the subscribers
237 // are already bounced and fall outside the status filter above.
238 $this->subscribersRepository->flush();
239 }
240
241 public function getNextRunDate() {
242 $date = Carbon::now()->millisecond(0);
243 return $date->startOfDay()
244 ->addDay()
245 ->addHours(rand(0, 5))
246 ->addMinutes(rand(0, 59))
247 ->addSeconds(rand(0, 59));
248 }
249
250 private function getReportFromDate(Carbon $now): Carbon {
251 $lastReportTo = $this->settings->get(self::LAST_REPORT_TO_SETTING_KEY);
252 // A malformed stored value (corruption, manual edit, older code) would make
253 // Carbon::parse throw and crash the worker on every run, so guard it the
254 // same way the persist path above does and fall back to the default `from`.
255 $from = $now->copy()->subDay();
256 if (is_string($lastReportTo) && $lastReportTo !== '') {
257 try {
258 $from = Carbon::parse($lastReportTo);
259 } catch (\Exception $e) {
260 $from = $now->copy()->subDay();
261 }
262 }
263
264 // Keep an hour of margin inside MAX_LOOKBACK_DAYS so clock skew and request
265 // latency can't push the `from` past the limit the service enforces.
266 $earliestAllowed = $now->copy()->subDays(self::MAX_LOOKBACK_DAYS)->addHour();
267 return $from->lessThan($earliestAllowed) ? $earliestAllowed : $from;
268 }
269
270 private function saveBouncedStatistics(SubscriberEntity $subscriber, ScheduledTaskEntity $task, ?ScheduledTaskEntity $previousTask): void {
271 $dateFrom = null;
272 if ($previousTask instanceof ScheduledTaskEntity) {
273 $dateFrom = $previousTask->getScheduledAt();
274 }
275 $queues = $this->sendingQueuesRepository->findAllForSubscriberSentBetween($subscriber, $task->getScheduledAt(), $dateFrom);
276 foreach ($queues as $queue) {
277 $newsletter = $queue->getNewsletter();
278 if ($newsletter instanceof NewsletterEntity) {
279 $statistics = new StatisticsBounceEntity($newsletter, $queue, $subscriber);
280 $this->statisticsBouncesRepository->persist($statistics);
281 }
282 }
283 }
284 }
285