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 |