This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,195 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
class AutomaticEmailScheduler {
/** @var Scheduler */
private $scheduler;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var ScheduledTaskSubscribersRepository */
private $scheduledTaskSubscribersRepository;
public function __construct(
Scheduler $scheduler,
ScheduledTasksRepository $scheduledTasksRepository,
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
SendingQueuesRepository $sendingQueuesRepository
) {
$this->scheduler = $scheduler;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
$this->sendingQueuesRepository = $sendingQueuesRepository;
}
public function scheduleAutomaticEmail(
string $group,
string $event,
?callable $schedulingCondition = null,
?SubscriberEntity $subscriber = null,
?array $meta = null,
?callable $metaModifier = null
) {
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
if (empty($newsletters)) return false;
foreach ($newsletters as $newsletter) {
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) continue;
if (is_callable($schedulingCondition) && !$schedulingCondition($newsletter)) continue;
/**
* $meta will be the same for all newsletters by default. If we need to store newsletter-specific meta, the
* $metaModifier callback can be used.
*
* This was introduced because of WooCommerce product purchase automatic emails. We only want to store the
* product IDs that specifically triggered a newsletter, but $meta includes ALL the product IDs
* or category IDs from an order.
*/
if (is_callable($metaModifier)) {
$meta = $metaModifier($newsletter, $meta);
}
$this->createAutomaticEmailScheduledTask($newsletter, $subscriber, $meta);
}
}
public function scheduleOrRescheduleAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber, array $meta): void {
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
if (empty($newsletters)) {
return;
}
foreach ($newsletters as $newsletter) {
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
continue;
}
// try to find existing scheduled task for given subscriber
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
if ($task) {
$this->rescheduleAutomaticEmailSendingTask($newsletter, $task, $meta);
} else {
$this->createAutomaticEmailScheduledTask($newsletter, $subscriber, $meta);
}
}
}
public function rescheduleAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber): void {
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
if (empty($newsletters)) {
return;
}
foreach ($newsletters as $newsletter) {
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
continue;
}
// try to find existing scheduled task for given subscriber
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
if ($task) {
$this->rescheduleAutomaticEmailSendingTask($newsletter, $task);
}
}
}
public function cancelAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber): void {
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
if (empty($newsletters)) {
return;
}
foreach ($newsletters as $newsletter) {
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
continue;
}
// try to find existing scheduled task for given subscriber
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
if ($task) {
$queue = $task->getSendingQueue();
if ($queue instanceof SendingQueueEntity) {
$this->sendingQueuesRepository->remove($queue);
}
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
$this->scheduledTasksRepository->remove($task);
$this->scheduledTasksRepository->flush();
}
}
}
public function createAutomaticEmailScheduledTask(NewsletterEntity $newsletter, ?SubscriberEntity $subscriber, ?array $meta = null): ScheduledTaskEntity {
$scheduledTask = new ScheduledTaskEntity();
$scheduledTask->setType(SendingQueue::TASK_TYPE);
$scheduledTask->setStatus(SendingQueueEntity::STATUS_SCHEDULED);
$scheduledTask->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
$scheduledTask->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
));
$this->scheduledTasksRepository->persist($scheduledTask);
$this->scheduledTasksRepository->flush();
$sendingQueue = new SendingQueueEntity();
$sendingQueue->setNewsletter($newsletter);
$sendingQueue->setTask($scheduledTask);
// Because we changed the way how to updateCounts after sending we need to set initial counts
$sendingQueue->setCountTotal($subscriber ? 1 : 0);
$sendingQueue->setCountToProcess($subscriber ? 1 : 0);
$scheduledTask->setSendingQueue($sendingQueue);
if ($meta) {
$scheduledTask->setMeta($meta);
$sendingQueue->setMeta($meta);
}
$this->sendingQueuesRepository->persist($sendingQueue);
$this->sendingQueuesRepository->flush();
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEND_TO) === 'user' && $subscriber) {
$scheduledTaskSubscriber = new ScheduledTaskSubscriberEntity($scheduledTask, $subscriber);
$this->scheduledTaskSubscribersRepository->persist($scheduledTaskSubscriber);
$this->scheduledTaskSubscribersRepository->flush();
$scheduledTask->getSubscribers()->add($scheduledTaskSubscriber);
}
return $scheduledTask;
}
private function rescheduleAutomaticEmailSendingTask(NewsletterEntity $newsletter, ScheduledTaskEntity $scheduledTask, ?array $meta = null): void {
$sendingQueue = $this->sendingQueuesRepository->findOneBy(['task' => $scheduledTask]);
if (!$sendingQueue) {
return;
}
if ($meta) {
$sendingQueue->setMeta($meta);
$scheduledTask->setMeta($meta);
}
// compute new 'scheduled_at' from now
$scheduledTask->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
));
$this->sendingQueuesRepository->flush();
}
}
@@ -0,0 +1,102 @@
<?php declare(strict_types = 1);
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class AutomationEmailScheduler {
/** @var EntityManager */
private $entityManager;
private ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository;
public function __construct(
EntityManager $entityManager,
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository
) {
$this->entityManager = $entityManager;
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
}
public function createSendingTask(NewsletterEntity $email, SubscriberEntity $subscriber, array $meta): ScheduledTaskEntity {
if (!in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) {
throw InvalidStateException::create()->withMessage(
// translators: %s is the type which was given.
sprintf(__("Email with type 'automation' or 'automation_transactional' expected, '%s' given.", 'mailpoet'), $email->getType())
);
}
$task = new ScheduledTaskEntity();
$task->setType(SendingQueue::TASK_TYPE);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$task->setScheduledAt(Carbon::now()->millisecond(0));
$task->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
$task->setMeta($meta);
$this->entityManager->persist($task);
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
$this->entityManager->persist($taskSubscriber);
$queue = new SendingQueueEntity();
$queue->setTask($task);
$queue->setMeta($meta);
$queue->setNewsletter($email);
$queue->setCountToProcess(1);
$queue->setCountTotal(1);
$this->entityManager->persist($queue);
$this->entityManager->flush();
return $task;
}
public function getScheduledTaskSubscriber(NewsletterEntity $email, SubscriberEntity $subscriber, AutomationRun $run): ?ScheduledTaskSubscriberEntity {
$results = $this->entityManager->createQueryBuilder()
->select('sts')
->from(ScheduledTaskSubscriberEntity::class, 'sts')
->join('sts.task', 'st')
->join('st.sendingQueue', 'sq')
->where('sq.newsletter = :newsletter')
->andWhere('sts.subscriber = :subscriber')
->andWhere('st.createdAt >= :runCreatedAt')
->setParameter('newsletter', $email)
->setParameter('subscriber', $subscriber)
->setParameter('runCreatedAt', $run->getCreatedAt())
->getQuery()
->getResult();
$result = null;
foreach ($results as $scheduledTaskSubscriber) {
$task = $scheduledTaskSubscriber->getTask();
if (!$task instanceof ScheduledTaskEntity) {
continue;
}
$meta = $task->getMeta();
if (($meta['automation']['run_id'] ?? null) === $run->getId()) {
$result = $scheduledTaskSubscriber;
break;
}
}
return $result instanceof ScheduledTaskSubscriberEntity ? $result : null;
}
public function saveError(ScheduledTaskSubscriberEntity $scheduledTaskSubscriber, string $error): void {
$task = $scheduledTaskSubscriber->getTask();
$subscriber = $scheduledTaskSubscriber->getSubscriber();
if (!$task || !$subscriber || !$subscriber->getId()) {
return;
}
$this->scheduledTaskSubscribersRepository->saveError($task, $subscriber->getId(), $error);
}
}
@@ -0,0 +1,216 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\NewsletterPostsRepository;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository;
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
use MailPoet\WP\DateTime;
use MailPoet\WP\Posts;
class PostNotificationScheduler {
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = 3600;
const LAST_WEEKDAY_FORMAT = 'L';
const INTERVAL_DAILY = 'daily';
const INTERVAL_IMMEDIATELY = 'immediately';
const INTERVAL_NTHWEEKDAY = 'nthWeekDay';
const INTERVAL_WEEKLY = 'weekly';
const INTERVAL_IMMEDIATE = 'immediate';
const INTERVAL_MONTHLY = 'monthly';
/** @var LoggerFactory */
private $loggerFactory;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var NewsletterOptionsRepository */
private $newsletterOptionsRepository;
/** @var NewsletterOptionFieldsRepository */
private $newsletterOptionFieldsRepository;
/** @var NewsletterPostsRepository */
private $newsletterPostsRepository;
/** @var Scheduler */
private $scheduler;
/*** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/*** @var SendingQueuesRepository */
private $sendingQueuesRepository;
public function __construct(
NewslettersRepository $newslettersRepository,
NewsletterOptionsRepository $newsletterOptionsRepository,
NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository,
NewsletterPostsRepository $newsletterPostsRepository,
Scheduler $scheduler,
ScheduledTasksRepository $scheduledTasksRepository,
SendingQueuesRepository $sendingQueuesRepository
) {
$this->loggerFactory = LoggerFactory::getInstance();
$this->newslettersRepository = $newslettersRepository;
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
$this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository;
$this->newsletterPostsRepository = $newsletterPostsRepository;
$this->scheduler = $scheduler;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->sendingQueuesRepository = $sendingQueuesRepository;
}
public function transitionHook($newStatus, $oldStatus, $post) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'transition post notification hook initiated',
[
'post_id' => $post->ID,
'new_status' => $newStatus,
'old_status' => $oldStatus,
]
);
$types = Posts::getTypes();
if (($newStatus !== 'publish') || $oldStatus === 'publish' || !isset($types[$post->post_type])) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
return;
}
$this->schedulePostNotification($post->ID);
}
public function schedulePostNotification($postId) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'schedule post notification hook',
['post_id' => $postId]
);
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_NOTIFICATION]);
$this->newslettersRepository->prefetchOptions($newsletters);
if (!count($newsletters)) {
return false;
}
foreach ($newsletters as $newsletter) {
$post = $this->newsletterPostsRepository->findOneBy([
'newsletter' => $newsletter,
'postId' => $postId,
]);
if ($post === null) {
$this->createPostNotificationSendingTask($newsletter);
}
}
}
public function createPostNotificationSendingTask(NewsletterEntity $newsletter): ?ScheduledTaskEntity {
$notificationHistory = $this->newslettersRepository->findSendingNotificationHistoryWithoutPausedOrInvalidTask($newsletter);
if (count($notificationHistory) > 0) {
return null;
}
$scheduleOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_SCHEDULE);
if (!$scheduleOption) {
return null;
}
$nextRunDate = $this->scheduler->getNextRunDateTime($scheduleOption->getValue());
if (!$nextRunDate) {
return null;
}
// do not schedule duplicate queues for the same time
$lastQueue = $newsletter->getLatestQueue();
$task = $lastQueue !== null ? $lastQueue->getTask() : null;
$scheduledAt = $task !== null ? $task->getScheduledAt() : null;
if ($scheduledAt && $scheduledAt->format('Y-m-d H:i:s') === $nextRunDate->format('Y-m-d H:i:s')) {
return null;
}
$scheduledTask = new ScheduledTaskEntity();
$scheduledTask->setType(SendingQueue::TASK_TYPE);
$scheduledTask->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$scheduledTask->setScheduledAt($nextRunDate);
$scheduledTask->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
$this->scheduledTasksRepository->persist($scheduledTask);
$this->scheduledTasksRepository->flush();
$sendingQueue = new SendingQueueEntity();
$sendingQueue->setNewsletter($newsletter);
$sendingQueue->setTask($scheduledTask);
$this->sendingQueuesRepository->persist($sendingQueue);
$this->sendingQueuesRepository->flush();
$scheduledTask->setSendingQueue($sendingQueue);
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'schedule post notification',
[
'sending_task' => $scheduledTask->getId(),
'scheduled_at' => $nextRunDate->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
]
);
return $scheduledTask;
}
public function processPostNotificationSchedule(NewsletterEntity $newsletter) {
$intervalTypeOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_INTERVAL_TYPE);
$intervalType = $intervalTypeOption ? $intervalTypeOption->getValue() : null;
$timeOfDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_TIME_OF_DAY);
$hour = $timeOfDayOption ? (int)floor((int)$timeOfDayOption->getValue() / self::SECONDS_IN_HOUR) : null;
$minute = $timeOfDayOption ? ((int)$timeOfDayOption->getValue() - (int)($hour * self::SECONDS_IN_HOUR)) / self::SECONDS_IN_MINUTE : null;
$weekDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_WEEK_DAY);
$weekDay = $weekDayOption ? $weekDayOption->getValue() : null;
$monthDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_MONTH_DAY);
$monthDay = $monthDayOption ? $monthDayOption->getValue() : null;
$nthWeekDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_NTH_WEEK_DAY);
$nthWeekDay = $nthWeekDayOption ? $nthWeekDayOption->getValue() : null;
$nthWeekDay = ($nthWeekDay === self::LAST_WEEKDAY_FORMAT) ? $nthWeekDay : '#' . $nthWeekDay;
switch ($intervalType) {
case self::INTERVAL_IMMEDIATE:
case self::INTERVAL_DAILY:
$schedule = sprintf('%s %s * * *', $minute, $hour);
break;
case self::INTERVAL_WEEKLY:
$schedule = sprintf('%s %s * * %s', $minute, $hour, $weekDay);
break;
case self::INTERVAL_NTHWEEKDAY:
$schedule = sprintf('%s %s ? * %s%s', $minute, $hour, $weekDay, $nthWeekDay);
break;
case self::INTERVAL_MONTHLY:
$schedule = sprintf('%s %s %s * *', $minute, $hour, $monthDay);
break;
case self::INTERVAL_IMMEDIATELY:
default:
$schedule = '* * * * *';
break;
}
$optionField = $this->newsletterOptionFieldsRepository->findOneBy([
'name' => NewsletterOptionFieldEntity::NAME_SCHEDULE,
]);
if (!$optionField instanceof NewsletterOptionFieldEntity) {
throw new \Exception('NewsletterOptionField for schedule doesnt exist.');
}
$scheduleOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_SCHEDULE);
if ($scheduleOption === null) {
$scheduleOption = new NewsletterOptionEntity($newsletter, $optionField);
$newsletter->getOptions()->add($scheduleOption);
}
$scheduleOption->setValue($schedule);
$this->newsletterOptionsRepository->persist($scheduleOption);
$this->newsletterOptionsRepository->flush();
return $scheduleOption->getValue();
}
}
@@ -0,0 +1,172 @@
<?php declare(strict_types = 1);
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class ReEngagementScheduler {
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var EntityManager */
private $entityManager;
public function __construct(
NewslettersRepository $newslettersRepository,
ScheduledTasksRepository $scheduledTasksRepository,
EntityManager $entityManager
) {
$this->newslettersRepository = $newslettersRepository;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->entityManager = $entityManager;
}
/**
* Schedules sending tasks for re-engagement emails
* @return ScheduledTaskEntity[]
*/
public function scheduleAll(): array {
$scheduled = [];
$emails = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_RE_ENGAGEMENT]);
if (!$emails) {
return $scheduled;
}
foreach ($emails as $email) {
$scheduled[] = $this->scheduleForEmail($email);
}
return array_filter($scheduled);
}
private function scheduleForEmail(NewsletterEntity $newsletter): ?ScheduledTaskEntity {
$scheduledOrRunning = $this->scheduledTasksRepository->findByScheduledAndRunningForNewsletter($newsletter);
if ($scheduledOrRunning) {
return null;
}
$intervalUnit = $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE);
$intervalValue = (int)$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER);
if (!$intervalValue || !in_array($intervalUnit, ['weeks', 'months'], true)) {
return null;
}
if (!$newsletter->getNewsletterSegments()->count()) {
return null;
}
$scheduledTask = $this->scheduleTask();
$enqueuedCount = 0;
foreach ($newsletter->getSegmentIds() as $segmentId) {
$enqueuedCount += $this->enqueueSubscribersForSegment((int)$newsletter->getId(), $segmentId, $scheduledTask, $intervalUnit, $intervalValue);
}
if ($enqueuedCount) {
$this->createSendingQueue($newsletter, $scheduledTask, $enqueuedCount);
return $scheduledTask;
} else {
// Nothing to send
$this->scheduledTasksRepository->remove($scheduledTask);
$this->scheduledTasksRepository->flush();
return null;
}
}
private function scheduleTask(): ScheduledTaskEntity {
// Scheduled task
$scheduledTask = new ScheduledTaskEntity();
$scheduledTask->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$scheduledTask->setScheduledAt(Carbon::now()->millisecond(0));
$scheduledTask->setType(SendingQueue::TASK_TYPE);
$scheduledTask->setPriority(SendingQueueEntity::PRIORITY_MEDIUM);
$this->scheduledTasksRepository->persist($scheduledTask);
$this->scheduledTasksRepository->flush();
return $scheduledTask;
}
private function createSendingQueue(NewsletterEntity $newsletter, ScheduledTaskEntity $scheduledTask, int $countToProcess): SendingQueueEntity {
// Sending queue
$sendingQueue = new SendingQueueEntity();
$sendingQueue->setTask($scheduledTask);
$sendingQueue->setNewsletter($newsletter);
$sendingQueue->setCountToProcess($countToProcess);
$sendingQueue->setCountTotal($countToProcess);
$this->entityManager->persist($sendingQueue);
$this->entityManager->flush();
return $sendingQueue;
}
/**
* Finds subscribers that should receive re-engagement email and saves scheduled tasks subscribers
* @return int Count of enqueued subscribers
*/
private function enqueueSubscribersForSegment(int $newsletterId, int $segmentId, ScheduledTaskEntity $scheduledTask, string $intervalUnit, int $intervalValue): int {
// Parameters for scheduled task subscribers query
$thresholdDate = Carbon::now()->millisecond(0);
if ($intervalUnit === 'months') {
$thresholdDate->subMonths($intervalValue);
} else {
$thresholdDate->subWeeks($intervalValue);
}
$thresholdDateSql = $thresholdDate->toDateTimeString();
// When checking engagement, we ignore emails that subscribers received in the last 24 hours so that we leave them some time to engage.
// This is prevention for sending re-engagement emails to subscribers who have received a single email very recently.
$upperThresholdDate = Carbon::now()->millisecond(0);
$upperThresholdDate->subDay();
$upperThresholdDate = $upperThresholdDate->toDateTimeString();
$taskId = $scheduledTask->getId();
$subscribedStatus = SubscriberEntity::STATUS_SUBSCRIBED;
$newsletterStatsTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$nowSql = Carbon::now()->millisecond(0)->toDateTimeString();
$query = "INSERT IGNORE INTO $scheduledTaskSubscribersTable
(subscriber_id, task_id, processed, created_at)
SELECT DISTINCT ns.subscriber_id as subscriber_id, :taskId as task_id, 0 as processed, :now as created_at
FROM $newsletterStatsTable as ns
JOIN $subscribersTable s ON
ns.subscriber_id = s.id
AND s.deleted_at is NULL
AND s.status = :subscribed
AND GREATEST(COALESCE(s.created_at, '0'), COALESCE(s.last_subscribed_at, '0'), COALESCE(s.last_engagement_at, '0')) < :thresholdDate
JOIN $subscriberSegmentTable as ss ON ns.subscriber_id = ss.subscriber_id
AND ss.segment_id = :segmentId
AND ss.status = :subscribed
WHERE ns.sent_at > :thresholdDate
AND ns.sent_at < :upperThresholdDate
AND ns.subscriber_id NOT IN (
SELECT DISTINCT subscriber_id as id FROM $newsletterStatsTable WHERE newsletter_id = :newsletterId AND sent_at > :thresholdDate
);
";
$statement = $this->entityManager->getConnection()->prepare($query);
$statement->bindValue('now', $nowSql, ParameterType::STRING);
$statement->bindValue('taskId', $taskId, ParameterType::INTEGER);
$statement->bindValue('subscribed', $subscribedStatus, ParameterType::STRING);
$statement->bindValue('thresholdDate', $thresholdDateSql, ParameterType::STRING);
$statement->bindValue('upperThresholdDate', $upperThresholdDate, ParameterType::STRING);
$statement->bindValue('newsletterId', $newsletterId, ParameterType::INTEGER);
$statement->bindValue('segmentId', $segmentId, ParameterType::INTEGER);
$result = $statement->executeQuery();
return $result->rowCount();
}
}
@@ -0,0 +1,112 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class Scheduler {
const MYSQL_TIMESTAMP_MAX = '2038-01-19 03:14:07';
/** @var WPFunctions */
private $wp;
/** @var NewslettersRepository */
private $newslettersRepository;
public function __construct(
WPFunctions $wp,
NewslettersRepository $newslettersRepository
) {
$this->wp = $wp;
$this->newslettersRepository = $newslettersRepository;
}
/**
* @return string|false
*/
public function getNextRunDate($schedule) {
$nextRunDateTime = $this->getNextRunDateTime($schedule);
return $nextRunDateTime ? $nextRunDateTime->format('Y-m-d H:i:s') : $nextRunDateTime;
}
public function getPreviousRunDate($schedule) {
// User enters time in WordPress site timezone, but we need to calculate it in UTC before we save it to DB
// 1) As the initial time we use time in site timezone via current_datetime
// 2) We use CronExpression to calculate previous run (still in site's timezone)
// 3) We convert the calculated time to UTC
$from = $this->wp->currentDatetime();
try {
$schedule = new \Cron\CronExpression((string)$schedule);
$previousRunDate = $schedule->getPreviousRunDate(Carbon::instance($from));
$previousRunDate->setTimezone(new \DateTimeZone('UTC'));
$previousRunDate = $previousRunDate->format('Y-m-d H:i:s');
} catch (\Exception $e) {
$previousRunDate = false;
}
return $previousRunDate;
}
public function getScheduledTimeWithDelay($afterTimeType, $afterTimeNumber): Carbon {
$currentTime = Carbon::now()->millisecond(0);
switch ($afterTimeType) {
case 'minutes':
$currentTime->addMinutes($afterTimeNumber);
break;
case 'hours':
$currentTime->addHours($afterTimeNumber);
break;
case 'days':
$currentTime->addDays($afterTimeNumber);
break;
case 'weeks':
$currentTime->addWeeks($afterTimeNumber);
break;
}
$maxScheduledTime = Carbon::createFromFormat('Y-m-d H:i:s', self::MYSQL_TIMESTAMP_MAX);
if ($maxScheduledTime && $currentTime > $maxScheduledTime) {
return $maxScheduledTime;
}
return $currentTime;
}
/**
* @return NewsletterEntity[]
*/
public function getNewsletters(string $type, ?string $group = null): array {
return $this->newslettersRepository->findActiveByTypeAndGroup($type, $group);
}
public function formatDatetimeString($datetimeString) {
return Carbon::parse($datetimeString)->format('Y-m-d H:i:s');
}
/**
* @return \DateTime|false
*/
public function getNextRunDateTime($schedule) {
// User enters time in WordPress site timezone, but we need to calculate it in UTC before we save it to DB
// 1) As the initial time we use time in site timezone via current_datetime
// 2) We use CronExpression to calculate next run (still in site's timezone)
// 3) We convert the calculated time to UTC
//$fromTimestamp = $this->wp->currentTime('timestamp', false);
$from = $this->wp->currentDatetime();
try {
$schedule = new \Cron\CronExpression((string)$schedule);
$nextRunDate = $schedule->getNextRunDate(Carbon::instance($from));
$nextRunDate->setTimezone(new \DateTimeZone('UTC'));
// Work around CronExpression transforming Carbon into DateTime
if (!$nextRunDate instanceof Carbon) {
$nextRunDate = new Carbon($nextRunDate);
}
} catch (\Exception $e) {
$nextRunDate = false;
}
return $nextRunDate;
}
}
@@ -0,0 +1,155 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Newsletter\Scheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WelcomeScheduler {
const WORDPRESS_ALL_ROLES = 'mailpoet_all';
/** @var EntityManager */
private $entityManager;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var Scheduler */
private $scheduler;
public function __construct(
EntityManager $entityManager,
SubscribersRepository $subscribersRepository,
SegmentsRepository $segmentsRepository,
NewslettersRepository $newslettersRepository,
ScheduledTasksRepository $scheduledTasksRepository,
Scheduler $scheduler
) {
$this->entityManager = $entityManager;
$this->subscribersRepository = $subscribersRepository;
$this->segmentsRepository = $segmentsRepository;
$this->newslettersRepository = $newslettersRepository;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->scheduler = $scheduler;
}
public function scheduleSubscriberWelcomeNotification($subscriberId, $segments): void {
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_WELCOME]);
foreach ($newsletters as $newsletter) {
if (
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'segment' &&
in_array($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEGMENT), $segments)
) {
$this->createWelcomeNotificationSendingTask($newsletter, $subscriberId);
}
}
}
public function scheduleWPUserWelcomeNotification(
$subscriberId,
$wpUser,
$oldUserData = false
) {
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_WELCOME]);
if (empty($newsletters)) return false;
foreach ($newsletters as $newsletter) {
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== 'user') {
continue;
}
$newsletterRole = $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_ROLE);
if (!empty($oldUserData['roles'])) {
// do not schedule welcome newsletter if roles have not changed
$oldRole = $oldUserData['roles'];
$newRole = $wpUser['roles'];
if (
$newsletterRole === self::WORDPRESS_ALL_ROLES ||
!array_diff($newRole, $oldRole)
) {
continue;
}
}
if (
$newsletterRole === self::WORDPRESS_ALL_ROLES ||
in_array($newsletterRole, $wpUser['roles'])
) {
$this->createWelcomeNotificationSendingTask($newsletter, $subscriberId);
}
}
}
public function createWelcomeNotificationSendingTask(NewsletterEntity $newsletter, $subscriberId): void {
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
if (!($subscriber instanceof SubscriberEntity) || $subscriber->getDeletedAt() !== null) {
return;
}
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'segment') {
$segment = $this->segmentsRepository->findOneById((int)$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEGMENT));
if ((!$segment instanceof SegmentEntity) || $segment->getDeletedAt() !== null) {
return;
}
}
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'user') {
$segment = $this->segmentsRepository->getWPUsersSegment();
if ((!$segment instanceof SegmentEntity) || $segment->getDeletedAt() !== null) {
return;
}
}
$previouslyScheduledNotification = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($newsletter, $subscriberId);
if (!empty($previouslyScheduledNotification)) {
return;
}
// task
$task = new ScheduledTaskEntity();
$task->setType(SendingQueue::TASK_TYPE);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$task->setPriority(ScheduledTaskEntity::PRIORITY_HIGH);
$task->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
));
$this->entityManager->persist($task);
// queue
$queue = new SendingQueueEntity();
$queue->setTask($task);
$queue->setNewsletter($newsletter);
// Because we changed the way how to updateCounts after sending we need to set initial counts
$queue->setCountTotal(1);
$queue->setCountToProcess(1);
$task->setSendingQueue($queue);
$this->entityManager->persist($queue);
// task subscriber
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
$task->getSubscribers()->add($taskSubscriber);
$this->entityManager->persist($taskSubscriber);
$this->entityManager->flush();
}
}
@@ -0,0 +1 @@
<?php