PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.35.0
MailPoet – Newsletters, Email Marketing, and Automation v5.35.0
5.35.0 5.34.3 5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / lib / Segments / WP.php
mailpoet / lib / Segments Last commit date
DynamicSegments 1 week ago RestApi 1 month ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 1 month ago SegmentSaveController.php 1 month ago SegmentSubscribersRepository.php 1 week ago SegmentsFinder.php 3 years ago SegmentsRepository.php 1 month ago SegmentsSimpleListRepository.php 3 months ago SubscribersFinder.php 1 month ago WP.php 6 days ago WPUserDeleteNotice.php 6 days ago WooCommerce.php 1 month ago index.php 3 years ago
WP.php
612 lines
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\Segments;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Config\SubscriberChangesNotifier;
9 use MailPoet\DI\ContainerWrapper;
10 use MailPoet\Doctrine\WPDB\Connection;
11 use MailPoet\Entities\SegmentEntity;
12 use MailPoet\Entities\SubscriberEntity;
13 use MailPoet\Entities\SubscriberSegmentEntity;
14 use MailPoet\Logging\LoggerFactory;
15 use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
16 use MailPoet\Services\Validator;
17 use MailPoet\Settings\SettingsController;
18 use MailPoet\Subscribers\ConfirmationEmailMailer;
19 use MailPoet\Subscribers\SegmentsCountRecalculator;
20 use MailPoet\Subscribers\Source;
21 use MailPoet\Subscribers\SubscriberSegmentRepository;
22 use MailPoet\Subscribers\SubscribersRepository;
23 use MailPoet\Util\DBCollationChecker;
24 use MailPoet\WooCommerce\Helper as WooCommerceHelper;
25 use MailPoet\WP\Functions as WPFunctions;
26 use MailPoetVendor\Carbon\Carbon;
27 use MailPoetVendor\Doctrine\ORM\EntityManager;
28
29 class WP {
30
31 /** @var WPFunctions */
32 private $wp;
33
34 /** @var WelcomeScheduler */
35 private $welcomeScheduler;
36
37 /** @var WooCommerceHelper */
38 private $wooHelper;
39
40 /** @var SubscribersRepository */
41 private $subscribersRepository;
42
43 /** @var SubscriberChangesNotifier */
44 private $subscriberChangesNotifier;
45
46 /** @var SubscriberSegmentRepository */
47 private $subscriberSegmentRepository;
48
49 /** @var Validator */
50 private $validator;
51
52 /** @var SegmentsRepository */
53 private $segmentsRepository;
54
55 /** @var EntityManager */
56 private $entityManager;
57
58 /** @var DBCollationChecker */
59 private $collationChecker;
60
61 /** @var string */
62 private $subscribersTable;
63
64 /** @var \MailPoetVendor\Doctrine\DBAL\Connection */
65 private $databaseConnection;
66
67 /** @var SegmentsCountRecalculator */
68 private $segmentsCountRecalculator;
69
70 public function __construct(
71 WPFunctions $wp,
72 WelcomeScheduler $welcomeScheduler,
73 WooCommerceHelper $wooHelper,
74 SubscribersRepository $subscribersRepository,
75 SubscriberSegmentRepository $subscriberSegmentRepository,
76 SubscriberChangesNotifier $subscriberChangesNotifier,
77 Validator $validator,
78 SegmentsRepository $segmentsRepository,
79 EntityManager $entityManager,
80 DBCollationChecker $collationChecker,
81 SegmentsCountRecalculator $segmentsCountRecalculator
82 ) {
83 $this->wp = $wp;
84 $this->welcomeScheduler = $welcomeScheduler;
85 $this->wooHelper = $wooHelper;
86 $this->subscribersRepository = $subscribersRepository;
87 $this->subscriberSegmentRepository = $subscriberSegmentRepository;
88 $this->subscriberChangesNotifier = $subscriberChangesNotifier;
89 $this->validator = $validator;
90 $this->segmentsRepository = $segmentsRepository;
91 $this->entityManager = $entityManager;
92 $this->collationChecker = $collationChecker;
93 $this->segmentsCountRecalculator = $segmentsCountRecalculator;
94 $this->databaseConnection = $this->entityManager->getConnection();
95 $this->subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
96 }
97
98 /**
99 * @param int $wpUserId
100 * @param array|false $oldWpUserData
101 */
102 public function synchronizeUser(int $wpUserId, $oldWpUserData = false): void {
103 $wpUser = \get_userdata($wpUserId);
104 if ($wpUser === false) return;
105
106 $subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
107
108 $currentFilter = $this->wp->currentFilter();
109 // Delete
110 if (in_array($currentFilter, ['delete_user', 'deleted_user', 'remove_user_from_blog'])) {
111 if ($subscriber instanceof SubscriberEntity) {
112 $this->unlinkSubscriberFromWpUser($subscriber, $wpUser);
113 }
114 return;
115 }
116 $this->handleCreatingOrUpdatingSubscriber($currentFilter, $wpUser, $subscriber, $oldWpUserData);
117
118 // In WP::synchronizeUser, after the subscriber is created
119 $this->wp->doAction('mailpoet_user_registered', $wpUserId, $subscriber);
120 }
121
122 private function deleteSubscriber(SubscriberEntity $subscriber): void {
123 $this->entityManager->wrapInTransaction(function() use ($subscriber): void {
124 $this->subscriberSegmentRepository->deleteAllBySubscriber($subscriber);
125 $this->subscribersRepository->remove($subscriber);
126 $this->subscribersRepository->flush();
127 });
128 }
129
130 private function unlinkSubscriberFromWpUser(SubscriberEntity $subscriber, \WP_User $wpUser): void {
131 // Backwards-compat escape hatch for sites that need the legacy hard-delete behavior
132 // (e.g. GDPR-driven account deletion flows). Returning true reproduces pre-STOMAIL-8018 behavior.
133 $hardDelete = (bool)$this->wp->applyFilters(
134 'mailpoet_delete_subscriber_on_wp_user_delete',
135 false,
136 $subscriber,
137 (int)$wpUser->ID // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
138 );
139 if ($hardDelete) {
140 $this->deleteSubscriber($subscriber);
141 return;
142 }
143
144 $this->entityManager->wrapInTransaction(function() use ($subscriber, $wpUser): void {
145 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
146
147 // Remove only the WP-Users segment membership; other list subscriptions stay intact.
148 $this->entityManager->createQueryBuilder()
149 ->delete(SubscriberSegmentEntity::class, 'ss')
150 ->where('ss.subscriber = :subscriber AND ss.segment = :segment')
151 ->setParameter('subscriber', $subscriber)
152 ->setParameter('segment', $wpSegment)
153 ->getQuery()
154 ->execute();
155
156 $subscriber->setWpUserId(null);
157 $subscriber->setSource(Source::WORDPRESS_USER_DELETED);
158
159 // If the subscriber was only on the WP-Users list and is not a WC customer,
160 // they had no list of their own to remain on — trash them instead of leaving a floating row.
161 // Skip when the subscriber was already trashed (e.g. manually by an admin) so we keep
162 // the original deleted_at and status as audit information.
163 $hasOtherActiveSegments = $this->hasOtherActiveSegments($subscriber);
164 $isWooCustomer = $this->wooHelper->isWooCommerceActive() && in_array('customer', $wpUser->roles, true); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
165 if (!$hasOtherActiveSegments && !$isWooCustomer && $subscriber->getDeletedAt() === null) {
166 $subscriber->setStatus(SubscriberEntity::STATUS_UNCONFIRMED);
167 $subscriber->setDeletedAt(Carbon::now()->millisecond(0));
168 }
169
170 $this->subscribersRepository->persist($subscriber);
171 $this->subscribersRepository->flush();
172 });
173 $this->segmentsCountRecalculator->recalculateForSubscribers([(int)$subscriber->getId()]);
174 }
175
176 private function hasOtherActiveSegments(SubscriberEntity $subscriber): bool {
177 $subscriberId = $subscriber->getId();
178 if ($subscriberId === null) {
179 return false;
180 }
181
182 $count = $this->entityManager->createQueryBuilder()
183 ->select('COUNT(segment.id)')
184 ->from(SubscriberSegmentEntity::class, 'subscriberSegment')
185 ->innerJoin('subscriberSegment.segment', 'segment')
186 ->where('subscriberSegment.subscriber = :subscriber')
187 ->andWhere('segment.type != :wpType')
188 ->andWhere('segment.deletedAt IS NULL')
189 ->setParameter('subscriber', $subscriber)
190 ->setParameter('wpType', SegmentEntity::TYPE_WP_USERS)
191 ->getQuery()
192 ->getSingleScalarResult();
193
194 return is_numeric($count) && (int)$count > 0;
195 }
196
197 /**
198 * @param string $currentFilter
199 * @param \WP_User $wpUser
200 * @param ?SubscriberEntity $subscriber
201 * @param array|false $oldWpUserData
202 */
203 private function handleCreatingOrUpdatingSubscriber(string $currentFilter, \WP_User $wpUser, ?SubscriberEntity $subscriber = null, $oldWpUserData = false): void {
204 // Add or update
205 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
206
207 // find subscriber by email when is null
208 if (is_null($subscriber)) {
209 $subscriber = $this->subscribersRepository->findOneBy(['email' => $wpUser->user_email]); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
210 }
211
212 // get first name & last name
213 $firstName = $this->decodeUserName($wpUser->first_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
214 $lastName = $this->decodeUserName($wpUser->last_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
215 if (empty($wpUser->first_name) && empty($wpUser->last_name)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
216 $firstName = $this->decodeUserName($wpUser->display_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
217 }
218 $signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
219 $status = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
220 // we want to mark a new subscriber as unsubscribe when the checkbox from registration is unchecked
221 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- type narrowing only, value is read as bool
222 $mailpoetPost = isset($_POST['mailpoet']) && is_array($_POST['mailpoet']) ? $_POST['mailpoet'] : [];
223 if (isset($mailpoetPost['subscribe_on_register_active']) && (bool)$mailpoetPost['subscribe_on_register_active'] === true) {
224 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
225 }
226
227 // subscriber data
228 $wpUserId = (int)$wpUser->ID; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
229 $data = [
230 'wp_user_id' => $wpUserId,
231 'email' => $wpUser->user_email, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
232 'first_name' => $firstName,
233 'last_name' => $lastName,
234 'status' => $status,
235 'source' => Source::WORDPRESS_USER,
236 ];
237
238 $isRelinkingDeletedWpUser = $subscriber !== null
239 && $subscriber->getSource() === Source::WORDPRESS_USER_DELETED
240 && $subscriber->getWpUserId() !== $wpUserId;
241 if (!is_null($subscriber)) {
242 $data['id'] = $subscriber->getId();
243 unset($data['status']); // don't override status for existing users
244 unset($data['source']); // don't override source for existing users
245 if ($isRelinkingDeletedWpUser) {
246 // Restore the live WP-user source on any re-link, not only the auto-trashed case.
247 // Only revive deleted_at when the subscriber was actually trashed by the unlink
248 // (subscribers kept on other lists were never trashed and must keep deleted_at IS NULL).
249 $data['source'] = Source::WORDPRESS_USER;
250 if ($subscriber->getDeletedAt() !== null) {
251 $data['deleted_at'] = null;
252 }
253 }
254 }
255
256 $addingNewUserToDisabledWPSegment = $wpSegment->getDeletedAt() !== null && $currentFilter === 'user_register';
257
258 $otherActiveSegments = [];
259 if ($subscriber) {
260 $otherActiveSegments = array_filter($subscriber->getSegments()->toArray() ?? [], function (SegmentEntity $segment) {
261 return $segment->getType() !== SegmentEntity::TYPE_WP_USERS && $segment->getDeletedAt() === null;
262 });
263 }
264 $isWooCustomer = $this->wooHelper->isWooCommerceActive() && in_array('customer', $wpUser->roles, true);
265 // When WP Segment is disabled force trashed state and unconfirmed status for new WPUsers without active segment
266 // or who are not WooCommerce customers at the same time since customers are added to the WooCommerce list
267 if ($addingNewUserToDisabledWPSegment && !$isRelinkingDeletedWpUser && !$otherActiveSegments && !$isWooCustomer) {
268 $data['deleted_at'] = Carbon::now()->millisecond(0);
269 $data['status'] = SubscriberEntity::STATUS_UNCONFIRMED;
270 }
271
272 // Apply filter to allow modifying subscriber data before save
273 $data = $this->wp->applyFilters('mailpoet_subscriber_data_before_save', $data);
274
275 // Ensure data is an array
276 if (!is_array($data)) {
277 // If the filter returned a non-array, log it and use the original data
278 $logger = LoggerFactory::getInstance()->getLogger();
279 $logger->error(
280 'Filter mailpoet_subscriber_data_before_save returned non-array data.',
281 ['data_type' => gettype($data)]
282 );
283 return;
284 }
285
286 // When updating an existing subscriber's email, remove any other subscriber
287 // that already holds the new email to avoid a unique constraint violation.
288 // This can happen when a WP user registers with email A, checks out with
289 // email B (creating a second subscriber), then changes their account email
290 // from A to B.
291 // Uses bulkDelete() to properly clean up related data (segments, tags,
292 // custom fields) and to restrict deletion to safe duplicates (non-WP,
293 // non-WooCommerce subscribers).
294 if ($subscriber !== null && $subscriber->getEmail() !== $data['email']) {
295 $existingSubscriber = $this->subscribersRepository->findOneBy(['email' => $data['email']]);
296 if ($existingSubscriber !== null && $existingSubscriber->getId() !== $subscriber->getId()) {
297 $duplicateId = $existingSubscriber->getId();
298 $this->entityManager->detach($existingSubscriber);
299 $deletedCount = $this->subscribersRepository->bulkDelete([$duplicateId]);
300 if ($deletedCount === 0) {
301 // The duplicate is a WP user or WooCommerce customer and cannot be
302 // safely removed. Skip the email update to avoid a constraint violation.
303 $logger = LoggerFactory::getInstance()->getLogger();
304 $logger->warning(
305 'Cannot update subscriber email: duplicate subscriber is a WP user or WooCommerce customer',
306 ['subscriber_id' => $subscriber->getId(), 'duplicate_id' => $duplicateId, 'email' => $data['email']]
307 );
308 return;
309 }
310 }
311 }
312
313 try {
314 $subscriber = $this->createOrUpdateSubscriber($data, $subscriber);
315 } catch (\Exception $e) {
316 return; // fails silently as this was the behavior before the Doctrine refactor.
317 }
318
319 // add subscriber to the WP Users segment
320 $this->subscriberSegmentRepository->subscribeToSegments(
321 $subscriber,
322 [$wpSegment]
323 );
324
325 if (!$signupConfirmationEnabled && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED && $currentFilter === 'user_register') {
326 $subscriberSegment = $this->subscriberSegmentRepository->findOneBy([
327 'subscriber' => $subscriber->getId(),
328 'segment' => $wpSegment->getId(),
329 ]);
330
331 if (!is_null($subscriberSegment)) {
332 $this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
333 }
334 }
335
336 $subscribeOnRegisterEnabled = SettingsController::getInstance()->get('subscribe.on_register.enabled');
337 $sendConfirmationEmail =
338 $signupConfirmationEnabled
339 && $subscribeOnRegisterEnabled
340 && $currentFilter !== 'profile_update'
341 && !$addingNewUserToDisabledWPSegment;
342
343 if ($sendConfirmationEmail && ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED)) {
344 /** @var ConfirmationEmailMailer $confirmationEmailMailer */
345 $confirmationEmailMailer = ContainerWrapper::getInstance()->get(ConfirmationEmailMailer::class);
346 try {
347 // Per-list confirmation settings are not resolved here because this path
348 // subscribes to the WordPress Users segment (TYPE_WP_USERS),
349 // which does not support custom confirmation overrides.
350 $confirmationEmailMailer->sendConfirmationEmailOnce($subscriber);
351 } catch (\Exception $e) {
352 // ignore errors
353 }
354 }
355
356 // welcome email
357 $scheduleWelcomeNewsletter = false;
358 if (in_array($currentFilter, ['profile_update', 'user_register', 'add_user_role', 'set_user_role'])) {
359 $scheduleWelcomeNewsletter = true;
360 }
361 if ($scheduleWelcomeNewsletter === true) {
362 $this->welcomeScheduler->scheduleWPUserWelcomeNotification(
363 $subscriber->getId(),
364 (array)$wpUser,
365 (array)$oldWpUserData
366 );
367 }
368 }
369
370 /**
371 * WordPress stores user names entity-encoded (see the `pre_user_first_name`,
372 * `pre_user_last_name` and `pre_user_display_name` filters). We decode them so a
373 * name such as "Family & friends" is stored the way it was written, then run the
374 * same text sanitizer the other subscriber write paths use, so decoding can never
375 * turn encoded markup back into markup.
376 *
377 * @param mixed $name
378 */
379 private function decodeUserName($name): string {
380 $decoded = html_entity_decode(is_string($name) ? $name : '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401);
381
382 return $this->wp->sanitizeTextField($decoded);
383 }
384
385 private function createOrUpdateSubscriber(array $data, ?SubscriberEntity $subscriber = null): SubscriberEntity {
386 if (is_null($subscriber)) {
387 $subscriber = new SubscriberEntity();
388 }
389
390 $subscriber->setWpUserId($data['wp_user_id']);
391 $subscriber->setEmail($data['email']);
392
393 // Only set first_name if it's present in the data array
394 if (isset($data['first_name'])) {
395 $subscriber->setFirstName($data['first_name']);
396 }
397
398 // Only set last_name if it's present in the data array
399 if (isset($data['last_name'])) {
400 $subscriber->setLastName($data['last_name']);
401 }
402
403 if (isset($data['status'])) {
404 $subscriber->setStatus($data['status']);
405 }
406
407 if (isset($data['source'])) {
408 $subscriber->setSource($data['source']);
409 }
410
411 if (array_key_exists('deleted_at', $data)) {
412 $subscriber->setDeletedAt($data['deleted_at']);
413 }
414
415 $this->subscribersRepository->persist($subscriber);
416 $this->subscribersRepository->flush();
417
418 return $subscriber;
419 }
420
421 public function synchronizeUsers(): bool {
422 // Temporarily skip synchronization in WP Playground.
423 // Some of the queries are not yet supported by the SQLite integration.
424 if (Connection::isSQLite()) {
425 return true;
426 }
427
428 // Save timestamp about changes and update before insert
429 $this->subscriberChangesNotifier->subscribersBatchCreate();
430 $this->subscriberChangesNotifier->subscribersBatchUpdate();
431
432 $updatedUsersEmails = $this->updateSubscribersEmails();
433 $insertedUsersEmails = $this->insertSubscribers();
434 $this->removeUpdatedSubscribersWithInvalidEmail(array_merge($updatedUsersEmails, $insertedUsersEmails));
435 // There is high chance that an update will be made
436 $this->subscriberChangesNotifier->subscribersBatchUpdate();
437 unset($updatedUsersEmails);
438 unset($insertedUsersEmails);
439 $this->updateFirstNames();
440 $this->updateLastNames();
441 $this->updateFirstNameIfMissing();
442 $this->insertUsersToSegment();
443 $this->removeOrphanedSubscribers();
444 // insertUsersToSegment adds WP users to the WP-Users segment via raw SQL,
445 // so refresh segments_count for that segment's members.
446 // recalculateForSegment() only sees subscribers that still have a membership
447 // row. Orphans that are hard-deleted by removeOrphanedSubscribers() are fine
448 // (row gone, count moot). Orphans whose membership is deleted but who survive
449 // (soft-trashed or still on other lists) are recalculated explicitly inside
450 // removeOrphanedSubscribersFromWpSegment() before the membership DELETE.
451 $this->segmentsCountRecalculator->recalculateForSegment((int)$this->segmentsRepository->getWPUsersSegment()->getId());
452 $this->subscribersRepository->invalidateTotalSubscribersCache();
453 $this->subscribersRepository->refreshAll();
454
455 return true;
456 }
457
458 private function removeUpdatedSubscribersWithInvalidEmail(array $updatedEmails): void {
459 $invalidWpUserIds = array_map(function($item) {
460 return $item['id'];
461 },
462 array_filter($updatedEmails, function($updatedEmail) {
463 return !$this->validator->validateEmail($updatedEmail['email']) && $updatedEmail['id'] !== null;
464 }));
465 if (!$invalidWpUserIds) {
466 return;
467 }
468
469 $this->subscribersRepository->removeByWpUserIds($invalidWpUserIds);
470 }
471
472 private function updateSubscribersEmails(): array {
473 global $wpdb;
474
475 $stmt = $this->databaseConnection->executeQuery('SELECT NOW();');
476 $startTime = $stmt->fetchOne();
477
478 if (!is_string($startTime)) {
479 throw new \RuntimeException("Failed to fetch the current time.");
480 }
481
482 $updateSql =
483 "UPDATE IGNORE {$this->subscribersTable} s
484 INNER JOIN {$wpdb->users} as wu ON s.wp_user_id = wu.id
485 SET s.email = wu.user_email";
486 $this->databaseConnection->executeStatement($updateSql);
487
488 $selectSql =
489 "SELECT wp_user_id as id, email FROM {$this->subscribersTable}
490 WHERE updated_at >= '{$startTime}'";
491 $updatedEmails = $this->databaseConnection->fetchAllAssociative($selectSql);
492
493 return $updatedEmails;
494 }
495
496 private function insertSubscribers(): array {
497 global $wpdb;
498 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
499
500 if ($wpSegment->getDeletedAt() !== null) {
501 $subscriberStatus = SubscriberEntity::STATUS_UNCONFIRMED;
502 $deletedAt = 'CURRENT_TIMESTAMP()';
503 } else {
504 $signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
505 $subscriberStatus = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
506 $deletedAt = 'null';
507 }
508
509 // Fetch users that are not in the subscribers table
510 $selectSql =
511 "SELECT u.id, u.user_email as email
512 FROM {$wpdb->users} u
513 LEFT JOIN {$this->subscribersTable} AS s ON s.wp_user_id = u.id
514 WHERE s.wp_user_id IS NULL AND u.user_email != ''";
515 $insertedUserIds = $this->databaseConnection->fetchAllAssociative($selectSql);
516
517 // Insert new users into the subscribers table.
518 // The email-based join can cross a collation boundary when wp_users.user_email and
519 // mailpoet_subscribers.email use different (but compatible) collations — force a
520 // matching collation to avoid "Illegal mix of collations" errors.
521 $emailCollation = $this->collationChecker->getCollateIfNeeded(
522 $wpdb->users,
523 'user_email',
524 $this->subscribersTable,
525 'email'
526 );
527 $insertSql =
528 "INSERT IGNORE INTO {$this->subscribersTable} (wp_user_id, email, status, created_at, `source`, deleted_at)
529 SELECT wu.id, wu.user_email, :subscriberStatus, CURRENT_TIMESTAMP(), :source, {$deletedAt}
530 FROM {$wpdb->users} wu
531 LEFT JOIN {$this->subscribersTable} s ON wu.id = s.wp_user_id
532 LEFT JOIN {$this->subscribersTable} existingSubscriber ON wu.user_email = existingSubscriber.email {$emailCollation}
533 WHERE s.wp_user_id IS NULL AND wu.user_email != ''
534 ON DUPLICATE KEY UPDATE
535 wp_user_id = wu.id,
536 deleted_at = IF(
537 existingSubscriber.`source` = :deletedSource AND existingSubscriber.deleted_at IS NOT NULL,
538 NULL,
539 existingSubscriber.deleted_at
540 ),
541 `source` = IF(
542 existingSubscriber.`source` = :deletedSource,
543 :source,
544 existingSubscriber.`source`
545 )";
546 $stmt = $this->databaseConnection->prepare($insertSql);
547 $stmt->bindValue('subscriberStatus', $subscriberStatus);
548 $stmt->bindValue('source', Source::WORDPRESS_USER);
549 $stmt->bindValue('deletedSource', Source::WORDPRESS_USER_DELETED);
550 $stmt->executeStatement();
551
552 return $insertedUserIds;
553 }
554
555 private function updateFirstNames(): void {
556 global $wpdb;
557
558 $sql =
559 "UPDATE {$this->subscribersTable} s
560 JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'first_name'
561 SET s.first_name = SUBSTRING(wpum.meta_value, 1, 255)
562 WHERE s.first_name = ''
563 AND s.wp_user_id IS NOT NULL
564 AND wpum.meta_value IS NOT NULL";
565
566 $this->databaseConnection->executeStatement($sql);
567 }
568
569 private function updateLastNames(): void {
570 global $wpdb;
571
572 $sql =
573 "UPDATE {$this->subscribersTable} s
574 JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'last_name'
575 SET s.last_name = SUBSTRING(wpum.meta_value, 1, 255)
576 WHERE s.last_name = ''
577 AND s.wp_user_id IS NOT NULL
578 AND wpum.meta_value IS NOT NULL";
579
580 $this->databaseConnection->executeStatement($sql);
581 }
582
583 private function updateFirstNameIfMissing(): void {
584 global $wpdb;
585
586 $sql =
587 "UPDATE {$this->subscribersTable} s
588 JOIN {$wpdb->users} wu ON s.wp_user_id = wu.id
589 SET s.first_name = wu.display_name
590 WHERE s.first_name = ''
591 AND s.wp_user_id IS NOT NULL";
592
593 $this->databaseConnection->executeStatement($sql);
594 }
595
596 private function insertUsersToSegment(): void {
597 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
598 $subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
599
600 $sql =
601 "INSERT IGNORE INTO {$subscribersSegmentTable} (subscriber_id, segment_id, created_at)
602 SELECT s.id, '{$wpSegment->getId()}', CURRENT_TIMESTAMP() FROM {$this->subscribersTable} s
603 WHERE s.wp_user_id > 0";
604
605 $this->databaseConnection->executeStatement($sql);
606 }
607
608 private function removeOrphanedSubscribers(): void {
609 $this->subscribersRepository->removeOrphanedSubscribersFromWpSegment();
610 }
611 }
612