PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.33.1
MailPoet – Newsletters, Email Marketing, and Automation v5.33.1
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 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 / Newsletter / Sending / TimeZoneCampaignScheduler.php
mailpoet / lib / Newsletter / Sending Last commit date
NewsletterReplayMetadata.php 3 months ago ScheduledTaskSubscribersListingRepository.php 2 years ago ScheduledTaskSubscribersRepository.php 2 months ago ScheduledTasksRepository.php 2 months ago SendingQueuesRepository.php 1 month ago TimeZoneCampaignScheduler.php 4 months ago index.php 3 years ago
TimeZoneCampaignScheduler.php
586 lines
1 <?php declare(strict_types = 1);
2
3 namespace MailPoet\Newsletter\Sending;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker;
9 use MailPoet\Entities\NewsletterEntity;
10 use MailPoet\Entities\NewsletterOptionFieldEntity;
11 use MailPoet\Entities\ScheduledTaskEntity;
12 use MailPoet\Entities\SendingQueueEntity;
13 use MailPoet\Entities\SubscriberEntity;
14 use MailPoet\Features\FeaturesController;
15 use MailPoet\Segments\SubscribersFinder;
16 use MailPoet\Subscribers\SubscribersRepository;
17 use MailPoet\Util\License\Features\CapabilitiesManager;
18 use MailPoet\Util\Security;
19 use MailPoet\WP\Functions as WPFunctions;
20 use MailPoetVendor\Carbon\Carbon;
21 use MailPoetVendor\Doctrine\ORM\EntityManager;
22
23 class TimeZoneCampaignScheduler {
24 public const SCHEDULE_MODE_WEBSITE_TIME = 'website_time';
25 public const SCHEDULE_MODE_SUBSCRIBER_TIMEZONE = 'subscriber_timezone';
26 public const META_SEND_BY_TIMEZONE = 'sendByTimezone';
27 public const META_TIMEZONE_CAMPAIGN_ID = 'timezoneCampaignId';
28 public const META_SELECTED_LOCAL_DATE = 'selectedLocalDate';
29 public const META_SELECTED_LOCAL_TIME = 'selectedLocalTime';
30 public const META_GROUP_TIMEZONE = 'groupTimezone';
31 public const META_FALLBACK_USED = 'fallbackUsed';
32 public const META_SITE_TIMEZONE = 'siteTimezone';
33 public const META_FIRST_SCHEDULED_AT = 'firstScheduledAt';
34 public const META_LAST_SCHEDULED_AT = 'lastScheduledAt';
35 public const META_NEXT_SCHEDULED_AT = 'nextScheduledAt';
36 public const META_TIMEZONE_BREAKDOWN = 'timezoneBreakdown';
37 private const LEAD_TIME_HOURS = 24;
38
39 private CapabilitiesManager $capabilitiesManager;
40 private EntityManager $entityManager;
41 private FeaturesController $featuresController;
42 private ScheduledTasksRepository $scheduledTasksRepository;
43 private ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository;
44 private SendingQueuesRepository $sendingQueuesRepository;
45 private SubscribersFinder $subscribersFinder;
46 private SubscribersRepository $subscribersRepository;
47 private WPFunctions $wp;
48
49 public function __construct(
50 CapabilitiesManager $capabilitiesManager,
51 EntityManager $entityManager,
52 FeaturesController $featuresController,
53 ScheduledTasksRepository $scheduledTasksRepository,
54 ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
55 SendingQueuesRepository $sendingQueuesRepository,
56 SubscribersFinder $subscribersFinder,
57 SubscribersRepository $subscribersRepository,
58 WPFunctions $wp
59 ) {
60 $this->capabilitiesManager = $capabilitiesManager;
61 $this->entityManager = $entityManager;
62 $this->featuresController = $featuresController;
63 $this->scheduledTasksRepository = $scheduledTasksRepository;
64 $this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
65 $this->sendingQueuesRepository = $sendingQueuesRepository;
66 $this->subscribersFinder = $subscribersFinder;
67 $this->subscribersRepository = $subscribersRepository;
68 $this->wp = $wp;
69 }
70
71 public function isSubscriberTimeZoneMode(NewsletterEntity $newsletter): bool {
72 return $newsletter->getType() === NewsletterEntity::TYPE_STANDARD
73 && $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SCHEDULE_MODE) === self::SCHEDULE_MODE_SUBSCRIBER_TIMEZONE;
74 }
75
76 /**
77 * @throws \Exception
78 */
79 public function schedule(NewsletterEntity $newsletter): SendingQueueEntity {
80 if (!$this->featuresController->isSupported(FeaturesController::FEATURE_SEND_BY_TIMEZONE)) {
81 throw new \Exception(__('Send by subscriber time zone is not available.', 'mailpoet'), 400);
82 }
83 if (!$this->isSubscriberTimeZoneMode($newsletter)) {
84 throw new \Exception(__('Send by subscriber time zone is available only for standard newsletters.', 'mailpoet'), 400);
85 }
86 $capability = $this->capabilitiesManager->getCapability('sendByTimezone');
87 if ($capability && $capability->isRestricted) {
88 throw new \Exception(__('Send by subscriber time zone requires a paid MailPoet plan.', 'mailpoet'), 403);
89 }
90 if (!$this->canReplaceScheduledQueues($newsletter)) {
91 throw new \Exception(__('This email can no longer be edited because one or more time zone batches have already started.', 'mailpoet'), 400);
92 }
93
94 $selectedLocalDate = $this->getRequiredOption($newsletter, NewsletterOptionFieldEntity::NAME_SCHEDULED_LOCAL_DATE);
95 $selectedLocalTime = $this->normalizeLocalTime($this->getRequiredOption($newsletter, NewsletterOptionFieldEntity::NAME_SCHEDULED_LOCAL_TIME));
96 $this->validateLocalDate($selectedLocalDate);
97 $this->validateLocalTime($selectedLocalTime);
98
99 $subscriberIds = $this->subscribersFinder->getSubscriberIdsFromSegments($newsletter->getSegmentIds(), $newsletter->getFilterSegmentId());
100 if ($subscriberIds === []) {
101 throw new \Exception(__('There are no subscribers in that list!', 'mailpoet'));
102 }
103
104 $siteTimeZone = $this->wp->wpTimezone();
105 $siteTimeZoneName = $siteTimeZone->getName();
106 $groups = $this->groupSubscribersByTimeZone($subscriberIds, $siteTimeZoneName);
107 if ($groups === []) {
108 throw new \Exception(__('There are no subscribers in that list!', 'mailpoet'));
109 }
110
111 $schedule = $this->buildGroupSchedule($groups, $selectedLocalDate, $selectedLocalTime);
112 $this->validateLeadTime($schedule);
113
114 $campaignId = Security::generateRandomString(16);
115 $firstScheduledAt = $schedule[0]['scheduledAt']->format('Y-m-d H:i:s');
116 $lastScheduledAt = $schedule[count($schedule) - 1]['scheduledAt']->format('Y-m-d H:i:s');
117
118 $this->entityManager->beginTransaction();
119 try {
120 $this->deleteReplaceableScheduledQueues($newsletter);
121 $createdQueues = [];
122
123 foreach ($schedule as $group) {
124 $task = new ScheduledTaskEntity();
125 $task->setType(SendingQueueWorker::TASK_TYPE);
126 $task->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
127 $task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
128 $task->setScheduledAt($group['scheduledAt']);
129
130 $queue = new SendingQueueEntity();
131 $queue->setNewsletter($newsletter);
132 $queue->setTask($task);
133 $queue->setMeta([
134 self::META_SEND_BY_TIMEZONE => true,
135 self::META_TIMEZONE_CAMPAIGN_ID => $campaignId,
136 self::META_SELECTED_LOCAL_DATE => $selectedLocalDate,
137 self::META_SELECTED_LOCAL_TIME => $selectedLocalTime,
138 self::META_GROUP_TIMEZONE => $group['timeZone'],
139 self::META_FALLBACK_USED => $group['fallbackUsed'],
140 self::META_SITE_TIMEZONE => $siteTimeZoneName,
141 self::META_FIRST_SCHEDULED_AT => $firstScheduledAt,
142 self::META_LAST_SCHEDULED_AT => $lastScheduledAt,
143 ]);
144
145 $this->scheduledTasksRepository->persist($task);
146 $this->sendingQueuesRepository->persist($queue);
147 $this->entityManager->flush();
148
149 $this->scheduledTaskSubscribersRepository->addSubscribersByIds($task, $group['subscriberIds']);
150 $this->sendingQueuesRepository->updateCounts($queue);
151 $createdQueues[] = $queue;
152 }
153
154 $newsletter->setStatus(NewsletterEntity::STATUS_SCHEDULED);
155 $this->entityManager->flush();
156 $this->entityManager->commit();
157 } catch (\Throwable $exception) {
158 $this->entityManager->rollback();
159 throw $exception;
160 }
161
162 return $createdQueues[0];
163 }
164
165 public function isTimeZoneQueue(SendingQueueEntity $queue): bool {
166 $meta = $queue->getMeta() ?? [];
167 return !empty($meta[self::META_SEND_BY_TIMEZONE]) && !empty($meta[self::META_TIMEZONE_CAMPAIGN_ID]);
168 }
169
170 public function getCampaignId(SendingQueueEntity $queue): ?string {
171 if (!$this->isTimeZoneQueue($queue)) {
172 return null;
173 }
174 $meta = $queue->getMeta() ?? [];
175 return is_string($meta[self::META_TIMEZONE_CAMPAIGN_ID] ?? null) ? $meta[self::META_TIMEZONE_CAMPAIGN_ID] : null;
176 }
177
178 /** @return SendingQueueEntity[] */
179 public function getCampaignQueues(SendingQueueEntity $queue): array {
180 $campaignId = $this->getCampaignId($queue);
181 $newsletter = $queue->getNewsletter();
182 if (!$campaignId || !$newsletter instanceof NewsletterEntity) {
183 return [$queue];
184 }
185 return $this->getCampaignQueuesById($newsletter, $campaignId);
186 }
187
188 public function hasIncompleteCampaignQueues(SendingQueueEntity $queue): bool {
189 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
190 $task = $campaignQueue->getTask();
191 if (!$task instanceof ScheduledTaskEntity) {
192 continue;
193 }
194 if (
195 !in_array(
196 $task->getStatus(),
197 [
198 ScheduledTaskEntity::STATUS_COMPLETED,
199 ScheduledTaskEntity::STATUS_CANCELLED,
200 ScheduledTaskEntity::STATUS_INVALID,
201 ],
202 true
203 )
204 ) {
205 return true;
206 }
207 }
208 return false;
209 }
210
211 public function pauseCampaign(SendingQueueEntity $queue): void {
212 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
213 // Mirrors the guard in resumeCampaign(): a queue is only "fully processed"
214 // when it has work to do AND all of it has been done. A zero-count queue
215 // is still pending and must be paused.
216 if ($campaignQueue->getCountTotal() > 0 && $campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal()) {
217 continue;
218 }
219 $task = $campaignQueue->getTask();
220 if ($task instanceof ScheduledTaskEntity) {
221 $task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
222 }
223 }
224 $this->entityManager->flush();
225 }
226
227 public function resumeCampaign(SendingQueueEntity $queue): void {
228 $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
229 $newsletter = $queue->getNewsletter();
230 $hasDueBatch = false;
231 $hasPendingBatch = false;
232 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
233 $task = $campaignQueue->getTask();
234 if (!$task instanceof ScheduledTaskEntity) {
235 continue;
236 }
237 if ($campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal() && $campaignQueue->getCountTotal() > 0) {
238 // Mirrors SendingQueuesRepository::resume(): when pause interrupted the worker after all
239 // recipients were processed but before STATUS_COMPLETED was set, finalize processedAt now
240 // so aggregate "processed at" reporting is not left null for this batch.
241 $task->setProcessedAt(Carbon::now()->millisecond(0));
242 $task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
243 continue;
244 }
245 $hasPendingBatch = true;
246 $scheduledAt = $task->getScheduledAt();
247 if ($scheduledAt && $scheduledAt > $now) {
248 $task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
249 } else {
250 $task->setStatus(null);
251 $hasDueBatch = true;
252 }
253 }
254
255 if ($newsletter instanceof NewsletterEntity) {
256 if (!$hasPendingBatch) {
257 // All batches are already completed: do not demote a finished campaign back to
258 // SCHEDULED. No task remains for the scheduler to pick up, so doing so would
259 // leave the newsletter permanently stuck in the wrong status.
260 if ($newsletter->canBeSetSent()) {
261 $newsletter->setStatus(NewsletterEntity::STATUS_SENT);
262 }
263 } else {
264 $newsletter->setStatus($hasDueBatch ? NewsletterEntity::STATUS_SENDING : NewsletterEntity::STATUS_SCHEDULED);
265 }
266 }
267 $this->entityManager->flush();
268 }
269
270 /**
271 * @return array{status:?string,scheduledAt:\DateTimeInterface|null,processedAt:\DateTimeInterface|null,countTotal:int,countProcessed:int,countToProcess:int,meta:array<string,mixed>}|null
272 */
273 public function getAggregateQueueData(SendingQueueEntity $queue): ?array {
274 if (!$this->isTimeZoneQueue($queue)) {
275 return null;
276 }
277
278 $queues = $this->getCampaignQueues($queue);
279 $countTotal = 0;
280 $countProcessed = 0;
281 $countToProcess = 0;
282 $firstScheduledAt = null;
283 $lastScheduledAt = null;
284 $nextScheduledAt = null;
285 $lastProcessedAt = null;
286 $breakdown = [];
287 $statuses = [];
288 $meta = $queue->getMeta() ?? [];
289
290 foreach ($queues as $campaignQueue) {
291 $task = $campaignQueue->getTask();
292 if (!$task instanceof ScheduledTaskEntity) {
293 continue;
294 }
295 $countTotal += $campaignQueue->getCountTotal();
296 $countProcessed += $campaignQueue->getCountProcessed();
297 $countToProcess += $campaignQueue->getCountToProcess();
298 $statuses[] = $task->getStatus();
299 $scheduledAt = $task->getScheduledAt();
300 if ($scheduledAt) {
301 $firstScheduledAt = $this->minDate($firstScheduledAt, $scheduledAt);
302 $lastScheduledAt = $this->maxDate($lastScheduledAt, $scheduledAt);
303 if ($task->getStatus() === ScheduledTaskEntity::STATUS_SCHEDULED || $task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) {
304 $nextScheduledAt = $this->minDate($nextScheduledAt, $scheduledAt);
305 }
306 }
307 $processedAt = $task->getProcessedAt();
308 if ($processedAt) {
309 $lastProcessedAt = $this->maxDate($lastProcessedAt, $processedAt);
310 }
311 $queueMeta = $campaignQueue->getMeta() ?? [];
312 $breakdown[] = [
313 'timezone' => $queueMeta[self::META_GROUP_TIMEZONE] ?? null,
314 'fallback_used' => $queueMeta[self::META_FALLBACK_USED] ?? false,
315 'scheduled_at' => $scheduledAt ? $scheduledAt->format('Y-m-d H:i:s') : null,
316 'status' => $task->getStatus(),
317 'count_total' => $campaignQueue->getCountTotal(),
318 'count_processed' => $campaignQueue->getCountProcessed(),
319 'count_to_process' => $campaignQueue->getCountToProcess(),
320 ];
321 }
322
323 $meta[self::META_FIRST_SCHEDULED_AT] = $firstScheduledAt ? $firstScheduledAt->format('Y-m-d H:i:s') : null;
324 $meta[self::META_LAST_SCHEDULED_AT] = $lastScheduledAt ? $lastScheduledAt->format('Y-m-d H:i:s') : null;
325 $meta[self::META_NEXT_SCHEDULED_AT] = $nextScheduledAt ? $nextScheduledAt->format('Y-m-d H:i:s') : null;
326 $meta[self::META_TIMEZONE_BREAKDOWN] = $breakdown;
327
328 return [
329 'status' => $this->resolveAggregateStatus($statuses),
330 'scheduledAt' => $nextScheduledAt ?: $firstScheduledAt,
331 'processedAt' => $lastProcessedAt,
332 'countTotal' => $countTotal,
333 'countProcessed' => $countProcessed,
334 'countToProcess' => $countToProcess,
335 'meta' => $meta,
336 ];
337 }
338
339 private function getRequiredOption(NewsletterEntity $newsletter, string $optionName): string {
340 $value = $newsletter->getOptionValue($optionName);
341 return is_string($value) ? $value : '';
342 }
343
344 private function normalizeLocalTime(string $time): string {
345 if (strlen($time) === 5) {
346 return "{$time}:00";
347 }
348 return $time;
349 }
350
351 /**
352 * @throws \Exception
353 */
354 private function validateLocalDate(string $date): void {
355 $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
356 if (!$dateTime || $dateTime->format('Y-m-d') !== $date) {
357 throw new \Exception(__('Please enter a valid scheduled date.', 'mailpoet'), 400);
358 }
359 }
360
361 /**
362 * @throws \Exception
363 */
364 private function validateLocalTime(string $time): void {
365 $dateTime = \DateTimeImmutable::createFromFormat('!H:i:s', $time);
366 if (
367 !$dateTime
368 || $dateTime->format('H:i:s') !== $time
369 || ((int)$dateTime->format('i')) % 15 !== 0
370 || ((int)$dateTime->format('s')) !== 0
371 ) {
372 throw new \Exception(__('Please enter a valid scheduled time.', 'mailpoet'), 400);
373 }
374 }
375
376 /**
377 * @param int[] $subscriberIds
378 * @return array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}>
379 */
380 private function groupSubscribersByTimeZone(array $subscriberIds, string $siteTimeZoneName): array {
381 $groups = [];
382 foreach (array_chunk($subscriberIds, 1000) as $idsChunk) {
383 $subscribers = $this->subscribersRepository->findBy(['id' => $idsChunk]);
384 foreach ($subscribers as $subscriber) {
385 if (!$subscriber instanceof SubscriberEntity || !$subscriber->getId()) {
386 continue;
387 }
388 $timeZone = SubscriberEntity::sanitizeTimeZone($subscriber->getTimeZone());
389 $fallbackUsed = $timeZone === null;
390 $resolvedTimeZone = $timeZone ?: $siteTimeZoneName;
391 $key = $resolvedTimeZone . ':' . (int)$fallbackUsed;
392 if (!isset($groups[$key])) {
393 $groups[$key] = [
394 'timeZone' => $resolvedTimeZone,
395 'fallbackUsed' => $fallbackUsed,
396 'subscriberIds' => [],
397 ];
398 }
399 $groups[$key]['subscriberIds'][] = (int)$subscriber->getId();
400 }
401 }
402 return $groups;
403 }
404
405 /**
406 * @param array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}> $groups
407 * @return array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}>
408 */
409 private function buildGroupSchedule(array $groups, string $selectedLocalDate, string $selectedLocalTime): array {
410 $schedule = [];
411 foreach ($groups as $group) {
412 $scheduledAt = new \DateTimeImmutable(
413 "{$selectedLocalDate} {$selectedLocalTime}",
414 new \DateTimeZone($group['timeZone'])
415 );
416 $scheduledAt = $scheduledAt->setTimezone(new \DateTimeZone('UTC'));
417 $schedule[] = [
418 'timeZone' => $group['timeZone'],
419 'fallbackUsed' => $group['fallbackUsed'],
420 'subscriberIds' => $group['subscriberIds'],
421 'scheduledAt' => $scheduledAt,
422 ];
423 }
424 usort($schedule, function(array $a, array $b): int {
425 return $a['scheduledAt'] <=> $b['scheduledAt'];
426 });
427 return $schedule;
428 }
429
430 /**
431 * @param array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}> $schedule
432 * @throws \Exception
433 */
434 private function validateLeadTime(array $schedule): void {
435 $earliest = $schedule[0]['scheduledAt'];
436 $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
437 if ($earliest <= $now) {
438 throw new \Exception(__('Subscriber time zone scheduling cannot include time zones that have already passed.', 'mailpoet'), 400);
439 }
440 if ($earliest < $now->modify('+' . self::LEAD_TIME_HOURS . ' hours')) {
441 throw new \Exception(sprintf(
442 // translators: %d is the minimum number of hours required before the first timezone batch can send.
443 __('Subscriber time zone scheduling requires at least %d hours of lead time.', 'mailpoet'),
444 self::LEAD_TIME_HOURS
445 ), 400);
446 }
447 }
448
449 public function canReplaceScheduledCampaign(NewsletterEntity $newsletter): bool {
450 foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) {
451 if (!$this->isReplaceableScheduledQueue($queue)) {
452 return false;
453 }
454 }
455 return true;
456 }
457
458 public function canReplaceScheduledQueues(NewsletterEntity $newsletter): bool {
459 foreach ($this->getQueuesForNewsletter($newsletter) as $queue) {
460 if ($this->isTerminalQueue($queue)) {
461 continue;
462 }
463 if (!$this->isReplaceableScheduledQueue($queue)) {
464 return false;
465 }
466 }
467 return true;
468 }
469
470 public function deleteReplaceableScheduledQueues(NewsletterEntity $newsletter): void {
471 foreach ($this->getQueuesForNewsletter($newsletter) as $queue) {
472 if ($this->isReplaceableScheduledQueue($queue)) {
473 $this->deleteQueue($queue);
474 }
475 }
476 $this->entityManager->flush();
477 }
478
479 public function deleteScheduledCampaignQueues(NewsletterEntity $newsletter): void {
480 foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) {
481 $this->deleteQueue($queue);
482 }
483 $this->entityManager->flush();
484 }
485
486 /** @return SendingQueueEntity[] */
487 private function getQueuesForNewsletter(NewsletterEntity $newsletter): array {
488 return $this->sendingQueuesRepository->findBy(['newsletter' => $newsletter]);
489 }
490
491 /** @return SendingQueueEntity[] */
492 private function getTimeZoneQueuesForNewsletter(NewsletterEntity $newsletter): array {
493 return array_values(array_filter($this->getQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue): bool {
494 return $this->isTimeZoneQueue($queue);
495 }));
496 }
497
498 private function isReplaceableScheduledQueue(SendingQueueEntity $queue): bool {
499 $task = $queue->getTask();
500 return $task instanceof ScheduledTaskEntity
501 && $queue->getCountProcessed() === 0
502 && !$task->getInProgress()
503 && in_array($task->getStatus(), [ScheduledTaskEntity::STATUS_SCHEDULED, ScheduledTaskEntity::STATUS_PAUSED], true);
504 }
505
506 private function isTerminalQueue(SendingQueueEntity $queue): bool {
507 $task = $queue->getTask();
508 return $task instanceof ScheduledTaskEntity
509 && in_array(
510 $task->getStatus(),
511 [
512 ScheduledTaskEntity::STATUS_COMPLETED,
513 ScheduledTaskEntity::STATUS_CANCELLED,
514 ScheduledTaskEntity::STATUS_INVALID,
515 ],
516 true
517 );
518 }
519
520 private function deleteQueue(SendingQueueEntity $queue): void {
521 $task = $queue->getTask();
522 if ($task instanceof ScheduledTaskEntity) {
523 $this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
524 }
525 $this->sendingQueuesRepository->remove($queue);
526 if ($task instanceof ScheduledTaskEntity) {
527 $this->scheduledTasksRepository->remove($task);
528 }
529 }
530
531 /** @return SendingQueueEntity[] */
532 private function getCampaignQueuesById(NewsletterEntity $newsletter, string $campaignId): array {
533 $queues = array_filter($this->getTimeZoneQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue) use ($campaignId): bool {
534 return $this->getCampaignId($queue) === $campaignId;
535 });
536 usort($queues, function(SendingQueueEntity $a, SendingQueueEntity $b): int {
537 $taskA = $a->getTask();
538 $taskB = $b->getTask();
539 $scheduledA = $taskA instanceof ScheduledTaskEntity ? $taskA->getScheduledAt() : null;
540 $scheduledB = $taskB instanceof ScheduledTaskEntity ? $taskB->getScheduledAt() : null;
541 if (!$scheduledA || !$scheduledB) {
542 return (int)$a->getId() <=> (int)$b->getId();
543 }
544 return $scheduledA <=> $scheduledB;
545 });
546 return $queues;
547 }
548
549 /**
550 * Resolves the aggregate status of a multi-batch time zone campaign from the per-batch statuses.
551 *
552 * The priority is intentionally explicit (instead of falling back to "the first status in the
553 * list") so that the result is deterministic and reflects what the campaign is doing, not the
554 * order in which batches happen to be sorted.
555 */
556 private function resolveAggregateStatus(array $statuses): ?string {
557 if ($statuses === []) {
558 return null;
559 }
560 if (in_array(ScheduledTaskEntity::STATUS_PAUSED, $statuses, true)) {
561 return ScheduledTaskEntity::STATUS_PAUSED;
562 }
563 if (in_array(null, $statuses, true)) {
564 return null;
565 }
566 if (in_array(ScheduledTaskEntity::STATUS_SCHEDULED, $statuses, true)) {
567 return ScheduledTaskEntity::STATUS_SCHEDULED;
568 }
569 if (in_array(ScheduledTaskEntity::STATUS_COMPLETED, $statuses, true)) {
570 return ScheduledTaskEntity::STATUS_COMPLETED;
571 }
572 if (in_array(ScheduledTaskEntity::STATUS_CANCELLED, $statuses, true)) {
573 return ScheduledTaskEntity::STATUS_CANCELLED;
574 }
575 return ScheduledTaskEntity::STATUS_INVALID;
576 }
577
578 private function minDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface {
579 return $current && $current <= $candidate ? $current : $candidate;
580 }
581
582 private function maxDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface {
583 return $current && $current >= $candidate ? $current : $candidate;
584 }
585 }
586