This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,104 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Env;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Renderer\Renderer as NewsletterRenderer;
use MailPoet\Settings\SettingsController;
use MailPoet\Util\Security;
class ConfirmationEmailCustomizer {
const SETTING_EMAIL_ID = 'signup_confirmation.transactional_email_id';
const SETTING_ENABLE_EMAIL_CUSTOMIZER = 'signup_confirmation.use_mailpoet_editor';
/** @var SettingsController */
private $settings;
/** @var NewslettersRepository */
private $newslettersRepository;
/** @var NewsletterRenderer */
private $renderer;
public function __construct(
SettingsController $settings,
NewslettersRepository $newslettersRepository,
NewsletterRenderer $renderer
) {
$this->settings = $settings;
$this->newslettersRepository = $newslettersRepository;
$this->renderer = $renderer;
}
public function init() {
$savedEmailId = (bool)$this->settings->get(self::SETTING_EMAIL_ID, false);
if (!$savedEmailId) {
$email = $this->createNewsletter();
if (is_null($email)) return;
$this->settings->set(self::SETTING_EMAIL_ID, $email->getId());
}
}
private function createNewsletter(): ?NewsletterEntity {
$emailTemplate = $this->fetchEmailTemplate();
if (empty($emailTemplate)) {
// if it's not able to fetch email template, don't bother creating newsletter
return null;
}
$newsletter = new NewsletterEntity;
$newsletter->setType(NewsletterEntity::TYPE_CONFIRMATION_EMAIL_CUSTOMIZER);
$newsletter->setSubject($this->settings->get('signup_confirmation.subject', 'Confirm your subscription to [site:title]'));
$newsletter->setBody($emailTemplate);
$newsletter->setHash(Security::generateHash());
$this->newslettersRepository->persist($newsletter);
$this->newslettersRepository->flush();
return $newsletter;
}
private function fetchEmailTemplate() {
$templateUrl = Env::$libPath . '/Subscribers/ConfirmationEmailTemplate/template-confirmation.json';
$templateString = file_get_contents($templateUrl);
$templateArr = json_decode((string)$templateString, true);
$template = (array)$templateArr;
return $template['body'];
}
public function getNewsletter(): NewsletterEntity {
$savedEmailId = $this->settings->get(self::SETTING_EMAIL_ID, false);
if (empty($savedEmailId)) {
$this->init();
$savedEmailId = $this->settings->get(self::SETTING_EMAIL_ID);
}
$newsletter = $this->newslettersRepository->findOneById($savedEmailId);
if (!$newsletter) {
// the newsletter should always be present in the database, if it s not we shouldn't keep using this feature
// we need to recreate the newsletter
$this->settings->set(self::SETTING_EMAIL_ID, false); // reset
$this->init();
return $this->getNewsletter();
}
return $newsletter;
}
public function render(NewsletterEntity $newsletter): ?array {
$renderedContent = $this->renderer->renderAsPreview($newsletter);
if (empty($renderedContent)) {
return null;
}
$renderedContent['subject'] = $newsletter->getSubject();
return $renderedContent;
}
}
@@ -0,0 +1,226 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cron\Workers\SendingQueue\Tasks\Shortcodes;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\MailerFactory;
use MailPoet\Mailer\MailerLog;
use MailPoet\Mailer\MetaInfo;
use MailPoet\Services\AuthorizedEmailsController;
use MailPoet\Services\Bridge;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscription\SubscriptionUrlFactory;
use MailPoet\Util\Helpers;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Html2Text\Html2Text;
class ConfirmationEmailMailer {
const MAX_CONFIRMATION_EMAILS = 3;
/** @var MailerFactory */
private $mailerFactory;
/** @var WPFunctions */
private $wp;
/** @var SettingsController */
private $settings;
/** @var MetaInfo */
private $mailerMetaInfo;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SubscriptionUrlFactory */
private $subscriptionUrlFactory;
/** @var ConfirmationEmailCustomizer */
private $confirmationEmailCustomizer;
/** @var array Cache for confirmation emails sent within a request */
private $sentEmails = [];
public function __construct(
MailerFactory $mailerFactory,
WPFunctions $wp,
SettingsController $settings,
SubscribersRepository $subscribersRepository,
SubscriptionUrlFactory $subscriptionUrlFactory,
ConfirmationEmailCustomizer $confirmationEmailCustomizer
) {
$this->mailerFactory = $mailerFactory;
$this->wp = $wp;
$this->settings = $settings;
$this->mailerMetaInfo = new MetaInfo;
$this->subscriptionUrlFactory = $subscriptionUrlFactory;
$this->subscribersRepository = $subscribersRepository;
$this->confirmationEmailCustomizer = $confirmationEmailCustomizer;
}
/**
* Use this method if you want to make sure the confirmation email
* is not sent multiple times within a single request
* e.g. if sending confirmation emails from hooks
* @throws \Exception if unable to send the email.
*/
public function sendConfirmationEmailOnce(SubscriberEntity $subscriber): bool {
if (isset($this->sentEmails[$subscriber->getId()])) {
return true;
}
return $this->sendConfirmationEmail($subscriber);
}
public function clearSentEmailsCache(): void {
$this->sentEmails = [];
}
public function buildEmailData(string $subject, string $html, string $text): array {
return [
'subject' => $subject,
'body' => [
'html' => $html,
'text' => $text,
],
];
}
public function getMailBody(array $signupConfirmation, SubscriberEntity $subscriber, array $segmentNames): array {
$body = nl2br($signupConfirmation['body']);
// replace list of segments shortcode
$body = str_replace(
'[lists_to_confirm]',
'<strong>' . join(', ', $segmentNames) . '</strong>',
$body
);
// replace activation link
$body = Helpers::replaceLinkTags(
$body,
$this->subscriptionUrlFactory->getConfirmationUrl($subscriber),
['target' => '_blank'],
'activation_link'
);
$subject = Shortcodes::process($signupConfirmation['subject'], null, null, $subscriber, null);
$body = Shortcodes::process($body, null, null, $subscriber, null);
//create a text version. @ is important here, Html2Text throws warnings
$text = @Html2Text::convert(
(mb_detect_encoding($body, 'UTF-8', true)) ? $body : mb_convert_encoding($body, 'UTF-8', mb_list_encodings()),
true
);
return $this->buildEmailData($subject, $body, $text);
}
public function getMailBodyWithCustomizer(SubscriberEntity $subscriber, array $segmentNames): array {
$newsletter = $this->confirmationEmailCustomizer->getNewsletter();
$renderedNewsletter = $this->confirmationEmailCustomizer->render($newsletter);
$stringBody = Helpers::joinObject($renderedNewsletter);
// replace list of segments shortcode
$body = (string)str_replace(
'[lists_to_confirm]',
join(', ', $segmentNames),
$stringBody
);
// replace activation link
$body = (string)str_replace(
[
'http://[activation_link]', // See MAILPOET-5253
'[activation_link]',
],
$this->subscriptionUrlFactory->getConfirmationUrl($subscriber),
$body
);
[
$html,
$text,
$subject,
] = Helpers::splitObject(Shortcodes::process($body, null, $newsletter, $subscriber, null));
return $this->buildEmailData($subject, $html, $text);
}
/**
* @throws \Exception if unable to send the email.
*/
public function sendConfirmationEmail(SubscriberEntity $subscriber) {
$signupConfirmation = $this->settings->get('signup_confirmation');
if ((bool)$signupConfirmation['enabled'] === false) {
return false;
}
if (!$this->wp->isUserLoggedIn() && $subscriber->getConfirmationsCount() >= self::MAX_CONFIRMATION_EMAILS) {
return false;
}
$authorizationEmailsValidation = $this->settings->get(AuthorizedEmailsController::AUTHORIZED_EMAIL_ADDRESSES_ERROR_SETTING);
$unauthorizedSenderEmail = isset($authorizationEmailsValidation['invalid_sender_address']);
if (Bridge::isMPSendingServiceEnabled() && $unauthorizedSenderEmail) {
return false;
}
$segments = $subscriber->getSegments()->toArray();
$segmentNames = array_map(function(SegmentEntity $segment) {
return $segment->getName();
}, $segments);
$IsConfirmationEmailCustomizerEnabled = (bool)$this->settings->get(ConfirmationEmailCustomizer::SETTING_ENABLE_EMAIL_CUSTOMIZER, false);
$email = $IsConfirmationEmailCustomizerEnabled ?
$this->getMailBodyWithCustomizer($subscriber, $segmentNames) :
$this->getMailBody($signupConfirmation, $subscriber, $segmentNames);
// send email
$extraParams = [
'meta' => $this->mailerMetaInfo->getConfirmationMetaInfo($subscriber),
];
// Don't attempt to send confirmation email when sending is paused
$confirmationEmailErrorMessage = __('There was an error when sending a confirmation email for your subscription. Please contact the website owner.', 'mailpoet');
if (MailerLog::isSendingPaused()) {
throw new \Exception($confirmationEmailErrorMessage);
}
try {
$defaultMailer = $this->mailerFactory->getDefaultMailer();
$result = $defaultMailer->send($email, $subscriber, $extraParams);
} catch (\Exception $e) {
MailerLog::processTransactionalEmailError(MailerError::OPERATION_CONNECT, $e->getMessage(), $e->getCode());
throw new \Exception($confirmationEmailErrorMessage);
}
if ($result['response'] === false) {
if ($result['error'] instanceof MailerError && $result['error']->getLevel() === MailerError::LEVEL_HARD) {
MailerLog::processTransactionalEmailError($result['error']->getOperation(), (string)$result['error']->getMessage());
}
throw new \Exception($confirmationEmailErrorMessage);
};
// E-mail was successfully sent we need to update the MailerLog
MailerLog::incrementSentCount();
if (!$this->wp->isUserLoggedIn()) {
$subscriber->setConfirmationsCount($subscriber->getConfirmationsCount() + 1);
$this->subscribersRepository->persist($subscriber);
$this->subscribersRepository->flush();
}
$this->sentEmails[$subscriber->getId()] = true;
return true;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Segments\DynamicSegments\Filters\FilterHelper;
use MailPoet\Segments\DynamicSegments\Filters\WooFilterHelper;
use MailPoet\WooCommerce\Helper;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Result;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class EngagementDataBackfiller {
/** @var EntityManager */
private $entityManager;
/** @var Helper */
private $wcHelper;
/** @var WooFilterHelper */
private $wooFilterHelper;
/** @var FilterHelper */
private $filterHelper;
/** @var int */
private $lastProcessedSubscriberId = 0;
public function __construct(
EntityManager $entityManager,
WooFilterHelper $wooFilterHelper,
FilterHelper $filterHelper,
Helper $wcHelper
) {
$this->entityManager = $entityManager;
$this->wcHelper = $wcHelper;
$this->wooFilterHelper = $wooFilterHelper;
$this->filterHelper = $filterHelper;
}
/**
* @return SubscriberEntity[]
*/
public function getBatch(int $lastProcessedId = 0, int $batchSize = 100): array {
$subscribers = $this->entityManager->createQueryBuilder()
->select('PARTIAL s.{id, email, lastPurchaseAt, lastClickAt, lastOpenAt, lastSendingAt}')
->from(SubscriberEntity::class, 's')
->where('s.id > :lastProcessedId')
->orderBy('s.id', 'ASC')
->setMaxResults($batchSize)
->setParameter('lastProcessedId', $lastProcessedId)
->getQuery()
->getResult();
if (!is_array($subscribers)) {
return [];
}
return $subscribers;
}
/**
* @param SubscriberEntity[] $subscribers
*
* @return void
*/
public function updateBatch(array $subscribers): void {
$subscriberIds = array_map(function (SubscriberEntity $subscriber) {
return $subscriber->getId();
}, $subscribers);
$clickData = $this->getClickDataForBatch($subscriberIds);
$openData = $this->getOpenDataForBatch($subscriberIds);
$sendingData = $this->getSendingDataForBatch($subscriberIds);
$purchaseData = $this->getPurchaseDataForBatch($subscriberIds);
foreach ($subscribers as $subscriber) {
if ($subscriber->getLastPurchaseAt() === null && isset($purchaseData[$subscriber->getId()]['last_purchase_at'])) {
$purchaseDate = new Carbon($purchaseData[$subscriber->getId()]['last_purchase_at']);
$subscriber->setLastPurchaseAt($purchaseDate);
}
if ($subscriber->getLastOpenAt() === null && isset($openData[$subscriber->getId()]['last_open_at'])) {
$openDate = new Carbon($openData[$subscriber->getId()]['last_open_at']);
$subscriber->setLastOpenAt($openDate);
}
if ($subscriber->getLastClickAt() === null && isset($clickData[$subscriber->getId()]['last_click_at'])) {
$clickDate = new Carbon($clickData[$subscriber->getId()]['last_click_at']);
$subscriber->setLastClickAt($clickDate);
}
if ($subscriber->getLastSendingAt() === null && isset($sendingData[$subscriber->getId()]['last_sending_at'])) {
$sendingDate = new Carbon($sendingData[$subscriber->getId()]['last_sending_at']);
$subscriber->setLastSendingAt($sendingDate);
}
if (is_int($subscriber->getId())) {
$this->lastProcessedSubscriberId = $subscriber->getId();
}
}
$this->entityManager->flush();
}
public function getClickDataForBatch(array $subscriberIds): array {
$subscribersTable = $this->filterHelper->getSubscribersTable();
$clicksTable = $this->filterHelper->getTableForEntity(StatisticsClickEntity::class);
$query = $this
->entityManager
->getConnection()
->createQueryBuilder()
->select("$subscribersTable.id, MAX(clicks.created_at) as last_click_at")
->from($subscribersTable)
->innerJoin($subscribersTable, $clicksTable, 'clicks', "$subscribersTable.id = clicks.subscriber_id")
->andWhere("$subscribersTable.id IN (:subscriberIds)")
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
->groupBy("$subscribersTable.id");
$result = $query->execute();
if ($result instanceof Result) {
return $result->fetchAllAssociativeIndexed();
}
return [];
}
public function getPurchaseDataForBatch(array $subscriberIds): array {
if (!$this->wcHelper->isWooCommerceActive()) {
return [];
}
$subscribersTable = $this->filterHelper->getSubscribersTable();
$query = $this
->entityManager
->getConnection()
->createQueryBuilder()
// The orderStats alias comes from wooFilterHelper->applyOrderStatusFilter, which calls wooFilterHelper->applyCustomerOrderJoin
->select("$subscribersTable.id, MAX(orderStats.date_created) as last_purchase_at")
->from($subscribersTable)
->andWhere("$subscribersTable.id IN (:subscriberIds)")
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER);
$this->wooFilterHelper->applyOrderStatusFilter($query);
$query->groupBy("$subscribersTable.id");
$result = $query->execute();
if ($result instanceof Result) {
return $result->fetchAllAssociativeIndexed();
}
return [];
}
public function getOpenDataForBatch(array $subscriberIds): array {
$subscribersTable = $this->filterHelper->getSubscribersTable();
$opensTable = $this->filterHelper->getTableForEntity(StatisticsOpenEntity::class);
$query = $this
->entityManager
->getConnection()
->createQueryBuilder()
->select("$subscribersTable.id, MAX(opens.created_at) as last_open_at")
->from($subscribersTable)
->innerJoin($subscribersTable, $opensTable, 'opens', "$subscribersTable.id = opens.subscriber_id")
->andWhere("$subscribersTable.id IN (:subscriberIds)")
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
->groupBy("$subscribersTable.id");
$result = $query->execute();
if ($result instanceof Result) {
return $result->fetchAllAssociativeIndexed();
}
return [];
}
public function getSendingDataForBatch(array $subscriberIds): array {
$subscribersTable = $this->filterHelper->getSubscribersTable();
$sendsTable = $this->filterHelper->getTableForEntity(StatisticsNewsletterEntity::class);
$query = $this
->entityManager
->getConnection()
->createQueryBuilder()
->select("$subscribersTable.id, MAX(sends.sent_at) as last_sending_at")
->from($subscribersTable)
->innerJoin($subscribersTable, $sendsTable, 'sends', "$subscribersTable.id = sends.subscriber_id")
->andWhere("$subscribersTable.id IN (:subscriberIds)")
->setParameter('subscriberIds', $subscriberIds, ArrayParameterType::INTEGER)
->groupBy("$subscribersTable.id");
$result = $query->execute();
if ($result instanceof Result) {
return $result->fetchAllAssociativeIndexed();
}
return [];
}
/**
* @return int
*/
public function getLastProcessedSubscriberId(): int {
return $this->lastProcessedSubscriberId;
}
public function setLastProcessedSubscriberId(int $id): void {
$this->lastProcessedSubscriberId = $id;
}
}
@@ -0,0 +1,301 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\Export;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Env;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Subscribers\ImportExport\ImportExportFactory;
use MailPoet\Subscribers\ImportExport\ImportExportRepository;
use MailPoet\Util\Security;
use MailPoetVendor\XLSXWriter;
class Export {
const SUBSCRIBER_BATCH_SIZE = 15000;
public $exportFormatOption;
public $subscriberFields;
public $subscriberCustomFields;
public $formattedSubscriberFields;
public $formattedSubscriberFieldsWithList;
public $exportPath;
public $exportFile;
public $exportFileURL;
/** @var int */
private $subscribersOffset;
/** @var array<SegmentEntity|null> null value is for subscribers without a list */
private $segments;
/** @var int */
private $segmentIndex;
/** @var CustomFieldsRepository */
private $customFieldsRepository;
/** @var ImportExportRepository */
private $importExportRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
CustomFieldsRepository $customFieldsRepository,
ImportExportRepository $importExportRepository,
SegmentsRepository $segmentsRepository,
array $data
) {
$this->customFieldsRepository = $customFieldsRepository;
$this->importExportRepository = $importExportRepository;
$this->segmentsRepository = $segmentsRepository;
if (strpos((string)@ini_get('disable_functions'), 'set_time_limit') === false) {
set_time_limit(0);
}
$this->subscribersOffset = 0;
$this->segmentIndex = 0;
$this->segments = $this->getSegments($data['segments']);
$this->exportFormatOption = $data['export_format_option'];
$this->subscriberFields = $data['subscriber_fields'];
$this->subscriberCustomFields = $this->getSubscriberCustomFields();
$this->formattedSubscriberFields = $this->formatSubscriberFields(
$this->subscriberFields,
$this->subscriberCustomFields
);
$this->formattedSubscriberFieldsWithList = $this->formattedSubscriberFields;
$this->formattedSubscriberFieldsWithList[] = __('List', 'mailpoet');
$this->exportPath = self::getExportPath();
$this->exportFile = $this->getExportFile($this->exportFormatOption);
$this->exportFileURL = $this->getExportFileURL($this->exportFile);
}
public static function getFilePrefix() {
return 'MailPoet_export_';
}
public static function getExportPath() {
return Env::$tempPath;
}
public function process(): array {
$processedSubscribers = 0;
$this->resetCounters();
try {
if (is_writable($this->exportPath) === false) {
throw new \Exception(__('The export file could not be saved on the server.', 'mailpoet'));
}
if (!extension_loaded('zip') && ($this->exportFormatOption === 'xlsx')) {
throw new \Exception(__('Export requires a ZIP extension to be installed on the host.', 'mailpoet'));
}
$callback = [
$this,
'generate' . strtoupper($this->exportFormatOption),
];
if (is_callable($callback)) {
$processedSubscribers = call_user_func($callback);
}
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
return [
'totalExported' => $processedSubscribers,
'exportFileURL' => $this->exportFileURL,
];
}
public function generateCSV(): int {
$processedSubscribers = 0;
$formattedSubscriberFields = $this->formattedSubscriberFieldsWithList;
$cSVFile = fopen($this->exportFile, 'w');
if ($cSVFile === false) {
throw new \Exception(__('Failed opening file for export.', 'mailpoet'));
}
$formatCSV = function($row) {
return '"' . str_replace('"', '\"', (string)$row) . '"';
};
// add UTF-8 BOM (3 bytes, hex EF BB BF) at the start of the file for
// Excel to automatically recognize the encoding
fwrite($cSVFile, chr(0xEF) . chr(0xBB) . chr(0xBF));
fwrite(
$cSVFile,
implode(
',',
array_map(
$formatCSV,
$formattedSubscriberFields
)
) . PHP_EOL
);
while (($subscribers = $this->getSubscribers()) !== null) {
$processedSubscribers += count($subscribers);
foreach ($subscribers as $subscriber) {
$row = $this->formatSubscriberData($subscriber);
$row[] = ucwords($subscriber['segment_name']);
fwrite($cSVFile, implode(',', array_map($formatCSV, $row)) . "\n");
}
}
fclose($cSVFile);
return $processedSubscribers;
}
public function generateXLSX(): int {
$processedSubscribers = 0;
$xLSXWriter = new XLSXWriter();
$xLSXWriter->setAuthor('MailPoet (www.mailpoet.com)');
$lastSegment = false;
$processedSegments = [];
while (($subscribers = $this->getSubscribers()) !== null) {
$processedSubscribers += count($subscribers);
foreach ($subscribers as $i => $subscriber) {
$currentSegment = ucwords($subscriber['segment_name']);
// Sheet header (1st row) will be written only if:
// * This is the first time we're processing a segment
// * The previous subscriber's segment is different from the current subscriber's segment
// Header will NOT be written if:
// * We have already processed the segment. Because SQL results are not
// sorted by segment name (due to slow queries when using ORDER BY and LIMIT),
// we need to keep track of processed segments so that we do not create header
// multiple times when switching from one segment to another and back.
if (
(!count($processedSegments) || $lastSegment !== $currentSegment) &&
(!in_array($lastSegment, $processedSegments) || !in_array($currentSegment, $processedSegments))
) {
$this->writeXLSX(
$xLSXWriter,
$subscriber['segment_name'],
$this->formattedSubscriberFieldsWithList
);
$processedSegments[] = $currentSegment;
}
$lastSegment = ucwords($subscriber['segment_name']);
// detect RTL language and set Excel to properly display the sheet
$rTLRegex = '/\p{Arabic}|\p{Hebrew}/u';
if (
!$xLSXWriter->rtl && (
preg_grep($rTLRegex, $subscriber) ||
preg_grep($rTLRegex, $this->formattedSubscriberFieldsWithList))
) {
$xLSXWriter->rtl = true;
}
$xlsxData = $this->formatSubscriberData($subscriber);
$xlsxData[] = ucwords($subscriber['segment_name']);
$this->writeXLSX(
$xLSXWriter,
$lastSegment,
$xlsxData
);
}
}
$xLSXWriter->writeToFile($this->exportFile);
return $processedSubscribers;
}
public function writeXLSX($xLSXWriter, $segment, $data) {
return $xLSXWriter->writeSheetRow(ucwords($segment), $data);
}
public function getSubscribers(): ?array {
$segment = array_key_exists($this->segmentIndex, $this->segments) ? $this->segments[$this->segmentIndex] : false;
if ($segment === false) {
return null;
}
$subscribers = $this->importExportRepository->getSubscribersBatchBySegment(
$segment,
self::SUBSCRIBER_BATCH_SIZE,
$this->subscribersOffset
);
$this->subscribersOffset += count($subscribers);
if (count($subscribers) < self::SUBSCRIBER_BATCH_SIZE) {
$this->segmentIndex++;
$this->subscribersOffset = 0;
}
return $subscribers;
}
public function getExportFileURL($file): string {
return sprintf(
'%s/%s',
Env::$tempUrl,
basename($file)
);
}
public function getExportFile($format): string {
return sprintf(
$this->exportPath . '/' . self::getFilePrefix() . '%s.%s',
Security::generateRandomString(15),
$format
);
}
/**
* @return array<int, string>
*/
public function getSubscriberCustomFields(): array {
$result = [];
foreach ($this->customFieldsRepository->findAll() as $customField) {
$result[(int)$customField->getId()] = $customField->getName();
}
return $result;
}
/**
* @param array $segmentIds
* @return array<SegmentEntity|null> null value is for subscribers without a list
*/
private function getSegments(array $segmentIds): array {
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
$result = [];
foreach ($segmentIds as $segmentId) {
$segmentId = (int)$segmentId;
$segment = current(array_filter($segments, function (SegmentEntity $segment) use ($segmentId): bool {
return $segment->getId() === $segmentId;
})) ?: null;
if (!$segment && $segmentId !== 0) {
continue;
}
$result[] = $segment;
}
return $result;
}
private function resetCounters(): void {
$this->segmentIndex = 0;
$this->subscribersOffset = 0;
}
public function formatSubscriberFields($subscriberFields, $subscriberCustomFields): array {
$exportFactory = new ImportExportFactory('export');
$translatedFields = $exportFactory->getSubscriberFields();
return array_map(function($field) use (
$translatedFields, $subscriberCustomFields
) {
$field = (isset($translatedFields[$field])) ?
ucfirst($translatedFields[$field]) :
ucfirst($field);
return (isset($subscriberCustomFields[$field])) ?
ucfirst($subscriberCustomFields[$field]) : $field;
}, $subscriberFields);
}
public function formatSubscriberData($subscriber): array {
return array_map(function($field) use ($subscriber) {
return $subscriber[$field];
}, $this->subscriberFields);
}
}
@@ -0,0 +1,707 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\Import;
if (!defined('ABSPATH')) exit;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Entities\SubscriberTagEntity;
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
use MailPoet\Segments\WP;
use MailPoet\Services\Validator;
use MailPoet\Subscribers\ImportExport\ImportExportFactory;
use MailPoet\Subscribers\ImportExport\ImportExportRepository;
use MailPoet\Subscribers\Source;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Tags\TagRepository;
use MailPoet\Util\DateConverter;
use MailPoet\Util\Helpers;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class Import {
/** @var array */
public $subscribersData;
/** @var array */
public $segmentsIds;
/** @var string[] */
public $tags;
/** @var string */
public $newSubscribersStatus;
/** @var string */
public $existingSubscribersStatus;
/** @var bool */
public $updateSubscribers;
/** @var array */
public $subscribersFields;
/** @var array */
public $subscribersCustomFields;
/** @var int */
public $subscribersCount;
/** @var Carbon */
public $createdAt;
/** @var Carbon */
public $updatedAt;
/** @var array<string, mixed> */
public $requiredSubscribersFields;
const DB_QUERY_CHUNK_SIZE = 100;
const STATUS_DONT_UPDATE = 'dont_update';
public const ACTION_CREATE = 'create';
public const ACTION_UPDATE = 'update';
/** @var WP */
private $wpSegment;
/** @var CustomFieldsRepository */
private $customFieldsRepository;
/** @var ImportExportRepository */
private $importExportRepository;
/** @var NewsletterOptionsRepository */
private $newsletterOptionsRepository;
/** @var SubscribersRepository */
private $subscriberRepository;
/** @var TagRepository */
private $tagRepository;
/** @var Validator */
private $validator;
public function __construct(
WP $wpSegment,
CustomFieldsRepository $customFieldsRepository,
ImportExportRepository $importExportRepository,
NewsletterOptionsRepository $newsletterOptionsRepository,
SubscribersRepository $subscriberRepository,
TagRepository $tagRepository,
Validator $validator,
array $data
) {
$this->wpSegment = $wpSegment;
$this->customFieldsRepository = $customFieldsRepository;
$this->importExportRepository = $importExportRepository;
$this->newsletterOptionsRepository = $newsletterOptionsRepository;
$this->subscriberRepository = $subscriberRepository;
$this->tagRepository = $tagRepository;
$this->validator = $validator;
$this->validateImportData($data);
$this->subscribersData = $this->transformSubscribersData(
$data['subscribers'],
$data['columns']
);
$this->segmentsIds = $data['segments'];
$this->tags = $data['tags'];
$this->newSubscribersStatus = $data['newSubscribersStatus'];
$this->existingSubscribersStatus = $data['existingSubscribersStatus'];
$this->updateSubscribers = $data['updateSubscribers'];
$this->subscribersFields = $this->getSubscribersFields(
array_keys($data['columns'])
);
$this->subscribersCustomFields = $this->getCustomSubscribersFields(
array_keys($data['columns'])
);
$this->subscribersCount = (reset($this->subscribersData) === false) ? 0 : count(reset($this->subscribersData));
$this->createdAt = Carbon::now()->millisecond(0);
$this->updatedAt = Carbon::createFromTimestamp(WPFunctions::get()->currentTime('timestamp', true) + 1);
$this->requiredSubscribersFields = [
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
'first_name' => '',
'last_name' => '',
'created_at' => $this->createdAt,
];
}
public function validateImportData(array $data): void {
$requiredDataFields = [
'subscribers',
'columns',
'segments',
'timestamp',
'newSubscribersStatus',
'existingSubscribersStatus',
'updateSubscribers',
'tags',
];
// 1. data should contain all required fields
// 2. column names should only contain alphanumeric & underscore characters
if (
count(array_intersect_key(array_flip($requiredDataFields), $data)) !== count($requiredDataFields) ||
preg_grep('/[^a-zA-Z0-9_]/', array_keys($data['columns']))
) {
throw new \Exception(__('Missing or invalid import data.', 'mailpoet'));
}
}
/**
* @return array{created: int, updated:int, segments: array, added_to_segment_with_welcome_notification:bool}
* @throws \Exception
*/
public function process(): array {
// validate data based on field validation rules
$subscribersData = $this->validateSubscribersData($this->subscribersData);
if (!$subscribersData) {
throw new \Exception(__('No valid subscribers were found.', 'mailpoet'));
}
// permanently trash deleted subscribers
$this->deleteExistingTrashedSubscribers($subscribersData);
// split subscribers into "existing" and "new" and free up memory
$existingSubscribers = $newSubscribers = [
'data' => [],
'fields' => $this->subscribersFields,
];
list($existingSubscribers['data'], $newSubscribers['data'], $wpUsers) =
$this->splitSubscribersData($subscribersData);
$subscribersData = null;
// create or update subscribers
$createdSubscribers = $updatedSubscribers = [];
try {
if ($newSubscribers['data']) {
// add, if required, missing required fields to new subscribers
$newSubscribers = $this->addMissingRequiredFields($newSubscribers);
$newSubscribers = $this->setSubscriptionStatusToDefault($newSubscribers, $this->newSubscribersStatus);
$newSubscribers = $this->setSource($newSubscribers);
$newSubscribers = $this->setLinkToken($newSubscribers);
$createdSubscribers =
$this->createOrUpdateSubscribers(
self::ACTION_CREATE,
$newSubscribers,
$this->subscribersCustomFields
);
}
$updateExistingSubscribersStatus = false;
if ($existingSubscribers['data']) {
$allowedStatuses = [
SubscriberEntity::STATUS_SUBSCRIBED,
SubscriberEntity::STATUS_UNSUBSCRIBED,
SubscriberEntity::STATUS_INACTIVE,
];
if (in_array($this->existingSubscribersStatus, $allowedStatuses, true)) {
$updateExistingSubscribersStatus = true;
$existingSubscribers = $this->addField($existingSubscribers, 'status', $this->existingSubscribersStatus);
}
if ($this->updateSubscribers) {
// Update existing subscribers' info (first_name, last_name etc.)
// as well as status (optionally) if the status column was added above
$updatedSubscribers =
$this->createOrUpdateSubscribers(
self::ACTION_UPDATE,
$existingSubscribers,
$this->subscribersCustomFields
);
if ($wpUsers) {
$this->synchronizeWPUsers($wpUsers);
}
} elseif ($updateExistingSubscribersStatus) {
// Only update existing subscribers' status
// For this we need to remove all other fields except email and status
$existingSubscribers['fields'] = array_intersect($existingSubscribers['fields'], ['email', 'status']);
$existingSubscribers['data'] = array_intersect_key($existingSubscribers['data'], array_flip(['email', 'status']));
$updatedSubscribers =
$this->createOrUpdateSubscribers(
self::ACTION_UPDATE,
$existingSubscribers
);
}
}
} catch (\Exception $e) {
throw new \Exception(__('Unable to save imported subscribers.', 'mailpoet'));
}
// check if any subscribers were added to segments that have welcome notifications configured
$importFactory = new ImportExportFactory('import');
$segments = $importFactory->getSegments();
$welcomeNotificationsInSegments =
($createdSubscribers || $updatedSubscribers) ?
$this->newsletterOptionsRepository->findWelcomeNotificationsForSegments($this->segmentsIds) :
false;
return [
'created' => is_array($createdSubscribers) ? count($createdSubscribers) : 0,
'updated' => is_array($updatedSubscribers) ? count($updatedSubscribers) : 0,
'segments' => $segments,
'added_to_segment_with_welcome_notification' =>
($welcomeNotificationsInSegments) ? true : false,
];
}
/**
* @param array $subscribersData
* @return false|array
*/
public function validateSubscribersData(array $subscribersData) {
$invalidRecords = [];
foreach ($subscribersData as $column => &$data) {
if ($column === 'email') {
$data = array_map(
function($index, $email) use(&$invalidRecords) {
if (!$this->validator->validateNonRoleEmail($email)) {
$invalidRecords[] = $index;
}
return strtolower($email);
},
array_keys($data),
$data
);
}
if (in_array($column, ['created_at', 'confirmed_at'], true)) {
$data = $this->validateDateTime($data, $invalidRecords);
}
if (in_array($column, ['confirmed_ip', 'subscribed_ip'], true)) {
$data = array_map(
function($index, $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
// if invalid or empty, we allow the import but remove the IP
return null;
}
return $ip;
},
array_keys($data),
$data
);
}
// if this is a custom column
if (in_array($column, $this->subscribersCustomFields)) {
$customField = $this->customFieldsRepository->findOneById($column);
if (!$customField instanceof CustomFieldEntity) {
continue;
}
// validate date type
if ($customField->getType() === CustomFieldEntity::TYPE_DATE) {
$data = $this->validateDateTime($data, $invalidRecords);
}
}
}
if ($invalidRecords) {
foreach ($subscribersData as $column => &$data) {
$data = array_diff_key($data, array_flip($invalidRecords));
$data = array_values($data);
}
}
if (empty($subscribersData['email'])) return false;
return $subscribersData;
}
private function validateDateTime(array $data, array &$invalidRecords): array {
$siteUsesCustomFormat = WPFunctions::get()->getOption('date_format') === 'd/m/Y';
if ($siteUsesCustomFormat) {
return $this->validateDateTimeAttemptCustomFormat($data, $invalidRecords);
}
$validationRule = 'datetime';
return array_map(
function ($index, $date) use ($validationRule, &$invalidRecords) {
if (empty($date)) return $date;
$date = (new DateConverter())->convertDateToDatetime($date, $validationRule);
if (!$date) {
$invalidRecords[] = $index;
}
return $date;
},
array_keys($data),
$data
);
}
private function validateDateTimeAttemptCustomFormat(array $data, array &$invalidRecords): array {
$validationRule = 'datetime';
$dateTimeDates = $data;
$dateTimeInvalidRecords = $invalidRecords;
$datetimeErrorCount = 0;
$validationRuleCustom = 'd/m/Y';
$customFormatDates = $data;
$customFormatInvalidRecords = $invalidRecords;
$customFormatErrorCount = 0;
// We attempt converting with both date formats
foreach ($data as $index => $date) {
if (empty($date)) {
$dateTimeDates[$index] = $date;
$customFormatDates[$index] = $date;
continue;
};
$dateTimeDates[$index] = (new DateConverter())->convertDateToDatetime($date, $validationRule);
if (!$dateTimeDates[$index]) {
$datetimeErrorCount ++;
$dateTimeInvalidRecords[] = $index;
}
$customFormatDates[$index] = (new DateConverter())->convertDateToDatetime($date, $validationRuleCustom);
if (!$customFormatDates[$index]) {
$customFormatErrorCount ++;
$customFormatInvalidRecords[] = $index;
}
}
if ($customFormatErrorCount < $datetimeErrorCount) {
$invalidRecords = $customFormatInvalidRecords;
return $customFormatDates;
}
$invalidRecords = $dateTimeInvalidRecords;
return $dateTimeDates;
}
public function transformSubscribersData(array $subscribers, array $columns): array {
$transformedSubscribers = [];
foreach ($columns as $column => $data) {
$transformedSubscribers[$column] = array_column($subscribers, $data['index']);
}
return $transformedSubscribers;
}
/**
* @param array $subscribersData
* @return array{array|false,array,array|false}
*/
public function splitSubscribersData(array $subscribersData): array {
// $subscribers_data is an two-dimensional associative array
// of all subscribers being imported: [field => [value1, value2], field => [value1, value2], ...]
$tempExistingSubscribers = [];
foreach (array_chunk($subscribersData['email'], self::DB_QUERY_CHUNK_SIZE) as $subscribersEmails) {
// create a two-dimensional indexed array of all existing subscribers
// with just wp_user_id and email fields: [[wp_user_id, email], [wp_user_id, email], ...]
$tempExistingSubscribers = array_merge(
$tempExistingSubscribers,
$this->subscriberRepository->findWpUserIdAndEmailByEmails($subscribersEmails)
);
}
if (!$tempExistingSubscribers) {
return [
false, // existing subscribers
$subscribersData, // new subscribers
false, // WP users
];
}
// extract WP users ids into a simple indexed array: [wp_user_id_1, wp_user_id_2, ...]
$wpUsers = array_filter(array_column($tempExistingSubscribers, 'wp_user_id'));
// create a new two-dimensional associative array with existing subscribers ($existing_subscribers)
// and reduce $subscribers_data to only new subscribers by removing existing subscribers
$existingSubscribers = [];
$subscribersEmails = array_flip($subscribersData['email']);
foreach ($tempExistingSubscribers as $tempExistingSubscriber) {
$existingSubscriberKey = $subscribersEmails[$tempExistingSubscriber['email']];
foreach ($subscribersData as $field => &$value) {
$existingSubscribers[$field][] = $value[$existingSubscriberKey];
unset($value[$existingSubscriberKey]);
}
}
$newSubscribers = $subscribersData;
// reindex array after unsetting elements
$newSubscribers = array_map('array_values', $newSubscribers);
// remove empty values
$newSubscribers = array_filter($newSubscribers);
return [
$existingSubscribers,
$newSubscribers,
$wpUsers,
];
}
public function deleteExistingTrashedSubscribers(array $subscribersData): void {
$existingTrashedRecords = array_filter(
array_map(function($subscriberEmails) {
return $this->subscriberRepository->findIdsOfDeletedByEmails($subscriberEmails);
}, array_chunk($subscribersData['email'], self::DB_QUERY_CHUNK_SIZE))
);
$existingTrashedRecords = Helpers::flattenArray($existingTrashedRecords);
if (!$existingTrashedRecords) {
return;
}
foreach (array_chunk($existingTrashedRecords, self::DB_QUERY_CHUNK_SIZE) as $subscriberIds) {
$this->subscriberRepository->bulkDelete($subscriberIds);
}
}
public function addMissingRequiredFields(array $subscribers): array {
foreach (array_keys($this->requiredSubscribersFields) as $requiredField) {
$subscribers = $this->addField($subscribers, $requiredField, $this->requiredSubscribersFields[$requiredField]);
}
return $subscribers;
}
/**
* @param array $subscribers
* @param string $fieldName
* @param mixed $fieldValue
* @return array
*/
private function addField(array $subscribers, string $fieldName, $fieldValue): array {
if (in_array($fieldName, $subscribers['fields'])) return $subscribers;
$subscribersCount = count($subscribers['data'][key($subscribers['data'])]);
$subscribers['data'][$fieldName] = array_fill(
0,
$subscribersCount,
$fieldValue
);
$subscribers['fields'][] = $fieldName;
return $subscribers;
}
private function setSubscriptionStatusToDefault(array $subscribersData, string $defaultStatus): array {
if (!in_array('status', $subscribersData['fields'])) return $subscribersData;
$subscribersData['data']['status'] = array_map(function() use ($defaultStatus) {
return $defaultStatus;
}, $subscribersData['data']['status']);
if ($defaultStatus === SubscriberEntity::STATUS_SUBSCRIBED) {
if (!in_array('last_subscribed_at', $subscribersData['fields'])) {
$subscribersData['fields'][] = 'last_subscribed_at';
}
$subscribersData['data']['last_subscribed_at'] = array_map(function() {
return $this->createdAt;
}, $subscribersData['data']['status']);
}
return $subscribersData;
}
private function setSource(array $subscribersData): array {
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
$subscribersData['fields'][] = 'source';
$subscribersData['data']['source'] = array_fill(
0,
$subscribersCount,
Source::IMPORTED
);
return $subscribersData;
}
private function setLinkToken(array $subscribersData): array {
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
$subscribersData['fields'][] = 'link_token';
$subscribersData['data']['link_token'] = array_map(
function () {
return Security::generateRandomString(SubscriberEntity::LINK_TOKEN_LENGTH);
},
array_fill(0, $subscribersCount, null)
);
return $subscribersData;
}
public function getSubscribersFields(array $subscribersFields): array {
return array_values(
array_filter(
array_map(function($field) {
if (!is_int($field)) return $field;
}, $subscribersFields)
)
);
}
/**
* @param array $subscribersFields
* @return int[]
*/
public function getCustomSubscribersFields(array $subscribersFields): array {
return array_values(
array_filter(
array_map(function($field) {
if (is_int($field)) return $field;
}, $subscribersFields)
)
);
}
public function createOrUpdateSubscribers(
string $action,
array $subscribersData,
array $subscribersCustomFields = []
): ?array {
$subscribersCount = count($subscribersData['data'][key($subscribersData['data'])]);
$subscribers = array_map(function($index) use ($subscribersData) {
return array_map(function($field) use ($index, $subscribersData) {
return $subscribersData['data'][$field][$index];
}, $subscribersData['fields']);
}, range(0, $subscribersCount - 1));
foreach (array_chunk($subscribers, self::DB_QUERY_CHUNK_SIZE) as $data) {
if ($action === self::ACTION_CREATE) {
$this->importExportRepository->insertMultiple(
SubscriberEntity::class,
$subscribersData['fields'],
$data
);
} elseif ($action === self::ACTION_UPDATE) {
$this->importExportRepository->updateMultiple(
SubscriberEntity::class,
$subscribersData['fields'],
$data,
$this->updatedAt
);
}
}
$createdOrUpdatedSubscribers = [];
foreach (array_chunk($subscribersData['data']['email'], self::DB_QUERY_CHUNK_SIZE) as $emails) {
foreach ($this->subscriberRepository->findIdAndEmailByEmails($emails) as $createdOrUpdatedSubscriber) {
// ensure emails loaded from the DB are lowercased (imported emails are lowercased as well)
$createdOrUpdatedSubscriber['email'] = mb_strtolower($createdOrUpdatedSubscriber['email']);
$createdOrUpdatedSubscribers[] = $createdOrUpdatedSubscriber;
}
}
if (empty($createdOrUpdatedSubscribers)) return null;
$this->subscriberRepository->invalidateTotalSubscribersCache();
$createdOrUpdatedSubscribersIds = array_column($createdOrUpdatedSubscribers, 'id');
if ($subscribersCustomFields) {
$this->createOrUpdateCustomFields(
$action,
$createdOrUpdatedSubscribers,
$subscribersData,
$subscribersCustomFields
);
}
$this->addSubscribersToSegments(
$createdOrUpdatedSubscribersIds,
$this->segmentsIds
);
$this->addTagsToSubscribers(
$createdOrUpdatedSubscribersIds,
$this->tags
);
return $createdOrUpdatedSubscribers;
}
public function createOrUpdateCustomFields(
string $action,
array $createdOrUpdatedSubscribers,
array $subscribersData,
array $subscribersCustomFieldsIds
): void {
// check if custom fields exist in the database
$subscribersCustomFieldsIds = array_map(function(CustomFieldEntity $customField): int {
return (int)$customField->getId();
}, $this->customFieldsRepository->findBy(['id' => $subscribersCustomFieldsIds]));
if (!$subscribersCustomFieldsIds) {
return;
}
// assemble a two-dimensional array: [[custom_field_id, subscriber_id, value], [custom_field_id, subscriber_id, value], ...]
$subscribersCustomFieldsData = [];
$subscribersEmails = array_flip($subscribersData['data']['email']);
foreach ($createdOrUpdatedSubscribers as $createdOrUpdatedSubscriber) {
$subscriberIndex = $subscribersEmails[$createdOrUpdatedSubscriber['email']];
foreach ($subscribersData['data'] as $field => $values) {
// exclude non-custom fields
if (!is_int($field)) continue;
$subscribersCustomFieldsData[] = [
(int)$field,
$createdOrUpdatedSubscriber['id'],
$values[$subscriberIndex],
$this->createdAt,
];
}
}
$columns = [
'custom_field_id',
'subscriber_id',
'value',
'created_at',
];
$customFieldCount = count($subscribersCustomFieldsIds);
$customFieldBatchSize = (int)(round(self::DB_QUERY_CHUNK_SIZE / $customFieldCount) * $customFieldCount);
$customFieldBatchSize = ($customFieldBatchSize > 0) ? $customFieldBatchSize : 1;
foreach (array_chunk($subscribersCustomFieldsData, $customFieldBatchSize) as $subscribersCustomFieldsDataChunk) {
$this->importExportRepository->insertMultiple(
SubscriberCustomFieldEntity::class,
$columns,
$subscribersCustomFieldsDataChunk
);
if ($action === self::ACTION_UPDATE) {
$this->importExportRepository->updateMultiple(
SubscriberCustomFieldEntity::class,
$columns,
$subscribersCustomFieldsDataChunk,
$this->updatedAt
);
}
}
}
/**
* @param int[] $wpUsers
* @return array
*/
public function synchronizeWPUsers(array $wpUsers): array {
$users = array_map([$this->wpSegment, 'synchronizeUser'], $wpUsers);
$this->subscriberRepository->invalidateTotalSubscribersCache();
return $users;
}
public function addSubscribersToSegments(array $subscribersIds, array $segmentsIds): void {
$columns = [
'subscriber_id',
'segment_id',
'created_at',
];
foreach ($segmentsIds as $segmentId) {
foreach (array_chunk($subscribersIds, self::DB_QUERY_CHUNK_SIZE) as $subscriberIdsChunk) {
$data = [];
$data = array_merge($data, array_map(function ($subscriberId) use ($segmentId): array {
return [
$subscriberId,
$segmentId,
$this->createdAt,
];
}, $subscriberIdsChunk));
$this->importExportRepository->insertMultiple(
SubscriberSegmentEntity::class,
$columns,
$data
);
}
}
}
/**
* @param int[] $subscribersIds
* @param string[] $tagNames
*/
public function addTagsToSubscribers(array $subscribersIds, array $tagNames): void {
$tagIds = [];
foreach ($tagNames as $tagName) {
$tag = $this->tagRepository->findOneBy(['name' => $tagName]);
if (!$tag) {
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
}
$tagIds[] = $tag->getId();
}
$columns = [
'subscriber_id',
'tag_id',
'created_at',
];
foreach ($tagIds as $tagId) {
foreach (array_chunk($subscribersIds, self::DB_QUERY_CHUNK_SIZE) as $subscriberIdsChunk) {
$data = [];
$data = array_merge($data, array_map(function ($subscriberId) use ($tagId): array {
return [
$subscriberId,
$tagId,
$this->createdAt,
];
}, $subscriberIdsChunk));
$this->importExportRepository->insertMultiple(
SubscriberTagEntity::class,
$columns,
$data
);
}
}
}
}
@@ -0,0 +1,207 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\Import;
if (!defined('ABSPATH')) exit;
use MailPoet\Util\Helpers;
class MailChimp {
private const API_BASE_URI = 'https://user:%s@%s.api.mailchimp.com/3.0/';
private const API_KEY_REGEX = '/[a-zA-Z0-9]{32}-[a-zA-Z0-9]{2,4}$/';
private const API_BATCH_SIZE = 100;
/** @var false|string */
public $apiKey;
/** @var int */
public $maxPostSize;
/** @var false|string */
public $dataCenter;
/** @var MailChimpDataMapper */
private $mapper;
public function __construct(
string $apiKey
) {
$this->apiKey = $this->getAPIKey($apiKey);
$this->maxPostSize = (int)Helpers::getMaxPostSize('bytes');
$this->dataCenter = $this->getDataCenter($this->apiKey);
$this->mapper = new MailChimpDataMapper();
}
public function getLists(): array {
if (!$this->apiKey || !$this->dataCenter) {
$this->throwException('API');
}
$lists = [];
$count = 0;
while (true) {
$data = $this->getApiData('lists', $count);
if ($data === null) {
$this->throwException('lists');
break;
}
$count += count($data['lists']);
foreach ($data['lists'] as $list) {
$lists[] = [
'id' => $list['id'],
'name' => $list['name'],
];
}
if ($data['total_items'] <= $count) {
break;
}
}
return $lists;
}
public function getSubscribers(array $lists = []): array {
if (!$this->apiKey || !$this->dataCenter) {
$this->throwException('API');
}
if (!$lists) {
$this->throwException('lists');
}
$subscribers = [];
$duplicate = [];
$disallowed = [];
foreach ($lists as $list) {
$count = 0;
while (true) {
$data = $this->getApiData("lists/{$list}/members", $count);
if ($data === null) {
$this->throwException('lists');
break;
}
$count += count($data['members']);
foreach ($data['members'] as $member) {
$emailAddress = $member['email_address'];
if (!$this->isSubscriberAllowed($member)) {
$disallowed[$emailAddress] = $this->mapper->mapMember($member);
} elseif (isset($subscribers[$emailAddress])) {
$duplicate[$emailAddress] = $this->mapper->mapMember($member);
} else {
$subscribers[$emailAddress] = $this->mapper->mapMember($member);
}
}
if ($data['total_items'] <= $count) {
break;
}
}
}
if (!count($subscribers)) {
$this->throwException('subscribers');
}
return [
'subscribers' => array_values($subscribers),
'invalid' => [],
'duplicate' => $duplicate,
'disallowed' => $disallowed,
'role' => [],
'header' => $this->mapper->getMembersHeader(),
'subscribersCount' => count($subscribers),
];
}
/**
* @param string|false $apiKey
* @return false|string
*/
public function getDataCenter($apiKey) {
if (!$apiKey) return false;
$apiKeyParts = explode('-', $apiKey);
return end($apiKeyParts);
}
/**
* @param string $apiKey
* @return false|string
*/
public function getAPIKey(string $apiKey) {
return (preg_match(self::API_KEY_REGEX, $apiKey)) ? $apiKey : false;
}
/**
* @param string $error
* @throws \Exception
*/
public function throwException(string $error): void {
$errorMessage = __('Unknown MailChimp error.', 'mailpoet');
switch ($error) {
case 'API':
$errorMessage = __('Invalid API Key.', 'mailpoet');
break;
case 'size':
$errorMessage = __('The information received from MailChimp is too large for processing. Please limit the number of lists!', 'mailpoet');
break;
case 'subscribers':
$errorMessage = __('Did not find any active subscribers.', 'mailpoet');
break;
case 'lists':
$errorMessage = __('Did not find any valid lists.', 'mailpoet');
break;
}
throw new \Exception($errorMessage);
}
public function isSubscriberAllowed(array $subscriber): bool {
if (in_array($subscriber['status'], ['unsubscribed', 'cleaned', 'pending'], true)) {
return false;
}
if ($subscriber['member_rating'] < 2) {
return false;
}
// Rate 1 is on MailChimp API equal to 100% and we don't want to import avg_open_rate lower than 5%
if ($subscriber['stats']['avg_open_rate'] < 0.05) {
return false;
}
// We don't want to import avg_click_rate lower than 0.5%
if ($subscriber['stats']['avg_click_rate'] < 0.005) {
return false;
}
return true;
}
private function getApiData(string $endpoint, int $offset): ?array {
$url = sprintf(self::API_BASE_URI, $this->apiKey, $this->dataCenter);
$url .= $endpoint . '?' . http_build_query([
'count' => self::API_BATCH_SIZE,
'offset' => $offset,
]);
$connection = @fopen($url, 'r');
if (!$connection) {
return null;
}
$bytesFetched = 0;
$response = '';
while (!feof($connection)) {
$buffer = fgets($connection, 4096);
if (!is_string($buffer)) {
return null;
}
if (trim($buffer) !== '') {
$response .= $buffer;
}
$bytesFetched += strlen((string)$buffer);
if ($bytesFetched > $this->maxPostSize) {
$this->throwException('size');
}
}
fclose($connection);
return json_decode($response, true);
}
}
@@ -0,0 +1,64 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\Import;
if (!defined('ABSPATH')) exit;
class MailChimpDataMapper {
public function getMembersHeader(): array {
return [
'email_address',
'status',
'first_name',
'last_name',
'address',
'phone',
'birthday',
'ip_signup',
'timestamp_signup',
'ip_opt',
'timestamp_opt',
'member_rating',
'last_changed',
'language',
'vip',
'email_client',
'latitude',
'longitude',
'gmtoff',
'dstoff',
'country_code',
'timezone',
'source',
];
}
public function mapMember(array $member): array {
return [
$member['email_address'],
$member['status'],
$member['merge_fields']['FNAME'] ?? '',
$member['merge_fields']['LNAME'] ?? '',
is_array($member['merge_fields']['ADDRESS']) ? implode(' ', $member['merge_fields']['ADDRESS'] ?? []) : $member['merge_fields']['ADDRESS'],
$member['merge_fields']['PHONE'] ?? '',
$member['merge_fields']['BIRTHDAY'] ?? '',
$member['ip_signup'],
$member['timestamp_signup'],
$member['ip_opt'],
$member['timestamp_opt'],
$member['member_rating'],
$member['last_changed'],
$member['language'],
$member['vip'],
$member['email_client'],
$member['location']['latitude'] ?? '',
$member['location']['longitude'] ?? '',
$member['location']['gmtoff'] ?? '',
$member['location']['dstoff'] ?? '',
$member['location']['country_code'] ?? '',
$member['location']['timezone'] ?? '',
$member['source'],
];
}
}
@@ -0,0 +1,194 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport;
if (!defined('ABSPATH')) exit;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\TagEntity;
use MailPoet\Segments\SegmentsSimpleListRepository;
use MailPoet\Tags\TagRepository;
use MailPoet\Util\Helpers;
class ImportExportFactory {
const IMPORT_ACTION = 'import';
const EXPORT_ACTION = 'export';
/** @var string|null */
public $action;
/** @var SegmentsSimpleListRepository */
private $segmentsListRepository;
/** @var CustomFieldsRepository */
private $customFieldsRepository;
/** @var TagRepository */
private $tagRepository;
public function __construct(
$action = null
) {
$this->action = $action;
$this->segmentsListRepository = ContainerWrapper::getInstance()->get(SegmentsSimpleListRepository::class);
$this->customFieldsRepository = ContainerWrapper::getInstance()->get(CustomFieldsRepository::class);
$this->tagRepository = ContainerWrapper::getInstance()->get(TagRepository::class);
}
public function getSegments() {
if ($this->action === self::IMPORT_ACTION) {
$segments = $this->segmentsListRepository->getListWithSubscribedSubscribersCounts([SegmentEntity::TYPE_DEFAULT]);
} else {
$segments = $this->segmentsListRepository->getListWithAssociatedSubscribersCounts();
$segments = $this->segmentsListRepository->addVirtualSubscribersWithoutListSegment($segments);
$segments = array_values(array_filter($segments, function($segment) {
return $segment['subscribers'] > 0;
}));
}
return array_map(function($segment) {
return [
'id' => $segment['id'],
'name' => esc_attr($segment['name']),
'count' => $segment['subscribers'],
];
}, $segments);
}
public function getSubscriberFields() {
$fields = [
'email' => __('Email', 'mailpoet'),
'first_name' => __('First name', 'mailpoet'),
'last_name' => __('Last name', 'mailpoet'),
'subscribed_ip' => __('Subscription IP', 'mailpoet'),
'created_at' => __('Subscription time', 'mailpoet'),
'confirmed_at' => __('Confirmation time', 'mailpoet'),
'confirmed_ip' => __('Confirmation IP', 'mailpoet'),
];
if ($this->action === 'export') {
$fields = array_merge(
$fields,
[
'list_status' => _x('List status', 'Subscription status', 'mailpoet'),
'global_status' => _x('Global status', 'Subscription status', 'mailpoet'),
]
);
}
return $fields;
}
public function formatSubscriberFields($subscriberFields) {
return array_map(function($fieldId, $fieldName) {
return [
'id' => $fieldId,
'name' => $fieldName,
'text' => $fieldName, // Required for select2 default functionality
'type' => ($fieldId === 'confirmed_at' || $fieldId === 'created_at') ? 'date' : null,
'custom' => false,
];
}, array_keys($subscriberFields), $subscriberFields);
}
public function getSubscriberCustomFields() {
return $this->customFieldsRepository->findAllAsArray();
}
public function formatSubscriberCustomFields($subscriberCustomFields) {
return array_map(function($field) {
return [
'id' => $field['id'],
'name' => $field['name'],
'text' => $field['name'], // Required for select2 default functionality
'type' => $field['type'],
'params' => unserialize($field['params']),
'custom' => true,
];
}, $subscriberCustomFields);
}
public function formatFieldsForSelect2(
$subscriberFields,
$subscriberCustomFields
) {
$actions = ($this->action === 'import') ?
[
[
'id' => 'ignore',
'name' => __('Ignore field...', 'mailpoet'),
'text' => __('Ignore field...', 'mailpoet'), // Required for select2 default functionality
],
[
'id' => 'create',
'name' => __('Create new field...', 'mailpoet'),
'text' => __('Create new field...', 'mailpoet'), // Required for select2 default functionality
],
] :
[
[
'id' => 'select',
'name' => __('Select all...', 'mailpoet'),
'text' => __('Select all...', 'mailpoet'), // Required for select2 default functionality
],
[
'id' => 'deselect',
'name' => __('Deselect all...', 'mailpoet'),
'text' => __('Deselect all...', 'mailpoet'), // Required for select2 default functionality
],
];
$select2Fields = [
[
'name' => __('Actions', 'mailpoet'),
'text' => __('Actions', 'mailpoet'), // Required for select2 default functionality
'children' => $actions,
],
[
'name' => __('System fields', 'mailpoet'),
'text' => __('System fields', 'mailpoet'), // Required for select2 default functionality
'children' => $this->formatSubscriberFields($subscriberFields),
],
];
if ($subscriberCustomFields) {
array_push($select2Fields, [
'name' => __('User fields', 'mailpoet'),
'text' => __('User fields', 'mailpoet'), // Required for select2 default functionality
'children' => $this->formatSubscriberCustomFields(
$subscriberCustomFields
),
]);
}
return $select2Fields;
}
public function bootstrap() {
$subscriberFields = $this->getSubscriberFields();
$subscriberCustomFields = $this->getSubscriberCustomFields();
$data['segments'] = json_encode($this->getSegments());
$data['subscriberFieldsSelect2'] = json_encode(
$this->formatFieldsForSelect2(
$subscriberFields,
$subscriberCustomFields
)
);
if ($this->action === 'import') {
$data['subscriberFields'] = json_encode(
array_merge(
$this->formatSubscriberFields($subscriberFields),
$this->formatSubscriberCustomFields($subscriberCustomFields)
)
);
$data['maxPostSizeBytes'] = Helpers::getMaxPostSize('bytes');
$data['maxPostSize'] = Helpers::getMaxPostSize();
$data['tags'] = array_map(function (TagEntity $tag): array {
return [
'id' => $tag->getId(),
'name' => $tag->getName(),
];
}, $this->tagRepository->findAll());
}
$data['zipExtensionLoaded'] = extension_loaded('zip');
return $data;
}
}
@@ -0,0 +1,335 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport;
if (!defined('ABSPATH')) exit;
use DateTime;
use MailPoet\Config\SubscriberChangesNotifier;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Segments\DynamicSegments\FilterHandler;
use MailPoet\Subscribers\SubscriberCustomFieldRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
use MailPoetVendor\Doctrine\DBAL\Result;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Mapping\ClassMetadata;
class ImportExportRepository {
private const IGNORED_COLUMNS_FOR_BULK_UPDATE = [
SubscriberEntity::class => [
'wp_user_id',
'is_woocommerce_user',
'email',
'created_at',
'last_subscribed_at',
],
SubscriberCustomFieldEntity::class => [
'created_at',
],
SubscriberSegmentEntity::class => [
'created_at',
],
];
private const KEY_COLUMNS_FOR_BULK_UPDATE = [
SubscriberEntity::class => [
'email',
],
SubscriberCustomFieldEntity::class => [
'subscriber_id',
'custom_field_id',
],
];
/** @var EntityManager */
protected $entityManager;
/** @var SubscriberChangesNotifier */
private $subscriberChangesNotifier;
/** @var FilterHandler */
private $filterHandler;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SubscriberCustomFieldRepository */
private $subscriberCustomFieldRepository;
public function __construct(
EntityManager $entityManager,
SubscriberChangesNotifier $changesNotifier,
FilterHandler $filterHandler,
SubscribersRepository $subscribersRepository,
SubscriberCustomFieldRepository $subscriberCustomFieldRepository
) {
$this->entityManager = $entityManager;
$this->subscriberChangesNotifier = $changesNotifier;
$this->filterHandler = $filterHandler;
$this->subscribersRepository = $subscribersRepository;
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
}
/**
* @param class-string<object> $className
* @return ClassMetadata<object>
*/
protected function getClassMetadata(string $className): ClassMetadata {
return $this->entityManager->getClassMetadata($className);
}
/**
* @param class-string<object> $className
*/
protected function getTableName(string $className): string {
return $this->getClassMetadata($className)->getTableName();
}
/**
* @param class-string<object> $className
*/
protected function getTableColumns(string $className): array {
return $this->getClassMetadata($className)->getColumnNames();
}
/**
* @param class-string<object> $className
*/
public function insertMultiple(
string $className,
array $columns,
array $data
): int {
$tableName = $this->getTableName($className);
if (!$columns || !$data) {
return 0;
}
$rows = [];
$parameters = [];
foreach ($data as $key => $item) {
$paramNames = array_map(function (string $parameter) use ($key): string {
return ":{$parameter}_{$key}";
}, $columns);
foreach ($item as $columnKey => $column) {
// We need to remove the colon character from the query parameter name that is passed to the query builder
$parameters[substr($paramNames[$columnKey], 1)] = $column;
}
$rows[] = "(" . implode(', ', $paramNames) . ")";
}
$count = (int)$this->entityManager->getConnection()->executeStatement("
INSERT IGNORE INTO {$tableName} (`" . implode("`, `", $columns) . "`) VALUES
" . implode(", \n", $rows) . "
", $parameters);
$this->notifyCreations($className, $columns, $data);
return $count;
}
/**
* @param class-string<object> $className
*/
public function updateMultiple(
string $className,
array $columns,
array $data,
?DateTime $updatedAt = null
): int {
$tableName = $this->getTableName($className);
$entityColumns = $this->getTableColumns($className);
if (!$columns || !$data) {
return 0;
}
$parameters = [];
$parameterTypes = [];
$keyColumns = self::KEY_COLUMNS_FOR_BULK_UPDATE[$className] ?? [];
if (!$keyColumns) {
return 0;
}
$keyColumnsConditions = [];
foreach ($keyColumns as $keyColumn) {
$columnIndex = array_search($keyColumn, $columns);
$parameters[$keyColumn] = array_map(function(array $row) use ($columnIndex) {
return $row[$columnIndex];
}, $data);
$parameterTypes[$keyColumn] = ArrayParameterType::STRING;
$keyColumnsConditions[] = "{$keyColumn} IN (:{$keyColumn})";
}
$ignoredColumns = self::IGNORED_COLUMNS_FOR_BULK_UPDATE[$className] ?? ['created_at'];
$updateColumns = array_map(function($columnName) use ($keyColumns, $columns, $data, &$parameters): string {
$values = [];
foreach ($data as $index => $row) {
$keyCondition = array_map(function($keyColumn) use ($index, $row, $columns, &$parameters): string {
$parameters["{$keyColumn}_{$index}"] = $row[array_search($keyColumn, $columns)];
return "{$keyColumn} = :{$keyColumn}_{$index}";
}, $keyColumns);
$values[] = "WHEN " . implode(' AND ', $keyCondition) . " THEN :{$columnName}_{$index}";
$parameters["{$columnName}_{$index}"] = $row[array_search($columnName, $columns)];
}
return "{$columnName} = (CASE " . implode("\n", $values) . " END)";
}, array_diff($columns, $ignoredColumns));
if ($updatedAt && in_array('updated_at', $entityColumns, true)) {
$parameters['updated_at'] = $updatedAt;
$updateColumns[] = "updated_at = :updated_at";
}
// we want to reset deleted_at for updated rows
if (in_array('deleted_at', $entityColumns, true)) {
$updateColumns[] = 'deleted_at = NULL';
}
$count = (int)$this->entityManager->getConnection()->executeStatement("
UPDATE {$tableName} SET
" . implode(", \n", $updateColumns) . "
WHERE
" . implode(' AND ', $keyColumnsConditions) . "
", $parameters, $parameterTypes);
$this->notifyUpdates($className, $columns, $data);
if ($className === SubscriberEntity::class) {
$this->subscribersRepository->refreshAll();
}
if ($className === SubscriberCustomFieldEntity::class) {
$this->subscriberCustomFieldRepository->refreshAll();
}
return $count;
}
public function getSubscribersBatchBySegment(?SegmentEntity $segment, int $limit, int $offset = 0): array {
$subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
$subscriberTable = $this->getTableName(SubscriberEntity::class);
$segmentTable = $this->getTableName(SegmentEntity::class);
$qb = $this->createSubscribersQueryBuilder($limit, $offset);
$qb = $this->addSubscriberCustomFieldsToQueryBuilder($qb);
if (!$segment || $segment->isStatic()) {
// joining with the segments table is used only when there is no segment or for static segments.
// this because dynamic segments don't have a corresponding entry in the segments table.
$qb->leftJoin($subscriberSegmentTable, $segmentTable, $segmentTable, "{$segmentTable}.id = {$subscriberSegmentTable}.segment_id")
->groupBy("{$subscriberTable}.id, {$segmentTable}.id");
}
if (!$segment) {
// if there are subscribers who do not belong to any segment, use
// a CASE function to group them under "Not In Segment"
$qb->addSelect("'" . __('Not In Segment', 'mailpoet') . "' AS segment_name")
->leftJoin($subscriberTable, $subscriberTable, 's2', "{$subscriberTable}.id = s2.id")
->leftJoin('s2', $subscriberSegmentTable, 'ssg2', "s2.id = ssg2.subscriber_id AND ssg2.status = :statusSubscribed AND {$segmentTable}.id <> ssg2.segment_id")
->leftJoin('ssg2', $segmentTable, 'sg2', 'ssg2.segment_id = sg2.id AND sg2.deleted_at IS NULL')
->andWhere("({$subscriberSegmentTable}.status != :statusSubscribed OR {$subscriberSegmentTable}.id IS NULL OR {$segmentTable}.deleted_at IS NOT NULL)")
->andWhere('sg2.id IS NULL')
->setParameter('statusSubscribed', SubscriberEntity::STATUS_SUBSCRIBED);
} elseif ($segment->isStatic()) {
$qb->addSelect("{$segmentTable}.name AS segment_name")
->andWhere("{$subscriberSegmentTable}.segment_id = :segmentId")
->setParameter('segmentId', $segment->getId());
} else {
// Dynamic segments don't have a relation to the segment table,
// So we need to use a placeholder
$qb->addSelect(":segmentName AS segment_name")
->setParameter('segmentName', $segment->getName())
->groupBy("{$subscriberTable}.id");
$qb = $this->filterHandler->apply($qb, $segment);
}
$statement = $qb->execute();
return $statement instanceof Result ? $statement->fetchAll() : [];
}
private function createSubscribersQueryBuilder(int $limit, int $offset): QueryBuilder {
$subscriberSegmentTable = $this->getTableName(SubscriberSegmentEntity::class);
$subscriberTable = $this->getTableName(SubscriberEntity::class);
return $this->entityManager->getConnection()->createQueryBuilder()
->select("
{$subscriberTable}.first_name,
{$subscriberTable}.last_name,
{$subscriberTable}.email,
{$subscriberTable}.subscribed_ip,
{$subscriberTable}.confirmed_at,
{$subscriberTable}.confirmed_ip,
{$subscriberTable}.created_at,
{$subscriberTable}.status AS global_status,
{$subscriberSegmentTable}.status AS list_status
")
->from($subscriberTable)
->leftJoin($subscriberTable, $subscriberSegmentTable, $subscriberSegmentTable, "{$subscriberTable}.id = {$subscriberSegmentTable}.subscriber_id")
->andWhere("{$subscriberTable}.deleted_at IS NULL")
->orderBy("{$subscriberTable}.id")
->setFirstResult($offset)
->setMaxResults($limit);
}
private function addSubscriberCustomFieldsToQueryBuilder(QueryBuilder $qb): QueryBuilder {
$segmentsTable = $this->getTableName(SubscriberEntity::class);
$customFieldsTable = $this->getTableName(CustomFieldEntity::class);
$subscriberCustomFieldTable = $this->getTableName(SubscriberCustomFieldEntity::class);
$customFields = $this->entityManager->getConnection()->createQueryBuilder()
->select("{$customFieldsTable}.*")
->from($customFieldsTable)
->execute();
$customFields = $customFields->fetchAll();
foreach ($customFields as $customField) {
$customFieldId = "customFieldId{$customField['id']}export";
$qb->addSelect("MAX(CASE WHEN {$customFieldsTable}.id = :{$customFieldId} THEN {$subscriberCustomFieldTable}.value END) AS :{$customFieldId}")
->setParameter($customFieldId, $customField['id']);
}
$qb->leftJoin($segmentsTable, $subscriberCustomFieldTable, $subscriberCustomFieldTable, "{$segmentsTable}.id = {$subscriberCustomFieldTable}.subscriber_id")
->leftJoin($subscriberCustomFieldTable, $customFieldsTable, $customFieldsTable, "{$customFieldsTable}.id = {$subscriberCustomFieldTable}.custom_field_id");
return $qb;
}
private function notifyCreations(string $className, array $columns, array $data): void {
if ($className === SubscriberEntity::class) {
$ids = $this->getIdsByEmail($className, $columns, $data);
$this->subscriberChangesNotifier->subscribersCreated($ids);
}
}
private function notifyUpdates(string $className, array $columns, array $data): void {
if ($className === SubscriberEntity::class) {
$ids = $this->getIdsByEmail($className, $columns, $data);
$this->subscriberChangesNotifier->subscribersUpdated($ids);
}
}
/**
* @param class-string<object> $className
*/
private function getIdsByEmail(string $className, array $columns, array $data): array {
$tableName = $this->getTableName($className);
$emailIndex = array_search('email', $columns);
if ($emailIndex === false) {
return [];
}
$emails = [];
foreach ($data as $item) {
$emails[] = $item[$emailIndex];
}
// get ids for updated/created rows
return $this->entityManager->getConnection()->executeQuery("
SELECT id
FROM {$tableName}
WHERE email IN (:emails)
", ['emails' => $emails], ['emails' => ArrayParameterType::STRING])->fetchFirstColumn();
}
}
@@ -0,0 +1,46 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\Statistics\StatisticsClicksRepository;
class NewsletterClicksExporter extends NewsletterStatsBaseExporter {
protected $statsClassName = StatisticsClicksRepository::class;
protected function getEmailStats(array $row) {
$newsletterData = [];
$newsletterData[] = [
'name' => __('Email subject', 'mailpoet'),
'value' => $row['newsletterRenderedSubject'],
];
$newsletterData[] = [
'name' => __('Timestamp of the click event', 'mailpoet'),
'value' => $row['createdAt']->format("Y-m-d H:i:s"),
];
$newsletterData[] = [
'name' => __('URL', 'mailpoet'),
'value' => $row['url'],
];
if (!is_null($row['userAgent'])) {
$userAgent = $row['userAgent'];
} else {
$userAgent = __('Unknown', 'mailpoet');
}
$newsletterData[] = [
'name' => __('User-agent', 'mailpoet'),
'value' => $userAgent,
];
return [
'group_id' => 'mailpoet-newsletter-clicks',
'group_label' => __('MailPoet Emails Clicks', 'mailpoet'),
'item_id' => 'newsletter-' . $row['id'],
'data' => $newsletterData,
];
}
}
@@ -0,0 +1,42 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\Statistics\StatisticsOpensRepository;
class NewsletterOpensExporter extends NewsletterStatsBaseExporter {
protected $statsClassName = StatisticsOpensRepository::class;
protected function getEmailStats(array $row): array {
$newsletterData = [];
$newsletterData[] = [
'name' => __('Email subject', 'mailpoet'),
'value' => $row['newsletterRenderedSubject'],
];
$newsletterData[] = [
'name' => __('Timestamp of the open event', 'mailpoet'),
'value' => $row['createdAt']->format("Y-m-d H:i:s"),
];
if (!is_null($row['userAgent'])) {
$userAgent = $row['userAgent'];
} else {
$userAgent = __('Unknown', 'mailpoet');
}
$newsletterData[] = [
'name' => __('User-agent', 'mailpoet'),
'value' => $userAgent,
];
return [
'group_id' => 'mailpoet-newsletter-opens',
'group_label' => __('MailPoet Emails Opens', 'mailpoet'),
'item_id' => 'newsletter-' . $row['id'],
'data' => $newsletterData,
];
}
}
@@ -0,0 +1,63 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\DI\ContainerWrapper;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Statistics\StatisticsClicksRepository;
use MailPoet\Statistics\StatisticsOpensRepository;
use MailPoet\Subscribers\SubscribersRepository;
abstract class NewsletterStatsBaseExporter {
const LIMIT = 100;
/** @var class-string<StatisticsClicksRepository>|class-string<StatisticsOpensRepository> */
protected $statsClassName;
protected $subscriberRepository;
public function __construct(
SubscribersRepository $subscribersRepository
) {
$this->subscriberRepository = $subscribersRepository;
}
public function export($email, $page = 1): array {
$data = [];
$subscriber = $this->subscriberRepository->findOneBy(['email' => trim($email)]);
if ($subscriber instanceof SubscriberEntity) {
$data = $this->getSubscriberData($subscriber, $page);
}
return [
'data' => $data,
'done' => count($data) < self::LIMIT,
];
}
private function getSubscriberData(SubscriberEntity $subscriber, $page): array {
$result = [];
$statsClass = ContainerWrapper::getInstance()->get($this->statsClassName);
/** @var array[] $statistics */
$statistics = $statsClass->getAllForSubscriber($subscriber)
->setMaxResults(self::LIMIT)
->setFirstResult(self::LIMIT * ($page - 1))
->getQuery()
->getResult();
foreach ($statistics as $row) {
$result[] = $this->getEmailStats($row);
}
return $result;
}
protected abstract function getEmailStats(array $row);
}
@@ -0,0 +1,130 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Statistics\NewsletterStatisticsRepository;
use MailPoet\Newsletter\Url as NewsletterUrl;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WP\DateTime;
class NewslettersExporter {
const LIMIT = 100;
/** @var NewsletterUrl */
private $newsletterUrl;
/*** @var SubscribersRepository */
private $subscribersRepository;
/*** @var NewslettersRepository */
private $newslettersRepository;
/*** @var NewsletterStatisticsRepository */
private $newsletterStatisticsRepository;
public function __construct(
NewsletterUrl $newsletterUrl,
SubscribersRepository $subscribersRepository,
NewslettersRepository $newslettersRepository,
NewsletterStatisticsRepository $newsletterStatisticsRepository
) {
$this->newsletterUrl = $newsletterUrl;
$this->subscribersRepository = $subscribersRepository;
$this->newslettersRepository = $newslettersRepository;
$this->newsletterStatisticsRepository = $newsletterStatisticsRepository;
}
public function export($email, $page = 1) {
$data = $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)]), $page);
return [
'data' => $data,
'done' => count($data) < self::LIMIT,
];
}
private function exportSubscriber(?SubscriberEntity $subscriber, $page) {
if (!$subscriber) return [];
$result = [];
$statistics = $this->newsletterStatisticsRepository->getAllForSubscriber(
$subscriber,
self::LIMIT,
self::LIMIT * ($page - 1)
);
$newsletters = $this->loadNewsletters($statistics);
foreach ($statistics as $row) {
$result[] = $this->exportNewsletter($row, $newsletters, $subscriber);
}
return $result;
}
private function exportNewsletter($statisticsRow, $newsletters, $subscriber) {
$newsletterData = [];
$newsletterData[] = [
'name' => __('Email subject', 'mailpoet'),
'value' => $statisticsRow['newsletter_rendered_subject'],
];
$newsletterData[] = [
'name' => __('Sent at', 'mailpoet'),
'value' => $statisticsRow['sent_at']
? $statisticsRow['sent_at']->format(DateTime::DEFAULT_DATE_TIME_FORMAT)
: '',
];
if (!empty($statisticsRow['opened_at'])) {
$newsletterData[] = [
'name' => __('Opened', 'mailpoet'),
'value' => 'Yes',
];
$newsletterData[] = [
'name' => __('Opened at', 'mailpoet'),
'value' => $statisticsRow['opened_at']->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
];
} else {
$newsletterData[] = [
'name' => __('Opened', 'mailpoet'),
'value' => __('No', 'mailpoet'),
];
}
if (isset($newsletters[$statisticsRow['newsletter_id']])) {
$newsletterData[] = [
'name' => __('Email preview', 'mailpoet'),
'value' => $this->newsletterUrl->getViewInBrowserUrl(
$newsletters[$statisticsRow['newsletter_id']],
$subscriber
),
];
}
return [
'group_id' => 'mailpoet-newsletters',
'group_label' => __('MailPoet Emails Sent', 'mailpoet'),
'item_id' => 'newsletter-' . $statisticsRow['newsletter_id'],
'data' => $newsletterData,
];
}
private function loadNewsletters($statistics) {
$newsletterIds = array_map(function ($statisticsRow) {
return $statisticsRow['newsletter_id'];
}, $statistics);
if (empty($newsletterIds)) return [];
$newsletters = $this->newslettersRepository->findBy(['id' => $newsletterIds]);
$result = [];
foreach ($newsletters as $newsletter) {
$result[$newsletter->getId()] = $newsletter;
}
return $result;
}
}
@@ -0,0 +1,77 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WP\DateTime;
class SegmentsExporter {
/*** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SubscribersRepository $subscribersRepository
) {
$this->subscribersRepository = $subscribersRepository;
}
/**
* @param string $email
* @return array(data: mixed[], done: boolean)
*/
public function export(string $email): array {
return [
'data' => $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)])),
'done' => true,
];
}
/**
* @param SubscriberEntity|null $subscriber
* @return mixed[]
*/
private function exportSubscriber(?SubscriberEntity $subscriber): array {
if (!$subscriber) return [];
$result = [];
$segments = $subscriber->getSubscriberSegments();
foreach ($segments as $segment) {
$result[] = $this->exportSegment($segment);
}
return $result;
}
/**
* @param SubscriberSegmentEntity $segment
* @return mixed[]
*/
private function exportSegment(SubscriberSegmentEntity $segment): array {
$segmentData = [];
$segmentData[] = [
'name' => __('List name', 'mailpoet'),
'value' => $segment->getSegment() ? $segment->getSegment()->getName() : '',
];
$segmentData[] = [
'name' => __('Subscription status', 'mailpoet'),
'value' => $segment->getStatus(),
];
$segmentData[] = [
'name' => __('Timestamp of the subscription (or last change of the subscription status)', 'mailpoet'),
'value' => $segment->getUpdatedAt()->format(DateTime::DEFAULT_DATE_TIME_FORMAT),
];
return [
'group_id' => 'mailpoet-lists',
'group_label' => __('MailPoet Mailing Lists', 'mailpoet'),
'item_id' => 'list-' . ($segment->getSegment() ? $segment->getSegment()->getId() : ''),
'data' => $segmentData,
];
}
}
@@ -0,0 +1,154 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\ImportExport\PersonalDataExporters;
if (!defined('ABSPATH')) exit;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Subscribers\Source;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\WP\DateTime;
class SubscriberExporter {
/*** @var SubscribersRepository */
private $subscribersRepository;
/*** @var CustomFieldsRepository */
private $customFieldsRepository;
/*** @var array<int, string> */
private $customFields = [];
public function __construct(
SubscribersRepository $subscribersRepository,
CustomFieldsRepository $customFieldsRepository
) {
$this->subscribersRepository = $subscribersRepository;
$this->customFieldsRepository = $customFieldsRepository;
}
/**
* @param string $email
* @return array(data: mixed[], done: boolean)
*/
public function export(string $email): array {
return [
'data' => $this->exportSubscriber($this->subscribersRepository->findOneBy(['email' => trim($email)])),
'done' => true,
];
}
/**
* @param SubscriberEntity|null $subscriber
* @return array|mixed[][]
*/
private function exportSubscriber(?SubscriberEntity $subscriber): array {
if (!$subscriber) return [];
return [[
'group_id' => 'mailpoet-subscriber',
'group_label' => __('MailPoet Subscriber Data', 'mailpoet'),
'item_id' => 'subscriber-' . $subscriber->getId(),
'data' => $this->getSubscriberExportData($subscriber),
]];
}
/**
* @param SubscriberEntity $subscriber
* @return mixed[][]
*/
private function getSubscriberExportData(SubscriberEntity $subscriber): array {
$customFields = $this->getCustomFields();
$result = [
[
'name' => __('First Name', 'mailpoet'),
'value' => $subscriber->getFirstName(),
],
[
'name' => __('Last Name', 'mailpoet'),
'value' => $subscriber->getLastName(),
],
[
'name' => __('Email', 'mailpoet'),
'value' => $subscriber->getEmail(),
],
[
'name' => __('Status', 'mailpoet'),
'value' => $subscriber->getStatus(),
],
];
if ($subscriber->getSubscribedIp()) {
$result[] = [
'name' => __('Subscribed IP', 'mailpoet'),
'value' => $subscriber->getSubscribedIp(),
];
}
if ($subscriber->getConfirmedIp()) {
$result[] = [
'name' => __('Confirmed IP', 'mailpoet'),
'value' => $subscriber->getConfirmedIp(),
];
}
$result[] = [
'name' => __('Created at', 'mailpoet'),
'value' => $subscriber->getCreatedAt()
? $subscriber->getCreatedAt()->format(DateTime::DEFAULT_DATE_TIME_FORMAT)
: '',
];
foreach ($subscriber->getSubscriberCustomFields() as $subscriberCustomField) {
$customField = $subscriberCustomField->getCustomField();
if (!$customField instanceof CustomFieldEntity) {
continue;
}
$customFieldId = $customField->getId();
if (isset($this->getCustomFields()[$customFieldId])) {
$result[] = [
'name' => $customFields[$customFieldId],
'value' => $subscriberCustomField->getValue(),
];
}
}
$result[] = [
'name' => __("Subscriber's subscription source", 'mailpoet'),
'value' => $this->formatSource($subscriber->getSource()),
];
return $result;
}
/**
* @return array<int, string>
*/
private function getCustomFields(): array {
if (!empty($this->customFields)) {
return $this->customFields;
}
$fields = $this->customFieldsRepository->findAll();
foreach ($fields as $field) {
$this->customFields[$field->getId()] = $field->getName();
}
return $this->customFields;
}
private function formatSource(string $source): string {
switch ($source) {
case Source::WORDPRESS_USER:
return __('Subscriber information synchronized via WP user sync', 'mailpoet');
case Source::FORM:
return __('Subscription via a MailPoet subscription form', 'mailpoet');
case Source::API:
return __('Added by a 3rd party via MailPoet API', 'mailpoet');
case Source::ADMINISTRATOR:
return __('Created by the administrator', 'mailpoet');
case Source::IMPORTED:
return __('Imported by the administrator', 'mailpoet');
default:
return __('Unknown', 'mailpoet');
}
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,189 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
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\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class InactiveSubscribersController {
const UNOPENED_EMAILS_THRESHOLD = 3;
const LIFETIME_EMAILS_THRESHOLD = 10;
private $processedTaskIdsTableCreated = false;
/** @var EntityManager */
private $entityManager;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
}
public function markInactiveSubscribers(int $daysToInactive, int $batchSize, ?int $startId = null, ?int $unopenedEmails = self::UNOPENED_EMAILS_THRESHOLD) {
$thresholdDate = $this->getThresholdDate($daysToInactive);
return $this->deactivateSubscribers($thresholdDate, $batchSize, $startId, $unopenedEmails);
}
public function markActiveSubscribers(int $daysToInactive, int $batchSize): int {
$thresholdDate = $this->getThresholdDate($daysToInactive);
return $this->activateSubscribers($thresholdDate, $batchSize);
}
public function reactivateInactiveSubscribers(): void {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$reactivateAllInactiveQuery = "
UPDATE {$subscribersTable} SET status = :statusSubscribed WHERE status = :statusInactive
";
$this->entityManager->getConnection()->executeQuery($reactivateAllInactiveQuery, [
'statusSubscribed' => SubscriberEntity::STATUS_SUBSCRIBED,
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
]);
}
private function getThresholdDate(int $daysToInactive): Carbon {
$now = new Carbon();
return $now->subDays($daysToInactive);
}
/**
* @return int
*/
private function deactivateSubscribers(Carbon $thresholdDate, int $batchSize, ?int $startId = null, ?int $unopenedEmails = self::UNOPENED_EMAILS_THRESHOLD) {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$sendingQueuesTable = $this->entityManager->getClassMetadata(SendingQueueEntity::class)->getTableName();
$connection = $this->entityManager->getConnection();
$thresholdDateIso = $thresholdDate->toDateTimeString();
$dayAgo = new Carbon();
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
// Temporary table with processed tasks from threshold date up to yesterday
$processedTaskIdsTable = 'inactive_task_ids';
if (!$this->processedTaskIdsTableCreated) {
$processedTaskIdsTableSql = "
CREATE TEMPORARY TABLE IF NOT EXISTS {$processedTaskIdsTable}
(INDEX task_id_ids (id), PRIMARY KEY (`id`))
SELECT DISTINCT task_id as id FROM {$sendingQueuesTable} as sq
JOIN {$scheduledTasksTable} as st ON sq.task_id = st.id
WHERE st.processed_at > :thresholdDate
AND st.processed_at < :dayAgo
";
$connection->executeQuery($processedTaskIdsTableSql, [
'thresholdDate' => $thresholdDateIso,
'dayAgo' => $dayAgoIso,
]);
$this->processedTaskIdsTableCreated = true;
}
// Select subscribers who received at least a number of emails after threshold date and subscribed before that
$startId = (int)$startId;
$endId = $startId + $batchSize;
$lifetimeEmailsThreshold = self::LIFETIME_EMAILS_THRESHOLD;
$inactiveSubscriberIdsTmpTable = 'inactive_subscriber_ids';
$connection->executeQuery(
"
CREATE TEMPORARY TABLE IF NOT EXISTS {$inactiveSubscriberIdsTmpTable}
(UNIQUE subscriber_id (id), PRIMARY KEY (`id`))
SELECT s.id FROM {$subscribersTable} as s
JOIN {$scheduledTaskSubscribersTable} as sts USE INDEX (subscriber_id) ON s.id = sts.subscriber_id
JOIN {$processedTaskIdsTable} task_ids ON task_ids.id = sts.task_id
WHERE s.last_subscribed_at < :thresholdDate
AND s.status = :status
AND s.id >= :startId
AND s.id < :endId
AND s.email_count >= {$lifetimeEmailsThreshold}
GROUP BY s.id
HAVING count(s.id) >= :unopenedEmailsThreshold
",
[
'thresholdDate' => $thresholdDateIso,
'status' => SubscriberEntity::STATUS_SUBSCRIBED,
'startId' => $startId,
'endId' => $endId,
'unopenedEmailsThreshold' => $unopenedEmails,
]
);
$result = $connection->executeQuery("
SELECT isi.id FROM {$inactiveSubscriberIdsTmpTable} isi
LEFT OUTER JOIN {$subscribersTable} as s ON isi.id = s.id AND GREATEST(
COALESCE(s.last_engagement_at, '0'),
COALESCE(s.last_subscribed_at, '0'),
COALESCE(s.created_at, '0')
) > :thresholdDate
WHERE s.id IS NULL
", [
'thresholdDate' => $thresholdDateIso,
]);
$idsToDeactivate = $result->fetchAllAssociative();
$connection->executeQuery("DROP TABLE {$inactiveSubscriberIdsTmpTable}");
$idsToDeactivate = array_map(
function ($id) {
return (int)$id['id'];
},
$idsToDeactivate
);
if (!count($idsToDeactivate)) {
return 0;
}
$connection->executeQuery("UPDATE {$subscribersTable} SET status = :statusInactive WHERE id IN (:idsToDeactivate)", [
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
'idsToDeactivate' => $idsToDeactivate,
], ['idsToDeactivate' => ArrayParameterType::INTEGER]);
return count($idsToDeactivate);
}
private function activateSubscribers(Carbon $thresholdDate, int $batchSize): int {
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$connection = $this->entityManager->getConnection();
$idsToActivate = $connection->executeQuery("
SELECT s.id
FROM {$subscribersTable} s
LEFT OUTER JOIN {$subscribersTable} s2 ON s.id = s2.id AND GREATEST(
COALESCE(s2.last_engagement_at, '0'),
COALESCE(s2.last_subscribed_at, '0'),
COALESCE(s2.created_at, '0')
) > :thresholdDate
WHERE s.last_subscribed_at < :thresholdDate
AND s.status = :statusInactive
AND s2.id IS NOT NULL
GROUP BY s.id
LIMIT :batchSize
", [
'thresholdDate' => $thresholdDate,
'statusInactive' => SubscriberEntity::STATUS_INACTIVE,
'batchSize' => $batchSize,
], ['batchSize' => ParameterType::INTEGER])->fetchAllAssociative();
$idsToActivate = array_map(
function($id) {
return (int)$id['id'];
},
$idsToActivate
);
if (!count($idsToActivate)) {
return 0;
}
$connection->executeQuery("UPDATE {$subscribersTable} SET status = :statusSubscribed WHERE id IN (:idsToActivate)", [
'statusSubscribed' => SubscriberEntity::STATUS_SUBSCRIBED,
'idsToActivate' => $idsToActivate,
], ['idsToActivate' => ArrayParameterType::INTEGER]);
return count($idsToActivate);
}
}
@@ -0,0 +1,49 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
class LinkTokens {
private const OBSOLETE_LINK_TOKEN_LENGTH = 6;
/** @var SubscribersRepository */
private $subscribersRepository;
public function __construct(
SubscribersRepository $subscribersRepository
) {
$this->subscribersRepository = $subscribersRepository;
}
public function getToken(SubscriberEntity $subscriber): string {
if ($subscriber->getLinkToken() === null) {
$subscriber->setLinkToken($this->generateToken($subscriber->getEmail()));
$this->subscribersRepository->flush();
}
return (string)$subscriber->getLinkToken();
}
public function verifyToken(SubscriberEntity $subscriber, string $token) {
$databaseToken = $this->getToken($subscriber);
$requestToken = substr($token, 0, strlen($databaseToken));
return hash_equals($databaseToken, $requestToken);
}
/**
* Only for backward compatibility for old tokens
*/
private function generateToken(?string $email, int $length = self::OBSOLETE_LINK_TOKEN_LENGTH): ?string {
if ($email !== null) {
$authKey = '';
if (defined('AUTH_KEY')) {
$authKey = AUTH_KEY;
}
return substr(md5($authKey . $email), 0, $length);
}
return null;
}
}
@@ -0,0 +1,117 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\Renderer;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Mailer\MailerFactory;
use MailPoet\Mailer\MetaInfo;
use MailPoet\Settings\SettingsController;
use MailPoet\WP\Functions as WPFunctions;
class NewSubscriberNotificationMailer {
const SETTINGS_KEY = 'subscriber_email_notification';
/** @var MailerFactory */
private $mailerFactory;
/** @var Renderer */
private $renderer;
/** @var SettingsController */
private $settings;
/** @var MetaInfo */
private $mailerMetaInfo;
public function __construct(
MailerFactory $mailerFactory,
Renderer $renderer,
SettingsController $settings
) {
$this->mailerFactory = $mailerFactory;
$this->renderer = $renderer;
$this->settings = $settings;
$this->mailerMetaInfo = new MetaInfo();
}
/**
* @param SubscriberEntity $subscriber
* @param SegmentEntity[] $segments
*
* @throws \Exception
*/
public function send(SubscriberEntity $subscriber, array $segments): void {
$settings = $this->settings->get(NewSubscriberNotificationMailer::SETTINGS_KEY);
if ($this->isDisabled($settings)) {
return;
}
try {
$extraParams = [
'meta' => $this->mailerMetaInfo->getNewSubscriberNotificationMetaInfo(),
];
$this->mailerFactory->getDefaultMailer()->send($this->constructNewsletter($subscriber, $segments), $settings['address'], $extraParams);
} catch (\Exception $e) {
if (WP_DEBUG) {
throw $e;
}
}
}
public static function isDisabled($settings) {
if (!is_array($settings)) {
return true;
}
if (!isset($settings['enabled'])) {
return true;
}
if (!isset($settings['address'])) {
return true;
}
if (empty(trim($settings['address']))) {
return true;
}
return !(bool)$settings['enabled'];
}
/**
* @param SubscriberEntity $subscriber
* @param SegmentEntity[] $segments
*
* @return array
* @throws \Exception
*/
private function constructNewsletter(SubscriberEntity $subscriber, array $segments) {
$segmentNames = $this->getSegmentNames($segments);
$context = [
'subscriber_email' => $subscriber->getEmail(),
'segments_names' => $segmentNames,
'link_settings' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-settings'),
'link_premium' => WPFunctions::get()->getSiteUrl(null, '/wp-admin/admin.php?page=mailpoet-upgrade'),
];
return [
// translators: %s is name of the segment.
'subject' => sprintf(__('New subscriber to %s', 'mailpoet'), $segmentNames),
'body' => [
'html' => $this->renderer->render('emails/newSubscriberNotification.html', $context),
'text' => $this->renderer->render('emails/newSubscriberNotification.txt', $context),
],
];
}
/**
* @param SegmentEntity[] $segments
* @return string
*/
private function getSegmentNames(array $segments): string {
$names = [];
foreach ($segments as $segment) {
$names[] = $segment->getName();
}
return implode(', ', $names);
}
}
@@ -0,0 +1,89 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use Exception;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Entities\FormEntity;
class RequiredCustomFieldValidator {
/** @var CustomFieldsRepository */
private $customFieldRepository;
public function __construct(
CustomFieldsRepository $customFieldRepository
) {
$this->customFieldRepository = $customFieldRepository;
}
/**
* @param array $data
* @param FormEntity|null $form
*
* @throws Exception
*/
public function validate(array $data, FormEntity $form = null) {
$allCustomFields = $this->getCustomFields($form);
foreach ($allCustomFields as $customFieldId => $customFieldName) {
if ($this->isCustomFieldMissing($customFieldId, $data)) {
throw new Exception(
// translators: %s is the name of the custom field.
sprintf(__('Missing value for custom field "%s"', 'mailpoet'), $customFieldName)
);
}
}
}
private function isCustomFieldMissing(int $customFieldId, array $data): bool {
if (!array_key_exists($customFieldId, $data) && !array_key_exists('cf_' . $customFieldId, $data)) {
return true;
}
if (isset($data[$customFieldId]) && !$data[$customFieldId]) {
return true;
}
if (isset($data['cf_' . $customFieldId]) && !$data['cf_' . $customFieldId]) {
return true;
}
return false;
}
private function getCustomFields(FormEntity $form = null): array {
$result = [];
if ($form) {
$ids = $this->getFormCustomFieldIds($form);
if (!$ids) {
return [];
}
$requiredCustomFields = $this->customFieldRepository->findBy(['id' => $ids]);
} else {
$requiredCustomFields = $this->customFieldRepository->findAll();
}
foreach ($requiredCustomFields as $customField) {
$params = $customField->getParams();
if (is_array($params) && isset($params['required']) && $params['required']) {
$result[$customField->getId()] = $customField->getName();
}
}
return $result;
}
/**
* @return int[]
*/
private function getFormCustomFieldIds(FormEntity $form): array {
$formFields = $form->getBlocksByTypes(FormEntity::FORM_FIELD_TYPES);
$customFieldIds = [];
foreach ($formFields as $formField) {
if (isset($formField['id']) && is_numeric($formField['id'])) {
$customFieldIds[] = (int)$formField['id'];
}
}
return $customFieldIds;
}
}
@@ -0,0 +1,17 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
class Source {
const FORM = 'form';
const IMPORTED = 'imported';
const ADMINISTRATOR = 'administrator';
const API = 'api';
const WORDPRESS_USER = 'wordpress_user';
const WOOCOMMERCE_USER = 'woocommerce_user';
const WOOCOMMERCE_CHECKOUT = 'woocommerce_checkout';
const UNKNOWN = 'unknown';
}
@@ -0,0 +1,63 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\Statistics;
if (!defined('ABSPATH')) exit;
use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
class SubscriberStatistics {
/** @var int */
private $clickCount;
/** @var int */
private $openCount;
/** @var int */
private $machineOpenCount;
/** @var int */
private $totalSentCount;
/** @var WooCommerceRevenue|null */
private $wooCommerceRevenue;
public function __construct(
$clickCount,
$openCount,
$machineOpenCount,
$totalSentCount,
$wooCommerceRevenue = null
) {
$this->clickCount = $clickCount;
$this->openCount = $openCount;
$this->machineOpenCount = $machineOpenCount;
$this->totalSentCount = $totalSentCount;
$this->wooCommerceRevenue = $wooCommerceRevenue;
}
public function getClickCount(): int {
return $this->clickCount;
}
public function getOpenCount(): int {
return $this->openCount;
}
public function getMachineOpenCount(): int {
return $this->machineOpenCount;
}
public function getTotalSentCount(): int {
return $this->totalSentCount;
}
/**
* @return WooCommerceRevenue|null
*/
public function getWooCommerceRevenue() {
return $this->wooCommerceRevenue;
}
}
@@ -0,0 +1,157 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers\Statistics;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\StatisticsClickEntity;
use MailPoet\Entities\StatisticsNewsletterEntity;
use MailPoet\Entities\StatisticsOpenEntity;
use MailPoet\Entities\StatisticsWooCommercePurchaseEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\UserAgentEntity;
use MailPoet\Newsletter\Statistics\WooCommerceRevenue;
use MailPoet\Settings\TrackingConfig;
use MailPoet\WooCommerce\Helper as WCHelper;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
/**
* @extends Repository<SubscriberEntity>
*/
class SubscriberStatisticsRepository 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 SubscriberEntity::class;
}
public function getStatistics(SubscriberEntity $subscriber, ?Carbon $startTime = null) {
return new SubscriberStatistics(
$this->getStatisticsClickCount($subscriber, $startTime),
$this->getStatisticsOpenCount($subscriber, $startTime),
$this->getStatisticsMachineOpenCount($subscriber, $startTime),
$this->getTotalSentCount($subscriber, $startTime),
$this->getWooCommerceRevenue($subscriber, $startTime)
);
}
public function getStatisticsClickCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
$queryBuilder = $this->getStatisticsCountQuery(StatisticsClickEntity::class, $subscriber);
if ($startTime) {
$this->applyDateConstraint($queryBuilder, $startTime);
}
return (int)$queryBuilder
->getQuery()
->getSingleScalarResult();
}
public function getStatisticsOpenCountQuery(SubscriberEntity $subscriber, ?Carbon $startTime = null): QueryBuilder {
$queryBuilder = $this->getStatisticsCountQuery(StatisticsOpenEntity::class, $subscriber);
if ($startTime) {
$this->applyDateConstraint($queryBuilder, $startTime);
}
return $queryBuilder;
}
public function getStatisticsOpenCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
$queryBuilder = $this->getStatisticsOpenCountQuery($subscriber, $startTime);
if ($this->trackingConfig->areOpensSeparated()) {
$queryBuilder
->andWhere('(stats.userAgentType = :userAgentType)')
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_HUMAN);
}
return (int)$queryBuilder
->getQuery()
->getSingleScalarResult();
}
public function getStatisticsMachineOpenCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
return (int)$this->getStatisticsOpenCountQuery($subscriber, $startTime)
->andWhere('(stats.userAgentType = :userAgentType)')
->setParameter('userAgentType', UserAgentEntity::USER_AGENT_TYPE_MACHINE)
->getQuery()
->getSingleScalarResult();
}
public function getTotalSentCount(SubscriberEntity $subscriber, ?Carbon $startTime = null): int {
$queryBuilder = $this->getStatisticsCountQuery(StatisticsNewsletterEntity::class, $subscriber);
if ($startTime) {
$queryBuilder
->andWhere('stats.sentAt >= :dateTime')
->setParameter('dateTime', $startTime);
}
return (int)$queryBuilder
->getQuery()
->getSingleScalarResult();
}
public function getStatisticsCountQuery(string $entityName, SubscriberEntity $subscriber): QueryBuilder {
return $this->entityManager->createQueryBuilder()
->select('COUNT(DISTINCT stats.newsletter) as cnt')
->from($entityName, 'stats')
->where('stats.subscriber = :subscriber')
->setParameter('subscriber', $subscriber);
}
public function getWooCommerceRevenue(SubscriberEntity $subscriber, ?Carbon $startTime = null): ?WooCommerceRevenue {
if (!$this->wcHelper->isWooCommerceActive()) {
return null;
}
$revenueStatus = $this->wcHelper->getPurchaseStates();
$currency = $this->wcHelper->getWoocommerceCurrency();
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('stats.orderPriceTotal')
->from(StatisticsWooCommercePurchaseEntity::class, 'stats')
->where('stats.subscriber = :subscriber')
->andWhere('stats.orderCurrency = :currency')
->setParameter('subscriber', $subscriber)
->setParameter('currency', $currency)
->andWhere('stats.status IN (:revenue_status)')
->setParameter('subscriber', $subscriber)
->setParameter('currency', $currency)
->setParameter('revenue_status', $revenueStatus)
->groupBy('stats.orderId, stats.orderPriceTotal');
if ($startTime) {
$queryBuilder
->andWhere('stats.createdAt >= :dateTime')
->setParameter('dateTime', $startTime);
}
$purchases =
$queryBuilder->getQuery()
->getResult();
$sum = array_sum(array_column($purchases, 'orderPriceTotal'));
return new WooCommerceRevenue(
$currency,
(float)$sum,
count($purchases),
$this->wcHelper
);
}
private function applyDateConstraint(QueryBuilder $queryBuilder, Carbon $startTime): QueryBuilder {
$queryBuilder->join(StatisticsNewsletterEntity::class, 'sent_stats', 'WITH', 'stats.newsletter = sent_stats.newsletter AND stats.subscriber = sent_stats.subscriber AND sent_stats.sentAt >= :dateTime')
->setParameter('dateTime', $startTime);
return $queryBuilder;
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,131 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Util\Helpers;
class SubscriberActions {
/** @var SettingsController */
private $settings;
/** @var NewSubscriberNotificationMailer */
private $newSubscriberNotificationMailer;
/** @var ConfirmationEmailMailer */
private $confirmationEmailMailer;
/** @var WelcomeScheduler */
private $welcomeScheduler;
/** @var SubscriberSaveController */
private $subscriberSaveController;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SubscriberSegmentRepository */
private $subscriberSegmentRepository;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
SettingsController $settings,
NewSubscriberNotificationMailer $newSubscriberNotificationMailer,
ConfirmationEmailMailer $confirmationEmailMailer,
WelcomeScheduler $welcomeScheduler,
SegmentsRepository $segmentsRepository,
SubscriberSaveController $subscriberSaveController,
SubscribersRepository $subscribersRepository,
SubscriberSegmentRepository $subscriberSegmentRepository
) {
$this->settings = $settings;
$this->newSubscriberNotificationMailer = $newSubscriberNotificationMailer;
$this->confirmationEmailMailer = $confirmationEmailMailer;
$this->welcomeScheduler = $welcomeScheduler;
$this->subscriberSaveController = $subscriberSaveController;
$this->subscribersRepository = $subscribersRepository;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->segmentsRepository = $segmentsRepository;
}
/**
* Returns SubscriberEntity and associative array with some metadata related to the subscription (e.g. ['confirmationEmailResult' => $exception])
* @return array{0: SubscriberEntity, 1: array{confirmationEmailResult: bool|\Exception}}
*/
public function subscribe($subscriberData = [], $segmentIds = []): array {
// filter out keys from the subscriber_data array
// that should not be editable when subscribing
$subscriberData = $this->subscriberSaveController->filterOutReservedColumns($subscriberData);
$signupConfirmationEnabled = (bool)$this->settings->get(
'signup_confirmation.enabled'
);
$subscriberData['subscribed_ip'] = Helpers::getIP();
$subscriber = $this->subscribersRepository->findOneBy(['email' => $subscriberData['email']]);
if (!$subscriber && !isset($subscriberData['source'])) {
$subscriberData['source'] = Source::FORM;
}
if (!$subscriber || !$signupConfirmationEnabled) {
// create new subscriber or update if no confirmation is required
$subscriber = $this->subscriberSaveController->createOrUpdate($subscriberData, $subscriber);
// custom fields should use the same approach as the subscriber main data that means to wait on confirmation
$this->subscriberSaveController->updateCustomFields($subscriberData, $subscriber);
} else {
// store subscriber data to be updated after confirmation
$unconfirmedData = $this->subscriberSaveController->filterOutReservedColumns($subscriberData);
$unconfirmedData = json_encode($unconfirmedData);
$subscriber->setUnconfirmedData($unconfirmedData ?: null);
}
// restore trashed subscriber
if ($subscriber->getDeletedAt()) {
$subscriber->setDeletedAt(null);
}
// set status depending on signup confirmation setting
if ($subscriber->getStatus() !== SubscriberEntity::STATUS_SUBSCRIBED) {
if ($signupConfirmationEnabled === true) {
$subscriber->setStatus(SubscriberEntity::STATUS_UNCONFIRMED);
} else {
$subscriber->setStatus(SubscriberEntity::STATUS_SUBSCRIBED);
}
}
$this->subscribersRepository->flush();
$metaData = ['confirmationEmailResult' => false];
// link subscriber to segments
$segments = $this->segmentsRepository->findBy(['id' => $segmentIds]);
$this->subscriberSegmentRepository->subscribeToSegments($subscriber, $segments);
try {
$metaData['confirmationEmailResult'] = $this->confirmationEmailMailer->sendConfirmationEmailOnce($subscriber);
} catch (\Exception $e) {
$metaData['confirmationEmailResult'] = $e;
}
// We want to send the notification on subscribe only when signupConfirmation is disabled
if ($signupConfirmationEnabled === false && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
$this->newSubscriberNotificationMailer->send($subscriber, $this->segmentsRepository->findBy(['id' => $segmentIds]));
$this->welcomeScheduler->scheduleSubscriberWelcomeNotification(
$subscriber->getId(),
$segmentIds
);
}
return [$subscriber, $metaData];
}
}
@@ -0,0 +1,36 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\CustomFieldEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
/**
* @extends Repository<SubscriberCustomFieldEntity>
*/
class SubscriberCustomFieldRepository extends Repository {
protected function getEntityClassName() {
return SubscriberCustomFieldEntity::class;
}
/**
* @param string|array|null $value
*/
public function createOrUpdate(SubscriberEntity $subscriber, CustomFieldEntity $customField, $value): SubscriberCustomFieldEntity {
$subscriberCustomField = $this->findOneBy(['subscriber' => $subscriber, 'customField' => $customField]);
if ($subscriberCustomField instanceof SubscriberCustomFieldEntity) {
$subscriberCustomField->setValue($value);
} else {
$subscriberCustomField = new SubscriberCustomFieldEntity($subscriber, $customField, $value);
$this->entityManager->persist($subscriberCustomField);
$subscriber->getSubscriberCustomFields()->add($subscriberCustomField);
}
$this->entityManager->flush();
return $subscriberCustomField;
}
}
@@ -0,0 +1,54 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\SubscriberIPEntity;
use MailPoetVendor\Carbon\Carbon;
/**
* @extends Repository<SubscriberIPEntity>
*/
class SubscriberIPsRepository extends Repository {
protected function getEntityClassName() {
return SubscriberIPEntity::class;
}
public function findOneByIPAndCreatedAtAfterTimeInSeconds(string $ip, int $seconds): ?SubscriberIPEntity {
return $this->entityManager->createQueryBuilder()
->select('sip')
->from(SubscriberIPEntity::class, 'sip')
->where('sip.ip = :ip')
->andWhere('sip.createdAt >= :timeThreshold')
->setParameter('ip', $ip)
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function getCountByIPAndCreatedAtAfterTimeInSeconds(string $ip, int $seconds): int {
return (int)$this->entityManager->createQueryBuilder()
->select('COUNT(sip)')
->from(SubscriberIPEntity::class, 'sip')
->where('sip.ip = :ip')
->andWhere('sip.createdAt >= :timeThreshold')
->setParameter('ip', $ip)
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
->getQuery()
->getSingleScalarResult();
}
public function deleteCreatedAtBeforeTimeInSeconds(int $seconds): int {
return (int)$this->entityManager->createQueryBuilder()
->delete()
->from(SubscriberIPEntity::class, 'sip')
->where('sip.createdAt < :timeThreshold')
->setParameter('timeThreshold', (new Carbon())->subSeconds($seconds))
->getQuery()
->execute();
}
}
@@ -0,0 +1,432 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\TagEntity;
use MailPoet\Listing\ListingDefinition;
use MailPoet\Listing\ListingRepository;
use MailPoet\Segments\DynamicSegments\FilterHandler;
use MailPoet\Segments\SegmentSubscribersRepository;
use MailPoet\Util\Helpers;
use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder as DBALQueryBuilder;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
use MailPoetVendor\Doctrine\ORM\QueryBuilder;
class SubscriberListingRepository extends ListingRepository {
public const FILTER_WITHOUT_LIST = 'without-list';
const DEFAULT_SORT_BY = 'createdAt';
private static $supportedStatuses = [
SubscriberEntity::STATUS_SUBSCRIBED,
SubscriberEntity::STATUS_UNSUBSCRIBED,
SubscriberEntity::STATUS_INACTIVE,
SubscriberEntity::STATUS_BOUNCED,
SubscriberEntity::STATUS_UNCONFIRMED,
];
/** @var FilterHandler */
private $dynamicSegmentsFilter;
/** @var EntityManager */
private $entityManager;
/** @var SegmentSubscribersRepository */
private $segmentSubscribersRepository;
/** @var SubscribersCountsController */
private $subscribersCountsController;
/** @var null | ListingDefinition */
private $definition = null;
public function __construct(
EntityManager $entityManager,
FilterHandler $dynamicSegmentsFilter,
SegmentSubscribersRepository $segmentSubscribersRepository,
SubscribersCountsController $subscribersCountsController
) {
parent::__construct($entityManager);
$this->dynamicSegmentsFilter = $dynamicSegmentsFilter;
$this->entityManager = $entityManager;
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
$this->subscribersCountsController = $subscribersCountsController;
}
public function getData(ListingDefinition $definition): array {
$this->definition = $definition;
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getData($definition);
}
return $this->getDataForDynamicSegment($definition, $dynamicSegment);
}
public function getCount(ListingDefinition $definition): int {
$this->definition = $definition;
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getCount($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("count(DISTINCT $subscribersTable.id)")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
return (int)$subscribersIdsQuery->execute()->fetchOne();
}
public function getActionableIds(ListingDefinition $definition): array {
$this->definition = $definition;
$ids = $definition->getSelection();
if (!empty($ids)) {
return $ids;
}
$dynamicSegment = $this->getDynamicSegmentFromFilters($definition);
if ($dynamicSegment === null) {
return parent::getActionableIds($definition);
}
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $dynamicSegment);
$idsStatement = $subscribersIdsQuery->execute();
$result = $idsStatement->fetchAll();
return array_column($result, 'id');
}
protected function applySelectClause(QueryBuilder $queryBuilder) {
$queryBuilder->select("PARTIAL s.{id,email,firstName,lastName,status,createdAt,updatedAt,countConfirmations,wpUserId,isWoocommerceUser,engagementScore,lastSubscribedAt}");
}
protected function applyFromClause(QueryBuilder $queryBuilder) {
$queryBuilder->from(SubscriberEntity::class, 's');
}
protected function applyGroup(QueryBuilder $queryBuilder, string $group) {
// include/exclude deleted
if ($group === 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NOT NULL');
} else {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
if (!in_array($group, self::$supportedStatuses)) {
return;
}
if (!in_array($group, [SubscriberEntity::STATUS_SUBSCRIBED, SubscriberEntity::STATUS_UNSUBSCRIBED])) {
$queryBuilder
->andWhere('s.status = :status')
->setParameter('status', $group);
return;
}
$segment = $this->definition && array_key_exists('segment', $this->definition->getFilters()) ? $this->entityManager->find(SegmentEntity::class, (int)$this->definition->getFilters()['segment']) : null;
if (!$segment instanceof SegmentEntity || !$segment->isStatic()) {
$queryBuilder
->andWhere('s.status = :status')
->setParameter('status', $group);
return;
}
$operator = $group === SubscriberEntity::STATUS_SUBSCRIBED ? 'AND' : 'OR';
$queryBuilder
->andWhere('(s.status = :status ' . $operator . ' ss.status = :status)')
->setParameter('status', $group);
}
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) {
if (isset($filters['segment'])) {
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
$this->segmentSubscribersRepository->addConstraintsForSubscribersWithoutSegment($queryBuilder);
} else {
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
if ($segment instanceof SegmentEntity && $segment->isStatic()) {
$queryBuilder->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :ssSegment')
->setParameter('ssSegment', $segment->getId());
}
}
}
// filtering by minimal updated at
if (isset($filters['minUpdatedAt']) && $filters['minUpdatedAt'] instanceof \DateTimeInterface) {
$queryBuilder->andWhere('s.updatedAt >= :updatedAt')
->setParameter('updatedAt', $filters['minUpdatedAt']);
}
if (isset($filters['tag'])) {
$tag = $this->entityManager->find(TagEntity::class, (int)$filters['tag']);
if ($tag) {
$queryBuilder->join('s.subscriberTags', 'st', Join::WITH, 'st.tag = :stTag')
->setParameter('stTag', $tag);
}
}
}
protected function applyParameters(QueryBuilder $queryBuilder, array $parameters) {
// nothing to do here
}
protected function applySorting(QueryBuilder $queryBuilder, string $sortBy, string $sortOrder) {
if (!$sortBy) {
$sortBy = self::DEFAULT_SORT_BY;
}
$queryBuilder->addOrderBy("s.$sortBy", $sortOrder);
}
public function getGroups(ListingDefinition $definition): array {
$queryBuilder = clone $this->queryBuilder;
$this->applyFromClause($queryBuilder);
$groupCounts = [
SubscriberEntity::STATUS_SUBSCRIBED => 0,
SubscriberEntity::STATUS_UNCONFIRMED => 0,
SubscriberEntity::STATUS_UNSUBSCRIBED => 0,
SubscriberEntity::STATUS_INACTIVE => 0,
SubscriberEntity::STATUS_BOUNCED => 0,
'trash' => 0,
];
foreach (array_keys($groupCounts) as $group) {
$groupDefinition = $group === $definition->getGroup() ? $definition : new ListingDefinition(
$group,
$definition->getFilters(),
$definition->getSearch(),
$definition->getParameters(),
$definition->getSortBy(),
$definition->getSortOrder(),
$definition->getOffset(),
$definition->getLimit(),
$definition->getSelection()
);
$groupCounts[$group] = $this->getCount($groupDefinition);
}
$trashedCount = $groupCounts['trash'];
unset($groupCounts['trash']);
$totalCount = (int)array_sum($groupCounts);
return [
[
'name' => 'all',
'label' => __('All', 'mailpoet'),
'count' => $totalCount,
],
[
'name' => SubscriberEntity::STATUS_SUBSCRIBED,
'label' => __('Subscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_SUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_UNCONFIRMED,
'label' => __('Unconfirmed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNCONFIRMED],
],
[
'name' => SubscriberEntity::STATUS_UNSUBSCRIBED,
'label' => __('Unsubscribed', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_UNSUBSCRIBED],
],
[
'name' => SubscriberEntity::STATUS_INACTIVE,
'label' => __('Inactive', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_INACTIVE],
],
[
'name' => SubscriberEntity::STATUS_BOUNCED,
'label' => __('Bounced', 'mailpoet'),
'count' => $groupCounts[SubscriberEntity::STATUS_BOUNCED],
],
[
'name' => 'trash',
'label' => __('Trash', 'mailpoet'),
'count' => $trashedCount,
],
];
}
public function getFilters(ListingDefinition $definition): array {
return [
'segment' => $this->getSegmentFilter($definition),
'tag' => $this->getTagsFilter($definition),
];
}
/**
* @return array<array{label: string, value: string|int}>
*/
private function getSegmentFilter(ListingDefinition $definition): array {
$group = $definition->getGroup();
$subscribersWithoutSegmentStats = $this->subscribersCountsController->getSubscribersWithoutSegmentStatisticsCount();
$key = $group ?: 'all';
$subscribersWithoutSegmentCount = $subscribersWithoutSegmentStats[$key];
$subscribersWithoutSegmentLabel = sprintf(
// translators: %s is the number of subscribers without a list.
__('Subscribers without a list (%s)', 'mailpoet'),
number_format((float)$subscribersWithoutSegmentCount)
);
$queryBuilder = clone $this->queryBuilder;
$queryBuilder
->select('s')
->from(SegmentEntity::class, 's');
if ($group !== 'trash') {
$queryBuilder->andWhere('s.deletedAt IS NULL');
}
// format segment list
$allSubscribersList = [
'label' => __('All Lists', 'mailpoet'),
'value' => '',
];
$withoutSegmentList = [
'label' => $subscribersWithoutSegmentLabel,
'value' => self::FILTER_WITHOUT_LIST,
];
$segmentList = [];
foreach ($queryBuilder->getQuery()->getResult() as $segment) {
$key = $group ?: 'all';
$count = $this->subscribersCountsController->getSegmentStatisticsCount($segment);
$subscribersCount = (float)$count[$key];
// filter segments without subscribers
if (!$subscribersCount) {
continue;
}
$segmentList[] = [
'label' => sprintf('%s (%s)', $segment->getName(), number_format($subscribersCount)),
'value' => $segment->getId(),
];
}
usort($segmentList, function($a, $b) {
return strcasecmp($a['label'], $b['label']);
});
array_unshift($segmentList, $allSubscribersList, $withoutSegmentList);
return $segmentList;
}
/**
* @return array<int, array{label: string, value: string|int}>
*/
private function getTagsFilter(ListingDefinition $definition): array {
$group = $definition->getGroup();
$allTagsList = [
'label' => __('All Tags', 'mailpoet'),
'value' => '',
];
$status = in_array($group, ['all', 'trash']) ? null : $group;
$isDeleted = $group === 'trash';
$tagsStatistics = $this->subscribersCountsController->getTagsStatisticsCount($status, $isDeleted);
$tagsList = [];
foreach ($tagsStatistics as $tagStatistics) {
$tagsList[] = [
'label' => sprintf('%s (%s)', $tagStatistics['name'], number_format((float)$tagStatistics['subscribersCount'])),
'value' => $tagStatistics['id'],
];
}
array_unshift($tagsList, $allTagsList);
return $tagsList;
}
private function getDataForDynamicSegment(ListingDefinition $definition, SegmentEntity $segment) {
$queryBuilder = clone $this->queryBuilder;
$sortBy = Helpers::underscoreToCamelCase($definition->getSortBy()) ?: self::DEFAULT_SORT_BY;
$this->applySelectClause($queryBuilder);
$this->applyFromClause($queryBuilder);
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscribersIdsQuery = $this->entityManager
->getConnection()
->createQueryBuilder()
->select("DISTINCT $subscribersTable.id")
->from($subscribersTable);
$subscribersIdsQuery = $this->applyConstraintsForDynamicSegment($subscribersIdsQuery, $definition, $segment);
$subscribersIdsQuery->orderBy("$subscribersTable." . Helpers::camelCaseToUnderscore($sortBy), $definition->getSortOrder());
$subscribersIdsQuery->setFirstResult($definition->getOffset());
$subscribersIdsQuery->setMaxResults($definition->getLimit());
$idsStatement = $subscribersIdsQuery->executeQuery();
$result = $idsStatement->fetchAll();
$ids = array_column($result, 'id');
if (count($ids)) {
$queryBuilder->andWhere('s.id IN (:subscriberIds)')
->setParameter('subscriberIds', $ids);
} else {
$queryBuilder->andWhere('0 = 1'); // Don't return any subscribers if no ids found
}
$this->applySorting($queryBuilder, $sortBy, $definition->getSortOrder());
return $queryBuilder->getQuery()->getResult();
}
private function applyConstraintsForDynamicSegment(
DBALQueryBuilder $subscribersQuery,
ListingDefinition $definition,
SegmentEntity $segment
) {
// Apply dynamic segments filters
$subscribersQuery = $this->dynamicSegmentsFilter->apply($subscribersQuery, $segment);
// Apply group, search to fetch only necessary ids
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
if ($definition->getSearch()) {
$search = Helpers::escapeSearch((string)$definition->getSearch());
$subscribersQuery
->andWhere("$subscribersTable.email LIKE :search or $subscribersTable.first_name LIKE :search or $subscribersTable.last_name LIKE :search")
->setParameter('search', "%$search%");
}
if ($definition->getGroup()) {
if ($definition->getGroup() === 'trash') {
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NOT NULL");
} else {
$subscribersQuery->andWhere("$subscribersTable.deleted_at IS NULL");
}
if (in_array($definition->getGroup(), self::$supportedStatuses)) {
$subscribersQuery
->andWhere("$subscribersTable.status = :status")
->setParameter('status', $definition->getGroup());
}
}
return $subscribersQuery;
}
private function getDynamicSegmentFromFilters(ListingDefinition $definition): ?SegmentEntity {
$filters = $definition->getFilters();
if (!$filters || !isset($filters['segment'])) {
return null;
}
if ($filters['segment'] === self::FILTER_WITHOUT_LIST) {
return null;
}
$segment = $this->entityManager->find(SegmentEntity::class, (int)$filters['segment']);
if (!$segment instanceof SegmentEntity) {
return null;
}
return $segment->isStatic() ? null : $segment;
}
}
@@ -0,0 +1,78 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscriberPersonalDataEraser {
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var EntityManager */
private $entityManager;
/** @var SubscriberCustomFieldRepository */
private $subscriberCustomFieldRepository;
public function __construct(
SubscribersRepository $subscribersRepository,
EntityManager $entityManager,
SubscriberCustomFieldRepository $subscriberCustomFieldRepository
) {
$this->subscribersRepository = $subscribersRepository;
$this->entityManager = $entityManager;
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
}
public function erase($email) {
if (empty($email)) {
return [
'items_removed' => false,
'items_retained' => false,
'messages' => [],
'done' => true,
];
}
$subscriber = $this->subscribersRepository->findOneBy(['email' => trim($email)]);
$itemRemoved = false;
$itemsRetained = true;
if ($subscriber) {
$this->eraseCustomFields($subscriber);
$this->anonymizeSubscriberData($subscriber);
$itemRemoved = true;
$itemsRetained = false;
}
return [
'items_removed' => $itemRemoved,
'items_retained' => $itemsRetained,
'messages' => [],
'done' => true,
];
}
private function eraseCustomFields(SubscriberEntity $subscriber) {
$customFields = $this->subscriberCustomFieldRepository->findBy(['subscriber' => $subscriber]);
foreach ($customFields as $customField) {
$customField->setValue('');
$this->entityManager->persist($customField);
}
$this->entityManager->flush();
}
private function anonymizeSubscriberData(SubscriberEntity $subscriber) {
$subscriber->setEmail(sprintf('deleted-%s@site.invalid', bin2hex(random_bytes(12)))); // phpcs:ignore
$subscriber->setFirstName('Anonymous');
$subscriber->setLastName('Anonymous');
$subscriber->setStatus(SubscriberEntity::STATUS_UNSUBSCRIBED);
$subscriber->setSubscribedIp('0.0.0.0');
$subscriber->setConfirmedIp('0.0.0.0');
$subscriber->setUnconfirmedData('');
$this->entityManager->persist($subscriber);
$this->entityManager->flush();
}
}
@@ -0,0 +1,370 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\ConflictException;
use MailPoet\CustomFields\CustomFieldsRepository;
use MailPoet\Doctrine\Validator\ValidationException;
use MailPoet\Entities\StatisticsUnsubscribeEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Entities\SubscriberTagEntity;
use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Statistics\Track\Unsubscribes;
use MailPoet\Tags\TagRepository;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class SubscriberSaveController {
/** @var CustomFieldsRepository */
private $customFieldsRepository;
/** @var Security */
private $security;
/** @var SettingsController */
private $settings;
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var SubscriberCustomFieldRepository */
private $subscriberCustomFieldRepository;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var SubscriberSegmentRepository */
private $subscriberSegmentRepository;
/** @var SubscriberTagRepository */
private $subscriberTagRepository;
/** @var TagRepository */
private $tagRepository;
/** @var Unsubscribes */
private $unsubscribesTracker;
/** @var WelcomeScheduler */
private $welcomeScheduler;
/** @var WPFunctions */
private $wp;
public function __construct(
CustomFieldsRepository $customFieldsRepository,
Security $security,
SettingsController $settings,
SegmentsRepository $segmentsRepository,
SubscriberCustomFieldRepository $subscriberCustomFieldRepository,
SubscribersRepository $subscribersRepository,
SubscriberSegmentRepository $subscriberSegmentRepository,
SubscriberTagRepository $subscriberTagRepository,
TagRepository $tagRepository,
Unsubscribes $unsubscribesTracker,
WelcomeScheduler $welcomeScheduler,
WPFunctions $wp
) {
$this->customFieldsRepository = $customFieldsRepository;
$this->security = $security;
$this->settings = $settings;
$this->segmentsRepository = $segmentsRepository;
$this->subscriberSegmentRepository = $subscriberSegmentRepository;
$this->subscribersRepository = $subscribersRepository;
$this->subscriberCustomFieldRepository = $subscriberCustomFieldRepository;
$this->tagRepository = $tagRepository;
$this->unsubscribesTracker = $unsubscribesTracker;
$this->welcomeScheduler = $welcomeScheduler;
$this->wp = $wp;
$this->subscriberTagRepository = $subscriberTagRepository;
}
public function filterOutReservedColumns(array $subscriberData): array {
$reservedColumns = [
'id',
'wp_user_id',
'is_woocommerce_user',
'status',
'subscribed_ip',
'confirmed_ip',
'confirmed_at',
'created_at',
'updated_at',
'deleted_at',
'unconfirmed_data',
];
return array_diff_key(
$subscriberData,
array_flip($reservedColumns)
);
}
/**
* @throws ConflictException
* @throws ValidationException
* @throws \Exception
*/
public function save(array $data): SubscriberEntity {
if (!empty($data)) {
$data = $this->wp->stripslashesDeep($data);
}
if (empty($data['segments'])) {
$data['segments'] = [];
}
$data['segments'] = array_merge($data['segments'], $this->getNonDefaultSubscribedSegments($data));
$newSegments = $this->findNewSegments($data);
if (empty($data['tags'])) {
$data['tags'] = [];
}
$oldSubscriber = $this->findSubscriber($data);
$oldStatus = $oldSubscriber ? $oldSubscriber->getStatus() : null;
if (
$oldSubscriber instanceof SubscriberEntity
&& isset($data['status'])
&& ($data['status'] === SubscriberEntity::STATUS_UNSUBSCRIBED)
&& ($oldSubscriber->getStatus() !== SubscriberEntity::STATUS_UNSUBSCRIBED)
) {
$currentUser = $this->wp->wpGetCurrentUser();
$this->unsubscribesTracker->track(
(int)$oldSubscriber->getId(),
StatisticsUnsubscribeEntity::SOURCE_ADMINISTRATOR,
null,
$currentUser->display_name // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
);
}
if (isset($data['email']) && $this->isNewEmail($data['email'], $oldSubscriber)) {
$this->verifyEmailIsUnique($data['email']);
}
$subscriber = $this->createOrUpdate($data, $oldSubscriber);
$this->updateCustomFields($data, $subscriber);
$this->updateTags($data, $subscriber);
$segments = isset($data['segments']) ? $this->findSegments($data['segments']) : null;
// check for status change
if (
$oldStatus === SubscriberEntity::STATUS_SUBSCRIBED
&& $subscriber->getStatus() === SubscriberEntity::STATUS_UNSUBSCRIBED
) {
// make sure we unsubscribe the user from all segments
$this->subscriberSegmentRepository->unsubscribeFromSegments($subscriber);
} elseif ($segments !== null) {
$this->subscriberSegmentRepository->resetSubscriptions($subscriber, $segments);
}
if (!empty($newSegments)) {
$this->welcomeScheduler->scheduleSubscriberWelcomeNotification($subscriber->getId(), $newSegments);
}
// when global status changes to subscribed, fire subscribed hook for all subscribed segments
if (
$subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
&& $oldStatus !== null // don't trigger for new subscribers (handled in subscriber segments repository)
&& $oldStatus !== SubscriberEntity::STATUS_SUBSCRIBED
) {
$segments = $subscriber->getSubscriberSegments();
foreach ($segments as $subscriberSegment) {
if ($subscriberSegment->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
$this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
}
}
}
return $subscriber;
}
private function getNonDefaultSubscribedSegments(array $data): array {
if (!isset($data['id']) || (int)$data['id'] <= 0) {
return [];
}
$subscribedSegments = $this->subscriberSegmentRepository->getNonDefaultSubscribedSegments($data['id']);
return array_filter(array_map(function(SubscriberSegmentEntity $subscriberSegment): int {
$segment = $subscriberSegment->getSegment();
if (!$segment) {
return 0;
}
return (int)$segment->getId();
}, $subscribedSegments));
}
private function findSegments(array $segmentIds): array {
return $this->segmentsRepository->findBy(['id' => $segmentIds]);
}
private function findNewSegments(array $data): array {
$oldSegmentIds = [];
if (isset($data['id']) && (int)$data['id'] > 0) {
$subscribersSegments = $this->subscriberSegmentRepository->findBy(['subscriber' => $data['id']]);
foreach ($subscribersSegments as $subscribersSegment) {
$segment = $subscribersSegment->getSegment();
if (!$segment) {
continue;
}
$oldSegmentIds[] = (int)$segment->getId();
}
}
return array_diff($data['segments'], $oldSegmentIds);
}
/**
* @throws ValidationException
*/
public function createOrUpdate(array $data, ?SubscriberEntity $subscriber): SubscriberEntity {
if (!$subscriber) {
$subscriber = $this->createSubscriber();
if (!isset($data['source'])) $data['source'] = Source::ADMINISTRATOR;
}
if (isset($data['email'])) $subscriber->setEmail($data['email']);
if (isset($data['first_name'])) $subscriber->setFirstName($data['first_name']);
if (isset($data['last_name'])) $subscriber->setLastName($data['last_name']);
if (isset($data['status'])) $subscriber->setStatus($data['status']);
if (isset($data['source'])) $subscriber->setSource($data['source']);
if (isset($data['wp_user_id'])) $subscriber->setWpUserId($data['wp_user_id']);
if (isset($data['subscribed_ip'])) $subscriber->setSubscribedIp($data['subscribed_ip']);
if (isset($data['confirmed_ip'])) $subscriber->setConfirmedIp($data['confirmed_ip']);
if (isset($data['is_woocommerce_user'])) $subscriber->setIsWoocommerceUser((bool)$data['is_woocommerce_user']);
$createdAt = isset($data['created_at']) ? Carbon::createFromFormat('Y-m-d H:i:s', $data['created_at']) : null;
if ($createdAt) $subscriber->setCreatedAt($createdAt);
$confirmedAt = isset($data['confirmed_at']) ? Carbon::createFromFormat('Y-m-d H:i:s', $data['confirmed_at']) : null;
if ($confirmedAt) $subscriber->setConfirmedAt($confirmedAt);
// wipe any unconfirmed data at this point
$subscriber->setUnconfirmedData(null);
// Validate the email (Saving group) + everything else (Default group)
$subscriber->setValidationGroups(['Saving', 'Default']);
try {
$this->subscribersRepository->persist($subscriber);
$this->subscribersRepository->flush();
} catch (ValidationException $exception) {
// detach invalid entity because it can block another work with doctrine
$this->subscribersRepository->detach($subscriber);
throw $exception;
}
return $subscriber;
}
private function isNewEmail(string $email, ?SubscriberEntity $subscriber): bool {
if ($subscriber && ($subscriber->getEmail() === $email)) return false;
return true;
}
/**
* @throws ConflictException
*/
private function verifyEmailIsUnique(string $email): void {
$existingSubscriber = $this->subscribersRepository->findOneBy(['email' => $email]);
if ($existingSubscriber) {
// translators: %s is email address which already exists.
$exceptionMessage = sprintf(__('A subscriber with E-mail "%s" already exists.', 'mailpoet'), $email);
throw new ConflictException($exceptionMessage);
}
}
private function createSubscriber(): SubscriberEntity {
$subscriber = new SubscriberEntity();
$subscriber->setUnsubscribeToken($this->security->generateUnsubscribeTokenByEntity($subscriber));
$subscriber->setLinkToken(Security::generateHash(SubscriberEntity::LINK_TOKEN_LENGTH));
$subscriber->setStatus(!$this->settings->get('signup_confirmation.enabled') ? SubscriberEntity::STATUS_SUBSCRIBED : SubscriberEntity::STATUS_UNCONFIRMED);
return $subscriber;
}
private function findSubscriber(array &$data): ?SubscriberEntity {
$subscriber = null;
if (isset($data['id']) && (int)$data['id'] > 0) {
$subscriber = $this->subscribersRepository->findOneById(((int)$data['id']));
unset($data['id']);
}
if (!$subscriber && !empty($data['email'])) {
$subscriber = $this->subscribersRepository->findOneBy(['email' => $data['email']]);
if ($subscriber) {
unset($data['email']);
}
}
return $subscriber;
}
public function updateCustomFields(array $data, SubscriberEntity $subscriber): void {
$customFieldsMap = [];
foreach ($data as $key => $value) {
if (strpos($key, 'cf_') === 0) {
$customFieldsMap[(int)substr($key, 3)] = $value;
}
}
if (empty($customFieldsMap)) {
return;
}
$customFields = $this->customFieldsRepository->findBy(['id' => array_keys($customFieldsMap)]);
foreach ($customFields as $customField) {
$this->subscriberCustomFieldRepository->createOrUpdate($subscriber, $customField, $customFieldsMap[$customField->getId()]);
}
}
private function updateTags(array $data, SubscriberEntity $subscriber): void {
$removedTags = [];
/**
* $data['tags'] is either an array of arrays containing name, id etc. of the tag or an array of strings - the names
* of the tag.
*
* Therefore we map it to be only an array of strings, containing the names of the tag.
*/
$tags = array_map(
function($tag): string {
if (is_array($tag)) {
return array_key_exists('name', $tag) ? (string)$tag['name'] : '';
}
return (string)$tag;
},
(array)$data['tags']
);
foreach ($subscriber->getSubscriberTags() as $subscriberTag) {
$tag = $subscriberTag->getTag();
if (!$tag || !in_array($tag->getName(), $tags, true)) {
$subscriber->getSubscriberTags()->removeElement($subscriberTag);
$removedTags[] = $subscriberTag;
}
}
$newlyAddedTags = [];
foreach ($tags as $tagName) {
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
$subscriberTag = $subscriber->getSubscriberTag($tag);
if (!$subscriberTag) {
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
$subscriber->getSubscriberTags()->add($subscriberTag);
$this->subscriberTagRepository->persist($subscriberTag);
$newlyAddedTags[] = $subscriberTag;
}
}
$this->subscriberTagRepository->flush();
foreach ($newlyAddedTags as $subscriberTag) {
$this->wp->doAction('mailpoet_subscriber_tag_added', $subscriberTag);
}
foreach ($removedTags as $subscriberTag) {
$this->wp->doAction('mailpoet_subscriber_tag_removed', $subscriberTag);
}
}
}
@@ -0,0 +1,181 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
/**
* @extends Repository<SubscriberSegmentEntity>
*/
class SubscriberSegmentRepository extends Repository {
/** @var WPFunctions */
private $wp;
public function __construct(
EntityManager $entityManager,
WPFunctions $wp
) {
parent::__construct($entityManager);
$this->wp = $wp;
}
protected function getEntityClassName() {
return SubscriberSegmentEntity::class;
}
public function getNonDefaultSubscribedSegments(int $subscriberId): array {
$qb = $this->entityManager->createQueryBuilder();
return $qb->select('ss')
->from(SubscriberSegmentEntity::class, 'ss')
->join('ss.segment', 'seg', Join::WITH, 'seg.type != :typeDefault')
->where('ss.subscriber = :subscriberId')
->andWhere('ss.status = :subscribed')
->setParameter('subscriberId', $subscriberId)
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('typeDefault', SegmentEntity::TYPE_DEFAULT)
->getQuery()
->getResult();
}
/**
* @param SegmentEntity[] $segments
*/
public function unsubscribeFromSegments(SubscriberEntity $subscriber, array $segments = []): void {
$subscriber->setConfirmationsCount(0);
if (!empty($segments)) {
// unsubscribe from segments
foreach ($segments as $segment) {
// do not remove subscriptions to the WP Users segment
if ($segment->getType() === SegmentEntity::TYPE_WP_USERS) {
continue;
}
$this->createOrUpdate($subscriber, $segment, SubscriberEntity::STATUS_UNSUBSCRIBED);
}
$this->entityManager->flush();
} else {
$subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$segmentTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
$this->entityManager->getConnection()->executeStatement("
UPDATE $subscriberSegmentTable ss
JOIN $segmentTable s ON s.`id` = ss.`segment_id` AND ss.`subscriber_id` = :subscriberId
SET ss.`status` = :status
WHERE s.`type` != :typeWordPress
", [
'subscriberId' => $subscriber->getId(),
'status' => SubscriberEntity::STATUS_UNSUBSCRIBED,
'typeWordPress' => SegmentEntity::TYPE_WP_USERS,
]);
// Refresh SubscriberSegments status
foreach ($subscriber->getSubscriberSegments() as $subscriberSegment) {
$this->entityManager->refresh($subscriberSegment);
}
}
}
public function resetSubscriptions(SubscriberEntity $subscriber, array $segments): void {
// Already existing subscriptions are stored in $existingSegments. Their IDs in $existingSegmentIds.
$existingSegments = array_values(array_filter(array_map(
function(SubscriberSegmentEntity $subscriberSegmentEntity): ?SegmentEntity {
return $subscriberSegmentEntity->getSegment();
},
$this->findBy(['subscriber' => $subscriber, 'status' => SubscriberEntity::STATUS_SUBSCRIBED])
)));
$existingSegmentIds = array_map(
function(SegmentEntity $segment): int {
return $segment->getId() ?? 0;
},
$existingSegments
);
// $segmentIds are the IDs of the segments we want the user to be subscribed to.
$segmentIds = array_map(
function(SegmentEntity $segment): int {
return $segment->getId() ?? 0;
},
$segments
);
// $unsubscribedSegments are the segment IDs to which we need to unsubscribe.
$unsubscribedSegments = array_diff($existingSegmentIds, $segmentIds);
// $newlySubscribedSegments are the segment IDs to which we need to newly subscribe.
$newlySubscribedSegments = array_diff($segmentIds, $existingSegmentIds);
if (!$newlySubscribedSegments && !$unsubscribedSegments) {
return;
}
// The segments we need to unsubscribe.
$unsubscribe = array_filter(
$existingSegments,
function(SegmentEntity $segment) use ($unsubscribedSegments): bool {
return in_array($segment->getId(), $unsubscribedSegments);
}
);
// The segments we need to newly subscribe.
$subscribe = array_filter(
$segments,
function(SegmentEntity $segment) use ($newlySubscribedSegments): bool {
return in_array($segment->getId(), $newlySubscribedSegments);
}
);
if ($unsubscribe) {
$this->unsubscribeFromSegments($subscriber, $unsubscribe);
}
if ($subscribe) {
$this->subscribeToSegments($subscriber, $subscribe);
}
}
/**
* @param SegmentEntity[] $segments
*/
public function subscribeToSegments(SubscriberEntity $subscriber, array $segments, bool $skipHooks = false): void {
foreach ($segments as $segment) {
$this->createOrUpdate($subscriber, $segment, SubscriberEntity::STATUS_SUBSCRIBED, $skipHooks);
}
}
public function createOrUpdate(
SubscriberEntity $subscriber,
SegmentEntity $segment,
string $status,
bool $skipHooks = false
): SubscriberSegmentEntity {
$subscriberSegment = $this->findOneBy(['segment' => $segment, 'subscriber' => $subscriber]);
$oldStatus = null;
if ($subscriberSegment instanceof SubscriberSegmentEntity) {
$oldStatus = $subscriberSegment->getStatus();
$subscriberSegment->setStatus($status);
} else {
$subscriberSegment = new SubscriberSegmentEntity($segment, $subscriber, $status);
$subscriber->getSubscriberSegments()->add($subscriberSegment);
$this->entityManager->persist($subscriberSegment);
}
$this->entityManager->flush();
// fire subscribed hook for new subscriptions
if (
!$skipHooks
&& $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
&& $subscriberSegment->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED
&& $oldStatus !== SubscriberEntity::STATUS_SUBSCRIBED
) {
$this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
}
return $subscriberSegment;
}
}
@@ -0,0 +1,297 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Captcha\CaptchaConstants;
use MailPoet\Captcha\CaptchaSession;
use MailPoet\Captcha\Validator\CaptchaValidator;
use MailPoet\Captcha\Validator\RecaptchaValidator;
use MailPoet\Captcha\Validator\ValidationError;
use MailPoet\Entities\FormEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberTagEntity;
use MailPoet\Form\FormsRepository;
use MailPoet\Form\Util\FieldNameObfuscator;
use MailPoet\NotFoundException;
use MailPoet\Segments\SubscribersFinder;
use MailPoet\Settings\SettingsController;
use MailPoet\Statistics\StatisticsFormsRepository;
use MailPoet\Subscription\Throttling as SubscriptionThrottling;
use MailPoet\Tags\TagRepository;
use MailPoet\UnexpectedValueException;
use MailPoet\WP\Functions as WPFunctions;
class SubscriberSubscribeController {
/** @var FormsRepository */
private $formsRepository;
/** @var CaptchaSession */
private $captchaSession;
/** @var FieldNameObfuscator */
private $fieldNameObfuscator;
/** @var SettingsController */
private $settings;
/** @var RequiredCustomFieldValidator */
private $requiredCustomFieldValidator;
/** @var SubscriberActions */
private $subscriberActions;
/** @var WPFunctions */
private $wp;
/** @var SubscriptionThrottling */
private $throttling;
/** @var StatisticsFormsRepository */
private $statisticsFormsRepository;
/** @var SubscribersFinder */
private $subscribersFinder;
/** @var TagRepository */
private $tagRepository;
/** @var SubscriberTagRepository */
private $subscriberTagRepository;
/** @var CaptchaValidator */
private $builtInCaptchaValidator;
/** @var RecaptchaValidator */
private $recaptchaValidator;
public function __construct(
CaptchaSession $captchaSession,
SubscriberActions $subscriberActions,
SubscribersFinder $subscribersFinder,
SubscriptionThrottling $throttling,
FieldNameObfuscator $fieldNameObfuscator,
RequiredCustomFieldValidator $requiredCustomFieldValidator,
SettingsController $settings,
FormsRepository $formsRepository,
StatisticsFormsRepository $statisticsFormsRepository,
TagRepository $tagRepository,
SubscriberTagRepository $subscriberTagRepository,
WPFunctions $wp,
CaptchaValidator $builtInCaptchaValidator,
RecaptchaValidator $recaptchaValidator
) {
$this->formsRepository = $formsRepository;
$this->captchaSession = $captchaSession;
$this->requiredCustomFieldValidator = $requiredCustomFieldValidator;
$this->fieldNameObfuscator = $fieldNameObfuscator;
$this->settings = $settings;
$this->subscriberActions = $subscriberActions;
$this->subscribersFinder = $subscribersFinder;
$this->wp = $wp;
$this->throttling = $throttling;
$this->statisticsFormsRepository = $statisticsFormsRepository;
$this->tagRepository = $tagRepository;
$this->subscriberTagRepository = $subscriberTagRepository;
$this->builtInCaptchaValidator = $builtInCaptchaValidator;
$this->recaptchaValidator = $recaptchaValidator;
}
public function subscribe(array $data): array {
$form = $this->getForm($data);
if (!empty($data['email'])) {
throw new UnexpectedValueException(__('Please leave the first field empty.', 'mailpoet'));
}
$captchaSettings = $this->settings->get('captcha');
$data = $this->initCaptcha($captchaSettings, $form, $data);
$data = $this->deobfuscateFormPayload($data);
try {
$this->requiredCustomFieldValidator->validate($data, $form);
} catch (\Exception $e) {
throw new UnexpectedValueException($e->getMessage());
}
$segmentIds = $this->getSegmentIds($form, $data['segments'] ?? []);
unset($data['segments']);
$meta = $this->validateCaptcha($captchaSettings, $data);
if (isset($meta['error'])) {
return $meta;
}
// only accept fields defined in the form
$formFieldIds = array_filter(array_map(function (array $formField): ?string {
if (!isset($formField['id'])) {
return null;
}
return is_numeric($formField['id']) ? "cf_{$formField['id']}" : $formField['id'];
}, $form->getBlocksByTypes(FormEntity::FORM_FIELD_TYPES)));
$data = array_intersect_key($data, array_flip($formFieldIds));
// make sure we don't allow too many subscriptions with the same ip address
$timeout = $this->throttling->throttle();
if ($timeout > 0) {
$timeToWait = $this->throttling->secondsToTimeString($timeout);
$meta['refresh_captcha'] = true;
// translators: %s is the amount of time the user has to wait.
$meta['error'] = sprintf(__('You need to wait %s before subscribing again.', 'mailpoet'), $timeToWait);
return $meta;
}
/**
* Fires before a subscription gets created.
* To interrupt the subscription process, you can throw an MailPoet\Exception.
* The error message will then be displayed to the user.
*
* @param array $data The subscription data.
* @param array $segmentIds The segment IDs the user gets subscribed to.
* @param FormEntity $form The form the user used to subscribe.
*/
$this->wp->doAction('mailpoet_subscription_before_subscribe', $data, $segmentIds, $form);
[$subscriber, $subscriptionMeta] = $this->subscriberActions->subscribe($data, $segmentIds);
if (!empty($captchaSettings['type']) && $captchaSettings['type'] === CaptchaConstants::TYPE_BUILTIN && isset($data['captcha_session_id'])) {
// Captcha has been verified, invalidate the session vars
$this->captchaSession->reset($data['captcha_session_id']);
}
// record form statistics
$this->statisticsFormsRepository->record($form, $subscriber);
$formSettings = $form->getSettings();
// add tags to subscriber if they are filled
$this->addTagsToSubscriber($formSettings['tags'] ?? [], $subscriber);
// Confirmation email failed. We want to show the error message
if ($subscriptionMeta['confirmationEmailResult'] instanceof \Exception) {
$meta['error'] = $subscriptionMeta['confirmationEmailResult']->getMessage();
return $meta;
}
if (!empty($formSettings['on_success'])) {
if ($formSettings['on_success'] === 'page') {
// redirect to a page on a success, pass the page url in the meta
$meta['redirect_url'] = $this->wp->getPermalink($formSettings['success_page']);
} else if ($formSettings['on_success'] === 'url') {
$meta['redirect_url'] = $formSettings['success_url'];
}
}
return $meta;
}
/**
* Checks if the subscriber is subscribed to any segments in the form
*
* @param FormEntity $form The form entity
* @param SubscriberEntity $subscriber The subscriber entity
* @return bool True if the subscriber is subscribed to any of the segments in the form
*/
public function isSubscribedToAnyFormSegments(FormEntity $form, SubscriberEntity $subscriber): bool {
$formSegments = array_merge($form->getSegmentBlocksSegmentIds(), $form->getSettingsSegmentIds());
$subscribersFound = $this->subscribersFinder->findSubscribersInSegments([$subscriber->getId()], $formSegments);
if (!empty($subscribersFound)) return true;
return false;
}
private function deobfuscateFormPayload($data): array {
return $this->fieldNameObfuscator->deobfuscateFormPayload($data);
}
private function initCaptcha(?array $captchaSettings, FormEntity $form, array $data): array {
if (
!$captchaSettings
|| !isset($captchaSettings['type'])
|| $captchaSettings['type'] !== CaptchaConstants::TYPE_BUILTIN
) {
return $data;
}
// When serving the built-in CAPTCHA for the first time, generate a new session ID.
if (!isset($data['captcha_session_id'])) {
$data['captcha_session_id'] = $this->captchaSession->generateSessionId();
}
$sessionId = $data['captcha_session_id'];
if (!isset($data['captcha'])) {
// Save form data to session
$this->captchaSession->setFormData($sessionId, array_merge($data, ['form_id' => $form->getId()]));
} elseif ($this->captchaSession->getFormData($sessionId)) {
// Restore form data from session
$data = array_merge($this->captchaSession->getFormData($sessionId), ['captcha' => $data['captcha']]);
}
return $data;
}
private function validateCaptcha($captchaSettings, $data): array {
if (empty($captchaSettings['type'])) {
return [];
}
try {
if ($captchaSettings['type'] === CaptchaConstants::TYPE_BUILTIN) {
$this->builtInCaptchaValidator->validate($data);
}
if (CaptchaConstants::isReCaptcha($captchaSettings['type'])) {
$this->recaptchaValidator->validate($data);
}
} catch (ValidationError $error) {
return $error->getMeta();
}
return [];
}
private function getSegmentIds(FormEntity $form, array $segmentIds): array {
// If form contains segment selection blocks allow only segments ids configured in those blocks
$segmentBlocksSegmentIds = $form->getSegmentBlocksSegmentIds();
if (!empty($segmentBlocksSegmentIds)) {
$segmentIds = array_intersect($segmentIds, $segmentBlocksSegmentIds);
} else {
$segmentIds = $form->getSettingsSegmentIds();
}
if (empty($segmentIds)) {
throw new UnexpectedValueException(__('Please select a list.', 'mailpoet'));
}
return $segmentIds;
}
private function getForm(array $data): FormEntity {
$formId = (isset($data['form_id']) ? (int)$data['form_id'] : false);
$form = $this->formsRepository->findOneById($formId);
if (!$form) {
throw new NotFoundException(__('Please specify a valid form ID.', 'mailpoet'));
}
return $form;
}
/**
* @param string[] $tagNames
*/
private function addTagsToSubscriber(array $tagNames, SubscriberEntity $subscriber): void {
foreach ($tagNames as $tagName) {
$tag = $this->tagRepository->createOrUpdate(['name' => $tagName]);
$subscriberTag = $subscriber->getSubscriberTag($tag);
if (!$subscriberTag) {
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
$subscriber->getSubscriberTags()->add($subscriberTag);
$this->subscriberTagRepository->persist($subscriberTag);
$this->subscriberTagRepository->flush();
$this->wp->doAction('mailpoet_subscriber_tag_added', $subscriberTag);
}
}
}
}
@@ -0,0 +1,18 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\SubscriberTagEntity;
/**
* @extends Repository<SubscriberTagEntity>
*/
class SubscriberTagRepository extends Repository {
protected function getEntityClassName() {
return SubscriberTagEntity::class;
}
}
@@ -0,0 +1,156 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Cache\TransientCache;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Segments\SegmentSubscribersRepository;
use MailPoet\Tags\TagRepository;
use MailPoet\Util\License\Features\Subscribers as SubscribersFeature;
use MailPoetVendor\Carbon\Carbon;
class SubscribersCountsController {
/** @var SegmentsRepository */
private $segmentsRepository;
/** @var SegmentSubscribersRepository */
private $segmentSubscribersRepository;
/** @var SubscribersRepository */
private $subscribersRepository;
/** @var TagRepository */
private $tagRepository;
/** @var TransientCache */
private $transientCache;
/** @var SubscribersFeature */
private $subscribersFeature;
public function __construct(
SegmentsRepository $segmentsRepository,
SegmentSubscribersRepository $segmentSubscribersRepository,
SubscribersRepository $subscribersRepository,
TagRepository $subscriberTagRepository,
TransientCache $transientCache,
SubscribersFeature $subscribersFeature
) {
$this->segmentSubscribersRepository = $segmentSubscribersRepository;
$this->transientCache = $transientCache;
$this->segmentsRepository = $segmentsRepository;
$this->subscribersRepository = $subscribersRepository;
$this->tagRepository = $subscriberTagRepository;
$this->subscribersFeature = $subscribersFeature;
}
public function getSubscribersWithoutSegmentStatisticsCount(): array {
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, 0)['item'] ?? null;
if (!$result) {
$result = $this->recalculateSubscribersWithoutSegmentStatisticsCache();
}
return $result;
}
public function getSegmentStatisticsCount(SegmentEntity $segment): array {
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, (int)$segment->getId())['item'] ?? null;
if (!$result) {
$result = $this->recalculateSegmentStatisticsCache($segment);
}
return $result;
}
public function getSegmentStatisticsCountById(int $segmentId): array {
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $segmentId)['item'] ?? null;
if (!$result) {
$segment = $this->segmentsRepository->findOneById($segmentId);
if (!$segment) {
throw new InvalidStateException();
}
$result = $this->recalculateSegmentStatisticsCache($segment);
}
return $result;
}
public function getHomepageStatistics(): array {
$result = $this->getCacheItem(TransientCache::SUBSCRIBERS_HOMEPAGE_STATISTICS_COUNT_KEY, 0)['item'] ?? [];
if (!$result) {
$result = $this->recalculateHomepageStatisticsCache();
}
return $result;
}
public function recalculateSegmentStatisticsCache(SegmentEntity $segment): array {
$result = $this->segmentSubscribersRepository->getSubscribersStatisticsCount($segment);
$this->setCacheItem(
TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY,
$result,
(int)$segment->getId()
);
return $result;
}
public function recalculateSubscribersWithoutSegmentStatisticsCache(): array {
$result = $this->segmentSubscribersRepository->getSubscribersWithoutSegmentStatisticsCount();
$this->setCacheItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $result, 0);
return $result;
}
public function recalculateHomepageStatisticsCache(): array {
$thirtyDaysAgo = Carbon::now()->millisecond(0)->subDays(30);
$result = [];
$result['listsDataSubscribed'] = $this->subscribersRepository->getListLevelCountsOfSubscribedAfter($thirtyDaysAgo);
$result['listsDataUnsubscribed'] = $this->subscribersRepository->getListLevelCountsOfUnsubscribedAfter($thirtyDaysAgo);
$result['subscribedCount'] = $this->subscribersRepository->getCountOfLastSubscribedAfter($thirtyDaysAgo);
$result['unsubscribedCount'] = $this->subscribersRepository->getCountOfUnsubscribedAfter($thirtyDaysAgo);
$result['subscribedSubscribersCount'] = $this->subscribersRepository->getCountOfSubscribersForStates([SubscriberEntity::STATUS_SUBSCRIBED]);
$this->setCacheItem(
TransientCache::SUBSCRIBERS_HOMEPAGE_STATISTICS_COUNT_KEY,
$result,
0
);
return $result;
}
public function removeRedundancyFromStatisticsCache() {
$segments = $this->segmentsRepository->findAll();
$segmentIds = array_map(function (SegmentEntity $segment): int {
return (int)$segment->getId();
}, $segments);
foreach ($this->transientCache->getItems(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY) as $id => $item) {
if (!in_array($id, $segmentIds)) {
$this->transientCache->invalidateItem(TransientCache::SUBSCRIBERS_STATISTICS_COUNT_KEY, $id);
}
}
}
/**
* Use cache only if subscribers count is above minimum
*/
private function getCacheItem(string $key, int $id): ?array {
if ($this->subscribersFeature->isSubscribersCountEnoughForCache()) {
return $this->transientCache->getItem($key, $id);
}
return [];
}
private function setCacheItem(string $key, array $item, int $id): void {
if ($this->subscribersFeature->isSubscribersCountEnoughForCache()) {
$this->transientCache->setItem($key, $item, $id);
}
}
/**
* @return array<int, array{id: int, name: string, subscribersCount: int}>
*/
public function getTagsStatisticsCount(?string $status, bool $isDeleted): array {
return $this->tagRepository->getSubscriberStatisticsCount($status, $isDeleted);
}
}
@@ -0,0 +1,139 @@
<?php declare(strict_types = 1);
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use MailPoet\Entities\ScheduledTaskEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
class SubscribersEmailCountsController {
/** @var EntityManager */
private $entityManager;
/** @var string */
private $subscribersTable;
/** @var string */
private $scheduledTasksTable;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
$this->subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$this->scheduledTasksTable = $this->entityManager->getClassMetadata(ScheduledTaskEntity::class)->getTableName();
}
public function updateSubscribersEmailCounts(?\DateTimeInterface $dateLastProcessed, int $batchSize, ?int $startId = null): array {
$scheduledTaskSubscribersTable = $this->entityManager->getClassMetadata(ScheduledTaskSubscriberEntity::class)->getTableName();
$connection = $this->entityManager->getConnection();
$dayAgo = new Carbon();
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
$startId = (int)$startId;
// Return if there are no new sending tasks
if ($dateLastProcessed && !$this->newSendingTasksSince($dateLastProcessed)) {
return [0, 0];
}
// Return if there are no subscribers to update
[$countSubscribersToUpdate, $endId] = $this->countAndMaxOfSubscribersInRange($startId, $batchSize);
if (!$countSubscribersToUpdate) {
return [0, 0];
}
$queryParams = [
'startId' => $startId,
'endId' => $endId,
'dayAgo' => $dayAgoIso,
];
if ($dateLastProcessed) {
$carbonDateLastProcessed = Carbon::createFromTimestamp($dateLastProcessed->getTimestamp());
$dateFromIso = ($carbonDateLastProcessed->subDay())->toDateTimeString();
$queryParams['dateFrom'] = $dateFromIso;
}
// If $dateLastProcessed provided, increment value, otherwise count all and reset value
$initUpdateValue = $dateLastProcessed ? 's.email_count' : '';
$dateLastProcessedSql = $dateLastProcessed ? ' AND st.processed_at >= :dateFrom' : '';
$connection->executeQuery(
"
UPDATE {$this->subscribersTable} as s
JOIN (
SELECT s.id, COUNT(st.id) as email_count
FROM {$this->subscribersTable} as s
JOIN {$scheduledTaskSubscribersTable} as sts ON s.id = sts.subscriber_id
JOIN {$this->scheduledTasksTable} as st ON st.id = sts.task_id
WHERE s.id >= :startId
AND s.id <= :endId
AND st.type = 'sending'
AND st.processed_at IS NOT NULL
AND st.processed_at < :dayAgo
{$dateLastProcessedSql}
GROUP BY s.id
) counts ON counts.id = s.id
SET s.email_count = {$initUpdateValue} + IFNULL(counts.email_count, 0)
",
$queryParams
);
return [$countSubscribersToUpdate, $endId];
}
private function newSendingTasksSince(\DateTimeInterface $dateLastProcessed): bool {
$carbonDateLastProcessed = Carbon::createFromTimestamp($dateLastProcessed->getTimestamp());
$dateFromIso = ($carbonDateLastProcessed->subDay())->toDateTimeString();
$queryParams['dateFrom'] = $dateFromIso;
$dayAgo = new Carbon();
$dayAgoIso = $dayAgo->subDay()->toDateTimeString();
$queryParams['dayAgo'] = $dayAgoIso;
$result = $this->entityManager->getConnection()->executeQuery(
"
SELECT count(id) FROM {$this->scheduledTasksTable}
WHERE type = 'sending'
AND processed_at IS NOT NULL
AND processed_at < :dayAgo
AND processed_at >= :dateFrom
",
$queryParams
)->fetchNumeric();
/** @var int[] $result - it's required for PHPStan */
return is_array($result) && isset($result[0]) && ((int)$result[0] > 0);
}
private function countAndMaxOfSubscribersInRange(int $startId, int $batchSize): array {
$result = $this->entityManager->getConnection()->executeQuery(
"
SELECT COUNT(ids.id) as count, COALESCE(MAX(ids.id), 0) as max FROM (
SELECT s.id FROM {$this->subscribersTable} as s
WHERE s.id >= :startId
ORDER BY s.id
LIMIT :batchSize
) ids
",
[
'startId' => $startId,
'batchSize' => $batchSize,
],
[
'startId' => ParameterType::INTEGER,
'batchSize' => ParameterType::INTEGER,
]
);
/** @var array{0: array{count:int, max:int}} $subscribersInRange */
$subscribersInRange = $result->fetchAllAssociative();
return [intval($subscribersInRange[0]['count']), intval($subscribersInRange[0]['max'])];
}
}
@@ -0,0 +1,681 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Subscribers;
if (!defined('ABSPATH')) exit;
use DateTimeInterface;
use MailPoet\Config\SubscriberChangesNotifier;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\StatisticsUnsubscribeEntity;
use MailPoet\Entities\SubscriberCustomFieldEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Entities\SubscriberTagEntity;
use MailPoet\Entities\TagEntity;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Util\License\Features\Subscribers;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\Query\Expr\Join;
/**
* @extends Repository<SubscriberEntity>
*/
class SubscribersRepository extends Repository {
/** @var WPFunctions */
private $wp;
protected $ignoreColumnsForUpdate = [
'wp_user_id',
'is_woocommerce_user',
'email',
'created_at',
'last_subscribed_at',
];
/** @var SubscriberChangesNotifier */
private $changesNotifier;
/** @var SegmentsRepository */
private $segmentsRepository;
public function __construct(
EntityManager $entityManager,
SubscriberChangesNotifier $changesNotifier,
WPFunctions $wp,
SegmentsRepository $segmentsRepository
) {
$this->wp = $wp;
parent::__construct($entityManager);
$this->changesNotifier = $changesNotifier;
$this->segmentsRepository = $segmentsRepository;
}
protected function getEntityClassName() {
return SubscriberEntity::class;
}
public function getTotalSubscribers(): int {
return $this->getCountOfSubscribersForStates([
SubscriberEntity::STATUS_SUBSCRIBED,
SubscriberEntity::STATUS_UNCONFIRMED,
SubscriberEntity::STATUS_INACTIVE,
]);
}
public function getCountOfSubscribersForStates(array $states): int {
$query = $this->entityManager
->createQueryBuilder()
->select('count(n.id)')
->from(SubscriberEntity::class, 'n')
->where('n.deletedAt IS NULL AND n.status IN (:statuses)')
->setParameter('statuses', $states)
->getQuery();
return intval($query->getSingleScalarResult());
}
public function invalidateTotalSubscribersCache(): void {
$this->wp->deleteTransient(Subscribers::SUBSCRIBERS_COUNT_CACHE_KEY);
}
public function findBySegment(int $segmentId): array {
return $this->entityManager
->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
->setParameter('segment', $segmentId)
->getQuery()->getResult();
}
public function findExclusiveSubscribersBySegment(int $segmentId): array {
return $this->entityManager->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->join('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
->leftJoin('s.subscriberSegments', 'ss2', Join::WITH, 'ss2.segment <> :segment AND ss2.status = :subscribed')
->leftJoin('ss2.segment', 'seg', Join::WITH, 'seg.deletedAt IS NULL')
->groupBy('s.id')
->andHaving('COUNT(seg.id) = 0')
->setParameter('segment', $segmentId)
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->getQuery()->getResult();
}
public function getWooCommerceSegmentSubscriber(string $email): ?SubscriberEntity {
$subscriber = $this->doctrineRepository->createQueryBuilder('s')
->join('s.subscriberSegments', 'ss')
->join('ss.segment', 'sg', Join::WITH, 'sg.type = :typeWcUsers')
->where('s.isWoocommerceUser = 1')
->andWhere('s.status IN (:subscribed, :unconfirmed)')
->andWhere('ss.status = :subscribed')
->andWhere('s.email = :email')
->setParameter('typeWcUsers', SegmentEntity::TYPE_WC_USERS)
->setParameter('subscribed', SubscriberEntity::STATUS_SUBSCRIBED)
->setParameter('unconfirmed', SubscriberEntity::STATUS_UNCONFIRMED)
->setParameter('email', $email)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $subscriber instanceof SubscriberEntity ? $subscriber : null;
}
/**
* @return int - number of processed ids
*/
public function bulkTrash(array $ids): int {
if (empty($ids)) {
return 0;
}
$this->entityManager->createQueryBuilder()
->update(SubscriberEntity::class, 's')
->set('s.deletedAt', 'CURRENT_TIMESTAMP()')
->where('s.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()->execute();
$this->changesNotifier->subscribersUpdated($ids);
$this->invalidateTotalSubscribersCache();
return count($ids);
}
/**
* @return int - number of processed ids
*/
public function bulkRestore(array $ids): int {
if (empty($ids)) {
return 0;
}
$this->entityManager->createQueryBuilder()
->update(SubscriberEntity::class, 's')
->set('s.deletedAt', ':deletedAt')
->where('s.id IN (:ids)')
->setParameter('deletedAt', null)
->setParameter('ids', $ids)
->getQuery()->execute();
$this->changesNotifier->subscribersUpdated($ids);
$this->invalidateTotalSubscribersCache();
return count($ids);
}
/**
* @return int - number of processed ids
*/
public function bulkDelete(array $ids): int {
if (empty($ids)) {
return 0;
}
$count = 0;
$this->entityManager->transactional(function (EntityManager $entityManager) use ($ids, &$count) {
// Delete subscriber segments
$this->removeSubscribersFromAllSegments($ids);
// Delete subscriber custom fields
$subscriberCustomFieldTable = $entityManager->getClassMetadata(SubscriberCustomFieldEntity::class)->getTableName();
$subscriberTable = $entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$entityManager->getConnection()->executeStatement("
DELETE scs FROM $subscriberCustomFieldTable scs
JOIN $subscriberTable s ON s.`id` = scs.`subscriber_id`
WHERE scs.`subscriber_id` IN (:ids)
AND s.`is_woocommerce_user` = false
AND s.`wp_user_id` IS NULL
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
// Delete subscriber tags
$subscriberTagTable = $entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
$entityManager->getConnection()->executeStatement("
DELETE st FROM $subscriberTagTable st
JOIN $subscriberTable s ON s.`id` = st.`subscriber_id`
WHERE st.`subscriber_id` IN (:ids)
AND s.`is_woocommerce_user` = false
AND s.`wp_user_id` IS NULL
", ['ids' => $ids], ['ids' => ArrayParameterType::INTEGER]);
$queryBuilder = $entityManager->createQueryBuilder();
$count = $queryBuilder->delete(SubscriberEntity::class, 's')
->where('s.id IN (:ids)')
->andWhere('s.wpUserId IS NULL')
->andWhere('s.isWoocommerceUser = false')
->setParameter('ids', $ids)
->getQuery()->execute();
});
$this->changesNotifier->subscribersDeleted($ids);
$this->invalidateTotalSubscribersCache();
return $count;
}
/**
* @return int - number of processed ids
*/
public function bulkRemoveFromSegment(SegmentEntity $segment, array $ids): int {
if (empty($ids)) {
return 0;
}
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$count = (int)$this->entityManager->getConnection()->executeStatement("
DELETE ss FROM $subscriberSegmentsTable ss
WHERE ss.`subscriber_id` IN (:ids)
AND ss.`segment_id` = :segment_id
", ['ids' => $ids, 'segment_id' => $segment->getId()], ['ids' => ArrayParameterType::INTEGER]);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
/**
* @return int - number of processed ids
*/
public function bulkRemoveFromAllSegments(array $ids): int {
$count = $this->removeSubscribersFromAllSegments($ids);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
/**
* @return int - number of processed ids
*/
public function bulkAddToSegment(SegmentEntity $segment, array $ids): int {
$count = $this->addSubscribersToSegment($segment, $ids);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
public function woocommerceUserExists(): bool {
$subscribers = $this->entityManager
->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->join('s.subscriberSegments', 'ss')
->join('ss.segment', 'segment')
->where('segment.type = :segmentType')
->setParameter('segmentType', SegmentEntity::TYPE_WC_USERS)
->andWhere('s.isWoocommerceUser = true')
->getQuery()
->setMaxResults(1)
->execute();
return count($subscribers) > 0;
}
/**
* @return int - number of processed ids
*/
public function bulkMoveToSegment(SegmentEntity $segment, array $ids): int {
if (empty($ids)) {
return 0;
}
$this->removeSubscribersFromAllSegments($ids);
$count = $this->addSubscribersToSegment($segment, $ids);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
public function bulkUnsubscribe(array $ids): int {
$this->entityManager->createQueryBuilder()
->update(SubscriberEntity::class, 's')
->set('s.status', ':status')
->where('s.id IN (:ids)')
->setParameter('status', SubscriberEntity::STATUS_UNSUBSCRIBED)
->setParameter('ids', $ids)
->getQuery()->execute();
$this->changesNotifier->subscribersUpdated($ids);
$this->invalidateTotalSubscribersCache();
return count($ids);
}
public function bulkUpdateLastSendingAt(array $ids, DateTimeInterface $dateTime): int {
if (empty($ids)) {
return 0;
}
$this->entityManager->createQueryBuilder()
->update(SubscriberEntity::class, 's')
->set('s.lastSendingAt', ':lastSendingAt')
->where('s.id IN (:ids)')
->setParameter('lastSendingAt', $dateTime)
->setParameter('ids', $ids)
->getQuery()
->execute();
return count($ids);
}
public function bulkUpdateEngagementScoreUpdatedAt(array $ids, ?DateTimeInterface $dateTime): void {
if (empty($ids)) {
return;
}
$this->entityManager->createQueryBuilder()
->update(SubscriberEntity::class, 's')
->set('s.engagementScoreUpdatedAt', ':dateTime')
->where('s.id IN (:ids)')
->setParameter('dateTime', $dateTime)
->setParameter('ids', $ids)
->getQuery()
->execute();
}
public function findWpUserIdAndEmailByEmails(array $emails): array {
return $this->entityManager->createQueryBuilder()
->select('s.wpUserId AS wp_user_id, LOWER(s.email) AS email')
->from(SubscriberEntity::class, 's')
->where('s.email IN (:emails)')
->setParameter('emails', $emails)
->getQuery()->getResult();
}
public function findIdAndEmailByEmails(array $emails): array {
return $this->entityManager->createQueryBuilder()
->select('s.id, s.email')
->from(SubscriberEntity::class, 's')
->where('s.email IN (:emails)')
->setParameter('emails', $emails)
->getQuery()->getResult();
}
/**
* @return int[]
*/
public function findIdsOfDeletedByEmails(array $emails): array {
return $this->entityManager->createQueryBuilder()
->select('s.id')
->from(SubscriberEntity::class, 's')
->where('s.email IN (:emails)')
->andWhere('s.deletedAt IS NOT NULL')
->setParameter('emails', $emails)
->getQuery()->getResult();
}
public function getCurrentWPUser(): ?SubscriberEntity {
$wpUser = WPFunctions::get()->wpGetCurrentUser();
if (empty($wpUser->ID)) {
return null; // Don't look up a subscriber for guests
}
return $this->findOneBy(['wpUserId' => $wpUser->ID]);
}
public function findByUpdatedScoreNotInLastMonth(int $limit): array {
$dateTime = (new Carbon())->subMonths(1);
return $this->entityManager->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->where('s.engagementScoreUpdatedAt IS NULL')
->orWhere('s.engagementScoreUpdatedAt < :dateTime')
->setParameter('dateTime', $dateTime)
->getQuery()
->setMaxResults($limit)
->getResult();
}
public function maybeUpdateLastEngagement(SubscriberEntity $subscriberEntity): void {
$now = $this->getCurrentDateTime();
// Do not update engagement if was recently updated to avoid unnecessary updates in DB
if ($subscriberEntity->getLastEngagementAt() && $subscriberEntity->getLastEngagementAt() > $now->subMinute()) {
return;
}
// Update last engagement
$subscriberEntity->setLastEngagementAt($now);
$this->flush();
}
public function maybeUpdateLastOpenAt(SubscriberEntity $subscriberEntity): void {
$now = $this->getCurrentDateTime();
// Avoid unnecessary DB calls
if ($subscriberEntity->getLastOpenAt() && $subscriberEntity->getLastOpenAt() > $now->subMinute()) {
return;
}
$subscriberEntity->setLastOpenAt($now);
$subscriberEntity->setLastEngagementAt($now);
$this->flush();
}
public function maybeUpdateLastClickAt(SubscriberEntity $subscriberEntity): void {
$now = $this->getCurrentDateTime();
// Avoid unnecessary DB calls
if ($subscriberEntity->getLastClickAt() && $subscriberEntity->getLastClickAt() > $now->subMinute()) {
return;
}
$subscriberEntity->setLastClickAt($now);
$subscriberEntity->setLastEngagementAt($now);
$this->flush();
}
public function maybeUpdateLastPurchaseAt(SubscriberEntity $subscriberEntity): void {
$now = $this->getCurrentDateTime();
// Avoid unnecessary DB calls
if ($subscriberEntity->getLastPurchaseAt() && $subscriberEntity->getLastPurchaseAt() > $now->subMinute()) {
return;
}
$subscriberEntity->setLastPurchaseAt($now);
$subscriberEntity->setLastEngagementAt($now);
$this->flush();
}
public function maybeUpdateLastPageViewAt(SubscriberEntity $subscriberEntity): void {
$now = $this->getCurrentDateTime();
// Avoid unnecessary DB calls
if ($subscriberEntity->getLastPageViewAt() && $subscriberEntity->getLastPageViewAt() > $now->subMinute()) {
return;
}
$subscriberEntity->setLastPageViewAt($now);
$subscriberEntity->setLastEngagementAt($now);
$this->flush();
}
/**
* @param array $ids
* @return string[]
*/
public function getUndeletedSubscribersEmailsByIds(array $ids): array {
return $this->entityManager->createQueryBuilder()
->select('s.email')
->from(SubscriberEntity::class, 's')
->where('s.deletedAt IS NULL')
->andWhere('s.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getArrayResult();
}
public function getMaxSubscriberId(): int {
$maxSubscriberId = $this->entityManager->createQueryBuilder()
->select('MAX(s.id)')
->from(SubscriberEntity::class, 's')
->getQuery()
->getSingleScalarResult();
return intval($maxSubscriberId);
}
/**
* Returns count of subscribers who subscribed after given date regardless of their current status.
* @return int
*/
public function getCountOfLastSubscribedAfter(\DateTimeInterface $subscribedAfter): int {
$result = $this->entityManager->createQueryBuilder()
->select('COUNT(s.id)')
->from(SubscriberEntity::class, 's')
->where('s.lastSubscribedAt > :lastSubscribedAt')
->andWhere('s.deletedAt IS NULL')
->setParameter('lastSubscribedAt', $subscribedAfter)
->getQuery()
->getSingleScalarResult();
return intval($result);
}
/**
* Returns count of subscribers who unsubscribed after given date regardless of their current status.
* @return int
*/
public function getCountOfUnsubscribedAfter(\DateTimeInterface $unsubscribedAfter): int {
$result = $this->entityManager->createQueryBuilder()
->select('COUNT(DISTINCT s.id)')
->from(StatisticsUnsubscribeEntity::class, 'su')
->join('su.subscriber', 's')
->andWhere('su.createdAt > :unsubscribedAfter')
->andWhere('s.deletedAt IS NULL')
->setParameter('unsubscribedAfter', $unsubscribedAfter)
->getQuery()
->getSingleScalarResult();
return intval($result);
}
/**
* Returns count of subscribers who subscribed to a list after given date regardless of their current global status.
*/
public function getListLevelCountsOfSubscribedAfter(\DateTimeInterface $date): array {
$data = $this->entityManager->createQueryBuilder()
->select('seg.id, seg.name, seg.type, seg.averageEngagementScore, COUNT(ss.id) as count')
->from(SubscriberSegmentEntity::class, 'ss')
->join('ss.subscriber', 's')
->join('ss.segment', 'seg')
->where('ss.updatedAt > :date')
->andWhere('ss.status = :segment_status')
->andWhere('s.lastSubscribedAt > :date') // subscriber subscribed at some point after the date
->andWhere('s.deletedAt IS NULL')
->andWhere('seg.deletedAt IS NULL') // no trashed lists and disabled WP Users list
->setParameter('date', $date)
->setParameter('segment_status', SubscriberEntity::STATUS_SUBSCRIBED)
->groupBy('ss.segment')
->getQuery()
->getArrayResult();
return $data;
}
/**
* Returns count of subscribers who unsubscribed from a list after given date regardless of their current global status.
*/
public function getListLevelCountsOfUnsubscribedAfter(\DateTimeInterface $date): array {
return $this->entityManager->createQueryBuilder()
->select('seg.id, seg.name, seg.type, seg.averageEngagementScore, COUNT(ss.id) as count')
->from(SubscriberSegmentEntity::class, 'ss')
->join('ss.subscriber', 's')
->join('ss.segment', 'seg')
->where('ss.updatedAt > :date')
->andWhere('ss.status = :segment_status')
->andWhere('s.deletedAt IS NULL')
->andWhere('seg.deletedAt IS NULL') // no trashed lists and disabled WP Users list
->setParameter('date', $date)
->setParameter('segment_status', SubscriberEntity::STATUS_UNSUBSCRIBED)
->groupBy('ss.segment')
->getQuery()
->getArrayResult();
}
/**
* @return int - number of processed ids
*/
public function bulkAddTag(TagEntity $tag, array $ids): int {
$count = $this->addTagToSubscribers($tag, $ids);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
/**
* @return int - number of processed ids
*/
public function bulkRemoveTag(TagEntity $tag, array $ids): int {
if (empty($ids)) {
return 0;
}
$subscriberTagsTable = $this->entityManager->getClassMetadata(SubscriberTagEntity::class)->getTableName();
$count = (int)$this->entityManager->getConnection()->executeStatement("
DELETE st FROM $subscriberTagsTable st
WHERE st.`subscriber_id` IN (:ids)
AND st.`tag_id` = :tag_id
", ['ids' => $ids, 'tag_id' => $tag->getId()], ['ids' => ArrayParameterType::INTEGER]);
$this->changesNotifier->subscribersUpdated($ids);
return $count;
}
public function removeOrphanedSubscribersFromWpSegment(): void {
global $wpdb;
$segmentId = $this->segmentsRepository->getWpUsersSegment()->getId();
$subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$this->entityManager->getConnection()->executeStatement(
"DELETE s
FROM {$subscribersTable} s
INNER JOIN {$subscriberSegmentsTable} ss ON s.id = ss.subscriber_id
LEFT JOIN {$wpdb->users} u ON s.wp_user_id = u.id
WHERE ss.segment_id = :segmentId AND (u.id IS NULL OR s.email = '')",
['segmentId' => $segmentId],
['segmentId' => ParameterType::INTEGER]
);
}
public function removeByWpUserIds(array $wpUserIds) {
$queryBuilder = $this->entityManager->createQueryBuilder();
$queryBuilder
->delete(SubscriberEntity::class, 's')
->where('s.wpUserId IN (:wpUserIds)')
->setParameter('wpUserIds', $wpUserIds);
return $queryBuilder->getQuery()->execute();
}
/**
* @return int - number of processed ids
*/
private function removeSubscribersFromAllSegments(array $ids): int {
if (empty($ids)) {
return 0;
}
$subscriberSegmentsTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
$segmentsTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
$count = (int)$this->entityManager->getConnection()->executeStatement("
DELETE ss FROM $subscriberSegmentsTable ss
JOIN $segmentsTable s ON s.id = ss.segment_id AND s.`type` = :typeDefault
WHERE ss.`subscriber_id` IN (:ids)
", [
'ids' => $ids,
'typeDefault' => SegmentEntity::TYPE_DEFAULT,
], ['ids' => ArrayParameterType::INTEGER]);
return $count;
}
/**
* @return int - number of processed ids
*/
private function addSubscribersToSegment(SegmentEntity $segment, array $ids): int {
if (empty($ids)) {
return 0;
}
$subscribers = $this->entityManager
->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->leftJoin('s.subscriberSegments', 'ss', Join::WITH, 'ss.segment = :segment')
->where('s.id IN (:ids)')
->andWhere('ss.segment IS NULL')
->setParameter('ids', $ids)
->setParameter('segment', $segment)
->getQuery()->execute();
$this->entityManager->transactional(function (EntityManager $entityManager) use ($subscribers, $segment) {
foreach ($subscribers as $subscriber) {
$subscriberSegment = new SubscriberSegmentEntity($segment, $subscriber, SubscriberEntity::STATUS_SUBSCRIBED);
$this->entityManager->persist($subscriberSegment);
}
$this->entityManager->flush();
});
return count($subscribers);
}
/**
* @return int - number of processed ids
*/
private function addTagToSubscribers(TagEntity $tag, array $ids): int {
if (empty($ids)) {
return 0;
}
/** @var SubscriberEntity[] $subscribers */
$subscribers = $this->entityManager
->createQueryBuilder()
->select('s')
->from(SubscriberEntity::class, 's')
->leftJoin('s.subscriberTags', 'st', Join::WITH, 'st.tag = :tag')
->where('s.id IN (:ids)')
->andWhere('st.tag IS NULL')
->setParameter('ids', $ids)
->setParameter('tag', $tag)
->getQuery()->execute();
$this->entityManager->wrapInTransaction(function (EntityManager $entityManager) use ($subscribers, $tag) {
foreach ($subscribers as $subscriber) {
$subscriberTag = new SubscriberTagEntity($tag, $subscriber);
$entityManager->persist($subscriberTag);
}
$entityManager->flush();
});
return count($subscribers);
}
private function getCurrentDateTime(): Carbon {
return Carbon::now()->setMilliseconds(0);
}
}
@@ -0,0 +1 @@
<?php