init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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('Can‘t 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 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+87
@@ -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 @@
|
||||
<?php
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user