AutomationSendEmailSubjectResolver.php
2 months ago
SendEmailAction.php
1 month ago
SendLatestNewsletterAction.php
2 months ago
index.php
3 years ago
SendLatestNewsletterAction.php
343 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\Automation\Engine\Control\StepRunController; |
| 9 | use MailPoet\Automation\Engine\Data\Step; |
| 10 | use MailPoet\Automation\Engine\Data\StepRunArgs; |
| 11 | use MailPoet\Automation\Engine\Data\StepValidationArgs; |
| 12 | use MailPoet\Automation\Engine\Exceptions\NotFoundException; |
| 13 | use MailPoet\Automation\Engine\Integration\Action; |
| 14 | use MailPoet\Automation\Engine\Integration\ValidationException; |
| 15 | use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload; |
| 16 | use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload; |
| 17 | use MailPoet\Automation\Integrations\MailPoet\Subjects\SegmentSubject; |
| 18 | use MailPoet\Automation\Integrations\MailPoet\Subjects\SubscriberSubject; |
| 19 | use MailPoet\Entities\NewsletterEntity; |
| 20 | use MailPoet\Entities\ScheduledTaskSubscriberEntity; |
| 21 | use MailPoet\Entities\SubscriberEntity; |
| 22 | use MailPoet\InvalidStateException; |
| 23 | use MailPoet\Newsletter\NewslettersRepository; |
| 24 | use MailPoet\Newsletter\Scheduler\LatestNewsletterScheduler; |
| 25 | use MailPoet\Segments\SegmentsRepository; |
| 26 | use MailPoet\Subscribers\SubscriberSegmentRepository; |
| 27 | use MailPoet\Subscribers\SubscribersRepository; |
| 28 | use MailPoet\Validator\Builder; |
| 29 | use MailPoet\Validator\Schema\ObjectSchema; |
| 30 | use Throwable; |
| 31 | |
| 32 | class SendLatestNewsletterAction implements Action { |
| 33 | public const KEY = 'mailpoet:send-latest-newsletter'; |
| 34 | |
| 35 | private const POLL_INTERVALS = [ |
| 36 | 5 * MINUTE_IN_SECONDS, |
| 37 | 10 * MINUTE_IN_SECONDS, |
| 38 | 45 * MINUTE_IN_SECONDS, |
| 39 | 4 * HOUR_IN_SECONDS, |
| 40 | 19 * HOUR_IN_SECONDS, |
| 41 | 4 * DAY_IN_SECONDS, |
| 42 | 25 * DAY_IN_SECONDS, |
| 43 | ]; |
| 44 | |
| 45 | private const OPTIN_RETRY_INTERVALS = [ |
| 46 | 1 * MINUTE_IN_SECONDS, |
| 47 | 5 * MINUTE_IN_SECONDS, |
| 48 | 20 * MINUTE_IN_SECONDS, |
| 49 | 1 * HOUR_IN_SECONDS, |
| 50 | 12 * HOUR_IN_SECONDS, |
| 51 | 1 * DAY_IN_SECONDS, |
| 52 | ]; |
| 53 | |
| 54 | private const WAIT_OPTIN = 'wait_optin'; |
| 55 | private const OPTIN_RETRIES = 'optin_retries'; |
| 56 | private const OUTCOME = 'outcome'; |
| 57 | private const NEWSLETTER_ID = 'newsletter_id'; |
| 58 | private const OUTCOME_POLLING = 'polling'; |
| 59 | private const OUTCOME_SENT = 'sent'; |
| 60 | private const OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER = 'skipped-ineligible-subscriber'; |
| 61 | |
| 62 | private SubscribersRepository $subscribersRepository; |
| 63 | |
| 64 | private SubscriberSegmentRepository $subscriberSegmentRepository; |
| 65 | |
| 66 | private NewslettersRepository $newslettersRepository; |
| 67 | |
| 68 | private SegmentsRepository $segmentsRepository; |
| 69 | |
| 70 | private LatestNewsletterScheduler $latestNewsletterScheduler; |
| 71 | |
| 72 | public function __construct( |
| 73 | SubscribersRepository $subscribersRepository, |
| 74 | SubscriberSegmentRepository $subscriberSegmentRepository, |
| 75 | NewslettersRepository $newslettersRepository, |
| 76 | SegmentsRepository $segmentsRepository, |
| 77 | LatestNewsletterScheduler $latestNewsletterScheduler |
| 78 | ) { |
| 79 | $this->subscribersRepository = $subscribersRepository; |
| 80 | $this->subscriberSegmentRepository = $subscriberSegmentRepository; |
| 81 | $this->newslettersRepository = $newslettersRepository; |
| 82 | $this->segmentsRepository = $segmentsRepository; |
| 83 | $this->latestNewsletterScheduler = $latestNewsletterScheduler; |
| 84 | } |
| 85 | |
| 86 | public function getKey(): string { |
| 87 | return self::KEY; |
| 88 | } |
| 89 | |
| 90 | public function getName(): string { |
| 91 | // translators: automation action title |
| 92 | return __('Send latest newsletter', 'mailpoet'); |
| 93 | } |
| 94 | |
| 95 | public function getArgsSchema(): ObjectSchema { |
| 96 | return Builder::object([]) |
| 97 | ->disableAdditionalProperties() |
| 98 | ->maxProperties(0); |
| 99 | } |
| 100 | |
| 101 | public function getSubjectKeys(): array { |
| 102 | return [ |
| 103 | SubscriberSubject::KEY, |
| 104 | SegmentSubject::KEY, |
| 105 | ]; |
| 106 | } |
| 107 | |
| 108 | public function validate(StepValidationArgs $args): void { |
| 109 | if ($args->getStep()->getArgs() !== []) { |
| 110 | throw ValidationException::create() |
| 111 | ->withMessage(__('This action does not accept configuration.', 'mailpoet')) |
| 112 | ->withError('general', __('Send latest newsletter does not accept any configuration fields.', 'mailpoet')); |
| 113 | } |
| 114 | |
| 115 | try { |
| 116 | $args->getSingleSubject(SubscriberSubject::KEY); |
| 117 | $args->getSingleSubject(SegmentSubject::KEY); |
| 118 | } catch (NotFoundException $e) { |
| 119 | throw ValidationException::create() |
| 120 | ->withMessage(__('This action needs a trigger list.', 'mailpoet')) |
| 121 | ->withError('general', __('This action needs a trigger list, such as "Someone subscribes".', 'mailpoet')); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | public function run(StepRunArgs $args, StepRunController $controller): void { |
| 126 | [$subscriber, $segmentId] = $this->getSubscriberAndSegment($args); |
| 127 | $state = null; |
| 128 | |
| 129 | if ($args->isFirstRun()) { |
| 130 | if ($this->isSubscriberIneligible($subscriber, $segmentId)) { |
| 131 | $this->saveOutcome($controller, self::OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER); |
| 132 | return; |
| 133 | } |
| 134 | |
| 135 | if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED) { |
| 136 | $controller->getRunLog()->saveLogData([self::WAIT_OPTIN => 1]); |
| 137 | $this->rerunLater($args->getRunNumber(), $controller, true); |
| 138 | return; |
| 139 | } |
| 140 | |
| 141 | if (!$this->scheduleLatestNewsletter($args, $controller, $subscriber, $segmentId)) { |
| 142 | return; |
| 143 | } |
| 144 | } else { |
| 145 | $state = $this->getRunLogData($controller); |
| 146 | if (($state[self::WAIT_OPTIN] ?? 0) === 1) { |
| 147 | if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED) { |
| 148 | $this->rerunLater($args->getRunNumber(), $controller, true); |
| 149 | return; |
| 150 | } |
| 151 | |
| 152 | if ($this->isSubscriberIneligible($subscriber, $segmentId)) { |
| 153 | $this->saveOutcome($controller, self::OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER); |
| 154 | return; |
| 155 | } |
| 156 | |
| 157 | $optinRetryCount = max(0, $args->getRunNumber() - 1); |
| 158 | $controller->getRunLog()->saveLogData([ |
| 159 | self::WAIT_OPTIN => 0, |
| 160 | self::OPTIN_RETRIES => $optinRetryCount, |
| 161 | ]); |
| 162 | $state[self::OPTIN_RETRIES] = $optinRetryCount; |
| 163 | if (!$this->scheduleLatestNewsletter($args, $controller, $subscriber, $segmentId)) { |
| 164 | return; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | $success = $this->checkSendingStatus($args, $controller, $subscriber, $segmentId); |
| 169 | if ($success) { |
| 170 | return; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | $runNumber = $args->getRunNumber(); |
| 175 | $state = $state ?? $this->getRunLogData($controller); |
| 176 | $optinRetryCount = $state[self::OPTIN_RETRIES] ?? 0; |
| 177 | $runNumber -= $optinRetryCount; |
| 178 | $this->rerunLater($runNumber, $controller, false); |
| 179 | } |
| 180 | |
| 181 | private function scheduleLatestNewsletter(StepRunArgs $args, StepRunController $controller, SubscriberEntity $subscriber, int $segmentId): bool { |
| 182 | try { |
| 183 | $result = $this->latestNewsletterScheduler->schedule($subscriber, $segmentId, $this->getAutomationMeta($args)); |
| 184 | } catch (Throwable $e) { |
| 185 | throw InvalidStateException::create()->withMessage(__('Could not create sending task.', 'mailpoet')); |
| 186 | } |
| 187 | |
| 188 | $newsletter = $result['newsletter']; |
| 189 | $newsletterId = $newsletter instanceof NewsletterEntity ? $newsletter->getId() : null; |
| 190 | $this->saveOutcome($controller, $result['outcome'], $newsletterId); |
| 191 | |
| 192 | if ($result['outcome'] === LatestNewsletterScheduler::OUTCOME_SCHEDULED) { |
| 193 | return true; |
| 194 | } |
| 195 | return false; |
| 196 | } |
| 197 | |
| 198 | private function checkSendingStatus(StepRunArgs $args, StepRunController $controller, SubscriberEntity $subscriber, int $segmentId): bool { |
| 199 | $state = $this->getRunLogData($controller); |
| 200 | $newsletterId = (int)($state[self::NEWSLETTER_ID] ?? 0); |
| 201 | if (!$newsletterId) { |
| 202 | return true; |
| 203 | } |
| 204 | |
| 205 | $newsletter = $this->latestNewsletterScheduler->getScheduledTaskSubscriber( |
| 206 | $this->getNewsletter($newsletterId), |
| 207 | $subscriber, |
| 208 | $args->getAutomationRun() |
| 209 | ); |
| 210 | if (!$newsletter) { |
| 211 | if ($this->isSubscriberIneligible($subscriber, $segmentId)) { |
| 212 | $this->saveOutcome($controller, self::OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER, $newsletterId); |
| 213 | return true; |
| 214 | } |
| 215 | throw InvalidStateException::create()->withMessage(__('Email failed to schedule.', 'mailpoet')); |
| 216 | } |
| 217 | |
| 218 | if ($newsletter->getFailed() === ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED) { |
| 219 | throw InvalidStateException::create()->withMessage( |
| 220 | // translators: %s is the error message. |
| 221 | sprintf(__('Email failed to send. Error: %s', 'mailpoet'), $newsletter->getError() ?: 'Unknown error') |
| 222 | ); |
| 223 | } |
| 224 | |
| 225 | $wasSent = $newsletter->getProcessed() === ScheduledTaskSubscriberEntity::STATUS_PROCESSED; |
| 226 | if ($wasSent) { |
| 227 | $this->saveOutcome($controller, self::OUTCOME_SENT, $newsletterId); |
| 228 | return true; |
| 229 | } |
| 230 | |
| 231 | if ($this->isSubscriberIneligible($subscriber, $segmentId)) { |
| 232 | $this->latestNewsletterScheduler->saveErrorAndPause( |
| 233 | $newsletter, |
| 234 | __('Subscriber is no longer eligible for this email.', 'mailpoet') |
| 235 | ); |
| 236 | $this->saveOutcome($controller, self::OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER, $newsletterId); |
| 237 | return true; |
| 238 | } |
| 239 | |
| 240 | $optinRetryCount = (int)($state[self::OPTIN_RETRIES] ?? 0); |
| 241 | $pollRunNumber = max(1, $args->getRunNumber() - $optinRetryCount); |
| 242 | $isLastRun = $pollRunNumber >= 1 + count(self::POLL_INTERVALS); |
| 243 | if (!$isLastRun) { |
| 244 | $this->saveOutcome($controller, self::OUTCOME_POLLING, $newsletterId); |
| 245 | return false; |
| 246 | } |
| 247 | |
| 248 | $error = __('Email sending process timed out.', 'mailpoet'); |
| 249 | $this->latestNewsletterScheduler->saveErrorAndPause($newsletter, $error); |
| 250 | throw InvalidStateException::create()->withMessage($error); |
| 251 | } |
| 252 | |
| 253 | private function getNewsletter(int $newsletterId): NewsletterEntity { |
| 254 | $newsletter = $this->newslettersRepository->findOneById($newsletterId); |
| 255 | if (!$newsletter instanceof NewsletterEntity) { |
| 256 | throw InvalidStateException::create()->withMessage(__('Email failed to schedule.', 'mailpoet')); |
| 257 | } |
| 258 | return $newsletter; |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * @return array{0: SubscriberEntity, 1: int} |
| 263 | */ |
| 264 | private function getSubscriberAndSegment(StepRunArgs $args): array { |
| 265 | try { |
| 266 | $subscriberId = $args->getSinglePayloadByClass(SubscriberPayload::class)->getId(); |
| 267 | $segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId(); |
| 268 | } catch (Throwable $e) { |
| 269 | throw InvalidStateException::create()->withMessage(__('This action needs a trigger list, such as "Someone subscribes".', 'mailpoet')); |
| 270 | } |
| 271 | |
| 272 | $subscriber = $this->subscribersRepository->findOneById($subscriberId); |
| 273 | if (!$subscriber) { |
| 274 | throw InvalidStateException::create()->withMessage(__('Subscriber was not found.', 'mailpoet')); |
| 275 | } |
| 276 | if (!$this->segmentsRepository->findOneById($segmentId)) { |
| 277 | throw InvalidStateException::create()->withMessage(__('Cannot send the email because the list was not found.', 'mailpoet')); |
| 278 | } |
| 279 | |
| 280 | return [$subscriber, $segmentId]; |
| 281 | } |
| 282 | |
| 283 | private function isSubscriberIneligible(SubscriberEntity $subscriber, int $segmentId): bool { |
| 284 | if ( |
| 285 | $subscriber->getDeletedAt() !== null |
| 286 | || in_array($subscriber->getStatus(), [SubscriberEntity::STATUS_BOUNCED, SubscriberEntity::STATUS_UNSUBSCRIBED], true) |
| 287 | ) { |
| 288 | return true; |
| 289 | } |
| 290 | |
| 291 | if ($subscriber->getStatus() !== SubscriberEntity::STATUS_SUBSCRIBED) { |
| 292 | return false; |
| 293 | } |
| 294 | |
| 295 | return !$this->subscriberSegmentRepository->findOneBy([ |
| 296 | 'subscriber' => $subscriber->getId(), |
| 297 | 'segment' => $segmentId, |
| 298 | 'status' => SubscriberEntity::STATUS_SUBSCRIBED, |
| 299 | ]); |
| 300 | } |
| 301 | |
| 302 | private function rerunLater(int $runNumber, StepRunController $controller, bool $waitingForOptIn): void { |
| 303 | if ($waitingForOptIn) { |
| 304 | if ($runNumber > count(self::OPTIN_RETRY_INTERVALS)) { |
| 305 | $this->saveOutcome($controller, self::OUTCOME_SKIPPED_INELIGIBLE_SUBSCRIBER); |
| 306 | return; |
| 307 | } |
| 308 | $controller->scheduleProgress(time() + self::OPTIN_RETRY_INTERVALS[$runNumber - 1]); |
| 309 | return; |
| 310 | } |
| 311 | |
| 312 | $nextInterval = self::POLL_INTERVALS[$runNumber - 1] ?? 0; |
| 313 | $controller->scheduleProgress(time() + $nextInterval); |
| 314 | } |
| 315 | |
| 316 | private function getRunLogData(StepRunController $controller): array { |
| 317 | $runLog = $controller->getRunLog()->getLog(); |
| 318 | return $runLog->getData(); |
| 319 | } |
| 320 | |
| 321 | /** @return array{id: mixed, run_id: mixed, step_id: mixed, run_number: mixed} */ |
| 322 | private function getAutomationMeta(StepRunArgs $args): array { |
| 323 | return [ |
| 324 | 'id' => $args->getAutomation()->getId(), |
| 325 | 'run_id' => $args->getAutomationRun()->getId(), |
| 326 | 'step_id' => $args->getStep()->getId(), |
| 327 | 'run_number' => $args->getRunNumber(), |
| 328 | ]; |
| 329 | } |
| 330 | |
| 331 | private function saveOutcome(StepRunController $controller, string $outcome, ?int $newsletterId = null): void { |
| 332 | $data = [self::OUTCOME => $outcome]; |
| 333 | if ($newsletterId) { |
| 334 | $data[self::NEWSLETTER_ID] = $newsletterId; |
| 335 | } |
| 336 | $controller->getRunLog()->saveLogData($data); |
| 337 | } |
| 338 | |
| 339 | public function onDuplicate(Step $step): Step { |
| 340 | return $step; |
| 341 | } |
| 342 | } |
| 343 |