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 / Newsletter / Sending / TimeZoneCampaignScheduler.php
mailpoet / lib / Newsletter / Sending Last commit date
NewsletterReplayMetadata.php 2 months ago ScheduledTaskSubscribersListingRepository.php 2 years ago ScheduledTaskSubscribersRepository.php 1 month ago ScheduledTasksRepository.php 1 month ago SendingQueuesRepository.php 1 week ago TimeZoneCampaignScheduler.php 1 week ago index.php 3 years ago
TimeZoneCampaignScheduler.php
615 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 /** Resolves replay sources directly so newsletters with many replays stay cheap to inspect. */
189 public function resolveTimeZoneCampaignQueue(NewsletterEntity $newsletter): ?SendingQueueEntity {
190 $latestQueue = $newsletter->getLatestQueue();
191 if ($latestQueue === null) {
192 return null;
193 }
194 if ($this->isTimeZoneQueue($latestQueue)) {
195 return $latestQueue;
196 }
197
198 $meta = $latestQueue->getMeta() ?? [];
199 if (!NewsletterReplayMetadata::isLatestNewsletterReplayMeta($meta)) {
200 return null;
201 }
202 $sourceQueueId = (int)($meta[NewsletterReplayMetadata::REPLAY_SOURCE_QUEUE_ID] ?? 0);
203 if ($sourceQueueId <= 0) {
204 return null;
205 }
206
207 $sourceQueue = $this->sendingQueuesRepository->findOneBy([
208 'id' => $sourceQueueId,
209 'newsletter' => $newsletter,
210 ]);
211 return $sourceQueue instanceof SendingQueueEntity && $this->isTimeZoneQueue($sourceQueue)
212 ? $sourceQueue
213 : null;
214 }
215
216 public function hasIncompleteCampaignQueues(SendingQueueEntity $queue): bool {
217 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
218 $task = $campaignQueue->getTask();
219 if (!$task instanceof ScheduledTaskEntity) {
220 continue;
221 }
222 if (
223 !in_array(
224 $task->getStatus(),
225 [
226 ScheduledTaskEntity::STATUS_COMPLETED,
227 ScheduledTaskEntity::STATUS_CANCELLED,
228 ScheduledTaskEntity::STATUS_INVALID,
229 ],
230 true
231 )
232 ) {
233 return true;
234 }
235 }
236 return false;
237 }
238
239 public function pauseCampaign(SendingQueueEntity $queue): void {
240 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
241 // Mirrors the guard in resumeCampaign(): a queue is only "fully processed"
242 // when it has work to do AND all of it has been done. A zero-count queue
243 // is still pending and must be paused.
244 if ($campaignQueue->getCountTotal() > 0 && $campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal()) {
245 continue;
246 }
247 $task = $campaignQueue->getTask();
248 if ($task instanceof ScheduledTaskEntity) {
249 $task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
250 }
251 }
252 $this->entityManager->flush();
253 }
254
255 public function resumeCampaign(SendingQueueEntity $queue): void {
256 $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
257 $newsletter = $queue->getNewsletter();
258 $hasDueBatch = false;
259 $hasPendingBatch = false;
260 foreach ($this->getCampaignQueues($queue) as $campaignQueue) {
261 $task = $campaignQueue->getTask();
262 if (!$task instanceof ScheduledTaskEntity) {
263 continue;
264 }
265 if ($campaignQueue->getCountProcessed() === $campaignQueue->getCountTotal() && $campaignQueue->getCountTotal() > 0) {
266 // Mirrors SendingQueuesRepository::resume(): when pause interrupted the worker after all
267 // recipients were processed but before STATUS_COMPLETED was set, finalize processedAt now
268 // so aggregate "processed at" reporting is not left null for this batch.
269 $task->setProcessedAt(Carbon::now()->millisecond(0));
270 $task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
271 continue;
272 }
273 $hasPendingBatch = true;
274 $scheduledAt = $task->getScheduledAt();
275 if ($scheduledAt && $scheduledAt > $now) {
276 $task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
277 } else {
278 $task->setStatus(null);
279 $hasDueBatch = true;
280 }
281 }
282
283 if ($newsletter instanceof NewsletterEntity) {
284 if (!$hasPendingBatch) {
285 // All batches are already completed: do not demote a finished campaign back to
286 // SCHEDULED. No task remains for the scheduler to pick up, so doing so would
287 // leave the newsletter permanently stuck in the wrong status.
288 if ($newsletter->canBeSetSent()) {
289 $newsletter->setStatus(NewsletterEntity::STATUS_SENT);
290 }
291 } else {
292 $newsletter->setStatus($hasDueBatch ? NewsletterEntity::STATUS_SENDING : NewsletterEntity::STATUS_SCHEDULED);
293 }
294 }
295 $this->entityManager->flush();
296 }
297
298 /**
299 * @return array{status:?string,scheduledAt:\DateTimeInterface|null,processedAt:\DateTimeInterface|null,countTotal:int,countProcessed:int,countToProcess:int,meta:array<string,mixed>}|null
300 */
301 public function getAggregateQueueData(SendingQueueEntity $queue): ?array {
302 if (!$this->isTimeZoneQueue($queue)) {
303 return null;
304 }
305
306 $queues = $this->getCampaignQueues($queue);
307 $countTotal = 0;
308 $countProcessed = 0;
309 $countToProcess = 0;
310 $firstScheduledAt = null;
311 $lastScheduledAt = null;
312 $nextScheduledAt = null;
313 $lastProcessedAt = null;
314 $breakdown = [];
315 $statuses = [];
316 $meta = $queue->getMeta() ?? [];
317
318 foreach ($queues as $campaignQueue) {
319 $task = $campaignQueue->getTask();
320 if (!$task instanceof ScheduledTaskEntity) {
321 continue;
322 }
323 $countTotal += $campaignQueue->getCountTotal();
324 $countProcessed += $campaignQueue->getCountProcessed();
325 $countToProcess += $campaignQueue->getCountToProcess();
326 $statuses[] = $task->getStatus();
327 $scheduledAt = $task->getScheduledAt();
328 if ($scheduledAt) {
329 $firstScheduledAt = $this->minDate($firstScheduledAt, $scheduledAt);
330 $lastScheduledAt = $this->maxDate($lastScheduledAt, $scheduledAt);
331 if ($task->getStatus() === ScheduledTaskEntity::STATUS_SCHEDULED || $task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) {
332 $nextScheduledAt = $this->minDate($nextScheduledAt, $scheduledAt);
333 }
334 }
335 $processedAt = $task->getProcessedAt();
336 if ($processedAt) {
337 $lastProcessedAt = $this->maxDate($lastProcessedAt, $processedAt);
338 }
339 $queueMeta = $campaignQueue->getMeta() ?? [];
340 $breakdown[] = [
341 'timezone' => $queueMeta[self::META_GROUP_TIMEZONE] ?? null,
342 'fallback_used' => $queueMeta[self::META_FALLBACK_USED] ?? false,
343 'scheduled_at' => $scheduledAt ? $scheduledAt->format('Y-m-d H:i:s') : null,
344 'status' => $task->getStatus(),
345 'in_progress' => (bool)$task->getInProgress(),
346 'count_total' => $campaignQueue->getCountTotal(),
347 'count_processed' => $campaignQueue->getCountProcessed(),
348 'count_to_process' => $campaignQueue->getCountToProcess(),
349 ];
350 }
351
352 $meta[self::META_FIRST_SCHEDULED_AT] = $firstScheduledAt ? $firstScheduledAt->format('Y-m-d H:i:s') : null;
353 $meta[self::META_LAST_SCHEDULED_AT] = $lastScheduledAt ? $lastScheduledAt->format('Y-m-d H:i:s') : null;
354 $meta[self::META_NEXT_SCHEDULED_AT] = $nextScheduledAt ? $nextScheduledAt->format('Y-m-d H:i:s') : null;
355 $meta[self::META_TIMEZONE_BREAKDOWN] = $breakdown;
356
357 return [
358 'status' => $this->resolveAggregateStatus($statuses),
359 'scheduledAt' => $nextScheduledAt ?: $firstScheduledAt,
360 'processedAt' => $lastProcessedAt,
361 'countTotal' => $countTotal,
362 'countProcessed' => $countProcessed,
363 'countToProcess' => $countToProcess,
364 'meta' => $meta,
365 ];
366 }
367
368 private function getRequiredOption(NewsletterEntity $newsletter, string $optionName): string {
369 $value = $newsletter->getOptionValue($optionName);
370 return is_string($value) ? $value : '';
371 }
372
373 private function normalizeLocalTime(string $time): string {
374 if (strlen($time) === 5) {
375 return "{$time}:00";
376 }
377 return $time;
378 }
379
380 /**
381 * @throws \Exception
382 */
383 private function validateLocalDate(string $date): void {
384 $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
385 if (!$dateTime || $dateTime->format('Y-m-d') !== $date) {
386 throw new \Exception(__('Please enter a valid scheduled date.', 'mailpoet'), 400);
387 }
388 }
389
390 /**
391 * @throws \Exception
392 */
393 private function validateLocalTime(string $time): void {
394 $dateTime = \DateTimeImmutable::createFromFormat('!H:i:s', $time);
395 if (
396 !$dateTime
397 || $dateTime->format('H:i:s') !== $time
398 || ((int)$dateTime->format('i')) % 15 !== 0
399 || ((int)$dateTime->format('s')) !== 0
400 ) {
401 throw new \Exception(__('Please enter a valid scheduled time.', 'mailpoet'), 400);
402 }
403 }
404
405 /**
406 * @param int[] $subscriberIds
407 * @return array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}>
408 */
409 private function groupSubscribersByTimeZone(array $subscriberIds, string $siteTimeZoneName): array {
410 $groups = [];
411 foreach (array_chunk($subscriberIds, 1000) as $idsChunk) {
412 $subscribers = $this->subscribersRepository->findBy(['id' => $idsChunk]);
413 foreach ($subscribers as $subscriber) {
414 if (!$subscriber instanceof SubscriberEntity || !$subscriber->getId()) {
415 continue;
416 }
417 $timeZone = SubscriberEntity::sanitizeTimeZone($subscriber->getTimeZone());
418 $fallbackUsed = $timeZone === null;
419 $resolvedTimeZone = $timeZone ?: $siteTimeZoneName;
420 $key = $resolvedTimeZone . ':' . (int)$fallbackUsed;
421 if (!isset($groups[$key])) {
422 $groups[$key] = [
423 'timeZone' => $resolvedTimeZone,
424 'fallbackUsed' => $fallbackUsed,
425 'subscriberIds' => [],
426 ];
427 }
428 $groups[$key]['subscriberIds'][] = (int)$subscriber->getId();
429 }
430 }
431 return $groups;
432 }
433
434 /**
435 * @param array<string,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[]}> $groups
436 * @return array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}>
437 */
438 private function buildGroupSchedule(array $groups, string $selectedLocalDate, string $selectedLocalTime): array {
439 $schedule = [];
440 foreach ($groups as $group) {
441 $scheduledAt = new \DateTimeImmutable(
442 "{$selectedLocalDate} {$selectedLocalTime}",
443 new \DateTimeZone($group['timeZone'])
444 );
445 $scheduledAt = $scheduledAt->setTimezone(new \DateTimeZone('UTC'));
446 $schedule[] = [
447 'timeZone' => $group['timeZone'],
448 'fallbackUsed' => $group['fallbackUsed'],
449 'subscriberIds' => $group['subscriberIds'],
450 'scheduledAt' => $scheduledAt,
451 ];
452 }
453 usort($schedule, function(array $a, array $b): int {
454 return $a['scheduledAt'] <=> $b['scheduledAt'];
455 });
456 return $schedule;
457 }
458
459 /**
460 * @param array<int,array{timeZone:string,fallbackUsed:bool,subscriberIds:int[],scheduledAt:\DateTimeImmutable}> $schedule
461 * @throws \Exception
462 */
463 private function validateLeadTime(array $schedule): void {
464 $earliest = $schedule[0]['scheduledAt'];
465 $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
466 if ($earliest <= $now) {
467 throw new \Exception(__('Subscriber time zone scheduling cannot include time zones that have already passed.', 'mailpoet'), 400);
468 }
469 if ($earliest < $now->modify('+' . self::LEAD_TIME_HOURS . ' hours')) {
470 throw new \Exception(sprintf(
471 // translators: %d is the minimum number of hours required before the first timezone batch can send.
472 __('Subscriber time zone scheduling requires at least %d hours of lead time.', 'mailpoet'),
473 self::LEAD_TIME_HOURS
474 ), 400);
475 }
476 }
477
478 public function canReplaceScheduledCampaign(NewsletterEntity $newsletter): bool {
479 foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) {
480 if (!$this->isReplaceableScheduledQueue($queue)) {
481 return false;
482 }
483 }
484 return true;
485 }
486
487 public function canReplaceScheduledQueues(NewsletterEntity $newsletter): bool {
488 foreach ($this->getQueuesForNewsletter($newsletter) as $queue) {
489 if ($this->isTerminalQueue($queue)) {
490 continue;
491 }
492 if (!$this->isReplaceableScheduledQueue($queue)) {
493 return false;
494 }
495 }
496 return true;
497 }
498
499 public function deleteReplaceableScheduledQueues(NewsletterEntity $newsletter): void {
500 foreach ($this->getQueuesForNewsletter($newsletter) as $queue) {
501 if ($this->isReplaceableScheduledQueue($queue)) {
502 $this->deleteQueue($queue);
503 }
504 }
505 $this->entityManager->flush();
506 }
507
508 public function deleteScheduledCampaignQueues(NewsletterEntity $newsletter): void {
509 foreach ($this->getTimeZoneQueuesForNewsletter($newsletter) as $queue) {
510 $this->deleteQueue($queue);
511 }
512 $this->entityManager->flush();
513 }
514
515 /** @return SendingQueueEntity[] */
516 private function getQueuesForNewsletter(NewsletterEntity $newsletter): array {
517 return $this->sendingQueuesRepository->findBy(['newsletter' => $newsletter]);
518 }
519
520 /** @return SendingQueueEntity[] */
521 private function getTimeZoneQueuesForNewsletter(NewsletterEntity $newsletter): array {
522 return array_values(array_filter($this->getQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue): bool {
523 return $this->isTimeZoneQueue($queue);
524 }));
525 }
526
527 private function isReplaceableScheduledQueue(SendingQueueEntity $queue): bool {
528 $task = $queue->getTask();
529 return $task instanceof ScheduledTaskEntity
530 && $queue->getCountProcessed() === 0
531 && !$task->getInProgress()
532 && in_array($task->getStatus(), [ScheduledTaskEntity::STATUS_SCHEDULED, ScheduledTaskEntity::STATUS_PAUSED], true);
533 }
534
535 private function isTerminalQueue(SendingQueueEntity $queue): bool {
536 $task = $queue->getTask();
537 return $task instanceof ScheduledTaskEntity
538 && in_array(
539 $task->getStatus(),
540 [
541 ScheduledTaskEntity::STATUS_COMPLETED,
542 ScheduledTaskEntity::STATUS_CANCELLED,
543 ScheduledTaskEntity::STATUS_INVALID,
544 ],
545 true
546 );
547 }
548
549 private function deleteQueue(SendingQueueEntity $queue): void {
550 $task = $queue->getTask();
551 if ($task instanceof ScheduledTaskEntity) {
552 $this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
553 }
554 $this->sendingQueuesRepository->remove($queue);
555 if ($task instanceof ScheduledTaskEntity) {
556 $this->scheduledTasksRepository->remove($task);
557 }
558 }
559
560 /** @return SendingQueueEntity[] */
561 private function getCampaignQueuesById(NewsletterEntity $newsletter, string $campaignId): array {
562 $queues = array_filter($this->getTimeZoneQueuesForNewsletter($newsletter), function(SendingQueueEntity $queue) use ($campaignId): bool {
563 return $this->getCampaignId($queue) === $campaignId;
564 });
565 usort($queues, function(SendingQueueEntity $a, SendingQueueEntity $b): int {
566 $taskA = $a->getTask();
567 $taskB = $b->getTask();
568 $scheduledA = $taskA instanceof ScheduledTaskEntity ? $taskA->getScheduledAt() : null;
569 $scheduledB = $taskB instanceof ScheduledTaskEntity ? $taskB->getScheduledAt() : null;
570 if (!$scheduledA || !$scheduledB) {
571 return (int)$a->getId() <=> (int)$b->getId();
572 }
573 return $scheduledA <=> $scheduledB;
574 });
575 return $queues;
576 }
577
578 /**
579 * Resolves the aggregate status of a multi-batch time zone campaign from the per-batch statuses.
580 *
581 * The priority is intentionally explicit (instead of falling back to "the first status in the
582 * list") so that the result is deterministic and reflects what the campaign is doing, not the
583 * order in which batches happen to be sorted.
584 */
585 private function resolveAggregateStatus(array $statuses): ?string {
586 if ($statuses === []) {
587 return null;
588 }
589 if (in_array(ScheduledTaskEntity::STATUS_PAUSED, $statuses, true)) {
590 return ScheduledTaskEntity::STATUS_PAUSED;
591 }
592 if (in_array(null, $statuses, true)) {
593 return null;
594 }
595 if (in_array(ScheduledTaskEntity::STATUS_SCHEDULED, $statuses, true)) {
596 return ScheduledTaskEntity::STATUS_SCHEDULED;
597 }
598 if (in_array(ScheduledTaskEntity::STATUS_COMPLETED, $statuses, true)) {
599 return ScheduledTaskEntity::STATUS_COMPLETED;
600 }
601 if (in_array(ScheduledTaskEntity::STATUS_CANCELLED, $statuses, true)) {
602 return ScheduledTaskEntity::STATUS_CANCELLED;
603 }
604 return ScheduledTaskEntity::STATUS_INVALID;
605 }
606
607 private function minDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface {
608 return $current && $current <= $candidate ? $current : $candidate;
609 }
610
611 private function maxDate(?\DateTimeInterface $current, \DateTimeInterface $candidate): \DateTimeInterface {
612 return $current && $current >= $candidate ? $current : $candidate;
613 }
614 }
615