init
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class ApiDataSanitizer {
|
||||
/** @var NewsletterHtmlSanitizer */
|
||||
private $htmlSanitizer;
|
||||
|
||||
/**
|
||||
* Configuration specifies which block types and properties within newsletters content blocks are sanitized
|
||||
*/
|
||||
private const SANITIZATION_CONFIG = [
|
||||
'header' => ['text'],
|
||||
'footer' => ['text'],
|
||||
'text' => ['text'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
NewsletterHtmlSanitizer $htmlSanitizer
|
||||
) {
|
||||
$this->htmlSanitizer = $htmlSanitizer;
|
||||
}
|
||||
|
||||
public function sanitizeBody(array $body): array {
|
||||
if (isset($body['content']) && isset($body['content']['blocks']) && is_array($body['content']['blocks'])) {
|
||||
$body['content']['blocks'] = $this->sanitizeBlocks($body['content']['blocks']);
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function sanitizeBlocks(array $blocks): array {
|
||||
foreach ($blocks as $key => $block) {
|
||||
if (!is_array($block) || !isset($block['type'])) {
|
||||
continue;
|
||||
}
|
||||
if (isset($block['blocks']) && is_array($block['blocks'])) {
|
||||
$blocks[$key]['blocks'] = $this->sanitizeBlocks($block['blocks']);
|
||||
} else {
|
||||
$blocks[$key] = $this->sanitizeBlock($block);
|
||||
}
|
||||
};
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
private function sanitizeBlock(array $block): array {
|
||||
if (!isset(self::SANITIZATION_CONFIG[$block['type']])) {
|
||||
return $block;
|
||||
}
|
||||
foreach (self::SANITIZATION_CONFIG[$block['type']] as $property) {
|
||||
if (!isset($block[$property])) {
|
||||
continue;
|
||||
}
|
||||
$block[$property] = $this->htmlSanitizer->sanitize($block[$property]);
|
||||
}
|
||||
return $block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Logging\LoggerFactory;
|
||||
use MailPoet\Newsletter\Editor\Transformer;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class AutomatedLatestContent {
|
||||
|
||||
/** @var LoggerFactory */
|
||||
private $loggerFactory;
|
||||
|
||||
/** @var int|false */
|
||||
private $newsletterId;
|
||||
|
||||
/** @var NewsletterPostsRepository */
|
||||
private $newsletterPostsRepository;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
LoggerFactory $loggerFactory,
|
||||
NewsletterPostsRepository $newsletterPostsRepository,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->loggerFactory = $loggerFactory;
|
||||
$this->newsletterPostsRepository = $newsletterPostsRepository;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function filterOutSentPosts(string $where): string {
|
||||
$newsletterPostsTableName = $this->newsletterPostsRepository->getTableName();
|
||||
$sentPostsQuery = 'SELECT ' . $newsletterPostsTableName . '.post_id FROM '
|
||||
. $newsletterPostsTableName . ' WHERE '
|
||||
. $newsletterPostsTableName . ".newsletter_id='" . $this->newsletterId . "'";
|
||||
|
||||
$wherePostUnsent = 'ID NOT IN (' . $sentPostsQuery . ')';
|
||||
|
||||
if (!empty($where)) $wherePostUnsent = ' AND ' . $wherePostUnsent;
|
||||
|
||||
return $where . $wherePostUnsent;
|
||||
}
|
||||
|
||||
public function ensureConsistentQueryType(\WP_Query $query) {
|
||||
// Queries with taxonomies are autodetected as 'is_archive=true' and 'is_home=false'
|
||||
// while queries without them end up being 'is_archive=false' and 'is_home=true'.
|
||||
// This is to fix that by always enforcing constistent behavior.
|
||||
$query->is_archive = true; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$query->is_home = false; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
|
||||
public function getPosts(BlockPostQuery $query) {
|
||||
$this->newsletterId = $query->newsletterId;
|
||||
// Get posts as logged out user, so private posts hidden by other plugins (e.g. UAM) are also excluded
|
||||
$currentUserId = $this->wp->getCurrentUserId();
|
||||
// phpcs:ignore Generic.PHP.ForbiddenFunctions.Discouraged
|
||||
wp_set_current_user(0);
|
||||
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'loading automated latest content',
|
||||
[
|
||||
'args' => $query->args,
|
||||
'posts_to_exclude' => $query->postsToExclude,
|
||||
'newsletter_id' => $query->newsletterId,
|
||||
'newer_than_timestamp' => $query->newerThanTimestamp,
|
||||
]
|
||||
);
|
||||
|
||||
// set low priority to execute 'ensureConstistentQueryType' before any other filter
|
||||
$filterPriority = defined('PHP_INT_MIN') ? constant('PHP_INT_MIN') : ~PHP_INT_MAX;
|
||||
$this->wp->addAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority);
|
||||
$this->_attachSentPostsFilter($query->newsletterId);
|
||||
$parameters = $query->getQueryParams();
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'getting automated latest content',
|
||||
['parameters' => $parameters]
|
||||
);
|
||||
$posts = $this->wp->getPosts($parameters);
|
||||
$this->logPosts($posts);
|
||||
|
||||
$this->wp->removeAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority);
|
||||
$this->_detachSentPostsFilter($query->newsletterId);
|
||||
// phpcs:ignore Generic.PHP.ForbiddenFunctions.Discouraged
|
||||
wp_set_current_user($currentUserId);
|
||||
return $posts;
|
||||
}
|
||||
|
||||
public function transformPosts($args, $posts) {
|
||||
$transformer = new Transformer($args);
|
||||
return $transformer->transform($posts);
|
||||
}
|
||||
|
||||
private function _attachSentPostsFilter($newsletterId) {
|
||||
if ($newsletterId > 0) {
|
||||
$this->wp->addAction('posts_where', [$this, 'filterOutSentPosts']);
|
||||
}
|
||||
}
|
||||
|
||||
private function _detachSentPostsFilter($newsletterId) {
|
||||
if ($newsletterId > 0) {
|
||||
$this->wp->removeAction('posts_where', [$this, 'filterOutSentPosts']);
|
||||
}
|
||||
}
|
||||
|
||||
private function logPosts(array $posts) {
|
||||
$postsToLog = [];
|
||||
foreach ($posts as $post) {
|
||||
$postsToLog[] = [
|
||||
'id' => $post->ID,
|
||||
'post_date' => $post->post_date, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
];
|
||||
}
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'automated latest content loaded posts',
|
||||
['posts' => $postsToLog]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterEntity>
|
||||
*/
|
||||
class AutomaticEmailsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterEntity::class;
|
||||
}
|
||||
|
||||
public function wasScheduledForSubscriber(int $newsletterId, int $subscriberId): bool {
|
||||
$query = $this->doctrineRepository->createQueryBuilder('n')
|
||||
->select('COUNT(q)')
|
||||
->from(SendingQueueEntity::class, 'q');
|
||||
$query = $this->getAllQueuesForSubscscriberQuery($query, $newsletterId, $subscriberId);
|
||||
$count = $query->getQuery()
|
||||
->getSingleScalarResult() ?: 0;
|
||||
return ((int)$count) > 0;
|
||||
}
|
||||
|
||||
private function getAllQueuesForSubscscriberQuery(QueryBuilder $query, int $newsletterId, int $subscriberId): QueryBuilder {
|
||||
return $query
|
||||
->join('q.task', 't')
|
||||
->join('t.subscribers', 's')
|
||||
->andWhere('q.newsletter = :newsletterId')
|
||||
->andWhere('s.subscriber = :subscriberId')
|
||||
->setParameter('newsletterId', $newsletterId)
|
||||
->setParameter('subscriberId', $subscriberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products/categories in meta if all of the ordered products have already been sent to the subscriber.
|
||||
*/
|
||||
public function alreadySentAllProducts(int $newsletterId, int $subscriberId, string $orderedKey, array $ordered): bool {
|
||||
$query = $this->doctrineRepository->createQueryBuilder('n')
|
||||
->select('q')
|
||||
->from(SendingQueueEntity::class, 'q');
|
||||
$queues = $this->getAllQueuesForSubscscriberQuery($query, $newsletterId, $subscriberId)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$sent = [];
|
||||
foreach ($queues as $queue) {
|
||||
$meta = $queue->getMeta();
|
||||
if (isset($meta[$orderedKey])) {
|
||||
$sent = array_merge($sent, $meta[$orderedKey]);
|
||||
}
|
||||
}
|
||||
$notSentProducts = array_diff($ordered, $sent);
|
||||
|
||||
return empty($notSentProducts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
class BlockPostQuery {
|
||||
const DEFAULT_POSTS_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* @var array{
|
||||
* amount?: int,
|
||||
* offset?: int,
|
||||
* posts?: int[],
|
||||
* contentType?: string,
|
||||
* postStatus?: string,
|
||||
* search?: string,
|
||||
* sortBy?: 'newest' | 'DESC' | 'ASC',
|
||||
* terms?: array{'taxonomy': string, 'id': int}[],
|
||||
* inclusionType?: 'include'|'exclude'
|
||||
* } $args
|
||||
*/
|
||||
public $args = [];
|
||||
|
||||
/*** @var null|int[] \WP_Query::post__not_in */
|
||||
public $postsToExclude = [];
|
||||
|
||||
/** @var int|false */
|
||||
public $newsletterId = false;
|
||||
|
||||
/***
|
||||
* Translates to \WP_Query::date_query => array{'column': 'post_date_gmt', 'after': date string}
|
||||
*
|
||||
* @var bool|DateTimeInterface|null
|
||||
*/
|
||||
public $newerThanTimestamp = false;
|
||||
|
||||
/**
|
||||
* If it's a dynamic block
|
||||
* Dynamic blocks are not allowed to query none-public posts
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $dynamic = true;
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* args?: array{
|
||||
* amount?: int,
|
||||
* offset?: int,
|
||||
* posts?: int[],
|
||||
* contentType?: string,
|
||||
* postStatus?: string,
|
||||
* search?: string,
|
||||
* sortBy?: 'newest' | 'DESC' | 'ASC',
|
||||
* terms?: array{'taxonomy': string, 'id': int}[],
|
||||
* inclusionType?: 'include'|'exclude'
|
||||
* },
|
||||
* postsToExclude?: int[],
|
||||
* newsletterId?: int|false|null,
|
||||
* newerThanTimestamp?: bool|DateTimeInterface|null,
|
||||
* dynamic?: bool,
|
||||
* } $query
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
array $query = []
|
||||
) {
|
||||
$this->args = $query['args'] ?? [];
|
||||
$this->postsToExclude = $query['postsToExclude'] ?? [];
|
||||
$this->newsletterId = $query['newsletterId'] ?? false;
|
||||
$this->newerThanTimestamp = $query['newerThanTimestamp'] ?? false;
|
||||
$this->dynamic = $query['dynamic'] ?? true;
|
||||
}
|
||||
|
||||
public function getPostType(): string {
|
||||
return $this->args['contentType'] ?? 'post';
|
||||
}
|
||||
|
||||
public function getPostStatus(): string {
|
||||
if ($this->dynamic) {
|
||||
return 'publish';
|
||||
}
|
||||
return $this->args['postStatus'] ?? 'publish';
|
||||
}
|
||||
|
||||
public function getOrder(): string {
|
||||
return isset($this->args['sortBy']) && in_array($this->args['sortBy'], ['newest', 'DESC']) ? 'DESC' : 'ASC';
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.wordpress.org/reference/classes/wp_query/#taxonomy-parameters
|
||||
* @return array[] array{relation: string, taxonomy: string, field: string, terms: int/string/array, operator: string}
|
||||
*/
|
||||
private function constructTaxonomiesQuery(): array {
|
||||
$taxonomiesQuery = [];
|
||||
if (isset($this->args['terms']) && is_array($this->args['terms'])) {
|
||||
$taxonomies = [];
|
||||
// Categorize terms based on their taxonomies
|
||||
foreach ($this->args['terms'] as $term) {
|
||||
$taxonomy = $term['taxonomy'];
|
||||
if (!isset($taxonomies[$taxonomy])) {
|
||||
$taxonomies[$taxonomy] = [];
|
||||
}
|
||||
$taxonomies[$taxonomy][] = $term['id'];
|
||||
}
|
||||
|
||||
foreach ($taxonomies as $taxonomy => $terms) {
|
||||
if (!empty($terms)) {
|
||||
$tax = [
|
||||
'taxonomy' => $taxonomy,
|
||||
'field' => 'id',
|
||||
'terms' => $terms,
|
||||
];
|
||||
if (isset($this->args['inclusionType']) && $this->args['inclusionType'] === 'exclude') $tax['operator'] = 'NOT IN';
|
||||
$taxonomiesQuery[] = $tax;
|
||||
}
|
||||
}
|
||||
if (!empty($taxonomiesQuery)) {
|
||||
// With exclusion we want to use 'AND', because we want posts that
|
||||
// don't have excluded tags/categories. But with inclusion we want to
|
||||
// use 'OR', because we want posts that have any of the included
|
||||
// tags/categories
|
||||
$taxonomiesQuery['relation'] = (isset($this->args['inclusionType']) && $this->args['inclusionType'] === 'exclude')
|
||||
? 'AND'
|
||||
: 'OR';
|
||||
}
|
||||
}
|
||||
|
||||
// make $taxonomies_query nested to avoid conflicts with plugins that use taxonomies
|
||||
return empty($taxonomiesQuery) ? [] : [$taxonomiesQuery];
|
||||
}
|
||||
|
||||
public function getQueryParams(): array {
|
||||
$postsPerPage = (!empty($this->args['amount']) && (int)$this->args['amount'] > 0)
|
||||
? (int)$this->args['amount']
|
||||
: self::DEFAULT_POSTS_PER_PAGE;
|
||||
$parameters = [
|
||||
'posts_per_page' => $postsPerPage,
|
||||
'post_type' => $this->getPostType(),
|
||||
'post_status' => $this->getPostStatus(),
|
||||
'orderby' => 'date',
|
||||
'order' => $this->getOrder(),
|
||||
];
|
||||
if (!empty($this->args['offset']) && (int)$this->args['offset'] > 0) {
|
||||
$parameters['offset'] = (int)$this->args['offset'];
|
||||
}
|
||||
if (isset($this->args['search'])) {
|
||||
$parameters['s'] = $this->args['search'];
|
||||
}
|
||||
if (isset($this->args['posts']) && is_array($this->args['posts'])) {
|
||||
$parameters['post__in'] = $this->args['posts'];
|
||||
$parameters['posts_per_page'] = -1; // Get all posts with matching IDs
|
||||
}
|
||||
if (!empty($this->postsToExclude)) {
|
||||
$parameters['post__not_in'] = $this->postsToExclude;
|
||||
}
|
||||
|
||||
// WP posts with the type attachment have always post_status `inherit`
|
||||
if ($parameters['post_type'] === 'attachment' && $parameters['post_status'] === 'publish') {
|
||||
$parameters['post_status'] = 'inherit';
|
||||
}
|
||||
|
||||
// This enables using posts query filters for get_posts, where by default
|
||||
// it is disabled.
|
||||
// However, it also enables other plugins and themes to hook in and alter
|
||||
// the query.
|
||||
$parameters['suppress_filters'] = false;
|
||||
|
||||
if ($this->newerThanTimestamp instanceof DateTimeInterface) {
|
||||
//Ensure UTC timezone
|
||||
$after = new \DateTime('now', new \DateTimeZone('UTC'));
|
||||
$after->setTimestamp($this->newerThanTimestamp->getTimestamp());
|
||||
$parameters['date_query'] = [
|
||||
[
|
||||
'column' => 'post_date_gmt',
|
||||
'after' => $after->format('Y-m-d H:i:s'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$parameters['tax_query'] = $this->constructTaxonomiesQuery();
|
||||
return $parameters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class LayoutHelper {
|
||||
public static function row($blocks, $styles = []) {
|
||||
if (empty($styles['backgroundColor'])) {
|
||||
$styles['backgroundColor'] = 'transparent';
|
||||
}
|
||||
return [
|
||||
'type' => 'container',
|
||||
'orientation' => 'horizontal',
|
||||
'styles' => ['block' => $styles],
|
||||
'blocks' => $blocks,
|
||||
];
|
||||
}
|
||||
|
||||
public static function col($blocks, $styles = []) {
|
||||
if (empty($styles['backgroundColor'])) {
|
||||
$styles['backgroundColor'] = 'transparent';
|
||||
}
|
||||
return [
|
||||
'type' => 'container',
|
||||
'orientation' => 'vertical',
|
||||
'styles' => ['block' => $styles],
|
||||
'blocks' => $blocks,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class MetaInformationManager {
|
||||
public function appendMetaInformation($content, $post, $args) {
|
||||
// Append author and categories above and below contents
|
||||
foreach (['above', 'below'] as $position) {
|
||||
$positionField = $position . 'Text';
|
||||
$text = [];
|
||||
|
||||
if (isset($args['showAuthor']) && $args['showAuthor'] === $positionField) {
|
||||
$text[] = self::getPostAuthor(
|
||||
$post->post_author, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$args['authorPrecededBy']
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($args['showCategories']) && $args['showCategories'] === $positionField) {
|
||||
$text[] = self::getPostCategories(
|
||||
$post->ID,
|
||||
$post->post_type, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$args['categoriesPrecededBy']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($text)) {
|
||||
$text = '<p>' . implode('<br />', $text) . '</p>';
|
||||
if ($position === 'above') $content = $text . $content;
|
||||
else if ($position === 'below') $content .= $text;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private static function getPostCategories($postId, $postType, $precededBy) {
|
||||
$precededBy = trim($precededBy);
|
||||
|
||||
// Get categories
|
||||
$categories = WPFunctions::get()->wpGetPostTerms(
|
||||
$postId,
|
||||
['category'],
|
||||
['fields' => 'names']
|
||||
);
|
||||
if (!empty($categories)) {
|
||||
// check if the user specified a label to be displayed before the author's name
|
||||
if (strlen($precededBy) > 0) {
|
||||
$content = stripslashes($precededBy) . ' ';
|
||||
} else {
|
||||
$content = '';
|
||||
}
|
||||
|
||||
return $content . join(', ', $categories);
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private static function getPostAuthor($authorId, $precededBy) {
|
||||
$authorName = WPFunctions::get()->getTheAuthorMeta('display_name', (int)$authorId);
|
||||
|
||||
$precededBy = trim($precededBy);
|
||||
if (strlen($precededBy) > 0) {
|
||||
$authorName = stripslashes($precededBy) . ' ' . $authorName;
|
||||
}
|
||||
|
||||
return $authorName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class PostContentManager {
|
||||
const WP_POST_CLASS = 'mailpoet_wp_post';
|
||||
|
||||
public $maxExcerptLength = 60;
|
||||
|
||||
/** @var WooCommerceHelper */
|
||||
private $woocommerceHelper;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
WooCommerceHelper $woocommerceHelper = null
|
||||
) {
|
||||
$this->wp = new WPFunctions;
|
||||
$this->maxExcerptLength = $this->wp->applyFilters('mailpoet_newsletter_post_excerpt_length', $this->maxExcerptLength);
|
||||
$this->woocommerceHelper = $woocommerceHelper ?: new WooCommerceHelper($this->wp);
|
||||
}
|
||||
|
||||
public function getContent($post, $displayType) {
|
||||
if ($displayType === 'titleOnly') {
|
||||
return '';
|
||||
}
|
||||
if ($this->woocommerceHelper->isWooCommerceActive() && $this->wp->getPostType($post) === 'product') {
|
||||
$product = $this->woocommerceHelper->wcGetProduct($post->ID);
|
||||
if ($product) {
|
||||
return $this->getContentForProduct($product, $displayType);
|
||||
}
|
||||
}
|
||||
if ($displayType === 'excerpt') {
|
||||
if ($this->wp->hasExcerpt($post)) {
|
||||
return self::stripShortCodes($this->wp->getTheExcerpt($post));
|
||||
}
|
||||
return $this->generateExcerpt($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
return self::stripShortCodes($post->post_content); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
|
||||
public function filterContent($content, $displayType, $withPostClass = true) {
|
||||
$content = self::convertEmbeddedContent($content);
|
||||
|
||||
// convert h4 h5 h6 to h3
|
||||
$content = preg_replace('/<([\/])?h[456](.*?)>/', '<$1h3$2>', $content);
|
||||
|
||||
// convert currency signs
|
||||
$content = str_replace(
|
||||
['$', '€', '£', '¥'],
|
||||
['$', '€', '£', '¥'],
|
||||
$content
|
||||
);
|
||||
|
||||
// strip useless tags
|
||||
$tagsNotBeingStripped = [
|
||||
'<p>', '<em>', '<span>', '<b>', '<strong>', '<i>',
|
||||
'<a>', '<ul>', '<ol>', '<li>', '<br>', '<blockquote>',
|
||||
];
|
||||
if ($displayType === 'full') {
|
||||
$tagsNotBeingStripped = array_merge($tagsNotBeingStripped, ['<figure>', '<img>', '<h1>', '<h2>', '<h3>', '<hr>']);
|
||||
}
|
||||
|
||||
if (is_array($content)) {
|
||||
$content = implode(' ', $content);
|
||||
}
|
||||
|
||||
$content = strip_tags($content, implode('', $tagsNotBeingStripped));
|
||||
if ($withPostClass) {
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr(WPFunctions::get()->wpautop($content));
|
||||
$paragraphs = $DOM->query('p');
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
// We replace the class attribute to avoid conflicts in the newsletter editor
|
||||
$paragraph->removeAttr('class');
|
||||
$paragraph->addClass(self::WP_POST_CLASS);
|
||||
}
|
||||
$content = $DOM->__toString();
|
||||
} else {
|
||||
$content = WPFunctions::get()->wpautop($content);
|
||||
}
|
||||
$content = trim($content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function getContentForProduct($product, $displayType) {
|
||||
if ($displayType === 'excerpt') {
|
||||
return $product->get_short_description();
|
||||
}
|
||||
return $product->get_description();
|
||||
}
|
||||
|
||||
private function generateExcerpt($content) {
|
||||
// remove image captions in gutenberg
|
||||
$content = preg_replace(
|
||||
"/<figcaption.*?>.*?<\/figcaption>/",
|
||||
'',
|
||||
$content
|
||||
);
|
||||
// remove image captions in classic posts
|
||||
$content = preg_replace(
|
||||
"/\[caption.*?\](.*?)\[\/caption\]/",
|
||||
'',
|
||||
$content
|
||||
);
|
||||
|
||||
$content = self::stripShortCodes($content);
|
||||
|
||||
// if excerpt is empty then try to find the "more" tag
|
||||
$excerpts = explode('<!--more-->', $content);
|
||||
if (count($excerpts) > 1) {
|
||||
// <!--more--> separator was present
|
||||
return $excerpts[0];
|
||||
} else {
|
||||
// Separator not present, try to shorten long posts
|
||||
return WPFunctions::get()->wpTrimWords($content, $this->maxExcerptLength, ' …');
|
||||
}
|
||||
}
|
||||
|
||||
private function stripShortCodes($content) {
|
||||
// remove captions
|
||||
$content = preg_replace(
|
||||
"/\[caption.*?\](.*<\/a>)(.*?)\[\/caption\]/",
|
||||
'$1',
|
||||
$content
|
||||
);
|
||||
|
||||
// remove other shortcodes
|
||||
$content = preg_replace('/\[[^\[\]]*\]/', '', $content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function convertEmbeddedContent($content = '') {
|
||||
// remove embedded video and replace with links
|
||||
$content = preg_replace(
|
||||
'#<iframe.*?src=\"(.+?)\".*><\/iframe>#',
|
||||
'<a href="$1">' . __('Click here to view media.', 'mailpoet') . '</a>',
|
||||
$content
|
||||
);
|
||||
|
||||
// replace youtube links
|
||||
$content = preg_replace(
|
||||
'#http://www.youtube.com/embed/([a-zA-Z0-9_-]*)#Ui',
|
||||
'http://www.youtube.com/watch?v=$1',
|
||||
$content
|
||||
);
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class PostListTransformer {
|
||||
|
||||
private $args;
|
||||
private $transformer;
|
||||
|
||||
public function __construct(
|
||||
$args
|
||||
) {
|
||||
$this->args = $args;
|
||||
$this->transformer = new PostTransformer($args);
|
||||
}
|
||||
|
||||
public function transform($posts) {
|
||||
$results = [];
|
||||
$useDivider = filter_var($this->args['showDivider'], FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
foreach ($posts as $index => $post) {
|
||||
if ($useDivider && $index > 0) {
|
||||
$results[] = $this->transformer->getDivider();
|
||||
}
|
||||
|
||||
$results = array_merge($results, $this->transformer->transform($post));
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class PostTransformer {
|
||||
/** @var PostTransformerContentsExtractor */
|
||||
private $extractor;
|
||||
|
||||
/** @var array */
|
||||
private $args;
|
||||
/** @var bool */
|
||||
private $withLayout;
|
||||
/** @var string */
|
||||
private $imagePosition;
|
||||
|
||||
public function __construct(
|
||||
$args,
|
||||
PostTransformerContentsExtractor $extractor = null
|
||||
) {
|
||||
$this->args = $args;
|
||||
$this->withLayout = isset($args['withLayout']) ? (bool)filter_var($args['withLayout'], FILTER_VALIDATE_BOOLEAN) : false;
|
||||
$this->imagePosition = 'left';
|
||||
if ($extractor === null) {
|
||||
$extractor = new PostTransformerContentsExtractor($args);
|
||||
}
|
||||
$this->extractor = $extractor;
|
||||
}
|
||||
|
||||
public function getDivider() {
|
||||
if (empty($this->withLayout)) {
|
||||
return $this->args['divider'];
|
||||
}
|
||||
return LayoutHelper::row([
|
||||
LayoutHelper::col([$this->args['divider']]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function transform($post) {
|
||||
if (empty($this->withLayout)) {
|
||||
return $this->getStructure($post);
|
||||
}
|
||||
return $this->getStructureWithLayout($post);
|
||||
}
|
||||
|
||||
private function getStructure($post) {
|
||||
$content = $this->extractor->getContent($post, true, $this->args['displayType']);
|
||||
$title = $this->extractor->getTitle($post);
|
||||
$featuredImage = $this->extractor->getFeaturedImage($post);
|
||||
$featuredImagePosition = $this->getFeaturedImagePosition($this->extractor->isProduct($post));
|
||||
|
||||
if (
|
||||
$featuredImage
|
||||
&& $featuredImagePosition === 'belowTitle'
|
||||
&& (
|
||||
$this->args['displayType'] !== 'titleOnly'
|
||||
|| $this->extractor->isProduct($post)
|
||||
)
|
||||
) {
|
||||
array_unshift($content, $title, $featuredImage);
|
||||
return $content;
|
||||
}
|
||||
|
||||
if ($content[0]['type'] === 'text') {
|
||||
$content[0]['text'] = $title['text'] . $content[0]['text'];
|
||||
} else {
|
||||
array_unshift($content, $title);
|
||||
}
|
||||
|
||||
if ($featuredImage && $this->args['displayType'] !== 'titleOnly') {
|
||||
array_unshift($content, $featuredImage);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function getStructureWithLayout($post) {
|
||||
$withPostClass = $this->args['displayType'] === 'full' || $this->args['displayType'] === 'excerpt';
|
||||
$content = $this->extractor->getContent($post, $withPostClass, $this->args['displayType']);
|
||||
$title = $this->extractor->getTitle($post);
|
||||
$featuredImage = $this->extractor->getFeaturedImage($post);
|
||||
$featuredImagePosition = $this->getFeaturedImagePosition($this->extractor->isProduct($post));
|
||||
|
||||
if (
|
||||
!$featuredImage
|
||||
|| $featuredImagePosition === 'none'
|
||||
|| (
|
||||
$this->args['displayType'] === 'titleOnly'
|
||||
&& !$this->extractor->isProduct($post)
|
||||
)
|
||||
) {
|
||||
array_unshift($content, $title);
|
||||
|
||||
return [
|
||||
LayoutHelper::row([
|
||||
LayoutHelper::col($content),
|
||||
]),
|
||||
];
|
||||
}
|
||||
$titlePosition = isset($this->args['titlePosition']) ? $this->args['titlePosition'] : '';
|
||||
|
||||
if ($featuredImagePosition === 'aboveTitle' || $featuredImagePosition === 'belowTitle') {
|
||||
$featuredImagePosition = 'centered';
|
||||
}
|
||||
|
||||
if ($featuredImagePosition === 'centered') {
|
||||
if ($titlePosition === 'aboveExcerpt') {
|
||||
array_unshift($content, $featuredImage, $title);
|
||||
} else {
|
||||
array_unshift($content, $title, $featuredImage);
|
||||
}
|
||||
return [
|
||||
LayoutHelper::row([
|
||||
LayoutHelper::col($content),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if ($titlePosition === 'aboveExcerpt') {
|
||||
array_unshift($content, $title);
|
||||
}
|
||||
|
||||
if ($featuredImagePosition === 'alternate') {
|
||||
$featuredImagePosition = $this->nextImagePosition();
|
||||
}
|
||||
|
||||
$content = ($featuredImagePosition === 'left')
|
||||
? [
|
||||
LayoutHelper::col([$featuredImage]),
|
||||
LayoutHelper::col($content),
|
||||
]
|
||||
: [
|
||||
LayoutHelper::col($content),
|
||||
LayoutHelper::col([$featuredImage]),
|
||||
];
|
||||
|
||||
$result = [
|
||||
LayoutHelper::row($content),
|
||||
];
|
||||
|
||||
if ($titlePosition !== 'aboveExcerpt') {
|
||||
array_unshift(
|
||||
$result,
|
||||
LayoutHelper::row(
|
||||
[
|
||||
LayoutHelper::col([$title]),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function nextImagePosition() {
|
||||
$this->imagePosition = ($this->imagePosition === 'left') ? 'right' : 'left';
|
||||
return $this->imagePosition;
|
||||
}
|
||||
|
||||
private function getFeaturedImagePosition(bool $isProduct) {
|
||||
if ($this->args['displayType'] !== 'full') {
|
||||
return $this->args['featuredImagePosition'];
|
||||
}
|
||||
|
||||
// For products with display type 'full' use 'featuredImagePosition' if 'fullPostFeaturedImagePosition' not set.
|
||||
// This is because products always supported images, even for 'full' post display type.
|
||||
if ($isProduct && empty($this->args['fullPostFeaturedImagePosition'])) {
|
||||
return $this->args['featuredImagePosition'];
|
||||
}
|
||||
|
||||
// For posts with display type 'full' use 'fullPostFeaturedImagePosition'. This is for back compatibility
|
||||
// with posts that don't have featured image but contain some value for 'featuredImagePosition' in the DB.
|
||||
return $this->args['fullPostFeaturedImagePosition'] ?? 'none';
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\WooCommerce\Helper as WooCommerceHelper;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class PostTransformerContentsExtractor {
|
||||
|
||||
private $args;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var WooCommerceHelper */
|
||||
private $woocommerceHelper;
|
||||
|
||||
public function __construct(
|
||||
$args
|
||||
) {
|
||||
$this->args = $args;
|
||||
$this->wp = new WPFunctions();
|
||||
$this->woocommerceHelper = new WooCommerceHelper($this->wp);
|
||||
}
|
||||
|
||||
public function getContent($post, $withPostClass, $displayType) {
|
||||
$contentManager = new PostContentManager();
|
||||
$metaManager = new MetaInformationManager();
|
||||
|
||||
$content = $contentManager->getContent($post, $this->args['displayType']);
|
||||
$content = $metaManager->appendMetaInformation($content, $post, $this->args);
|
||||
$content = $contentManager->filterContent($content, $displayType, $withPostClass);
|
||||
|
||||
$structureTransformer = new StructureTransformer();
|
||||
$content = $structureTransformer->transform($content, $this->args['imageFullWidth'] === true);
|
||||
|
||||
if ($this->isProduct($post)) {
|
||||
$content = $this->addProductDataToContent($content, $post);
|
||||
}
|
||||
|
||||
$readMoreBtn = $this->getReadMoreButton($post);
|
||||
$blocksCount = count($content);
|
||||
if (!$readMoreBtn) {
|
||||
// Don't attach a button
|
||||
} else if ($readMoreBtn['type'] === 'text' && $blocksCount > 0 && $content[$blocksCount - 1]['type'] === 'text') {
|
||||
$content[$blocksCount - 1]['text'] .= $readMoreBtn['text'];
|
||||
} else {
|
||||
$content[] = $readMoreBtn;
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function getImageInfo($id) {
|
||||
/*
|
||||
* In some cases wp_get_attachment_image_src ignore the second parameter
|
||||
* and use global variable $content_width value instead.
|
||||
* By overriding it ourselves when ensure a constant behaviour regardless
|
||||
* of the user setup.
|
||||
*
|
||||
* https://mailpoet.atlassian.net/browse/MAILPOET-1365
|
||||
*/
|
||||
global $content_width; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps, default is NULL
|
||||
|
||||
$contentWidthCopy = $content_width; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$content_width = Env::NEWSLETTER_CONTENT_WIDTH; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$imageInfo = $this->wp->wpGetAttachmentImageSrc($id, 'mailpoet_newsletter_max');
|
||||
$content_width = $contentWidthCopy; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
|
||||
return $imageInfo;
|
||||
}
|
||||
|
||||
public function getFeaturedImage($post) {
|
||||
$postId = $post->ID;
|
||||
$postTitle = $this->sanitizeTitle($post->post_title); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$imageFullWidth = (bool)filter_var($this->args['imageFullWidth'], FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!has_post_thumbnail($postId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$thumbnailId = $this->wp->getPostThumbnailId($postId);
|
||||
$imageInfo = $this->getImageInfo($thumbnailId);
|
||||
|
||||
// get alt text
|
||||
$altText = trim(strip_tags(get_post_meta(
|
||||
$thumbnailId,
|
||||
'_wp_attachment_image_alt',
|
||||
true
|
||||
)));
|
||||
if (strlen($altText) === 0) {
|
||||
// if the alt text is empty then use the post title
|
||||
$altText = trim(strip_tags($postTitle));
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'image',
|
||||
'link' => $this->wp->getPermalink($postId),
|
||||
'src' => $imageInfo[0],
|
||||
'alt' => $altText,
|
||||
'fullWidth' => $imageFullWidth,
|
||||
'width' => $imageInfo[1],
|
||||
'height' => $imageInfo[2],
|
||||
'styles' => [
|
||||
'block' => [
|
||||
'textAlign' => 'center',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getReadMoreButton($post) {
|
||||
if ($this->args['readMoreType'] === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->args['readMoreType'] === 'button') {
|
||||
$button = $this->args['readMoreButton'];
|
||||
$button['url'] = $this->wp->getPermalink($post->ID);
|
||||
return $button;
|
||||
}
|
||||
|
||||
$readMoreText = sprintf(
|
||||
'<p><a href="%s">%s</a></p>',
|
||||
$this->wp->getPermalink($post->ID),
|
||||
$this->args['readMoreText']
|
||||
);
|
||||
|
||||
return [
|
||||
'type' => 'text',
|
||||
'text' => $readMoreText,
|
||||
];
|
||||
}
|
||||
|
||||
public function getTitle($post) {
|
||||
$title = $post->post_title; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
|
||||
if (filter_var($this->args['titleIsLink'], FILTER_VALIDATE_BOOLEAN)) {
|
||||
$title = '<a href="' . $this->wp->getPermalink($post->ID) . '">' . $title . '</a>';
|
||||
}
|
||||
|
||||
if (in_array($this->args['titleFormat'], ['h1', 'h2', 'h3'])) {
|
||||
$tag = $this->args['titleFormat'];
|
||||
} elseif ($this->args['titleFormat'] === 'ul') {
|
||||
$tag = 'li';
|
||||
} else {
|
||||
$tag = 'h1';
|
||||
}
|
||||
|
||||
$alignment = (in_array($this->args['titleAlignment'], ['left', 'right', 'center'])) ? $this->args['titleAlignment'] : 'left';
|
||||
|
||||
$title = '<' . $tag . ' data-post-id="' . $post->ID . '" style="text-align: ' . $alignment . ';">' . $title . '</' . $tag . '>';
|
||||
|
||||
// The allowed HTML is based on all the possible ways we might construct a $title above
|
||||
$commonAttributes = [
|
||||
'data-post-id' => [],
|
||||
'style' => [],
|
||||
];
|
||||
|
||||
$allowedTitleHtml = [
|
||||
'a' => [
|
||||
'href' => [],
|
||||
],
|
||||
'li' => $commonAttributes,
|
||||
'h1' => $commonAttributes,
|
||||
'h2' => $commonAttributes,
|
||||
'h3' => $commonAttributes,
|
||||
'b' => [],
|
||||
'i' => [],
|
||||
'strong' => [],
|
||||
'em' => [],
|
||||
'small' => [],
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'text',
|
||||
'text' => wp_kses($title, $allowedTitleHtml),
|
||||
];
|
||||
}
|
||||
|
||||
private function getPrice($post) {
|
||||
$price = null;
|
||||
$product = null;
|
||||
if ($this->woocommerceHelper->isWooCommerceActive()) {
|
||||
$product = $this->woocommerceHelper->wcGetProduct($post->ID);
|
||||
}
|
||||
if ($product) {
|
||||
$price = '<h2>' . strip_tags($product->get_price_html(), '<span><del>') . '</h2>';
|
||||
}
|
||||
return $price;
|
||||
}
|
||||
|
||||
private function addProductDataToContent($content, $post) {
|
||||
if (!isset($this->args['pricePosition']) || $this->args['pricePosition'] === 'hidden') {
|
||||
return $content;
|
||||
}
|
||||
$price = $this->getPrice($post);
|
||||
$blocksCount = count($content);
|
||||
if ($blocksCount > 0 && $content[$blocksCount - 1]['type'] === 'text') {
|
||||
if ($this->args['pricePosition'] === 'below') {
|
||||
$content[$blocksCount - 1]['text'] = $content[$blocksCount - 1]['text'] . $price;
|
||||
} else {
|
||||
$content[$blocksCount - 1]['text'] = $price . $content[$blocksCount - 1]['text'];
|
||||
}
|
||||
} else {
|
||||
$content[] = [
|
||||
'type' => 'text',
|
||||
'text' => $price,
|
||||
];
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function isProduct($post) {
|
||||
return $post->post_type === 'product'; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces double quote character with a unicode
|
||||
* alternative to avoid problems when inlining CSS.
|
||||
* [MAILPOET-1937]
|
||||
*
|
||||
* @param string $title
|
||||
* @return string
|
||||
*/
|
||||
private function sanitizeTitle($title) {
|
||||
return str_replace('"', '"', $title);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Util\DOM as DOMUtil;
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
use MailPoetVendor\pQuery\DomNode;
|
||||
|
||||
class StructureTransformer {
|
||||
public function transform($content, $imageFullWidth) {
|
||||
$root = pQuery::parseStr($content);
|
||||
|
||||
$this->hoistImagesToRoot($root);
|
||||
$structure = $this->transformTagsToBlocks($root, $imageFullWidth);
|
||||
$structure = $this->mergeNeighboringBlocks($structure);
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoists images to root level, preserves order by splitting neighboring
|
||||
* elements and inserts tags as children of top ancestor
|
||||
*/
|
||||
protected function hoistImagesToRoot(DomNode $root) {
|
||||
foreach ($root->query('img') as $item) {
|
||||
$topAncestor = DOMUtil::findTopAncestor($item);
|
||||
$offset = $topAncestor->index();
|
||||
|
||||
if ($item->hasParent('a') || $item->hasParent('figure')) {
|
||||
$item = $item->parent;
|
||||
}
|
||||
|
||||
DOMUtil::splitOn($item->getRoot(), $item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms HTML tags into their respective JSON objects,
|
||||
* turns other root children into text blocks
|
||||
*/
|
||||
private function transformTagsToBlocks(DomNode $root, $imageFullWidth) {
|
||||
$children = $this->filterOutFiguresWithoutImages($root->children);
|
||||
return array_map(function($item) use ($imageFullWidth) {
|
||||
if ($this->isImageElement($item)) {
|
||||
$image = $item->tag === 'img' ? $item : $item->query('img')[0];
|
||||
$width = $image->getAttribute('width');
|
||||
$height = $image->getAttribute('height');
|
||||
return [
|
||||
'type' => 'image',
|
||||
'link' => $item->getAttribute('href') ?: '',
|
||||
'src' => $image->getAttribute('src'),
|
||||
'alt' => $image->getAttribute('alt'),
|
||||
'fullWidth' => $imageFullWidth,
|
||||
'width' => $width === null ? 'auto' : $width,
|
||||
'height' => $height === null ? 'auto' : $height,
|
||||
'styles' => [
|
||||
'block' => [
|
||||
'textAlign' => $this->getImageAlignment($image),
|
||||
],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'type' => 'text',
|
||||
'text' => $item->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
}, $children);
|
||||
}
|
||||
|
||||
private function filterOutFiguresWithoutImages(array $items) {
|
||||
$items = array_filter($items, function (DomNode $item) {
|
||||
if ($item->tag === 'figure' && $item->query('img')->count() === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return array_values($items);
|
||||
}
|
||||
|
||||
private function isImageElement(DomNode $item) {
|
||||
return $item->tag === 'img' || (in_array($item->tag, ['a', 'figure'], true) && $item->query('img')->count() > 0);
|
||||
}
|
||||
|
||||
private function getImageAlignment(DomNode $image) {
|
||||
$alignItem = $image->hasParent('figure') ? $image->parent : $image;
|
||||
if ($alignItem->hasClass('aligncenter')) {
|
||||
$align = 'center';
|
||||
} elseif ($alignItem->hasClass('alignleft')) {
|
||||
$align = 'left';
|
||||
} elseif ($alignItem->hasClass('alignright')) {
|
||||
$align = 'right';
|
||||
} else {
|
||||
$align = 'left';
|
||||
}
|
||||
return $align;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges neighboring blocks when possible.
|
||||
* E.g. 2 adjacent text blocks may be combined into one.
|
||||
*/
|
||||
private function mergeNeighboringBlocks(array $structure) {
|
||||
$updatedStructure = [];
|
||||
$textAccumulator = '';
|
||||
foreach ($structure as $item) {
|
||||
if ($item['type'] === 'text') {
|
||||
$textAccumulator .= $item['text'];
|
||||
}
|
||||
if ($item['type'] !== 'text') {
|
||||
if (!empty($textAccumulator)) {
|
||||
$updatedStructure[] = [
|
||||
'type' => 'text',
|
||||
'text' => trim($textAccumulator),
|
||||
];
|
||||
$textAccumulator = '';
|
||||
}
|
||||
$updatedStructure[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($textAccumulator)) {
|
||||
$updatedStructure[] = [
|
||||
'type' => 'text',
|
||||
'text' => trim($textAccumulator),
|
||||
];
|
||||
}
|
||||
|
||||
return $updatedStructure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class TitleListTransformer {
|
||||
|
||||
private $args;
|
||||
|
||||
public function __construct(
|
||||
$args
|
||||
) {
|
||||
$this->args = $args;
|
||||
}
|
||||
|
||||
public function transform($posts) {
|
||||
$results = array_map(function($post) {
|
||||
return $this->getPostTitle($post);
|
||||
}, $posts);
|
||||
|
||||
return [
|
||||
$this->wrap([
|
||||
'type' => 'text',
|
||||
'text' => '<ul>' . implode('', $results) . '</ul>',
|
||||
])];
|
||||
}
|
||||
|
||||
private function wrap($block) {
|
||||
return LayoutHelper::row([
|
||||
LayoutHelper::col([$block]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getPostTitle($post) {
|
||||
$title = $post->post_title; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$alignment = $this->args['titleAlignment'];
|
||||
$alignment = (in_array($alignment, ['left', 'right', 'center'])) ? $alignment : 'left';
|
||||
|
||||
if ($this->args['titleIsLink']) {
|
||||
$title = '<a data-post-id="' . $post->ID . '" href="' . WPFunctions::get()->getPermalink($post->ID) . '">' . $title . '</a>';
|
||||
}
|
||||
|
||||
return '<li style="text-align: ' . $alignment . ';">' . $title . '</li>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Editor;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class Transformer {
|
||||
|
||||
private $transformer;
|
||||
|
||||
public function __construct(
|
||||
$args
|
||||
) {
|
||||
$titleListOnly = $args['displayType'] === 'titleOnly' && $args['titleFormat'] === 'ul';
|
||||
|
||||
if ($titleListOnly) $transformer = new TitleListTransformer($args);
|
||||
else $transformer = new PostListTransformer($args);
|
||||
$this->transformer = $transformer;
|
||||
}
|
||||
|
||||
public function transform($posts) {
|
||||
return $this->transformer->transform($posts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Links;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository;
|
||||
use MailPoet\DI\ContainerWrapper;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterLinkEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\InvalidStateException;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Link;
|
||||
use MailPoet\Newsletter\Shortcodes\Shortcodes;
|
||||
use MailPoet\Router\Endpoints\Track as TrackEndpoint;
|
||||
use MailPoet\Router\Router;
|
||||
use MailPoet\Subscribers\LinkTokens;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoet\Util\pQuery\pQuery as DomParser;
|
||||
use MailPoet\Util\Security;
|
||||
|
||||
class Links {
|
||||
const DATA_TAG_CLICK = '[mailpoet_click_data]';
|
||||
const DATA_TAG_OPEN = '[mailpoet_open_data]';
|
||||
const LINK_TYPE_SHORTCODE = 'shortcode';
|
||||
const LINK_TYPE_URL = 'link';
|
||||
|
||||
/** @var LinkTokens */
|
||||
private $linkTokens;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var NewsletterLinkRepository */
|
||||
private $newsletterLinkRepository;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var SendingQueuesRepository */
|
||||
private $sendingQueueRepository;
|
||||
|
||||
public function __construct(
|
||||
LinkTokens $linkTokens,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
NewsletterLinkRepository $newsletterLinkRepository,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
SendingQueuesRepository $sendingQueuesRepository
|
||||
) {
|
||||
$this->linkTokens = $linkTokens;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->newsletterLinkRepository = $newsletterLinkRepository;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->sendingQueueRepository = $sendingQueuesRepository;
|
||||
}
|
||||
|
||||
public function process($content, $newsletterId, $queueId) {
|
||||
$extractedLinks = $this->extract($content);
|
||||
$savedLinks = $this->load($newsletterId, $queueId);
|
||||
$processedLinks = $this->hash($extractedLinks, $savedLinks);
|
||||
return $this->replace($content, $processedLinks);
|
||||
}
|
||||
|
||||
public function extract($content) {
|
||||
$extractedLinks = [];
|
||||
// extract link shortcodes
|
||||
/** @var Shortcodes $shortcodes */
|
||||
$shortcodes = ContainerWrapper::getInstance()->get(Shortcodes::class);
|
||||
$shortcodes = $shortcodes->extract(
|
||||
$content,
|
||||
$categories = [Link::CATEGORY_NAME]
|
||||
);
|
||||
if ($shortcodes) {
|
||||
$extractedLinks = array_map(function($shortcode) {
|
||||
return [
|
||||
'type' => Links::LINK_TYPE_SHORTCODE,
|
||||
'link' => $shortcode,
|
||||
];
|
||||
}, $shortcodes);
|
||||
}
|
||||
// extract HTML anchor tags
|
||||
$DOM = DomParser::parseStr($content);
|
||||
foreach ($DOM->query('a') as $link) {
|
||||
if (!$link->href) continue;
|
||||
$extractedLinks[] = [
|
||||
'type' => self::LINK_TYPE_URL,
|
||||
'link' => $link->href,
|
||||
];
|
||||
}
|
||||
return array_unique($extractedLinks, SORT_REGULAR);
|
||||
}
|
||||
|
||||
public function replace($content, $processedLinks) {
|
||||
// replace HTML anchor tags
|
||||
$DOM = DomParser::parseStr($content);
|
||||
foreach ($DOM->query('a') as $link) {
|
||||
$linkToReplace = $link->href;
|
||||
$replacementLink = (!empty($processedLinks[$linkToReplace]['processed_link'])) ?
|
||||
$processedLinks[$linkToReplace]['processed_link'] :
|
||||
null;
|
||||
if (!$replacementLink) continue;
|
||||
$link->setAttribute('href', $replacementLink);
|
||||
}
|
||||
$content = $DOM->__toString();
|
||||
// replace link shortcodes and markdown links
|
||||
foreach ($processedLinks as $processedLink) {
|
||||
$linkToReplace = $processedLink['link'];
|
||||
$replacementLink = $processedLink['processed_link'];
|
||||
if ($processedLink['type'] == self::LINK_TYPE_SHORTCODE) {
|
||||
$content = str_replace($linkToReplace, $replacementLink, (string)$content);
|
||||
}
|
||||
$content = preg_replace(
|
||||
'/\[(.*?)\](\(' . preg_quote($linkToReplace, '/') . '\))/',
|
||||
'[$1](' . $replacementLink . ')',
|
||||
(string)$content
|
||||
);
|
||||
}
|
||||
return [
|
||||
$content,
|
||||
array_values($processedLinks),
|
||||
];
|
||||
}
|
||||
|
||||
public function replaceSubscriberData(
|
||||
$subscriberId,
|
||||
$queueId,
|
||||
$content,
|
||||
$preview = false
|
||||
) {
|
||||
// match data tags
|
||||
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
|
||||
if (!$subscriber) {
|
||||
throw new InvalidStateException();
|
||||
}
|
||||
preg_match_all($this->getLinkRegex(), $content, $matches);
|
||||
foreach ($matches[1] as $index => $match) {
|
||||
$hash = null;
|
||||
if (preg_match('/-/', $match)) {
|
||||
[, $hash] = explode('-', $match);
|
||||
}
|
||||
$data = $this->createUrlDataObject(
|
||||
$subscriber->getId(),
|
||||
$this->linkTokens->getToken($subscriber),
|
||||
$queueId,
|
||||
$hash,
|
||||
$preview
|
||||
);
|
||||
$routerAction = ($matches[2][$index] === self::DATA_TAG_CLICK) ?
|
||||
TrackEndpoint::ACTION_CLICK :
|
||||
TrackEndpoint::ACTION_OPEN;
|
||||
$link = Router::buildRequest(
|
||||
TrackEndpoint::ENDPOINT,
|
||||
$routerAction,
|
||||
$data
|
||||
);
|
||||
$content = str_replace($match, $link, $content);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function save(array $links, $newsletterId, $queueId) {
|
||||
foreach ($links as $link) {
|
||||
if (isset($link['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (empty($link['hash']) || empty($link['link'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newsletter = $this->newslettersRepository->getReference($newsletterId);
|
||||
$sendingQueue = $this->sendingQueueRepository->getReference($queueId);
|
||||
|
||||
if (!$newsletter instanceof NewsletterEntity || !$sendingQueue instanceof SendingQueueEntity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newsletterLink = new NewsletterLinkEntity($newsletter, $sendingQueue, $link['link'], $link['hash']);
|
||||
$this->newsletterLinkRepository->persist($newsletterLink);
|
||||
}
|
||||
|
||||
$this->newsletterLinkRepository->flush();
|
||||
}
|
||||
|
||||
public function ensureInstantUnsubscribeLink(array $processedLinks) {
|
||||
if (
|
||||
in_array(
|
||||
NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE,
|
||||
array_column($processedLinks, 'link')
|
||||
)
|
||||
) {
|
||||
return $processedLinks;
|
||||
}
|
||||
$processedLinks[] = $this->hashLink(
|
||||
NewsletterLinkEntity::INSTANT_UNSUBSCRIBE_LINK_SHORT_CODE,
|
||||
Links::LINK_TYPE_SHORTCODE
|
||||
);
|
||||
return $processedLinks;
|
||||
}
|
||||
|
||||
public function convertHashedLinksToShortcodesAndUrls($content, $queueId, $convertAll = false) {
|
||||
preg_match_all($this->getLinkRegex(), $content, $links);
|
||||
$links = array_unique(Helpers::flattenArray($links));
|
||||
foreach ($links as $link) {
|
||||
$linkHash = explode('-', $link);
|
||||
|
||||
if (!isset($linkHash[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newsletterLink = $this->newsletterLinkRepository->findOneBy(['hash' => $linkHash[1], 'queue' => $queueId]);
|
||||
|
||||
// convert either only link shortcodes or all hashes links if "convert all"
|
||||
// option is specified
|
||||
if (
|
||||
($newsletterLink instanceof NewsletterLinkEntity) &&
|
||||
(preg_match('/\[link:/', $newsletterLink->getUrl()) || $convertAll)
|
||||
) {
|
||||
$content = str_replace($link, $newsletterLink->getUrl(), $content);
|
||||
}
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function getLinkRegex() {
|
||||
return sprintf(
|
||||
'/((%s|%s)(?:-\w+)?)/',
|
||||
preg_quote(self::DATA_TAG_CLICK),
|
||||
preg_quote(self::DATA_TAG_OPEN)
|
||||
);
|
||||
}
|
||||
|
||||
public function createUrlDataObject(
|
||||
$subscriberId, $subscriberLinkToken, $queueId, $linkHash, $preview
|
||||
) {
|
||||
return [
|
||||
(string)$subscriberId,
|
||||
$subscriberLinkToken,
|
||||
(string)$queueId,
|
||||
$linkHash,
|
||||
$preview,
|
||||
];
|
||||
}
|
||||
|
||||
public function transformUrlDataObject($data) {
|
||||
reset($data);
|
||||
if (!is_int(key($data))) return $data;
|
||||
$transformedData = [];
|
||||
$transformedData['subscriber_id'] = (!empty($data[0])) ? $data[0] : false;
|
||||
$transformedData['subscriber_token'] = (!empty($data[1])) ? $data[1] : false;
|
||||
$transformedData['queue_id'] = (!empty($data[2])) ? $data[2] : false;
|
||||
$transformedData['link_hash'] = (!empty($data[3])) ? $data[3] : false;
|
||||
$transformedData['preview'] = (!empty($data[4])) ? $data[4] : false;
|
||||
return $transformedData;
|
||||
}
|
||||
|
||||
private static function hashLink($link, $type) {
|
||||
$hash = Security::generateHash();
|
||||
return [
|
||||
'type' => $type,
|
||||
'hash' => $hash,
|
||||
'link' => $link,
|
||||
// replace link with a temporary data tag + hash
|
||||
// it will be further replaced with the proper track API URL during sending
|
||||
'processed_link' => self::DATA_TAG_CLICK . '-' . $hash,
|
||||
];
|
||||
}
|
||||
|
||||
private function hash($extractedLinks, $savedLinks) {
|
||||
$processedLinks = array_map(function($link) {
|
||||
$link['type'] = Links::LINK_TYPE_URL;
|
||||
$link['link'] = $link['url'];
|
||||
$link['processed_link'] = self::DATA_TAG_CLICK . '-' . $link['hash'];
|
||||
return $link;
|
||||
}, $savedLinks);
|
||||
foreach ($extractedLinks as $extractedLink) {
|
||||
$link = $extractedLink['link'];
|
||||
if (array_key_exists($link, $processedLinks))
|
||||
continue;
|
||||
// Use URL as a key to map between extracted and processed links
|
||||
// regardless of their sequential position (useful for link skips etc.)
|
||||
$processedLinks[$link] = $this->hashLink($link, $extractedLink['type']);
|
||||
}
|
||||
return $processedLinks;
|
||||
}
|
||||
|
||||
private function load($newsletterId, $queueId) {
|
||||
$links = $this->newsletterLinkRepository->findBy(
|
||||
['newsletter' => $newsletterId, 'queue' => $queueId]
|
||||
);
|
||||
|
||||
$savedLinks = [];
|
||||
foreach ($links as $link) {
|
||||
$savedLinks[$link->getUrl()] = $link->toArray();
|
||||
}
|
||||
return $savedLinks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Listing;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Listing\ListingDefinition;
|
||||
use MailPoet\Listing\ListingRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class NewsletterListingRepository extends ListingRepository {
|
||||
private static $supportedStatuses = [
|
||||
NewsletterEntity::STATUS_DRAFT,
|
||||
NewsletterEntity::STATUS_SCHEDULED,
|
||||
NewsletterEntity::STATUS_SENDING,
|
||||
NewsletterEntity::STATUS_SENT,
|
||||
NewsletterEntity::STATUS_ACTIVE,
|
||||
];
|
||||
|
||||
private static $supportedTypes = [
|
||||
NewsletterEntity::TYPE_STANDARD,
|
||||
NewsletterEntity::TYPE_RE_ENGAGEMENT,
|
||||
NewsletterEntity::TYPE_WELCOME,
|
||||
NewsletterEntity::TYPE_AUTOMATIC,
|
||||
NewsletterEntity::TYPE_NOTIFICATION,
|
||||
NewsletterEntity::TYPE_NOTIFICATION_HISTORY,
|
||||
];
|
||||
|
||||
public function getFilters(ListingDefinition $definition): array {
|
||||
$group = $definition->getGroup();
|
||||
$typeParam = $definition->getParameters()['type'] ?? null;
|
||||
$groupParam = $definition->getParameters()['group'] ?? null;
|
||||
|
||||
// newsletter types without filters
|
||||
if (in_array($typeParam, [NewsletterEntity::TYPE_NOTIFICATION_HISTORY])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$this->applyFromClause($queryBuilder);
|
||||
|
||||
if ($group) {
|
||||
$this->applyGroup($queryBuilder, $group);
|
||||
}
|
||||
|
||||
if ($typeParam) {
|
||||
$this->applyType($queryBuilder, $typeParam, $groupParam);
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->select('s.id, s.name, COUNT(n) AS newsletterCount')
|
||||
->join('n.newsletterSegments', 'ns')
|
||||
->join('ns.segment', 's')
|
||||
->groupBy('s.id')
|
||||
->addGroupBy('s.name')
|
||||
->orderBy('s.name')
|
||||
->having('COUNT(n) > 0');
|
||||
|
||||
// format segment list
|
||||
$segmentList = [
|
||||
[
|
||||
'label' => __('All Lists', 'mailpoet'),
|
||||
'value' => '',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($queryBuilder->getQuery()->getResult() as $item) {
|
||||
$segmentList[] = [
|
||||
'label' => sprintf('%s (%d)', $item['name'], $item['newsletterCount']),
|
||||
'value' => $item['id'],
|
||||
];
|
||||
}
|
||||
return ['segment' => $segmentList];
|
||||
}
|
||||
|
||||
public function getGroups(ListingDefinition $definition): array {
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$this->applyFromClause($queryBuilder);
|
||||
$this->applyParameters($queryBuilder, $definition->getParameters());
|
||||
|
||||
// total count
|
||||
$countQueryBuilder = clone $queryBuilder;
|
||||
$countQueryBuilder->select('COUNT(n) AS newsletterCount');
|
||||
$countQueryBuilder->andWhere('n.deletedAt IS NULL');
|
||||
$totalCount = (int)$countQueryBuilder->getQuery()->getSingleScalarResult();
|
||||
|
||||
// trashed count
|
||||
$trashedCountQueryBuilder = clone $queryBuilder;
|
||||
$trashedCountQueryBuilder->select('COUNT(n) AS newsletterCount');
|
||||
$trashedCountQueryBuilder->andWhere('n.deletedAt IS NOT NULL');
|
||||
$trashedCount = (int)$trashedCountQueryBuilder->getQuery()->getSingleScalarResult();
|
||||
|
||||
// count-by-status query
|
||||
$queryBuilder->select('n.status, COUNT(n) AS newsletterCount');
|
||||
$queryBuilder->andWhere('n.deletedAt IS NULL');
|
||||
$queryBuilder->groupBy('n.status');
|
||||
|
||||
$map = [];
|
||||
foreach ($queryBuilder->getQuery()->getResult() as $item) {
|
||||
$map[$item['status']] = (int)$item['newsletterCount'];
|
||||
}
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => 'all',
|
||||
'label' => __('All', 'mailpoet'),
|
||||
'count' => $totalCount,
|
||||
],
|
||||
];
|
||||
|
||||
$type = $definition->getParameters()['type'] ?? null;
|
||||
switch ($type) {
|
||||
case NewsletterEntity::TYPE_STANDARD:
|
||||
$groups = array_merge($groups, [
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_DRAFT,
|
||||
'label' => __('Draft', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_DRAFT] ?? 0,
|
||||
],
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_SCHEDULED,
|
||||
'label' => __('Scheduled', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_SCHEDULED] ?? 0,
|
||||
],
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_SENDING,
|
||||
'label' => __('Sending', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_SENDING] ?? 0,
|
||||
],
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_SENT,
|
||||
'label' => __('Sent', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_SENT] ?? 0,
|
||||
],
|
||||
]);
|
||||
break;
|
||||
|
||||
case NewsletterEntity::TYPE_NOTIFICATION_HISTORY:
|
||||
$groups = array_merge($groups, [
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_SENDING,
|
||||
'label' => __('Sending', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_SENDING] ?? 0,
|
||||
],
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_SENT,
|
||||
'label' => __('Sent', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_SENT] ?? 0,
|
||||
],
|
||||
]);
|
||||
break;
|
||||
|
||||
case NewsletterEntity::TYPE_WELCOME:
|
||||
case NewsletterEntity::TYPE_RE_ENGAGEMENT:
|
||||
case NewsletterEntity::TYPE_NOTIFICATION:
|
||||
case NewsletterEntity::TYPE_AUTOMATIC:
|
||||
$groups = array_merge($groups, [
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_ACTIVE,
|
||||
'label' => __('Active', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_ACTIVE] ?? 0,
|
||||
],
|
||||
[
|
||||
'name' => NewsletterEntity::STATUS_DRAFT,
|
||||
'label' => __('Not active', 'mailpoet'),
|
||||
'count' => $map[NewsletterEntity::STATUS_DRAFT] ?? 0,
|
||||
],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
$groups[] = [
|
||||
'name' => 'trash',
|
||||
'label' => __('Trash', 'mailpoet'),
|
||||
'count' => $trashedCount,
|
||||
];
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
protected function applySelectClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->select("PARTIAL n.{id,subject,hash,type,status,sentAt,updatedAt,deletedAt}, PARTIAL wpPost.{id,postTitle}");
|
||||
}
|
||||
|
||||
protected function applyFromClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->from(NewsletterEntity::class, 'n')
|
||||
->leftJoin('n.wpPost', 'wpPost');
|
||||
}
|
||||
|
||||
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
|
||||
// include/exclude deleted
|
||||
if ($group === 'trash') {
|
||||
$queryBuilder->andWhere('n.deletedAt IS NOT NULL');
|
||||
} else {
|
||||
$queryBuilder->andWhere('n.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
if (!in_array($group, self::$supportedStatuses)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryBuilder
|
||||
->andWhere('n.status = :status')
|
||||
->setParameter('status', $group);
|
||||
}
|
||||
|
||||
protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
|
||||
$search = Helpers::escapeSearch($search);
|
||||
|
||||
$type = $parameters['type'] ?? null;
|
||||
|
||||
if ($type && $type === NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
|
||||
$queryBuilder
|
||||
->leftJoin('n.queues', 'sq')
|
||||
->andWhere('sq.newsletterRenderedSubject LIKE :search or n.subject LIKE :search')
|
||||
->setParameter('search', "%$search%");
|
||||
} else {
|
||||
$queryBuilder
|
||||
->andWhere('n.subject LIKE :search')
|
||||
->setParameter('search', "%$search%");
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
|
||||
$segmentId = $filters['segment'] ?? null;
|
||||
if ($segmentId) {
|
||||
$queryBuilder
|
||||
->join('n.newsletterSegments', 'ns')
|
||||
->andWhere('ns.segment = :segmentId')
|
||||
->setParameter('segmentId', $segmentId);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
|
||||
$type = $parameters['type'] ?? null;
|
||||
$group = $parameters['group'] ?? null;
|
||||
$parentId = $parameters['parentId'] ?? null;
|
||||
|
||||
if ($type) {
|
||||
$this->applyType($queryBuilder, $type, $group);
|
||||
}
|
||||
|
||||
if ($parentId) {
|
||||
$queryBuilder
|
||||
->andWhere('n.parent = :parentId')
|
||||
->setParameter('parentId', $parentId);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
|
||||
if ($sortBy === 'name') {
|
||||
$queryBuilder->addSelect('CONCAT(COALESCE(wpPost.postTitle, \'\'), n.subject) AS HIDDEN sortingName');
|
||||
$queryBuilder->addOrderBy("sortingName", $sortOrder);
|
||||
return;
|
||||
}
|
||||
if ($sortBy === 'sentAt') {
|
||||
$queryBuilder->addSelect('CASE WHEN n.sentAt IS NULL THEN 1 ELSE 0 END AS HIDDEN sentAtIsNull');
|
||||
$queryBuilder->addOrderBy('sentAtIsNull', 'DESC');
|
||||
}
|
||||
$queryBuilder->addOrderBy("n.$sortBy", $sortOrder);
|
||||
}
|
||||
|
||||
private function applyType(QueryBuilder $queryBuilder, string $type, string $group = null) {
|
||||
if (!in_array($type, self::$supportedTypes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === NewsletterEntity::TYPE_AUTOMATIC && $group) {
|
||||
$queryBuilder
|
||||
->join('n.options', 'o')
|
||||
->join('o.optionField', 'opf')
|
||||
->andWhere('o.value = :group')
|
||||
->setParameter('group', $group)
|
||||
->andWhere('opf.newsletterType = n.type');
|
||||
} else {
|
||||
$queryBuilder
|
||||
->andWhere('n.type = :type')
|
||||
->setParameter('type', $type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\Blocks\Coupon;
|
||||
|
||||
class NewsletterCoupon {
|
||||
public function cleanupBodySensitiveData(array $newsletterBody): array {
|
||||
|
||||
if (!is_array($newsletterBody) || empty($newsletterBody['content'])) {
|
||||
return $newsletterBody;
|
||||
}
|
||||
$cleanBlocks = $this->cleanupCouponBlocks($newsletterBody['content']['blocks']);
|
||||
return array_merge(
|
||||
$newsletterBody,
|
||||
[
|
||||
'content' => array_merge(
|
||||
$newsletterBody['content'],
|
||||
['blocks' => $cleanBlocks]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function cleanupCouponBlocks(array &$blocks): array {
|
||||
foreach ($blocks as &$block) {
|
||||
if (isset($block['blocks']) && !empty($block['blocks'])) {
|
||||
$this->cleanupCouponBlocks($block['blocks']);
|
||||
}
|
||||
|
||||
if (isset($block['type']) && $block['type'] === Coupon::TYPE) {
|
||||
$block['code'] = Coupon::CODE_PLACEHOLDER;
|
||||
|
||||
if(isset($block['couponId']))
|
||||
unset($block['couponId']);
|
||||
}
|
||||
}
|
||||
return $blocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\StatsNotifications\NewsletterLinkRepository;
|
||||
use MailPoet\Cron\Workers\StatsNotifications\StatsNotificationsRepository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatsNotificationEntity;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
|
||||
use MailPoet\Newsletter\Segment\NewsletterSegmentRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\Statistics\StatisticsClicksRepository;
|
||||
use MailPoet\Statistics\StatisticsNewslettersRepository;
|
||||
use MailPoet\Statistics\StatisticsOpensRepository;
|
||||
use MailPoet\Statistics\StatisticsWooCommercePurchasesRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use Throwable;
|
||||
|
||||
class NewsletterDeleteController {
|
||||
private EntityManager $entityManager;
|
||||
private NewslettersRepository $newslettersRepository;
|
||||
private NewsletterLinkRepository $newsletterLinkRepository;
|
||||
private NewsletterOptionsRepository $newsletterOptionsRepository;
|
||||
private NewsletterPostsRepository $newsletterPostsRepository;
|
||||
private NewsletterSegmentRepository $newsletterSegmentRepository;
|
||||
private ScheduledTasksRepository $scheduledTasksRepository;
|
||||
private ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository;
|
||||
private SendingQueuesRepository $sendingQueuesRepository;
|
||||
private StatisticsClicksRepository $statisticsClicksRepository;
|
||||
private StatisticsNewslettersRepository $statisticsNewslettersRepository;
|
||||
private StatisticsOpensRepository $statisticsOpensRepository;
|
||||
private StatisticsWooCommercePurchasesRepository $statisticsWooCommercePurchasesRepository;
|
||||
private StatsNotificationsRepository $statsNotificationsRepository;
|
||||
private WPFunctions $wp;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
NewsletterLinkRepository $newsletterLinkRepository,
|
||||
NewsletterOptionsRepository $newsletterOptionsRepository,
|
||||
NewsletterPostsRepository $newsletterPostsRepository,
|
||||
NewsletterSegmentRepository $newsletterSegmentRepository,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
|
||||
SendingQueuesRepository $sendingQueuesRepository,
|
||||
StatisticsClicksRepository $statisticsClicksRepository,
|
||||
StatisticsNewslettersRepository $statisticsNewslettersRepository,
|
||||
StatisticsOpensRepository $statisticsOpensRepository,
|
||||
StatisticsWooCommercePurchasesRepository $statisticsWooCommercePurchasesRepository,
|
||||
StatsNotificationsRepository $statsNotificationsRepository,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->newsletterLinkRepository = $newsletterLinkRepository;
|
||||
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
|
||||
$this->newsletterPostsRepository = $newsletterPostsRepository;
|
||||
$this->newsletterSegmentRepository = $newsletterSegmentRepository;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
$this->statisticsClicksRepository = $statisticsClicksRepository;
|
||||
$this->statisticsNewslettersRepository = $statisticsNewslettersRepository;
|
||||
$this->statisticsOpensRepository = $statisticsOpensRepository;
|
||||
$this->statisticsWooCommercePurchasesRepository = $statisticsWooCommercePurchasesRepository;
|
||||
$this->statsNotificationsRepository = $statsNotificationsRepository;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function bulkDelete(array $ids): int {
|
||||
if (!$ids) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fetch children ids for deleting
|
||||
$childrenIds = $this->newslettersRepository->fetchChildrenIds($ids);
|
||||
$ids = array_merge($ids, $childrenIds);
|
||||
|
||||
$this->entityManager->beginTransaction();
|
||||
try {
|
||||
// Delete statistics data
|
||||
$this->statisticsNewslettersRepository->deleteByNewsletterIds($ids);
|
||||
$this->statisticsOpensRepository->deleteByNewsletterIds($ids);
|
||||
$this->statisticsClicksRepository->deleteByNewsletterIds($ids);
|
||||
|
||||
// Update WooCommerce statistics and remove newsletter and click id
|
||||
$this->statisticsWooCommercePurchasesRepository->removeNewsletterDataByNewsletterIds($ids);
|
||||
|
||||
// Delete newsletter posts, options, links, and segments
|
||||
$this->newsletterPostsRepository->deleteByNewsletterIds($ids);
|
||||
$this->newsletterOptionsRepository->deleteByNewsletterIds($ids);
|
||||
$this->newsletterLinkRepository->deleteByNewsletterIds($ids);
|
||||
$this->newsletterSegmentRepository->deleteByNewsletterIds($ids);
|
||||
|
||||
// Delete stats notifications and related tasks
|
||||
/** @var string[] $taskIds */
|
||||
$taskIds = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(sn.task)')
|
||||
->from(StatsNotificationEntity::class, 'sn')
|
||||
->where('sn.newsletter IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
$taskIds = array_map('intval', $taskIds);
|
||||
|
||||
$this->scheduledTasksRepository->deleteByIds($taskIds);
|
||||
$this->statsNotificationsRepository->deleteByNewsletterIds($ids);
|
||||
|
||||
// Delete scheduled task subscribers, scheduled tasks, and sending queues
|
||||
/** @var string[] $taskIds */
|
||||
$taskIds = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(q.task)')
|
||||
->from(SendingQueueEntity::class, 'q')
|
||||
->where('q.newsletter IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
$taskIds = array_map('intval', $taskIds);
|
||||
|
||||
$this->scheduledTaskSubscribersRepository->deleteByTaskIds($taskIds);
|
||||
$this->scheduledTasksRepository->deleteByIds($taskIds);
|
||||
$this->sendingQueuesRepository->deleteByNewsletterIds($ids);
|
||||
|
||||
// Fetch WP Posts IDs and delete them
|
||||
/** @var string[] $wpPostIds */
|
||||
$wpPostIds = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(n.wpPost) AS id')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.id IN (:ids)')
|
||||
->andWhere('n.wpPost IS NOT NULL')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
$wpPostIds = array_map('intval', $wpPostIds);
|
||||
|
||||
foreach ($wpPostIds as $wpPostId) {
|
||||
$this->wp->wpDeletePost($wpPostId, true);
|
||||
}
|
||||
|
||||
// Delete newsletter entities
|
||||
$this->newslettersRepository->deleteByIds($ids);
|
||||
|
||||
$this->entityManager->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->entityManager->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return count($ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class NewsletterHtmlSanitizer {
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
* Configuration of allowed tags for form blocks that may contain some html.
|
||||
* Covers all tags available in the form editor's Rich Text component
|
||||
*/
|
||||
private $allowedHtml = [
|
||||
'p' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'span' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'a' => [
|
||||
'href' => true,
|
||||
'class' => true,
|
||||
'title' => true,
|
||||
'target' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'h1' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'h2' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'h3' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'ol' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'ul' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'li' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'strong' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'em' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'strike' => [],
|
||||
'br' => [],
|
||||
'blockquote' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'table' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'tr' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'th' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'td' => [
|
||||
'class' => true,
|
||||
'style' => true,
|
||||
],
|
||||
'del' => [],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function sanitize(string $html): string {
|
||||
// Because wpKses break shortcodes we prefix shortcodes with http protocol
|
||||
$html = str_replace('href="[', 'href="http://[', $html);
|
||||
$this->wp->addFilter('safecss_filter_attr_allow_css', [$this, 'allowRgbInCss'], 10, 2);
|
||||
$html = $this->wp->wpKses($html, $this->allowedHtml);
|
||||
$this->wp->removeFilter('safecss_filter_attr_allow_css', [$this, 'allowRgbInCss'], 10);
|
||||
$html = str_replace('href="http://[', 'href="[', $html);
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* At the moment rgb() is not allowed to use in the style attribute. `style="color:rgb(0,0,0);"` gets
|
||||
* sanitized if you use wp_kses. We hook into safecss_filter_attr_allow_css to allow for rgb. The code
|
||||
* follows the precedent WordPress sets for the usage of var(), calc() etc. in safecss_filter_attr()
|
||||
*/
|
||||
public function allowRgbInCss($allowed, $cssString): bool {
|
||||
if ($allowed) {
|
||||
return (bool)$allowed;
|
||||
}
|
||||
$cssString = preg_replace(
|
||||
'/\b(?:rgb)(\((?:[^()]|(?1))*\))/',
|
||||
'',
|
||||
$cssString
|
||||
);
|
||||
return !preg_match('%[\\\(&=}]|/\*%', $cssString);
|
||||
}
|
||||
|
||||
public function sanitizeURL(string $url): string {
|
||||
return $this->wp->escUrlRaw($url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterPostEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterPostEntity>
|
||||
*/
|
||||
class NewsletterPostsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterPostEntity::class;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(NewsletterPostEntity::class, 'p')
|
||||
->where('p.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 (NewsletterPostEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\Tasks\Newsletter as NewsletterQueueTask;
|
||||
use MailPoet\EmailEditor\Integrations\MailPoet\EmailEditor;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\NewsletterSegmentEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\WpPostEntity;
|
||||
use MailPoet\InvalidStateException;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
|
||||
use MailPoet\Newsletter\Scheduler\PostNotificationScheduler;
|
||||
use MailPoet\Newsletter\Scheduler\Scheduler;
|
||||
use MailPoet\Newsletter\Segment\NewsletterSegmentRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoet\NewsletterTemplates\NewsletterTemplatesRepository;
|
||||
use MailPoet\NotFoundException;
|
||||
use MailPoet\Services\AuthorizedEmailsController;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\UnexpectedValueException;
|
||||
use MailPoet\Util\Security;
|
||||
use MailPoet\WP\Emoji;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class NewsletterSaveController {
|
||||
/** @var AuthorizedEmailsController */
|
||||
private $authorizedEmailsController;
|
||||
|
||||
/** @var Emoji */
|
||||
private $emoji;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var NewsletterOptionsRepository */
|
||||
private $newsletterOptionsRepository;
|
||||
|
||||
/** @var NewsletterOptionFieldsRepository */
|
||||
private $newsletterOptionFieldsRepository;
|
||||
|
||||
/** @var NewsletterSegmentRepository */
|
||||
private $newsletterSegmentRepository;
|
||||
|
||||
/** @var NewsletterTemplatesRepository */
|
||||
private $newsletterTemplatesRepository;
|
||||
|
||||
/** @var PostNotificationScheduler */
|
||||
private $postNotificationScheduler;
|
||||
|
||||
/** @var ScheduledTasksRepository */
|
||||
private $scheduledTasksRepository;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var Security */
|
||||
private $security;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var ApiDataSanitizer */
|
||||
private $dataSanitizer;
|
||||
|
||||
/** @var Scheduler */
|
||||
private $scheduler;
|
||||
|
||||
/*** @var NewsletterCoupon */
|
||||
private $newsletterCoupon;
|
||||
|
||||
public function __construct(
|
||||
AuthorizedEmailsController $authorizedEmailsController,
|
||||
Emoji $emoji,
|
||||
EntityManager $entityManager,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
NewsletterOptionsRepository $newsletterOptionsRepository,
|
||||
NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository,
|
||||
NewsletterSegmentRepository $newsletterSegmentRepository,
|
||||
NewsletterTemplatesRepository $newsletterTemplatesRepository,
|
||||
PostNotificationScheduler $postNotificationScheduler,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
SettingsController $settings,
|
||||
Security $security,
|
||||
WPFunctions $wp,
|
||||
ApiDataSanitizer $dataSanitizer,
|
||||
Scheduler $scheduler,
|
||||
NewsletterCoupon $newsletterCoupon
|
||||
) {
|
||||
$this->authorizedEmailsController = $authorizedEmailsController;
|
||||
$this->emoji = $emoji;
|
||||
$this->entityManager = $entityManager;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
|
||||
$this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository;
|
||||
$this->newsletterSegmentRepository = $newsletterSegmentRepository;
|
||||
$this->newsletterTemplatesRepository = $newsletterTemplatesRepository;
|
||||
$this->postNotificationScheduler = $postNotificationScheduler;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->settings = $settings;
|
||||
$this->security = $security;
|
||||
$this->wp = $wp;
|
||||
$this->dataSanitizer = $dataSanitizer;
|
||||
$this->scheduler = $scheduler;
|
||||
$this->newsletterCoupon = $newsletterCoupon;
|
||||
}
|
||||
|
||||
public function save(array $data = []): NewsletterEntity {
|
||||
if (!empty($data['template_id'])) {
|
||||
$template = $this->newsletterTemplatesRepository->findOneById($data['template_id']);
|
||||
if ($template) {
|
||||
$data['body'] = json_encode($template->getBody());
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['body'])) {
|
||||
$newslettersTableName = $this->newslettersRepository->getTableName();
|
||||
$body = $this->emoji->encodeForUTF8Column($newslettersTableName, 'body', $data['body']);
|
||||
$body = $this->dataSanitizer->sanitizeBody(json_decode($body, true));
|
||||
$data['body'] = json_encode($body);
|
||||
}
|
||||
|
||||
$newsletter = isset($data['id']) ? $this->getNewsletter($data) : $this->createNewsletter($data);
|
||||
$data = $this->sanitizeAutomationEmailData($data, $newsletter);
|
||||
$oldSenderAddress = $newsletter->getSenderAddress();
|
||||
|
||||
$this->updateNewsletter($newsletter, $data);
|
||||
$this->newslettersRepository->flush();
|
||||
if (!empty($data['segments'])) {
|
||||
$this->updateSegments($newsletter, $data['segments']);
|
||||
}
|
||||
if (!empty($data['options'])) {
|
||||
$this->updateOptions($newsletter, $data['options']);
|
||||
}
|
||||
|
||||
// save default sender if needed
|
||||
if (!$this->settings->get('sender') && !empty($data['sender_address']) && !empty($data['sender_name'])) {
|
||||
$this->settings->set('sender', [
|
||||
'address' => $data['sender_address'],
|
||||
'name' => $data['sender_name'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->rescheduleIfNeeded($newsletter);
|
||||
$this->updateQueue($newsletter, $data['options'] ?? []);
|
||||
$this->authorizedEmailsController->onNewsletterSenderAddressUpdate($newsletter, $oldSenderAddress);
|
||||
if (isset($data['new_editor']) && $data['new_editor']) {
|
||||
$this->ensureWpPost($newsletter);
|
||||
}
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
private function sanitizeAutomationEmailData(array $data, NewsletterEntity $newsletter): array {
|
||||
if ($newsletter->getType() !== NewsletterEntity::TYPE_AUTOMATION) {
|
||||
return $data;
|
||||
}
|
||||
$data['segments'] = [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function duplicate(NewsletterEntity $newsletter): NewsletterEntity {
|
||||
$duplicate = clone $newsletter;
|
||||
|
||||
// reset timestamps
|
||||
$createdAt = Carbon::now()->millisecond(0);
|
||||
$duplicate->setCreatedAt($createdAt);
|
||||
$duplicate->setUpdatedAt($createdAt);
|
||||
$duplicate->setDeletedAt(null);
|
||||
|
||||
// translators: %s is the subject of the mail which has been copied.
|
||||
$duplicate->setSubject(sprintf(__('Copy of %s', 'mailpoet'), $newsletter->getSubject()));
|
||||
// generate new unsubscribe token
|
||||
$duplicate->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($duplicate));
|
||||
// reset status
|
||||
$duplicate->setStatus(NewsletterEntity::STATUS_DRAFT);
|
||||
// reset hash
|
||||
$duplicate->setHash(Security::generateHash());
|
||||
// reset sent at date
|
||||
$duplicate->setSentAt(null);
|
||||
|
||||
$body = $duplicate->getBody();
|
||||
if ($body) {
|
||||
$duplicate->setBody($this->newsletterCoupon->cleanupBodySensitiveData($body));
|
||||
}
|
||||
$this->newslettersRepository->persist($duplicate);
|
||||
$this->newslettersRepository->flush();
|
||||
|
||||
// duplicate wp post data
|
||||
$post = $this->wp->getPost($newsletter->getWpPostId());
|
||||
if ($post instanceof \WP_Post) {
|
||||
$newPostId = $this->wp->wpInsertPost([
|
||||
'post_status' => NewsletterEntity::STATUS_DRAFT,
|
||||
'post_author' => $this->wp->getCurrentUserId(),
|
||||
'post_content' => $post->post_content, // @phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
'post_type' => $post->post_type, // @phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
// translators: %s is the campaign name of the mail which has been copied.
|
||||
'post_title' => sprintf(__('Copy of %s', 'mailpoet'), $post->post_title), // @phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
]);
|
||||
// Post meta duplication
|
||||
$originalPostMeta = $this->wp->getPostMeta($post->ID);
|
||||
foreach ($originalPostMeta as $key => $values) {
|
||||
foreach ($values as $value) {
|
||||
// Unserialize the value if it was serialized to avoid invalid data format
|
||||
if (is_string($value) && is_serialized($value)) {
|
||||
$value = unserialize($value);
|
||||
}
|
||||
update_post_meta($newPostId, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$duplicate->setWpPost($this->entityManager->getReference(WpPostEntity::class, $newPostId));
|
||||
}
|
||||
|
||||
// create relationships between duplicate and segments
|
||||
foreach ($newsletter->getNewsletterSegments() as $newsletterSegment) {
|
||||
$segment = $newsletterSegment->getSegment();
|
||||
if (!$segment) {
|
||||
continue;
|
||||
}
|
||||
$duplicateSegment = new NewsletterSegmentEntity($duplicate, $segment);
|
||||
$duplicate->getNewsletterSegments()->add($duplicateSegment);
|
||||
$this->newsletterSegmentRepository->persist($duplicateSegment);
|
||||
}
|
||||
|
||||
// duplicate options
|
||||
$ignoredOptions = [
|
||||
NewsletterOptionFieldEntity::NAME_IS_SCHEDULED,
|
||||
NewsletterOptionFieldEntity::NAME_SCHEDULED_AT,
|
||||
];
|
||||
foreach ($newsletter->getOptions() as $newsletterOption) {
|
||||
$optionField = $newsletterOption->getOptionField();
|
||||
if (!$optionField) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($optionField->getName(), $ignoredOptions, true)) {
|
||||
continue;
|
||||
}
|
||||
$duplicateOption = new NewsletterOptionEntity($duplicate, $optionField);
|
||||
$duplicateOption->setValue($newsletterOption->getValue());
|
||||
$duplicate->getOptions()->add($duplicateOption);
|
||||
$this->newsletterOptionsRepository->persist($duplicateOption);
|
||||
}
|
||||
$this->newslettersRepository->flush();
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
private function getNewsletter(array $data): NewsletterEntity {
|
||||
if (!isset($data['id'])) {
|
||||
throw new UnexpectedValueException();
|
||||
}
|
||||
|
||||
$newsletter = $this->newslettersRepository->findOneById((int)$data['id']);
|
||||
if (!$newsletter) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
private function createNewsletter(array $data): NewsletterEntity {
|
||||
$newsletter = new NewsletterEntity();
|
||||
$newsletter->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($newsletter));
|
||||
$newsletter->setHash(Security::generateHash());
|
||||
// set default sender based on settings
|
||||
if (empty($data['sender'])) {
|
||||
$sender = $this->settings->get('sender', []);
|
||||
$data['sender_name'] = $sender['name'] ?? '';
|
||||
$data['sender_address'] = $sender['address'] ?? '';
|
||||
}
|
||||
|
||||
// set default reply_to based on settings
|
||||
if (empty($data['reply_to'])) {
|
||||
$replyTo = $this->settings->get('reply_to', []);
|
||||
$data['reply_to_name'] = $replyTo['name'] ?? '';
|
||||
$data['reply_to_address'] = $replyTo['address'] ?? '';
|
||||
}
|
||||
|
||||
$this->updateNewsletter($newsletter, $data);
|
||||
$this->newslettersRepository->persist($newsletter);
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
private function updateNewsletter(NewsletterEntity $newsletter, array $data) {
|
||||
if (array_key_exists('type', $data)) {
|
||||
$newsletter->setType($data['type']);
|
||||
}
|
||||
|
||||
if (array_key_exists('subject', $data)) {
|
||||
$newsletter->setSubject($data['subject']);
|
||||
}
|
||||
|
||||
if (array_key_exists('preheader', $data)) {
|
||||
$newsletter->setPreheader($data['preheader']);
|
||||
}
|
||||
|
||||
if (array_key_exists('body', $data)) {
|
||||
$newsletter->setBody(json_decode($data['body'], true));
|
||||
}
|
||||
|
||||
if (array_key_exists('ga_campaign', $data)) {
|
||||
$newsletter->setGaCampaign($data['ga_campaign']);
|
||||
}
|
||||
|
||||
if (array_key_exists('sender_name', $data)) {
|
||||
$newsletter->setSenderName($data['sender_name'] ?? '');
|
||||
}
|
||||
|
||||
if (array_key_exists('sender_address', $data)) {
|
||||
$newsletter->setSenderAddress($data['sender_address'] ?? '');
|
||||
}
|
||||
|
||||
if (array_key_exists('reply_to_name', $data)) {
|
||||
$newsletter->setReplyToName($data['reply_to_name'] ?? '');
|
||||
}
|
||||
|
||||
if (array_key_exists('reply_to_address', $data)) {
|
||||
$newsletter->setReplyToAddress($data['reply_to_address'] ?? '');
|
||||
}
|
||||
|
||||
if ($newsletter->getStatus() === NewsletterEntity::STATUS_CORRUPT) {
|
||||
$newsletter->setStatus($newsletter->canBeSetActive() ? NewsletterEntity::STATUS_ACTIVE : NewsletterEntity::STATUS_SENDING);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateSegments(NewsletterEntity $newsletter, array $segments) {
|
||||
$newsletterSegments = [];
|
||||
foreach ($segments as $segmentData) {
|
||||
if (!is_array($segmentData) || !isset($segmentData['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$segment = $this->entityManager->getReference(SegmentEntity::class, (int)$segmentData['id']);
|
||||
if (!$segment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newsletterSegment = $this->newsletterSegmentRepository->findOneBy([
|
||||
'newsletter' => $newsletter,
|
||||
'segment' => $segment,
|
||||
]);
|
||||
|
||||
if (!$newsletterSegment) {
|
||||
$newsletterSegment = new NewsletterSegmentEntity($newsletter, $segment);
|
||||
$this->entityManager->persist($newsletterSegment);
|
||||
}
|
||||
|
||||
if (!$newsletter->getNewsletterSegments()->contains($newsletterSegment)) {
|
||||
$newsletter->getNewsletterSegments()->add($newsletterSegment);
|
||||
}
|
||||
$newsletterSegments[] = $newsletterSegment;
|
||||
}
|
||||
|
||||
// on Doctrine < 2.6, when using orphan removal, we need to remove items manually instead of replacing the
|
||||
// whole collection (see https://github.com/doctrine/orm/commit/1587aac4ff6b0753ddd5f8b8d4558b6b40096057)
|
||||
foreach ($newsletter->getNewsletterSegments() as $newsletterSegment) {
|
||||
if (!in_array($newsletterSegment, $newsletterSegments, true)) {
|
||||
$newsletter->getNewsletterSegments()->removeElement($newsletterSegment); // triggers orphan removal
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function updateOptions(NewsletterEntity $newsletter, array $options) {
|
||||
$optionFields = $this->newsletterOptionFieldsRepository->findBy(['newsletterType' => $newsletter->getType()]);
|
||||
foreach ($optionFields as $optionField) {
|
||||
if (!isset($options[$optionField->getName()])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$option = $this->newsletterOptionsRepository->findOneBy([
|
||||
'newsletter' => $newsletter,
|
||||
'optionField' => $optionField,
|
||||
]);
|
||||
|
||||
if (!$option) {
|
||||
$option = new NewsletterOptionEntity($newsletter, $optionField);
|
||||
$this->newsletterOptionsRepository->persist($option);
|
||||
}
|
||||
$option->setValue($options[$optionField->getName()]);
|
||||
|
||||
if (!$newsletter->getOptions()->contains($option)) {
|
||||
$newsletter->getOptions()->add($option);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function rescheduleIfNeeded(NewsletterEntity $newsletter) {
|
||||
if ($newsletter->getType() !== NewsletterEntity::TYPE_NOTIFICATION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// generate the new schedule from options and get the new "next run" date
|
||||
$schedule = $this->postNotificationScheduler->processPostNotificationSchedule($newsletter);
|
||||
$nextRunDateString = $this->scheduler->getNextRunDate($schedule);
|
||||
$nextRunDate = $nextRunDateString ? Carbon::createFromFormat('Y-m-d H:i:s', $nextRunDateString) : null;
|
||||
if ($nextRunDate === false) {
|
||||
throw InvalidStateException::create()->withMessage('Invalid next run date generated');
|
||||
}
|
||||
|
||||
// find previously scheduled jobs and reschedule them
|
||||
$scheduledTasks = $this->scheduledTasksRepository->findByNewsletterAndStatus($newsletter, ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
foreach ($scheduledTasks as $scheduledTask) {
|
||||
$scheduledTask->setScheduledAt($nextRunDate);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
|
||||
// 'processPostNotificationSchedule' modifies newsletter options by old model - let's reload them
|
||||
foreach ($newsletter->getOptions() as $newsletterOption) {
|
||||
$this->entityManager->refresh($newsletterOption);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateQueue(NewsletterEntity $newsletter, array $options) {
|
||||
if ($newsletter->getType() !== NewsletterEntity::TYPE_STANDARD) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queue = $newsletter->getLatestQueue();
|
||||
if (!$queue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if newsletter was previously scheduled and is now unscheduled, set its status to DRAFT and delete associated queue record
|
||||
if ($newsletter->getStatus() === NewsletterEntity::STATUS_SCHEDULED && isset($options['isScheduled']) && empty($options['isScheduled'])) {
|
||||
$this->entityManager->remove($queue);
|
||||
$newsletter->setStatus(NewsletterEntity::STATUS_DRAFT);
|
||||
} else {
|
||||
$queue->setNewsletterRenderedSubject(null);
|
||||
$queue->setNewsletterRenderedBody(null);
|
||||
$this->entityManager->persist($queue);
|
||||
|
||||
$newsletterQueueTask = new NewsletterQueueTask();
|
||||
$task = $queue->getTask();
|
||||
|
||||
if (!$task instanceof ScheduledTaskEntity) {
|
||||
throw new InvalidStateException();
|
||||
}
|
||||
|
||||
$newsletterQueueTask->preProcessNewsletter($newsletter, $task);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function ensureWpPost(NewsletterEntity $newsletter): void {
|
||||
if ($newsletter->getWpPostId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newPostId = $this->wp->wpInsertPost([
|
||||
'post_content' => '',
|
||||
'post_type' => EmailEditor::MAILPOET_EMAIL_POST_TYPE,
|
||||
'post_status' => 'draft',
|
||||
'post_author' => $this->wp->getCurrentUserId(),
|
||||
'post_title' => __('New Email', 'mailpoet'),
|
||||
]);
|
||||
$newsletter->setWpPost($this->entityManager->getReference(WpPostEntity::class, $newPostId));
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Services\Bridge;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Validator\ValidationException;
|
||||
|
||||
class NewsletterValidator {
|
||||
|
||||
/** @var Bridge */
|
||||
private $bridge;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
Bridge $bridge,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->bridge = $bridge;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function validate(NewsletterEntity $newsletterEntity): ?string {
|
||||
if (
|
||||
$newsletterEntity->getWpPostId() !== null
|
||||
) {
|
||||
// Temporarily skip validation for emails created via Gutenberg editor
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->validateSegments($newsletterEntity);
|
||||
$this->validateBody($newsletterEntity);
|
||||
$this->validateUnsubscribeRequirements($newsletterEntity);
|
||||
$this->validateReEngagementRequirements($newsletterEntity);
|
||||
$this->validateAutomaticLatestContentRequirements($newsletterEntity);
|
||||
} catch (ValidationException $exception) {
|
||||
return $exception->getMessage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateUnsubscribeRequirements(NewsletterEntity $newsletterEntity): void {
|
||||
if (!$this->bridge->isMailpoetSendingServiceEnabled()) {
|
||||
return;
|
||||
}
|
||||
$content = $newsletterEntity->getContent();
|
||||
$hasUnsubscribeUrl = strpos($content, '[link:subscription_unsubscribe_url]') !== false;
|
||||
$hasUnsubscribeLink = strpos($content, '[link:subscription_unsubscribe]') !== false;
|
||||
|
||||
if (!$hasUnsubscribeLink && !$hasUnsubscribeUrl) {
|
||||
throw new ValidationException(__('All emails must include an "Unsubscribe" link. Add a footer widget to your email to continue.', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
|
||||
private function validateSegments(NewsletterEntity $newsletterEntity): void {
|
||||
if (
|
||||
$newsletterEntity->getType() !== NewsletterEntity::TYPE_NOTIFICATION
|
||||
&& $newsletterEntity->getType() !== NewsletterEntity::TYPE_RE_ENGAGEMENT
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emptySegmentsErrorMessage = __('You need to select a list to send to.', 'mailpoet');
|
||||
$segmentIds = $newsletterEntity->getSegmentIds();
|
||||
|
||||
if (empty($segmentIds)) {
|
||||
throw new ValidationException($emptySegmentsErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateBody(NewsletterEntity $newsletterEntity): void {
|
||||
$emptyBodyErrorMessage = __('Poet, please add prose to your masterpiece before you send it to your followers.', 'mailpoet');
|
||||
$content = $newsletterEntity->getContent();
|
||||
|
||||
if ($content === '') {
|
||||
throw new ValidationException($emptyBodyErrorMessage);
|
||||
}
|
||||
|
||||
$contentBlocks = $newsletterEntity->getBody()['content']['blocks'] ?? [];
|
||||
if (count($contentBlocks) < 1) {
|
||||
throw new ValidationException($emptyBodyErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateReEngagementRequirements(NewsletterEntity $newsletterEntity): void {
|
||||
if ($newsletterEntity->getType() !== NewsletterEntity::TYPE_RE_ENGAGEMENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strpos($newsletterEntity->getContent(), '[link:subscription_re_engage_url]') === false) {
|
||||
throw new ValidationException(__('A re-engagement email must include a link with [link:subscription_re_engage_url] shortcode.', 'mailpoet'));
|
||||
}
|
||||
|
||||
if (!$this->trackingConfig->isEmailTrackingEnabled()) {
|
||||
throw new ValidationException(__('Re-engagement emails are disabled because open and click tracking is disabled in MailPoet → Settings → Advanced.', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
|
||||
private function validateAutomaticLatestContentRequirements(NewsletterEntity $newsletterEntity) {
|
||||
if ($newsletterEntity->getType() !== NewsletterEntity::TYPE_NOTIFICATION) {
|
||||
return;
|
||||
}
|
||||
$content = $newsletterEntity->getContent();
|
||||
if (
|
||||
strpos($content, '"type":"automatedLatestContent"') === false &&
|
||||
strpos($content, '"type":"automatedLatestContentLayout"') === false
|
||||
) {
|
||||
throw new ValidationException(_x('Please add an “Automatic Latest Content” widget to the email from the right sidebar.', '(Please reuse the current translation used for the string “Automatic Latest Content”) This Error message is displayed when a user tries to send a “Post Notification” email without any “Automatic Latest Content” widget inside', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use DateTimeInterface;
|
||||
use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
|
||||
use MailPoet\AutomaticEmails\WooCommerce\Events\FirstPurchase;
|
||||
use MailPoet\AutomaticEmails\WooCommerce\Events\PurchasedInCategory;
|
||||
use MailPoet\AutomaticEmails\WooCommerce\Events\PurchasedProduct;
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\NewsletterSegmentEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Logging\LoggerFactory;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterEntity>
|
||||
*/
|
||||
class NewslettersRepository extends Repository {
|
||||
private LoggerFactory $loggerFactory;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->loggerFactory = LoggerFactory::getInstance();
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterEntity::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $types
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function findActiveByTypes($types) {
|
||||
return $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('n')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.status = :status')
|
||||
->setParameter('status', NewsletterEntity::STATUS_ACTIVE)
|
||||
->andWhere('n.deletedAt is null')
|
||||
->andWhere('n.type IN (:types)')
|
||||
->setParameter('types', $types)
|
||||
->orderBy('n.subject')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function getCountForStatusAndTypes(string $status, array $types): int {
|
||||
return intval($this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('COUNT(n.id)')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.status = :status')
|
||||
->andWhere('n.deletedAt is null')
|
||||
->andWhere('n.type IN (:types)')
|
||||
->setParameter('status', $status)
|
||||
->setParameter('types', $types)
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function getCountOfActiveAutomaticEmailsForEvent(string $event): int {
|
||||
return intval($this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(n.id)')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.status = :status')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->andWhere('n.type IN (:types)')
|
||||
->join('n.options', 'o', Join::WITH, 'o.value = :event')
|
||||
->join('o.optionField', 'f', Join::WITH, 'f.name = :nameEvent AND f.newsletterType IN (:types)')
|
||||
->setParameter('status', NewsletterEntity::STATUS_ACTIVE)
|
||||
->setParameter('nameEvent', NewsletterOptionFieldEntity::NAME_EVENT)
|
||||
->setParameter('types', [NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL, NewsletterEntity::TYPE_AUTOMATIC], ArrayParameterType::STRING)
|
||||
->setParameter('event', $event)
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function getCountOfEmailsWithWPPost(): int {
|
||||
return intval($this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(n.id)')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->andWhere('n.wpPost IS NOT NULL')
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function findActiveByTypeAndGroup(string $type, ?string $group): array {
|
||||
$qb = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('n')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.status = :status')
|
||||
->setParameter('status', NewsletterEntity::STATUS_ACTIVE)
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->andWhere('n.type = :type')
|
||||
->setParameter('type', $type);
|
||||
|
||||
if ($group) {
|
||||
$qb->join('n.options', 'o', Join::WITH, 'o.value = :group')
|
||||
->join('o.optionField', 'f', Join::WITH, 'f.name = :nameGroup AND f.newsletterType = :type')
|
||||
->setParameter('nameGroup', NewsletterOptionFieldEntity::NAME_GROUP)
|
||||
->setParameter('group', $group);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $types
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function findDraftByTypes($types) {
|
||||
return $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('n')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.status = :status')
|
||||
->setParameter('status', NewsletterEntity::STATUS_DRAFT)
|
||||
->andWhere('n.deletedAt is null')
|
||||
->andWhere('n.type IN (:types)')
|
||||
->setParameter('types', $types)
|
||||
->orderBy('n.subject')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function getStandardNewsletterSentCount(DateTimeInterface $since): int {
|
||||
return (int)$this->doctrineRepository->createQueryBuilder('n')
|
||||
->select('COUNT(n)')
|
||||
->join('n.queues', 'q')
|
||||
->join('q.task', 't')
|
||||
->andWhere('n.type = :type')
|
||||
->andWhere('n.status = :status')
|
||||
->andWhere('t.status = :taskStatus')
|
||||
->andWhere('t.processedAt >= :since')
|
||||
->setParameter('type', NewsletterEntity::TYPE_STANDARD)
|
||||
->setParameter('status', NewsletterEntity::STATUS_SENT)
|
||||
->setParameter('taskStatus', ScheduledTaskEntity::STATUS_COMPLETED)
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?: 0;
|
||||
}
|
||||
|
||||
public function getGutenbergNewsletterSentCount(): int {
|
||||
return intval($this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(n.id)')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.deletedAt IS NULL')
|
||||
->andWhere('n.wpPost IS NOT NULL')
|
||||
->andWhere('n.status IN (:statuses)')
|
||||
->setParameter('statuses', [NewsletterEntity::STATUS_SENT])
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function getTotalGutenbergNewsletterCount() {
|
||||
return intval($this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(n.id)')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.deletedAt IS NULL')
|
||||
->andWhere('n.wpPost IS NOT NULL')
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function getAnalytics(): array {
|
||||
// for automatic emails join 'event' newsletter option to further group the counts
|
||||
$eventOptionId = (int)$this->entityManager->createQueryBuilder()
|
||||
->select('nof.id')
|
||||
->from(NewsletterOptionFieldEntity::class, 'nof')
|
||||
->andWhere('nof.newsletterType = :eventOptionFieldType')
|
||||
->andWhere('nof.name = :eventOptionFieldName')
|
||||
->setParameter('eventOptionFieldType', NewsletterEntity::TYPE_AUTOMATIC)
|
||||
->setParameter('eventOptionFieldName', 'event')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$results = $this->doctrineRepository->createQueryBuilder('n')
|
||||
->select('n.type, eventOption.value AS event, COUNT(n) AS cnt')
|
||||
->leftJoin('n.options', 'eventOption', Join::WITH, "eventOption.optionField = :eventOptionId")
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->andWhere('n.status IN (:statuses)')
|
||||
->setParameter('eventOptionId', $eventOptionId)
|
||||
->setParameter('statuses', [NewsletterEntity::STATUS_ACTIVE, NewsletterEntity::STATUS_SENT])
|
||||
->groupBy('n.type, eventOption.value')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$analyticsMap = [];
|
||||
foreach ($results as $result) {
|
||||
$type = $result['type'];
|
||||
if ($type === NewsletterEntity::TYPE_AUTOMATIC) {
|
||||
$analyticsMap[$type][$result['event'] ?? ''] = (int)$result['cnt'];
|
||||
} else {
|
||||
$analyticsMap[$type] = (int)$result['cnt'];
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'welcome_newsletters_count' => $analyticsMap[NewsletterEntity::TYPE_WELCOME] ?? 0,
|
||||
'notifications_count' => $analyticsMap[NewsletterEntity::TYPE_NOTIFICATION] ?? 0,
|
||||
'automatic_emails_count' => array_sum($analyticsMap[NewsletterEntity::TYPE_AUTOMATIC] ?? []),
|
||||
'automation_emails_count' => $analyticsMap[NewsletterEntity::TYPE_AUTOMATION] ?? 0,
|
||||
're-engagement_emails_count' => $analyticsMap[NewsletterEntity::TYPE_RE_ENGAGEMENT] ?? 0,
|
||||
'sent_newsletters_count' => $analyticsMap[NewsletterEntity::TYPE_STANDARD] ?? 0,
|
||||
'sent_newsletters_7_days' => $this->getStandardNewsletterSentCount(Carbon::now()->subDays(7)),
|
||||
'sent_newsletters_3_months' => $this->getStandardNewsletterSentCount(Carbon::now()->subMonths(3)),
|
||||
'sent_newsletters_30_days' => $this->getStandardNewsletterSentCount(Carbon::now()->subDays(30)),
|
||||
'first_purchase_emails_count' => $analyticsMap[NewsletterEntity::TYPE_AUTOMATIC][FirstPurchase::SLUG] ?? 0,
|
||||
'product_purchased_emails_count' => $analyticsMap[NewsletterEntity::TYPE_AUTOMATIC][PurchasedProduct::SLUG] ?? 0,
|
||||
'product_purchased_in_category_emails_count' => $analyticsMap[NewsletterEntity::TYPE_AUTOMATIC][PurchasedInCategory::SLUG] ?? 0,
|
||||
'abandoned_cart_emails_count' => $analyticsMap[NewsletterEntity::TYPE_AUTOMATIC][AbandonedCart::SLUG] ?? 0,
|
||||
'total_gutenberg_newsletter_count' => $this->getTotalGutenbergNewsletterCount() ?: 0,
|
||||
'sent_gutenberg_newsletter_count' => $this->getGutenbergNewsletterSentCount() ?: 0,
|
||||
];
|
||||
// Count all campaigns
|
||||
$analyticsMap[NewsletterEntity::TYPE_AUTOMATIC] = array_sum($analyticsMap[NewsletterEntity::TYPE_AUTOMATIC] ?? []);
|
||||
// Post notification history is not a campaign, we count only the parent notification
|
||||
unset($analyticsMap[NewsletterEntity::TYPE_NOTIFICATION_HISTORY]);
|
||||
$data['campaigns_count'] = array_sum($analyticsMap);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $params
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function getArchives(array $params = []) {
|
||||
$types = [
|
||||
NewsletterEntity::TYPE_STANDARD,
|
||||
NewsletterEntity::TYPE_NOTIFICATION_HISTORY,
|
||||
];
|
||||
|
||||
$queryBuilder = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('n')
|
||||
->distinct()
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->innerJoin(SendingQueueEntity::class, 'sq', Join::WITH, 'sq.newsletter = n.id')
|
||||
->innerJoin(ScheduledTaskEntity::class, 'st', Join::WITH, 'st.id = sq.task')
|
||||
->where('n.type IN (:types)')
|
||||
->andWhere('st.status = :statusCompleted')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->orderBy('st.processedAt', 'DESC')
|
||||
->addOrderBy('st.id', 'ASC')
|
||||
->setParameter('types', $types)
|
||||
->setParameter('statusCompleted', SendingQueueEntity::STATUS_COMPLETED);
|
||||
|
||||
$segmentIds = $params['segmentIds'] ?? [];
|
||||
if (!empty($segmentIds)) {
|
||||
$queryBuilder->innerJoin(NewsletterSegmentEntity::class, 'ns', Join::WITH, 'ns.newsletter = n.id')
|
||||
->andWhere('ns.segment IN (:segmentIds)')
|
||||
->setParameter('segmentIds', $segmentIds);
|
||||
}
|
||||
|
||||
$startDate = $params['startDate'] ?? null;
|
||||
if ($startDate instanceof DateTimeInterface) {
|
||||
$queryBuilder
|
||||
->andWhere('st.processedAt >= :startDate')
|
||||
->setParameter('startDate', $startDate);
|
||||
}
|
||||
|
||||
$endDate = $params['endDate'] ?? null;
|
||||
if ($endDate instanceof DateTimeInterface) {
|
||||
$queryBuilder
|
||||
->andWhere('st.processedAt <= :endDate')
|
||||
->setParameter('endDate', $endDate);
|
||||
}
|
||||
|
||||
$subjectContains = $params['subjectContains'] ?? null;
|
||||
if (is_string($subjectContains)) {
|
||||
$queryBuilder
|
||||
->andWhere($queryBuilder->expr()->like('n.subject', ':subjectContains'))
|
||||
->setParameter('subjectContains', '%' . Helpers::escapeSearch($subjectContains) . '%');
|
||||
}
|
||||
|
||||
$limit = $params['limit'] ?? null;
|
||||
if (is_int($limit) && $limit > 0) {
|
||||
$queryBuilder->setMaxResults($limit);
|
||||
}
|
||||
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int - number of processed ids
|
||||
*/
|
||||
public function bulkTrash(array $ids): int {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_NEWSLETTERS, $attachProcessors = true)->info(
|
||||
'trashing newsletters',
|
||||
['id' => $ids]
|
||||
);
|
||||
// Fetch children id for trashing
|
||||
$childrenIds = $this->fetchChildrenIds($ids);
|
||||
$ids = array_merge($ids, $childrenIds);
|
||||
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(NewsletterEntity::class, 'n')
|
||||
->set('n.deletedAt', 'CURRENT_TIMESTAMP()')
|
||||
->where('n.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
|
||||
// Trash scheduled tasks
|
||||
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
|
||||
$sendingQueueTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
|
||||
$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE $scheduledTasksTable t
|
||||
JOIN $sendingQueueTable q ON t.`id` = q.`task_id`
|
||||
SET t.`deleted_at` = NOW()
|
||||
WHERE q.`newsletter_id` IN (:ids)
|
||||
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
// Trash sending queues
|
||||
$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE $sendingQueueTable q
|
||||
SET q.`deleted_at` = NOW()
|
||||
WHERE q.`newsletter_id` IN (:ids)
|
||||
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
// Trash CPT.
|
||||
$wpPostIds = $this->getWpPostIds($ids);
|
||||
|
||||
foreach ($wpPostIds as $wpPostId) {
|
||||
wp_trash_post($wpPostId);
|
||||
}
|
||||
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
public function bulkRestore(array $ids) {
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
// Fetch children ids to restore
|
||||
$childrenIds = $this->fetchChildrenIds($ids);
|
||||
$ids = array_merge($ids, $childrenIds);
|
||||
|
||||
$this->entityManager->createQueryBuilder()->update(NewsletterEntity::class, 'n')
|
||||
->set('n.deletedAt', ':deletedAt')
|
||||
->where('n.id IN (:ids)')
|
||||
->setParameter('deletedAt', null)
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()->execute();
|
||||
|
||||
// Restore scheduled tasks and pause running ones
|
||||
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
|
||||
$sendingQueueTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
|
||||
$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE $scheduledTasksTable t
|
||||
JOIN $sendingQueueTable q ON t.`id` = q.`task_id`
|
||||
SET t.`deleted_at` = null, t.`status` = IFNULL(t.status, :pausedStatus)
|
||||
WHERE q.`newsletter_id` IN (:ids)
|
||||
", [
|
||||
'ids' => $ids,
|
||||
'pausedStatus' => ScheduledTaskEntity::STATUS_PAUSED,
|
||||
], [
|
||||
'ids' => ArrayParameterType::INTEGER,
|
||||
]);
|
||||
|
||||
// Restore sending queues
|
||||
$this->entityManager->getConnection()->executeStatement("
|
||||
UPDATE $sendingQueueTable q
|
||||
SET q.`deleted_at` = null
|
||||
WHERE q.`newsletter_id` IN (:ids)
|
||||
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
|
||||
|
||||
// Untrash CPT.
|
||||
$wpPostIds = $this->getWpPostIds($ids);
|
||||
|
||||
foreach ($wpPostIds as $wpPostId) {
|
||||
wp_untrash_post($wpPostId);
|
||||
}
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(NewsletterEntity::class, 'n')
|
||||
->where('n.id 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 (NewsletterEntity $entity) use ($ids) {
|
||||
return in_array($entity->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function findSendingNotificationHistoryWithoutPausedOrInvalidTask(NewsletterEntity $newsletter): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('n')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->join('n.queues', 'q')
|
||||
->join('q.task', 't')
|
||||
->where('n.parent = :parent')
|
||||
->andWhere('n.type = :type')
|
||||
->andWhere('n.status = :status')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->andWhere('t.status != :taskStatusPaused')
|
||||
->andWhere('t.status != :taskStatusInvalid')
|
||||
->setParameter('parent', $newsletter)
|
||||
->setParameter('type', NewsletterEntity::TYPE_NOTIFICATION_HISTORY)
|
||||
->setParameter('status', NewsletterEntity::STATUS_SENDING)
|
||||
->setParameter('taskStatusPaused', ScheduledTaskEntity::STATUS_PAUSED)
|
||||
->setParameter('taskStatusInvalid', ScheduledTaskEntity::STATUS_INVALID)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns standard newsletters ordered by sentAt
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function getStandardNewsletterList(): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL n.{id,subject,sentAt}, PARTIAL wpPost.{id, postTitle}')
|
||||
->addSelect('CASE WHEN n.sentAt IS NULL THEN 1 ELSE 0 END as HIDDEN sent_at_is_null')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->leftJoin('n.wpPost', 'wpPost')
|
||||
->where('n.type = :typeStandard')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->orderBy('sent_at_is_null', 'DESC')
|
||||
->addOrderBy('n.sentAt', 'DESC')
|
||||
->setParameter('typeStandard', NewsletterEntity::TYPE_STANDARD)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns standard newsletters ordered by sentAt
|
||||
* filter by status STATUS_SCHEDULED, STATUS_SENDING, STATUS_SENT
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function getStandardNewsletterListWithMultipleStatuses($limit): array {
|
||||
$statuses = [
|
||||
NewsletterEntity::STATUS_SCHEDULED,
|
||||
NewsletterEntity::STATUS_SENDING,
|
||||
NewsletterEntity::STATUS_SENT,
|
||||
];
|
||||
|
||||
$query = $this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL n.{id,subject,sentAt}')
|
||||
->addSelect('CASE WHEN n.sentAt IS NULL THEN 1 ELSE 0 END as HIDDEN sent_at_is_null')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.type = :typeStandard')
|
||||
->andWhere('n.status IN (:statuses)')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->orderBy('sent_at_is_null', 'DESC')
|
||||
->addOrderBy('n.sentAt', 'DESC')
|
||||
->setParameter('typeStandard', NewsletterEntity::TYPE_STANDARD)
|
||||
->setParameter('statuses', $statuses);
|
||||
|
||||
if (is_int($limit)) {
|
||||
$query->setMaxResults($limit);
|
||||
}
|
||||
|
||||
$result = $query->getQuery()->getResult();
|
||||
|
||||
return is_array($result) ? $result : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sent post-notification history newsletters ordered by sentAt
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function getNotificationHistoryItems($limit): array {
|
||||
$query = $this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL n.{id,subject,sentAt}')
|
||||
->addSelect('CASE WHEN n.sentAt IS NULL THEN 1 ELSE 0 END as HIDDEN sent_at_is_null')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.type = :typeNotificationHistory')
|
||||
->andWhere('n.status = :status')
|
||||
->andWhere('n.deletedAt IS NULL')
|
||||
->orderBy('sent_at_is_null', 'DESC')
|
||||
->addOrderBy('n.sentAt', 'DESC')
|
||||
->setParameter('typeNotificationHistory', NewsletterEntity::TYPE_NOTIFICATION_HISTORY)
|
||||
->setParameter('status', NewsletterEntity::STATUS_SENT);
|
||||
|
||||
if (is_int($limit)) {
|
||||
$query->setMaxResults($limit);
|
||||
}
|
||||
|
||||
$result = $query->getQuery()->getResult();
|
||||
|
||||
return is_array($result) ? $result : [];
|
||||
}
|
||||
|
||||
public function prefetchOptions(array $newsletters) {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL n.{id}, o, opf')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->join('n.options', 'o')
|
||||
->join('o.optionField', 'opf')
|
||||
->where('n.id IN (:newsletters)')
|
||||
->setParameter('newsletters', $newsletters)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function prefetchSegments(array $newsletters) {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->select('PARTIAL n.{id}, ns, s')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->join('n.newsletterSegments', 'ns')
|
||||
->join('ns.segment', 's')
|
||||
->where('n.id IN (:newsletters)')
|
||||
->setParameter('newsletters', $newsletters)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of emails that are either scheduled standard emails
|
||||
* or active automatic emails of the provided types.
|
||||
*
|
||||
* @param array $automaticEmailTypes
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getScheduledStandardEmailsAndActiveAutomaticEmails(array $automaticEmailTypes): array {
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder();
|
||||
|
||||
$newsletters = $queryBuilder
|
||||
->select('n')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->orWhere(
|
||||
$queryBuilder->expr()->andX(
|
||||
$queryBuilder->expr()->eq('n.type', ':typeStandard'),
|
||||
$queryBuilder->expr()->eq('n.status', ':statusScheduled')
|
||||
)
|
||||
)
|
||||
->orWhere(
|
||||
$queryBuilder->expr()->andX(
|
||||
$queryBuilder->expr()->in('n.type', ':automaticEmailTypes'),
|
||||
$queryBuilder->expr()->eq('n.status', ':statusActive')
|
||||
)
|
||||
)
|
||||
->setParameter('typeStandard', NewsletterEntity::TYPE_STANDARD)
|
||||
->setParameter('statusScheduled', NewsletterEntity::STATUS_SCHEDULED)
|
||||
->setParameter('automaticEmailTypes', $automaticEmailTypes)
|
||||
->setParameter('statusActive', NewsletterEntity::STATUS_ACTIVE)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $newsletters;
|
||||
}
|
||||
|
||||
public function getCorruptNewsletters(): array {
|
||||
return $this->findBy(['status' => NewsletterEntity::STATUS_CORRUPT, 'deletedAt' => null]);
|
||||
}
|
||||
|
||||
public function setAsCorrupt(NewsletterEntity $entity): void {
|
||||
$entity->setStatus(NewsletterEntity::STATUS_CORRUPT);
|
||||
$this->persist($entity);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $parentIds
|
||||
* @return int[]
|
||||
*/
|
||||
public function fetchChildrenIds(array $parentIds): array {
|
||||
/** @var string[] $ids */
|
||||
$ids = $this->entityManager->createQueryBuilder()
|
||||
->select('n.id')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.parent IN (:ids)')
|
||||
->setParameter('ids', $parentIds)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
return array_map('intval', $ids);
|
||||
}
|
||||
|
||||
public function getWpPostIds(array $ids): array {
|
||||
/** @var string[] $wpPostIds */
|
||||
$wpPostIds = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(n.wpPost) AS id')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.id IN (:ids)')
|
||||
->andWhere('n.wpPost IS NOT NULL')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
|
||||
$wpPostIds = array_map('intval', $wpPostIds);
|
||||
|
||||
return $wpPostIds;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Options;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterOptionFieldEntity>
|
||||
*/
|
||||
class NewsletterOptionFieldsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterOptionFieldEntity::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Options;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterOptionEntity>
|
||||
*/
|
||||
class NewsletterOptionsRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterOptionEntity::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NewsletterOptionEntity[]
|
||||
*/
|
||||
public function findWelcomeNotificationsForSegments(array $segmentIds): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('no')
|
||||
->from(NewsletterOptionEntity::class, 'no')
|
||||
->join('no.newsletter', 'n')
|
||||
->join('no.optionField', 'nof')
|
||||
->where('n.deletedAt IS NULL')
|
||||
->andWhere('n.type = :typeWelcome')
|
||||
->andWhere('nof.name = :nameSegment')
|
||||
->andWhere('no.value IN (:segmentIds)')
|
||||
->setParameter('typeWelcome', NewsletterEntity::TYPE_WELCOME)
|
||||
->setParameter('nameSegment', NewsletterOptionFieldEntity::NAME_SEGMENT)
|
||||
->setParameter('segmentIds', $segmentIds)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NewsletterOptionEntity[]
|
||||
*/
|
||||
public function findAutomaticEmailsForSegments(array $segmentIds): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('no')
|
||||
->from(NewsletterOptionEntity::class, 'no')
|
||||
->join('no.newsletter', 'n')
|
||||
->join('no.optionField', 'nof')
|
||||
->where('n.deletedAt IS NULL')
|
||||
->andWhere('n.type = :typeAutomatic')
|
||||
->andWhere('nof.name = :nameSegment')
|
||||
->andWhere('no.value IN (:segmentIds)')
|
||||
->setParameter('typeAutomatic', NewsletterEntity::TYPE_AUTOMATIC)
|
||||
->setParameter('nameSegment', NewsletterOptionFieldEntity::NAME_SEGMENT)
|
||||
->setParameter('segmentIds', $segmentIds)
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(NewsletterOptionEntity::class, 'o')
|
||||
->where('o.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 (NewsletterOptionEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Preview;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\EmailEditor\Engine\Personalizer;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Mailer\MailerFactory;
|
||||
use MailPoet\Mailer\MetaInfo;
|
||||
use MailPoet\Newsletter\Renderer\Renderer;
|
||||
use MailPoet\Newsletter\Shortcodes\Shortcodes;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class SendPreviewController {
|
||||
/** @var MailerFactory */
|
||||
private $mailerFactory;
|
||||
|
||||
/** @var MetaInfo */
|
||||
private $mailerMetaInfo;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var Renderer */
|
||||
private $renderer;
|
||||
|
||||
/** @var Shortcodes */
|
||||
private $shortcodes;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var Personalizer */
|
||||
private $personalizer;
|
||||
|
||||
public function __construct(
|
||||
MailerFactory $mailerFactory,
|
||||
MetaInfo $mailerMetaInfo,
|
||||
Renderer $renderer,
|
||||
WPFunctions $wp,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
Shortcodes $shortcodes,
|
||||
Personalizer $personalizer
|
||||
) {
|
||||
$this->mailerFactory = $mailerFactory;
|
||||
$this->mailerMetaInfo = $mailerMetaInfo;
|
||||
$this->wp = $wp;
|
||||
$this->renderer = $renderer;
|
||||
$this->shortcodes = $shortcodes;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->personalizer = $personalizer;
|
||||
}
|
||||
|
||||
public function sendPreview(NewsletterEntity $newsletter, string $emailAddress) {
|
||||
$renderedNewsletter = $this->renderer->renderAsPreview($newsletter);
|
||||
$divider = '***MailPoet***';
|
||||
$dataForShortcodes = array_merge(
|
||||
[$newsletter->getSubject()],
|
||||
$renderedNewsletter
|
||||
);
|
||||
|
||||
$body = implode($divider, $dataForShortcodes);
|
||||
|
||||
$subscriber = $this->subscribersRepository->getCurrentWPUser();
|
||||
$this->shortcodes->setNewsletter($newsletter);
|
||||
if ($subscriber instanceof SubscriberEntity) {
|
||||
$this->shortcodes->setSubscriber($subscriber);
|
||||
}
|
||||
$this->shortcodes->setWpUserPreview(true);
|
||||
|
||||
[
|
||||
$renderedNewsletter['subject'],
|
||||
$renderedNewsletter['body']['html'],
|
||||
$renderedNewsletter['body']['text'],
|
||||
] = explode($divider, $this->shortcodes->replace($body));
|
||||
|
||||
if ($newsletter->getWpPostId()) {
|
||||
$this->personalizer->set_context([
|
||||
'recipient_email' => $subscriber ? $subscriber->getEmail() : $emailAddress,
|
||||
'newsletter_id' => $newsletter->getId(),
|
||||
'is_preview' => true,
|
||||
]);
|
||||
$renderedNewsletter['subject'] = $this->personalizer->personalize_content($renderedNewsletter['subject']);
|
||||
$renderedNewsletter['body']['html'] = $this->personalizer->personalize_content($renderedNewsletter['body']['html']);
|
||||
$renderedNewsletter['body']['text'] = $this->personalizer->personalize_content($renderedNewsletter['body']['text']);
|
||||
}
|
||||
|
||||
$renderedNewsletter['id'] = $newsletter->getId();
|
||||
|
||||
$extraParams = [
|
||||
'unsubscribe_url' => $this->wp->homeUrl(),
|
||||
'meta' => $this->mailerMetaInfo->getPreviewMetaInfo(),
|
||||
];
|
||||
|
||||
$result = $this->mailerFactory->getDefaultMailer()->send($renderedNewsletter, $emailAddress, $extraParams);
|
||||
if ($result['response'] === false) {
|
||||
$error = sprintf(
|
||||
// translators: %s contains the actual error message.
|
||||
__('The email could not be sent: %s', 'mailpoet'),
|
||||
$result['error']->getMessage()
|
||||
);
|
||||
throw new SendPreviewException($error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Preview;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class SendPreviewException extends \RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
|
||||
use MailPoet\AutomaticEmails\WooCommerce\WooCommerce as WooCommerceEmail;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
|
||||
class AbandonedCartContent {
|
||||
/** @var AutomatedLatestContentBlock */
|
||||
private $ALCBlock;
|
||||
|
||||
public function __construct(
|
||||
AutomatedLatestContentBlock $ALCBlock
|
||||
) {
|
||||
$this->ALCBlock = $ALCBlock;
|
||||
}
|
||||
|
||||
public function render(
|
||||
NewsletterEntity $newsletter,
|
||||
array $args,
|
||||
bool $preview = false,
|
||||
SendingQueueEntity $sendingQueue = null
|
||||
): array {
|
||||
if (
|
||||
!in_array(
|
||||
$newsletter->getType(),
|
||||
[
|
||||
NewsletterEntity::TYPE_AUTOMATIC,
|
||||
NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL,
|
||||
NewsletterEntity::TYPE_AUTOMATION,
|
||||
],
|
||||
true
|
||||
)
|
||||
) {
|
||||
// Do not display the block if not an automatic email
|
||||
return [];
|
||||
}
|
||||
$groupOption = $newsletter->getOptions()->filter(function (NewsletterOptionEntity $newsletterOption = null) {
|
||||
if (!$newsletterOption) return false;
|
||||
$optionField = $newsletterOption->getOptionField();
|
||||
return $optionField && $optionField->getName() === 'group';
|
||||
})->first();
|
||||
$eventOption = $newsletter->getOptions()->filter(function (NewsletterOptionEntity $newsletterOption = null) {
|
||||
if (!$newsletterOption) return false;
|
||||
$optionField = $newsletterOption->getOptionField();
|
||||
return $optionField && $optionField->getName() === 'event';
|
||||
})->first();
|
||||
if (
|
||||
!$groupOption
|
||||
|| $groupOption->getValue() !== WooCommerceEmail::SLUG
|
||||
|| !$eventOption
|
||||
|| $eventOption->getValue() !== AbandonedCart::SLUG
|
||||
) {
|
||||
// Do not display the block if not an AbandonedCart email
|
||||
return [];
|
||||
}
|
||||
if ($preview) {
|
||||
// Display latest products for preview (no 'posts' argument specified)
|
||||
return $this->ALCBlock->render($newsletter, $args);
|
||||
}
|
||||
if (!$sendingQueue) {
|
||||
// Do not display the block if we're not sending an email
|
||||
return [];
|
||||
}
|
||||
$meta = $sendingQueue->getMeta();
|
||||
if (empty($meta[AbandonedCart::TASK_META_NAME])) {
|
||||
// Do not display the block if a cart is empty
|
||||
return [];
|
||||
}
|
||||
$args['amount'] = 50;
|
||||
$args['posts'] = $meta[AbandonedCart::TASK_META_NAME];
|
||||
return $this->ALCBlock->render($newsletter, $args);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterPostEntity;
|
||||
use MailPoet\Newsletter\AutomatedLatestContent;
|
||||
use MailPoet\Newsletter\BlockPostQuery;
|
||||
use MailPoet\Newsletter\NewsletterPostsRepository;
|
||||
|
||||
class AutomatedLatestContentBlock {
|
||||
/**
|
||||
* Cache for rendered posts in newsletter.
|
||||
* Used to prevent duplicate post in case a newsletter contains 2 ALC blocks
|
||||
* @var array
|
||||
*/
|
||||
public $renderedPostsInNewsletter;
|
||||
|
||||
/** @var AutomatedLatestContent */
|
||||
private $ALC;
|
||||
|
||||
/** @var NewsletterPostsRepository */
|
||||
private $newsletterPostsRepository;
|
||||
|
||||
public function __construct(
|
||||
NewsletterPostsRepository $newsletterPostsRepository,
|
||||
AutomatedLatestContent $ALC
|
||||
) {
|
||||
$this->renderedPostsInNewsletter = [];
|
||||
$this->ALC = $ALC;
|
||||
$this->newsletterPostsRepository = $newsletterPostsRepository;
|
||||
}
|
||||
|
||||
public function render(NewsletterEntity $newsletter, $args) {
|
||||
$newerThanTimestamp = false;
|
||||
$newsletterId = false;
|
||||
if ($newsletter->getType() === NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
|
||||
$parent = $newsletter->getParent();
|
||||
if ($parent instanceof NewsletterEntity) {
|
||||
$newsletterId = $parent->getId();
|
||||
|
||||
$lastPost = $this->newsletterPostsRepository->findOneBy(['newsletter' => $parent], ['createdAt' => 'desc']);
|
||||
if ($lastPost instanceof NewsletterPostEntity) {
|
||||
$newerThanTimestamp = $lastPost->getCreatedAt();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
$postsToExclude = $this->getRenderedPosts((int)$newsletterId);
|
||||
$query = new BlockPostQuery([
|
||||
'args' => $args,
|
||||
'postsToExclude' => $postsToExclude,
|
||||
'newsletterId' => $newsletterId,
|
||||
'newerThanTimestamp' => $newerThanTimestamp,
|
||||
'dynamic' => true,
|
||||
]);
|
||||
$aLCPosts = $this->ALC->getPosts($query);
|
||||
foreach ($aLCPosts as $post) {
|
||||
$postsToExclude[] = $post->ID;
|
||||
}
|
||||
$this->setRenderedPosts((int)$newsletterId, $postsToExclude);
|
||||
return $this->ALC->transformPosts($args, $aLCPosts);
|
||||
}
|
||||
|
||||
private function getRenderedPosts(int $newsletterId) {
|
||||
return $this->renderedPostsInNewsletter[$newsletterId] ?? [];
|
||||
}
|
||||
|
||||
private function setRenderedPosts(int $newsletterId, array $posts) {
|
||||
return $this->renderedPostsInNewsletter[$newsletterId] = $posts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
|
||||
class Button {
|
||||
public function render($element, $columnBaseWidth) {
|
||||
$originalWidth = $this->getOriginalWidth($element, $columnBaseWidth);
|
||||
$element['styles']['block']['width'] = $this->calculateWidth($element, $columnBaseWidth);
|
||||
$styles = 'display:block;text-decoration:none;text-align:center;' . StylesHelper::getBlockStyles($element, $exclude = ['textAlign']);
|
||||
$styles = EHelper::escapeHtmlStyleAttr($styles);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_padded_vertical mailpoet_padded_side" valign="top">
|
||||
<div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;">
|
||||
<tr>
|
||||
<td class="mailpoet_button-container" style="text-align:' . $element['styles']['block']['textAlign'] . ';"><!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="' . EHelper::escapeHtmlLinkAttr($element['url']) . '"
|
||||
style="height:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['lineHeight']) . ';
|
||||
width:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['width']) . ';
|
||||
v-text-anchor:middle;"
|
||||
arcsize="' . round((int)$element['styles']['block']['borderRadius'] / ((int)$element['styles']['block']['lineHeight'] ?: 1) * 100) . '%"
|
||||
strokeweight="' . EHelper::escapeHtmlAttr($element['styles']['block']['borderWidth']) . '"
|
||||
strokecolor="' . EHelper::escapeHtmlAttr($element['styles']['block']['borderColor']) . '"
|
||||
fillcolor="' . EHelper::escapeHtmlAttr($element['styles']['block']['backgroundColor']) . '">
|
||||
<w:anchorlock/>
|
||||
<center style="color:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontColor']) . ';
|
||||
font-family:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontFamily']) . ';
|
||||
font-size:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontSize']) . ';
|
||||
font-weight:bold;">' . EHelper::escapeHtmlText($element['text']) . '
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<table
|
||||
border="0"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
role="presentation"
|
||||
style="display:inline-block;border-collapse:separate;mso-table-lspace:0;mso-table-rspace:0;width:' . EHelper::escapeHtmlStyleAttr($originalWidth) . '"
|
||||
width="' . EHelper::escapeHtmlStyleAttr($originalWidth) . '"
|
||||
>
|
||||
<tr>
|
||||
<td class="mailpoet_table_button"
|
||||
valign="middle"
|
||||
role="presentation"
|
||||
style="mso-table-lspace: 0;mso-table-rspace: 0;' . $styles . '"
|
||||
>
|
||||
<a class="mailpoet_button" style="
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
line-height: ' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['lineHeight']) . ';
|
||||
color: ' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontColor']) . ';
|
||||
" href="' . EHelper::escapeHtmlLinkAttr($element['url']) . '" target="_blank">' . EHelper::escapeHtmlText($element['text']) . '</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function getOriginalWidth($element, $columnBaseWidth): string {
|
||||
$columnWidth = $columnBaseWidth - (StylesHelper::$paddingWidth * 2);
|
||||
$originalWidth = (int)$element['styles']['block']['width'];
|
||||
$originalWidth = ($originalWidth > $columnWidth) ?
|
||||
$columnWidth :
|
||||
$originalWidth;
|
||||
|
||||
return $originalWidth . 'px';
|
||||
}
|
||||
|
||||
public function calculateWidth($element, $columnBaseWidth): string {
|
||||
$columnWidth = $columnBaseWidth - (StylesHelper::$paddingWidth * 2);
|
||||
$borderWidth = (int)$element['styles']['block']['borderWidth'];
|
||||
$buttonWidth = (int)$element['styles']['block']['width'];
|
||||
$buttonWidth = ($buttonWidth > $columnWidth) ?
|
||||
$columnWidth :
|
||||
$buttonWidth;
|
||||
$buttonWidth = $buttonWidth - (2 * $borderWidth) . 'px';
|
||||
return $buttonWidth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
use MailPoet\NewsletterProcessingException;
|
||||
use MailPoet\WooCommerce\Helper;
|
||||
|
||||
class Coupon {
|
||||
const TYPE = 'coupon';
|
||||
|
||||
const CODE_PLACEHOLDER = 'XXXX-XXXXXXX-XXXX';
|
||||
|
||||
/*** @var Helper */
|
||||
private $helper;
|
||||
|
||||
public function __construct(
|
||||
Helper $helper
|
||||
) {
|
||||
$this->helper = $helper;
|
||||
}
|
||||
|
||||
public function render($element, $columnBaseWidth) {
|
||||
$couponCode = self::CODE_PLACEHOLDER;
|
||||
if (!empty($element['couponId'])) {
|
||||
try {
|
||||
$couponCode = $this->helper->wcGetCouponCodeById((int)$element['couponId']);
|
||||
} catch (\Exception $e) {
|
||||
if (!$this->helper->isWooCommerceActive()) {
|
||||
throw NewsletterProcessingException::create()->withMessage(__('WooCommerce is not active', 'mailpoet'));
|
||||
} else {
|
||||
throw NewsletterProcessingException::create()->withMessage($e->getMessage())->withCode($e->getCode());
|
||||
}
|
||||
}
|
||||
if (empty($couponCode)) {
|
||||
throw NewsletterProcessingException::create()->withMessage(__('Couldn\'t find the coupon. Please update the email if the coupon was removed.', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
$element['styles']['block']['width'] = $this->calculateWidth($element, $columnBaseWidth);
|
||||
$styles = 'display:inline-block;-webkit-text-size-adjust:none;mso-hide:all;text-decoration:none;text-align:center;' . StylesHelper::getBlockStyles($element, $exclude = ['textAlign']);
|
||||
$styles = EHelper::escapeHtmlStyleAttr($styles);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_padded_vertical mailpoet_padded_side" valign="top">
|
||||
<div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;">
|
||||
<tr>
|
||||
<td class="mailpoet_coupon-container" style="text-align:' . $element['styles']['block']['textAlign'] . ';"><!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
style="height:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['lineHeight']) . ';
|
||||
width:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['width']) . ';
|
||||
v-text-anchor:middle;"
|
||||
arcsize="' . round((int)$element['styles']['block']['borderRadius'] / ((int)$element['styles']['block']['lineHeight'] ?: 1) * 100) . '%"
|
||||
strokeweight="' . EHelper::escapeHtmlAttr($element['styles']['block']['borderWidth']) . '"
|
||||
strokecolor="' . EHelper::escapeHtmlAttr($element['styles']['block']['borderColor']) . '"
|
||||
fillcolor="' . EHelper::escapeHtmlAttr($element['styles']['block']['backgroundColor']) . '">
|
||||
<w:anchorlock/>
|
||||
<center style="color:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontColor']) . ';
|
||||
font-family:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontFamily']) . ';
|
||||
font-size:' . EHelper::escapeHtmlStyleAttr($element['styles']['block']['fontSize']) . ';
|
||||
font-weight:bold;">' . EHelper::escapeHtmlText($couponCode) . '
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div class="mailpoet_coupon" style="' . $styles . '">' . EHelper::escapeHtmlText($couponCode) . '</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function calculateWidth($element, $columnBaseWidth): string {
|
||||
$columnWidth = $columnBaseWidth - (StylesHelper::$paddingWidth * 2);
|
||||
$borderWidth = (int)$element['styles']['block']['borderWidth'];
|
||||
$width = (int)$element['styles']['block']['width'];
|
||||
$width = ($width > $columnWidth) ?
|
||||
$columnWidth :
|
||||
$width;
|
||||
return ($width - (2 * $borderWidth) . 'px');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
|
||||
class Divider {
|
||||
public function render($element) {
|
||||
$backgroundColor = $element['styles']['block']['backgroundColor'];
|
||||
$dividerCellStyle = "border-top-width: {$element['styles']['block']['borderWidth']};";
|
||||
$dividerCellStyle .= "border-top-style: {$element['styles']['block']['borderStyle']};";
|
||||
$dividerCellStyle .= "border-top-color: {$element['styles']['block']['borderColor']};";
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_divider" valign="top" ' .
|
||||
(($element['styles']['block']['backgroundColor'] !== 'transparent') ?
|
||||
'bgColor="' . EHelper::escapeHtmlAttr($backgroundColor) . '" style="background-color:' . EHelper::escapeHtmlStyleAttr($backgroundColor) . ';' :
|
||||
'style="'
|
||||
) .
|
||||
sprintf(
|
||||
'padding: %s %spx %s %spx;',
|
||||
EHelper::escapeHtmlStyleAttr($element['styles']['block']['padding']),
|
||||
StylesHelper::$paddingWidth,
|
||||
EHelper::escapeHtmlStyleAttr($element['styles']['block']['padding']),
|
||||
StylesHelper::$paddingWidth
|
||||
) . '">
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0"
|
||||
style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;">
|
||||
<tr>
|
||||
<td class="mailpoet_divider-cell" style="' . EHelper::escapeHtmlStyleAttr($dividerCellStyle) . '">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\NewsletterHtmlSanitizer;
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\CSS;
|
||||
|
||||
class Footer {
|
||||
private NewsletterHtmlSanitizer $htmlSanitizer;
|
||||
private WPFunctions $wp;
|
||||
|
||||
public function __construct(
|
||||
NewsletterHtmlSanitizer $htmlSanitizer,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->htmlSanitizer = $htmlSanitizer;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function render($element) {
|
||||
$element['text'] = preg_replace('/\n/', '<br />', $element['text']);
|
||||
$element['text'] = preg_replace('/(<\/?p.*?>)/i', '', $element['text']);
|
||||
$lineHeight = sprintf(
|
||||
'%spx',
|
||||
StylesHelper::$defaultLineHeight * (int)$element['styles']['text']['fontSize']
|
||||
);
|
||||
if (!is_string($element['text'])) {
|
||||
throw new \RuntimeException('$element[\'text\'] should be a string.');
|
||||
}
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($element['text']);
|
||||
if (isset($element['styles']['link'])) {
|
||||
$links = $DOM->query('a');
|
||||
if ($links->count()) {
|
||||
$css = new CSS();
|
||||
foreach ($links as $link) {
|
||||
$elementLinkStyles = StylesHelper::getStyles($element['styles'], 'link');
|
||||
$link->style = $css->mergeInlineStyles($elementLinkStyles, $link->style);
|
||||
}
|
||||
}
|
||||
}
|
||||
$backgroundColor = $element['styles']['block']['backgroundColor'];
|
||||
$backgroundColor = ($backgroundColor !== 'transparent') ?
|
||||
'bgcolor="' . $this->wp->escAttr($backgroundColor) . '"' :
|
||||
false;
|
||||
if (!$backgroundColor) unset($element['styles']['block']['backgroundColor']);
|
||||
$style = 'line-height: ' . $lineHeight . ';' . StylesHelper::getBlockStyles($element) . StylesHelper::getStyles($element['styles'], 'text');
|
||||
$style = EHelper::escapeHtmlStyleAttr($style);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_header_footer_padded mailpoet_footer" ' . $backgroundColor . ' style="' . $style . '">
|
||||
' . $this->htmlSanitizer->sanitize($DOM->__toString()) . '
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\NewsletterHtmlSanitizer;
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\CSS;
|
||||
|
||||
class Header {
|
||||
private NewsletterHtmlSanitizer $htmlSanitizer;
|
||||
private WPFunctions $wp;
|
||||
|
||||
public function __construct(
|
||||
NewsletterHtmlSanitizer $htmlSanitizer,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->htmlSanitizer = $htmlSanitizer;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function render($element) {
|
||||
$element['text'] = preg_replace('/\n/', '<br />', $element['text']);
|
||||
$element['text'] = preg_replace('/(<\/?p.*?>)/i', '', $element['text']);
|
||||
$lineHeight = sprintf(
|
||||
'%spx',
|
||||
StylesHelper::$defaultLineHeight * (int)$element['styles']['text']['fontSize']
|
||||
);
|
||||
if (!is_string($element['text'])) {
|
||||
throw new \RuntimeException('$element[\'text\'] should be a string.');
|
||||
}
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($element['text']);
|
||||
if (isset($element['styles']['link'])) {
|
||||
$links = $DOM->query('a');
|
||||
if ($links->count()) {
|
||||
$css = new CSS();
|
||||
foreach ($links as $link) {
|
||||
$elementLinkStyles = StylesHelper::getStyles($element['styles'], 'link');
|
||||
$link->style = $css->mergeInlineStyles($elementLinkStyles, $link->style);
|
||||
}
|
||||
}
|
||||
}
|
||||
$backgroundColor = $element['styles']['block']['backgroundColor'];
|
||||
$backgroundColor = ($backgroundColor !== 'transparent') ?
|
||||
'bgcolor="' . $this->wp->escAttr($backgroundColor) . '"' :
|
||||
false;
|
||||
if (!$backgroundColor) unset($element['styles']['block']['backgroundColor']);
|
||||
$style = 'line-height: ' . $lineHeight . ';' . StylesHelper::getBlockStyles($element) . StylesHelper::getStyles($element['styles'], 'text');
|
||||
$style = EHelper::escapeHtmlStyleAttr($style);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_header_footer_padded mailpoet_header" ' . $backgroundColor . ' style="' . $style . '">
|
||||
' . $this->htmlSanitizer->sanitize($DOM->__toString()) . '
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Image {
|
||||
public function render($element, $columnBaseWidth) {
|
||||
if (empty($element['src'])) {
|
||||
return '';
|
||||
}
|
||||
if (substr($element['src'], 0, 1) == '/' && substr($element['src'], 1, 1) != '/') {
|
||||
$element['src'] = WPFunctions::get()->getOption('siteurl') . $element['src'];
|
||||
}
|
||||
|
||||
$element['width'] = str_replace('px', '', $element['width']);
|
||||
$element['height'] = str_replace('px', '', $element['height']);
|
||||
$originalWidth = 0;
|
||||
if (is_numeric($element['width']) && is_numeric($element['height'])) {
|
||||
$element['width'] = (int)$element['width'];
|
||||
$element['height'] = (int)$element['height'];
|
||||
$originalWidth = $element['width'];
|
||||
$element = $this->adjustImageDimensions($element, $columnBaseWidth);
|
||||
}
|
||||
|
||||
// If image was downsized because of column width set width to aways fill full column (e.g. on mobile)
|
||||
$style = '';
|
||||
if ($element['fullWidth'] === true && $originalWidth > $element['width']) {
|
||||
$style = 'style="width:100%"';
|
||||
}
|
||||
|
||||
$imageTemplate = '
|
||||
<img src="' . EHelper::escapeHtmlLinkAttr($element['src']) . '" width="' . EHelper::escapeHtmlAttr($element['width']) . '" alt="' . EHelper::escapeHtmlAttr($element['alt']) . '"' . $style . '/>
|
||||
';
|
||||
if (!empty($element['link'])) {
|
||||
$imageTemplate = '<a href="' . EHelper::escapeHtmlLinkAttr($element['link']) . '">' . trim($imageTemplate) . '</a>';
|
||||
}
|
||||
$align = 'center';
|
||||
if (!empty($element['styles']['block']['textAlign']) && in_array($element['styles']['block']['textAlign'], ['left', 'right'])) {
|
||||
$align = $element['styles']['block']['textAlign'];
|
||||
}
|
||||
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_image ' . (($element['fullWidth'] === false) ? 'mailpoet_padded_vertical mailpoet_padded_side' : '') . '" align="' . EHelper::escapeHtmlAttr($align) . '" valign="top">
|
||||
' . trim($imageTemplate) . '
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function adjustImageDimensions($element, $columnBaseWidth) {
|
||||
$paddedWidth = StylesHelper::$paddingWidth * 2;
|
||||
// scale image to fit column width
|
||||
if ($element['width'] > $columnBaseWidth) {
|
||||
$ratio = $element['width'] / $columnBaseWidth;
|
||||
$element['width'] = $columnBaseWidth;
|
||||
$element['height'] = (int)ceil($element['height'] / $ratio);
|
||||
}
|
||||
// resize image if the image is padded and wider than padded column width
|
||||
if (
|
||||
$element['fullWidth'] === false &&
|
||||
$element['width'] > ($columnBaseWidth - $paddedWidth)
|
||||
) {
|
||||
$ratio = $element['width'] / ($columnBaseWidth - $paddedWidth);
|
||||
$element['width'] = $columnBaseWidth - $paddedWidth;
|
||||
$element['height'] = (int)ceil($element['height'] / $ratio);
|
||||
}
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Placeholder {
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function render($element): string {
|
||||
$placeholder = $element['placeholder'];
|
||||
$class = $element['class'] ?? '';
|
||||
$style = $element['style'] ?? '';
|
||||
return '
|
||||
<tr>
|
||||
<td class="' . $this->wp->escAttr($class) . '" style="' . $this->wp->escAttr($style) . '">
|
||||
' . $this->wp->escHtml($placeholder) . '
|
||||
</td>
|
||||
</tr>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Newsletter\Renderer\Columns\ColumnsHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
|
||||
class Renderer {
|
||||
/** @var AutomatedLatestContentBlock */
|
||||
private $ALC;
|
||||
|
||||
/** @var Button */
|
||||
private $button;
|
||||
|
||||
/** @var Divider */
|
||||
private $divider;
|
||||
|
||||
/** @var Footer */
|
||||
private $footer;
|
||||
|
||||
/** @var Header */
|
||||
private $header;
|
||||
|
||||
/** @var Image */
|
||||
private $image;
|
||||
|
||||
/** @var Social */
|
||||
private $social;
|
||||
|
||||
/** @var Spacer */
|
||||
private $spacer;
|
||||
|
||||
/** @var Text */
|
||||
private $text;
|
||||
|
||||
/** @var Placeholder */
|
||||
private $placeholder;
|
||||
|
||||
/** @var Coupon */
|
||||
private $coupon;
|
||||
|
||||
public function __construct(
|
||||
AutomatedLatestContentBlock $ALC,
|
||||
Button $button,
|
||||
Divider $divider,
|
||||
Footer $footer,
|
||||
Header $header,
|
||||
Image $image,
|
||||
Social $social,
|
||||
Spacer $spacer,
|
||||
Text $text,
|
||||
Placeholder $placeholder,
|
||||
Coupon $coupon
|
||||
) {
|
||||
$this->ALC = $ALC;
|
||||
$this->button = $button;
|
||||
$this->divider = $divider;
|
||||
$this->footer = $footer;
|
||||
$this->header = $header;
|
||||
$this->image = $image;
|
||||
$this->social = $social;
|
||||
$this->spacer = $spacer;
|
||||
$this->text = $text;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->coupon = $coupon;
|
||||
}
|
||||
|
||||
public function render(NewsletterEntity $newsletter, $data) {
|
||||
if (is_null($data['blocks']) && isset($data['type'])) {
|
||||
return null;
|
||||
}
|
||||
$columnCount = count($data['blocks']);
|
||||
$columnsLayout = isset($data['columnLayout']) ? $data['columnLayout'] : null;
|
||||
$columnWidths = ColumnsHelper::columnWidth($columnCount, $columnsLayout);
|
||||
$columnContent = [];
|
||||
|
||||
foreach ($data['blocks'] as $index => $columnBlocks) {
|
||||
$renderedBlockElement = $this->renderBlocksInColumn($newsletter, $columnBlocks, $columnWidths[$index]);
|
||||
$columnContent[] = $renderedBlockElement;
|
||||
}
|
||||
|
||||
return $columnContent;
|
||||
}
|
||||
|
||||
private function renderBlocksInColumn(NewsletterEntity $newsletter, $block, $columnBaseWidth) {
|
||||
$blockContent = '';
|
||||
$_this = $this;
|
||||
array_map(function($block) use (&$blockContent, $columnBaseWidth, $newsletter, $_this) {
|
||||
$renderedBlockElement = $_this->createElementFromBlockType($newsletter, $block, $columnBaseWidth);
|
||||
if (isset($block['blocks'])) {
|
||||
$renderedBlockElement = $_this->renderBlocksInColumn($newsletter, $block, $columnBaseWidth);
|
||||
// nested vertical column container is rendered as an array
|
||||
if (is_array($renderedBlockElement)) {
|
||||
$renderedBlockElement = implode('', $renderedBlockElement);
|
||||
}
|
||||
}
|
||||
|
||||
$blockContent .= $renderedBlockElement;
|
||||
}, $block['blocks']);
|
||||
return $blockContent;
|
||||
}
|
||||
|
||||
public function createElementFromBlockType(NewsletterEntity $newsletter, $block, $columnBaseWidth) {
|
||||
if ($block['type'] === 'automatedLatestContent') {
|
||||
return $this->processAutomatedLatestContent($newsletter, $block, $columnBaseWidth);
|
||||
}
|
||||
$block = StylesHelper::applyTextAlignment($block);
|
||||
switch ($block['type']) {
|
||||
case 'button':
|
||||
return $this->button->render($block, $columnBaseWidth);
|
||||
case 'divider':
|
||||
return $this->divider->render($block);
|
||||
case 'footer':
|
||||
return $this->footer->render($block);
|
||||
case 'header':
|
||||
return $this->header->render($block);
|
||||
case 'image':
|
||||
return $this->image->render($block, $columnBaseWidth);
|
||||
case 'social':
|
||||
return $this->social->render($block);
|
||||
case 'spacer':
|
||||
return $this->spacer->render($block);
|
||||
case 'text':
|
||||
return $this->text->render($block);
|
||||
case 'placeholder':
|
||||
return $this->placeholder->render($block);
|
||||
case Coupon::TYPE:
|
||||
return $this->coupon->render($block, $columnBaseWidth);
|
||||
}
|
||||
return "<!-- Skipped unsupported block type: {$block['type']} -->";
|
||||
}
|
||||
|
||||
public function processAutomatedLatestContent(NewsletterEntity $newsletter, $args, $columnBaseWidth) {
|
||||
$transformedPosts = [
|
||||
'blocks' => $this->ALC->render($newsletter, $args),
|
||||
];
|
||||
$transformedPosts = StylesHelper::applyTextAlignment($transformedPosts);
|
||||
return $this->renderBlocksInColumn($newsletter, $transformedPosts, $columnBaseWidth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
|
||||
class Social {
|
||||
public function render($element) {
|
||||
$iconsBlock = '';
|
||||
if (is_array($element['icons'])) {
|
||||
foreach ($element['icons'] as $index => $icon) {
|
||||
if (empty($icon['image'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$style = 'width:' . $icon['width'] . ';height:' . $icon['width'] . ';-ms-interpolation-mode:bicubic;border:0;display:inline;outline:none;';
|
||||
$iconsBlock .= '<a href="' . EHelper::escapeHtmlLinkAttr($icon['link']) . '" style="text-decoration:none!important;"
|
||||
><img
|
||||
src="' . EHelper::escapeHtmlLinkAttr($icon['image']) . '"
|
||||
width="' . (int)$icon['width'] . '"
|
||||
height="' . (int)$icon['height'] . '"
|
||||
style="' . EHelper::escapeHtmlStyleAttr($style) . '"
|
||||
alt="' . EHelper::escapeHtmlAttr($icon['iconType']) . '"
|
||||
></a> ';
|
||||
}
|
||||
}
|
||||
$alignment = isset($element['styles']['block']['textAlign']) ? $element['styles']['block']['textAlign'] : 'center';
|
||||
if (!empty($iconsBlock)) {
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_padded_side mailpoet_padded_vertical" valign="top" align="' . EHelper::escapeHtmlAttr($alignment) . '">
|
||||
' . $iconsBlock . '
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
|
||||
class Spacer {
|
||||
public function render($element) {
|
||||
$height = (int)$element['styles']['block']['height'];
|
||||
$backgroundColor = EHelper::escapeHtmlAttr($element['styles']['block']['backgroundColor']);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_spacer" ' .
|
||||
(($backgroundColor !== 'transparent') ? 'bgcolor="' . $backgroundColor . '" ' : '') .
|
||||
'height="' . $height . '" valign="top"></td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Blocks;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Editor\PostContentManager;
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Renderer\StylesHelper;
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
|
||||
class Text {
|
||||
public function render($element) {
|
||||
$html = $element['text'];
|
||||
// replace with spaces
|
||||
$html = str_replace(' ', ' ', $html);
|
||||
$html = str_replace('\xc2\xa0', ' ', $html);
|
||||
$html = $this->convertBlockquotesToTables($html);
|
||||
$html = $this->convertParagraphsToTables($html);
|
||||
$html = $this->styleLists($html);
|
||||
$html = $this->styleHeadings($html);
|
||||
$html = $this->removeLastLineBreak($html);
|
||||
$template = '
|
||||
<tr>
|
||||
<td class="mailpoet_text mailpoet_padded_vertical mailpoet_padded_side" valign="top" style="word-break:break-word;word-wrap:break-word;">
|
||||
' . $html . '
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function convertBlockquotesToTables($html) {
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($html);
|
||||
$blockquotes = $DOM->query('blockquote');
|
||||
foreach ($blockquotes as $blockquote) {
|
||||
$contents = [];
|
||||
$paragraphs = $blockquote->query('p, h1, h2, h3, h4', 0);
|
||||
foreach ($paragraphs as $index => $paragraph) {
|
||||
if (preg_match('/h\d/', $paragraph->getTag())) {
|
||||
$contents[] = $paragraph->getOuterText();
|
||||
} else {
|
||||
$contents[] = $paragraph->toString(true, true, 1);
|
||||
}
|
||||
if ($index + 1 < $paragraphs->count()) $contents[] = '<br />';
|
||||
$paragraph->remove();
|
||||
}
|
||||
if (empty($contents)) continue;
|
||||
$blockquote->setTag('table');
|
||||
$blockquote->addClass('mailpoet_blockquote');
|
||||
$blockquote->width = '100%';
|
||||
$blockquote->spacing = 0;
|
||||
$blockquote->border = 0;
|
||||
$blockquote->cellpadding = 0;
|
||||
$blockquote->html('
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="2" bgcolor="#565656"></td>
|
||||
<td width="10"></td>
|
||||
<td valign="top">
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0">
|
||||
<tr>
|
||||
<td class="mailpoet_blockquote">
|
||||
' . implode('', $contents) . '
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>');
|
||||
$blockquote = $this->insertLineBreak($blockquote);
|
||||
}
|
||||
return $DOM->__toString();
|
||||
}
|
||||
|
||||
public function convertParagraphsToTables($html) {
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($html);
|
||||
$paragraphs = $DOM->query('p');
|
||||
if (!$paragraphs->count()) return $html;
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
// process empty paragraphs
|
||||
if (!trim($paragraph->html())) {
|
||||
$nextElement = ($paragraph->getNextSibling()) ?
|
||||
trim($paragraph->getNextSibling()->text()) :
|
||||
false;
|
||||
$previousElement = ($paragraph->getPreviousSibling()) ?
|
||||
trim($paragraph->getPreviousSibling()->text()) :
|
||||
false;
|
||||
$previousElementTag = ($previousElement) ?
|
||||
$paragraph->getPreviousSibling()->tag :
|
||||
false;
|
||||
// if previous or next paragraphs are empty OR previous paragraph
|
||||
// is a heading, insert a break line
|
||||
if (
|
||||
!$nextElement ||
|
||||
!$previousElement ||
|
||||
(preg_match('/h\d+/', $previousElementTag))
|
||||
) {
|
||||
$paragraph = $this->insertLineBreak($paragraph);
|
||||
}
|
||||
$paragraph->remove();
|
||||
continue;
|
||||
}
|
||||
$style = (string)$paragraph->style;
|
||||
if (!preg_match('/text-align/i', $style)) {
|
||||
$style = 'text-align: left;' . $style;
|
||||
}
|
||||
$contents = $paragraph->toString(true, true, 1);
|
||||
$paragraph->setTag('table');
|
||||
$paragraph->style = 'border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;';
|
||||
$paragraph->width = '100%';
|
||||
$paragraph->cellpadding = 0;
|
||||
$nextElement = $paragraph->getNextSibling();
|
||||
// unless this is the last element in column, add double line breaks
|
||||
$lineBreaks = ($nextElement && !trim($nextElement->text())) ?
|
||||
'<br /><br />' :
|
||||
'';
|
||||
// if this element is followed by a list, add single line break
|
||||
$lineBreaks = ($nextElement && preg_match('/<li/i', $nextElement->getOuterText())) ?
|
||||
'<br />' :
|
||||
$lineBreaks;
|
||||
if ($paragraph->hasClass(PostContentManager::WP_POST_CLASS)) {
|
||||
$paragraph->removeClass(PostContentManager::WP_POST_CLASS);
|
||||
// if this element is followed by a paragraph or heading, add double line breaks
|
||||
$lineBreaks = ($nextElement && preg_match('/<(p|h[1-6]{1})/i', $nextElement->getOuterText())) ?
|
||||
'<br /><br />' :
|
||||
$lineBreaks;
|
||||
}
|
||||
$paragraph->html('
|
||||
<tr>
|
||||
<td class="mailpoet_paragraph" style="word-break:break-word;word-wrap:break-word;' . EHelper::escapeHtmlStyleAttr($style) . '">
|
||||
' . $contents . $lineBreaks . '
|
||||
</td>
|
||||
</tr>');
|
||||
}
|
||||
return $DOM->__toString();
|
||||
}
|
||||
|
||||
public function styleLists($html) {
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($html);
|
||||
$lists = $DOM->query('ol, ul, li');
|
||||
if (!$lists->count()) return $html;
|
||||
foreach ($lists as $list) {
|
||||
if ($list->tag === 'li') {
|
||||
$list->setInnertext($list->toString(true, true, 1));
|
||||
$list->class = 'mailpoet_paragraph';
|
||||
} else {
|
||||
$list->class = 'mailpoet_paragraph';
|
||||
$list->style = StylesHelper::joinStyles($list->style, 'padding-top:0;padding-bottom:0;margin-top:10px;');
|
||||
}
|
||||
$list->style = StylesHelper::applyTextAlignment($list->style);
|
||||
$list->style = StylesHelper::joinStyles($list->style, 'margin-bottom:10px;');
|
||||
$list->style = EHelper::escapeHtmlStyleAttr($list->style);
|
||||
}
|
||||
return $DOM->__toString();
|
||||
}
|
||||
|
||||
public function styleHeadings($html) {
|
||||
$dOMParser = new pQuery();
|
||||
$DOM = $dOMParser->parseStr($html);
|
||||
$headings = $DOM->query('h1, h2, h3, h4');
|
||||
if (!$headings->count()) return $html;
|
||||
foreach ($headings as $heading) {
|
||||
$heading->style = StylesHelper::applyTextAlignment($heading->style);
|
||||
$heading->style = StylesHelper::joinStyles($heading->style, 'padding:0;font-style:normal;font-weight:normal;');
|
||||
$heading->style = EHelper::escapeHtmlStyleAttr($heading->style);
|
||||
}
|
||||
return $DOM->__toString();
|
||||
}
|
||||
|
||||
public function removeLastLineBreak($html) {
|
||||
return preg_replace('/(^)?(<br[^>]*?\/?>)+$/i', '', $html);
|
||||
}
|
||||
|
||||
public function insertLineBreak($element) {
|
||||
$element->parent->insertChild(
|
||||
[
|
||||
'tag_name' => 'br',
|
||||
'self_close' => true,
|
||||
'attributes' => [],
|
||||
],
|
||||
$element->index() + 1
|
||||
);
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
|
||||
class BodyRenderer {
|
||||
/** @var Blocks\Renderer */
|
||||
private $blocksRenderer;
|
||||
|
||||
/** @var Columns\Renderer */
|
||||
private $columnsRenderer;
|
||||
|
||||
public function __construct(
|
||||
Blocks\Renderer $blocksRenderer,
|
||||
Columns\Renderer $columnsRenderer
|
||||
) {
|
||||
$this->blocksRenderer = $blocksRenderer;
|
||||
$this->columnsRenderer = $columnsRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @param array $content
|
||||
* @return string
|
||||
*/
|
||||
public function renderBody(NewsletterEntity $newsletter, array $content) {
|
||||
$blocks = (array_key_exists('blocks', $content))
|
||||
? $content['blocks']
|
||||
: [];
|
||||
|
||||
$renderedContent = [];
|
||||
foreach ($blocks as $contentBlock) {
|
||||
$columnsData = $this->blocksRenderer->render($newsletter, $contentBlock);
|
||||
|
||||
$renderedContent[] = $this->columnsRenderer->render(
|
||||
$contentBlock,
|
||||
$columnsData
|
||||
);
|
||||
}
|
||||
return implode('', $renderedContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Columns;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class ColumnsHelper {
|
||||
public static $columnsWidth = [
|
||||
1 => [660],
|
||||
2 => [330, 330],
|
||||
"1_2" => [220, 440],
|
||||
"2_1" => [440, 220],
|
||||
3 => [220, 220, 220],
|
||||
];
|
||||
|
||||
public static $columnsClass = [
|
||||
1 => 'cols-one',
|
||||
2 => 'cols-two',
|
||||
3 => 'cols-three',
|
||||
];
|
||||
|
||||
public static $columnsAlignment = [
|
||||
1 => null,
|
||||
2 => 'left',
|
||||
3 => 'right',
|
||||
];
|
||||
|
||||
/** @return int[] */
|
||||
public static function columnWidth($columnsCount, $columnsLayout) {
|
||||
if (isset(self::$columnsWidth[$columnsLayout])) {
|
||||
return self::$columnsWidth[$columnsLayout];
|
||||
}
|
||||
return self::$columnsWidth[$columnsCount];
|
||||
}
|
||||
|
||||
public static function columnClass($columnsCount) {
|
||||
return self::$columnsClass[$columnsCount];
|
||||
}
|
||||
|
||||
public static function columnClasses() {
|
||||
return self::$columnsClass;
|
||||
}
|
||||
|
||||
public static function columnAlignment($columnsCount) {
|
||||
return self::$columnsAlignment[$columnsCount];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\Columns;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
|
||||
class Renderer {
|
||||
public function render($contentBlock, $columnsData) {
|
||||
if (is_null($contentBlock['blocks']) && isset($contentBlock['type'])) {
|
||||
return "<!-- Skipped unsupported block type: {$contentBlock['type']} -->";
|
||||
}
|
||||
|
||||
$columnsCount = count($contentBlock['blocks']);
|
||||
|
||||
if ($columnsCount === 1) {
|
||||
return $this->renderOneColumn($contentBlock, $columnsData[0]);
|
||||
}
|
||||
return $this->renderMultipleColumns($contentBlock, $columnsData);
|
||||
}
|
||||
|
||||
private function renderOneColumn($contentBlock, $content) {
|
||||
$template = $this->getOneColumnTemplate(
|
||||
$contentBlock['styles']['block'],
|
||||
isset($contentBlock['image']) ? $contentBlock['image'] : null
|
||||
);
|
||||
return $template['content_start'] . $content . $template['content_end'];
|
||||
}
|
||||
|
||||
public function getOneColumnTemplate($styles, $image) {
|
||||
$backgroundCss = $this->getBackgroundCss($styles, $image);
|
||||
$template['content_start'] = '
|
||||
<tr>
|
||||
<td class="mailpoet_content" align="center" style="border-collapse:collapse;' . $backgroundCss . '" ' . $this->getBgColorAttribute($styles, $image) . '>
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding-left:0;padding-right:0">
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" class="mailpoet_' . ColumnsHelper::columnClass(1) . '" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;table-layout:fixed;margin-left:auto;margin-right:auto;padding-left:0;padding-right:0;">
|
||||
<tbody>';
|
||||
$template['content_end'] = '
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>';
|
||||
return $template;
|
||||
}
|
||||
|
||||
private function renderMultipleColumns($contentBlock, $columnsData) {
|
||||
$columnsCount = count($contentBlock['blocks']);
|
||||
$columnsLayout = isset($contentBlock['columnLayout']) ? $contentBlock['columnLayout'] : null;
|
||||
|
||||
$widths = ColumnsHelper::columnWidth($columnsCount, $columnsLayout);
|
||||
$class = ColumnsHelper::columnClass($columnsCount);
|
||||
$alignment = ColumnsHelper::columnAlignment($columnsCount);
|
||||
$index = 0;
|
||||
$result = $this->getMultipleColumnsContainerStart($class, $contentBlock['styles']['block'], isset($contentBlock['image']) ? $contentBlock['image'] : null);
|
||||
foreach ($columnsData as $content) {
|
||||
$result .= $this->getMultipleColumnsContentStart($widths[$index++], $alignment, $class);
|
||||
$result .= $content;
|
||||
$result .= $this->getMultipleColumnsContentEnd();
|
||||
}
|
||||
$result .= $this->getMultipleColumnsContainerEnd();
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getMultipleColumnsContainerStart($class, $styles, $image) {
|
||||
return '
|
||||
<tr>
|
||||
<td class="mailpoet_content-' . $class . '" align="left" style="border-collapse:collapse;' . $this->getBackgroundCss($styles, $image) . '" ' . $this->getBgColorAttribute($styles, $image) . '>
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0;"><!--[if mso]>
|
||||
<table border="0" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>';
|
||||
}
|
||||
|
||||
private function getMultipleColumnsContainerEnd() {
|
||||
return '
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>';
|
||||
}
|
||||
|
||||
private function getMultipleColumnsContentEnd() {
|
||||
return '
|
||||
</tbody>
|
||||
</table>
|
||||
</div><!--[if mso]>
|
||||
</td>';
|
||||
}
|
||||
|
||||
public function getMultipleColumnsContentStart($width, $alignment, $class) {
|
||||
return '
|
||||
<td width="' . $width . '" valign="top">
|
||||
<![endif]--><div style="display:inline-block; max-width:' . $width . 'px; vertical-align:top; width:100%;">
|
||||
<table width="' . $width . '" class="mailpoet_' . $class . '" border="0" cellpadding="0" cellspacing="0" align="' . $alignment . '" style="width:100%;max-width:' . $width . 'px;border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;table-layout:fixed;margin-left:auto;margin-right:auto;padding-left:0;padding-right:0;">
|
||||
<tbody>';
|
||||
}
|
||||
|
||||
private function getBackgroundCss($styles, $image) {
|
||||
if ($image !== null && $image['src'] !== null) {
|
||||
$backgroundColor = isset($styles['backgroundColor']) && $styles['backgroundColor'] !== 'transparent' ? $styles['backgroundColor'] : '#ffffff';
|
||||
$repeat = $image['display'] === 'tile' ? 'repeat' : 'no-repeat';
|
||||
$size = $image['display'] === 'scale' ? 'cover' : 'contain';
|
||||
$style = sprintf(
|
||||
'background: %s url(%s) %s center/%s;background-color: %s;background-image: url(%s);background-repeat: %s;background-position: center;background-size: %s;',
|
||||
$backgroundColor,
|
||||
$image['src'],
|
||||
$repeat,
|
||||
$size,
|
||||
$backgroundColor,
|
||||
$image['src'],
|
||||
$repeat,
|
||||
$size
|
||||
);
|
||||
return EHelper::escapeHtmlStyleAttr($style);
|
||||
} else {
|
||||
if (!isset($styles['backgroundColor'])) return false;
|
||||
$backgroundColor = $styles['backgroundColor'];
|
||||
return ($backgroundColor !== 'transparent') ?
|
||||
EHelper::escapeHtmlStyleAttr(sprintf('background-color:%s!important;', $backgroundColor)) :
|
||||
false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getBgColorAttribute($styles, $image) {
|
||||
if (
|
||||
($image === null || $image['src'] === null)
|
||||
&& isset($styles['backgroundColor'])
|
||||
&& $styles['backgroundColor'] !== 'transparent'
|
||||
) {
|
||||
return 'bgcolor="' . EHelper::escapeHtmlAttr($styles['backgroundColor']) . '"';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class EscapeHelper {
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
public static function escapeHtmlText($string) {
|
||||
return htmlspecialchars((string)$string, ENT_NOQUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
public static function escapeHtmlAttr($string) {
|
||||
return htmlspecialchars((string)$string, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes Style attributes, but preserves single quotes. Some email clients
|
||||
* (e.g. Yahoo webmail) don't support encoded quoted font names.
|
||||
* Previously used htmlspecialchars but switched to esc_attr which is more appropriate.
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
public static function escapeHtmlStyleAttr($string) {
|
||||
return str_replace(''', "'", esc_attr((string)$string));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
public static function unescapeHtmlStyleAttr($string) {
|
||||
// This decodes entities which may have been added by esc_attr.
|
||||
return htmlspecialchars_decode((string)$string, ENT_QUOTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return string
|
||||
*/
|
||||
public static function escapeHtmlLinkAttr($string) {
|
||||
$string = self::escapeHtmlAttr($string);
|
||||
if (preg_match('/\s*(javascript:|data:text|data:application)/ui', $string) === 1) {
|
||||
return '';
|
||||
}
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer\PostProcess;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Newsletter\Links\Links;
|
||||
use MailPoet\Newsletter\Renderer\Renderer;
|
||||
use MailPoet\Util\pQuery\pQuery;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class OpenTracking {
|
||||
public static function process($template) {
|
||||
$DOM = new pQuery();
|
||||
$DOM = $DOM->parseStr($template);
|
||||
$template = $DOM->select('body');
|
||||
// url is a temporary data tag that will be further replaced with
|
||||
// the proper track API URL during sending
|
||||
$url = Links::DATA_TAG_OPEN;
|
||||
$openTrackingImage = sprintf(
|
||||
'<img alt="" class="" src="%s"/>',
|
||||
$url
|
||||
);
|
||||
self::appendToDomNodes($template, $openTrackingImage);
|
||||
return $DOM->__toString();
|
||||
}
|
||||
|
||||
public static function addTrackingImage() {
|
||||
WPFunctions::get()->addFilter(Renderer::FILTER_POST_PROCESS, function ($template) {
|
||||
return OpenTracking::process($template);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function appendToDomNodes($template, $openTrackingImage): void {
|
||||
// Preserve backward compatibility with pQuery::html()
|
||||
// by processing an array of DomNodes
|
||||
if (!empty($template)) {
|
||||
$template = is_array($template) ? $template : [$template];
|
||||
array_map(
|
||||
function ($item) use ($openTrackingImage) {
|
||||
$itemHtml = $item->toString(true, true, 1);
|
||||
$item->html($itemHtml . $openTrackingImage);
|
||||
return $item;
|
||||
},
|
||||
$template
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Newsletter\Renderer\Blocks\AbandonedCartContent;
|
||||
use MailPoet\Newsletter\Renderer\Blocks\AutomatedLatestContentBlock;
|
||||
use MailPoet\WooCommerce\CouponPreProcessor;
|
||||
use MailPoet\WooCommerce\TransactionalEmails\ContentPreprocessor;
|
||||
|
||||
class Preprocessor {
|
||||
const WC_HEADING_BEFORE = '
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0">
|
||||
<tr>
|
||||
<td class="mailpoet_text" valign="top" style="padding-top:20px;padding-bottom:20px;word-break:break-word;word-wrap:break-word;">';
|
||||
const WC_HEADING_AFTER = '
|
||||
</td>
|
||||
</tr>
|
||||
</table>';
|
||||
|
||||
/** @var AbandonedCartContent */
|
||||
private $abandonedCartContent;
|
||||
|
||||
/** @var AutomatedLatestContentBlock */
|
||||
private $automatedLatestContent;
|
||||
|
||||
/** @var ContentPreprocessor */
|
||||
private $wooCommerceContentPreprocessor;
|
||||
|
||||
/*** @var CouponPreProcessor */
|
||||
private $couponPreProcessor;
|
||||
|
||||
public function __construct(
|
||||
AbandonedCartContent $abandonedCartContent,
|
||||
AutomatedLatestContentBlock $automatedLatestContent,
|
||||
ContentPreprocessor $wooCommerceContentPreprocessor,
|
||||
CouponPreProcessor $couponPreProcessor
|
||||
) {
|
||||
$this->abandonedCartContent = $abandonedCartContent;
|
||||
$this->automatedLatestContent = $automatedLatestContent;
|
||||
$this->wooCommerceContentPreprocessor = $wooCommerceContentPreprocessor;
|
||||
$this->couponPreProcessor = $couponPreProcessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $content
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @return array
|
||||
*/
|
||||
public function process(NewsletterEntity $newsletter, $content, bool $preview = false, SendingQueueEntity $sendingQueue = null) {
|
||||
if (!array_key_exists('blocks', $content)) {
|
||||
return $content;
|
||||
}
|
||||
$contentBlocks = $content['blocks'];
|
||||
$contentBlocks = $this->couponPreProcessor->processCoupons($newsletter, $contentBlocks, $preview);
|
||||
$content['blocks'] = $this->processContainer($newsletter, $contentBlocks, $preview, $sendingQueue);
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function processContainer(NewsletterEntity $newsletter, $blocks, bool $preview, ?SendingQueueEntity $sendingQueue): array {
|
||||
$containerBlocks = [];
|
||||
foreach ($blocks as $block) {
|
||||
if ($block['type'] === 'container' && isset($block['blocks'])) {
|
||||
$block['blocks'] = $this->processContainer($newsletter, $block['blocks'], $preview, $sendingQueue);
|
||||
$containerBlocks = array_merge($containerBlocks, [$block]);
|
||||
} else {
|
||||
$processedBlock = $this->processBlock($newsletter, $block, $preview, $sendingQueue);
|
||||
if (!empty($processedBlock)) {
|
||||
$containerBlocks = array_merge($containerBlocks, $processedBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $containerBlocks;
|
||||
}
|
||||
|
||||
public function processBlock(NewsletterEntity $newsletter, array $block, bool $preview = false, SendingQueueEntity $sendingQueue = null): array {
|
||||
switch ($block['type']) {
|
||||
case 'abandonedCartContent':
|
||||
return $this->abandonedCartContent->render($newsletter, $block, $preview, $sendingQueue);
|
||||
case 'automatedLatestContentLayout':
|
||||
return $this->automatedLatestContent->render($newsletter, $block);
|
||||
case 'woocommerceHeading':
|
||||
return $this->wooCommerceContentPreprocessor->preprocessHeader();
|
||||
case 'woocommerceContent':
|
||||
return $this->wooCommerceContentPreprocessor->preprocessContent();
|
||||
}
|
||||
return [$block];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\EmailEditor\Engine\Renderer\Renderer as GuntenbergRenderer;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Logging\LoggerFactory;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Renderer\EscapeHelper as EHelper;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\NewsletterProcessingException;
|
||||
use MailPoet\Util\License\Features\CapabilitiesManager;
|
||||
use MailPoet\Util\pQuery\DomNode;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Html2Text\Html2Text;
|
||||
|
||||
class Renderer {
|
||||
const NEWSLETTER_TEMPLATE = 'Template.html';
|
||||
const FILTER_POST_PROCESS = 'mailpoet_rendering_post_process';
|
||||
|
||||
/** @var BodyRenderer */
|
||||
private $bodyRenderer;
|
||||
|
||||
/** @var GuntenbergRenderer */
|
||||
private $guntenbergRenderer;
|
||||
|
||||
/** @var Preprocessor */
|
||||
private $preprocessor;
|
||||
|
||||
/** @var \MailPoetVendor\CSS */
|
||||
private $cSSInliner;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/*** @var LoggerFactory */
|
||||
private $loggerFactory;
|
||||
|
||||
/*** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/*** @var SendingQueuesRepository */
|
||||
private $sendingQueuesRepository;
|
||||
|
||||
private CapabilitiesManager $capabilitiesManager;
|
||||
|
||||
public function __construct(
|
||||
BodyRenderer $bodyRenderer,
|
||||
GuntenbergRenderer $guntenbergRenderer,
|
||||
Preprocessor $preprocessor,
|
||||
\MailPoetVendor\CSS $cSSInliner,
|
||||
WPFunctions $wp,
|
||||
LoggerFactory $loggerFactory,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
SendingQueuesRepository $sendingQueuesRepository,
|
||||
CapabilitiesManager $capabilitiesManager
|
||||
) {
|
||||
$this->bodyRenderer = $bodyRenderer;
|
||||
$this->guntenbergRenderer = $guntenbergRenderer;
|
||||
$this->preprocessor = $preprocessor;
|
||||
$this->cSSInliner = $cSSInliner;
|
||||
$this->wp = $wp;
|
||||
$this->loggerFactory = $loggerFactory;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
$this->capabilitiesManager = $capabilitiesManager;
|
||||
}
|
||||
|
||||
public function render(NewsletterEntity $newsletter, SendingQueueEntity $sendingQueue = null, $type = false) {
|
||||
return $this->_render($newsletter, $sendingQueue, $type);
|
||||
}
|
||||
|
||||
public function renderAsPreview(NewsletterEntity $newsletter, $type = false, ?string $subject = null) {
|
||||
return $this->_render($newsletter, null, $type, true, $subject);
|
||||
}
|
||||
|
||||
private function _render(NewsletterEntity $newsletter, SendingQueueEntity $sendingQueue = null, $type = false, $preview = false, $subject = null) {
|
||||
$language = $this->wp->getBloginfo('language');
|
||||
$metaRobots = $preview ? '<meta name="robots" content="noindex, nofollow" />' : '';
|
||||
$subject = $subject ?: $newsletter->getSubject();
|
||||
$wpPostEntity = $newsletter->getWpPost();
|
||||
$wpPost = $wpPostEntity ? $wpPostEntity->getWpPostInstance() : null;
|
||||
if ($wpPost instanceof \WP_Post) {
|
||||
$renderedNewsletter = $this->guntenbergRenderer->render($wpPost, $subject, $newsletter->getPreheader(), $language, $metaRobots);
|
||||
} else {
|
||||
$body = (is_array($newsletter->getBody()))
|
||||
? $newsletter->getBody()
|
||||
: [];
|
||||
$content = (array_key_exists('content', $body))
|
||||
? $body['content']
|
||||
: [];
|
||||
$styles = (array_key_exists('globalStyles', $body))
|
||||
? $body['globalStyles']
|
||||
: [];
|
||||
|
||||
$mailPoetLogoInEmails = $this->capabilitiesManager->getCapability('mailpoetLogoInEmails');
|
||||
if (
|
||||
(isset($mailPoetLogoInEmails) && $mailPoetLogoInEmails->isRestricted) && !$preview
|
||||
) {
|
||||
$content = $this->addMailpoetLogoContentBlock($content, $styles);
|
||||
}
|
||||
|
||||
$renderedBody = "";
|
||||
try {
|
||||
$content = $this->preprocessor->process($newsletter, $content, $preview, $sendingQueue);
|
||||
$renderedBody = $this->bodyRenderer->renderBody($newsletter, $content);
|
||||
} catch (NewsletterProcessingException $e) {
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_COUPONS)->error(
|
||||
$e->getMessage(),
|
||||
['newsletter_id' => $newsletter->getId()]
|
||||
);
|
||||
$this->newslettersRepository->setAsCorrupt($newsletter);
|
||||
if ($sendingQueue) {
|
||||
$this->sendingQueuesRepository->pause($sendingQueue);
|
||||
}
|
||||
}
|
||||
$renderedStyles = $this->renderStyles($styles);
|
||||
$customFontsLinks = StylesHelper::getCustomFontsLinks($styles);
|
||||
|
||||
$template = $this->injectContentIntoTemplate(
|
||||
(string)file_get_contents(dirname(__FILE__) . '/' . self::NEWSLETTER_TEMPLATE),
|
||||
[
|
||||
$language,
|
||||
$metaRobots,
|
||||
htmlspecialchars($subject),
|
||||
$renderedStyles,
|
||||
$customFontsLinks,
|
||||
EHelper::escapeHtmlText($newsletter->getPreheader()),
|
||||
$renderedBody,
|
||||
]
|
||||
);
|
||||
if ($template === null) {
|
||||
$template = '';
|
||||
}
|
||||
$templateDom = $this->inlineCSSStyles($template);
|
||||
$template = $this->postProcessTemplate($templateDom);
|
||||
|
||||
$renderedNewsletter = [
|
||||
'html' => $template,
|
||||
'text' => $this->renderTextVersion($template),
|
||||
];
|
||||
}
|
||||
|
||||
return ($type && !empty($renderedNewsletter[$type])) ?
|
||||
$renderedNewsletter[$type] :
|
||||
$renderedNewsletter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $styles
|
||||
* @return string
|
||||
*/
|
||||
private function renderStyles(array $styles) {
|
||||
$css = '';
|
||||
foreach ($styles as $selector => $style) {
|
||||
switch ($selector) {
|
||||
case 'text':
|
||||
$selector = 'td.mailpoet_paragraph, td.mailpoet_blockquote, li.mailpoet_paragraph';
|
||||
break;
|
||||
case 'body':
|
||||
$selector = 'body, .mailpoet-wrapper';
|
||||
break;
|
||||
case 'link':
|
||||
$selector = '.mailpoet-wrapper a';
|
||||
break;
|
||||
case 'wrapper':
|
||||
$selector = '.mailpoet_content-wrapper';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!is_array($style)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$css .= StylesHelper::setStyle($style, $selector);
|
||||
}
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $template
|
||||
* @param string[] $content
|
||||
* @return string|null
|
||||
*/
|
||||
private function injectContentIntoTemplate($template, $content) {
|
||||
return preg_replace_callback('/{{\w+}}/', function($matches) use (&$content) {
|
||||
return array_shift($content);
|
||||
}, $template);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $template
|
||||
* @return DomNode
|
||||
*/
|
||||
private function inlineCSSStyles($template) {
|
||||
return $this->cSSInliner->inlineCSS($template);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $template
|
||||
* @return string
|
||||
*/
|
||||
private function renderTextVersion($template) {
|
||||
$template = (mb_detect_encoding($template, 'UTF-8', true)) ? $template : mb_convert_encoding($template, 'UTF-8', mb_list_encodings());
|
||||
return @Html2Text::convert($template);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DomNode $templateDom
|
||||
* @return string
|
||||
*/
|
||||
private function postProcessTemplate(DomNode $templateDom) {
|
||||
// replace spaces in image tag URLs
|
||||
foreach ($templateDom->query('img') as $image) {
|
||||
$image->src = str_replace(' ', '%20', $image->src);
|
||||
}
|
||||
foreach ($templateDom->query('a') as $anchor) {
|
||||
// Fix for a TinyMCE bug in smart paste which encodes & as & which is then additionally encoded to &amp;
|
||||
// when saving the text block content in the editor
|
||||
$href = str_replace('&amp;', '&', $anchor->href);
|
||||
// Replace & with & in the href attributes of anchors. URLs are encoded when TinyMCE extracts Text block content via content.innerHTML.
|
||||
// Links containing & work when placed in an anchor tag in a browser, but they don't work when we redirect to them for example in tracking.
|
||||
$href = str_replace('&', '&', $href);
|
||||
$anchor->href = $href;
|
||||
}
|
||||
$template = $templateDom->__toString();
|
||||
$template = $this->wp->applyFilters(
|
||||
self::FILTER_POST_PROCESS,
|
||||
$template
|
||||
);
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $content
|
||||
* @param array $styles
|
||||
* @return array
|
||||
*/
|
||||
private function addMailpoetLogoContentBlock(array $content, array $styles) {
|
||||
if (empty($content['blocks'])) return $content;
|
||||
$content['blocks'][] = [
|
||||
'type' => 'container',
|
||||
'orientation' => 'horizontal',
|
||||
'styles' => [
|
||||
'block' => [
|
||||
'backgroundColor' => (!empty($styles['body']['backgroundColor'])) ?
|
||||
$styles['body']['backgroundColor'] :
|
||||
'transparent',
|
||||
],
|
||||
],
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'container',
|
||||
'orientation' => 'vertical',
|
||||
'styles' => [
|
||||
],
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'image',
|
||||
'link' => 'https://www.mailpoet.com/?ref=free-plan-user-email&utm_source=free_plan_user_email&utm_medium=email',
|
||||
'src' => Env::$assetsUrl . '/img/mailpoet_logo_newsletter.png',
|
||||
'fullWidth' => false,
|
||||
'alt' => 'Email Marketing Powered by MailPoet',
|
||||
'width' => '108px',
|
||||
'height' => '65px',
|
||||
'styles' => [
|
||||
'block' => [
|
||||
'textAlign' => 'center',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Renderer;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class StylesHelper {
|
||||
public static $cssAttributes = [
|
||||
'backgroundColor' => 'background-color',
|
||||
'fontColor' => 'color',
|
||||
'fontFamily' => 'font-family',
|
||||
'textDecoration' => 'text-decoration',
|
||||
'textAlign' => 'text-align',
|
||||
'fontSize' => 'font-size',
|
||||
'fontWeight' => 'font-weight',
|
||||
'borderWidth' => 'border-width',
|
||||
'borderStyle' => 'border-style',
|
||||
'borderColor' => 'border-color',
|
||||
'borderRadius' => 'border-radius',
|
||||
'lineHeight' => 'line-height',
|
||||
'msoLineHeightAlt' => 'mso-line-height-alt',
|
||||
'msoFontSize' => 'mso-ansi-font-size',
|
||||
];
|
||||
public static $font = [
|
||||
'Arial' => "Arial, 'Helvetica Neue', Helvetica, sans-serif",
|
||||
'Comic Sans MS' => "'Comic Sans MS', 'Marker Felt-Thin', Arial, sans-serif",
|
||||
'Courier New' => "'Courier New', Courier, 'Lucida Sans Typewriter', 'Lucida Typewriter', monospace",
|
||||
'Georgia' => "Georgia, Times, 'Times New Roman', serif",
|
||||
'Lucida' => "'Lucida Sans Unicode', 'Lucida Grande', sans-serif",
|
||||
'Tahoma' => 'Tahoma, Verdana, Segoe, sans-serif',
|
||||
'Times New Roman' => "'Times New Roman', Times, Baskerville, Georgia, serif",
|
||||
'Trebuchet MS' => "'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif",
|
||||
'Verdana' => 'Verdana, Geneva, sans-serif',
|
||||
'Arvo' => 'arvo, courier, georgia, serif',
|
||||
'Lato' => "lato, 'helvetica neue', helvetica, arial, sans-serif",
|
||||
'Lora' => "lora, georgia, 'times new roman', serif",
|
||||
'Merriweather' => "merriweather, georgia, 'times new roman', serif",
|
||||
'Merriweather Sans' => "'merriweather sans', 'helvetica neue', helvetica, arial, sans-serif",
|
||||
'Noticia Text' => "'noticia text', georgia, 'times new roman', serif",
|
||||
'Open Sans' => "'open sans', 'helvetica neue', helvetica, arial, sans-serif",
|
||||
'Playfair Display' => "'playfair display', georgia, 'times new roman', serif",
|
||||
'Roboto' => "roboto, 'helvetica neue', helvetica, arial, sans-serif",
|
||||
'Source Sans Pro' => "'source sans pro', 'helvetica neue', helvetica, arial, sans-serif",
|
||||
'Oswald' => "Oswald, 'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Lucida Sans', Tahoma, sans-serif",
|
||||
'Raleway' => "Raleway, 'Century Gothic', CenturyGothic, AppleGothic, sans-serif",
|
||||
'Permanent Marker' => "'Permanent Marker', Tahoma, Verdana, Segoe, sans-serif",
|
||||
'Pacifico' => "Pacifico, 'Arial Narrow', Arial, sans-serif",
|
||||
];
|
||||
public static $customFonts = [
|
||||
'Arvo',
|
||||
'Lato',
|
||||
'Lora',
|
||||
'Merriweather',
|
||||
'Merriweather Sans',
|
||||
'Noticia Text',
|
||||
'Open Sans',
|
||||
'Playfair Display',
|
||||
'Roboto',
|
||||
'Source Sans Pro',
|
||||
'Oswald',
|
||||
'Raleway',
|
||||
'Permanent Marker',
|
||||
'Pacifico',
|
||||
];
|
||||
public static $defaultLineHeight = 1.6;
|
||||
public static $headingMarginMultiplier = 0.3;
|
||||
public static $paddingWidth = 20;
|
||||
|
||||
public static function getBlockStyles($element, $ignoreSpecificStyles = false) {
|
||||
if (!isset($element['styles']['block'])) {
|
||||
return;
|
||||
}
|
||||
return self::getStyles($element['styles'], 'block', $ignoreSpecificStyles);
|
||||
}
|
||||
|
||||
public static function getStyles($data, $type, $ignoreSpecificStyles = false) {
|
||||
$styles = array_map(function($attribute, $style) use ($ignoreSpecificStyles) {
|
||||
if (!$ignoreSpecificStyles || !in_array($attribute, $ignoreSpecificStyles)) {
|
||||
$style = StylesHelper::applyFontFamily($attribute, $style);
|
||||
return StylesHelper::translateCSSAttribute($attribute) . ': ' . $style . ';';
|
||||
}
|
||||
}, array_keys($data[$type]), $data[$type]);
|
||||
return implode('', $styles);
|
||||
}
|
||||
|
||||
public static function translateCSSAttribute($attribute) {
|
||||
return (array_key_exists($attribute, self::$cssAttributes)) ?
|
||||
self::$cssAttributes[$attribute] :
|
||||
$attribute;
|
||||
}
|
||||
|
||||
public static function setStyle(array $style, string $selector): string {
|
||||
$css = $selector . '{' . PHP_EOL;
|
||||
$style = self::applyHeadingMargin($style, $selector);
|
||||
$style = self::applyLineHeight($style, $selector);
|
||||
foreach ($style as $attribute => $individualStyle) {
|
||||
$individualStyle = self::applyFontFamily($attribute, $individualStyle);
|
||||
$css .= self::translateCSSAttribute($attribute) . ':' . $individualStyle . ';' . PHP_EOL;
|
||||
}
|
||||
$css .= '}' . PHP_EOL;
|
||||
return $css;
|
||||
}
|
||||
|
||||
public static function applyTextAlignment($block) {
|
||||
if (is_array($block)) {
|
||||
$textAlignment = isset($block['styles']['block']['textAlign']) ?
|
||||
strtolower($block['styles']['block']['textAlign']) :
|
||||
'';
|
||||
if (preg_match('/center|right|justify/i', (string)$textAlignment)) {
|
||||
return $block;
|
||||
}
|
||||
$block['styles']['block']['textAlign'] = 'left';
|
||||
return $block;
|
||||
}
|
||||
return (preg_match('/text-align.*?[center|justify|right]/i', (string)$block)) ?
|
||||
$block :
|
||||
$block . 'text-align:left;';
|
||||
}
|
||||
|
||||
/**
|
||||
* Join styles and makes sure they are separated by ;
|
||||
*/
|
||||
public static function joinStyles(?string $styles1, ?string $styles2): string {
|
||||
if ($styles1 === null) $styles1 = '';
|
||||
if ($styles2 === null) $styles2 = '';
|
||||
|
||||
$style = trim($styles1);
|
||||
if (
|
||||
(strlen($style) > 0)
|
||||
&& (substr($style, -1) !== ';')
|
||||
) $style .= ';';
|
||||
$style .= $styles2;
|
||||
return $style;
|
||||
}
|
||||
|
||||
public static function applyFontFamily($attribute, $style) {
|
||||
if ($attribute !== 'fontFamily') return $style;
|
||||
return (isset(self::$font[$style])) ?
|
||||
self::$font[$style] :
|
||||
self::$font['Arial'];
|
||||
}
|
||||
|
||||
public static function applyHeadingMargin(array $style, string $selector): array {
|
||||
if (!preg_match('/h[1-4]/i', $selector)) return $style;
|
||||
$fontSize = (int)$style['fontSize'];
|
||||
$style['margin'] = sprintf('0 0 %spx 0', self::$headingMarginMultiplier * $fontSize);
|
||||
return $style;
|
||||
}
|
||||
|
||||
public static function applyLineHeight(array $style, string $selector): array {
|
||||
if (!preg_match('/mailpoet_paragraph|h[1-4]/i', $selector)) return $style;
|
||||
$lineHeight = isset($style['lineHeight']) ? (float)$style['lineHeight'] : self::$defaultLineHeight;
|
||||
$fontSize = (int)$style['fontSize'];
|
||||
$msoLineHeight = round($lineHeight * $fontSize);
|
||||
if ($msoLineHeight % 2 === 1) {
|
||||
$msoLineHeight++;
|
||||
}
|
||||
$msoFontSize = $fontSize;
|
||||
if ($msoFontSize % 2 === 1) {
|
||||
$msoFontSize++;
|
||||
}
|
||||
$style['msoLineHeightAlt'] = sprintf('%spx', $msoLineHeight);
|
||||
$style = ['msoFontSize' => sprintf('%spx', $msoFontSize)] + $style;
|
||||
$style['lineHeight'] = sprintf('%spx', $lineHeight * $fontSize);
|
||||
|
||||
return $style;
|
||||
}
|
||||
|
||||
private static function getCustomFontsNames($styles) {
|
||||
$fontNames = [];
|
||||
foreach ($styles as $style) {
|
||||
if (isset($style['fontFamily']) && in_array($style['fontFamily'], self::$customFonts)) {
|
||||
$fontNames[$style['fontFamily']] = true;
|
||||
}
|
||||
}
|
||||
return array_keys($fontNames);
|
||||
}
|
||||
|
||||
public static function getCustomFontsLinks($styles) {
|
||||
$links = [];
|
||||
foreach (self::getCustomFontsNames($styles) as $name) {
|
||||
$links[] = urlencode($name) . ':400,400i,700,700i';
|
||||
}
|
||||
if (!count($links)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// see https://stackoverflow.com/a/48214207
|
||||
return '<!--[if !mso]><!-- --><link href="https://fonts.googleapis.com/css?family='
|
||||
. implode("|", $links)
|
||||
. '" rel="stylesheet"><!--<![endif]-->';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<html lang="{{newsletter_language}}">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
{{newsletter_meta_robots}}
|
||||
<title>{{newsletter_subject}}</title>
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ExternalClass * {
|
||||
line-height: 130%;
|
||||
}
|
||||
.ExternalClass a {
|
||||
line-height: 140%;
|
||||
}
|
||||
.ExternalClass h1, h2, h3, h1, h2, h3 {
|
||||
Margin: 0;
|
||||
}
|
||||
.mailpoet_text h1:last-child, .mailpoet_text h2:last-child, .mailpoet_text h3:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.ExternalClass ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
table, td {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.mailpoet_image img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
border: 0;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
.mailpoet_padded_vertical {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.mailpoet_padded_side {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.mailpoet_header_footer_padded {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
/* https://www.emailonacid.com/blog/article/email-development/tips-for-coding-email-preheaders */
|
||||
.mailpoet_preheader, .mailpoet_preheader * {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
color: #ffffff;
|
||||
line-height: 1px;
|
||||
max-height: 0px;
|
||||
max-width: 0px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide text meant for screen readers. Styles copied from WordPress' common.css file */
|
||||
/* In addition, there are color, font-size, line-height, and mso-hide properties for clients where it doesn't work */
|
||||
.screen-reader-text {
|
||||
border: 0;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
-webkit-clip-path: inset(50%);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
word-wrap: normal !important;
|
||||
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
mso-hide: all;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.mailpoet_button {width:100% !important;}
|
||||
}
|
||||
@media screen and (max-width: 599px) {
|
||||
.mailpoet_header {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.mailpoet_button {
|
||||
width: 100% !important;
|
||||
padding: 5px 0 !important;
|
||||
box-sizing:border-box !important;
|
||||
}
|
||||
div, .mailpoet_cols-two, .mailpoet_cols-three {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
{{newsletter_styles}}
|
||||
</style>
|
||||
{{newsletter_custom_fonts}}
|
||||
</head>
|
||||
<body leftmargin="0" topmargin="0" marginwidth="0" marginheight="0">
|
||||
<table class="mailpoet_template" border="0" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mailpoet_preheader" style="-webkit-text-size-adjust:none;font-size:1px;line-height:1px;color:#333333;" height="1">
|
||||
{{newsletter_preheader}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" class="mailpoet-wrapper" valign="top"><!--[if mso]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0"
|
||||
width="660">
|
||||
<tr>
|
||||
<td class="mailpoet_content-wrapper" align="center" valign="top" width="660">
|
||||
<![endif]--><table class="mailpoet_content-wrapper" border="0" width="660" cellpadding="0" cellspacing="0"
|
||||
style="border-spacing:0;mso-table-lspace:0;mso-table-rspace:0;max-width:660px;width:100%;">
|
||||
<tbody>
|
||||
{{newsletter_body}}
|
||||
</tbody>
|
||||
</table><!--[if mso]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
|
||||
class AutomaticEmailScheduler {
|
||||
|
||||
/** @var Scheduler */
|
||||
private $scheduler;
|
||||
|
||||
/** @var ScheduledTasksRepository */
|
||||
private $scheduledTasksRepository;
|
||||
|
||||
/** @var SendingQueuesRepository */
|
||||
private $sendingQueuesRepository;
|
||||
|
||||
/** @var ScheduledTaskSubscribersRepository */
|
||||
private $scheduledTaskSubscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
Scheduler $scheduler,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
|
||||
SendingQueuesRepository $sendingQueuesRepository
|
||||
) {
|
||||
$this->scheduler = $scheduler;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
}
|
||||
|
||||
public function scheduleAutomaticEmail(
|
||||
string $group,
|
||||
string $event,
|
||||
?callable $schedulingCondition = null,
|
||||
?SubscriberEntity $subscriber = null,
|
||||
?array $meta = null,
|
||||
?callable $metaModifier = null
|
||||
) {
|
||||
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
|
||||
if (empty($newsletters)) return false;
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) continue;
|
||||
if (is_callable($schedulingCondition) && !$schedulingCondition($newsletter)) continue;
|
||||
|
||||
/**
|
||||
* $meta will be the same for all newsletters by default. If we need to store newsletter-specific meta, the
|
||||
* $metaModifier callback can be used.
|
||||
*
|
||||
* This was introduced because of WooCommerce product purchase automatic emails. We only want to store the
|
||||
* product IDs that specifically triggered a newsletter, but $meta includes ALL the product IDs
|
||||
* or category IDs from an order.
|
||||
*/
|
||||
if (is_callable($metaModifier)) {
|
||||
$meta = $metaModifier($newsletter, $meta);
|
||||
}
|
||||
$this->createAutomaticEmailScheduledTask($newsletter, $subscriber, $meta);
|
||||
}
|
||||
}
|
||||
|
||||
public function scheduleOrRescheduleAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber, array $meta): void {
|
||||
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
|
||||
if (empty($newsletters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// try to find existing scheduled task for given subscriber
|
||||
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
|
||||
if ($task) {
|
||||
$this->rescheduleAutomaticEmailSendingTask($newsletter, $task, $meta);
|
||||
} else {
|
||||
$this->createAutomaticEmailScheduledTask($newsletter, $subscriber, $meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rescheduleAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber): void {
|
||||
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
|
||||
if (empty($newsletters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// try to find existing scheduled task for given subscriber
|
||||
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
|
||||
if ($task) {
|
||||
$this->rescheduleAutomaticEmailSendingTask($newsletter, $task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelAutomaticEmail(string $group, string $event, SubscriberEntity $subscriber): void {
|
||||
$newsletters = $this->scheduler->getNewsletters(NewsletterEntity::TYPE_AUTOMATIC, $group);
|
||||
if (empty($newsletters)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// try to find existing scheduled task for given subscriber
|
||||
$task = $this->scheduledTasksRepository->findOneScheduledByNewsletterAndSubscriber($newsletter, $subscriber);
|
||||
if ($task) {
|
||||
$queue = $task->getSendingQueue();
|
||||
if ($queue instanceof SendingQueueEntity) {
|
||||
$this->sendingQueuesRepository->remove($queue);
|
||||
}
|
||||
$this->scheduledTaskSubscribersRepository->deleteByScheduledTask($task);
|
||||
$this->scheduledTasksRepository->remove($task);
|
||||
$this->scheduledTasksRepository->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function createAutomaticEmailScheduledTask(NewsletterEntity $newsletter, ?SubscriberEntity $subscriber, ?array $meta = null): ScheduledTaskEntity {
|
||||
$scheduledTask = new ScheduledTaskEntity();
|
||||
$scheduledTask->setType(SendingQueue::TASK_TYPE);
|
||||
$scheduledTask->setStatus(SendingQueueEntity::STATUS_SCHEDULED);
|
||||
$scheduledTask->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
|
||||
|
||||
$scheduledTask->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
|
||||
));
|
||||
$this->scheduledTasksRepository->persist($scheduledTask);
|
||||
$this->scheduledTasksRepository->flush();
|
||||
|
||||
$sendingQueue = new SendingQueueEntity();
|
||||
$sendingQueue->setNewsletter($newsletter);
|
||||
$sendingQueue->setTask($scheduledTask);
|
||||
// Because we changed the way how to updateCounts after sending we need to set initial counts
|
||||
$sendingQueue->setCountTotal($subscriber ? 1 : 0);
|
||||
$sendingQueue->setCountToProcess($subscriber ? 1 : 0);
|
||||
$scheduledTask->setSendingQueue($sendingQueue);
|
||||
|
||||
if ($meta) {
|
||||
$scheduledTask->setMeta($meta);
|
||||
$sendingQueue->setMeta($meta);
|
||||
}
|
||||
|
||||
$this->sendingQueuesRepository->persist($sendingQueue);
|
||||
$this->sendingQueuesRepository->flush();
|
||||
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEND_TO) === 'user' && $subscriber) {
|
||||
$scheduledTaskSubscriber = new ScheduledTaskSubscriberEntity($scheduledTask, $subscriber);
|
||||
$this->scheduledTaskSubscribersRepository->persist($scheduledTaskSubscriber);
|
||||
$this->scheduledTaskSubscribersRepository->flush();
|
||||
$scheduledTask->getSubscribers()->add($scheduledTaskSubscriber);
|
||||
}
|
||||
|
||||
return $scheduledTask;
|
||||
}
|
||||
|
||||
private function rescheduleAutomaticEmailSendingTask(NewsletterEntity $newsletter, ScheduledTaskEntity $scheduledTask, ?array $meta = null): void {
|
||||
$sendingQueue = $this->sendingQueuesRepository->findOneBy(['task' => $scheduledTask]);
|
||||
if (!$sendingQueue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($meta) {
|
||||
$sendingQueue->setMeta($meta);
|
||||
$scheduledTask->setMeta($meta);
|
||||
}
|
||||
// compute new 'scheduled_at' from now
|
||||
$scheduledTask->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
|
||||
));
|
||||
$this->sendingQueuesRepository->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Automation\Engine\Data\AutomationRun;
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\InvalidStateException;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTaskSubscribersRepository;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class AutomationEmailScheduler {
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
private ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
|
||||
}
|
||||
|
||||
public function createSendingTask(NewsletterEntity $email, SubscriberEntity $subscriber, array $meta): ScheduledTaskEntity {
|
||||
if (!in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) {
|
||||
throw InvalidStateException::create()->withMessage(
|
||||
// translators: %s is the type which was given.
|
||||
sprintf(__("Email with type 'automation' or 'automation_transactional' expected, '%s' given.", 'mailpoet'), $email->getType())
|
||||
);
|
||||
}
|
||||
|
||||
$task = new ScheduledTaskEntity();
|
||||
$task->setType(SendingQueue::TASK_TYPE);
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
$task->setScheduledAt(Carbon::now()->millisecond(0));
|
||||
$task->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
|
||||
$task->setMeta($meta);
|
||||
$this->entityManager->persist($task);
|
||||
|
||||
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
|
||||
$this->entityManager->persist($taskSubscriber);
|
||||
|
||||
$queue = new SendingQueueEntity();
|
||||
$queue->setTask($task);
|
||||
$queue->setMeta($meta);
|
||||
$queue->setNewsletter($email);
|
||||
$queue->setCountToProcess(1);
|
||||
$queue->setCountTotal(1);
|
||||
$this->entityManager->persist($queue);
|
||||
|
||||
$this->entityManager->flush();
|
||||
return $task;
|
||||
}
|
||||
|
||||
public function getScheduledTaskSubscriber(NewsletterEntity $email, SubscriberEntity $subscriber, AutomationRun $run): ?ScheduledTaskSubscriberEntity {
|
||||
$results = $this->entityManager->createQueryBuilder()
|
||||
->select('sts')
|
||||
->from(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->join('sts.task', 'st')
|
||||
->join('st.sendingQueue', 'sq')
|
||||
->where('sq.newsletter = :newsletter')
|
||||
->andWhere('sts.subscriber = :subscriber')
|
||||
->andWhere('st.createdAt >= :runCreatedAt')
|
||||
->setParameter('newsletter', $email)
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->setParameter('runCreatedAt', $run->getCreatedAt())
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$result = null;
|
||||
foreach ($results as $scheduledTaskSubscriber) {
|
||||
$task = $scheduledTaskSubscriber->getTask();
|
||||
if (!$task instanceof ScheduledTaskEntity) {
|
||||
continue;
|
||||
}
|
||||
$meta = $task->getMeta();
|
||||
if (($meta['automation']['run_id'] ?? null) === $run->getId()) {
|
||||
$result = $scheduledTaskSubscriber;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $result instanceof ScheduledTaskSubscriberEntity ? $result : null;
|
||||
}
|
||||
|
||||
public function saveError(ScheduledTaskSubscriberEntity $scheduledTaskSubscriber, string $error): void {
|
||||
$task = $scheduledTaskSubscriber->getTask();
|
||||
$subscriber = $scheduledTaskSubscriber->getSubscriber();
|
||||
if (!$task || !$subscriber || !$subscriber->getId()) {
|
||||
return;
|
||||
}
|
||||
$this->scheduledTaskSubscribersRepository->saveError($task, $subscriber->getId(), $error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Logging\LoggerFactory;
|
||||
use MailPoet\Newsletter\NewsletterPostsRepository;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\WP\DateTime;
|
||||
use MailPoet\WP\Posts;
|
||||
|
||||
class PostNotificationScheduler {
|
||||
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const SECONDS_IN_HOUR = 3600;
|
||||
const LAST_WEEKDAY_FORMAT = 'L';
|
||||
const INTERVAL_DAILY = 'daily';
|
||||
const INTERVAL_IMMEDIATELY = 'immediately';
|
||||
const INTERVAL_NTHWEEKDAY = 'nthWeekDay';
|
||||
const INTERVAL_WEEKLY = 'weekly';
|
||||
const INTERVAL_IMMEDIATE = 'immediate';
|
||||
const INTERVAL_MONTHLY = 'monthly';
|
||||
|
||||
/** @var LoggerFactory */
|
||||
private $loggerFactory;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var NewsletterOptionsRepository */
|
||||
private $newsletterOptionsRepository;
|
||||
|
||||
/** @var NewsletterOptionFieldsRepository */
|
||||
private $newsletterOptionFieldsRepository;
|
||||
|
||||
/** @var NewsletterPostsRepository */
|
||||
private $newsletterPostsRepository;
|
||||
|
||||
/** @var Scheduler */
|
||||
private $scheduler;
|
||||
|
||||
/*** @var ScheduledTasksRepository */
|
||||
private $scheduledTasksRepository;
|
||||
|
||||
/*** @var SendingQueuesRepository */
|
||||
private $sendingQueuesRepository;
|
||||
|
||||
public function __construct(
|
||||
NewslettersRepository $newslettersRepository,
|
||||
NewsletterOptionsRepository $newsletterOptionsRepository,
|
||||
NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository,
|
||||
NewsletterPostsRepository $newsletterPostsRepository,
|
||||
Scheduler $scheduler,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
SendingQueuesRepository $sendingQueuesRepository
|
||||
) {
|
||||
$this->loggerFactory = LoggerFactory::getInstance();
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
|
||||
$this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository;
|
||||
$this->newsletterPostsRepository = $newsletterPostsRepository;
|
||||
$this->scheduler = $scheduler;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
}
|
||||
|
||||
public function transitionHook($newStatus, $oldStatus, $post) {
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'transition post notification hook initiated',
|
||||
[
|
||||
'post_id' => $post->ID,
|
||||
'new_status' => $newStatus,
|
||||
'old_status' => $oldStatus,
|
||||
]
|
||||
);
|
||||
$types = Posts::getTypes();
|
||||
if (($newStatus !== 'publish') || $oldStatus === 'publish' || !isset($types[$post->post_type])) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
return;
|
||||
}
|
||||
$this->schedulePostNotification($post->ID);
|
||||
}
|
||||
|
||||
public function schedulePostNotification($postId) {
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'schedule post notification hook',
|
||||
['post_id' => $postId]
|
||||
);
|
||||
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_NOTIFICATION]);
|
||||
$this->newslettersRepository->prefetchOptions($newsletters);
|
||||
if (!count($newsletters)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($newsletters as $newsletter) {
|
||||
$post = $this->newsletterPostsRepository->findOneBy([
|
||||
'newsletter' => $newsletter,
|
||||
'postId' => $postId,
|
||||
]);
|
||||
if ($post === null) {
|
||||
$this->createPostNotificationSendingTask($newsletter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function createPostNotificationSendingTask(NewsletterEntity $newsletter): ?ScheduledTaskEntity {
|
||||
$notificationHistory = $this->newslettersRepository->findSendingNotificationHistoryWithoutPausedOrInvalidTask($newsletter);
|
||||
if (count($notificationHistory) > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheduleOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_SCHEDULE);
|
||||
if (!$scheduleOption) {
|
||||
return null;
|
||||
}
|
||||
$nextRunDate = $this->scheduler->getNextRunDateTime($scheduleOption->getValue());
|
||||
if (!$nextRunDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// do not schedule duplicate queues for the same time
|
||||
$lastQueue = $newsletter->getLatestQueue();
|
||||
$task = $lastQueue !== null ? $lastQueue->getTask() : null;
|
||||
$scheduledAt = $task !== null ? $task->getScheduledAt() : null;
|
||||
if ($scheduledAt && $scheduledAt->format('Y-m-d H:i:s') === $nextRunDate->format('Y-m-d H:i:s')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheduledTask = new ScheduledTaskEntity();
|
||||
$scheduledTask->setType(SendingQueue::TASK_TYPE);
|
||||
$scheduledTask->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
$scheduledTask->setScheduledAt($nextRunDate);
|
||||
$scheduledTask->setPriority(ScheduledTaskEntity::PRIORITY_MEDIUM);
|
||||
$this->scheduledTasksRepository->persist($scheduledTask);
|
||||
$this->scheduledTasksRepository->flush();
|
||||
|
||||
$sendingQueue = new SendingQueueEntity();
|
||||
$sendingQueue->setNewsletter($newsletter);
|
||||
$sendingQueue->setTask($scheduledTask);
|
||||
$this->sendingQueuesRepository->persist($sendingQueue);
|
||||
$this->sendingQueuesRepository->flush();
|
||||
$scheduledTask->setSendingQueue($sendingQueue);
|
||||
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_POST_NOTIFICATIONS)->info(
|
||||
'schedule post notification',
|
||||
[
|
||||
'sending_task' => $scheduledTask->getId(),
|
||||
'scheduled_at' => $nextRunDate->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
|
||||
]
|
||||
);
|
||||
return $scheduledTask;
|
||||
}
|
||||
|
||||
public function processPostNotificationSchedule(NewsletterEntity $newsletter) {
|
||||
$intervalTypeOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_INTERVAL_TYPE);
|
||||
$intervalType = $intervalTypeOption ? $intervalTypeOption->getValue() : null;
|
||||
|
||||
$timeOfDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_TIME_OF_DAY);
|
||||
$hour = $timeOfDayOption ? (int)floor((int)$timeOfDayOption->getValue() / self::SECONDS_IN_HOUR) : null;
|
||||
$minute = $timeOfDayOption ? ((int)$timeOfDayOption->getValue() - (int)($hour * self::SECONDS_IN_HOUR)) / self::SECONDS_IN_MINUTE : null;
|
||||
|
||||
$weekDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_WEEK_DAY);
|
||||
$weekDay = $weekDayOption ? $weekDayOption->getValue() : null;
|
||||
|
||||
$monthDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_MONTH_DAY);
|
||||
$monthDay = $monthDayOption ? $monthDayOption->getValue() : null;
|
||||
|
||||
$nthWeekDayOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_NTH_WEEK_DAY);
|
||||
$nthWeekDay = $nthWeekDayOption ? $nthWeekDayOption->getValue() : null;
|
||||
$nthWeekDay = ($nthWeekDay === self::LAST_WEEKDAY_FORMAT) ? $nthWeekDay : '#' . $nthWeekDay;
|
||||
switch ($intervalType) {
|
||||
case self::INTERVAL_IMMEDIATE:
|
||||
case self::INTERVAL_DAILY:
|
||||
$schedule = sprintf('%s %s * * *', $minute, $hour);
|
||||
break;
|
||||
case self::INTERVAL_WEEKLY:
|
||||
$schedule = sprintf('%s %s * * %s', $minute, $hour, $weekDay);
|
||||
break;
|
||||
case self::INTERVAL_NTHWEEKDAY:
|
||||
$schedule = sprintf('%s %s ? * %s%s', $minute, $hour, $weekDay, $nthWeekDay);
|
||||
break;
|
||||
case self::INTERVAL_MONTHLY:
|
||||
$schedule = sprintf('%s %s %s * *', $minute, $hour, $monthDay);
|
||||
break;
|
||||
case self::INTERVAL_IMMEDIATELY:
|
||||
default:
|
||||
$schedule = '* * * * *';
|
||||
break;
|
||||
}
|
||||
$optionField = $this->newsletterOptionFieldsRepository->findOneBy([
|
||||
'name' => NewsletterOptionFieldEntity::NAME_SCHEDULE,
|
||||
]);
|
||||
if (!$optionField instanceof NewsletterOptionFieldEntity) {
|
||||
throw new \Exception('NewsletterOptionField for schedule doesn’t exist.');
|
||||
}
|
||||
$scheduleOption = $newsletter->getOption(NewsletterOptionFieldEntity::NAME_SCHEDULE);
|
||||
if ($scheduleOption === null) {
|
||||
$scheduleOption = new NewsletterOptionEntity($newsletter, $optionField);
|
||||
$newsletter->getOptions()->add($scheduleOption);
|
||||
}
|
||||
$scheduleOption->setValue($schedule);
|
||||
$this->newsletterOptionsRepository->persist($scheduleOption);
|
||||
$this->newsletterOptionsRepository->flush();
|
||||
return $scheduleOption->getValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsNewsletterEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberSegmentEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class ReEngagementScheduler {
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var ScheduledTasksRepository */
|
||||
private $scheduledTasksRepository;
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(
|
||||
NewslettersRepository $newslettersRepository,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules sending tasks for re-engagement emails
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function scheduleAll(): array {
|
||||
$scheduled = [];
|
||||
$emails = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_RE_ENGAGEMENT]);
|
||||
if (!$emails) {
|
||||
return $scheduled;
|
||||
}
|
||||
foreach ($emails as $email) {
|
||||
$scheduled[] = $this->scheduleForEmail($email);
|
||||
}
|
||||
return array_filter($scheduled);
|
||||
}
|
||||
|
||||
private function scheduleForEmail(NewsletterEntity $newsletter): ?ScheduledTaskEntity {
|
||||
$scheduledOrRunning = $this->scheduledTasksRepository->findByScheduledAndRunningForNewsletter($newsletter);
|
||||
if ($scheduledOrRunning) {
|
||||
return null;
|
||||
}
|
||||
$intervalUnit = $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE);
|
||||
$intervalValue = (int)$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER);
|
||||
if (!$intervalValue || !in_array($intervalUnit, ['weeks', 'months'], true)) {
|
||||
return null;
|
||||
}
|
||||
if (!$newsletter->getNewsletterSegments()->count()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheduledTask = $this->scheduleTask();
|
||||
$enqueuedCount = 0;
|
||||
foreach ($newsletter->getSegmentIds() as $segmentId) {
|
||||
$enqueuedCount += $this->enqueueSubscribersForSegment((int)$newsletter->getId(), $segmentId, $scheduledTask, $intervalUnit, $intervalValue);
|
||||
}
|
||||
|
||||
if ($enqueuedCount) {
|
||||
$this->createSendingQueue($newsletter, $scheduledTask, $enqueuedCount);
|
||||
return $scheduledTask;
|
||||
} else {
|
||||
// Nothing to send
|
||||
$this->scheduledTasksRepository->remove($scheduledTask);
|
||||
$this->scheduledTasksRepository->flush();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function scheduleTask(): ScheduledTaskEntity {
|
||||
// Scheduled task
|
||||
$scheduledTask = new ScheduledTaskEntity();
|
||||
$scheduledTask->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
$scheduledTask->setScheduledAt(Carbon::now()->millisecond(0));
|
||||
$scheduledTask->setType(SendingQueue::TASK_TYPE);
|
||||
$scheduledTask->setPriority(SendingQueueEntity::PRIORITY_MEDIUM);
|
||||
$this->scheduledTasksRepository->persist($scheduledTask);
|
||||
$this->scheduledTasksRepository->flush();
|
||||
return $scheduledTask;
|
||||
}
|
||||
|
||||
private function createSendingQueue(NewsletterEntity $newsletter, ScheduledTaskEntity $scheduledTask, int $countToProcess): SendingQueueEntity {
|
||||
// Sending queue
|
||||
$sendingQueue = new SendingQueueEntity();
|
||||
$sendingQueue->setTask($scheduledTask);
|
||||
$sendingQueue->setNewsletter($newsletter);
|
||||
$sendingQueue->setCountToProcess($countToProcess);
|
||||
$sendingQueue->setCountTotal($countToProcess);
|
||||
$this->entityManager->persist($sendingQueue);
|
||||
$this->entityManager->flush();
|
||||
return $sendingQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds subscribers that should receive re-engagement email and saves scheduled tasks subscribers
|
||||
* @return int Count of enqueued subscribers
|
||||
*/
|
||||
private function enqueueSubscribersForSegment(int $newsletterId, int $segmentId, ScheduledTaskEntity $scheduledTask, string $intervalUnit, int $intervalValue): int {
|
||||
// Parameters for scheduled task subscribers query
|
||||
$thresholdDate = Carbon::now()->millisecond(0);
|
||||
if ($intervalUnit === 'months') {
|
||||
$thresholdDate->subMonths($intervalValue);
|
||||
} else {
|
||||
$thresholdDate->subWeeks($intervalValue);
|
||||
}
|
||||
$thresholdDateSql = $thresholdDate->toDateTimeString();
|
||||
// When checking engagement, we ignore emails that subscribers received in the last 24 hours so that we leave them some time to engage.
|
||||
// This is prevention for sending re-engagement emails to subscribers who have received a single email very recently.
|
||||
$upperThresholdDate = Carbon::now()->millisecond(0);
|
||||
$upperThresholdDate->subDay();
|
||||
$upperThresholdDate = $upperThresholdDate->toDateTimeString();
|
||||
$taskId = $scheduledTask->getId();
|
||||
$subscribedStatus = SubscriberEntity::STATUS_SUBSCRIBED;
|
||||
$newsletterStatsTable = $this->entityManager->getClassMetadata(StatisticsNewsletterEntity::class)->getTableName();
|
||||
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
|
||||
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
$nowSql = Carbon::now()->millisecond(0)->toDateTimeString();
|
||||
|
||||
$query = "INSERT IGNORE INTO $scheduledTaskSubscribersTable
|
||||
(subscriber_id, task_id, processed, created_at)
|
||||
SELECT DISTINCT ns.subscriber_id as subscriber_id, :taskId as task_id, 0 as processed, :now as created_at
|
||||
FROM $newsletterStatsTable as ns
|
||||
JOIN $subscribersTable s ON
|
||||
ns.subscriber_id = s.id
|
||||
AND s.deleted_at is NULL
|
||||
AND s.status = :subscribed
|
||||
AND GREATEST(COALESCE(s.created_at, '0'), COALESCE(s.last_subscribed_at, '0'), COALESCE(s.last_engagement_at, '0')) < :thresholdDate
|
||||
JOIN $subscriberSegmentTable as ss ON ns.subscriber_id = ss.subscriber_id
|
||||
AND ss.segment_id = :segmentId
|
||||
AND ss.status = :subscribed
|
||||
WHERE ns.sent_at > :thresholdDate
|
||||
AND ns.sent_at < :upperThresholdDate
|
||||
AND ns.subscriber_id NOT IN (
|
||||
SELECT DISTINCT subscriber_id as id FROM $newsletterStatsTable WHERE newsletter_id = :newsletterId AND sent_at > :thresholdDate
|
||||
);
|
||||
";
|
||||
|
||||
$statement = $this->entityManager->getConnection()->prepare($query);
|
||||
$statement->bindValue('now', $nowSql, ParameterType::STRING);
|
||||
$statement->bindValue('taskId', $taskId, ParameterType::INTEGER);
|
||||
$statement->bindValue('subscribed', $subscribedStatus, ParameterType::STRING);
|
||||
$statement->bindValue('thresholdDate', $thresholdDateSql, ParameterType::STRING);
|
||||
$statement->bindValue('upperThresholdDate', $upperThresholdDate, ParameterType::STRING);
|
||||
$statement->bindValue('newsletterId', $newsletterId, ParameterType::INTEGER);
|
||||
$statement->bindValue('segmentId', $segmentId, ParameterType::INTEGER);
|
||||
|
||||
$result = $statement->executeQuery();
|
||||
return $result->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
|
||||
class Scheduler {
|
||||
const MYSQL_TIMESTAMP_MAX = '2038-01-19 03:14:07';
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp,
|
||||
NewslettersRepository $newslettersRepository
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|false
|
||||
*/
|
||||
public function getNextRunDate($schedule) {
|
||||
$nextRunDateTime = $this->getNextRunDateTime($schedule);
|
||||
return $nextRunDateTime ? $nextRunDateTime->format('Y-m-d H:i:s') : $nextRunDateTime;
|
||||
}
|
||||
|
||||
public function getPreviousRunDate($schedule) {
|
||||
// User enters time in WordPress site timezone, but we need to calculate it in UTC before we save it to DB
|
||||
// 1) As the initial time we use time in site timezone via current_datetime
|
||||
// 2) We use CronExpression to calculate previous run (still in site's timezone)
|
||||
// 3) We convert the calculated time to UTC
|
||||
$from = $this->wp->currentDatetime();
|
||||
try {
|
||||
$schedule = new \Cron\CronExpression((string)$schedule);
|
||||
$previousRunDate = $schedule->getPreviousRunDate(Carbon::instance($from));
|
||||
$previousRunDate->setTimezone(new \DateTimeZone('UTC'));
|
||||
$previousRunDate = $previousRunDate->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
$previousRunDate = false;
|
||||
}
|
||||
return $previousRunDate;
|
||||
}
|
||||
|
||||
public function getScheduledTimeWithDelay($afterTimeType, $afterTimeNumber): Carbon {
|
||||
$currentTime = Carbon::now()->millisecond(0);
|
||||
switch ($afterTimeType) {
|
||||
case 'minutes':
|
||||
$currentTime->addMinutes($afterTimeNumber);
|
||||
break;
|
||||
case 'hours':
|
||||
$currentTime->addHours($afterTimeNumber);
|
||||
break;
|
||||
case 'days':
|
||||
$currentTime->addDays($afterTimeNumber);
|
||||
break;
|
||||
case 'weeks':
|
||||
$currentTime->addWeeks($afterTimeNumber);
|
||||
break;
|
||||
}
|
||||
$maxScheduledTime = Carbon::createFromFormat('Y-m-d H:i:s', self::MYSQL_TIMESTAMP_MAX);
|
||||
if ($maxScheduledTime && $currentTime > $maxScheduledTime) {
|
||||
return $maxScheduledTime;
|
||||
}
|
||||
return $currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return NewsletterEntity[]
|
||||
*/
|
||||
public function getNewsletters(string $type, ?string $group = null): array {
|
||||
return $this->newslettersRepository->findActiveByTypeAndGroup($type, $group);
|
||||
}
|
||||
|
||||
public function formatDatetimeString($datetimeString) {
|
||||
return Carbon::parse($datetimeString)->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|false
|
||||
*/
|
||||
public function getNextRunDateTime($schedule) {
|
||||
// User enters time in WordPress site timezone, but we need to calculate it in UTC before we save it to DB
|
||||
// 1) As the initial time we use time in site timezone via current_datetime
|
||||
// 2) We use CronExpression to calculate next run (still in site's timezone)
|
||||
// 3) We convert the calculated time to UTC
|
||||
//$fromTimestamp = $this->wp->currentTime('timestamp', false);
|
||||
$from = $this->wp->currentDatetime();
|
||||
try {
|
||||
$schedule = new \Cron\CronExpression((string)$schedule);
|
||||
$nextRunDate = $schedule->getNextRunDate(Carbon::instance($from));
|
||||
$nextRunDate->setTimezone(new \DateTimeZone('UTC'));
|
||||
// Work around CronExpression transforming Carbon into DateTime
|
||||
if (!$nextRunDate instanceof Carbon) {
|
||||
$nextRunDate = new Carbon($nextRunDate);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$nextRunDate = false;
|
||||
}
|
||||
return $nextRunDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Scheduler;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterOptionFieldEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SegmentEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Sending\ScheduledTasksRepository;
|
||||
use MailPoet\Segments\SegmentsRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
class WelcomeScheduler {
|
||||
|
||||
const WORDPRESS_ALL_ROLES = 'mailpoet_all';
|
||||
|
||||
/** @var EntityManager */
|
||||
private $entityManager;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SegmentsRepository */
|
||||
private $segmentsRepository;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var ScheduledTasksRepository */
|
||||
private $scheduledTasksRepository;
|
||||
|
||||
/** @var Scheduler */
|
||||
private $scheduler;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SegmentsRepository $segmentsRepository,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
ScheduledTasksRepository $scheduledTasksRepository,
|
||||
Scheduler $scheduler
|
||||
) {
|
||||
$this->entityManager = $entityManager;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->segmentsRepository = $segmentsRepository;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->scheduledTasksRepository = $scheduledTasksRepository;
|
||||
$this->scheduler = $scheduler;
|
||||
}
|
||||
|
||||
public function scheduleSubscriberWelcomeNotification($subscriberId, $segments): void {
|
||||
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_WELCOME]);
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if (
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'segment' &&
|
||||
in_array($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEGMENT), $segments)
|
||||
) {
|
||||
$this->createWelcomeNotificationSendingTask($newsletter, $subscriberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function scheduleWPUserWelcomeNotification(
|
||||
$subscriberId,
|
||||
$wpUser,
|
||||
$oldUserData = false
|
||||
) {
|
||||
$newsletters = $this->newslettersRepository->findActiveByTypes([NewsletterEntity::TYPE_WELCOME]);
|
||||
if (empty($newsletters)) return false;
|
||||
foreach ($newsletters as $newsletter) {
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) !== 'user') {
|
||||
continue;
|
||||
}
|
||||
$newsletterRole = $newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_ROLE);
|
||||
if (!empty($oldUserData['roles'])) {
|
||||
// do not schedule welcome newsletter if roles have not changed
|
||||
$oldRole = $oldUserData['roles'];
|
||||
$newRole = $wpUser['roles'];
|
||||
if (
|
||||
$newsletterRole === self::WORDPRESS_ALL_ROLES ||
|
||||
!array_diff($newRole, $oldRole)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (
|
||||
$newsletterRole === self::WORDPRESS_ALL_ROLES ||
|
||||
in_array($newsletterRole, $wpUser['roles'])
|
||||
) {
|
||||
$this->createWelcomeNotificationSendingTask($newsletter, $subscriberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function createWelcomeNotificationSendingTask(NewsletterEntity $newsletter, $subscriberId): void {
|
||||
$subscriber = $this->subscribersRepository->findOneById($subscriberId);
|
||||
if (!($subscriber instanceof SubscriberEntity) || $subscriber->getDeletedAt() !== null) {
|
||||
return;
|
||||
}
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'segment') {
|
||||
$segment = $this->segmentsRepository->findOneById((int)$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_SEGMENT));
|
||||
if ((!$segment instanceof SegmentEntity) || $segment->getDeletedAt() !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ($newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_EVENT) === 'user') {
|
||||
$segment = $this->segmentsRepository->getWPUsersSegment();
|
||||
if ((!$segment instanceof SegmentEntity) || $segment->getDeletedAt() !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$previouslyScheduledNotification = $this->scheduledTasksRepository->findByNewsletterAndSubscriberId($newsletter, $subscriberId);
|
||||
if (!empty($previouslyScheduledNotification)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// task
|
||||
$task = new ScheduledTaskEntity();
|
||||
$task->setType(SendingQueue::TASK_TYPE);
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
$task->setPriority(ScheduledTaskEntity::PRIORITY_HIGH);
|
||||
$task->setScheduledAt($this->scheduler->getScheduledTimeWithDelay(
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_TYPE),
|
||||
$newsletter->getOptionValue(NewsletterOptionFieldEntity::NAME_AFTER_TIME_NUMBER)
|
||||
));
|
||||
$this->entityManager->persist($task);
|
||||
|
||||
// queue
|
||||
$queue = new SendingQueueEntity();
|
||||
$queue->setTask($task);
|
||||
$queue->setNewsletter($newsletter);
|
||||
// Because we changed the way how to updateCounts after sending we need to set initial counts
|
||||
$queue->setCountTotal(1);
|
||||
$queue->setCountToProcess(1);
|
||||
|
||||
$task->setSendingQueue($queue);
|
||||
$this->entityManager->persist($queue);
|
||||
|
||||
// task subscriber
|
||||
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
|
||||
$task->getSubscribers()->add($taskSubscriber);
|
||||
$this->entityManager->persist($taskSubscriber);
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Segment;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\NewsletterSegmentEntity;
|
||||
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterSegmentEntity>
|
||||
*/
|
||||
class NewsletterSegmentRepository extends Repository {
|
||||
|
||||
/** @var NewsletterOptionsRepository */
|
||||
private $newsletterOptionsRepository;
|
||||
|
||||
public function __construct(
|
||||
NewsletterOptionsRepository $newsletterOptionsRepository,
|
||||
EntityManager $entityManager
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterSegmentEntity::class;
|
||||
}
|
||||
|
||||
public function getSubjectsOfActivelyUsedEmailsForSegments(array $segmentIds): array {
|
||||
$nameMap = [];
|
||||
// Welcome emails
|
||||
foreach ($this->newsletterOptionsRepository->findWelcomeNotificationsForSegments($segmentIds) as $option) {
|
||||
$newsletter = $option->getNewsletter();
|
||||
if (!$newsletter instanceof NewsletterEntity) continue;
|
||||
$nameMap[(string)$option->getValue()][] = $newsletter->getSubject();
|
||||
}
|
||||
// Automatic emails
|
||||
foreach ($this->newsletterOptionsRepository->findAutomaticEmailsForSegments($segmentIds) as $option) {
|
||||
$newsletter = $option->getNewsletter();
|
||||
if (!$newsletter instanceof NewsletterEntity) continue;
|
||||
$nameMap[(string)$option->getValue()][] = $newsletter->getSubject();
|
||||
}
|
||||
|
||||
$otherNewsletters = $this->doctrineRepository->createQueryBuilder('ns')
|
||||
->join('ns.newsletter', 'n')
|
||||
->leftJoin('n.queues', 'q')
|
||||
->leftJoin('q.task', 't')
|
||||
->select('IDENTITY(ns.segment) AS segment_id, n.subject')
|
||||
->where('(n.type = (:postNotification) OR n.status = :scheduled OR (t.id IS NOT NULL AND t.status IS NULL))')
|
||||
->andWhere('ns.segment IN (:segmentIds)')
|
||||
->setParameter('postNotification', NewsletterEntity::TYPE_NOTIFICATION)
|
||||
->setParameter('segmentIds', $segmentIds)
|
||||
->setParameter('scheduled', NewsletterEntity::STATUS_SCHEDULED)
|
||||
->addGroupBy('ns.segment, q.id, t.id')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
foreach ($otherNewsletters as $result) {
|
||||
if (isset($nameMap[(string)$result['segment_id']]) && in_array($result['subject'], $nameMap[(string)$result['segment_id']])) {
|
||||
continue;
|
||||
}
|
||||
$nameMap[(string)$result['segment_id']][] = $result['subject'];
|
||||
}
|
||||
return $nameMap;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(NewsletterSegmentEntity::class, 's')
|
||||
->where('s.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 (NewsletterSegmentEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Sending;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Listing\ListingDefinition;
|
||||
use MailPoet\Listing\ListingRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class ScheduledTaskSubscribersListingRepository extends ListingRepository {
|
||||
public function getGroups(ListingDefinition $definition): array {
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$this->applyFromClause($queryBuilder);
|
||||
$this->applyParameters($queryBuilder, $definition->getParameters());
|
||||
|
||||
// total count
|
||||
$countQueryBuilder = clone $queryBuilder;
|
||||
$countQueryBuilder->select('COUNT(sts.subscriber) AS subscriberCount');
|
||||
$totalCount = intval($countQueryBuilder->getQuery()->getSingleScalarResult());
|
||||
|
||||
// Sent count
|
||||
$sentCountQuery = clone $queryBuilder;
|
||||
$sentCountQuery->select('COUNT(sts.subscriber) AS subscriberCount');
|
||||
$sentCountQuery->andWhere('sts.processed = :processedStatus');
|
||||
$sentCountQuery->andWhere('sts.failed = :failedStatus');
|
||||
$sentCountQuery->setParameter('processedStatus', ScheduledTaskSubscriberEntity::STATUS_PROCESSED);
|
||||
$sentCountQuery->setParameter('failedStatus', ScheduledTaskSubscriberEntity::FAIL_STATUS_OK);
|
||||
$sentCount = intval($sentCountQuery->getQuery()->getSingleScalarResult());
|
||||
|
||||
// Failed count
|
||||
$failedCountQuery = clone $queryBuilder;
|
||||
$failedCountQuery->select('COUNT(sts.subscriber) AS subscriberCount');
|
||||
$failedCountQuery->andWhere('sts.failed = :failedStatus');
|
||||
$failedCountQuery->setParameter('failedStatus', ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED);
|
||||
$failedCount = intval($failedCountQuery->getQuery()->getSingleScalarResult());
|
||||
|
||||
// Unprocessed count
|
||||
$unprocessedCountQuery = clone $queryBuilder;
|
||||
$unprocessedCountQuery->select('COUNT(sts.subscriber) AS subscriberCount');
|
||||
$unprocessedCountQuery->andWhere('sts.processed = :processedStatus');
|
||||
$unprocessedCountQuery->setParameter('processedStatus', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED);
|
||||
$unprocessedCount = intval($unprocessedCountQuery->getQuery()->getSingleScalarResult());
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => 'all',
|
||||
'label' => __('All', 'mailpoet'),
|
||||
'count' => $totalCount,
|
||||
],
|
||||
[
|
||||
'name' => ScheduledTaskSubscriberEntity::SENDING_STATUS_SENT,
|
||||
'label' => __('Sent', 'mailpoet'),
|
||||
'count' => $sentCount,
|
||||
],
|
||||
[
|
||||
'name' => ScheduledTaskSubscriberEntity::SENDING_STATUS_FAILED,
|
||||
'label' => __('Failed', 'mailpoet'),
|
||||
'count' => $failedCount,
|
||||
],
|
||||
[
|
||||
'name' => ScheduledTaskSubscriberEntity::SENDING_STATUS_UNPROCESSED,
|
||||
'label' => __('Unprocessed', 'mailpoet'),
|
||||
'count' => $unprocessedCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function applySelectClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->select("PARTIAL sts.{task,subscriber,processed,failed,error,createdAt,updatedAt}, PARTIAL s.{id, email, firstName, lastName}");
|
||||
}
|
||||
|
||||
protected function applyFromClause(QueryBuilder $queryBuilder) {
|
||||
$queryBuilder->from(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->leftJoin('sts.subscriber', 's');
|
||||
}
|
||||
|
||||
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
|
||||
if ($group === ScheduledTaskSubscriberEntity::SENDING_STATUS_SENT) {
|
||||
$queryBuilder->andWhere('sts.processed = :processedStatus');
|
||||
$queryBuilder->andWhere('sts.failed = :failedStatus');
|
||||
$queryBuilder->setParameter('processedStatus', ScheduledTaskSubscriberEntity::STATUS_PROCESSED);
|
||||
$queryBuilder->setParameter('failedStatus', ScheduledTaskSubscriberEntity::FAIL_STATUS_OK);
|
||||
} elseif ($group === ScheduledTaskSubscriberEntity::SENDING_STATUS_FAILED) {
|
||||
$queryBuilder->andWhere('sts.failed = :failedStatus');
|
||||
$queryBuilder->setParameter('failedStatus', ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED);
|
||||
} elseif ($group === ScheduledTaskSubscriberEntity::SENDING_STATUS_UNPROCESSED) {
|
||||
$queryBuilder->andWhere('sts.processed = :processedStatus');
|
||||
$queryBuilder->setParameter('processedStatus', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
|
||||
// ScheduledTaskSubscriber doesn't have id column so the default fallback value 'id'
|
||||
// generated in MailPoet\Listing\Handler needs to be changed to something else
|
||||
if ($sortBy === 'id') {
|
||||
$sortBy = 'sts.subscriber';
|
||||
} elseif ($sortBy === 'subscriberId') { // Ordering by subscriberId is mapped to email for consistency with Subscriber listing
|
||||
$sortBy = 's.email';
|
||||
} else {
|
||||
$sortBy = "sts.{$sortBy}";
|
||||
}
|
||||
$queryBuilder->addOrderBy($sortBy, $sortOrder);
|
||||
}
|
||||
|
||||
protected function applySearch(QueryBuilder $queryBuilder, string $search, array $parameters = []) {
|
||||
$search = Helpers::escapeSearch($search);
|
||||
$queryBuilder
|
||||
->andWhere('s.email LIKE :search or s.firstName LIKE :search or s.lastName LIKE :search')
|
||||
->setParameter('search', "%$search%");
|
||||
}
|
||||
|
||||
protected function applyFilters(QueryBuilder $queryBuilder, array $filters) {
|
||||
// the parent class requires this method, but scheduled task subscribers listing doesn't currently support this feature.
|
||||
}
|
||||
|
||||
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
|
||||
if (isset($parameters['task_ids']) && !empty($parameters['task_ids'])) {
|
||||
$queryBuilder->andWhere('sts.task IN (:taskIds)')
|
||||
->setParameter('taskIds', $parameters['task_ids']);
|
||||
}
|
||||
}
|
||||
|
||||
public function getCount(ListingDefinition $definition): int {
|
||||
$queryBuilder = clone $this->queryBuilder;
|
||||
$this->applyFromClause($queryBuilder);
|
||||
$this->applyConstraints($queryBuilder, $definition);
|
||||
$queryBuilder->select("COUNT(DISTINCT sts.subscriber)");
|
||||
return intval($queryBuilder->getQuery()->getSingleScalarResult());
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Sending;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\InvalidStateException;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* @extends Repository<ScheduledTaskSubscriberEntity>
|
||||
*/
|
||||
class ScheduledTaskSubscribersRepository extends Repository {
|
||||
protected function getEntityClassName() {
|
||||
return ScheduledTaskSubscriberEntity::class;
|
||||
}
|
||||
|
||||
public function isSubscriberProcessed(ScheduledTaskEntity $task, SubscriberEntity $subscriber): bool {
|
||||
$scheduledTaskSubscriber = $this
|
||||
->doctrineRepository
|
||||
->createQueryBuilder('sts')
|
||||
->andWhere('sts.processed = 1')
|
||||
->andWhere('sts.task = :task')
|
||||
->andWhere('sts.subscriber = :subscriber')
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->setParameter('task', $task)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
return !empty($scheduledTaskSubscriber);
|
||||
}
|
||||
|
||||
public function createOrUpdate(array $data): ?ScheduledTaskSubscriberEntity {
|
||||
if (!isset($data['task_id'], $data['subscriber_id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$taskSubscriber = $this->findOneBy(['task' => $data['task_id'], 'subscriber' => $data['subscriber_id']]);
|
||||
if (!$taskSubscriber) {
|
||||
$task = $this->entityManager->getReference(ScheduledTaskEntity::class, (int)$data['task_id']);
|
||||
$subscriber = $this->entityManager->getReference(SubscriberEntity::class, (int)$data['subscriber_id']);
|
||||
if (!$task || !$subscriber) throw new InvalidStateException();
|
||||
|
||||
$taskSubscriber = new ScheduledTaskSubscriberEntity($task, $subscriber);
|
||||
$this->persist($taskSubscriber);
|
||||
}
|
||||
|
||||
$processed = $data['processed'] ?? ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED;
|
||||
$failed = $data['failed'] ?? ScheduledTaskSubscriberEntity::FAIL_STATUS_OK;
|
||||
|
||||
$taskSubscriber->setProcessed($processed);
|
||||
$taskSubscriber->setFailed($failed);
|
||||
$this->flush();
|
||||
return $taskSubscriber;
|
||||
}
|
||||
|
||||
public function countSubscriberIdsBatchForTask(int $taskId, int $lastProcessedSubscriberId): int {
|
||||
$queryBuilder = $this->getBaseSubscribersIdsBatchForTaskQuery($taskId, $lastProcessedSubscriberId);
|
||||
$countSubscribers = $queryBuilder
|
||||
->select('count(sts.subscriber)')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return intval($countSubscribers);
|
||||
}
|
||||
|
||||
public function getSubscriberIdsBatchForTask(int $taskId, int $lastProcessedSubscriberId, int $limit): array {
|
||||
$queryBuilder = $this->getBaseSubscribersIdsBatchForTaskQuery($taskId, $lastProcessedSubscriberId);
|
||||
$subscribersIds = $queryBuilder
|
||||
->select('IDENTITY(sts.subscriber) AS subscriber_id')
|
||||
->orderBy('sts.subscriber', 'asc')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
|
||||
return $subscribersIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $subscriberIds
|
||||
*/
|
||||
public function updateProcessedSubscribers(ScheduledTaskEntity $task, array $subscriberIds): void {
|
||||
if ($subscriberIds) {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->set('sts.processed', ScheduledTaskSubscriberEntity::STATUS_PROCESSED)
|
||||
->where('sts.subscriber IN (:subscriberIds)')
|
||||
->andWhere('sts.task = :task')
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
|
||||
->setParameter('task', $task)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// update was done via DQL, make sure the entities are also refreshed in the entity manager
|
||||
$this->refreshAll(function (ScheduledTaskSubscriberEntity $entity) use ($task, $subscriberIds) {
|
||||
return $entity->getTask() === $task && in_array($entity->getSubscriberId(), $subscriberIds, true);
|
||||
});
|
||||
}
|
||||
|
||||
$this->checkCompleted($task);
|
||||
}
|
||||
|
||||
public function createSubscribersForBounceWorker(ScheduledTaskEntity $scheduledTaskEntity): void {
|
||||
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
|
||||
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
|
||||
|
||||
$stmt = $this->entityManager->getConnection()->prepare("
|
||||
INSERT IGNORE INTO " . $scheduledTaskSubscribersTable . "
|
||||
(task_id, subscriber_id, processed)
|
||||
SELECT :taskId AS task_id, s.`id` AS subscriber_id, :unprocessed AS processed
|
||||
FROM " . $subscribersTable . " s
|
||||
WHERE s.`deleted_at` IS NULL
|
||||
AND s.`status` IN (:subscribed, :unconfirmed)
|
||||
");
|
||||
$stmt->bindValue('taskId', $scheduledTaskEntity->getId());
|
||||
$stmt->bindValue('unprocessed', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED);
|
||||
$stmt->bindValue('subscribed', SubscriberEntity::STATUS_SUBSCRIBED);
|
||||
$stmt->bindValue('unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED);
|
||||
$stmt->executeQuery();
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByTaskIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->where('sts.task IN (:taskIds)')
|
||||
->setParameter('taskIds', $ids)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (ScheduledTaskSubscriberEntity $entity) use ($ids) {
|
||||
$task = $entity->getTask();
|
||||
return $task && in_array($task->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteByScheduledTask(ScheduledTaskEntity $scheduledTask): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->where('sts.task = :task')
|
||||
->setParameter('task', $scheduledTask)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (ScheduledTaskSubscriberEntity $entity) use ($scheduledTask) {
|
||||
return $entity->getTask() === $scheduledTask;
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteByScheduledTaskAndSubscriberIds(ScheduledTaskEntity $scheduledTask, array $subscriberIds): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->where('sts.task = :task')
|
||||
->andWhere('sts.subscriber IN (:subscriberIds)')
|
||||
->setParameter('task', $scheduledTask)
|
||||
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (ScheduledTaskSubscriberEntity $entity) use ($scheduledTask, $subscriberIds) {
|
||||
return $entity->getTask() === $scheduledTask && in_array($entity->getSubscriberId(), $subscriberIds, true);
|
||||
});
|
||||
|
||||
$this->checkCompleted($scheduledTask);
|
||||
}
|
||||
|
||||
public function setSubscribers(ScheduledTaskEntity $task, array $subscriberIds): void {
|
||||
$this->deleteByScheduledTask($task);
|
||||
|
||||
foreach ($subscriberIds as $subscriberId) {
|
||||
$this->createOrUpdate([
|
||||
'task_id' => $task->getId(),
|
||||
'subscriber_id' => $subscriberId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveError(ScheduledTaskEntity $scheduledTask, int $subscriberId, string $errorMessage): void {
|
||||
$scheduledTaskSubscriber = $this->findOneBy(['task' => $scheduledTask, 'subscriber' => $subscriberId]);
|
||||
|
||||
if ($scheduledTaskSubscriber instanceof ScheduledTaskSubscriberEntity) {
|
||||
$scheduledTaskSubscriber->setFailed(ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED);
|
||||
$scheduledTaskSubscriber->setProcessed(ScheduledTaskSubscriberEntity::STATUS_PROCESSED);
|
||||
$scheduledTaskSubscriber->setError($errorMessage);
|
||||
$this->persist($scheduledTaskSubscriber);
|
||||
$this->flush();
|
||||
|
||||
$this->checkCompleted($scheduledTask);
|
||||
}
|
||||
}
|
||||
|
||||
public function countProcessed(ScheduledTaskEntity $scheduledTaskEntity): int {
|
||||
return $this->countBy(['task' => $scheduledTaskEntity, 'processed' => ScheduledTaskSubscriberEntity::STATUS_PROCESSED]);
|
||||
}
|
||||
|
||||
public function countUnprocessed(ScheduledTaskEntity $scheduledTaskEntity): int {
|
||||
return $this->countBy(['task' => $scheduledTaskEntity, 'processed' => ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED]);
|
||||
}
|
||||
|
||||
private function checkCompleted(ScheduledTaskEntity $task): void {
|
||||
$count = $this->countUnprocessed($task);
|
||||
if ($count === 0) {
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
|
||||
$task->setProcessedAt(Carbon::now()->millisecond(0));
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
private function getBaseSubscribersIdsBatchForTaskQuery(int $taskId, int $lastProcessedSubscriberId): QueryBuilder {
|
||||
return $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->from(ScheduledTaskSubscriberEntity::class, 'sts')
|
||||
->andWhere('sts.task = :taskId')
|
||||
->andWhere('sts.subscriber > :lastProcessedSubscriberId')
|
||||
->andWhere('sts.processed = :status')
|
||||
->setParameter('taskId', $taskId)
|
||||
->setParameter('lastProcessedSubscriberId', $lastProcessedSubscriberId)
|
||||
->setParameter('status', ScheduledTaskSubscriberEntity::STATUS_UNPROCESSED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Sending;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Cron\Workers\SendingQueue\SendingQueue;
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
|
||||
/**
|
||||
* @extends Repository<ScheduledTaskEntity>
|
||||
*/
|
||||
class ScheduledTasksRepository extends Repository {
|
||||
const TASK_BATCH_SIZE = 20;
|
||||
const CANCELLABLE_STATUSES = [
|
||||
ScheduledTaskEntity::STATUS_SCHEDULED,
|
||||
ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING,
|
||||
null,
|
||||
];
|
||||
|
||||
private SendingQueuesRepository $sendingQueuesRepository;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
SendingQueuesRepository $sendingQueuesRepository
|
||||
) {
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
parent::__construct($entityManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function findByNewsletterAndStatus(NewsletterEntity $newsletter, string $status): array {
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->andWhere('st.status = :status')
|
||||
->andWhere('sq.newsletter = :newsletter')
|
||||
->setParameter('status', $status)
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
*/
|
||||
public function findOneByNewsletter(NewsletterEntity $newsletter): ?ScheduledTaskEntity {
|
||||
$scheduledTask = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->andWhere('sq.newsletter = :newsletter')
|
||||
->orderBy('sq.updatedAt', 'desc')
|
||||
->setMaxResults(1)
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
// for phpstan because it detects mixed instead of entity
|
||||
return ($scheduledTask instanceof ScheduledTaskEntity) ? $scheduledTask : null;
|
||||
}
|
||||
|
||||
public function findOneBySendingQueue(SendingQueueEntity $sendingQueue): ?ScheduledTaskEntity {
|
||||
$scheduledTask = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->andWhere('sq.id = :sendingQueue')
|
||||
->setMaxResults(1)
|
||||
->setParameter('sendingQueue', $sendingQueue)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
// for phpstan because it detects mixed instead of entity
|
||||
return ($scheduledTask instanceof ScheduledTaskEntity) ? $scheduledTask : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function findByScheduledAndRunningForNewsletter(NewsletterEntity $newsletter): array {
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->andWhere('st.status = :status OR st.status IS NULL')
|
||||
->andWhere('sq.newsletter = :newsletter')
|
||||
->setParameter('status', NewsletterEntity::STATUS_SCHEDULED)
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function findByNewsletterAndSubscriberId(NewsletterEntity $newsletter, int $subscriberId): array {
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->join(ScheduledTaskSubscriberEntity::class, 'sts', Join::WITH, 'st = sts.task')
|
||||
->andWhere('sq.newsletter = :newsletter')
|
||||
->andWhere('sts.subscriber = :subscriber')
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->setParameter('subscriber', $subscriberId)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function findOneScheduledByNewsletterAndSubscriber(NewsletterEntity $newsletter, SubscriberEntity $subscriber): ?ScheduledTaskEntity {
|
||||
$scheduledTask = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->join(SendingQueueEntity::class, 'sq', Join::WITH, 'st = sq.task')
|
||||
->join(ScheduledTaskSubscriberEntity::class, 'sts', Join::WITH, 'st = sts.task')
|
||||
->andWhere('st.status = :status')
|
||||
->andWhere('sq.newsletter = :newsletter')
|
||||
->andWhere('sts.subscriber = :subscriber')
|
||||
->setMaxResults(1)
|
||||
->setParameter('status', ScheduledTaskEntity::STATUS_SCHEDULED)
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
// for phpstan because it detects mixed instead of entity
|
||||
return ($scheduledTask instanceof ScheduledTaskEntity) ? $scheduledTask : null;
|
||||
}
|
||||
|
||||
public function findScheduledOrRunningTask(?string $type): ?ScheduledTaskEntity {
|
||||
$queryBuilder = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->where('((st.status = :scheduledStatus) OR (st.status is NULL))')
|
||||
->andWhere('st.deletedAt IS NULL')
|
||||
->setParameter('scheduledStatus', ScheduledTaskEntity::STATUS_SCHEDULED)
|
||||
->setMaxResults(1)
|
||||
->orderBy('st.scheduledAt', 'DESC');
|
||||
if (!empty($type)) {
|
||||
$queryBuilder
|
||||
->andWhere('st.type = :type')
|
||||
->setParameter('type', $type);
|
||||
}
|
||||
return $queryBuilder->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findScheduledTask(?string $type): ?ScheduledTaskEntity {
|
||||
$queryBuilder = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->where('st.status = :scheduledStatus')
|
||||
->andWhere('st.deletedAt IS NULL')
|
||||
->setParameter('scheduledStatus', ScheduledTaskEntity::STATUS_SCHEDULED)
|
||||
->setMaxResults(1)
|
||||
->orderBy('st.scheduledAt', 'DESC');
|
||||
if (!empty($type)) {
|
||||
$queryBuilder
|
||||
->andWhere('st.type = :type')
|
||||
->setParameter('type', $type);
|
||||
}
|
||||
return $queryBuilder->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findPreviousTask(ScheduledTaskEntity $task): ?ScheduledTaskEntity {
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->where('st.type = :type')
|
||||
->setParameter('type', $task->getType())
|
||||
->andWhere('st.createdAt < :created')
|
||||
->setParameter('created', $task->getCreatedAt())
|
||||
->orderBy('st.scheduledAt', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findDueByType($type, $limit = null) {
|
||||
return $this->findByTypeAndStatus($type, ScheduledTaskEntity::STATUS_SCHEDULED, $limit);
|
||||
}
|
||||
|
||||
public function findRunningByType($type, $limit = null) {
|
||||
return $this->findByTypeAndStatus($type, null, $limit);
|
||||
}
|
||||
|
||||
public function findCompletedByType($type, $limit = null) {
|
||||
return $this->findByTypeAndStatus($type, ScheduledTaskEntity::STATUS_COMPLETED, $limit);
|
||||
}
|
||||
|
||||
public function findFutureScheduledByType($type, $limit = null) {
|
||||
return $this->findByTypeAndStatus($type, ScheduledTaskEntity::STATUS_SCHEDULED, $limit, true);
|
||||
}
|
||||
|
||||
public function getCountsPerStatus(string $type = 'sending') {
|
||||
$stats = [
|
||||
ScheduledTaskEntity::STATUS_COMPLETED => 0,
|
||||
ScheduledTaskEntity::STATUS_PAUSED => 0,
|
||||
ScheduledTaskEntity::STATUS_SCHEDULED => 0,
|
||||
ScheduledTaskEntity::STATUS_CANCELLED => 0,
|
||||
ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING => 0,
|
||||
];
|
||||
|
||||
$counts = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('COUNT(st.id) as value')
|
||||
->addSelect('st.status')
|
||||
->where('st.deletedAt IS NULL')
|
||||
->andWhere('st.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->addGroupBy('st.status')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
foreach ($counts as $count) {
|
||||
if ($count['status'] === null) {
|
||||
$stats[ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING] = (int)$count['value'];
|
||||
continue;
|
||||
}
|
||||
$stats[$count['status']] = (int)$count['value'];
|
||||
}
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $type
|
||||
* @param array $statuses
|
||||
* @param int $limit
|
||||
* @return array<ScheduledTaskEntity>
|
||||
*/
|
||||
public function getLatestTasks(
|
||||
$type = null,
|
||||
$statuses = [
|
||||
ScheduledTaskEntity::STATUS_COMPLETED,
|
||||
ScheduledTaskEntity::STATUS_CANCELLED,
|
||||
ScheduledTaskEntity::STATUS_SCHEDULED,
|
||||
ScheduledTaskEntity::STATUS_PAUSED,
|
||||
ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING,
|
||||
],
|
||||
$limit = self::TASK_BATCH_SIZE
|
||||
) {
|
||||
$result = [];
|
||||
foreach ($statuses as $status) {
|
||||
$tasksQuery = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->where('st.deletedAt IS NULL');
|
||||
|
||||
if ($status === ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING) {
|
||||
$tasksQuery = $tasksQuery->andWhere('st.status = :status OR st.status IS NULL');
|
||||
} else {
|
||||
$tasksQuery = $tasksQuery->andWhere('st.status = :status');
|
||||
}
|
||||
|
||||
if ($type) {
|
||||
$tasksQuery = $tasksQuery->andWhere('st.type = :type')
|
||||
->setParameter('type', $type);
|
||||
}
|
||||
|
||||
$tasks = $tasksQuery
|
||||
->setParameter('status', $status)
|
||||
->setMaxResults($limit)
|
||||
->orderBy('st.id', 'desc')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
$result = array_merge($result, $tasks);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function findRunningSendingTasks(?int $limit = null): array {
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join('st.sendingQueue', 'sq')
|
||||
->where('st.type = :type')
|
||||
->andWhere('st.status IS NULL')
|
||||
->andWhere('st.deletedAt IS NULL')
|
||||
->orderBy('st.priority', 'ASC')
|
||||
->addOrderBy('st.updatedAt', 'ASC')
|
||||
->setMaxResults($limit)
|
||||
->setParameter('type', SendingQueue::TASK_TYPE)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @return ScheduledTaskEntity[]
|
||||
* @throws \MailPoetVendor\Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
public function findByTypeAndSubscriber(string $type, SubscriberEntity $subscriber): array {
|
||||
$query = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join(ScheduledTaskSubscriberEntity::class, 'sts', Join::WITH, 'st = sts.task')
|
||||
->where('st.type = :type')
|
||||
->andWhere('sts.subscriber = :subscriber')
|
||||
->andWhere('st.deletedAt IS NULL')
|
||||
->andWhere('st.status = :status')
|
||||
->setParameter('type', $type)
|
||||
->setParameter('subscriber', $subscriber->getId())
|
||||
->setParameter('status', ScheduledTaskEntity::STATUS_SCHEDULED)
|
||||
->getQuery();
|
||||
$tasks = $query->getResult();
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
public function touchAllByIds(array $ids): void {
|
||||
$now = Carbon::now()->millisecond(0);
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(ScheduledTaskEntity::class, 'st')
|
||||
->set('st.updatedAt', ':updatedAt')
|
||||
->setParameter('updatedAt', $now)
|
||||
->where('st.id IN (:ids)')
|
||||
->setParameter('ids', $ids, ArrayParameterType::INTEGER)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// update was done via DQL, make sure the entities are also refreshed in the entity manager
|
||||
$this->refreshAll(function (ScheduledTaskEntity $entity) use ($ids) {
|
||||
return in_array($entity->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScheduledTaskEntity[]
|
||||
*/
|
||||
public function findScheduledSendingTasks(?int $limit = null): array {
|
||||
$now = Carbon::now()->millisecond(0);
|
||||
return $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->join('st.sendingQueue', 'sq')
|
||||
->where('st.deletedAt IS NULL')
|
||||
->andWhere('st.status = :status')
|
||||
->andWhere('st.scheduledAt <= :now')
|
||||
->andWhere('st.type = :type')
|
||||
->orderBy('st.updatedAt', 'ASC')
|
||||
->setMaxResults($limit)
|
||||
->setParameter('status', ScheduledTaskEntity::STATUS_SCHEDULED)
|
||||
->setParameter('now', $now)
|
||||
->setParameter('type', SendingQueue::TASK_TYPE)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function invalidateTask(ScheduledTaskEntity $task): void {
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_INVALID);
|
||||
$this->persist($task);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function cancelTask(ScheduledTaskEntity $task): void {
|
||||
if (!in_array($task->getStatus(), self::CANCELLABLE_STATUSES)) {
|
||||
throw new \Exception(__('Only scheduled and running tasks can be cancelled', 'mailpoet'), 400);
|
||||
}
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_CANCELLED);
|
||||
$task->setCancelledAt(Carbon::now()->millisecond(0));
|
||||
$this->persist($task);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function rescheduleTask(ScheduledTaskEntity $task): void {
|
||||
if ($task->getStatus() !== ScheduledTaskEntity::STATUS_CANCELLED) {
|
||||
throw new \Exception(__('Only cancelled tasks can be rescheduled', 'mailpoet'), 400);
|
||||
}
|
||||
if ($task->getScheduledAt() <= Carbon::now()->millisecond(0)) {
|
||||
$task->setStatus(ScheduledTaskEntity::VIRTUAL_STATUS_RUNNING);
|
||||
$queue = $task->getSendingQueue();
|
||||
if ($queue) {
|
||||
$this->sendingQueuesRepository->resume($queue);
|
||||
}
|
||||
} else {
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_SCHEDULED);
|
||||
}
|
||||
$task->setCancelledAt(null);
|
||||
$this->persist($task);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ScheduledTaskEntity::class, 't')
|
||||
->where('t.id 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 (ScheduledTaskEntity $entity) use ($ids) {
|
||||
return in_array($entity->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
|
||||
protected function findByTypeAndStatus($type, $status, $limit = null, $future = false) {
|
||||
$queryBuilder = $this->doctrineRepository->createQueryBuilder('st')
|
||||
->select('st')
|
||||
->where('st.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->andWhere('st.deletedAt IS NULL');
|
||||
|
||||
if (is_null($status)) {
|
||||
$queryBuilder->andWhere('st.status IS NULL');
|
||||
} else {
|
||||
$queryBuilder
|
||||
->andWhere('st.status = :status')
|
||||
->setParameter('status', $status);
|
||||
}
|
||||
|
||||
if ($future) {
|
||||
$queryBuilder->andWhere('st.scheduledAt > :now');
|
||||
} else {
|
||||
$queryBuilder->andWhere('st.scheduledAt <= :now');
|
||||
}
|
||||
|
||||
$now = Carbon::now()->millisecond(0);
|
||||
$queryBuilder->setParameter('now', $now);
|
||||
|
||||
if ($limit) {
|
||||
$queryBuilder->setMaxResults($limit);
|
||||
}
|
||||
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return ScheduledTaskEntity::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Sending;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\DynamicSegmentFilterEntity;
|
||||
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\Segments\DynamicSegments\FilterFactory;
|
||||
use MailPoetVendor\Carbon\Carbon;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @extends Repository<SendingQueueEntity>
|
||||
*/
|
||||
class SendingQueuesRepository extends Repository {
|
||||
/** @var ScheduledTaskSubscribersRepository */
|
||||
private $scheduledTaskSubscribersRepository;
|
||||
|
||||
/** @var FilterFactory */
|
||||
private $filterFactory;
|
||||
|
||||
/** @var LoggerFactory */
|
||||
private $loggerFactory;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
ScheduledTaskSubscribersRepository $scheduledTaskSubscribersRepository,
|
||||
FilterFactory $filterFactory,
|
||||
LoggerFactory $loggerFactory
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->scheduledTaskSubscribersRepository = $scheduledTaskSubscribersRepository;
|
||||
$this->filterFactory = $filterFactory;
|
||||
$this->loggerFactory = $loggerFactory;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return SendingQueueEntity::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @param string|null $status
|
||||
* @return SendingQueueEntity|null
|
||||
* @throws \MailPoetVendor\Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
public function findOneByNewsletterAndTaskStatus(NewsletterEntity $newsletter, $status): ?SendingQueueEntity {
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||
->select('s')
|
||||
->from(SendingQueueEntity::class, 's')
|
||||
->join('s.task', 't')
|
||||
->andWhere('s.newsletter = :newsletter')
|
||||
->setParameter('newsletter', $newsletter);
|
||||
|
||||
if (is_null($status)) {
|
||||
$queryBuilder->andWhere('t.status IS NULL');
|
||||
} else {
|
||||
$queryBuilder->andWhere('t.status = :status')
|
||||
->setParameter('status', $status);
|
||||
}
|
||||
|
||||
return $queryBuilder->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function countAllToProcessByNewsletter(NewsletterEntity $newsletter): int {
|
||||
return intval($this->entityManager->createQueryBuilder()
|
||||
->select('sum(s.countToProcess)')
|
||||
->from(SendingQueueEntity::class, 's')
|
||||
->andWhere('s.newsletter = :newsletter')
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->getQuery()
|
||||
->getSingleScalarResult());
|
||||
}
|
||||
|
||||
public function getTaskIdsByNewsletterId(int $newsletterId): array {
|
||||
$results = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(s.task) as task_id')
|
||||
->from(SendingQueueEntity::class, 's')
|
||||
->andWhere('s.newsletter = :newsletter')
|
||||
->setParameter('newsletter', $newsletterId)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
return array_map('intval', array_column($results, 'task_id'));
|
||||
}
|
||||
|
||||
public function isSubscriberProcessed(SendingQueueEntity $queue, SubscriberEntity $subscriber): bool {
|
||||
$task = $queue->getTask();
|
||||
if (is_null($task)) return false;
|
||||
return $this->scheduledTaskSubscribersRepository->isSubscriberProcessed($task, $subscriber);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SendingQueueEntity[]
|
||||
*/
|
||||
public function findAllForSubscriberSentBetween(
|
||||
SubscriberEntity $subscriber,
|
||||
?\DateTimeInterface $dateTo,
|
||||
?\DateTimeInterface $dateFrom
|
||||
): array {
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->select('s, n')
|
||||
->from(SendingQueueEntity::class, 's')
|
||||
->join('s.task', 't')
|
||||
->join('t.subscribers', 'tsub')
|
||||
->join('s.newsletter', 'n')
|
||||
->where('t.status = :status')
|
||||
->setParameter('status', ScheduledTaskEntity::STATUS_COMPLETED)
|
||||
->andWhere('t.type = :sendingType')
|
||||
->setParameter('sendingType', 'sending')
|
||||
->andWhere('tsub.subscriber = :subscriber')
|
||||
->setParameter('subscriber', $subscriber);
|
||||
if ($dateTo) {
|
||||
$qb->andWhere('t.updatedAt < :dateTo')
|
||||
->setParameter('dateTo', $dateTo);
|
||||
}
|
||||
if ($dateFrom) {
|
||||
$qb->andWhere('t.updatedAt > :dateFrom')
|
||||
->setParameter('dateFrom', $dateFrom);
|
||||
}
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function getCampaignAnalyticsQuery() {
|
||||
$sevenDaysAgo = Carbon::now()->subDays(7);
|
||||
$thirtyDaysAgo = Carbon::now()->subDays(30);
|
||||
$threeMonthsAgo = Carbon::now()->subMonths(3);
|
||||
|
||||
return $this->doctrineRepository->createQueryBuilder('q')
|
||||
->select('
|
||||
n.type as newsletterType,
|
||||
q.meta as sendingQueueMeta,
|
||||
CASE
|
||||
WHEN COUNT(s.id) > 0 THEN true
|
||||
ELSE false
|
||||
END as sentToSegment,
|
||||
CASE
|
||||
WHEN t.processedAt >= :sevenDaysAgo THEN true
|
||||
ELSE false
|
||||
END as sentLast7Days,
|
||||
CASE
|
||||
WHEN t.processedAt >= :thirtyDaysAgo THEN true
|
||||
ELSE false
|
||||
END as sentLast30Days,
|
||||
CASE
|
||||
WHEN t.processedAt >= :threeMonthsAgo THEN true
|
||||
ELSE false
|
||||
END as sentLast3Months')
|
||||
->join('q.task', 't')
|
||||
->leftJoin('q.newsletter', 'n')
|
||||
->leftJoin('n.newsletterSegments', 'ns')
|
||||
->leftJoin('ns.segment', 's', 'WITH', 's.type = :dynamicType')
|
||||
->andWhere('t.status = :taskStatus')
|
||||
->andWhere('t.processedAt >= :since')
|
||||
->setParameter('sevenDaysAgo', $sevenDaysAgo)
|
||||
->setParameter('thirtyDaysAgo', $thirtyDaysAgo)
|
||||
->setParameter('threeMonthsAgo', $threeMonthsAgo)
|
||||
->setParameter('dynamicType', SegmentEntity::TYPE_DYNAMIC)
|
||||
->setParameter('taskStatus', ScheduledTaskEntity::STATUS_COMPLETED)
|
||||
->setParameter('since', $threeMonthsAgo)
|
||||
->groupBy('q.id')
|
||||
->getQuery();
|
||||
}
|
||||
|
||||
public function pause(SendingQueueEntity $queue): void {
|
||||
if ($queue->getCountProcessed() !== $queue->getCountTotal()) {
|
||||
$task = $queue->getTask();
|
||||
if ($task instanceof ScheduledTaskEntity) {
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_PAUSED);
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resume(SendingQueueEntity $queue): void {
|
||||
$task = $queue->getTask();
|
||||
if (!$task instanceof ScheduledTaskEntity) return;
|
||||
|
||||
if ($queue->getCountProcessed() === $queue->getCountTotal()) {
|
||||
$processedAt = Carbon::now()->millisecond(0);
|
||||
$task->setProcessedAt($processedAt);
|
||||
$task->setStatus(ScheduledTaskEntity::STATUS_COMPLETED);
|
||||
// Update also status of newsletter if necessary
|
||||
$newsletter = $queue->getNewsletter();
|
||||
if ($newsletter instanceof NewsletterEntity && $newsletter->canBeSetSent()) {
|
||||
$newsletter->setStatus(NewsletterEntity::STATUS_SENT);
|
||||
}
|
||||
$this->flush();
|
||||
} else {
|
||||
$newsletter = $queue->getNewsletter();
|
||||
if (!$newsletter instanceof NewsletterEntity) return;
|
||||
if ($newsletter->getStatus() === NewsletterEntity::STATUS_CORRUPT) { // force a re-render
|
||||
$queue->setNewsletterRenderedBody(null);
|
||||
$this->persist($queue);
|
||||
}
|
||||
$newsletter->setStatus($newsletter->canBeSetActive() ? NewsletterEntity::STATUS_ACTIVE : NewsletterEntity::STATUS_SENDING);
|
||||
$task->setStatus(null);
|
||||
$this->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteByTask(ScheduledTaskEntity $scheduledTask): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(SendingQueueEntity::class, 'sq')
|
||||
->where('sq.task = :task')
|
||||
->setParameter('task', $scheduledTask)
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// delete was done via DQL, make sure the entities are also detached from the entity manager
|
||||
$this->detachAll(function (SendingQueueEntity $entity) use ($scheduledTask) {
|
||||
return $entity->getTask() === $scheduledTask;
|
||||
});
|
||||
}
|
||||
|
||||
public function saveCampaignId(SendingQueueEntity $queue, string $campaignId): void {
|
||||
$meta = $queue->getMeta();
|
||||
if (!is_array($meta)) {
|
||||
$meta = [];
|
||||
}
|
||||
$meta['campaignId'] = $campaignId;
|
||||
$queue->setMeta($meta);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function saveFilterSegmentMeta(SendingQueueEntity $queue, SegmentEntity $filterSegmentEntity): void {
|
||||
$meta = $queue->getMeta() ?? [];
|
||||
$meta['filterSegment'] = [
|
||||
'id' => $filterSegmentEntity->getId(),
|
||||
'name' => $filterSegmentEntity->getName(),
|
||||
'updatedAt' => $filterSegmentEntity->getUpdatedAt(),
|
||||
'filters' => array_map(function(DynamicSegmentFilterEntity $filterEntity) {
|
||||
$filter = $this->filterFactory->getFilterForFilterEntity($filterEntity);
|
||||
$data = $filterEntity->getFilterData();
|
||||
$filterData = [
|
||||
'filterType' => $data->getFilterType(),
|
||||
'action' => $data->getAction(),
|
||||
'data' => $filterEntity->getFilterData()->getData(),
|
||||
'lookupData' => [],
|
||||
];
|
||||
try {
|
||||
$filterData['lookupData'] = $filter->getLookupData($data);
|
||||
} catch (\Throwable $e) {
|
||||
$this->loggerFactory->getLogger(LoggerFactory::TOPIC_SEGMENTS)->error("Failed to save lookup data for filter {$filterEntity->getId()}: {$e->getMessage()}");
|
||||
}
|
||||
return $filterData;
|
||||
}, $filterSegmentEntity->getDynamicFilters()->toArray()),
|
||||
];
|
||||
$queue->setMeta($meta);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function updateCounts(SendingQueueEntity $queue, ?int $count = null): void {
|
||||
if ($count) {
|
||||
// increment/decrement counts based on known subscriber count, don't exceed the bounds
|
||||
$queue->setCountProcessed(min($queue->getCountProcessed() + $count, $queue->getCountTotal()));
|
||||
$queue->setCountToProcess(max($queue->getCountToProcess() - $count, 0));
|
||||
} else {
|
||||
// query DB to update counts, slower but more accurate, to be used if count isn't known
|
||||
$task = $queue->getTask();
|
||||
$processed = $task ? $this->scheduledTaskSubscribersRepository->countProcessed($task) : 0;
|
||||
$unprocessed = $task ? $this->scheduledTaskSubscribersRepository->countUnprocessed($task) : 0;
|
||||
$queue->setCountProcessed($processed);
|
||||
$queue->setCountToProcess($unprocessed);
|
||||
$queue->setCountTotal($processed + $unprocessed);
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function deleteByNewsletterIds(array $ids): void {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(SendingQueueEntity::class, 'q')
|
||||
->where('q.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 (SendingQueueEntity $entity) use ($ids) {
|
||||
$newsletter = $entity->getNewsletter();
|
||||
return $newsletter && in_array($newsletter->getId(), $ids, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
|
||||
interface CategoryInterface {
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Date implements CategoryInterface {
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string {
|
||||
$actionMapping = [
|
||||
'd' => 'd',
|
||||
'dordinal' => 'jS',
|
||||
'dtext' => 'l',
|
||||
'm' => 'm',
|
||||
'mtext' => 'F',
|
||||
'y' => 'Y',
|
||||
];
|
||||
$wp = new WPFunctions();
|
||||
$date = $wp->currentTime('timestamp');
|
||||
if (
|
||||
($newsletter instanceof NewsletterEntity)
|
||||
&& ($newsletter->getSentAt() instanceof \DateTimeInterface)
|
||||
&& ($newsletter->getStatus() === NewsletterEntity::STATUS_SENT)
|
||||
) {
|
||||
$date = $newsletter->getSentAt()->getTimestamp();
|
||||
}
|
||||
if (!empty($actionMapping[$shortcodeDetails['action']])) {
|
||||
return $wp->dateI18n($actionMapping[$shortcodeDetails['action']], $date);
|
||||
}
|
||||
return ($shortcodeDetails['action'] === 'custom' && $shortcodeDetails['action_argument'] === 'format') ?
|
||||
$wp->dateI18n($shortcodeDetails['action_argument_value'], $date) :
|
||||
null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Url as NewsletterUrl;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\Subscription\SubscriptionUrlFactory;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Link implements CategoryInterface {
|
||||
const CATEGORY_NAME = 'link';
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var NewsletterUrl */
|
||||
private $newsletterUrl;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
SettingsController $settings,
|
||||
NewsletterUrl $newsletterUrl,
|
||||
WPFunctions $wp,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->newsletterUrl = $newsletterUrl;
|
||||
$this->wp = $wp;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string {
|
||||
$subscriptionUrlFactory = SubscriptionUrlFactory::getInstance();
|
||||
|
||||
switch ($shortcodeDetails['action']) {
|
||||
case 'subscription_unsubscribe_url':
|
||||
return self::processUrl(
|
||||
$shortcodeDetails['action'],
|
||||
$subscriptionUrlFactory->getConfirmUnsubscribeUrl(
|
||||
$wpUserPreview ? null : $subscriber,
|
||||
$queue ? $queue->getId() : null
|
||||
),
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
|
||||
case 'subscription_instant_unsubscribe_url':
|
||||
return self::processUrl(
|
||||
$shortcodeDetails['action'],
|
||||
$subscriptionUrlFactory->getUnsubscribeUrl(
|
||||
$wpUserPreview ? null : $subscriber,
|
||||
$queue ? $queue->getId() : null
|
||||
),
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
|
||||
case 'subscription_manage_url':
|
||||
return self::processUrl(
|
||||
$shortcodeDetails['action'],
|
||||
$subscriptionUrlFactory->getManageUrl($wpUserPreview ? null : $subscriber),
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
|
||||
case 'newsletter_view_in_browser_url':
|
||||
$url = $this->newsletterUrl->getViewInBrowserUrl(
|
||||
$newsletter,
|
||||
$wpUserPreview ? null : $subscriber,
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
return self::processUrl($shortcodeDetails['action'], $url, $queue, $wpUserPreview);
|
||||
|
||||
case 'subscription_re_engage_url':
|
||||
$url = $subscriptionUrlFactory->getReEngagementUrl($wpUserPreview ? null : $subscriber);
|
||||
return self::processUrl($shortcodeDetails['action'], $url, $queue, $wpUserPreview);
|
||||
|
||||
default:
|
||||
$shortcode = self::getFullShortcode($shortcodeDetails['action']);
|
||||
$url = $this->wp->applyFilters(
|
||||
'mailpoet_newsletter_shortcode_link',
|
||||
$shortcode,
|
||||
$newsletter,
|
||||
$subscriber,
|
||||
$queue,
|
||||
$shortcodeDetails['arguments'],
|
||||
$wpUserPreview
|
||||
);
|
||||
|
||||
return ($url !== $shortcode) ?
|
||||
self::processUrl($shortcodeDetails['action'], $url, $queue, $wpUserPreview) :
|
||||
null;
|
||||
}
|
||||
}
|
||||
|
||||
public function processUrl($action, $url, ?SendingQueueEntity $queue, $wpUserPreview = false): string {
|
||||
if ($wpUserPreview) return $url;
|
||||
return ($queue && $this->trackingConfig->isEmailTrackingEnabled()) ?
|
||||
self::getFullShortcode($action) :
|
||||
$url;
|
||||
}
|
||||
|
||||
public function processShortcodeAction(
|
||||
$shortcodeAction,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
$wpUserPreview = false
|
||||
): ?string {
|
||||
$subscriptionUrlFactory = SubscriptionUrlFactory::getInstance();
|
||||
switch ($shortcodeAction) {
|
||||
case 'subscription_unsubscribe_url':
|
||||
$url = $subscriptionUrlFactory->getConfirmUnsubscribeUrl(
|
||||
$subscriber,
|
||||
$queue ? $queue->getId() : null
|
||||
);
|
||||
break;
|
||||
case 'subscription_instant_unsubscribe_url':
|
||||
$url = $subscriptionUrlFactory->getUnsubscribeUrl(
|
||||
$subscriber,
|
||||
$queue ? $queue->getId() : null
|
||||
);
|
||||
break;
|
||||
case 'subscription_manage_url':
|
||||
$url = $subscriptionUrlFactory->getManageUrl($subscriber);
|
||||
break;
|
||||
case 'newsletter_view_in_browser_url':
|
||||
$url = $this->newsletterUrl->getViewInBrowserUrl(
|
||||
$newsletter,
|
||||
$subscriber,
|
||||
$queue,
|
||||
false
|
||||
);
|
||||
break;
|
||||
case 'subscription_re_engage_url':
|
||||
$url = $subscriptionUrlFactory->getReEngagementUrl($subscriber);
|
||||
break;
|
||||
default:
|
||||
$shortcode = self::getFullShortcode($shortcodeAction);
|
||||
$url = $this->wp->applyFilters(
|
||||
'mailpoet_newsletter_shortcode_link',
|
||||
$shortcode,
|
||||
$newsletter,
|
||||
$subscriber,
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
$url = ($url !== $shortcodeAction) ? $url : null;
|
||||
break;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function getFullShortcode($action): string {
|
||||
return sprintf('[link:%s]', $action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
use MailPoet\WP\Posts as WPPosts;
|
||||
|
||||
class Newsletter implements CategoryInterface {
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
private WPFunctions $wp;
|
||||
|
||||
public function __construct(
|
||||
NewslettersRepository $newslettersRepository,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string {
|
||||
switch ($shortcodeDetails['action']) {
|
||||
case 'subject':
|
||||
return ($newsletter instanceof NewsletterEntity) ? $newsletter->getSubject() : null;
|
||||
|
||||
case 'total':
|
||||
return (string)substr_count($content, 'data-post-id');
|
||||
|
||||
case 'post_title':
|
||||
preg_match_all('/data-post-id="(\d+)"/ism', $content, $posts);
|
||||
$postIds = array_unique($posts[1]);
|
||||
$latestPost = (!empty($postIds)) ? $this->getLatestWPPost($postIds) : null;
|
||||
if ($latestPost) {
|
||||
// When a user with role author publish a post containing "&" in the title, the character is saved as "&" in the database.
|
||||
// Removing HTML tags from the title because
|
||||
$title = $this->wp->wpStripAllTags($latestPost['post_title']);
|
||||
// Decoding special characters such as & to &, etc.
|
||||
return htmlspecialchars_decode($title);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'number':
|
||||
if (!($newsletter instanceof NewsletterEntity)) return null;
|
||||
if ($newsletter->getType() !== NewsletterEntity::TYPE_NOTIFICATION_HISTORY) {
|
||||
return null;
|
||||
}
|
||||
$sentNewsletters = $this->newslettersRepository->countBy([
|
||||
'parent' => $newsletter->getParent(),
|
||||
'status' => NewsletterEntity::STATUS_SENT,
|
||||
]);
|
||||
return (string)++$sentNewsletters;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function ensureConsistentQueryType(\WP_Query $query) {
|
||||
// Queries with taxonomies are autodetected as 'is_archive=true' and 'is_home=false'
|
||||
// while queries without them end up being 'is_archive=false' and 'is_home=true'.
|
||||
// This is to fix that by always enforcing constistent behavior.
|
||||
$query->is_archive = true; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
$query->is_home = false; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
|
||||
private function getLatestWPPost($postIds) {
|
||||
// set low priority to execute 'ensureConstistentQueryType' before any other filter
|
||||
$filterPriority = defined('PHP_INT_MIN') ? constant('PHP_INT_MIN') : ~PHP_INT_MAX;
|
||||
$this->wp->addAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority);
|
||||
$posts = new \WP_Query(
|
||||
[
|
||||
'post_type' => WPPosts::getTypes(),
|
||||
'post__in' => $postIds,
|
||||
'posts_per_page' => 1,
|
||||
'ignore_sticky_posts' => true,
|
||||
'orderby' => 'post_date',
|
||||
'order' => 'DESC',
|
||||
]
|
||||
);
|
||||
$this->wp->removeAction('pre_get_posts', [$this, 'ensureConsistentQueryType'], $filterPriority);
|
||||
return (!empty($posts->posts[0])) && ($posts->posts[0] instanceof \WP_Post) ?
|
||||
$posts->posts[0]->to_array() :
|
||||
false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Site implements CategoryInterface {
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string {
|
||||
switch ($shortcodeDetails['action']) {
|
||||
case 'title':
|
||||
// Decoding special characters such as & to &, etc.
|
||||
return htmlspecialchars_decode($this->wp->getBloginfo('name'));
|
||||
|
||||
case 'homepage_url':
|
||||
return $this->wp->getBloginfo('url');
|
||||
|
||||
case 'homepage_link':
|
||||
return sprintf(
|
||||
'<a target="_blank" href="%s">%s</a>',
|
||||
$this->wp->escUrl($this->wp->getBloginfo('url')),
|
||||
$this->wp->escHtml($this->wp->getBloginfo('name'))
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes\Categories;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberCustomFieldEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Subscribers\SubscriberCustomFieldRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Subscriber implements CategoryInterface {
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SubscriberCustomFieldRepository */
|
||||
private $subscriberCustomFieldRepository;
|
||||
|
||||
public function __construct(
|
||||
SubscribersRepository $subscribersRepository,
|
||||
SubscriberCustomFieldRepository $subscriberCustomFieldRepository
|
||||
) {
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
|
||||
}
|
||||
|
||||
public function process(
|
||||
array $shortcodeDetails,
|
||||
NewsletterEntity $newsletter = null,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null,
|
||||
string $content = '',
|
||||
bool $wpUserPreview = false
|
||||
): ?string {
|
||||
if (!($subscriber instanceof SubscriberEntity)) {
|
||||
return $shortcodeDetails['shortcode'];
|
||||
}
|
||||
$defaultValue = ($shortcodeDetails['action_argument'] === 'default') ?
|
||||
$shortcodeDetails['action_argument_value'] :
|
||||
'';
|
||||
switch ($shortcodeDetails['action']) {
|
||||
case 'firstname':
|
||||
return (!empty($subscriber->getFirstName())) ? htmlspecialchars($subscriber->getFirstName()) : $defaultValue;
|
||||
case 'lastname':
|
||||
return !empty($subscriber->getLastName()) ? htmlspecialchars($subscriber->getLastName()) : $defaultValue;
|
||||
case 'email':
|
||||
return $subscriber->getEmail();
|
||||
case 'displayname':
|
||||
if ($subscriber->getWpUserId()) {
|
||||
$wpUser = WPFunctions::get()->getUserdata($subscriber->getWpUserId());
|
||||
return $wpUser->user_login; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
|
||||
}
|
||||
return $defaultValue;
|
||||
case 'count':
|
||||
return (string)$this->getSubscribersCountWithSubscribedStatus();
|
||||
default:
|
||||
if (
|
||||
preg_match('/cf_(\d+)/', $shortcodeDetails['action'], $customField) &&
|
||||
!empty($subscriber->getId())
|
||||
) {
|
||||
$customField = $this->subscriberCustomFieldRepository->findOneBy([
|
||||
'subscriber' => $subscriber,
|
||||
'customField' => $customField[1],
|
||||
]);
|
||||
return ($customField instanceof SubscriberCustomFieldEntity && !empty($customField->getValue()))
|
||||
? htmlspecialchars($customField->getValue())
|
||||
: $defaultValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getSubscribersCountWithSubscribedStatus(): int {
|
||||
return $this->subscribersRepository->countBy(['status' => SubscriberEntity::STATUS_SUBSCRIBED, 'deletedAt' => null]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\CategoryInterface;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Date;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Link;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Newsletter;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Site;
|
||||
use MailPoet\Newsletter\Shortcodes\Categories\Subscriber;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class Shortcodes {
|
||||
/** @var NewsletterEntity|null */
|
||||
private $newsletter;
|
||||
|
||||
/** @var SubscriberEntity|null */
|
||||
private $subscriber;
|
||||
|
||||
/** @var SendingQueueEntity|null */
|
||||
private $queue;
|
||||
|
||||
/** @var bool */
|
||||
private $wpUserPreview = false;
|
||||
|
||||
/** @var Date */
|
||||
private $dateCategory;
|
||||
|
||||
/** @var Link */
|
||||
private $linkCategory;
|
||||
|
||||
/** @var Newsletter */
|
||||
private $newsletterCategory;
|
||||
|
||||
/** @var Subscriber */
|
||||
private $subscriberCategory;
|
||||
|
||||
/** @var Site */
|
||||
private $siteCategory;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
Date $dateCategory,
|
||||
Link $linkCategory,
|
||||
Newsletter $newsletterCategory,
|
||||
Subscriber $subscriberCategory,
|
||||
Site $siteCategory,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->dateCategory = $dateCategory;
|
||||
$this->linkCategory = $linkCategory;
|
||||
$this->newsletterCategory = $newsletterCategory;
|
||||
$this->subscriberCategory = $subscriberCategory;
|
||||
$this->siteCategory = $siteCategory;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function setNewsletter(NewsletterEntity $newsletter = null): void {
|
||||
$this->newsletter = $newsletter;
|
||||
}
|
||||
|
||||
public function setSubscriber(SubscriberEntity $subscriber = null): void {
|
||||
$this->subscriber = $subscriber;
|
||||
}
|
||||
|
||||
public function setQueue(SendingQueueEntity $queue = null): void {
|
||||
$this->queue = $queue;
|
||||
}
|
||||
|
||||
public function setWpUserPreview(bool $wpUserPreview): void {
|
||||
$this->wpUserPreview = $wpUserPreview;
|
||||
}
|
||||
|
||||
public function extract($content, $categories = false) {
|
||||
$categories = (is_array($categories)) ? implode('|', $categories) : false;
|
||||
// match: [category:shortcode] or [category|category|...:shortcode]
|
||||
// dot not match: [category://shortcode] - avoids matching http/ftp links
|
||||
$regex = sprintf(
|
||||
'/\[%s:(?!\/\/).*?\]/i',
|
||||
($categories) ? '(?:' . $categories . ')' : '(?:\w+)'
|
||||
);
|
||||
preg_match_all($regex, (string)$content, $shortcodes);
|
||||
$shortcodes = $shortcodes[0];
|
||||
return (count($shortcodes)) ?
|
||||
array_values(array_unique($shortcodes)) :
|
||||
false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a MailPoet-style shortcode.
|
||||
* The syntax is [category:action | argument:argument_value], it can have a single argument.
|
||||
*/
|
||||
public function match($shortcode) {
|
||||
preg_match(
|
||||
'/\[(?P<category>\w+)?:(?P<action>\w+)(?:.*?\|.*?(?P<argument>\w+):(?P<argument_value>.*?))?\]/',
|
||||
$shortcode,
|
||||
$match
|
||||
);
|
||||
// If argument exists, copy it to the arguments array
|
||||
if (!empty($match['argument'])) {
|
||||
$match['arguments'] = [$match['argument'] => isset($match['argument_value']) ? $match['argument_value'] : ''];
|
||||
}
|
||||
return $match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a WordPress-style shortcode.
|
||||
* The syntax is [category:action arg1="value1" arg2="value2"], it can have multiple arguments.
|
||||
*/
|
||||
public function matchWPShortcode($shortcode) {
|
||||
$atts = $this->wp->shortcodeParseAtts(trim($shortcode, '[]/'));
|
||||
if (empty($atts[0])) {
|
||||
return [];
|
||||
}
|
||||
$shortcodeName = $atts[0];
|
||||
list($category, $action) = explode(':', $shortcodeName);
|
||||
$shortcodeDetails = [];
|
||||
$shortcodeDetails['category'] = $category;
|
||||
$shortcodeDetails['action'] = $action;
|
||||
$shortcodeDetails['arguments'] = [];
|
||||
foreach ($atts as $attrName => $attrValue) {
|
||||
if (is_numeric($attrName)) {
|
||||
continue; // Skip unnamed attributes
|
||||
}
|
||||
$shortcodeDetails['arguments'][$attrName] = $attrValue;
|
||||
// Make a shortcut to the first argument
|
||||
if (!isset($shortcodeDetails['argument'])) {
|
||||
$shortcodeDetails['argument'] = $attrName;
|
||||
$shortcodeDetails['argument_value'] = $attrValue;
|
||||
}
|
||||
}
|
||||
return $shortcodeDetails;
|
||||
}
|
||||
|
||||
public function process($shortcodes, $content = '') {
|
||||
$processedShortcodes = [];
|
||||
foreach ($shortcodes as $shortcode) {
|
||||
$shortcodeDetails = $this->match($shortcode);
|
||||
if (empty($shortcodeDetails)) {
|
||||
// Wrong MailPoet shortcode syntax, try to parse as a native WP shortcode
|
||||
$shortcodeDetails = $this->matchWPShortcode($shortcode);
|
||||
}
|
||||
$shortcodeDetails['shortcode'] = $shortcode;
|
||||
$shortcodeDetails['category'] = !empty($shortcodeDetails['category']) ?
|
||||
$shortcodeDetails['category'] :
|
||||
'';
|
||||
$shortcodeDetails['action'] = !empty($shortcodeDetails['action']) ?
|
||||
$shortcodeDetails['action'] :
|
||||
'';
|
||||
$shortcodeDetails['action_argument'] = !empty($shortcodeDetails['argument']) ?
|
||||
$shortcodeDetails['argument'] :
|
||||
'';
|
||||
$shortcodeDetails['action_argument_value'] = !empty($shortcodeDetails['argument_value']) ?
|
||||
$shortcodeDetails['argument_value'] :
|
||||
'';
|
||||
$shortcodeDetails['arguments'] = !empty($shortcodeDetails['arguments']) ?
|
||||
$shortcodeDetails['arguments'] : [];
|
||||
|
||||
$category = strtolower($shortcodeDetails['category']);
|
||||
$categoryClass = $this->getCategoryObject($category);
|
||||
if ($categoryClass instanceof CategoryInterface) {
|
||||
$processedShortcodes[] = $categoryClass->process(
|
||||
$shortcodeDetails,
|
||||
$this->newsletter,
|
||||
$this->subscriber,
|
||||
$this->queue,
|
||||
$content,
|
||||
$this->wpUserPreview
|
||||
);
|
||||
} else {
|
||||
$customShortcode = $this->wp->applyFilters(
|
||||
'mailpoet_newsletter_shortcode',
|
||||
$shortcode,
|
||||
$this->newsletter,
|
||||
$this->subscriber,
|
||||
$this->queue,
|
||||
$content,
|
||||
$shortcodeDetails['arguments'],
|
||||
$this->wpUserPreview
|
||||
);
|
||||
$processedShortcodes[] = ($customShortcode === $shortcode) ?
|
||||
false :
|
||||
$customShortcode;
|
||||
}
|
||||
|
||||
}
|
||||
return $processedShortcodes;
|
||||
}
|
||||
|
||||
public function replace($content, $contentSource = null, $categories = null) {
|
||||
$shortcodes = $this->extract($content, $categories);
|
||||
if (!$shortcodes) {
|
||||
return $content;
|
||||
}
|
||||
// if content contains only shortcodes (e.g., [newsletter:post_title]) but their processing
|
||||
// depends on some other content (e.g., "post_id" inside a rendered newsletter),
|
||||
// then we should use that content source when processing shortcodes
|
||||
$processedShortcodes = $this->process(
|
||||
$shortcodes,
|
||||
($contentSource) ? $contentSource : $content
|
||||
);
|
||||
return str_replace($shortcodes, $processedShortcodes, $content);
|
||||
}
|
||||
|
||||
private function getCategoryObject($category): ?CategoryInterface {
|
||||
if ($category === 'link') {
|
||||
return $this->linkCategory;
|
||||
} elseif ($category === 'date') {
|
||||
return $this->dateCategory;
|
||||
} elseif ($category === 'newsletter') {
|
||||
return $this->newsletterCategory;
|
||||
} elseif ($category === 'subscriber') {
|
||||
return $this->subscriberCategory;
|
||||
} elseif ($category === 'site') {
|
||||
return $this->siteCategory;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Shortcodes;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\CustomFields\CustomFieldsRepository;
|
||||
use MailPoet\Entities\NewsletterLinkEntity;
|
||||
|
||||
class ShortcodesHelper {
|
||||
/** @var CustomFieldsRepository */
|
||||
private $customFieldsRepository;
|
||||
|
||||
public function __construct(
|
||||
CustomFieldsRepository $customFieldsRepository
|
||||
) {
|
||||
$this->customFieldsRepository = $customFieldsRepository;
|
||||
}
|
||||
|
||||
public function getShortcodes(): array {
|
||||
$shortcodes = [
|
||||
__('Subscriber', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('First Name', 'mailpoet'),
|
||||
'shortcode' => '[subscriber:firstname | default:reader]',
|
||||
],
|
||||
[
|
||||
'text' => __('Last Name', 'mailpoet'),
|
||||
'shortcode' => '[subscriber:lastname | default:reader]',
|
||||
],
|
||||
[
|
||||
'text' => __('Email Address', 'mailpoet'),
|
||||
'shortcode' => '[subscriber:email]',
|
||||
],
|
||||
[
|
||||
'text' => __('WordPress User Display Name', 'mailpoet'),
|
||||
'shortcode' => '[subscriber:displayname | default:member]',
|
||||
],
|
||||
[
|
||||
'text' => __('Total Number of Subscribers', 'mailpoet'),
|
||||
'shortcode' => '[subscriber:count]',
|
||||
],
|
||||
],
|
||||
__('Newsletter', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('Newsletter Subject', 'mailpoet'),
|
||||
'shortcode' => '[newsletter:subject]',
|
||||
],
|
||||
],
|
||||
__('Post Notifications', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('Total Number of Posts or Pages', 'mailpoet'),
|
||||
'shortcode' => '[newsletter:total]',
|
||||
],
|
||||
[
|
||||
'text' => __('Most Recent Post Title', 'mailpoet'),
|
||||
'shortcode' => '[newsletter:post_title]',
|
||||
],
|
||||
[
|
||||
'text' => __('Issue Number', 'mailpoet'),
|
||||
'shortcode' => '[newsletter:number]',
|
||||
],
|
||||
],
|
||||
__('Date', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('Current day of the month number', 'mailpoet'),
|
||||
'shortcode' => '[date:d]',
|
||||
],
|
||||
[
|
||||
'text' => __('Current day of the month in ordinal form, i.e. 2nd, 3rd, 4th, etc.', 'mailpoet'),
|
||||
'shortcode' => '[date:dordinal]',
|
||||
],
|
||||
[
|
||||
'text' => __('Full name of current day', 'mailpoet'),
|
||||
'shortcode' => '[date:dtext]',
|
||||
],
|
||||
[
|
||||
'text' => __('Current month number', 'mailpoet'),
|
||||
'shortcode' => '[date:m]',
|
||||
],
|
||||
[
|
||||
'text' => __('Full name of current month', 'mailpoet'),
|
||||
'shortcode' => '[date:mtext]',
|
||||
],
|
||||
[
|
||||
'text' => __('Year', 'mailpoet'),
|
||||
'shortcode' => '[date:y]',
|
||||
],
|
||||
],
|
||||
__('Links', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('Unsubscribe link', 'mailpoet'),
|
||||
'shortcode' => sprintf(
|
||||
'<a target="_blank" href="%s">%s</a>',
|
||||
NewsletterLinkEntity::UNSUBSCRIBE_LINK_SHORT_CODE,
|
||||
__('Unsubscribe', 'mailpoet')
|
||||
),
|
||||
],
|
||||
[
|
||||
'text' => __('Edit subscription page link', 'mailpoet'),
|
||||
'shortcode' => sprintf(
|
||||
'<a target="_blank" href="%s">%s</a>',
|
||||
'[link:subscription_manage_url]',
|
||||
__('Manage subscription', 'mailpoet')
|
||||
),
|
||||
],
|
||||
[
|
||||
'text' => __('View in browser link', 'mailpoet'),
|
||||
'shortcode' => sprintf(
|
||||
'<a target="_blank" href="%s">%s</a>',
|
||||
'[link:newsletter_view_in_browser_url]',
|
||||
__('View in your browser', 'mailpoet')
|
||||
),
|
||||
],
|
||||
],
|
||||
__('Site', 'mailpoet') => [
|
||||
[
|
||||
'text' => __('Site title', 'mailpoet'),
|
||||
'shortcode' => '[site:title]',
|
||||
],
|
||||
[
|
||||
'text' => __('Homepage link', 'mailpoet'),
|
||||
'shortcode' => sprintf(
|
||||
'<a target="_blank" href="%s">%s</a>',
|
||||
'[site:homepage_url]',
|
||||
'[site:title]'
|
||||
),
|
||||
],
|
||||
[
|
||||
'text' => __('Homepage URL', 'mailpoet'),
|
||||
'shortcode' => '[site:homepage_url]',
|
||||
],
|
||||
],
|
||||
];
|
||||
$customFields = $this->getCustomFields();
|
||||
if (count($customFields) > 0) {
|
||||
$shortcodes[__('Subscriber', 'mailpoet')] = array_merge(
|
||||
$shortcodes[__('Subscriber', 'mailpoet')],
|
||||
$customFields
|
||||
);
|
||||
}
|
||||
return $shortcodes;
|
||||
}
|
||||
|
||||
public function getCustomFields(): array {
|
||||
$customFields = $this->customFieldsRepository->findAll();
|
||||
return array_map(function($customField) {
|
||||
return [
|
||||
'text' => $customField->getName(),
|
||||
'shortcode' => '[subscriber:cf_' . $customField->getId() . ']',
|
||||
];
|
||||
}, $customFields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class NewsletterStatistics {
|
||||
|
||||
/** @var int */
|
||||
private $clickCount;
|
||||
|
||||
/** @var int */
|
||||
private $openCount;
|
||||
|
||||
/** @var int */
|
||||
private $machineOpenCount;
|
||||
|
||||
/** @var int */
|
||||
private $unsubscribeCount;
|
||||
|
||||
/** @var int */
|
||||
private $bounceCount;
|
||||
|
||||
/** @var int */
|
||||
private $totalSentCount;
|
||||
|
||||
/** @var WooCommerceRevenue|null */
|
||||
private $wooCommerceRevenue;
|
||||
|
||||
public function __construct(
|
||||
$clickCount,
|
||||
$openCount,
|
||||
$unsubscribeCount,
|
||||
$bounceCount,
|
||||
$totalSentCount,
|
||||
$wooCommerceRevenue
|
||||
) {
|
||||
$this->clickCount = $clickCount;
|
||||
$this->openCount = $openCount;
|
||||
$this->unsubscribeCount = $unsubscribeCount;
|
||||
$this->bounceCount = $bounceCount;
|
||||
$this->totalSentCount = $totalSentCount;
|
||||
$this->wooCommerceRevenue = $wooCommerceRevenue;
|
||||
}
|
||||
|
||||
public function getClickCount(): int {
|
||||
return $this->clickCount;
|
||||
}
|
||||
|
||||
public function getOpenCount(): int {
|
||||
return $this->openCount;
|
||||
}
|
||||
|
||||
public function getUnsubscribeCount(): int {
|
||||
return $this->unsubscribeCount;
|
||||
}
|
||||
|
||||
public function getBounceCount(): int {
|
||||
return $this->bounceCount;
|
||||
}
|
||||
|
||||
public function getTotalSentCount(): int {
|
||||
return $this->totalSentCount;
|
||||
}
|
||||
|
||||
public function getWooCommerceRevenue(): ?WooCommerceRevenue {
|
||||
return $this->wooCommerceRevenue;
|
||||
}
|
||||
|
||||
public function setMachineOpenCount(int $machineOpenCount): void {
|
||||
$this->machineOpenCount = $machineOpenCount;
|
||||
}
|
||||
|
||||
public function getMachineOpenCount(): int {
|
||||
return $this->machineOpenCount;
|
||||
}
|
||||
|
||||
public function asArray(): array {
|
||||
return [
|
||||
'clicked' => $this->clickCount,
|
||||
'opened' => $this->openCount,
|
||||
'machineOpened' => $this->machineOpenCount,
|
||||
'unsubscribed' => $this->unsubscribeCount,
|
||||
'bounced' => $this->bounceCount,
|
||||
'revenue' => empty($this->wooCommerceRevenue) ? null : $this->wooCommerceRevenue->asArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Doctrine\Repository;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\ScheduledTaskEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\StatisticsBounceEntity;
|
||||
use MailPoet\Entities\StatisticsClickEntity;
|
||||
use MailPoet\Entities\StatisticsNewsletterEntity;
|
||||
use MailPoet\Entities\StatisticsOpenEntity;
|
||||
use MailPoet\Entities\StatisticsUnsubscribeEntity;
|
||||
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Entities\UserAgentEntity;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\WooCommerce\Helper as WCHelper;
|
||||
use MailPoetVendor\Doctrine\ORM\EntityManager;
|
||||
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
|
||||
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
|
||||
use MailPoetVendor\Doctrine\ORM\UnexpectedResultException;
|
||||
|
||||
/**
|
||||
* @extends Repository<NewsletterEntity>
|
||||
*/
|
||||
class NewsletterStatisticsRepository extends Repository {
|
||||
|
||||
/** @var WCHelper */
|
||||
private $wcHelper;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
public function __construct(
|
||||
EntityManager $entityManager,
|
||||
WCHelper $wcHelper,
|
||||
TrackingConfig $trackingConfig
|
||||
) {
|
||||
parent::__construct($entityManager);
|
||||
$this->wcHelper = $wcHelper;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
}
|
||||
|
||||
protected function getEntityClassName() {
|
||||
return NewsletterEntity::class;
|
||||
}
|
||||
|
||||
public function getStatistics(NewsletterEntity $newsletter): NewsletterStatistics {
|
||||
$stats = new NewsletterStatistics(
|
||||
$this->getStatisticsClickCount($newsletter),
|
||||
$this->getStatisticsOpenCount($newsletter),
|
||||
$this->getStatisticsUnsubscribeCount($newsletter),
|
||||
$this->getStatisticsBounceCount($newsletter),
|
||||
$this->getTotalSentCount($newsletter),
|
||||
$this->getWooCommerceRevenue($newsletter)
|
||||
);
|
||||
$stats->setMachineOpenCount($this->getStatisticsMachineOpenCount($newsletter));
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity[] $newsletters
|
||||
* @return NewsletterStatistics[]
|
||||
*/
|
||||
public function getBatchStatistics(
|
||||
array $newsletters,
|
||||
\DateTimeImmutable $from = null,
|
||||
\DateTimeImmutable $to = null,
|
||||
array $include = [
|
||||
'totals',
|
||||
StatisticsClickEntity::class,
|
||||
StatisticsOpenEntity::class,
|
||||
StatisticsUnsubscribeEntity::class,
|
||||
StatisticsBounceEntity::class,
|
||||
WooCommerceRevenue::class,
|
||||
]
|
||||
): array {
|
||||
|
||||
$totalSentCounts = in_array('totals', $include, true) ? $this->getTotalSentCounts($newsletters, $from, $to) : [];
|
||||
$clickCounts = in_array(StatisticsClickEntity::class, $include, true) ? $this->getStatisticCounts(StatisticsClickEntity::class, $newsletters, $from, $to) : [];
|
||||
$openCounts = in_array(StatisticsOpenEntity::class, $include, true) ? $this->getStatisticCounts(StatisticsOpenEntity::class, $newsletters, $from, $to) : [];
|
||||
$unsubscribeCounts = in_array(StatisticsUnsubscribeEntity::class, $include, true) ? $this->getStatisticCounts(StatisticsUnsubscribeEntity::class, $newsletters, $from, $to) : [];
|
||||
$bounceCounts = in_array(StatisticsBounceEntity::class, $include, true) ? $this->getStatisticCounts(StatisticsBounceEntity::class, $newsletters, $from, $to) : [];
|
||||
$wooCommerceRevenues = in_array(WooCommerceRevenue::class, $include, true) ? $this->getWooCommerceRevenues($newsletters, $from, $to) : [];
|
||||
|
||||
$statistics = [];
|
||||
foreach ($newsletters as $newsletter) {
|
||||
$id = $newsletter->getId();
|
||||
$statistics[$id] = new NewsletterStatistics(
|
||||
$clickCounts[$id] ?? 0,
|
||||
$openCounts[$id] ?? 0,
|
||||
$unsubscribeCounts[$id] ?? 0,
|
||||
$bounceCounts[$id] ?? 0,
|
||||
$totalSentCounts[$id] ?? 0,
|
||||
$wooCommerceRevenues[$id] ?? null
|
||||
);
|
||||
}
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
public function getTotalSentCount(NewsletterEntity $newsletter): int {
|
||||
$counts = $this->getTotalSentCounts([$newsletter]);
|
||||
return $counts[$newsletter->getId()] ?? 0;
|
||||
}
|
||||
|
||||
public function getStatisticsClickCount(NewsletterEntity $newsletter): int {
|
||||
$counts = $this->getStatisticCounts(StatisticsClickEntity::class, [$newsletter]);
|
||||
return $counts[$newsletter->getId()] ?? 0;
|
||||
}
|
||||
|
||||
public function getStatisticsOpenCount(NewsletterEntity $newsletter): int {
|
||||
$counts = $this->getStatisticCounts(StatisticsOpenEntity::class, [$newsletter]);
|
||||
return $counts[$newsletter->getId()] ?? 0;
|
||||
}
|
||||
|
||||
public function getStatisticsMachineOpenCount(NewsletterEntity $newsletter): int {
|
||||
$qb = $this->getStatisticsQuery(StatisticsOpenEntity::class, [$newsletter]);
|
||||
$result = $qb->andWhere('(stats.userAgentType = :userAgentType)')
|
||||
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_MACHINE)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
if (empty($result)) return 0;
|
||||
return $result['cnt'] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubscriberEntity $subscriber
|
||||
* @param int|null $limit
|
||||
* @param int|null $offset
|
||||
* @return array(newsletter_id: string, newsletter_rendered_subject: string, opened_at: string|null, sent_at: string)
|
||||
*/
|
||||
public function getAllForSubscriber(
|
||||
SubscriberEntity $subscriber,
|
||||
int $limit = null,
|
||||
int $offset = null
|
||||
): array {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(statistics.newsletter) AS newsletter_id')
|
||||
->addSelect('opens.createdAt AS opened_at')
|
||||
->addSelect('queue.newsletterRenderedSubject AS newsletter_rendered_subject')
|
||||
->addSelect('statistics.sentAt AS sent_at')
|
||||
->from(StatisticsNewsletterEntity::class, 'statistics')
|
||||
->join(SendingQueueEntity::class, 'queue', Join::WITH, 'statistics.queue = queue')
|
||||
->leftJoin(
|
||||
StatisticsOpenEntity::class,
|
||||
'opens',
|
||||
Join::WITH,
|
||||
'statistics.newsletter = opens.newsletter AND statistics.subscriber = opens.subscriber'
|
||||
)
|
||||
->where('statistics.subscriber = :subscriber')
|
||||
->setParameter('subscriber', $subscriber)
|
||||
->addOrderBy('newsletter_id')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function getStatisticsUnsubscribeCount(NewsletterEntity $newsletter): int {
|
||||
$counts = $this->getStatisticCounts(StatisticsUnsubscribeEntity::class, [$newsletter]);
|
||||
return $counts[$newsletter->getId()] ?? 0;
|
||||
}
|
||||
|
||||
public function getStatisticsBounceCount(NewsletterEntity $newsletter): int {
|
||||
$counts = $this->getStatisticCounts(StatisticsBounceEntity::class, [$newsletter]);
|
||||
return $counts[$newsletter->getId()] ?? 0;
|
||||
}
|
||||
|
||||
public function getWooCommerceRevenue(NewsletterEntity $newsletter) {
|
||||
$revenues = $this->getWooCommerceRevenues([$newsletter]);
|
||||
return $revenues[$newsletter->getId()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param NewsletterEntity $newsletter
|
||||
* @return int
|
||||
*/
|
||||
public function getChildrenCount(NewsletterEntity $newsletter) {
|
||||
try {
|
||||
return (int)$this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('COUNT(n.id) as cnt')
|
||||
->from(NewsletterEntity::class, 'n')
|
||||
->where('n.parent = :newsletter')
|
||||
->setParameter('newsletter', $newsletter)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
} catch (UnexpectedResultException $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function getTotalSentCounts(array $newsletters, \DateTimeImmutable $from = null, \DateTimeImmutable $to = null): array {
|
||||
$query = $this->doctrineRepository
|
||||
->createQueryBuilder('n')
|
||||
->select('n.id, SUM(q.countProcessed) AS cnt')
|
||||
->join('n.queues', 'q')
|
||||
->join('q.task', 't')
|
||||
->where('t.status = :status')
|
||||
->setParameter('status', ScheduledTaskEntity::STATUS_COMPLETED)
|
||||
->andWhere('q.newsletter IN (:newsletters)')
|
||||
->setParameter('newsletters', $newsletters)
|
||||
->groupBy('n.id');
|
||||
|
||||
if ($from && $to) {
|
||||
$query->andWhere('q.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to);
|
||||
} elseif ($from && $to === null) {
|
||||
$query->andWhere('q.createdAt >= :from')
|
||||
->setParameter('from', $from);
|
||||
} elseif ($from === null && $to) {
|
||||
$query->andWhere('q.createdAt <= :to')
|
||||
->setParameter('to', $to);
|
||||
}
|
||||
|
||||
$results = $query->getQuery()
|
||||
->getResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($results ?: [] as $result) {
|
||||
$counts[(int)$result['id']] = (int)$result['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function getStatisticCounts(string $statisticsEntityName, array $newsletters, \DateTimeImmutable $from = null, \DateTimeImmutable $to = null): array {
|
||||
$qb = $this->getStatisticsQuery($statisticsEntityName, $newsletters);
|
||||
if (
|
||||
$statisticsEntityName === StatisticsClickEntity::class
|
||||
|| ($statisticsEntityName === StatisticsOpenEntity::class && $this->trackingConfig->areOpensSeparated())
|
||||
) {
|
||||
$qb->andWhere('(stats.userAgentType = :userAgentType) OR (stats.userAgentType IS NULL)')
|
||||
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_HUMAN);
|
||||
}
|
||||
if ($from && $to) {
|
||||
$qb->andWhere('stats.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to);
|
||||
} elseif ($from && $to === null) {
|
||||
$qb->andWhere('stats.createdAt >= :from')
|
||||
->setParameter('from', $from);
|
||||
} elseif ($from === null && $to) {
|
||||
$qb->andWhere('stats.createdAt <= :to')
|
||||
->setParameter('to', $to);
|
||||
}
|
||||
|
||||
$results = $qb
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($results ?: [] as $result) {
|
||||
$counts[(int)$result['id']] = (int)$result['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function getStatisticsQuery(string $statisticsEntityName, array $newsletters): QueryBuilder {
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(stats.newsletter) AS id, COUNT(DISTINCT stats.subscriber) as cnt')
|
||||
->from($statisticsEntityName, 'stats')
|
||||
->where('stats.newsletter IN (:newsletters)')
|
||||
->groupBy('stats.newsletter')
|
||||
->setParameter('newsletters', $newsletters);
|
||||
}
|
||||
|
||||
private function getWooCommerceRevenues(array $newsletters, \DateTimeImmutable $from = null, \DateTimeImmutable $to = null) {
|
||||
if (!$this->wcHelper->isWooCommerceActive()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$revenueStatus = $this->wcHelper->getPurchaseStates();
|
||||
|
||||
$currency = $this->wcHelper->getWoocommerceCurrency();
|
||||
$query = $this->entityManager
|
||||
->createQueryBuilder()
|
||||
->select('IDENTITY(stats.newsletter) AS id, SUM(stats.orderPriceTotal) AS total, COUNT(stats.id) AS cnt')
|
||||
->from(StatisticsWooCommercePurchaseEntity::class, 'stats')
|
||||
->where('stats.newsletter IN (:newsletters)')
|
||||
->andWhere('stats.orderCurrency = :currency')
|
||||
->andWhere('stats.status IN (:revenue_status)')
|
||||
->setParameter('newsletters', $newsletters)
|
||||
->setParameter('currency', $currency)
|
||||
->setParameter('revenue_status', $revenueStatus)
|
||||
->groupBy('stats.newsletter');
|
||||
|
||||
if ($from && $to) {
|
||||
$query->andWhere('stats.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to);
|
||||
} elseif ($from && $to === null) {
|
||||
$query->andWhere('stats.createdAt >= :from')
|
||||
->setParameter('from', $from);
|
||||
} elseif ($from === null && $to) {
|
||||
$query->andWhere('stats.createdAt <= :to')
|
||||
->setParameter('to', $to);
|
||||
}
|
||||
|
||||
$results = $query->getQuery()
|
||||
->getResult();
|
||||
|
||||
$revenues = [];
|
||||
foreach ($results ?: [] as $result) {
|
||||
$revenues[(int)$result['id']] = new WooCommerceRevenue(
|
||||
$currency,
|
||||
(float)$result['total'],
|
||||
(int)$result['cnt'],
|
||||
$this->wcHelper
|
||||
);
|
||||
}
|
||||
return $revenues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\Statistics;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\WooCommerce\Helper;
|
||||
|
||||
class WooCommerceRevenue {
|
||||
|
||||
/** @var string */
|
||||
private $currency;
|
||||
|
||||
/** @var float */
|
||||
private $value;
|
||||
|
||||
/** @var int */
|
||||
private $ordersCount;
|
||||
|
||||
/** @var Helper */
|
||||
private $wooCommerceHelper;
|
||||
|
||||
public function __construct(
|
||||
$currency,
|
||||
$value,
|
||||
$ordersCount,
|
||||
Helper $wooCommerceHelper
|
||||
) {
|
||||
$this->currency = $currency;
|
||||
$this->value = $value;
|
||||
$this->ordersCount = $ordersCount;
|
||||
$this->wooCommerceHelper = $wooCommerceHelper;
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
public function getCurrency() {
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
/** @return int */
|
||||
public function getOrdersCount() {
|
||||
return $this->ordersCount;
|
||||
}
|
||||
|
||||
/** @return float */
|
||||
public function getValue() {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
public function getFormattedValue() {
|
||||
return $this->wooCommerceHelper->getRawPrice($this->value, ['currency' => $this->currency]);
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
public function getFormattedAverageValue(): string {
|
||||
$average = 0;
|
||||
if ($this->ordersCount > 0) {
|
||||
$average = $this->value / $this->ordersCount;
|
||||
}
|
||||
return $this->wooCommerceHelper->getRawPrice($average, ['currency' => $this->currency]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function asArray() {
|
||||
return [
|
||||
'currency' => $this->currency,
|
||||
'value' => (float)$this->value,
|
||||
'count' => (int)$this->ordersCount,
|
||||
'formatted' => $this->getFormattedValue(),
|
||||
'formatted_average' => $this->getFormattedAverageValue(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Router\Endpoints\ViewInBrowser as ViewInBrowserEndpoint;
|
||||
use MailPoet\Router\Router;
|
||||
use MailPoet\Subscribers\LinkTokens;
|
||||
|
||||
class Url {
|
||||
/** @var LinkTokens */
|
||||
private $linkTokens;
|
||||
|
||||
public function __construct(
|
||||
LinkTokens $linkTokens
|
||||
) {
|
||||
$this->linkTokens = $linkTokens;
|
||||
}
|
||||
|
||||
public function getViewInBrowserUrl(
|
||||
?NewsletterEntity $newsletter,
|
||||
?SubscriberEntity $subscriber = null,
|
||||
?SendingQueueEntity $queue = null,
|
||||
bool $preview = true
|
||||
) {
|
||||
$data = $this->createUrlDataObject($newsletter, $subscriber, $queue, $preview);
|
||||
return Router::buildRequest(
|
||||
ViewInBrowserEndpoint::ENDPOINT,
|
||||
ViewInBrowserEndpoint::ACTION_VIEW,
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
public function createUrlDataObject(
|
||||
?NewsletterEntity $newsletter,
|
||||
?SubscriberEntity $subscriber,
|
||||
?SendingQueueEntity $queue,
|
||||
bool $preview
|
||||
) {
|
||||
$newsletterId = $newsletter && $newsletter->getId() ? $newsletter->getId() : 0;
|
||||
$newsletterHash = $newsletter && $newsletter->getHash() ? $newsletter->getHash() : 0;
|
||||
$sendingQueueId = $queue && $queue->getId() ? $queue->getId() : 0;
|
||||
|
||||
return [
|
||||
$newsletterId,
|
||||
$newsletterHash,
|
||||
$subscriber && $subscriber->getId() ? $subscriber->getId() : 0,
|
||||
$subscriber && $subscriber->getId() ? $this->linkTokens->getToken($subscriber) : 0,
|
||||
$sendingQueueId,
|
||||
(int)$preview,
|
||||
];
|
||||
}
|
||||
|
||||
public function transformUrlDataObject($data) {
|
||||
reset($data);
|
||||
if (!is_int(key($data))) return $data;
|
||||
$transformedData = [];
|
||||
$transformedData['newsletter_id'] = (!empty($data[0])) ? $data[0] : false;
|
||||
$transformedData['newsletter_hash'] = (!empty($data[1])) ? $data[1] : false;
|
||||
$transformedData['subscriber_id'] = (!empty($data[2])) ? $data[2] : false;
|
||||
$transformedData['subscriber_token'] = (!empty($data[3])) ? $data[3] : false;
|
||||
$transformedData['queue_id'] = (!empty($data[4])) ? $data[4] : false;
|
||||
$transformedData['preview'] = (!empty($data[5])) ? $data[5] : false;
|
||||
return $transformedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\ViewInBrowser;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\EmailEditor\Integrations\MailPoet\DependencyNotice;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\NewslettersRepository;
|
||||
use MailPoet\Newsletter\Sending\SendingQueuesRepository;
|
||||
use MailPoet\Newsletter\Url as NewsletterUrl;
|
||||
use MailPoet\Subscribers\LinkTokens;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
|
||||
class ViewInBrowserController {
|
||||
/** @var LinkTokens */
|
||||
private $linkTokens;
|
||||
|
||||
/** @var NewsletterUrl */
|
||||
private $newsletterUrl;
|
||||
|
||||
/** @var ViewInBrowserRenderer */
|
||||
private $viewInBrowserRenderer;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
/** @var SendingQueuesRepository */
|
||||
private $sendingQueuesRepository;
|
||||
|
||||
/** @var NewslettersRepository */
|
||||
private $newslettersRepository;
|
||||
|
||||
/** @var DependencyNotice */
|
||||
private $dependencyNotice;
|
||||
|
||||
public function __construct(
|
||||
LinkTokens $linkTokens,
|
||||
NewsletterUrl $newsletterUrl,
|
||||
NewslettersRepository $newslettersRepository,
|
||||
ViewInBrowserRenderer $viewInBrowserRenderer,
|
||||
SendingQueuesRepository $sendingQueuesRepository,
|
||||
DependencyNotice $dependencyNotice,
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->linkTokens = $linkTokens;
|
||||
$this->viewInBrowserRenderer = $viewInBrowserRenderer;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
$this->sendingQueuesRepository = $sendingQueuesRepository;
|
||||
$this->newsletterUrl = $newsletterUrl;
|
||||
$this->dependencyNotice = $dependencyNotice;
|
||||
$this->newslettersRepository = $newslettersRepository;
|
||||
}
|
||||
|
||||
public function view(array $data) {
|
||||
$data = $this->newsletterUrl->transformUrlDataObject($data);
|
||||
$isPreview = !empty($data['preview']);
|
||||
$newsletter = $this->getNewsletter($data);
|
||||
$subscriber = $this->getSubscriber($data);
|
||||
if ($newsletter->getWpPostId() && $this->dependencyNotice->checkDependenciesAndEventuallyShowNotice()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// if queue and subscriber exist, subscriber must have received the newsletter
|
||||
$queue = isset($data['queue_id']) ? $this->sendingQueuesRepository->findOneById($data['queue_id']) : null;
|
||||
if (!$isPreview && $queue && $subscriber->getId() && !$this->sendingQueuesRepository->isSubscriberProcessed($queue, $subscriber)) {
|
||||
throw new \InvalidArgumentException("Subscriber did not receive the newsletter yet");
|
||||
}
|
||||
|
||||
return $this->viewInBrowserRenderer->render($isPreview, $newsletter, $subscriber, $queue);
|
||||
}
|
||||
|
||||
private function getNewsletter(array $data) {
|
||||
// newsletter - ID is mandatory, hash must be set and valid
|
||||
if (empty($data['newsletter_id'])) {
|
||||
throw new \InvalidArgumentException("Missing 'newsletter_id'");
|
||||
}
|
||||
if (empty($data['newsletter_hash'])) {
|
||||
throw new \InvalidArgumentException("Missing 'newsletter_hash'");
|
||||
}
|
||||
|
||||
$newsletter = $this->newslettersRepository->findOneById($data['newsletter_id']);
|
||||
if (!$newsletter) {
|
||||
throw new \InvalidArgumentException("Invalid 'newsletter_id'");
|
||||
}
|
||||
|
||||
if ($data['newsletter_hash'] !== $newsletter->getHash()) {
|
||||
throw new \InvalidArgumentException("Invalid 'newsletter_hash'");
|
||||
}
|
||||
return $newsletter;
|
||||
}
|
||||
|
||||
private function getSubscriber(array $data): SubscriberEntity {
|
||||
// subscriber is optional; if exists, token must validate
|
||||
$subscriber = null;
|
||||
if (!empty($data['subscriber_id'])) {
|
||||
$subscriber = $this->subscribersRepository->findOneById($data['subscriber_id']);
|
||||
}
|
||||
if ($subscriber && empty($data['subscriber_token'])) {
|
||||
throw new \InvalidArgumentException("Missing 'subscriber_token'");
|
||||
}
|
||||
|
||||
if ($subscriber && !$this->linkTokens->verifyToken($subscriber, $data['subscriber_token'])) {
|
||||
throw new \InvalidArgumentException("Invalid 'subscriber_token'");
|
||||
}
|
||||
|
||||
// if this is a preview and subscriber does not exist,
|
||||
// attempt to set subscriber to the current logged-in WP user
|
||||
if (!$subscriber && !empty($data['preview'])) {
|
||||
$subscriber = $this->subscribersRepository->getCurrentWPUser();
|
||||
}
|
||||
|
||||
return $subscriber ?? new SubscriberEntity();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Newsletter\ViewInBrowser;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\EmailEditor\Engine\Personalizer;
|
||||
use MailPoet\Entities\NewsletterEntity;
|
||||
use MailPoet\Entities\SendingQueueEntity;
|
||||
use MailPoet\Entities\SubscriberEntity;
|
||||
use MailPoet\Newsletter\Links\Links;
|
||||
use MailPoet\Newsletter\Renderer\Renderer;
|
||||
use MailPoet\Newsletter\Shortcodes\Shortcodes;
|
||||
use MailPoet\Settings\TrackingConfig;
|
||||
use MailPoet\WP\Emoji;
|
||||
|
||||
class ViewInBrowserRenderer {
|
||||
/** @var Emoji */
|
||||
private $emoji;
|
||||
|
||||
/** @var TrackingConfig */
|
||||
private $trackingConfig;
|
||||
|
||||
/** @var Renderer */
|
||||
private $renderer;
|
||||
|
||||
/** @var Shortcodes */
|
||||
private $shortcodes;
|
||||
|
||||
/** @var Links */
|
||||
private $links;
|
||||
|
||||
private Personalizer $personalizer;
|
||||
|
||||
public function __construct(
|
||||
Emoji $emoji,
|
||||
TrackingConfig $trackingConfig,
|
||||
Shortcodes $shortcodes,
|
||||
Renderer $renderer,
|
||||
Links $links,
|
||||
Personalizer $personalizer
|
||||
) {
|
||||
$this->emoji = $emoji;
|
||||
$this->trackingConfig = $trackingConfig;
|
||||
$this->renderer = $renderer;
|
||||
$this->shortcodes = $shortcodes;
|
||||
$this->links = $links;
|
||||
$this->personalizer = $personalizer;
|
||||
}
|
||||
|
||||
public function render(
|
||||
bool $isPreview,
|
||||
NewsletterEntity $newsletter,
|
||||
SubscriberEntity $subscriber = null,
|
||||
SendingQueueEntity $queue = null
|
||||
) {
|
||||
$wpUserPreview = $isPreview;
|
||||
$isTrackingEnabled = $this->trackingConfig->isEmailTrackingEnabled();
|
||||
|
||||
if ($queue && $queue->getNewsletterRenderedBody()) {
|
||||
$body = $queue->getNewsletterRenderedBody();
|
||||
if (is_array($body)) {
|
||||
$newsletterBody = $body['html'];
|
||||
} else {
|
||||
$newsletterBody = '';
|
||||
}
|
||||
$newsletterBody = $this->emoji->decodeEmojisInBody($newsletterBody);
|
||||
// rendered newsletter body has shortcodes converted to links; we need to
|
||||
// isolate "view in browser", "unsubscribe" and "manage subscription" links
|
||||
// and convert them to shortcodes, which later will be replaced with "#" when
|
||||
// newsletter is previewed
|
||||
if ($wpUserPreview && preg_match($this->links->getLinkRegex(), $newsletterBody)) {
|
||||
$newsletterBody = $this->links->convertHashedLinksToShortcodesAndUrls(
|
||||
$newsletterBody,
|
||||
$queue->getId(),
|
||||
$convertAll = true
|
||||
);
|
||||
// remove open tracking link
|
||||
$newsletterBody = str_replace(Links::DATA_TAG_OPEN, '', $newsletterBody);
|
||||
}
|
||||
} else {
|
||||
if ($wpUserPreview) {
|
||||
$newsletterBody = $this->renderer->renderAsPreview($newsletter, 'html');
|
||||
} else {
|
||||
$newsletterBody = $this->renderer->render($newsletter, $sendingTask = null, 'html');
|
||||
}
|
||||
}
|
||||
$this->prepareShortcodes(
|
||||
$newsletter,
|
||||
$subscriber,
|
||||
$queue,
|
||||
$wpUserPreview
|
||||
);
|
||||
$renderedNewsletter = $this->shortcodes->replace($newsletterBody);
|
||||
if (!$wpUserPreview && $queue && $subscriber && $isTrackingEnabled) {
|
||||
$renderedNewsletter = $this->links->replaceSubscriberData(
|
||||
$subscriber->getId(),
|
||||
$queue->getId(),
|
||||
$renderedNewsletter
|
||||
);
|
||||
}
|
||||
if ($newsletter->getWpPostId() !== null) {
|
||||
$this->personalizer->set_context([
|
||||
'recipient_email' => $subscriber ? $subscriber->getEmail() : null,
|
||||
'is_user_preview' => $wpUserPreview,
|
||||
'newsletter_id' => $newsletter->getId(),
|
||||
'queue_id' => $queue ? $queue->getId() : null,
|
||||
]);
|
||||
$renderedNewsletter = $this->personalizer->personalize_content($renderedNewsletter);
|
||||
}
|
||||
return $renderedNewsletter;
|
||||
}
|
||||
|
||||
private function prepareShortcodes(
|
||||
NewsletterEntity $newsletter,
|
||||
?SubscriberEntity $subscriber,
|
||||
?SendingQueueEntity $queue,
|
||||
bool $wpUserPreview
|
||||
) {
|
||||
$this->shortcodes->setQueue($queue);
|
||||
$this->shortcodes->setNewsletter($newsletter);
|
||||
$this->shortcodes->setWpUserPreview($wpUserPreview);
|
||||
$this->shortcodes->setSubscriber($subscriber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
Reference in New Issue
Block a user