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,45 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\ActionScheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\WP\Functions as WPFunctions;
class ActionScheduler {
public const GROUP_ID = 'mailpoet-cron';
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public function scheduleRecurringAction(int $timestamp, int $interval_in_seconds, string $hook, array $args = [], bool $unique = true): int {
$result = as_schedule_recurring_action($timestamp, $interval_in_seconds, $hook, $args, self::GROUP_ID, $unique);
return is_int($result) ? $result : 0;
}
public function scheduleImmediateSingleAction(string $hook, array $args = [], bool $unique = true): int {
$result = as_schedule_single_action($this->wp->currentTime('timestamp', true), $hook, $args, self::GROUP_ID, $unique);
return is_int($result) ? $result : 0;
}
public function unscheduleAction(string $hook, array $args = []): ?int {
$id = as_unschedule_action($hook, $args, self::GROUP_ID);
return $id !== null ? intval($id) : null;
}
public function unscheduleAllCronActions(): void {
// Passing only group to unschedule all by group
as_unschedule_all_actions('', [], self::GROUP_ID);
}
public function hasScheduledAction(string $hook, array $args = []): bool {
return as_has_scheduled_action($hook, $args, self::GROUP_ID);
}
}
@@ -0,0 +1,127 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\ActionScheduler\Actions;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\ActionScheduler\ActionScheduler;
use MailPoet\Cron\ActionScheduler\RemoteExecutorHandler;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\Daemon;
use MailPoet\Cron\Triggers\WordPress;
use MailPoet\Logging\LoggerFactory;
use MailPoet\WP\Functions as WPFunctions;
class DaemonRun {
const NAME = 'mailpoet/cron/daemon-run';
const EXECUTION_LIMIT_MARGIN = 10; // 10 seconds
const SHORT_DURATION_THRESHOLD = 2; // 2 seconds
/** @var WPFunctions */
private $wp;
/** @var Daemon */
private $daemon;
/** @var WordPress */
private $wordpressTrigger;
/** @var CronHelper */
private $cronHelper;
/** @var RemoteExecutorHandler */
private $remoteExecutorHandler;
/** @var ActionScheduler */
private $actionScheduler;
/** @var LoggerFactory */
private $loggerFactory;
/**
* Default 20 seconds
* @var float
*/
private $remainingExecutionLimit = 20;
/** @var int */
private $lastRunDuration = 0;
public function __construct(
WPFunctions $wp,
Daemon $daemon,
WordPress $wordpressTrigger,
CronHelper $cronHelper,
RemoteExecutorHandler $remoteExecutorHandler,
ActionScheduler $actionScheduler,
LoggerFactory $loggerFactory
) {
$this->wp = $wp;
$this->daemon = $daemon;
$this->wordpressTrigger = $wordpressTrigger;
$this->cronHelper = $cronHelper;
$this->remoteExecutorHandler = $remoteExecutorHandler;
$this->actionScheduler = $actionScheduler;
$this->loggerFactory = $loggerFactory;
}
public function init(): void {
$this->wp->addAction(self::NAME, [$this, 'process']);
$this->wp->addFilter('action_scheduler_maximum_execution_time_likely_to_be_exceeded', [$this, 'storeRemainingExecutionLimit'], 10, 5);
}
/**
* Run daemon that processes scheduled tasks for limited time
*/
public function process(): void {
$this->wp->addAction('action_scheduler_after_process_queue', [$this, 'afterProcess']);
$this->wp->addAction('mailpoet_cron_get_execution_limit', [$this, 'getDaemonExecutionLimit']);
$this->lastRunDuration = 0;
$startTime = time();
$this->daemon->run($this->cronHelper->createDaemon($this->cronHelper->createToken()));
$this->lastRunDuration = time() - $startTime;
}
/**
* Callback for setting the remaining execution time for the cron daemon (MailPoet\Cron\Daemon)
*/
public function getDaemonExecutionLimit(): float {
return $this->remainingExecutionLimit;
}
/**
* After Action Scheduler finishes its work we need to check if there is more work and in case there is we trigger additional runner.
*/
public function afterProcess(): void {
$hasJobsToDo = $this->wordpressTrigger->checkExecutionRequirements();
if (!$hasJobsToDo) {
return;
}
// The $lastDurationWasTooShort check prevents scheduling the next immediate action in case the last run was suspiciously short.
// If there was still some execution time left, the daemon should have been continued.
$lastDurationWasTooShort = ($this->lastRunDuration < self::SHORT_DURATION_THRESHOLD) && ($this->remainingExecutionLimit > 0);
if ($lastDurationWasTooShort) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_CRON)->info('Daemon run ended too early!', [
'duration' => $this->lastRunDuration,
'remainingLimit' => $this->remainingExecutionLimit,
]);
return;
}
$this->actionScheduler->scheduleImmediateSingleAction(self::NAME);
// Chaining async requests can crash MySQL. A brief sleep call in PHP prevents that.
// @see https://github.com/woocommerce/action-scheduler/blob/6633378283d33746eec7314586783f58deee5375/classes/ActionScheduler_AsyncRequest_QueueRunner.php#L91-L96
sleep(2);
$this->remoteExecutorHandler->triggerExecutor();
}
/**
* This method is hooked into action_scheduler_maximum_execution_time_likely_to_be_exceeded
* It checks how much execution time is left for the daemon to run
*/
public function storeRemainingExecutionLimit($likelyExceeded, $runner, $processedActions, $executionTime, $maxExecutionTime): bool {
$newLimit = ($maxExecutionTime - $executionTime) - self::EXECUTION_LIMIT_MARGIN;
$this->remainingExecutionLimit = max($newLimit, 0);
return (bool)$likelyExceeded;
}
}
@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\ActionScheduler\Actions;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\ActionScheduler\ActionScheduler;
use MailPoet\Cron\ActionScheduler\RemoteExecutorHandler;
use MailPoet\Cron\Triggers\WordPress;
use MailPoet\WP\Functions as WPFunctions;
class DaemonTrigger {
const NAME = 'mailpoet/cron/daemon-trigger';
const TRIGGER_RUN_INTERVAL = 120; // 2 minutes
/** @var WPFunctions */
private $wp;
/** @var WordPress */
private $wordpressTrigger;
/** @var RemoteExecutorHandler */
private $remoteExecutorHandler;
/** @var ActionScheduler */
private $actionScheduler;
public function __construct(
WPFunctions $wp,
WordPress $wordpressTrigger,
RemoteExecutorHandler $remoteExecutorHandler,
ActionScheduler $actionScheduler
) {
$this->wp = $wp;
$this->wordpressTrigger = $wordpressTrigger;
$this->remoteExecutorHandler = $remoteExecutorHandler;
$this->actionScheduler = $actionScheduler;
}
public function init() {
$this->wp->addAction(self::NAME, [$this, 'process']);
if (!$this->actionScheduler->hasScheduledAction(self::NAME)) {
$this->actionScheduler->scheduleRecurringAction($this->wp->currentTime('timestamp', true), self::TRIGGER_RUN_INTERVAL, self::NAME);
}
}
/**
* It checks if there are scheduled tasks to execute.
* In case there are tasks to do, it schedules a daemon-run action.
*/
public function process(): void {
$hasJobsToDo = $this->wordpressTrigger->checkExecutionRequirements();
if (!$hasJobsToDo) {
$this->actionScheduler->unscheduleAction(DaemonRun::NAME);
return;
}
if ($this->actionScheduler->hasScheduledAction(DaemonRun::NAME)) {
return;
}
// Schedule immediate action for execution of the daemon
$this->actionScheduler->scheduleImmediateSingleAction(DaemonRun::NAME);
$this->remoteExecutorHandler->triggerExecutor();
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,68 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\ActionScheduler;
if (!defined('ABSPATH')) exit;
use MailPoet\Util\Helpers;
use MailPoet\WP\Functions as WPFunctions;
class RemoteExecutorHandler {
const AJAX_ACTION_NAME = 'mailpoet-cron-action-scheduler-run';
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public function init(): void {
$this->wp->addAction('wp_ajax_nopriv_' . self::AJAX_ACTION_NAME, [$this, 'runActionScheduler'], 0);
}
/**
* Attempts to spawn Action Scheduler runner via ajax request
* @see https://actionscheduler.org/perf/#increasing-initialisation-rate-of-runners
*/
public function triggerExecutor(): void {
$this->wp->addFilter('https_local_ssl_verify', '__return_false', 100);
$this->wp->wpRemotePost($this->wp->adminUrl('admin-ajax.php'), [
'method' => 'POST',
'timeout' => 5,
'redirection' => 5,
'httpversion' => '1.0',
'blocking' => false,
'headers' => [],
'body' => [
'action' => self::AJAX_ACTION_NAME,
],
'cookies' => [],
]);
}
public function runActionScheduler(): void {
try {
$this->wp->addFilter('action_scheduler_queue_runner_concurrent_batches', [$this, 'ensureConcurrency']);
\ActionScheduler_QueueRunner::instance()->run();
wp_die();
} catch (\Exception $e) {
$mySqlGoneAwayMessage = Helpers::mySqlGoneAwayExceptionHandler($e);
if ($mySqlGoneAwayMessage) {
throw new \Exception($mySqlGoneAwayMessage, 0, $e);
}
throw $e;
}
}
/**
* When triggering new runner at the end of a runner execution
* we need to make sure the concurrency allows more one runner.
*/
public function ensureConcurrency(int $concurrency): int {
return ($concurrency) < 2 ? 2 : $concurrency;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,240 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Router\Endpoints\CronDaemon as CronDaemonEndpoint;
use MailPoet\Router\Router;
use MailPoet\Settings\SettingsController;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
class CronHelper {
const DAEMON_EXECUTION_LIMIT = 20; // seconds
const DAEMON_REQUEST_TIMEOUT = 5; // seconds
const DAEMON_SETTING = 'cron_daemon';
const DAEMON_STATUS_ACTIVE = 'active';
const DAEMON_STATUS_INACTIVE = 'inactive';
// Error codes
const DAEMON_EXECUTION_LIMIT_REACHED = 1001;
/** @var SettingsController */
private $settings;
/** @var WPFunctions */
private $wp;
public function __construct(
SettingsController $settings,
WPFunctions $wp
) {
$this->settings = $settings;
$this->wp = $wp;
}
public function getDaemonExecutionLimit() {
$limit = $this->wp->applyFilters('mailpoet_cron_get_execution_limit', self::DAEMON_EXECUTION_LIMIT);
return $limit;
}
public function getDaemonExecutionTimeout() {
$limit = $this->getDaemonExecutionLimit();
$timeout = $limit * 1.75;
return $this->wp->applyFilters('mailpoet_cron_get_execution_timeout', $timeout);
}
public function createDaemon($token) {
$daemon = [
'token' => $token,
'status' => self::DAEMON_STATUS_ACTIVE,
'run_accessed_at' => null,
'run_started_at' => null,
'run_completed_at' => null,
'last_error' => null,
'last_error_date' => null,
];
$this->saveDaemon($daemon);
return $daemon;
}
public function restartDaemon($token) {
return $this->createDaemon($token);
}
public function getDaemon() {
return $this->settings->fetch(self::DAEMON_SETTING);
}
public function saveDaemonLastError($error) {
$daemon = $this->getDaemon();
if ($daemon) {
$daemon['last_error'] = $error;
$daemon['last_error_date'] = time();
$this->saveDaemon($daemon);
}
}
public function saveDaemonRunCompleted($runCompletedAt) {
$daemon = $this->getDaemon();
if ($daemon) {
$daemon['run_completed_at'] = $runCompletedAt;
$this->saveDaemon($daemon);
}
}
public function saveDaemon($daemon) {
$daemon['updated_at'] = time();
$this->settings->set(
self::DAEMON_SETTING,
$daemon
);
}
public function deactivateDaemon($daemon) {
// We do not need to deactivate an inactive daemon
if (isset($daemon['status']) && $daemon['status'] === self::DAEMON_STATUS_INACTIVE) {
return;
}
$daemon['status'] = self::DAEMON_STATUS_INACTIVE;
$this->settings->set(
self::DAEMON_SETTING,
$daemon
);
}
public function createToken() {
return Security::generateRandomString();
}
public function pingDaemon() {
$url = $this->getCronUrl(
CronDaemonEndpoint::ACTION_PING_RESPONSE
);
$result = $this->queryCronUrl($url);
if (is_wp_error($result)) return $result->get_error_message();
$response = $this->wp->wpRemoteRetrieveBody($result);
$response = substr(trim($response), -strlen(DaemonHttpRunner::PING_SUCCESS_RESPONSE)) === DaemonHttpRunner::PING_SUCCESS_RESPONSE ?
DaemonHttpRunner::PING_SUCCESS_RESPONSE :
$response;
return $response;
}
public function validatePingResponse($response) {
return $response === DaemonHttpRunner::PING_SUCCESS_RESPONSE;
}
public function accessDaemon($token) {
$data = ['token' => $token];
$url = $this->getCronUrl(
CronDaemonEndpoint::ACTION_RUN,
$data
);
$daemon = $this->getDaemon();
if (!$daemon) {
throw new \LogicException('Daemon does not exist.');
}
$daemon['run_accessed_at'] = time();
$this->saveDaemon($daemon);
$result = $this->queryCronUrl($url);
return $this->wp->wpRemoteRetrieveBody($result);
}
/**
* @return bool|null
*/
public function isDaemonAccessible() {
$daemon = $this->getDaemon();
if (!$daemon || !isset($daemon['run_accessed_at'])) {
return null;
}
if ($daemon['run_accessed_at'] <= (int)$daemon['run_started_at']) {
return true;
}
if (
$daemon['run_accessed_at'] + self::DAEMON_REQUEST_TIMEOUT < time() &&
$daemon['run_accessed_at'] > (int)$daemon['run_started_at']
) {
return false;
}
return null;
}
public function queryCronUrl($url) {
$args = $this->wp->applyFilters(
'mailpoet_cron_request_args',
[
'blocking' => true,
'sslverify' => false,
'timeout' => self::DAEMON_REQUEST_TIMEOUT,
'user-agent' => 'MailPoet Cron',
]
);
return $this->wp->wpRemotePost($url, $args);
}
public function getCronUrl($action, $data = false) {
$url = Router::buildRequest(
CronDaemonEndpoint::ENDPOINT,
$action,
$data
);
$customCronUrl = $this->wp->applyFilters('mailpoet_cron_request_url', $url);
return ($customCronUrl === $url) ?
str_replace(home_url(), $this->getSiteUrl(), $url) :
$customCronUrl;
}
public function getSiteUrl($siteUrl = false) {
// additional check for some sites running inside a virtual machine or behind
// proxy where there could be different ports (e.g., host:8080 => guest:80)
if (!$siteUrl) {
$siteUrl = defined('MAILPOET_CRON_SITE_URL') ? MAILPOET_CRON_SITE_URL : $this->wp->homeUrl();
}
$parsedUrl = parse_url($siteUrl);
if (!is_array($parsedUrl)) {
throw new \Exception(__('Site URL is unreachable.', 'mailpoet'));
}
$callScheme = '';
if (isset($parsedUrl['scheme']) && ($parsedUrl['scheme'] === 'https')) {
$callScheme = 'ssl://';
}
// 1. if site URL does not contain a port, return the URL
if (!isset($parsedUrl['port']) || empty($parsedUrl['port'])) return $siteUrl;
// 2. if site URL contains valid port, try connecting to it
$urlHost = $parsedUrl['host'] ?? '';
$fp = @fsockopen($callScheme . $urlHost, $parsedUrl['port'], $errno, $errstr, 1);
if ($fp) return $siteUrl;
// 3. if connection fails, attempt to connect the standard port derived from URL
// schema
$urlScheme = $parsedUrl['scheme'] ?? '';
$port = (strtolower($urlScheme) === 'http') ? 80 : 443;
$fp = @fsockopen($callScheme . $urlHost, $port, $errno, $errstr, 1);
if ($fp) return sprintf('%s://%s', $urlScheme, $urlHost);
// 4. throw an error if all connection attempts failed
throw new \Exception(__('Site URL is unreachable.', 'mailpoet'));
}
public function enforceExecutionLimit($timer) {
$elapsedTime = microtime(true) - $timer;
$limit = $this->getDaemonExecutionLimit();
if ($elapsedTime >= $limit) {
throw new \Exception(
sprintf(
// translators: %1$d is the number of seconds the daemon is allowed to run, %2$d is how many more seconds the daemon did run.
__(
'The maximum execution time of %1$d seconds was exceeded by %2$d seconds. This task will resume during the next run.',
'mailpoet'
),
(int)round($limit),
(int)round($elapsedTime - $limit)
),
self::DAEMON_EXECUTION_LIMIT_REACHED
);
}
}
}
@@ -0,0 +1,78 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Triggers\WordPress;
use MailPoet\Settings\SettingsController;
use MailPoet\Util\Helpers;
class CronTrigger {
const METHOD_LINUX_CRON = 'Linux Cron';
const METHOD_WORDPRESS = 'WordPress';
const METHOD_ACTION_SCHEDULER = 'Action Scheduler';
const METHODS = [
'wordpress' => self::METHOD_WORDPRESS,
'linux_cron' => self::METHOD_LINUX_CRON,
'action_scheduler' => self::METHOD_ACTION_SCHEDULER,
'none' => 'Disabled',
];
const DEFAULT_METHOD = self::METHOD_ACTION_SCHEDULER;
const SETTING_NAME = 'cron_trigger';
const SETTING_CURRENT_METHOD = self::SETTING_NAME . '.method';
/** @var WordPress */
private $wordpressTrigger;
/** @var SettingsController */
private $settings;
/** @var DaemonActionSchedulerRunner */
private $cronActionScheduler;
public function __construct(
WordPress $wordpressTrigger,
SettingsController $settings,
DaemonActionSchedulerRunner $cronActionScheduler
) {
$this->wordpressTrigger = $wordpressTrigger;
$this->settings = $settings;
$this->cronActionScheduler = $cronActionScheduler;
}
public function init(string $environment = '') {
$currentMethod = $this->settings->get(self::SETTING_CURRENT_METHOD);
try {
$this->cronActionScheduler->init($currentMethod === self::METHOD_ACTION_SCHEDULER);
// setup WordPress cron method trigger only outside of cli environment
if ($currentMethod === self::METHOD_WORDPRESS && $environment !== 'cli') {
return $this->wordpressTrigger->run();
}
return false;
} catch (\Exception $e) {
// cron exceptions should not prevent the rest of the site from loading
Helpers::mySqlGoneAwayExceptionHandler($e);
}
}
public function disable() {
$currentMethod = $this->settings->get(self::SETTING_CURRENT_METHOD);
try {
if ($currentMethod === self::METHOD_ACTION_SCHEDULER) {
// deactivate the action Scheduler
$this->cronActionScheduler->deactivate();
}
if ($currentMethod === self::METHOD_WORDPRESS) {
// deactivate WordPress cron method trigger
$this->wordpressTrigger->stop();
}
} catch (\Exception $e) {
// cron exceptions should not prevent the rest of the site from loading
}
}
}
@@ -0,0 +1,41 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
interface CronWorkerInterface {
/** @return string */
public function getTaskType();
/** @return bool */
public function scheduleAutomatically();
/** @return bool */
public function supportsMultipleInstances();
/** @return bool */
public function checkProcessingRequirements();
public function init();
/**
* @param ScheduledTaskEntity $task
* @param float $timer
* @return bool
*/
public function prepareTaskStrategy(ScheduledTaskEntity $task, $timer);
/**
* @param ScheduledTaskEntity $task
* @param float $timer
* @return bool
*/
public function processTaskStrategy(ScheduledTaskEntity $task, $timer);
/** @return \DateTimeInterface */
public function getNextRunDate();
}
@@ -0,0 +1,191 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoetVendor\Carbon\Carbon;
class CronWorkerRunner {
const TASK_BATCH_SIZE = 5;
const TASK_RUN_TIMEOUT = 120;
const TIMED_OUT_TASK_RESCHEDULE_TIMEOUT = 5;
/** @var float */
private $timer;
/** @var CronHelper */
private $cronHelper;
/** @var CronWorkerScheduler */
private $cronWorkerScheduler;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var LoggerFactory */
private $loggerFactory;
public function __construct(
CronHelper $cronHelper,
CronWorkerScheduler $cronWorkerScheduler,
ScheduledTasksRepository $scheduledTasksRepository,
LoggerFactory $loggerFactory
) {
$this->timer = microtime(true);
$this->cronHelper = $cronHelper;
$this->cronWorkerScheduler = $cronWorkerScheduler;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->loggerFactory = $loggerFactory;
}
public function run(CronWorkerInterface $worker) {
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($this->timer);
$dueTasks = $this->getDueTasks($worker);
$runningTasks = $this->getRunningTasks($worker);
if (!$worker->checkProcessingRequirements()) {
foreach (array_merge($dueTasks, $runningTasks) as $task) {
$this->scheduledTasksRepository->remove($task);
$this->scheduledTasksRepository->flush();
}
return false;
}
$worker->init();
if (!$dueTasks && !$runningTasks) {
if ($worker->scheduleAutomatically()) {
$this->cronWorkerScheduler->schedule($worker->getTaskType(), $worker->getNextRunDate());
}
return false;
}
try {
foreach ($dueTasks as $task) {
$this->prepareTask($worker, $task);
}
// Re-fetch running tasks so that we can process tasks that were just prepared
$runningTasks = $this->getRunningTasks($worker);
foreach ($runningTasks as $task) {
$this->processTask($worker, $task);
}
} catch (\Exception $e) {
if (isset($task) && $task && $e->getCode() !== CronHelper::DAEMON_EXECUTION_LIMIT_REACHED) {
/**
* ToDo: Use \LoggerFactory::TOPIC_CRON as logger topic, once it is available
*/
$this->loggerFactory->getLogger()->error($e->getMessage(), ['error' => $e]);
$this->cronWorkerScheduler->rescheduleProgressively($task);
}
throw $e;
}
return true;
}
private function getDueTasks(CronWorkerInterface $worker) {
return $this->scheduledTasksRepository->findDueByType($worker->getTaskType(), self::TASK_BATCH_SIZE);
}
private function getRunningTasks(CronWorkerInterface $worker) {
return $this->scheduledTasksRepository->findRunningByType($worker->getTaskType(), self::TASK_BATCH_SIZE);
}
private function prepareTask(CronWorkerInterface $worker, ScheduledTaskEntity $task) {
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($this->timer);
$prepareCompleted = $worker->prepareTaskStrategy($task, $this->timer);
if ($prepareCompleted) {
$task->setStatus(null);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
}
}
private function processTask(CronWorkerInterface $worker, ScheduledTaskEntity $task) {
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($this->timer);
if (!$worker->supportsMultipleInstances()) {
if ($this->rescheduleOutdated($task)) {
return false;
}
if ($this->isInProgress($task)) {
return false;
}
}
$this->startProgress($task);
try {
$completed = $worker->processTaskStrategy($task, $this->timer);
} catch (\Exception $e) {
$this->stopProgress($task);
throw $e;
}
if ($completed) {
$this->complete($task);
}
$this->stopProgress($task);
return (bool)$completed;
}
private function rescheduleOutdated(ScheduledTaskEntity $task) {
$currentTime = Carbon::now()->millisecond(0);
if (empty($task->getUpdatedAt())) {
// missing updatedAt, consider this task outdated (set year to 2000) and reschedule
$updatedAt = Carbon::createFromDate(2000);
} else if (!$task->getUpdatedAt() instanceof Carbon) {
$updatedAt = new Carbon($task->getUpdatedAt());
} else {
$updatedAt = $task->getUpdatedAt();
}
// If the task is running for too long consider it stuck and reschedule
if (!empty($task->getUpdatedAt()) && $updatedAt->diffInMinutes($currentTime, false) > self::TASK_RUN_TIMEOUT) {
$this->stopProgress($task);
$this->cronWorkerScheduler->reschedule($task, self::TIMED_OUT_TASK_RESCHEDULE_TIMEOUT);
return true;
}
return false;
}
private function isInProgress(ScheduledTaskEntity $task) {
if ($task->getInProgress()) {
// Do not run multiple instances of the task
return true;
}
return false;
}
private function startProgress(ScheduledTaskEntity $task) {
$task->setInProgress(true);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
}
private function stopProgress(ScheduledTaskEntity $task) {
$task->setInProgress(false);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
}
private function complete(ScheduledTaskEntity $task) {
$task->setProcessedAt(Carbon::now()->millisecond(0));
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
}
}
@@ -0,0 +1,74 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoetVendor\Carbon\Carbon;
class CronWorkerScheduler {
/** @var ScheduledTasksRepository */
private $scheduledTaskRepository;
public function __construct(
ScheduledTasksRepository $scheduledTaskRepository
) {
$this->scheduledTaskRepository = $scheduledTaskRepository;
}
public function scheduleImmediatelyIfNotRunning($taskType, $priority = ScheduledTaskEntity::PRIORITY_LOW): ScheduledTaskEntity {
$task = $this->scheduledTaskRepository->findScheduledOrRunningTask($taskType);
// Do nothing when task is running
if (($task instanceof ScheduledTaskEntity) && $task->getStatus() === null) {
return $task;
}
$now = Carbon::now()->millisecond(0);
// Reschedule existing scheduled task
if ($task instanceof ScheduledTaskEntity) {
$task->setScheduledAt($now);
$task->setPriority($priority);
$this->scheduledTaskRepository->flush();
}
// Schedule new task
return $this->schedule($taskType, $now, $priority);
}
public function schedule($taskType, $nextRunDate, $priority = ScheduledTaskEntity::PRIORITY_LOW): ScheduledTaskEntity {
$alreadyScheduled = $this->scheduledTaskRepository->findScheduledTask($taskType);
if ($alreadyScheduled) {
return $alreadyScheduled;
}
$task = new ScheduledTaskEntity();
$task->setType($taskType);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$task->setPriority($priority);
$task->setScheduledAt($nextRunDate);
$this->scheduledTaskRepository->persist($task);
$this->scheduledTaskRepository->flush();
return $task;
}
public function reschedule(ScheduledTaskEntity $task, $timeout) {
$scheduledAt = Carbon::now()->millisecond(0);
$task->setScheduledAt($scheduledAt->addMinutes($timeout));
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$this->scheduledTaskRepository->persist($task);
$this->scheduledTaskRepository->flush();
}
public function rescheduleProgressively(ScheduledTaskEntity $task): int {
$scheduledAt = Carbon::now()->millisecond(0);
$rescheduleCount = $task->getRescheduleCount();
$timeout = (int)min(ScheduledTaskEntity::BASIC_RESCHEDULE_TIMEOUT * pow(2, $rescheduleCount), ScheduledTaskEntity::MAX_RESCHEDULE_TIMEOUT);
$task->setScheduledAt($scheduledAt->addMinutes($timeout));
$task->setRescheduleCount($rescheduleCount + 1);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$this->scheduledTaskRepository->persist($task);
$this->scheduledTaskRepository->flush();
return $timeout;
}
}
@@ -0,0 +1,119 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\WorkersFactory;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class Daemon {
public $timer;
/** @var CronHelper */
private $cronHelper;
/** @var CronWorkerRunner */
private $cronWorkerRunner;
/** @var EntityManager */
private $entityManager;
/** @var WorkersFactory */
private $workersFactory;
/** @var LoggerFactory */
private $loggerFactory;
public function __construct(
CronHelper $cronHelper,
CronWorkerRunner $cronWorkerRunner,
EntityManager $entityManager,
WorkersFactory $workersFactory,
LoggerFactory $loggerFactory
) {
$this->timer = microtime(true);
$this->workersFactory = $workersFactory;
$this->cronWorkerRunner = $cronWorkerRunner;
$this->entityManager = $entityManager;
$this->cronHelper = $cronHelper;
$this->loggerFactory = $loggerFactory;
}
public function run($settingsDaemonData) {
$settingsDaemonData['run_started_at'] = time();
$this->cronHelper->saveDaemon($settingsDaemonData);
$errors = [];
foreach ($this->getWorkers() as $worker) {
if (wp_is_maintenance_mode()) {
// stop execution when in maintenance mode
break;
}
try {
// Clear the entity manager memory for every cron run.
// This avoids using stale data and prevents memory leaks.
$this->entityManager->clear();
if ($worker instanceof CronWorkerInterface) {
$this->cronWorkerRunner->run($worker);
} else {
$worker->process($this->timer); // BC for workers not implementing CronWorkerInterface
}
} catch (\Exception $e) {
Helpers::mySqlGoneAwayExceptionHandler($e);
$workerClassNameParts = explode('\\', get_class($worker));
$workerName = end($workerClassNameParts);
$errors[] = [
'worker' => $workerName,
'message' => $e->getMessage(),
];
if ($e->getCode() === CronHelper::DAEMON_EXECUTION_LIMIT_REACHED) {
break;
}
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_CRON)->error($e->getMessage(), ['error' => $e, 'worker' => $workerName]);
}
}
if (!empty($errors)) {
$this->cronHelper->saveDaemonLastError($errors);
}
// Log successful execution
$this->cronHelper->saveDaemonRunCompleted(time());
}
private function getWorkers() {
yield $this->workersFactory->createStatsNotificationsWorker(); // not CronWorkerInterface compatible
yield $this->workersFactory->createScheduleWorker(); // not CronWorkerInterface compatible
yield $this->workersFactory->createQueueWorker(); // not CronWorkerInterface compatible
yield $this->workersFactory->createSendingServiceKeyCheckWorker();
yield $this->workersFactory->createPremiumKeyCheckWorker();
yield $this->workersFactory->createSubscribersStatsReportWorker();
yield $this->workersFactory->createBounceWorker();
yield $this->workersFactory->createExportFilesCleanupWorker();
yield $this->workersFactory->createSubscribersEmailCountsWorker();
yield $this->workersFactory->createInactiveSubscribersWorker();
yield $this->workersFactory->createUnsubscribeTokensWorker();
yield $this->workersFactory->createWooCommerceSyncWorker();
yield $this->workersFactory->createAuthorizedSendingEmailsCheckWorker();
yield $this->workersFactory->createWooCommercePastOrdersWorker();
yield $this->workersFactory->createStatsNotificationsWorkerForAutomatedEmails();
yield $this->workersFactory->createSubscriberLinkTokensWorker();
yield $this->workersFactory->createSubscribersEngagementScoreWorker();
yield $this->workersFactory->createSubscribersLastEngagementWorker();
yield $this->workersFactory->createSubscribersCountCacheRecalculationWorker();
yield $this->workersFactory->createReEngagementEmailsSchedulerWorker();
yield $this->workersFactory->createNewsletterTemplateThumbnailsWorker();
yield $this->workersFactory->createAbandonedCartWorker();
yield $this->workersFactory->createBackfillEngagementDataWorker();
yield $this->workersFactory->createMixpanelWorker();
}
}
@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\ActionScheduler\Actions\DaemonRun;
use MailPoet\Cron\ActionScheduler\Actions\DaemonTrigger;
use MailPoet\Cron\ActionScheduler\ActionScheduler;
use MailPoet\Cron\ActionScheduler\RemoteExecutorHandler;
use MailPoet\WP\Functions as WPFunctions;
class DaemonActionSchedulerRunner {
/** @var ActionScheduler */
private $actionScheduler;
/** @var RemoteExecutorHandler */
private $remoteExecutorHandler;
/** @var DaemonTrigger */
private $daemonTriggerAction;
/** @var DaemonRun */
private $daemonRunAction;
/** @var WPFunctions */
private $wp;
public function __construct(
ActionScheduler $actionScheduler,
RemoteExecutorHandler $remoteExecutorHandler,
DaemonTrigger $daemonTriggerAction,
DaemonRun $daemonRunAction,
WPFunctions $wp
) {
$this->actionScheduler = $actionScheduler;
$this->remoteExecutorHandler = $remoteExecutorHandler;
$this->daemonTriggerAction = $daemonTriggerAction;
$this->daemonRunAction = $daemonRunAction;
$this->wp = $wp;
}
public function init(bool $isActive = true): void {
if (!$isActive) {
$this->deactivateOnTrigger();
return;
}
$this->daemonRunAction->init();
$this->daemonTriggerAction->init();
$this->remoteExecutorHandler->init();
}
public function deactivate(): void {
$this->actionScheduler->unscheduleAllCronActions();
}
/**
* Unschedule all MailPoet actions when next "trigger" action is processed.
* Note: We can't unschedule the actions directly inside the trigger action itself,
* because the action is recurring and would reschedule itself anyway.
* We need do the deactivation after the action scheduler process finishes.
*/
private function deactivateOnTrigger(): void {
$this->wp->addAction(DaemonTrigger::NAME, [$this, 'deactivateAfterProcess']);
}
public function deactivateAfterProcess(): void {
$this->wp->addAction('action_scheduler_after_process_queue', [$this, 'deactivate']);
}
}
@@ -0,0 +1,151 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Triggers\WordPress;
use MailPoet\Settings\SettingsController;
use MailPoet\WP\Functions as WPFunctions;
use Tracy\Debugger;
class DaemonHttpRunner {
public $settingsDaemonData;
public $timer;
public $token;
/** @var Daemon|null */
private $daemon;
/** @var CronHelper */
private $cronHelper;
/** @var SettingsController */
private $settings;
const PING_SUCCESS_RESPONSE = 'pong';
/** @var WordPress */
private $wordpressTrigger;
public function __construct(
Daemon $daemon = null,
CronHelper $cronHelper,
SettingsController $settings,
WordPress $wordpressTrigger
) {
$this->cronHelper = $cronHelper;
$this->settingsDaemonData = $this->cronHelper->getDaemon();
$this->token = $this->cronHelper->createToken();
$this->timer = microtime(true);
$this->daemon = $daemon;
$this->settings = $settings;
$this->wordpressTrigger = $wordpressTrigger;
}
public function ping() {
// if Tracy enabled & called by 'MailPoet Cron' user agent, disable Tracy Bar
// (happens in CronHelperTest because it's not a real integration test - calls other WP instance)
$userAgent = isset($_SERVER['HTTP_USER_AGENT']) ?
sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT']))
: null;
if (class_exists(Debugger::class) && $userAgent === 'MailPoet Cron') {
Debugger::$showBar = false;
}
$this->terminateRequest(self::PING_SUCCESS_RESPONSE);
}
public function run($requestData) {
ignore_user_abort(true);
if (strpos((string)@ini_get('disable_functions'), 'set_time_limit') === false) {
set_time_limit(0);
}
if (!$requestData) {
$error = __('Invalid or missing request data.', 'mailpoet');
} else {
if (!$this->settingsDaemonData) {
$error = __('Daemon does not exist.', 'mailpoet');
} else {
if (
!isset($requestData['token']) ||
$requestData['token'] !== $this->settingsDaemonData['token']
) {
$error = 'Invalid or missing token.';
}
}
}
if (!empty($error)) {
return $this->abortWithError($error);
}
if ($this->daemon === null) {
return $this->abortWithError(__('Daemon does not set correctly.', 'mailpoet'));
}
$this->settingsDaemonData['token'] = $this->token;
$this->daemon->run($this->settingsDaemonData);
// If we're using the WordPress trigger, check the conditions to stop cron if necessary
$enableCronSelfDeactivation = WPFunctions::get()->applyFilters('mailpoet_cron_enable_self_deactivation', false);
if (
$enableCronSelfDeactivation
&& $this->isCronTriggerMethodWordPress()
&& !$this->checkWPTriggerExecutionRequirements()
) {
$this->stopCron();
} else {
// if workers took less time to execute than the daemon execution limit,
// pause daemon execution to ensure that daemon runs only once every X seconds
$elapsedTime = microtime(true) - $this->timer;
if ($elapsedTime < $this->cronHelper->getDaemonExecutionLimit()) {
$this->pauseExecution((int)ceil($this->cronHelper->getDaemonExecutionLimit() - $elapsedTime));
}
}
// after each execution, re-read daemon data in case it changed
$settingsDaemonData = $this->cronHelper->getDaemon();
if ($this->shouldTerminateExecution($settingsDaemonData)) {
return $this->terminateRequest();
}
return $this->callSelf();
}
public function pauseExecution(int $pauseTime) {
return sleep($pauseTime);
}
public function callSelf() {
$this->cronHelper->accessDaemon($this->token);
$this->terminateRequest();
}
public function abortWithError($message) {
WPFunctions::get()->statusHeader(404, $message);
exit;
}
public function terminateRequest($message = false) {
echo esc_html($message);
die();
}
public function isCronTriggerMethodWordPress() {
return $this->settings->get(CronTrigger::SETTING_NAME . '.method') === CronTrigger::METHOD_WORDPRESS;
}
public function checkWPTriggerExecutionRequirements() {
return $this->wordpressTrigger->checkExecutionRequirements();
}
public function stopCron() {
return $this->wordpressTrigger->stop();
}
/**
* @param array|null $settingsDaemonData
*
* @return bool
*/
private function shouldTerminateExecution(array $settingsDaemonData = null) {
return !$settingsDaemonData ||
$settingsDaemonData['token'] !== $this->token ||
(isset($settingsDaemonData['status']) && $settingsDaemonData['status'] !== CronHelper::DAEMON_STATUS_ACTIVE);
}
}
@@ -0,0 +1,54 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron;
if (!defined('ABSPATH')) exit;
class Supervisor {
public $daemon;
public $token;
/** @var CronHelper */
private $cronHelper;
public function __construct(
CronHelper $cronHelper
) {
$this->cronHelper = $cronHelper;
}
public function init() {
$this->token = $this->cronHelper->createToken();
$this->daemon = $this->getDaemon();
}
public function checkDaemon() {
$daemon = $this->daemon;
$updatedAt = $daemon ? (int)$daemon['updated_at'] : 0;
$executionTimeoutExceeded =
(time() - $updatedAt) >= $this->cronHelper->getDaemonExecutionTimeout();
$daemonIsInactive =
isset($daemon['status']) && $daemon['status'] === CronHelper::DAEMON_STATUS_INACTIVE;
if ($executionTimeoutExceeded || $daemonIsInactive) {
$this->cronHelper->restartDaemon($this->token);
return $this->runDaemon();
}
return $daemon;
}
public function runDaemon() {
$this->cronHelper->accessDaemon($this->token);
$daemon = $this->cronHelper->getDaemon();
return $daemon;
}
public function getDaemon() {
$daemon = $this->cronHelper->getDaemon();
if (!$daemon) {
$this->cronHelper->createDaemon($this->token);
return $this->runDaemon();
}
return $daemon;
}
}
@@ -0,0 +1,272 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Triggers;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\ServicesChecker;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\Supervisor;
use MailPoet\Cron\Workers\Bounce as BounceWorker;
use MailPoet\Cron\Workers\KeyCheck\PremiumKeyCheck as PremiumKeyCheckWorker;
use MailPoet\Cron\Workers\KeyCheck\SendingServiceKeyCheck as SendingServiceKeyCheckWorker;
use MailPoet\Cron\Workers\Scheduler as SchedulerWorker;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker;
use MailPoet\Cron\Workers\SubscribersStatsReport;
use MailPoet\Cron\Workers\WorkersFactory;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Mailer\MailerLog;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Services\Bridge;
use MailPoet\Settings\SettingsController;
use MailPoet\Util\Helpers;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class WordPress {
const SCHEDULED_IN_THE_PAST = 'past';
const SCHEDULED_IN_THE_FUTURE = 'future';
const RUN_INTERVAL = -1; // seconds
const LAST_RUN_AT_SETTING = 'cron_trigger_wordpress.last_run_at';
private $tasksCounts;
/** @var CronHelper */
private $cronHelper;
/** @var Supervisor */
private $supervisor;
/** @var SettingsController */
private $settings;
/** @var WPFunctions */
private $wp;
/** @var ServicesChecker */
private $serviceChecker;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var EntityManager */
private $entityManager;
public function __construct(
CronHelper $cronHelper,
Supervisor $supervisor,
SettingsController $settings,
ServicesChecker $serviceChecker,
WPFunctions $wp,
ScheduledTasksRepository $scheduledTasksRepository,
EntityManager $entityManager
) {
$this->supervisor = $supervisor;
$this->settings = $settings;
$this->wp = $wp;
$this->cronHelper = $cronHelper;
$this->serviceChecker = $serviceChecker;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->entityManager = $entityManager;
}
public function run() {
try {
if (!$this->checkRunInterval()) {
return false;
}
if (!$this->checkExecutionRequirements()) {
$this->stop();
return;
}
$this->supervisor->init();
return $this->supervisor->checkDaemon();
} catch (\Exception $e) {
$mySqlGoneAwayMessage = Helpers::mySqlGoneAwayExceptionHandler($e);
if ($mySqlGoneAwayMessage) {
throw new \Exception($mySqlGoneAwayMessage, 0, $e);
}
throw $e;
}
}
private function checkRunInterval(): bool {
$runInterval = $this->wp->applyFilters('mailpoet_cron_trigger_wordpress_run_interval', self::RUN_INTERVAL);
if ($runInterval === -1) {
return true;
}
$lastRunAt = (int)$this->settings->get(self::LAST_RUN_AT_SETTING, 0);
$runIntervalElapsed = (time() - $lastRunAt) >= $runInterval;
if ($runIntervalElapsed) {
$this->settings->set(self::LAST_RUN_AT_SETTING, time());
return true;
}
return false;
}
public static function resetRunInterval(): void {
$settings = SettingsController::getInstance();
$settings->set(self::LAST_RUN_AT_SETTING, 0);
}
public function checkExecutionRequirements(): bool {
if ($this->wp->wpIsMaintenanceMode()) {
// Skip if WP is currently in maintenance mode
// The maintenance mode is activated when WP core or a plugin update is in progress
return false;
}
$this->loadTasksCounts();
// Because a lot of workers has the same pattern for check if it's active we can use a loop here
$isSimpleWorkerActive = false;
foreach (WorkersFactory::SIMPLE_WORKER_TYPES as $simpleWorkerType) {
$tasksCount = $this->getTasksCount([
'type' => $simpleWorkerType,
'scheduled_in' => [self::SCHEDULED_IN_THE_PAST],
'status' => ['null', ScheduledTaskEntity::STATUS_SCHEDULED],
]);
if ($tasksCount) {
$isSimpleWorkerActive = true;
break;
}
}
return (
$this->isSendingQueueActive()
|| $this->isBounceActive()
|| $this->isSendingServiceKeyCheckActive()
|| $this->isPremiumKeyCheckActive()
|| $this->isSubscriberStatsReportActive()
|| $isSimpleWorkerActive
);
}
public function stop() {
$cronDaemon = $this->cronHelper->getDaemon();
if ($cronDaemon) {
$this->cronHelper->deactivateDaemon($cronDaemon);
}
}
private function isSendingQueueActive(): bool {
$scheduledQueues = $this->scheduledTasksRepository->findScheduledSendingTasks(SchedulerWorker::TASK_BATCH_SIZE);
$runningQueues = $this->scheduledTasksRepository->findRunningSendingTasks(SendingQueueWorker::TASK_BATCH_SIZE);
$sendingLimitReached = MailerLog::isSendingLimitReached();
$sendingIsPaused = MailerLog::isSendingPaused();
$sendingWaitingForRetry = MailerLog::isSendingWaitingForRetry();
return (($scheduledQueues || $runningQueues) && !$sendingLimitReached && !$sendingIsPaused && !$sendingWaitingForRetry);
}
private function isBounceActive(): bool {
$mpSendingEnabled = Bridge::isMPSendingServiceEnabled();
$bounceDueTasks = $this->getTasksCount([
'type' => BounceWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_PAST],
'status' => ['null', ScheduledTaskEntity::STATUS_SCHEDULED],
]);
$bounceFutureTasks = $this->getTasksCount([
'type' => BounceWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_FUTURE],
'status' => [ScheduledTaskEntity::STATUS_SCHEDULED],
]);
return ($mpSendingEnabled && ($bounceDueTasks || !$bounceFutureTasks));
}
private function isSendingServiceKeyCheckActive(): bool {
$mpSendingEnabled = Bridge::isMPSendingServiceEnabled();
$msskeycheckDueTasks = $this->getTasksCount([
'type' => SendingServiceKeyCheckWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_PAST],
'status' => ['null', ScheduledTaskEntity::STATUS_SCHEDULED],
]);
$msskeycheckFutureTasks = $this->getTasksCount([
'type' => SendingServiceKeyCheckWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_FUTURE],
'status' => [ScheduledTaskEntity::STATUS_SCHEDULED],
]);
return ($mpSendingEnabled && ($msskeycheckDueTasks || !$msskeycheckFutureTasks));
}
private function isPremiumKeyCheckActive(): bool {
$premiumKeySpecified = Bridge::isPremiumKeySpecified();
$premiumKeycheckDueTasks = $this->getTasksCount([
'type' => PremiumKeyCheckWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_PAST],
'status' => ['null', ScheduledTaskEntity::STATUS_SCHEDULED],
]);
$premiumKeycheckFutureTasks = $this->getTasksCount([
'type' => PremiumKeyCheckWorker::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_FUTURE],
'status' => [ScheduledTaskEntity::STATUS_SCHEDULED],
]);
return ($premiumKeySpecified && ($premiumKeycheckDueTasks || !$premiumKeycheckFutureTasks));
}
private function isSubscriberStatsReportActive(): bool {
$validAccountKey = $this->serviceChecker->getValidAccountKey();
$statsReportDueTasks = $this->getTasksCount([
'type' => SubscribersStatsReport::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_PAST],
'status' => ['null', ScheduledTaskEntity::STATUS_SCHEDULED],
]);
$statsReportFutureTasks = $this->getTasksCount([
'type' => SubscribersStatsReport::TASK_TYPE,
'scheduled_in' => [self::SCHEDULED_IN_THE_FUTURE],
'status' => [ScheduledTaskEntity::STATUS_SCHEDULED],
]);
return ($validAccountKey && ($statsReportDueTasks || !$statsReportFutureTasks));
}
private function loadTasksCounts(): void {
$scheduledTasksTableName = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
$sql = "
SELECT
type,
status,
count(*) AS count,
CASE WHEN scheduled_at <= :now THEN :past ELSE :future END AS scheduled_in
FROM $scheduledTasksTableName
WHERE deleted_at IS NULL AND (status != :statusCompleted OR status IS NULL)
GROUP BY type, status, scheduled_in";
$stmt = $this->entityManager->getConnection()->prepare($sql);
$stmt->bindValue('now', date('Y-m-d H:i:s', $this->wp->currentTime('timestamp', true)));
$stmt->bindValue('past', self::SCHEDULED_IN_THE_PAST);
$stmt->bindValue('future', self::SCHEDULED_IN_THE_FUTURE);
$stmt->bindValue('statusCompleted', ScheduledTaskEntity::STATUS_COMPLETED);
$rows = $stmt->executeQuery()->fetchAllAssociative();
$this->tasksCounts = [];
foreach ($rows as $r) {
if (empty($this->tasksCounts[$r['type']])) {
$this->tasksCounts[$r['type']] = [];
}
if (empty($this->tasksCounts[$r['type']][$r['scheduled_in']])) {
$this->tasksCounts[$r['type']][$r['scheduled_in']] = [];
}
$this->tasksCounts[$r['type']][$r['scheduled_in']][$r['status'] ?: 'null'] = $r['count'];
}
}
private function getTasksCount(array $options): int {
$count = 0;
$type = $options['type'];
foreach ($options['scheduled_in'] as $scheduledIn) {
foreach ($options['status'] as $status) {
if (!empty($this->tasksCounts[$type][$scheduledIn][$status])) {
$count += $this->tasksCounts[$type][$scheduledIn][$status];
}
}
}
return $count;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,34 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Services\AuthorizedEmailsController;
use MailPoet\Services\Bridge;
class AuthorizedSendingEmailsCheck extends SimpleWorker {
const TASK_TYPE = 'authorized_email_addresses_check';
const AUTOMATIC_SCHEDULING = false;
/** @var AuthorizedEmailsController */
private $authorizedEmailsController;
public function __construct(
AuthorizedEmailsController $authorizedEmailsController
) {
$this->authorizedEmailsController = $authorizedEmailsController;
parent::__construct();
}
public function checkProcessingRequirements() {
return Bridge::isMPSendingServiceEnabled();
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$this->authorizedEmailsController->checkAuthorizedEmailAddresses();
return true;
}
}
@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers\Automations;
if (!defined('ABSPATH')) exit;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Storage\AutomationStorage;
use MailPoet\Cron\Workers\SimpleWorker;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\WP\Functions as WPFunctions;
class AbandonedCartWorker extends SimpleWorker {
const TASK_TYPE = 'automation_abandoned_cart';
const ACTION = 'abandoned_cart';
const AUTOMATIC_SCHEDULING = false;
const BATCH_SIZE = 1000;
private AutomationStorage $automationStorage;
private WPFunctions $wp;
public function __construct(
AutomationStorage $automationStorage,
WPFunctions $wp
) {
parent::__construct();
$this->automationStorage = $automationStorage;
$this->wp = $wp;
}
public function checkProcessingRequirements() {
return true;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$productIds = $task->getMeta()['product_ids'] ?? [];
$automationId = $task->getMeta()['automation_id'] ?? 0;
$automationVersion = $task->getMeta()['automation_version'] ?? 0;
if (!$productIds || !$automationId || !$automationVersion) {
return true;
}
$lastActivityAt = $task->getCreatedAt();
$subscribers = $task->getSubscribers();
if ($subscribers->count() !== 1) {
return true;
}
$subscriber = isset($subscribers[0]) ? $subscribers[0]->getSubscriber() : null;
if (!$subscriber) {
return true;
}
$automation = $this->automationStorage->getAutomation((int)$automationId, (int)$automationVersion);
if (!$automation || $automation->getStatus() !== Automation::STATUS_ACTIVE) {
return true;
}
$this->wp->doAction(
self::ACTION,
$subscriber,
$productIds,
$lastActivityAt
);
return true;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Subscribers\EngagementDataBackfiller;
class BackfillEngagementData extends SimpleWorker {
const TASK_TYPE = 'backfill_engagement_data';
const BATCH_SIZE = 100;
const AUTOMATIC_SCHEDULING = false;
const SUPPORT_MULTIPLE_INSTANCES = false;
/** @var EngagementDataBackfiller */
private $engagementDataBackfiller;
public function __construct(
EngagementDataBackfiller $engagementDataBackfiller
) {
parent::__construct();
$this->engagementDataBackfiller = $engagementDataBackfiller;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$meta = $task->getMeta();
$lastSubscriberId = $meta['last_subscriber_id'] ?? 0;
do {
$this->cronHelper->enforceExecutionLimit($timer);
$batch = $this->engagementDataBackfiller->getBatch($lastSubscriberId, self::BATCH_SIZE);
if (empty($batch)) {
break;
}
$this->engagementDataBackfiller->updateBatch($batch);
$lastSubscriberId = $this->engagementDataBackfiller->getLastProcessedSubscriberId();
$meta['last_subscriber_id'] = $lastSubscriberId;
$task->setMeta($meta);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
} while (count($batch) === self::BATCH_SIZE);
return true;
}
}
@@ -0,0 +1,161 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\StatisticsBounceEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Mailer\Mailer;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
use MailPoet\Services\Bridge;
use MailPoet\Services\Bridge\API;
use MailPoet\Settings\SettingsController;
use MailPoet\Statistics\StatisticsBouncesRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Tasks\Subscribers\BatchIterator;
use MailPoetVendor\Carbon\Carbon;
class Bounce extends SimpleWorker {
const TASK_TYPE = 'bounce';
const BATCH_SIZE = 100;
const BOUNCED_HARD = 'hard';
const BOUNCED_SOFT = 'soft';
const NOT_BOUNCED = null;
public $api;
/** @var SettingsController */
private $settings;
/** @var Bridge */
private $bridge;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var StatisticsBouncesRepository */
private $statisticsBouncesRepository;
/** @var ScheduledTaskSubscribersRepository */
private $scheduledTaskSubscribersRepository;
public function __construct(
SettingsController $settings,
SubscribersRepository $subscribersRepository,
SendingQueuesRepository $sendingQueuesRepository,
StatisticsBouncesRepository $statisticsBouncesRepository,
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
Bridge $bridge
) {
$this->settings = $settings;
$this->bridge = $bridge;
parent::__construct();
$this->subscribersRepository = $subscribersRepository;
$this->sendingQueuesRepository = $sendingQueuesRepository;
$this->statisticsBouncesRepository = $statisticsBouncesRepository;
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
}
public function init() {
if (!$this->api) {
$this->api = new API($this->settings->get(Mailer::MAILER_CONFIG_SETTING_NAME)['mailpoet_api_key']);
}
}
public function checkProcessingRequirements() {
return $this->bridge->isMailpoetSendingServiceEnabled();
}
public function prepareTaskStrategy(ScheduledTaskEntity $task, $timer) {
$this->scheduledTaskSubscribersRepository->createSubscribersForBounceWorker($task);
if (!$this->scheduledTaskSubscribersRepository->countBy(['task' => $task, 'processed' => ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED])) {
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
return false;
}
return true;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$subscriberBatches = new BatchIterator($task->getId(), self::BATCH_SIZE);
if (count($subscriberBatches) === 0) {
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
return true; // mark completed
}
/** @var int[] $subscribersToProcessIds - it's required for PHPStan */
foreach ($subscriberBatches as $subscribersToProcessIds) {
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($timer);
$subscriberEmails = $this->subscribersRepository->getUndeletedSubscribersEmailsByIds($subscribersToProcessIds);
$subscriberEmails = array_column($subscriberEmails, 'email');
$this->processEmails($task, $subscriberEmails);
$this->scheduledTaskSubscribersRepository->updateProcessedSubscribers($task, $subscribersToProcessIds);
}
return true;
}
public function processEmails(ScheduledTaskEntity $task, array $subscriberEmails) {
$checkedEmails = $this->api->checkBounces($subscriberEmails);
$this->processApiResponse($task, (array)$checkedEmails);
}
public function processApiResponse(ScheduledTaskEntity $task, array $checkedEmails) {
$previousTask = $this->findPreviousTask($task);
foreach ($checkedEmails as $email) {
if (!isset($email['address'], $email['bounce'])) {
continue;
}
if ($email['bounce'] === self::BOUNCED_HARD) {
$subscriber = $this->subscribersRepository->findOneBy(['email' => $email['address']]);
if (!$subscriber instanceof SubscriberEntity) continue;
$subscriber->setStatus(SubscriberEntity::STATUS_BOUNCED);
$this->saveBouncedStatistics($subscriber, $task, $previousTask);
}
}
$this->subscribersRepository->flush();
}
public function getNextRunDate() {
$date = Carbon::now()->millisecond(0);
return $date->startOfDay()
->addDay()
->addHours(rand(0, 5))
->addMinutes(rand(0, 59))
->addSeconds(rand(0, 59));
}
private function findPreviousTask(ScheduledTaskEntity $task): ?ScheduledTaskEntity {
return $this->scheduledTasksRepository->findPreviousTask($task);
}
private function saveBouncedStatistics(SubscriberEntity $subscriber, ScheduledTaskEntity $task, ?ScheduledTaskEntity $previousTask): void {
$dateFrom = null;
if ($previousTask instanceof ScheduledTaskEntity) {
$dateFrom = $previousTask->getScheduledAt();
}
$queues = $this->sendingQueuesRepository->findAllForSubscriberSentBetween($subscriber, $task->getScheduledAt(), $dateFrom);
foreach ($queues as $queue) {
$newsletter = $queue->getNewsletter();
if ($newsletter instanceof NewsletterEntity) {
$statistics = new StatisticsBounceEntity($newsletter, $queue, $subscriber);
$this->statisticsBouncesRepository->persist($statistics);
}
}
}
}
@@ -0,0 +1,31 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Subscribers\ImportExport\Export\Export;
use MailPoetVendor\Carbon\Carbon;
class ExportFilesCleanup extends SimpleWorker {
const TASK_TYPE = 'export_files_cleanup';
const DELETE_FILES_AFTER_X_DAYS = 1;
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$iterator = new \GlobIterator(Export::getExportPath() . '/' . Export::getFilePrefix() . '*.*');
foreach ($iterator as $file) {
if (is_string($file)) {
continue;
}
$name = $file->getPathname();
$created = $file->getMTime();
$now = new Carbon();
if (Carbon::createFromTimestamp((int)$created)->lessThan($now->subDays(self::DELETE_FILES_AFTER_X_DAYS))) {
unlink($name);
};
}
return true;
}
}
@@ -0,0 +1,83 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Settings\SettingsController;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Subscribers\InactiveSubscribersController;
use MailPoet\Subscribers\SubscribersRepository;
class InactiveSubscribers extends SimpleWorker {
const TASK_TYPE = 'inactive_subscribers';
const BATCH_SIZE = 1000;
const SUPPORT_MULTIPLE_INSTANCES = false;
/** @var InactiveSubscribersController */
private $inactiveSubscribersController;
/** @var SettingsController */
private $settings;
/** @var TrackingConfig */
private $trackingConfig;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
InactiveSubscribersController $inactiveSubscribersController,
SettingsController $settings,
TrackingConfig $trackingConfig,
SubscribersRepository $subscribersRepository
) {
$this->inactiveSubscribersController = $inactiveSubscribersController;
$this->settings = $settings;
$this->trackingConfig = $trackingConfig;
$this->subscribersRepository = $subscribersRepository;
parent::__construct();
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
if (!$this->trackingConfig->isEmailTrackingEnabled()) {
$this->schedule();
return true;
}
$daysToInactive = (int)$this->settings->get('deactivate_subscriber_after_inactive_days');
// Activate all inactive subscribers in case the feature is turned off
if ($daysToInactive === 0) {
$this->inactiveSubscribersController->reactivateInactiveSubscribers();
$this->schedule();
return true;
}
// Handle activation/deactivation within interval
$meta = $task->getMeta();
$lastSubscriberId = isset($meta['last_subscriber_id']) ? $meta['last_subscriber_id'] : 0;
if (isset($meta['max_subscriber_id'])) {
$maxSubscriberId = $meta['max_subscriber_id'];
} else {
$maxSubscriberId = $this->subscribersRepository->getMaxSubscriberId();
}
while ($lastSubscriberId <= $maxSubscriberId) {
$count = $this->inactiveSubscribersController->markInactiveSubscribers($daysToInactive, self::BATCH_SIZE, $lastSubscriberId);
if ($count === false) {
break;
}
$lastSubscriberId += self::BATCH_SIZE;
$task->setMeta(['last_subscriber_id' => $lastSubscriberId]);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
$this->cronHelper->enforceExecutionLimit($timer);
};
while ($this->inactiveSubscribersController->markActiveSubscribers($daysToInactive, self::BATCH_SIZE) === self::BATCH_SIZE) {
$this->cronHelper->enforceExecutionLimit($timer);
};
$this->schedule();
return true;
}
}
@@ -0,0 +1,59 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\KeyCheck;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\Cron\Workers\SimpleWorker;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Services\Bridge;
use MailPoetVendor\Carbon\Carbon;
abstract class KeyCheckWorker extends SimpleWorker {
/** @var Bridge|null */
public $bridge;
/** @var CronWorkerScheduler */
protected $cronWorkerScheduler;
public function __construct(
CronWorkerScheduler $cronWorkerScheduler
) {
parent::__construct();
$this->cronWorkerScheduler = $cronWorkerScheduler;
}
public function init() {
if (!$this->bridge) {
$this->bridge = new Bridge();
}
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
try {
$result = $this->checkKey();
} catch (\Exception $e) {
$result = false;
}
if (empty($result['code']) || $result['code'] == Bridge::CHECK_ERROR_UNAVAILABLE) {
$this->cronWorkerScheduler->rescheduleProgressively($task);
return false;
}
return true;
}
public function getNextRunDate() {
$date = Carbon::now()->millisecond(0);
return $date->startOfDay()
->addDay()
->addHours(rand(0, 5))
->addMinutes(rand(0, 59))
->addSeconds(rand(0, 59));
}
public abstract function checkKey();
}
@@ -0,0 +1,42 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\KeyCheck;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\InvalidStateException;
use MailPoet\Services\Bridge;
use MailPoet\Settings\SettingsController;
class PremiumKeyCheck extends KeyCheckWorker {
const TASK_TYPE = 'premium_key_check';
/** @var SettingsController */
private $settings;
public function __construct(
SettingsController $settings,
CronWorkerScheduler $cronWorkerScheduler
) {
$this->settings = $settings;
parent::__construct($cronWorkerScheduler);
}
public function checkProcessingRequirements() {
return Bridge::isPremiumKeySpecified();
}
public function checkKey() {
// for phpstan because we set bridge property in the init function
if (!$this->bridge) {
throw new InvalidStateException('The class was not initialized properly. Please call the Init method before.');
};
$premiumKey = $this->settings->get(Bridge::PREMIUM_KEY_SETTING_NAME);
$result = $this->bridge->checkPremiumKey($premiumKey);
$this->bridge->storePremiumKeyAndState($premiumKey, $result);
return $result;
}
}
@@ -0,0 +1,70 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\KeyCheck;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\ServicesChecker;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\InvalidStateException;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\MailerLog;
use MailPoet\Services\Bridge;
use MailPoet\Settings\SettingsController;
use MailPoetVendor\Carbon\Carbon;
class SendingServiceKeyCheck extends KeyCheckWorker {
const TASK_TYPE = 'sending_service_key_check';
/** @var SettingsController */
private $settings;
/** @var ServicesChecker */
private $servicesChecker;
public function __construct(
SettingsController $settings,
ServicesChecker $servicesChecker,
CronWorkerScheduler $cronWorkerScheduler
) {
$this->settings = $settings;
$this->servicesChecker = $servicesChecker;
parent::__construct($cronWorkerScheduler);
}
public function checkProcessingRequirements() {
return Bridge::isMPSendingServiceEnabled();
}
/**
* @return \DateTimeInterface|Carbon
*/
public function getNextRunDate() {
// when key pending approval, check key sate every hour
if ($this->servicesChecker->isMailPoetAPIKeyPendingApproval()) {
$date = Carbon::now()->millisecond(0);
return $date->addHour();
}
return parent::getNextRunDate();
}
public function checkKey() {
// for phpstan because we set bridge property in the init function
if (!$this->bridge) {
throw new InvalidStateException('The class was not initialized properly. Please call the Init method before.');
};
$wasPendingApproval = $this->servicesChecker->isMailPoetAPIKeyPendingApproval();
$mssKey = $this->settings->get(Mailer::MAILER_CONFIG_SETTING_NAME)['mailpoet_api_key'];
$result = $this->bridge->checkMSSKey($mssKey);
$this->bridge->storeMSSKeyAndState($mssKey, $result);
$isPendingApproval = $this->servicesChecker->isMailPoetAPIKeyPendingApproval();
if ($wasPendingApproval && !$isPendingApproval) {
MailerLog::resumeSending();
}
return $result;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,64 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Analytics\Analytics;
use MailPoet\Entities\ScheduledTaskEntity;
use Mixpanel as MixpanelLibrary;
class Mixpanel extends SimpleWorker {
const PRODUCTION_PROJECT_ID = '8cce373b255e5a76fb22d57b85db0c92';
/** @var Analytics */
private $analytics;
const TASK_TYPE = 'mixpanel';
private MixpanelLibrary $mixpanel;
public function __construct(
Analytics $analytics
) {
parent::__construct();
$this->analytics = $analytics;
$this->mixpanel = MixpanelLibrary::getInstance(self::PRODUCTION_PROJECT_ID);
$this->mixpanel->register('Platform', 'Plugin');
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
return $this->maybeReportAnalyticsToMixpanel();
}
public function maybeReportAnalyticsToMixpanel(): bool {
if (!$this->analytics->shouldSend()) {
return true;
}
return $this->reportAnalyticsToMixpanel();
}
public function reportAnalyticsToMixpanel(): bool {
$publicId = $this->analytics->getPublicId();
if (strlen($publicId) < 1) {
return true;
}
$data = $this->analytics->getAnalyticsData();
$this->mixpanel->identify($publicId);
$this->mixpanel->people->set($publicId, $data);
$this->mixpanel->track('User Properties', $data);
$this->analytics->recordDataSent();
return true;
}
public function getNextRunDate() {
return $this->analytics->getNextSendDate()->addMinutes(rand(0, 59));
}
}
@@ -0,0 +1,30 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\NewsletterTemplates\ThumbnailSaver;
class NewsletterTemplateThumbnails extends SimpleWorker {
const TASK_TYPE = 'newsletter_templates_thumbnails';
const AUTOMATIC_SCHEDULING = false;
const SUPPORT_MULTIPLE_INSTANCES = false;
/** @var ThumbnailSaver */
private $thumbnailSaver;
public function __construct(
ThumbnailSaver $thumbnailSaver
) {
parent::__construct();
$this->thumbnailSaver = $thumbnailSaver;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$this->thumbnailSaver->ensureTemplateThumbnailsForAll();
return true;
}
}
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Newsletter\Scheduler\ReEngagementScheduler;
use MailPoetVendor\Carbon\Carbon;
class ReEngagementEmailsScheduler extends SimpleWorker {
const TASK_TYPE = 'schedule_re_engagement_email';
/** @var ReEngagementScheduler */
private $reEngagementEmailsScheduler;
public function __construct(
ReEngagementScheduler $reEngagementEmailsScheduler
) {
parent::__construct();
$this->reEngagementEmailsScheduler = $reEngagementEmailsScheduler;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$this->reEngagementEmailsScheduler->scheduleAll();
return true;
}
public function getNextRunDate() {
return Carbon::now()->millisecond(0)->addDay();
}
}
@@ -0,0 +1,455 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterSegmentEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Scheduler\PostNotificationScheduler;
use MailPoet\Newsletter\Scheduler\Scheduler as NewsletterScheduler;
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
use MailPoet\Newsletter\Segment\NewsletterSegmentRepository;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Segments\SubscribersFinder;
use MailPoet\Subscribers\SubscriberSegmentRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityNotFoundException;
class Scheduler {
const TASK_BATCH_SIZE = 5;
/** @var SubscribersFinder */
private $subscribersFinder;
/** @var LoggerFactory */
private $loggerFactory;
/** @var CronHelper */
private $cronHelper;
/** @var CronWorkerScheduler */
private $cronWorkerScheduler;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var ScheduledTaskSubscribersRepository */
private $scheduledTaskSubscribersRepository;
/** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var NewsletterSegmentRepository */
private $newsletterSegmentRepository;
/** @var Security */
private $security;
/** @var NewsletterScheduler */
private $scheduler;
/** @var SubscriberSegmentRepository */
private $subscriberSegmentRepository;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SubscribersFinder $subscribersFinder,
LoggerFactory $loggerFactory,
CronHelper $cronHelper,
CronWorkerScheduler $cronWorkerScheduler,
ScheduledTasksRepository $scheduledTasksRepository,
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
SendingQueuesRepository $sendingQueuesRepository,
NewslettersRepository $newslettersRepository,
SegmentsRepository $segmentsRepository,
NewsletterSegmentRepository $newsletterSegmentRepository,
Security $security,
NewsletterScheduler $scheduler,
SubscriberSegmentRepository $subscriberSegmentRepository,
SubscribersRepository $subscribersRepository
) {
$this->cronHelper = $cronHelper;
$this->subscribersFinder = $subscribersFinder;
$this->loggerFactory = $loggerFactory;
$this->cronWorkerScheduler = $cronWorkerScheduler;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
$this->sendingQueuesRepository = $sendingQueuesRepository;
$this->newslettersRepository = $newslettersRepository;
$this->segmentsRepository = $segmentsRepository;
$this->newsletterSegmentRepository = $newsletterSegmentRepository;
$this->security = $security;
$this->scheduler = $scheduler;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->subscribersRepository = $subscribersRepository;
}
public function process($timer = false) {
$timer = $timer ?: microtime(true);
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($timer);
$scheduledTasks = $this->getScheduledSendingTasks();
$this->updateTasks($scheduledTasks);
foreach ($scheduledTasks as $task) {
$queue = $task->getSendingQueue();
if (!$queue) {
$this->deleteByTask($task);
continue;
}
$newsletter = $queue->getNewsletter();
try {
if (!$newsletter instanceof NewsletterEntity || $newsletter->getDeletedAt() !== null) {
$this->deleteByTask($task);
} elseif ($newsletter->getStatus() !== NewsletterEntity::STATUS_ACTIVE && $newsletter->getStatus() !== NewsletterEntity::STATUS_SCHEDULED) {
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
continue;
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_WELCOME) {
$this->processWelcomeNewsletter($newsletter, $task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION) {
$this->processPostNotificationNewsletter($newsletter, $task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_STANDARD) {
$this->processScheduledStandardNewsletter($newsletter, $task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATIC) {
$this->processScheduledAutomaticEmail($newsletter, $task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_RE_ENGAGEMENT) {
$this->processReEngagementEmail($task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATION) {
$this->processScheduledAutomationEmail($task);
} elseif ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL) {
$this->processScheduledTransactionalEmail($task);
}
} catch (EntityNotFoundException $e) {
// Doctrine throws this exception when newsletter doesn't exist but is referenced in a scheduled task.
// This was added while refactoring this method to use Doctrine instead of Paris. We have to handle this case
// for the SchedulerTest::testItDeletesQueueDuringProcessingWhenNewsletterNotFound() test. I'm not sure
// if this problem could happen in production or not.
$this->deleteByTask($task);
}
$this->cronHelper->enforceExecutionLimit($timer);
}
}
public function processWelcomeNewsletter(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
$subscribers = $task->getSubscribers();
if (empty($subscribers[0])) {
$this->deleteByTask($task);
return false;
}
$subscriberId = (int)$subscribers[0]->getSubscriberId();
if ($newsletter->getOptionValue('event') === 'segment') {
if ($this->verifyMailpoetSubscriber($subscriberId, $newsletter, $task) === false) {
return false;
}
} else {
if ($newsletter->getOptionValue('event') === 'user') {
if ($this->verifyWPSubscriber($subscriberId, $newsletter, $task) === false) {
return false;
}
}
}
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
return true;
}
public function processPostNotificationNewsletter(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'process post notification in scheduler',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
// ensure that segments exist
$segments = $newsletter->getSegmentIds();
if (empty($segments)) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'post notification no segments',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
$this->deleteQueueOrUpdateNextRunDate($task, $newsletter);
return false;
}
// ensure that subscribers are in segments
$this->subscribersFinder->addSubscribersToTaskFromSegments($task, $segments, $newsletter->getFilterSegmentId());
$subscribersCount = $task->getSubscribers()->count();
if (empty($subscribersCount)) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'post notification no subscribers',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId(), 'segment_ids' => $segments]
);
$this->deleteQueueOrUpdateNextRunDate($task, $newsletter);
return false;
}
// create a duplicate newsletter that acts as a history record
try {
$notificationHistory = $this->createPostNotificationHistory($newsletter);
} catch (\Exception $exception) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->error(
'creating post notification history failed',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId(), 'error' => $exception->getMessage()]
);
return false;
}
// queue newsletter for delivery
$queue = $task->getSendingQueue();
if (!$queue) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->error(
'post notification no queue',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
return false;
}
$queue->setNewsletter($notificationHistory);
$this->sendingQueuesRepository->updateCounts($queue);
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'post notification set status to sending',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
return true;
}
public function processScheduledAutomaticEmail(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
if ($newsletter->getOptionValue('sendTo') === 'segment') {
$segment = $this->segmentsRepository->findOneById($newsletter->getOptionValue('segment'));
if ($segment instanceof SegmentEntity) {
$this->subscribersFinder->addSubscribersToTaskFromSegments($task, [(int)$segment->getId()]);
if (!$task->getSubscribers()->count()) {
$this->deleteByTask($task);
return false;
}
}
} else {
$subscribers = $task->getSubscribers();
$subscriber = isset($subscribers[0]) ? $subscribers[0]->getSubscriber() : null;
if (!$subscriber) {
$this->deleteByTask($task);
return false;
}
if ($this->verifySubscriber($subscriber, $task) === false) {
return false;
}
}
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
return true;
}
public function processScheduledAutomationEmail(ScheduledTaskEntity $task): bool {
$subscribers = $task->getSubscribers();
$subscriber = isset($subscribers[0]) ? $subscribers[0]->getSubscriber() : null;
if (!$subscriber) {
$this->deleteByTask($task);
return false;
}
if (!$this->verifySubscriber($subscriber, $task)) {
return false;
}
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
return true;
}
public function processScheduledTransactionalEmail(ScheduledTaskEntity $task): bool {
$subscribers = $task->getSubscribers();
$subscriber = isset($subscribers[0]) ? $subscribers[0]->getSubscriber() : null;
if (!$subscriber) {
$this->deleteByTask($task);
return false;
}
if (!$this->verifySubscriber($subscriber, $task)) {
$this->deleteByTask($task);
return false;
}
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
return true;
}
public function processScheduledStandardNewsletter(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
$segments = $newsletter->getSegmentIds();
$this->subscribersFinder->addSubscribersToTaskFromSegments($task, $segments, $newsletter->getFilterSegmentId());
$task->setStatus(null);
$queue = $task->getSendingQueue();
if ($queue) {
$this->sendingQueuesRepository->updateCounts($queue);
}
$newsletter->setStatus(NewsletterEntity::STATUS_SENDING);
$this->scheduledTasksRepository->flush();
return true;
}
private function processReEngagementEmail(ScheduledTaskEntity $task) {
$task->setStatus(null);
$this->scheduledTasksRepository->flush();
return true;
}
public function verifyMailpoetSubscriber(int $subscriberId, NewsletterEntity $newsletter, ScheduledTaskEntity $task): bool {
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
// check if subscriber is in proper segment
$subscriberInSegment = $this->subscriberSegmentRepository->findOneBy(
[
'subscriber' => $subscriberId,
'segment' => $newsletter->getOptionValue('segment'),
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
]
);
if (!$subscriber || !$subscriberInSegment) {
$this->deleteByTask($task);
return false;
}
return $this->verifySubscriber($subscriber, $task);
}
public function verifyWPSubscriber(int $subscriberId, NewsletterEntity $newsletter, ScheduledTaskEntity $task): bool {
// check if user has the proper role
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
if (!$subscriber || $subscriber->isWPUser() === false || is_null($subscriber->getWpUserId())) {
$this->deleteByTask($task);
return false;
}
$wpUser = get_userdata($subscriber->getWpUserId());
if ($wpUser === false) {
$this->deleteByTask($task);
return false;
}
if (
$newsletter->getOptionValue('role') !== WelcomeScheduler::WORDPRESS_ALL_ROLES
&& !in_array($newsletter->getOptionValue('role'), ((array)$wpUser)['roles'])
) {
$this->deleteByTask($task);
return false;
}
return $this->verifySubscriber($subscriber, $task);
}
public function verifySubscriber(SubscriberEntity $subscriber, ScheduledTaskEntity $task): bool {
$queue = $task->getSendingQueue();
$newsletter = $queue ? $queue->getNewsletter() : null;
if ($newsletter && $newsletter->isTransactional()) {
return $subscriber->getStatus() !== SubscriberEntity::STATUS_BOUNCED;
}
if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED) {
// reschedule delivery
$this->cronWorkerScheduler->rescheduleProgressively($task);
return false;
} else if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNSUBSCRIBED) {
$this->deleteByTask($task);
return false;
}
return true;
}
public function deleteQueueOrUpdateNextRunDate(ScheduledTaskEntity $task, NewsletterEntity $newsletter) {
if ($newsletter->getOptionValue('intervalType') === PostNotificationScheduler::INTERVAL_IMMEDIATELY) {
$this->deleteByTask($task);
} else {
$nextRunDate = $this->scheduler->getNextRunDateTime($newsletter->getOptionValue('schedule'));
if (!$nextRunDate) {
$this->deleteByTask($task);
return;
}
$task->setScheduledAt($nextRunDate);
$this->scheduledTasksRepository->flush();
}
}
public function createPostNotificationHistory(NewsletterEntity $newsletter): NewsletterEntity {
// clone newsletter
$notificationHistory = clone $newsletter;
$notificationHistory->setParent($newsletter);
$notificationHistory->setType(NewsletterEntity::TYPE_NOTIFICATION_HISTORY);
$notificationHistory->setStatus(NewsletterEntity::STATUS_SENDING);
$notificationHistory->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($notificationHistory));
// reset timestamps
$createdAt = Carbon::now()->millisecond(0);
$notificationHistory->setCreatedAt($createdAt);
$notificationHistory->setUpdatedAt($createdAt);
$notificationHistory->setDeletedAt(null);
// reset hash
$notificationHistory->setHash(Security::generateHash());
$this->newslettersRepository->persist($notificationHistory);
$this->newslettersRepository->flush();
// create relationships between notification history and segments
foreach ($newsletter->getNewsletterSegments() as $newsletterSegment) {
$segment = $newsletterSegment->getSegment();
if (!$segment) {
continue;
}
$duplicateSegment = new NewsletterSegmentEntity($notificationHistory, $segment);
$notificationHistory->getNewsletterSegments()->add($duplicateSegment);
$this->newsletterSegmentRepository->persist($duplicateSegment);
}
$this->newslettersRepository->flush();
return $notificationHistory;
}
/**
* @param ScheduledTaskEntity[] $scheduledTasks
*/
private function updateTasks(array $scheduledTasks): void {
$ids = array_map(function (ScheduledTaskEntity $scheduledTask): ?int {
return $scheduledTask->getId();
}, $scheduledTasks);
$ids = array_filter($ids);
$this->scheduledTasksRepository->touchAllByIds($ids);
}
/**
* @return ScheduledTaskEntity[]
*/
public function getScheduledSendingTasks(): array {
return $this->scheduledTasksRepository->findScheduledSendingTasks(self::TASK_BATCH_SIZE);
}
private function deleteByTask(ScheduledTaskEntity $task): void {
$queue = $task->getSendingQueue();
if ($queue) {
$this->sendingQueuesRepository->remove($queue);
}
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
$this->scheduledTasksRepository->remove($task);
$this->scheduledTasksRepository->flush();
}
}
@@ -0,0 +1,86 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\MailerLog;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
class SendingErrorHandler {
/** @var ScheduledTaskSubscribersRepository */
private $scheduledTaskSubscribersRepository;
/** @var SendingThrottlingHandler */
private $throttlingHandler;
/** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var LoggerFactory */
private $loggerFactory;
public function __construct(
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
SendingThrottlingHandler $throttlingHandler,
SendingQueuesRepository $sendingQueuesRepository,
LoggerFactory $loggerFactory
) {
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
$this->throttlingHandler = $throttlingHandler;
$this->sendingQueuesRepository = $sendingQueuesRepository;
$this->loggerFactory = $loggerFactory;
}
public function processError(
MailerError $error,
ScheduledTaskEntity $task,
array $preparedSubscribersIds,
array $preparedSubscribers
) {
if ($error->getLevel() === MailerError::LEVEL_HARD) {
return $this->processHardError($error);
}
$this->processSoftError($error, $task, $preparedSubscribersIds, $preparedSubscribers);
}
private function processHardError(MailerError $error) {
if ($error->getRetryInterval() !== null) {
MailerLog::processNonBlockingError($error->getOperation(), $error->getMessageWithFailedSubscribers(), $error->getRetryInterval());
} else {
$throttledBatchSize = null;
if ($error->getOperation() === MailerError::OPERATION_CONNECT) {
$throttledBatchSize = $this->throttlingHandler->throttleBatchSize();
}
MailerLog::processError($error->getOperation(), $error->getMessageWithFailedSubscribers(), null, false, $throttledBatchSize);
}
}
private function processSoftError(MailerError $error, ScheduledTaskEntity $task, $preparedSubscribersIds, $preparedSubscribers) {
foreach ($error->getSubscriberErrors() as $subscriberError) {
$subscriberIdIndex = array_search($subscriberError->getEmail(), $preparedSubscribers);
$message = $subscriberError->getMessage() ?: $error->getMessage();
$this->scheduledTaskSubscribersRepository->saveError($task, $preparedSubscribersIds[$subscriberIdIndex], $message ?? '');
}
$queue = $task->getSendingQueue();
if ($queue instanceof SendingQueueEntity) {
if ($error->getOperation() === MailerError::OPERATION_DOMAIN_AUTHORIZATION) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'Paused task in sending queue due to sender domain authorization error',
['task_id' => $task->getId()]
);
$this->sendingQueuesRepository->pause($queue);
return;
}
$this->sendingQueuesRepository->updateCounts($queue);
}
}
}
@@ -0,0 +1,686 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\Workers\Bounce;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Links;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Mailer as MailerTask;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Newsletter as NewsletterTask;
use MailPoet\Cron\Workers\StatsNotifications\Scheduler as StatsNotificationsScheduler;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Mailer\MailerLog;
use MailPoet\Mailer\MetaInfo;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Segments\SubscribersFinder;
use MailPoet\Services\AuthorizedEmailsController;
use MailPoet\Statistics\StatisticsNewslettersRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Tasks\Subscribers\BatchIterator;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use Throwable;
class SendingQueue {
/** @var MailerTask */
public $mailerTask;
/** @var NewsletterTask */
public $newsletterTask;
const TASK_TYPE = 'sending';
const TASK_BATCH_SIZE = 5;
const EMAIL_WITH_INVALID_SEGMENT_OPTION = 'mailpoet_email_with_invalid_segment';
/** @var StatsNotificationsScheduler */
public $statsNotificationsScheduler;
/** @var SendingErrorHandler */
private $errorHandler;
/** @var SendingThrottlingHandler */
private $throttlingHandler;
/** @var MetaInfo */
private $mailerMetaInfo;
/** @var LoggerFactory */
private $loggerFactory;
/** @var CronHelper */
private $cronHelper;
/** @var SubscribersFinder */
private $subscribersFinder;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var WPFunctions */
private $wp;
/** @var Links */
private $links;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var ScheduledTaskSubscribersRepository */
private $scheduledTaskSubscribersRepository;
/** @var SubscribersRepository */
private $subscribersRepository;
/*** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var EntityManager */
private $entityManager;
/** @var StatisticsNewslettersRepository */
private $statisticsNewslettersRepository;
/** @var AuthorizedEmailsController */
private $authorizedEmailsController;
public function __construct(
SendingErrorHandler $errorHandler,
SendingThrottlingHandler $throttlingHandler,
StatsNotificationsScheduler $statsNotificationsScheduler,
LoggerFactory $loggerFactory,
CronHelper $cronHelper,
SubscribersFinder $subscriberFinder,
SegmentsRepository $segmentsRepository,
WPFunctions $wp,
Links $links,
ScheduledTasksRepository $scheduledTasksRepository,
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
MailerTask $mailerTask,
SubscribersRepository $subscribersRepository,
SendingQueuesRepository $sendingQueuesRepository,
EntityManager $entityManager,
StatisticsNewslettersRepository $statisticsNewslettersRepository,
AuthorizedEmailsController $authorizedEmailsController,
$newsletterTask = false
) {
$this->errorHandler = $errorHandler;
$this->throttlingHandler = $throttlingHandler;
$this->statsNotificationsScheduler = $statsNotificationsScheduler;
$this->subscribersFinder = $subscriberFinder;
$this->mailerTask = $mailerTask;
$this->newsletterTask = ($newsletterTask) ? $newsletterTask : new NewsletterTask();
$this->segmentsRepository = $segmentsRepository;
$this->mailerMetaInfo = new MetaInfo;
$this->wp = $wp;
$this->loggerFactory = $loggerFactory;
$this->cronHelper = $cronHelper;
$this->links = $links;
$this->scheduledTasksRepository = $scheduledTasksRepository;
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
$this->subscribersRepository = $subscribersRepository;
$this->sendingQueuesRepository = $sendingQueuesRepository;
$this->entityManager = $entityManager;
$this->statisticsNewslettersRepository = $statisticsNewslettersRepository;
$this->authorizedEmailsController = $authorizedEmailsController;
}
public function process($timer = false) {
$timer = $timer ?: microtime(true);
$this->enforceSendingAndExecutionLimits($timer);
foreach ($this->scheduledTasksRepository->findRunningSendingTasks(self::TASK_BATCH_SIZE) as $task) {
$queue = $task->getSendingQueue();
if (!$queue) {
continue;
}
if ($task->getInProgress()) {
if ($this->isTimeout($task)) {
$this->stopProgress($task);
} else {
continue;
}
}
$this->startProgress($task);
try {
$this->scheduledTasksRepository->touchAllByIds([$task->getId()]);
$this->processSending($task, (int)$timer);
} catch (\Exception $e) {
$this->stopProgress($task);
throw $e;
}
$this->stopProgress($task);
}
}
private function processSending(ScheduledTaskEntity $task, int $timer): void {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'sending queue processing',
['task_id' => $task->getId()]
);
$this->deleteTaskIfNewsletterDoesNotExist($task);
$queue = $task->getSendingQueue();
$newsletter = $this->newsletterTask->getNewsletterFromQueue($task);
if (!$queue || !$newsletter) {
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
return;
}
// pre-process newsletter (render, replace shortcodes/links, etc.)
$newsletter = $this->newsletterTask->preProcessNewsletter($newsletter, $task);
// During pre-processing we may find that the newsletter can't be sent and we delete it including all associated entities
// E.g. post notification history newsletter when there are no posts to send
if (!$newsletter) {
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
return;
}
// configure mailer
$this->mailerTask->configureMailer($newsletter);
// get newsletter segments
$newsletterSegmentsIds = $newsletter->getSegmentIds();
$segmentIdsToCheck = $newsletterSegmentsIds;
$filterSegmentId = $newsletter->getFilterSegmentId();
if (is_int($filterSegmentId)) {
$segmentIdsToCheck[] = $filterSegmentId;
}
// Pause task in case some of related segments was deleted or trashed
if ($newsletterSegmentsIds && !$this->checkDeletedSegments($segmentIdsToCheck)) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'pause task in sending queue due deleted or trashed segment',
['task_id' => $task->getId()]
);
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
$this->wp->setTransient(self::EMAIL_WITH_INVALID_SEGMENT_OPTION, $newsletter->getSubject());
return;
}
// Pause task if sender domain requirements are not met
if (!$this->authorizedEmailsController->isSenderAddressValid($newsletter, 'sending')) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'pause task in sending queue due to sender domain requirements',
['task_id' => $task->getId()]
);
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
return;
}
// get subscribers
$subscriberBatches = new BatchIterator($task->getId(), $this->getBatchSize());
// Set invalid state for sending task for non-campaign (no-bulk) newsletters with no subscribers (e.g. welcome emails, automatic emails).
// This cover cases when a welcome or automatic email was scheduled but before processing it the subscriber was deleted.
// The non-campaign emails are sent only to a single recipient, and we count stats based on sending tasks statues, so we can't mark them as completed.
// At the same time we want to keep a record abut processing them
if ($subscriberBatches->count() === 0 && !in_array($newsletter->getType(), NewsletterEntity::CAMPAIGN_TYPES, true)) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'no subscribers to process',
['task_id' => $task->getId()]
);
$this->scheduledTasksRepository->invalidateTask($task);
return;
}
/** @var int[] $subscribersToProcessIds - it's required for PHPStan */
foreach ($subscriberBatches as $subscribersToProcessIds) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'subscriber batch processing',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId(), 'subscriber_batch_count' => count($subscribersToProcessIds)]
);
if (!empty($newsletterSegmentsIds[0])) {
// Check that subscribers are in segments
try {
$foundSubscribersIds = $this->subscribersFinder->findSubscribersInSegments($subscribersToProcessIds, $newsletterSegmentsIds, $filterSegmentId);
} catch (InvalidStateException $exception) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'paused task in sending queue due to problem finding subscribers: ' . $exception->getMessage(),
['task_id' => $task->getId()]
);
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
return;
}
$foundSubscribers = empty($foundSubscribersIds) ? [] : $this->subscribersRepository->findBy(['id' => $foundSubscribersIds, 'deletedAt' => null]);
} else {
// No segments = Welcome emails or some Automatic emails.
// Welcome emails or some Automatic emails use segments only for scheduling and store them as a newsletter option
$queryBuilder = $this->entityManager->createQueryBuilder();
$queryBuilder->select('s')
->from(SubscriberEntity::class, 's')
->where('s.id IN (:subscriberIds)')
->setParameter('subscriberIds', $subscribersToProcessIds)
->andWhere('s.deletedAt IS NULL');
if ($newsletter->isTransactional()) {
$queryBuilder->andWhere('s.status != :bouncedStatus')
->setParameter('bouncedStatus', SubscriberEntity::STATUS_BOUNCED);
} else {
$queryBuilder->andWhere('s.status = :subscribedStatus')
->setParameter('subscribedStatus', SubscriberEntity::STATUS_SUBSCRIBED);
}
$foundSubscribers = $queryBuilder->getQuery()->getResult();
$foundSubscribersIds = array_map(function(SubscriberEntity $subscriber) {
return $subscriber->getId();
}, $foundSubscribers);
}
// if some subscribers weren't found, remove them from the processing list
if (count($foundSubscribersIds) !== count($subscribersToProcessIds)) {
$subscribersToRemove = array_diff(
$subscribersToProcessIds,
$foundSubscribersIds
);
$this->scheduledTaskSubscribersRepository->deleteByScheduledTaskAndSubscriberIds($task, $subscribersToRemove);
$this->sendingQueuesRepository->updateCounts($queue);
// if there aren't any subscribers to process in the batch (e.g. all unsubscribed or were deleted), continue with the next batch
if (count($foundSubscribersIds) === 0) {
continue;
}
}
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'before queue chunk processing',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId(), 'found_subscribers_count' => count($foundSubscribers)]
);
// reschedule bounce task to run sooner, if needed
$this->reScheduleBounceTask();
// Check task has not been paused before continue processing
// This is needed because the task can be paused in the middle of the batch processing,
// for example on API error ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN
if ($task->getStatus() === ScheduledTaskEntity::STATUS_PAUSED) {
return;
}
if ($newsletter->getStatus() !== NewsletterEntity::STATUS_CORRUPT) {
$this->processQueue(
$task,
$newsletter,
$foundSubscribers,
$timer
);
if (!$newsletter->isTransactional()) {
$this->entityManager->wrapInTransaction(function() use ($foundSubscribersIds) {
$now = Carbon::now()->millisecond(0);
$this->subscribersRepository->bulkUpdateLastSendingAt($foundSubscribersIds, $now);
// We're nullifying this value so these subscribers' engagement score will be recalculated the next time the cron runs
$this->subscribersRepository->bulkUpdateEngagementScoreUpdatedAt($foundSubscribersIds, null);
});
}
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'after queue chunk processing',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
// In case we finished end sending properly before enforcing sending and execution limits
// The limit enforcing throws and exception and the sending end wouldn't be processed properly (stats notification, newsletter marked as sent etc.)
if ($task->getStatus() === ScheduledTaskEntity::STATUS_COMPLETED) {
$this->endSending($task, $newsletter);
return;
}
$this->enforceSendingAndExecutionLimits($timer);
} else {
$this->sendingQueuesRepository->pause($queue);
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->error(
'Can\'t send corrupt newsletter',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
return;
}
}
// At this point all batches were processed or there are no batches to process
// Also none of the checks above paused or invalidated the task
$this->endSending($task, $newsletter);
}
public function getBatchSize(): int {
return $this->throttlingHandler->getBatchSize();
}
/**
* @param SubscriberEntity[] $subscribers
*/
public function processQueue(ScheduledTaskEntity $task, NewsletterEntity $newsletter, array $subscribers, $timer) {
// determine if processing is done in bulk or individually
$processingMethod = $this->mailerTask->getProcessingMethod();
$preparedNewsletters = [];
$preparedSubscribers = [];
$preparedSubscribersIds = [];
$unsubscribeUrls = [];
$statistics = [];
$metas = [];
$oneClickUnsubscribeUrls = [];
$sendingQueueEntity = $task->getSendingQueue();
if (!$sendingQueueEntity) {
return;
}
$sendingQueueMeta = $sendingQueueEntity->getMeta() ?? [];
$campaignId = $sendingQueueMeta['campaignId'] ?? null;
foreach ($subscribers as $subscriber) {
// render shortcodes and replace subscriber data in tracked links
$preparedNewsletters[] =
$this->newsletterTask->prepareNewsletterForSending(
$newsletter,
$subscriber,
$sendingQueueEntity
);
// format subscriber name/address according to mailer settings
$preparedSubscribers[] = $this->mailerTask->prepareSubscriberForSending(
$subscriber
);
$preparedSubscribersIds[] = $subscriber->getId();
// create personalized instant unsubsribe link
$unsubscribeUrls[] = $this->links->getUnsubscribeUrl($sendingQueueEntity->getId(), $subscriber);
$oneClickUnsubscribeUrls[] = $this->links->getOneClickUnsubscribeUrl($sendingQueueEntity->getId(), $subscriber);
$metasForSubscriber = $this->mailerMetaInfo->getNewsletterMetaInfo($newsletter, $subscriber);
if ($campaignId) {
$metasForSubscriber['campaign_id'] = $campaignId;
}
$metas[] = $metasForSubscriber;
// keep track of values for statistics purposes
$statistics[] = [
'newsletter_id' => $newsletter->getId(),
'subscriber_id' => $subscriber->getId(),
'queue_id' => $sendingQueueEntity->getId(),
];
if ($processingMethod === 'individual') {
$this->sendNewsletter(
$task,
$preparedSubscribersIds[0],
$preparedNewsletters[0],
$preparedSubscribers[0],
$statistics[0],
$timer,
[
'unsubscribe_url' => $unsubscribeUrls[0],
'meta' => $metas[0],
'one_click_unsubscribe' => $oneClickUnsubscribeUrls[0],
]
);
$preparedNewsletters = [];
$preparedSubscribers = [];
$preparedSubscribersIds = [];
$unsubscribeUrls = [];
$oneClickUnsubscribeUrls = [];
$statistics = [];
$metas = [];
}
}
if ($processingMethod === 'bulk') {
$this->sendNewsletters(
$task,
$preparedSubscribersIds,
$preparedNewsletters,
$preparedSubscribers,
$statistics,
$timer,
[
'unsubscribe_url' => $unsubscribeUrls,
'meta' => $metas,
'one_click_unsubscribe' => $oneClickUnsubscribeUrls,
]
);
}
}
public function sendNewsletter(
ScheduledTaskEntity $task, $preparedSubscriberId, $preparedNewsletter,
$preparedSubscriber, $statistics, $timer, $extraParams = []
) {
// send newsletter
$sendResult = $this->mailerTask->send(
$preparedNewsletter,
$preparedSubscriber,
$extraParams
);
$this->processSendResult(
$task,
$sendResult,
[$preparedSubscriber],
[$preparedSubscriberId],
[$statistics],
$timer
);
}
public function sendNewsletters(
ScheduledTaskEntity $task, $preparedSubscribersIds, $preparedNewsletters,
$preparedSubscribers, $statistics, $timer, $extraParams = []
) {
// send newsletters
$sendResult = $this->mailerTask->sendBulk(
$preparedNewsletters,
$preparedSubscribers,
$extraParams
);
$this->processSendResult(
$task,
$sendResult,
$preparedSubscribers,
$preparedSubscribersIds,
$statistics,
$timer
);
}
/**
* Checks whether some of segments was deleted or trashed
* @param int[] $segmentIds
*/
private function checkDeletedSegments(array $segmentIds): bool {
if (count($segmentIds) === 0) {
return true;
}
$segmentIds = array_unique($segmentIds);
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
// Some segment was deleted from DB
if (count($segmentIds) > count($segments)) {
return false;
}
foreach ($segments as $segment) {
if ($segment->getDeletedAt() !== null) {
return false;
}
}
return true;
}
private function processSendResult(
ScheduledTaskEntity $task,
$sendResult,
array $preparedSubscribers,
array $preparedSubscribersIds,
array $statistics,
$timer
) {
// log error message and schedule retry/pause sending
if ($sendResult['response'] === false) {
$error = $sendResult['error'];
$this->errorHandler->processError($error, $task, $preparedSubscribersIds, $preparedSubscribers);
} else {
$queue = $task->getSendingQueue();
if (!$queue) {
return;
}
try {
$this->scheduledTaskSubscribersRepository->updateProcessedSubscribers($task, $preparedSubscribersIds);
$this->sendingQueuesRepository->updateCounts($queue);
} catch (Throwable $e) {
MailerLog::processError(
'processed_list_update',
sprintf('QUEUE-%d-PROCESSED-LIST-UPDATE', $queue->getId()),
null,
true
);
}
}
// log statistics
$this->statisticsNewslettersRepository->createMultiple($statistics);
// update the sent count
$this->mailerTask->updateSentCount();
// enforce execution limits if queue is still being processed
if ($task->getStatus() !== ScheduledTaskEntity::STATUS_COMPLETED) {
$this->enforceSendingAndExecutionLimits($timer);
}
// trigger automation email sent hook for automation emails
if (
$task->getStatus() === ScheduledTaskEntity::STATUS_COMPLETED
&& isset($task->getMeta()['automation'])
) {
try {
$this->wp->doAction('mailpoet_automation_email_sent', $task->getMeta()['automation']);
} catch (Throwable $e) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->error(
'Error while executing "mailpoet_automation_email_sent action" hook',
['task_id' => $task->getId(), 'error' => $e->getMessage()]
);
}
}
$this->throttlingHandler->processSuccess();
}
public function enforceSendingAndExecutionLimits($timer) {
// abort if execution limit is reached
$this->cronHelper->enforceExecutionLimit($timer);
// abort if sending limit has been reached
MailerLog::enforceExecutionRequirements();
}
private function reScheduleBounceTask() {
$bounceTasks = $this->scheduledTasksRepository->findFutureScheduledByType(Bounce::TASK_TYPE);
if (count($bounceTasks)) {
$bounceTask = reset($bounceTasks);
if (Carbon::now()->millisecond(0)->addHours(42)->lessThan($bounceTask->getScheduledAt())) {
$randomOffset = rand(-6 * 60 * 60, 6 * 60 * 60);
$bounceTask->setScheduledAt(Carbon::now()->millisecond(0)->addSeconds((36 * 60 * 60) + $randomOffset));
$this->scheduledTasksRepository->persist($bounceTask);
$this->scheduledTasksRepository->flush();
}
}
}
private function startProgress(ScheduledTaskEntity $task): void {
$task->setInProgress(true);
$this->scheduledTasksRepository->flush();
}
private function stopProgress(ScheduledTaskEntity $task): void {
// if task is not managed by entity manager, it's already deleted and detached
// it can be deleted in self::processSending method
if (!$this->entityManager->contains($task)) {
return;
}
$task->setInProgress(false);
$this->scheduledTasksRepository->flush();
}
private function isTimeout(ScheduledTaskEntity $task): bool {
$currentTime = Carbon::now()->millisecond(0);
$updatedAt = new Carbon($task->getUpdatedAt());
if ($updatedAt->diffInSeconds($currentTime, false) > $this->getExecutionLimit()) {
return true;
}
return false;
}
private function getExecutionLimit(): int {
return $this->cronHelper->getDaemonExecutionLimit() * 3;
}
private function deleteTaskIfNewsletterDoesNotExist(ScheduledTaskEntity $task) {
$queue = $task->getSendingQueue();
$newsletter = $queue ? $queue->getNewsletter() : null;
if ($newsletter !== null) {
return;
}
$this->deleteTask($task);
}
private function deleteTask(ScheduledTaskEntity $task) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'delete task in sending queue',
['task_id' => $task->getId()]
);
$queue = $task->getSendingQueue();
if ($queue) {
$this->sendingQueuesRepository->remove($queue);
}
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
$this->scheduledTasksRepository->remove($task);
$this->scheduledTasksRepository->flush();
}
private function endSending(ScheduledTaskEntity $task, NewsletterEntity $newsletter): void {
// We should handle all transitions into these states in the processSending method and end processing there or we throw an exception
// This might theoretically happen when multiple cron workers are running in parallel which we don't support and try to prevent
$unexpectedStates = [
ScheduledTaskEntity::STATUS_PAUSED,
ScheduledTaskEntity::STATUS_INVALID,
ScheduledTaskEntity::STATUS_SCHEDULED,
];
if (in_array($task->getStatus(), $unexpectedStates)) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->error(
'Sending task reached end of processing in sending queue worker in an unexpected state.',
['task_id' => $task->getId(), 'status' => $task->getStatus()]
);
return;
}
// The task is running but there is no one to send to.
// This may happen when we send to all but the execution is interrupted (e.g. by PHP time limit) and we don't update the task status
// or if we trigger sending to a newsletter without any subscriber (e.g. scheduled for long time but all were deleted)
// Lets set status to completed and update the queue counts
if ($task->getStatus() === null && $this->scheduledTaskSubscribersRepository->countUnprocessed($task) === 0) {
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
$queue = $task->getSendingQueue();
if ($queue) {
$this->sendingQueuesRepository->updateCounts($queue);
}
$this->scheduledTasksRepository->flush();
}
// Task is completed let's do all the stuff for the completed task
if ($task->getStatus() === ScheduledTaskEntity::STATUS_COMPLETED) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'completed newsletter sending',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
$this->newsletterTask->markNewsletterAsSent($newsletter);
$this->statsNotificationsScheduler->schedule($newsletter);
}
}
}
@@ -0,0 +1,88 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue;
if (!defined('ABSPATH')) exit;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Settings\SettingsController;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Monolog\Logger;
class SendingThrottlingHandler {
public const BATCH_SIZE = 20;
public const SETTINGS_KEY = 'mta_throttling';
public const SUCCESS_THRESHOLD_TO_INCREASE = 10;
/** @var Logger */
private $logger;
/** @var SettingsController */
private $settings;
/** @var WPFunctions */
private $wp;
public function __construct(
LoggerFactory $loggerFactory,
SettingsController $settings,
WPFunctions $wp
) {
$this->logger = $loggerFactory->getLogger(LoggerFactory::TOPIC_SENDING);
$this->settings = $settings;
$this->wp = $wp;
}
public function getBatchSize(): int {
$throttlingSettings = $this->loadSettings();
if (isset($throttlingSettings['batch_size'])) {
return $throttlingSettings['batch_size'];
}
return $this->getMaxBatchSize();
}
private function getMaxBatchSize(): int {
return $this->wp->applyFilters('mailpoet_cron_worker_sending_queue_batch_size', self::BATCH_SIZE);
}
public function throttleBatchSize(): int {
$batchSize = $this->getBatchSize();
if ($batchSize > 1) {
$batchSize = (int)ceil($this->getBatchSize() / 2);
$throttlingSettings = $this->loadSettings();
$throttlingSettings['batch_size'] = $batchSize;
unset($throttlingSettings['success_count']);
$this->logger->error("MailPoet throttling: decrease batch_size to: {$batchSize}");
$this->saveSettings($throttlingSettings);
}
return $batchSize;
}
public function processSuccess(): void {
$throttlingSettings = $this->loadSettings();
if (!isset($throttlingSettings['batch_size'])) {
return;
}
$throttlingSettings['success_count'] = isset($throttlingSettings['success_count']) ? ++$throttlingSettings['success_count'] : 1;
$this->logger->info("MailPoet throttling: increase success_count to: {$throttlingSettings['success_count']}");
if ($throttlingSettings['success_count'] >= self::SUCCESS_THRESHOLD_TO_INCREASE) {
unset($throttlingSettings['success_count']);
$throttlingSettings['batch_size'] = min($this->getMaxBatchSize(), $throttlingSettings['batch_size'] * 2);
$this->logger->info("MailPoet throttling: increase batch_size to: {$throttlingSettings['batch_size']}");
if ($this->getMaxBatchSize() === $throttlingSettings['batch_size']) {
unset($throttlingSettings['batch_size']);
}
}
$this->saveSettings($throttlingSettings);
}
private function loadSettings(): ?array {
return $this->settings->get(self::SETTINGS_KEY);
}
private function saveSettings(array $settings): void {
$this->settings->set(self::SETTINGS_KEY, $settings);
}
}
@@ -0,0 +1,105 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\Links\Links as NewsletterLinks;
use MailPoet\Router\Endpoints\Track;
use MailPoet\Router\Router;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Subscribers\LinkTokens;
use MailPoet\Subscription\SubscriptionUrlFactory;
use MailPoet\Util\Helpers;
class Links {
/** @var LinkTokens */
private $linkTokens;
/** @var NewsletterLinks */
private $newsletterLinks;
/** @var NewsletterLinkRepository */
private $newsletterLinkRepository;
/** @var TrackingConfig */
private $trackingConfig;
public function __construct(
LinkTokens $linkTokens,
NewsletterLinks $newsletterLinks,
NewsletterLinkRepository $newsletterLinkRepository,
TrackingConfig $trackingConfig
) {
$this->linkTokens = $linkTokens;
$this->newsletterLinks = $newsletterLinks;
$this->newsletterLinkRepository = $newsletterLinkRepository;
$this->trackingConfig = $trackingConfig;
}
public function process($renderedNewsletter, NewsletterEntity $newsletter, SendingQueueEntity $queue) {
[$renderedNewsletter, $links] = $this->hashAndReplaceLinks($renderedNewsletter, $newsletter->getId(), $queue->getId());
$this->saveLinks($links, $newsletter, $queue);
return $renderedNewsletter;
}
public function hashAndReplaceLinks($renderedNewsletter, $newsletterId, $queueId) {
// join HTML and TEXT rendered body into a text string
$content = Helpers::joinObject($renderedNewsletter);
[$content, $links] = $this->newsletterLinks->process($content, $newsletterId, $queueId);
$links = $this->newsletterLinks->ensureInstantUnsubscribeLink($links);
// split the processed body with hashed links back to HTML and TEXT
list($renderedNewsletter['html'], $renderedNewsletter['text'])
= Helpers::splitObject($content);
return [
$renderedNewsletter,
$links,
];
}
public function saveLinks($links, NewsletterEntity $newsletter, SendingQueueEntity $queue) {
return $this->newsletterLinks->save($links, $newsletter->getId(), $queue->getId());
}
public function getUnsubscribeUrl($queueId, SubscriberEntity $subscriber = null) {
if ($this->trackingConfig->isEmailTrackingEnabled() && $subscriber) {
$linkHash = $this->newsletterLinkRepository->findOneBy(
[
'queue' => $queueId,
'url' => NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE,
]
);
if (!$linkHash instanceof NewsletterLinkEntity) {
return '';
}
$data = $this->newsletterLinks->createUrlDataObject(
$subscriber->getId(),
$this->linkTokens->getToken($subscriber),
$queueId,
$linkHash->getHash(),
false
);
$url = Router::buildRequest(
Track::ENDPOINT,
Track::ACTION_CLICK,
$data
);
} else {
$subscriptionUrlFactory = SubscriptionUrlFactory::getInstance();
$url = $subscriptionUrlFactory->getUnsubscribeUrl($subscriber, $queueId);
}
return $url;
}
public function getOneClickUnsubscribeUrl($queueId, SubscriberEntity $subscriber): string {
$subscriptionUrlFactory = SubscriptionUrlFactory::getInstance();
return $subscriptionUrlFactory->getUnsubscribeUrl($subscriber, $queueId);
}
}
@@ -0,0 +1,91 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Mailer\Mailer as MailerInstance;
use MailPoet\Mailer\MailerFactory;
use MailPoet\Mailer\MailerLog;
use MailPoet\Mailer\Methods\MailPoet;
class Mailer {
/** @var MailerFactory */
private $mailerFactory;
/** @var MailerInstance */
private $mailer;
public function __construct(
MailerFactory $mailerFactory
) {
$this->mailerFactory = $mailerFactory;
$this->mailer = $this->configureMailer();
}
public function configureMailer(NewsletterEntity $newsletter = null) {
$sender['address'] = ($newsletter && !empty($newsletter->getSenderAddress())) ?
$newsletter->getSenderAddress() :
null;
$sender['name'] = ($newsletter && !empty($newsletter->getSenderName())) ?
$newsletter->getSenderName() :
null;
$replyTo['address'] = ($newsletter && !empty($newsletter->getReplyToAddress())) ?
$newsletter->getReplyToAddress() :
null;
$replyTo['name'] = ($newsletter && !empty($newsletter->getReplyToName())) ?
$newsletter->getReplyToName() :
null;
if (!$sender['address']) {
$sender = null;
}
if (!$replyTo['address']) {
$replyTo = null;
}
$this->mailer = $this->mailerFactory->buildMailer(null, $sender, $replyTo);
return $this->mailer;
}
public function getMailerLog() {
return MailerLog::getMailerLog();
}
public function updateSentCount() {
return MailerLog::incrementSentCount();
}
public function getProcessingMethod() {
return ($this->mailer->mailerMethod instanceof MailPoet) ?
'bulk' :
'individual';
}
public function prepareSubscriberForSending(SubscriberEntity $subscriber) {
return $this->mailer->formatSubscriberNameAndEmailAddress($subscriber);
}
public function sendBulk($preparedNewsletters, $preparedSubscribers, $extraParams = []) {
if ($this->getProcessingMethod() === 'individual') {
throw new \LogicException('Trying to send a batch with individual processing method');
}
return $this->mailer->mailerMethod->send(
$preparedNewsletters,
$preparedSubscribers,
$extraParams
);
}
public function send($preparedNewsletter, $preparedSubscriber, $extraParams = []) {
if ($this->getProcessingMethod() === 'bulk') {
throw new \LogicException('Trying to send an individual email with a bulk processing method');
}
return $this->mailer->mailerMethod->send(
$preparedNewsletter,
$preparedSubscriber,
$extraParams
);
}
}
@@ -0,0 +1,388 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Links as LinksTask;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Posts as PostsTask;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Shortcodes as ShortcodesTask;
use MailPoet\DI\ContainerWrapper;
use MailPoet\EmailEditor\Engine\Personalizer;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Mailer\MailerLog;
use MailPoet\Newsletter\Links\Links as NewsletterLinks;
use MailPoet\Newsletter\NewsletterDeleteController;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Renderer\PostProcess\OpenTracking;
use MailPoet\Newsletter\Renderer\Renderer;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
use MailPoet\RuntimeException;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Statistics\GATracking;
use MailPoet\Util\Helpers;
use MailPoet\Util\pQuery\DomNode;
use MailPoet\Util\pQuery\pQuery;
use MailPoet\WP\Emoji;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class Newsletter {
public $trackingEnabled;
public $trackingImageInserted;
/** @var WPFunctions */
private $wp;
/** @var PostsTask */
private $postsTask;
/** @var GATracking */
private $gaTracking;
/** @var LoggerFactory */
private $loggerFactory;
/** @var Renderer */
private $renderer;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var NewsletterDeleteController */
private $newsletterDeleteController;
/** @var Emoji */
private $emoji;
/** @var LinksTask */
private $linksTask;
/** @var NewsletterLinks */
private $newsletterLinks;
/** @var SendingQueuesRepository */
private $sendingQueuesRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var ScheduledTasksRepository */
private $scheduledTasksRepository;
/** @var Personalizer */
private $personalizer;
public function __construct(
WPFunctions $wp = null,
PostsTask $postsTask = null,
GATracking $gaTracking = null,
Emoji $emoji = null
) {
$trackingConfig = ContainerWrapper::getInstance()->get(TrackingConfig::class);
$this->trackingEnabled = $trackingConfig->isEmailTrackingEnabled();
if ($wp === null) {
$wp = new WPFunctions;
}
$this->wp = $wp;
if ($postsTask === null) {
$postsTask = new PostsTask;
}
$this->postsTask = $postsTask;
if ($gaTracking === null) {
$gaTracking = ContainerWrapper::getInstance()->get(GATracking::class);
}
$this->gaTracking = $gaTracking;
$this->loggerFactory = LoggerFactory::getInstance();
if ($emoji === null) {
$emoji = new Emoji();
}
$this->emoji = $emoji;
$this->renderer = ContainerWrapper::getInstance()->get(Renderer::class);
$this->newslettersRepository = ContainerWrapper::getInstance()->get(NewslettersRepository::class);
$this->newsletterDeleteController = ContainerWrapper::getInstance()->get(NewsletterDeleteController::class);
$this->linksTask = ContainerWrapper::getInstance()->get(LinksTask::class);
$this->newsletterLinks = ContainerWrapper::getInstance()->get(NewsletterLinks::class);
$this->sendingQueuesRepository = ContainerWrapper::getInstance()->get(SendingQueuesRepository::class);
$this->segmentsRepository = ContainerWrapper::getInstance()->get(SegmentsRepository::class);
$this->scheduledTasksRepository = ContainerWrapper::getInstance()->get(ScheduledTasksRepository::class);
$this->personalizer = ContainerWrapper::getInstance()->get(Personalizer::class);
}
public function getNewsletterFromQueue(ScheduledTaskEntity $task): ?NewsletterEntity {
// get existing active or sending newsletter
$queue = $task->getSendingQueue();
$newsletter = $queue ? $queue->getNewsletter() : null;
if (
is_null($newsletter)
|| $newsletter->getDeletedAt() !== null
|| !in_array($newsletter->getStatus(), [NewsletterEntity::STATUS_ACTIVE, NewsletterEntity::STATUS_SENDING])
|| $newsletter->getStatus() === NewsletterEntity::STATUS_CORRUPT
) {
$this->recoverFromInvalidState($task);
return null;
}
// if this is a notification history, get existing active or sending parent newsletter
if ($newsletter->getType() == NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
$parentNewsletter = $newsletter->getParent();
if (
is_null($parentNewsletter)
|| $parentNewsletter->getDeletedAt() !== null
|| !in_array($parentNewsletter->getStatus(), [NewsletterEntity::STATUS_ACTIVE, NewsletterEntity::STATUS_SENDING])
) {
return null;
}
}
return $newsletter;
}
/**
* Pre-processes the newsletter before sending.
* - Renders the newsletter
* - Adds tracking
* - Extracts links
* - Checks if the newsletter is a post notification and if it contains at least 1 ALC post.
* If not it deletes the notification history record and all associate entities.
*
* @return NewsletterEntity|false - Returns false only if the newsletter is a post notification history and was deleted.
*
*/
public function preProcessNewsletter(NewsletterEntity $newsletter, ScheduledTaskEntity $task) {
// return the newsletter if it was previously rendered
$queue = $task->getSendingQueue();
if (!$queue) {
throw new RuntimeException('Cant pre-process newsletter without queue.');
}
if ($queue->getNewsletterRenderedBody() !== null) {
return $newsletter;
}
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS)->info(
'pre-processing newsletter',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
$campaignId = null;
// if tracking is enabled, do additional processing
if ($this->trackingEnabled) {
// hook to the newsletter post-processing filter and add tracking image
$this->trackingImageInserted = OpenTracking::addTrackingImage();
// render newsletter
$renderedNewsletter = $this->renderer->render($newsletter, $queue);
$renderedNewsletter = $this->wp->applyFilters(
'mailpoet_sending_newsletter_render_after_pre_process',
$renderedNewsletter,
$newsletter
);
if (is_array($renderedNewsletter)) {
$campaignId = $this->calculateCampaignId($newsletter, $renderedNewsletter);
}
$renderedNewsletter = $this->gaTracking->applyGATracking($renderedNewsletter, $newsletter);
// hash and save all links
$renderedNewsletter = $this->linksTask->process($renderedNewsletter, $newsletter, $queue);
} else {
// render newsletter
$renderedNewsletter = $this->renderer->render($newsletter, $queue);
$renderedNewsletter = $this->wp->applyFilters(
'mailpoet_sending_newsletter_render_after_pre_process',
$renderedNewsletter,
$newsletter
);
if (is_array($renderedNewsletter)) {
$campaignId = $this->calculateCampaignId($newsletter, $renderedNewsletter);
}
$renderedNewsletter = $this->gaTracking->applyGATracking($renderedNewsletter, $newsletter);
}
// check if this is a post notification and if it contains at least 1 ALC post
if (
$newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION_HISTORY &&
$this->postsTask->getAlcPostsCount($renderedNewsletter, $newsletter) === 0
) {
// delete notification history record since it will never be sent
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'no posts in post notification, deleting it',
['newsletter_id' => $newsletter->getId(), 'task_id' => $task->getId()]
);
$this->newsletterDeleteController->bulkDelete([(int)$newsletter->getId()]);
return false;
}
// extract and save newsletter posts
$this->postsTask->extractAndSave($renderedNewsletter, $newsletter);
if ($campaignId !== null) {
$this->sendingQueuesRepository->saveCampaignId($queue, $campaignId);
}
$filterSegmentId = $newsletter->getFilterSegmentId();
if ($filterSegmentId) {
$filterSegment = $this->segmentsRepository->findOneById($filterSegmentId);
if ($filterSegment instanceof SegmentEntity && $filterSegment->getType() === SegmentEntity::TYPE_DYNAMIC) {
$this->sendingQueuesRepository->saveFilterSegmentMeta($queue, $filterSegment);
}
}
// update queue with the rendered and pre-processed newsletter
$queue->setNewsletterRenderedSubject(
ShortcodesTask::process(
$newsletter->getSubject(),
$renderedNewsletter['html'],
$newsletter,
null,
$queue
)
);
// if the rendered subject is empty, use a default subject,
// having no subject in a newsletter is considered spammy
if (empty(trim((string)$queue->getNewsletterRenderedSubject()))) {
$queue->setNewsletterRenderedSubject(__('No subject', 'mailpoet'));
}
$renderedNewsletter = $this->emoji->encodeEmojisInBody($renderedNewsletter);
$queue->setNewsletterRenderedBody($renderedNewsletter);
try {
$this->sendingQueuesRepository->flush();
} catch (\Throwable $e) {
$this->stopNewsletterPreProcessing(sprintf('QUEUE-%d-SAVE', $queue->getId()));
}
return $newsletter;
}
/**
* Shortcodes and links will be replaced in the subject, html and text body
* to speed the processing, join content into a continuous string.
*/
public function prepareNewsletterForSending(NewsletterEntity $newsletter, SubscriberEntity $subscriber, SendingQueueEntity $queue): array {
$renderedNewsletter = $queue->getNewsletterRenderedBody();
$renderedNewsletter = $this->emoji->decodeEmojisInBody($renderedNewsletter);
$preparedNewsletter = Helpers::joinObject(
[
$queue->getNewsletterRenderedSubject(),
$renderedNewsletter['html'],
$renderedNewsletter['text'],
]
);
$preparedNewsletter = ShortcodesTask::process(
$preparedNewsletter,
null,
$newsletter,
$subscriber,
$queue
);
if ($this->trackingEnabled) {
$preparedNewsletter = $this->newsletterLinks->replaceSubscriberData(
$subscriber->getId(),
$queue->getId(),
$preparedNewsletter
);
}
$preparedNewsletter = Helpers::splitObject($preparedNewsletter);
if ($newsletter->getWpPostId() !== null) {
$this->personalizer->set_context([
'recipient_email' => $subscriber->getEmail(),
'newsletter_id' => $newsletter->getId(),
'queue_id' => $queue->getId(),
]);
foreach ($preparedNewsletter as $key => $content) {
$preparedNewsletter[$key] = $this->personalizer->personalize_content($content);
}
}
return [
'id' => $newsletter->getId(),
'subject' => $preparedNewsletter[0],
'body' => [
'html' => $preparedNewsletter[1],
'text' => $preparedNewsletter[2],
],
];
}
public function markNewsletterAsSent(NewsletterEntity $newsletter) {
// if it's a standard or notification history newsletter, update its status
if (
$newsletter->getType() === NewsletterEntity::TYPE_STANDARD ||
$newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION_HISTORY
) {
$newsletter->setStatus(NewsletterEntity::STATUS_SENT);
$newsletter->setSentAt(Carbon::now()->millisecond(0));
$this->newslettersRepository->persist($newsletter);
$this->newslettersRepository->flush();
}
}
public function stopNewsletterPreProcessing($errorCode = null) {
MailerLog::processError(
'queue_save',
__('There was an error processing your newsletter during sending. If possible, please contact us and report this issue.', 'mailpoet'),
$errorCode
);
}
/**
* @param NewsletterEntity $newsletter
* @param array $renderedNewsletters - The pre-processed renderered newsletters, before link tracking has been added or shortcodes have been processed.
*
* @return string
*/
public function calculateCampaignId(NewsletterEntity $newsletter, array $renderedNewsletters): string {
$relevantContent = [
$newsletter->getId(),
$newsletter->getSubject(),
];
if (isset($renderedNewsletters['text'])) {
$relevantContent[] = $renderedNewsletters['text'];
}
// The text version of emails contains just the alt text of images, which could be the same for multiple images. In order to ensure
// campaign IDs change when images change, we should consider all image URLs.
if (isset($renderedNewsletters['html'])) {
$html = pQuery::parseStr($renderedNewsletters['html']);
if ($html instanceof DomNode) {
foreach ($html->query('img') as $imageNode) {
$src = $imageNode->getAttribute('src');
if (is_string($src)) {
$relevantContent[] = $src;
}
}
}
}
return substr(md5(implode('|', $relevantContent)), 0, 16);
}
/**
* This method recovers the scheduled task and newsletter from a state when sending cannot proceed.
*/
private function recoverFromInvalidState(ScheduledTaskEntity $task): void {
// When newsletter does not exist, we need to remove the scheduled task and sending queue.
$queue = $task->getSendingQueue();
$newsletter = $queue ? $queue->getNewsletter() : null;
if (!$newsletter) {
$this->scheduledTasksRepository->remove($task);
if ($queue) {
$this->sendingQueuesRepository->remove($queue);
}
$this->sendingQueuesRepository->flush();
return;
}
// Only deleted newsletter or newsletter with unexpected state should pass here.
// Because this state cannot proceed with sending, we need to pause the scheduled task.
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
$this->scheduledTasksRepository->flush();
}
}
@@ -0,0 +1,68 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
if (!defined('ABSPATH')) exit;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterPostEntity;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\NewsletterPostsRepository;
class Posts {
/** @var LoggerFactory */
private $loggerFactory;
/** @var NewsletterPostsRepository */
private $newsletterPostRepository;
public function __construct() {
$this->loggerFactory = LoggerFactory::getInstance();
$this->newsletterPostRepository = ContainerWrapper::getInstance()->get(NewsletterPostsRepository::class);
}
public function extractAndSave($renderedNewsletter, NewsletterEntity $newsletter): bool {
if ($newsletter->getType() !== NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
return false;
}
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'extract and save posts - before',
['newsletter_id' => $newsletter->getId()]
);
preg_match_all(
'/data-post-id="(\d+)"/ism',
$renderedNewsletter['html'],
$matchedPostsIds
);
$matchedPostsIds = $matchedPostsIds[1];
if (!count($matchedPostsIds)) {
return false;
}
$parent = $newsletter->getParent(); // parent post notification
if (!$parent instanceof NewsletterEntity) {
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'parent post has not been found',
['newsletter_id' => $newsletter->getId()]
);
return false;
}
foreach ($matchedPostsIds as $postId) {
$newsletterPost = new NewsletterPostEntity($parent, $postId);
$this->newsletterPostRepository->persist($newsletterPost);
}
$this->newsletterPostRepository->flush();
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
'extract and save posts - after',
['newsletter_id' => $newsletter->getId(), 'matched_posts_ids' => $matchedPostsIds]
);
return true;
}
public function getAlcPostsCount($renderedNewsletter, NewsletterEntity $newsletter) {
$templatePostsCount = substr_count($newsletter->getContent(), 'data-post-id');
$newsletterPostsCount = substr_count($renderedNewsletter['html'], 'data-post-id');
return $newsletterPostsCount - $templatePostsCount;
}
}
@@ -0,0 +1,45 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
if (!defined('ABSPATH')) exit;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\Shortcodes\Shortcodes as NewsletterShortcodes;
class Shortcodes {
/**
* @param string $content
* @param string|null $contentSource
* @param NewsletterEntity|null $newsletter
* @param SubscriberEntity|null $subscriber
* @param SendingQueueEntity|null $queue
*/
public static function process($content, $contentSource = null, NewsletterEntity $newsletter = null, SubscriberEntity $subscriber = null, SendingQueueEntity $queue = null) {
/** @var NewsletterShortcodes $shortcodes */
$shortcodes = ContainerWrapper::getInstance()->get(NewsletterShortcodes::class);
if ($queue instanceof SendingQueueEntity) {
$shortcodes->setQueue($queue);
} else {
$shortcodes->setQueue(null);
}
if ($newsletter instanceof NewsletterEntity) {
$shortcodes->setNewsletter($newsletter);
} else {
$shortcodes->setNewsletter(null);
}
if ($subscriber instanceof SubscriberEntity) {
$shortcodes->setSubscriber($subscriber);
} else {
$shortcodes->setSubscriber(null);
}
return $shortcodes->replace($content, $contentSource);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,92 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\CronWorkerInterface;
use MailPoet\Cron\CronWorkerRunner;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
use MailPoetVendor\Carbon\Carbon;
abstract class SimpleWorker implements CronWorkerInterface {
const TASK_TYPE = null;
const AUTOMATIC_SCHEDULING = true;
const SUPPORT_MULTIPLE_INSTANCES = true;
public $timer;
/** @var CronHelper */
protected $cronHelper;
/** @var CronWorkerScheduler */
protected $cronWorkerScheduler;
/** @var ScheduledTasksRepository */
protected $scheduledTasksRepository;
public function __construct() {
if (static::TASK_TYPE === null) {
throw new \Exception('Constant TASK_TYPE is not defined on subclass ' . get_class($this));
}
$this->cronHelper = ContainerWrapper::getInstance()->get(CronHelper::class);
$this->cronWorkerScheduler = ContainerWrapper::getInstance()->get(CronWorkerScheduler::class);
$this->scheduledTasksRepository = ContainerWrapper::getInstance()->get(ScheduledTasksRepository::class);
}
public function getTaskType() {
return static::TASK_TYPE;
}
public function supportsMultipleInstances() {
return static::SUPPORT_MULTIPLE_INSTANCES;
}
public function schedule() {
$this->cronWorkerScheduler->schedule(static::TASK_TYPE, $this->getNextRunDate());
}
protected function scheduleImmediately(): void {
$this->cronWorkerScheduler->schedule(static::TASK_TYPE, $this->getNextRunDateImmediately());
}
public function checkProcessingRequirements() {
return true;
}
public function init() {
}
public function prepareTaskStrategy(ScheduledTaskEntity $task, $timer) {
return true;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
return true;
}
public function getNextRunDate() {
// random day of the next week
$date = Carbon::now()->millisecond(0);
$date->setISODate((int)$date->format('o'), ((int)$date->format('W')) + 1, mt_rand(1, 7));
$date->startOfDay();
return $date;
}
protected function getNextRunDateImmediately(): Carbon {
return Carbon::now()->millisecond(0);
}
public function scheduleAutomatically() {
return static::AUTOMATIC_SCHEDULING;
}
protected function getCompletedTasks() {
return $this->scheduledTasksRepository->findCompletedByType(static::TASK_TYPE, CronWorkerRunner::TASK_BATCH_SIZE);
}
}
@@ -0,0 +1,171 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\StatsNotifications;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Renderer;
use MailPoet\Cron\Workers\SimpleWorker;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Mailer\MailerFactory;
use MailPoet\Mailer\MetaInfo;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Statistics\NewsletterStatistics;
use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Settings\TrackingConfig;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class AutomatedEmails extends SimpleWorker {
const TASK_TYPE = 'stats_notification_automated_emails';
/** @var MailerFactory */
private $mailerFactory;
/** @var SettingsController */
private $settings;
/** @var Renderer */
private $renderer;
/** @var MetaInfo */
private $mailerMetaInfo;
/** @var NewslettersRepository */
private $repository;
/** @var NewsletterStatisticsRepository */
private $newsletterStatisticsRepository;
/** @var TrackingConfig */
private $trackingConfig;
public function __construct(
MailerFactory $mailerFactory,
Renderer $renderer,
SettingsController $settings,
NewslettersRepository $repository,
NewsletterStatisticsRepository $newsletterStatisticsRepository,
MetaInfo $mailerMetaInfo,
TrackingConfig $trackingConfig
) {
parent::__construct();
$this->mailerFactory = $mailerFactory;
$this->settings = $settings;
$this->renderer = $renderer;
$this->mailerMetaInfo = $mailerMetaInfo;
$this->repository = $repository;
$this->newsletterStatisticsRepository = $newsletterStatisticsRepository;
$this->trackingConfig = $trackingConfig;
}
public function checkProcessingRequirements() {
$settings = $this->settings->get(Worker::SETTINGS_KEY);
if (!is_array($settings)) {
return false;
}
if (!isset($settings['automated'])) {
return false;
}
if (!isset($settings['address'])) {
return false;
}
if (empty(trim($settings['address']))) {
return false;
}
if (!$this->trackingConfig->isEmailTrackingEnabled()) {
return false;
}
return (bool)$settings['automated'];
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
try {
$settings = $this->settings->get(Worker::SETTINGS_KEY);
$newsletters = $this->getNewsletters();
if ($newsletters) {
$extraParams = [
'meta' => $this->mailerMetaInfo->getStatsNotificationMetaInfo(),
];
$this->mailerFactory->getDefaultMailer()->send($this->constructNewsletter($newsletters), $settings['address'], $extraParams);
}
} catch (\Exception $e) {
if (WP_DEBUG) {
throw $e;
}
}
return true;
}
/**
* @param array<int, array{newsletter: NewsletterEntity, statistics: NewsletterStatistics}> $newsletters
*/
private function constructNewsletter(array $newsletters): array {
$context = $this->prepareContext($newsletters);
return [
'subject' => __('Your monthly stats are in!', 'mailpoet'),
'body' => [
'html' => $this->renderer->render('emails/statsNotificationAutomatedEmails.html', $context),
'text' => $this->renderer->render('emails/statsNotificationAutomatedEmails.txt', $context),
],
];
}
/**
* @return array<int, array{newsletter: NewsletterEntity, statistics: NewsletterStatistics}>
*/
protected function getNewsletters(): array {
$result = [];
$newsletters = $this->repository->findActiveByTypes(
[NewsletterEntity::TYPE_AUTOMATIC, NewsletterEntity::TYPE_WELCOME]
);
foreach ($newsletters as $newsletter) {
$statistics = $this->newsletterStatisticsRepository->getStatistics($newsletter);
if ($statistics->getTotalSentCount()) {
$result[] = [
'statistics' => $statistics,
'newsletter' => $newsletter,
];
}
}
return $result;
}
/**
* @param array<int, array{newsletter: NewsletterEntity, statistics: NewsletterStatistics}> $newsletters
* @return array
*/
private function prepareContext(array $newsletters): array {
$context = [
'linkSettings' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-settings#basics'),
'newsletters' => [],
];
foreach ($newsletters as $row) {
$statistics = $row['statistics'];
$newsletter = $row['newsletter'];
$clicked = ($statistics->getClickCount() * 100) / $statistics->getTotalSentCount();
$opened = ($statistics->getOpenCount() * 100) / $statistics->getTotalSentCount();
$machineOpened = ($statistics->getMachineOpenCount() * 100) / $statistics->getTotalSentCount();
$unsubscribed = ($statistics->getUnsubscribeCount() * 100) / $statistics->getTotalSentCount();
$bounced = ($statistics->getBounceCount() * 100) / $statistics->getTotalSentCount();
$context['newsletters'][] = [
'linkStats' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-newsletters#/stats/' . $newsletter->getId()),
'clicked' => $clicked,
'opened' => $opened,
'machineOpened' => $machineOpened,
'unsubscribed' => $unsubscribed,
'bounced' => $bounced,
'subject' => $newsletter->getSubject(),
];
}
return $context;
}
public function getNextRunDate() {
$date = Carbon::now()->millisecond(0);
return $date->endOfMonth()->next(Carbon::MONDAY)->midDay();
}
}
@@ -0,0 +1,62 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\StatsNotifications;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoetVendor\Doctrine\DBAL\Result;
/**
* @extends Repository<NewsletterLinkEntity>
*/
class NewsletterLinkRepository extends Repository {
protected function getEntityClassName() {
return NewsletterLinkEntity::class;
}
/**
* @param int $newsletterId
* @return NewsletterLinkEntity|null
*/
public function findTopLinkForNewsletter($newsletterId) {
$statisticsClicksTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName();
$topIdQuery = $this->entityManager->getConnection()->createQueryBuilder()
->select('c.link_id')
->addSelect('count(c.id) AS counter')
->from($statisticsClicksTable, 'c')
->where('c.newsletter_id = :newsletterId')
->setParameter('newsletterId', $newsletterId)
->groupBy('c.link_id')
->orderBy('counter', 'desc')
->setMaxResults(1)
->execute();
if (!$topIdQuery instanceof Result) {
return null;
}
$topId = $topIdQuery->fetch();
if (is_array($topId) && isset($topId['link_id'])) {
return $this->findOneById((int)$topId['link_id']);
}
return null;
}
/** @param int[] $ids */
public function deleteByNewsletterIds(array $ids): void {
$this->entityManager->createQueryBuilder()
->delete(NewsletterLinkEntity::class, 'l')
->where('l.newsletter IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->execute();
// delete was done via DQL, make sure the entities are also detached from the entity manager
$this->detachAll(function (NewsletterLinkEntity $entity) use ($ids) {
$newsletter = $entity->getNewsletter();
return $newsletter && in_array($newsletter->getId(), $ids, true);
});
}
}
@@ -0,0 +1,113 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\StatsNotifications;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\StatsNotificationEntity;
use MailPoet\Settings\SettingsController;
use MailPoet\Settings\TrackingConfig;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class Scheduler {
/**
* How many hours after the newsletter will be the stats notification sent
* @var int
*/
const HOURS_TO_SEND_AFTER_NEWSLETTER = 24;
/** @var SettingsController */
private $settings;
private $supportedTypes = [
NewsletterEntity::TYPE_NOTIFICATION_HISTORY,
NewsletterEntity::TYPE_STANDARD,
];
/** @var EntityManager */
private $entityManager;
/** @var StatsNotificationsRepository */
private $repository;
/** @var TrackingConfig */
private $trackingConfig;
public function __construct(
SettingsController $settings,
EntityManager $entityManager,
StatsNotificationsRepository $repository,
TrackingConfig $trackingConfig
) {
$this->settings = $settings;
$this->entityManager = $entityManager;
$this->repository = $repository;
$this->trackingConfig = $trackingConfig;
}
public function schedule(NewsletterEntity $newsletter) {
if (!$this->shouldSchedule($newsletter)) {
return false;
}
$task = new ScheduledTaskEntity();
$task->setType(Worker::TASK_TYPE);
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
$task->setScheduledAt($this->getNextRunDate());
$this->entityManager->persist($task);
$this->entityManager->flush();
$statsNotifications = new StatsNotificationEntity($newsletter, $task);
$this->entityManager->persist($statsNotifications);
$this->entityManager->flush();
}
private function shouldSchedule(NewsletterEntity $newsletter) {
if ($this->isDisabled()) {
return false;
}
if (!in_array($newsletter->getType(), $this->supportedTypes)) {
return false;
}
if ($this->hasTaskBeenScheduled($newsletter->getId())) {
return false;
}
return true;
}
private function isDisabled() {
$settings = $this->settings->get(Worker::SETTINGS_KEY);
if (!is_array($settings)) {
return true;
}
if (!isset($settings['enabled'])) {
return true;
}
if (!isset($settings['address'])) {
return true;
}
if (empty(trim($settings['address']))) {
return true;
}
if (!$this->trackingConfig->isEmailTrackingEnabled()) {
return true;
}
return !(bool)$settings['enabled'];
}
private function hasTaskBeenScheduled($newsletterId) {
$existing = $this->repository->findOneByNewsletterId($newsletterId);
return $existing instanceof StatsNotificationEntity;
}
private function getNextRunDate() {
$date = new Carbon();
$date->addHours(self::HOURS_TO_SEND_AFTER_NEWSLETTER);
return $date;
}
}
@@ -0,0 +1,87 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\StatsNotifications;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\StatsNotificationEntity;
use MailPoetVendor\Carbon\Carbon;
/**
* @extends Repository<StatsNotificationEntity>
*/
class StatsNotificationsRepository extends Repository {
protected function getEntityClassName() {
return StatsNotificationEntity::class;
}
/**
* @param int $newsletterId
* @return StatsNotificationEntity|null
*/
public function findOneByNewsletterId($newsletterId) {
return $this->doctrineRepository
->createQueryBuilder('sn')
->andWhere('sn.newsletter = :newsletterId')
->setParameter('newsletterId', $newsletterId)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* @param int|null $limit
* @return StatsNotificationEntity[]
*/
public function findScheduled($limit = null) {
$date = new Carbon();
$query = $this->doctrineRepository
->createQueryBuilder('sn')
->join('sn.task', 'tasks')
->join('sn.newsletter', 'n')
->addSelect('tasks')
->addSelect('n')
->addOrderBy('tasks.priority')
->addOrderBy('tasks.updatedAt')
->where('tasks.deletedAt IS NULL')
->andWhere('tasks.status = :status')
->setParameter('status', ScheduledTaskEntity::STATUS_SCHEDULED)
->andWhere('tasks.scheduledAt < :date')
->setParameter('date', $date)
->andWhere('tasks.type = :workerType')
->setParameter('workerType', Worker::TASK_TYPE);
if (is_int($limit)) {
$query->setMaxResults($limit);
}
return $query->getQuery()->getResult();
}
public function deleteOrphanedScheduledTasks() {
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
$statsNotificationsTable = $this->entityManager->getClassMetadata(StatsNotificationEntity::class)->getTableName();
$this->entityManager->getConnection()->executeStatement("
DELETE st FROM $scheduledTasksTable st
LEFT JOIN $statsNotificationsTable sn ON sn.task_id = st.id
WHERE sn.id IS NULL AND st.type = :taskType;
", ['taskType' => Worker::TASK_TYPE]);
}
/** @param int[] $ids */
public function deleteByNewsletterIds(array $ids): void {
$this->entityManager->createQueryBuilder()
->delete(StatsNotificationEntity::class, 'n')
->where('n.newsletter IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->execute();
// delete was done via DQL, make sure the entities are also detached from the entity manager
$this->detachAll(function (StatsNotificationEntity $entity) use ($ids) {
$newsletter = $entity->getNewsletter();
return $newsletter && in_array($newsletter->getId(), $ids, true);
});
}
}
@@ -0,0 +1,205 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers\StatsNotifications;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Renderer;
use MailPoet\Config\ServicesChecker;
use MailPoet\Cron\CronHelper;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterLinkEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SendingQueueEntity;
use MailPoet\Entities\StatsNotificationEntity;
use MailPoet\Mailer\MailerFactory;
use MailPoet\Mailer\MetaInfo;
use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Util\License\Features\Subscribers as SubscribersFeature;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class Worker {
const TASK_TYPE = 'stats_notification';
const SETTINGS_KEY = 'stats_notifications';
const BATCH_SIZE = 5;
/** @var Renderer */
private $renderer;
/** @var MailerFactory */
private $mailerFactory;
/** @var SettingsController */
private $settings;
/** @var CronHelper */
private $cronHelper;
/** @var MetaInfo */
private $mailerMetaInfo;
/** @var StatsNotificationsRepository */
private $repository;
/** @var EntityManager */
private $entityManager;
/** @var NewsletterLinkRepository */
private $newsletterLinkRepository;
/** @var NewsletterStatisticsRepository */
private $newsletterStatisticsRepository;
/** @var SubscribersFeature */
private $subscribersFeature;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var ServicesChecker */
private $servicesChecker;
public function __construct(
MailerFactory $mailerFactory,
Renderer $renderer,
SettingsController $settings,
CronHelper $cronHelper,
MetaInfo $mailerMetaInfo,
StatsNotificationsRepository $repository,
NewsletterLinkRepository $newsletterLinkRepository,
NewsletterStatisticsRepository $newsletterStatisticsRepository,
EntityManager $entityManager,
SubscribersFeature $subscribersFeature,
SubscribersRepository $subscribersRepository,
ServicesChecker $servicesChecker
) {
$this->renderer = $renderer;
$this->mailerFactory = $mailerFactory;
$this->settings = $settings;
$this->cronHelper = $cronHelper;
$this->mailerMetaInfo = $mailerMetaInfo;
$this->repository = $repository;
$this->entityManager = $entityManager;
$this->newsletterLinkRepository = $newsletterLinkRepository;
$this->newsletterStatisticsRepository = $newsletterStatisticsRepository;
$this->subscribersFeature = $subscribersFeature;
$this->subscribersRepository = $subscribersRepository;
$this->servicesChecker = $servicesChecker;
}
/** @throws \Exception */
public function process($timer = false) {
$timer = $timer ?: microtime(true);
$settings = $this->settings->get(self::SETTINGS_KEY);
// Cleanup potential orphaned task created due bug MAILPOET-3015
$this->repository->deleteOrphanedScheduledTasks();
foreach ($this->repository->findScheduled(self::BATCH_SIZE) as $statsNotificationEntity) {
try {
$extraParams = [
'meta' => $this->mailerMetaInfo->getStatsNotificationMetaInfo(),
];
$this->mailerFactory->getDefaultMailer()->send($this->constructNewsletter($statsNotificationEntity), $settings['address'], $extraParams);
} catch (\Exception $e) {
if (WP_DEBUG) {
throw $e;
}
} finally {
$task = $statsNotificationEntity->getTask();
if ($task instanceof ScheduledTaskEntity) {
$this->markTaskAsFinished($task);
}
}
$this->cronHelper->enforceExecutionLimit($timer);
}
}
private function constructNewsletter(StatsNotificationEntity $statsNotificationEntity) {
$newsletter = $statsNotificationEntity->getNewsletter();
if (!$newsletter instanceof NewsletterEntity) {
throw new \RuntimeException('Missing newsletter entity for statistic notification.');
}
$link = $this->newsletterLinkRepository->findTopLinkForNewsletter((int)$newsletter->getId());
$sendingQueue = $newsletter->getLatestQueue();
if (!$sendingQueue instanceof SendingQueueEntity) {
throw new \RuntimeException('Missing sending queue entity for statistic notification.');
}
$context = $this->prepareContext($newsletter, $sendingQueue, $link);
$subject = $sendingQueue->getNewsletterRenderedSubject();
return [
// translators: %s is the subject of the email.
'subject' => sprintf(_x('Stats for email %s', 'title of an automatic email containing statistics (newsletter open rate, click rate, etc)', 'mailpoet'), $subject),
'body' => [
'html' => $this->renderer->render('emails/statsNotification.html', $context),
'text' => $this->renderer->render('emails/statsNotification.txt', $context),
],
];
}
private function prepareContext(NewsletterEntity $newsletter, SendingQueueEntity $sendingQueue, NewsletterLinkEntity $link = null) {
$statistics = $this->newsletterStatisticsRepository->getStatistics($newsletter);
$clicked = ($statistics->getClickCount() * 100) / $statistics->getTotalSentCount();
$opened = ($statistics->getOpenCount() * 100) / $statistics->getTotalSentCount();
$machineOpened = ($statistics->getMachineOpenCount() * 100) / $statistics->getTotalSentCount();
$unsubscribed = ($statistics->getUnsubscribeCount() * 100) / $statistics->getTotalSentCount();
$bounced = ($statistics->getBounceCount() * 100) / $statistics->getTotalSentCount();
$subject = $sendingQueue->getNewsletterRenderedSubject();
$subscribersCount = $this->subscribersRepository->getTotalSubscribers();
$hasValidApiKey = $this->subscribersFeature->hasValidApiKey();
$context = [
'subject' => $subject,
'preheader' => sprintf(
// translators: %1$s is the percentage of clicks, %2$s the percentage of opens and %3$s the number of unsubscribes.
_x(
'%1$s%% clicks, %2$s%% opens, %3$s%% unsubscribes in a nutshell.',
'newsletter open rate, click rate and unsubscribe rate',
'mailpoet'
),
number_format($clicked, 2),
number_format($opened, 2),
number_format($unsubscribed, 2)
),
'topLinkClicks' => 0,
'linkSettings' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-settings#basics'),
'linkStats' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-newsletters&stats=' . $newsletter->getId()),
'clicked' => $clicked,
'opened' => $opened,
'machineOpened' => $machineOpened,
'unsubscribed' => $unsubscribed,
'bounced' => $bounced,
'subscribersLimitReached' => $this->subscribersFeature->check(),
'hasValidApiKey' => $hasValidApiKey,
'subscribersLimit' => $this->subscribersFeature->getSubscribersLimit(),
'upgradeNowLink' => $hasValidApiKey
? 'https://account.mailpoet.com/orders/upgrade/' . $this->servicesChecker->generatePartialApiKey()
: 'https://account.mailpoet.com/?s=' . ($subscribersCount + 1),
];
if ($link) {
$context['topLinkClicks'] = $link->getTotalClicksCount();
$mappings = self::getShortcodeLinksMapping();
$context['topLink'] = isset($mappings[$link->getUrl()]) ? $mappings[$link->getUrl()] : $link->getUrl();
}
return $context;
}
private function markTaskAsFinished(ScheduledTaskEntity $task) {
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
$task->setProcessedAt(Carbon::now()->millisecond(0));
$task->setScheduledAt(null);
$this->entityManager->flush();
}
public static function getShortcodeLinksMapping() {
return [
NewsletterLinkEntity::UNSUBSCRIBE_LINK_SHORT_CODE => __('Unsubscribe link', 'mailpoet'),
NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE => __('Unsubscribe link (without confirmation)', 'mailpoet'),
'[link:subscription_manage_url]' => __('Manage subscription link', 'mailpoet'),
'[link:newsletter_view_in_browser_url]' => __('View in browser link', 'mailpoet'),
];
}
}
@@ -0,0 +1,48 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
if (!defined('ABSPATH')) exit;
class SubscriberLinkTokens extends SimpleWorker {
const TASK_TYPE = 'subscriber_link_tokens';
const BATCH_SIZE = 10000;
const AUTOMATIC_SCHEDULING = false;
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$entityManager = ContainerWrapper::getInstance()->get(EntityManager::class);
$subscribersRepository = ContainerWrapper::getInstance()->get(SubscribersRepository::class);
$subscribersTable = $entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$connection = $entityManager->getConnection();
$count = $subscribersRepository->countBy(['linkToken' => null]);
if ($count) {
$authKey = defined('AUTH_KEY') ? AUTH_KEY : '';
$connection->executeStatement(
"UPDATE {$subscribersTable} SET link_token = SUBSTRING(MD5(CONCAT(:authKey, email)), 1, :tokenLength) WHERE link_token IS NULL LIMIT :limit",
['authKey' => $authKey, 'tokenLength' => SubscriberEntity::OBSOLETE_LINK_TOKEN_LENGTH, 'limit' => self::BATCH_SIZE],
['authKey' => ParameterType::STRING, 'tokenLength' => ParameterType::INTEGER, 'limit' => ParameterType::INTEGER]
);
$this->schedule();
}
return true;
}
public function getNextRunDate() {
return Carbon::now()->millisecond(0);
}
}
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cache\TransientCache;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Subscribers\SubscribersCountsController;
use MailPoetVendor\Carbon\Carbon;
class SubscribersCountCacheRecalculation extends SimpleWorker {
private const EXPIRATION_IN_MINUTES = 30;
const TASK_TYPE = 'subscribers_count_cache_recalculation';
const AUTOMATIC_SCHEDULING = false;
const SUPPORT_MULTIPLE_INSTANCES = false;
/** @var TransientCache */
private $transientCache;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var SubscribersCountsController */
private $subscribersCountsController;
public function __construct(
TransientCache $transientCache,
SegmentsRepository $segmentsRepository,
SubscribersCountsController $subscribersCountsController
) {
parent::__construct();
$this->transientCache = $transientCache;
$this->segmentsRepository = $segmentsRepository;
$this->subscribersCountsController = $subscribersCountsController;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$segments = $this->segmentsRepository->findAll();
foreach ($segments as $segment) {
$this->recalculateSegmentCache($timer, (int)$segment->getId(), $segment);
}
// update cache for subscribers without segment
$this->recalculateSegmentCache($timer, 0);
$this->recalculateHomepageCache($timer);
// remove redundancies from cache
$this->cronHelper->enforceExecutionLimit($timer);
$this->subscribersCountsController->removeRedundancyFromStatisticsCache();
return true;
}
private function recalculateSegmentCache($timer, int $segmentId, ?SegmentEntity $segment = null): void {
$this->cronHelper->enforceExecutionLimit($timer);
$now = Carbon::now();
$item = $this->transientCache->getItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $segmentId);
if ($item === null || !isset($item['created_at']) || $now->diffInMinutes($item['created_at']) > self::EXPIRATION_IN_MINUTES) {
if ($segment) {
$this->subscribersCountsController->recalculateSegmentStatisticsCache($segment);
} else {
$this->subscribersCountsController->recalculateSubscribersWithoutSegmentStatisticsCache();
}
}
}
private function recalculateHomepageCache($timer): void {
$this->cronHelper->enforceExecutionLimit($timer);
$now = Carbon::now();
$item = $this->transientCache->getItem(TransientCache::SUBSCRIBERS_HOMEPAGE_STATISTICS_COUNT_KEY, 0);
if ($item === null || !isset($item['created_at']) || $now->diffInMinutes($item['created_at']) > self::EXPIRATION_IN_MINUTES) {
$this->cronHelper->enforceExecutionLimit($timer);
$this->subscribersCountsController->recalculateHomepageStatisticsCache();
}
}
public function getNextRunDate() {
return Carbon::now()->millisecond(0);
}
public function shouldBeScheduled(): bool {
$scheduledOrRunningTask = $this->scheduledTasksRepository->findScheduledOrRunningTask(self::TASK_TYPE);
if ($scheduledOrRunningTask) {
return false;
}
$now = Carbon::now();
$oldestCreatedAt = $this->transientCache->getOldestCreatedAt(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY);
return $oldestCreatedAt === null || $now->diffInMinutes($oldestCreatedAt) > self::EXPIRATION_IN_MINUTES;
}
}
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Settings\SettingsController;
use MailPoet\Settings\TrackingConfig;
use MailPoet\Subscribers\SubscribersEmailCountsController;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscribersEmailCount extends SimpleWorker {
const TASK_TYPE = 'subscribers_email_count';
const BATCH_SIZE = 1000;
const SUPPORT_MULTIPLE_INSTANCES = false;
/** @var SubscribersEmailCountsController */
private $subscribersEmailCountsController;
/** @var EntityManager */
private $entityManager;
/** @var SettingsController */
private $settings;
/** @var TrackingConfig */
private $trackingConfig;
public function __construct(
SubscribersEmailCountsController $subscribersEmailCountsController,
EntityManager $entityManager,
SettingsController $settings,
TrackingConfig $trackingConfig
) {
$this->subscribersEmailCountsController = $subscribersEmailCountsController;
$this->entityManager = $entityManager;
$this->settings = $settings;
$this->trackingConfig = $trackingConfig;
parent::__construct();
}
public function checkProcessingRequirements() {
if (!$this->trackingConfig->isEmailTrackingEnabled()) {
return false;
}
$daysToInactive = (int)$this->settings->get('deactivate_subscriber_after_inactive_days');
if ($daysToInactive === 0) {
return false;
}
return true;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$previousTask = $this->findPreviousTask($task);
$dateFromLastRun = null;
if ($previousTask instanceof ScheduledTaskEntity) {
$dateFromLastRun = $previousTask->getScheduledAt();
}
$meta = $task->getMeta();
$lastSubscriberId = isset($meta['last_subscriber_id']) ? (int)$meta['last_subscriber_id'] : 0;
$highestSubscriberId = isset($meta['highest_subscriber_id']) ? (int)$meta['highest_subscriber_id'] : $this->getHighestSubscriberId();
$meta['highest_subscriber_id'] = $highestSubscriberId;
$task->setMeta($meta);
while ($lastSubscriberId <= $highestSubscriberId) {
[$count, $lastSubscriberId] = $this->subscribersEmailCountsController->updateSubscribersEmailCounts($dateFromLastRun, self::BATCH_SIZE, intval($lastSubscriberId));
if ($count === 0) {
break;
}
$meta['last_subscriber_id'] = $lastSubscriberId++;
$task->setMeta($meta);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
$this->cronHelper->enforceExecutionLimit($timer);
};
$this->schedule();
return true;
}
private function findPreviousTask(ScheduledTaskEntity $task): ?ScheduledTaskEntity {
return $this->scheduledTasksRepository->findPreviousTask($task);
}
private function getHighestSubscriberId(): int {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$result = $this->entityManager->getConnection()->executeQuery("SELECT MAX(id) FROM $subscribersTable LIMIT 1;")->fetchNumeric();
/** @var int[] $result - it's required for PHPStan */
return is_array($result) && isset($result[0]) ? (int)$result[0] : 0;
}
}
@@ -0,0 +1,79 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Statistics\StatisticsOpensRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoetVendor\Carbon\Carbon;
class SubscribersEngagementScore extends SimpleWorker {
const AUTOMATIC_SCHEDULING = true;
const BATCH_SIZE = 60;
const TASK_TYPE = 'subscribers_engagement_score';
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var StatisticsOpensRepository */
private $statisticsOpensRepository;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SegmentsRepository $segmentsRepository,
StatisticsOpensRepository $statisticsOpensRepository,
SubscribersRepository $subscribersRepository
) {
parent::__construct();
$this->segmentsRepository = $segmentsRepository;
$this->statisticsOpensRepository = $statisticsOpensRepository;
$this->subscribersRepository = $subscribersRepository;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$recalculatedSubscribersCount = $this->recalculateSubscribers();
if ($recalculatedSubscribersCount > 0) {
$this->scheduleImmediately();
return true;
}
$recalculatedSegmentsCount = $this->recalculateSegments();
if ($recalculatedSegmentsCount > 0) {
$this->scheduleImmediately();
return true;
}
$this->schedule();
return true;
}
private function recalculateSubscribers(): int {
$subscribers = $this->subscribersRepository->findByUpdatedScoreNotInLastMonth(self::BATCH_SIZE);
foreach ($subscribers as $subscriber) {
$this->statisticsOpensRepository->recalculateSubscriberScore($subscriber);
}
return count($subscribers);
}
private function recalculateSegments(): int {
$segments = $this->segmentsRepository->findByUpdatedScoreNotInLastDay(self::BATCH_SIZE);
foreach ($segments as $segment) {
$this->statisticsOpensRepository->recalculateSegmentScore($segment);
}
return count($segments);
}
public function getNextRunDate() {
// random day of the next week
$date = Carbon::now()->millisecond(0);
$date->addDay();
$date->setTime(mt_rand(0, 23), mt_rand(0, 59));
return $date;
}
}
@@ -0,0 +1,67 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscribersLastEngagement extends SimpleWorker {
const AUTOMATIC_SCHEDULING = false;
const SUPPORT_MULTIPLE_INSTANCES = false;
const BATCH_SIZE = 2000;
const TASK_TYPE = 'subscribers_last_engagement';
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
parent::__construct();
$this->entityManager = $entityManager;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer): bool {
$meta = $task->getMeta();
$minId = $meta['nextId'] ?? 1;
$highestId = $this->getHighestSubscriberId();
while ($minId <= $highestId) {
$maxId = $minId + self::BATCH_SIZE;
$this->processBatch($minId, $maxId);
$task->setMeta(['nextId' => $maxId]);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
$this->cronHelper->enforceExecutionLimit($timer); // Throws exception and interrupts process if over execution limit
$minId = $maxId;
}
return true;
}
private function processBatch(int $minSubscriberId, int $maxSubscriberId): void {
$statisticsClicksTable = $this->entityManager->getClassMetadata(StatisticsClickEntity::class)->getTableName();
$statisticsOpensTable = $this->entityManager->getClassMetadata(StatisticsOpenEntity::class)->getTableName();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$query = "
UPDATE $subscribersTable as mps
LEFT JOIN (SELECT max(created_at) as created_at, subscriber_id FROM $statisticsOpensTable as mpsoinner GROUP BY mpsoinner.subscriber_id) as mpso ON mpso.subscriber_id = mps.id
LEFT JOIN (SELECT max(created_at) as created_at, subscriber_id FROM $statisticsClicksTable as mpscinner GROUP BY mpscinner.subscriber_id) as mpsc ON mpsc.subscriber_id = mps.id
SET mps.last_engagement_at = NULLIF(GREATEST(COALESCE(mpso.created_at, '0'), COALESCE(mpsc.created_at, '0')), '0')
WHERE mps.last_engagement_at IS NULL AND mps.id >= $minSubscriberId AND mps.id < $maxSubscriberId;
";
$this->entityManager->getConnection()->executeStatement($query);
}
private function getHighestSubscriberId(): int {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$result = $this->entityManager->getConnection()->executeQuery("SELECT MAX(id) FROM $subscribersTable LIMIT 1;")->fetchNumeric();
return is_array($result) && isset($result[0]) ? (int)$result[0] : 0;
}
}
@@ -0,0 +1,63 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\ServicesChecker;
use MailPoet\Cron\CronWorkerScheduler;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Services\SubscribersCountReporter;
use MailPoetVendor\Carbon\Carbon;
class SubscribersStatsReport extends SimpleWorker {
const TASK_TYPE = 'subscribers_stats_report';
/** @var SubscribersCountReporter */
private $subscribersCountReporter;
/** @var ServicesChecker */
private $serviceChecker;
/** @var CronWorkerScheduler */
private $workerScheduler;
public function __construct(
SubscribersCountReporter $subscribersCountReporter,
ServicesChecker $servicesChecker,
CronWorkerScheduler $workerScheduler
) {
parent::__construct();
$this->subscribersCountReporter = $subscribersCountReporter;
$this->serviceChecker = $servicesChecker;
$this->workerScheduler = $workerScheduler;
}
public function checkProcessingRequirements() {
return (bool)$this->serviceChecker->getValidAccountKey();
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer): bool {
$key = $this->serviceChecker->getValidAccountKey();
if ($key === null) {
return false;
}
$result = $this->subscribersCountReporter->report($key);
// We have a valid key, but request failed
if ($result === false) {
$this->workerScheduler->rescheduleProgressively($task);
}
return $result;
}
public function getNextRunDate() {
$date = Carbon::now()->millisecond(0);
// Spread the check within 6 hours after midnight so that all plugins don't ping the service at the same time
return $date->startOfDay()
->addDay()
->addHours(rand(0, 5))
->addMinutes(rand(0, 59))
->addSeconds(rand(0, 59));
}
}
@@ -0,0 +1,99 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Util\Security;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class UnsubscribeTokens extends SimpleWorker {
const TASK_TYPE = 'unsubscribe_tokens';
const BATCH_SIZE = 1000;
const AUTOMATIC_SCHEDULING = false;
/** @var Security */
private $security;
/** @var EntityManager */
private $entityManager;
public function __construct(
Security $security,
EntityManager $entityManager
) {
parent::__construct();
$this->security = $security;
$this->entityManager = $entityManager;
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$meta = $task->getMeta();
if (!isset($meta['last_subscriber_id'])) {
$meta['last_subscriber_id'] = 0;
}
if (!isset($meta['last_newsletter_id'])) {
$meta['last_newsletter_id'] = 0;
}
do {
$this->cronHelper->enforceExecutionLimit($timer);
$subscribersCount = $this->addTokens(SubscriberEntity::class, $meta['last_subscriber_id']);
$task->setMeta($meta);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
} while ($subscribersCount === self::BATCH_SIZE);
do {
$this->cronHelper->enforceExecutionLimit($timer);
$newslettersCount = $this->addTokens(NewsletterEntity::class, $meta['last_newsletter_id']);
$task->setMeta($meta);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
} while ($newslettersCount === self::BATCH_SIZE);
if ($subscribersCount > 0 || $newslettersCount > 0) {
return false;
}
return true;
}
private function addTokens($entityClass, &$lastProcessedId = 0) {
$queryBuilder = $this->entityManager->createQueryBuilder();
$entities = $queryBuilder
->select('PARTIAL e.{id}')
->from($entityClass, 'e')
->where('e.unsubscribeToken IS NULL')
->andWhere('e.id > :lastProcessedId')
->orderBy('e.id', 'ASC')
->setMaxResults(self::BATCH_SIZE)
->setParameter('lastProcessedId', $lastProcessedId)
->getQuery()
->getResult();
if (!is_iterable($entities) || !is_countable($entities)) {
throw new InvalidStateException('Entities must be iterable');
}
foreach ($entities as $entity) {
$lastProcessedId = $entity->getId();
$entity->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($entity));
$this->entityManager->persist($entity);
}
$this->entityManager->flush();
return count($entities);
}
public function getNextRunDate() {
return Carbon::now()->millisecond(0);
}
}
@@ -0,0 +1,83 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Statistics\StatisticsClicksRepository;
use MailPoet\Statistics\Track\WooCommercePurchases;
use MailPoet\WooCommerce\Helper as WCHelper;
use MailPoetVendor\Carbon\Carbon;
class WooCommercePastOrders extends SimpleWorker {
const TASK_TYPE = 'woocommerce_past_orders';
const BATCH_SIZE = 20;
/** @var WCHelper */
private $woocommerceHelper;
/** @var WooCommercePurchases */
private $woocommercePurchases;
/** @var StatisticsClicksRepository */
private $statisticsClicksRepository;
public function __construct(
WCHelper $woocommerceHelper,
StatisticsClicksRepository $statisticsClicksRepository,
WooCommercePurchases $woocommercePurchases
) {
$this->woocommerceHelper = $woocommerceHelper;
$this->woocommercePurchases = $woocommercePurchases;
$this->statisticsClicksRepository = $statisticsClicksRepository;
parent::__construct();
}
public function checkProcessingRequirements() {
return $this->woocommerceHelper->isWooCommerceActive() && empty($this->getCompletedTasks()); // run only once
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$oldestClick = $this->statisticsClicksRepository->findOneBy([], ['createdAt' => 'asc']);
if (!$oldestClick instanceof StatisticsClickEntity) {
return true;
}
// continue from 'last_processed_id' from previous run
$meta = $task->getMeta();
$lastId = isset($meta['last_processed_id']) ? $meta['last_processed_id'] : 0;
add_filter('posts_where', function ($where = '') use ($lastId) {
global $wpdb;
return $where . " AND {$wpdb->prefix}posts.ID > " . $lastId;
}, 10, 1);
$orderIds = $this->woocommerceHelper->wcGetOrders([
'date_completed' => '>=' . (($createdAt = $oldestClick->getCreatedAt()) ? $createdAt->format('Y-m-d H:i:s') : null),
'orderby' => 'ID',
'order' => 'ASC',
'limit' => self::BATCH_SIZE,
'return' => 'ids',
]);
if (empty($orderIds)) {
return true;
}
foreach ($orderIds as $orderId) {
$this->woocommercePurchases->trackPurchase($orderId, false);
}
$task->setMeta(['last_processed_id' => end($orderIds)]);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
return false;
}
public function getNextRunDate() {
return Carbon::now()->millisecond(0); // schedule immediately
}
}
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Segments\WooCommerce as WooCommerceSegment;
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
class WooCommerceSync extends SimpleWorker {
const TASK_TYPE = 'woocommerce_sync';
const SUPPORT_MULTIPLE_INSTANCES = false;
const AUTOMATIC_SCHEDULING = false;
const BATCH_SIZE = 1000;
/** @var WooCommerceSegment */
private $woocommerceSegment;
/** @var WooCommerceHelper */
private $woocommerceHelper;
public function __construct(
WooCommerceSegment $woocommerceSegment,
WooCommerceHelper $woocommerceHelper
) {
$this->woocommerceSegment = $woocommerceSegment;
$this->woocommerceHelper = $woocommerceHelper;
parent::__construct();
}
public function checkProcessingRequirements() {
return $this->woocommerceHelper->isWooCommerceActive();
}
public function processTaskStrategy(ScheduledTaskEntity $task, $timer) {
$meta = $task->getMeta();
$highestOrderId = $this->getHighestOrderId();
if (!isset($meta['last_checked_order_id'])) {
$meta['last_checked_order_id'] = 0;
}
do {
$this->cronHelper->enforceExecutionLimit($timer);
$meta['last_checked_order_id'] = $this->woocommerceSegment->synchronizeCustomers(
$meta['last_checked_order_id'],
$highestOrderId,
self::BATCH_SIZE
);
$task->setMeta($meta);
$this->scheduledTasksRepository->persist($task);
$this->scheduledTasksRepository->flush();
} while ($meta['last_checked_order_id'] < $highestOrderId);
return true;
}
private function getHighestOrderId(): int {
$orders = $this->woocommerceHelper->wcGetOrders(
[
'status' => 'all',
'type' => 'shop_order',
'limit' => 1,
'orderby' => 'ID',
'order' => 'DESC',
'return' => 'ids',
]
);
return (!empty($orders)) ? $orders[0] : 0;
}
}
@@ -0,0 +1,167 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Cron\Workers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\Automations\AbandonedCartWorker;
use MailPoet\Cron\Workers\Bounce as BounceWorker;
use MailPoet\Cron\Workers\KeyCheck\PremiumKeyCheck as PremiumKeyCheckWorker;
use MailPoet\Cron\Workers\KeyCheck\SendingServiceKeyCheck as SendingServiceKeyCheckWorker;
use MailPoet\Cron\Workers\Scheduler as SchedulerWorker;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker;
use MailPoet\Cron\Workers\StatsNotifications\AutomatedEmails as StatsNotificationsWorkerForAutomatedEmails;
use MailPoet\Cron\Workers\StatsNotifications\Worker as StatsNotificationsWorker;
use MailPoet\Cron\Workers\WooCommerceSync as WooCommerceSyncWorker;
use MailPoet\DI\ContainerWrapper;
class WorkersFactory {
public const SIMPLE_WORKER_TYPES = [
SubscribersCountCacheRecalculation::TASK_TYPE,
NewsletterTemplateThumbnails::TASK_TYPE,
ReEngagementEmailsScheduler::TASK_TYPE,
SubscribersLastEngagement::TASK_TYPE,
SubscribersEngagementScore::TASK_TYPE,
WooCommercePastOrders::TASK_TYPE,
AuthorizedSendingEmailsCheck::TASK_TYPE,
WooCommerceSyncWorker::TASK_TYPE,
SubscriberLinkTokens::TASK_TYPE,
UnsubscribeTokens::TASK_TYPE,
InactiveSubscribers::TASK_TYPE,
SubscribersEmailCount::TASK_TYPE,
StatsNotificationsWorkerForAutomatedEmails::TASK_TYPE,
StatsNotificationsWorker::TASK_TYPE,
BackfillEngagementData::TASK_TYPE,
Mixpanel::TASK_TYPE,
AbandonedCartWorker::TASK_TYPE,
];
/** @var ContainerWrapper */
private $container;
public function __construct(
ContainerWrapper $container
) {
$this->container = $container;
}
/** @return SchedulerWorker */
public function createScheduleWorker() {
return $this->container->get(SchedulerWorker::class);
}
/** @return SendingQueueWorker */
public function createQueueWorker() {
return $this->container->get(SendingQueueWorker::class);
}
/** @return StatsNotificationsWorker */
public function createStatsNotificationsWorker() {
return $this->container->get(StatsNotificationsWorker::class);
}
/** @return StatsNotificationsWorkerForAutomatedEmails */
public function createStatsNotificationsWorkerForAutomatedEmails() {
return $this->container->get(StatsNotificationsWorkerForAutomatedEmails::class);
}
/** @return SendingServiceKeyCheckWorker */
public function createSendingServiceKeyCheckWorker() {
return $this->container->get(SendingServiceKeyCheckWorker::class);
}
/** @return PremiumKeyCheckWorker */
public function createPremiumKeyCheckWorker() {
return $this->container->get(PremiumKeyCheckWorker::class);
}
/** @return BounceWorker */
public function createBounceWorker() {
return $this->container->get(BounceWorker::class);
}
/** @return WooCommerceSyncWorker */
public function createWooCommerceSyncWorker() {
return $this->container->get(WooCommerceSyncWorker::class);
}
/** @return ExportFilesCleanup */
public function createExportFilesCleanupWorker() {
return $this->container->get(ExportFilesCleanup::class);
}
/** @return InactiveSubscribers */
public function createInactiveSubscribersWorker() {
return $this->container->get(InactiveSubscribers::class);
}
/** @return UnsubscribeTokens */
public function createUnsubscribeTokensWorker() {
return $this->container->get(UnsubscribeTokens::class);
}
/** @return SubscriberLinkTokens */
public function createSubscriberLinkTokensWorker() {
return $this->container->get(SubscriberLinkTokens::class);
}
/** @return SubscribersEngagementScore */
public function createSubscribersEngagementScoreWorker() {
return $this->container->get(SubscribersEngagementScore::class);
}
/** @return SubscribersLastEngagement */
public function createSubscribersLastEngagementWorker() {
return $this->container->get(SubscribersLastEngagement::class);
}
/** @return AuthorizedSendingEmailsCheck */
public function createAuthorizedSendingEmailsCheckWorker() {
return $this->container->get(AuthorizedSendingEmailsCheck::class);
}
/** @return WooCommercePastOrders */
public function createWooCommercePastOrdersWorker() {
return $this->container->get(WooCommercePastOrders::class);
}
/** @return SubscribersCountCacheRecalculation */
public function createSubscribersCountCacheRecalculationWorker() {
return $this->container->get(SubscribersCountCacheRecalculation::class);
}
/** @return ReEngagementEmailsScheduler */
public function createReEngagementEmailsSchedulerWorker() {
return $this->container->get(ReEngagementEmailsScheduler::class);
}
/** @return SubscribersStatsReport */
public function createSubscribersStatsReportWorker() {
return $this->container->get(SubscribersStatsReport::class);
}
/** @return NewsletterTemplateThumbnails */
public function createNewsletterTemplateThumbnailsWorker() {
return $this->container->get(NewsletterTemplateThumbnails::class);
}
/** @return SubscribersEmailCount */
public function createSubscribersEmailCountsWorker() {
return $this->container->get(SubscribersEmailCount::class);
}
/** @return AbandonedCartWorker */
public function createAbandonedCartWorker() {
return $this->container->get(AbandonedCartWorker::class);
}
/** @return BackfillEngagementData */
public function createBackfillEngagementDataWorker() {
return $this->container->get(BackfillEngagementData::class);
}
public function createMixpanelWorker() {
return $this->container->get(Mixpanel::class);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1 @@
<?php