PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / trunk
MailPoet – Newsletters, Email Marketing, and Automation vtrunk
5.37.0 5.36.1 5.36.0 5.35.1 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 14 hours ago RestApi 2 months ago SegmentDependencyValidator.php 3 years ago SegmentListingRepository.php 2 months ago SegmentSaveController.php 1 month ago SegmentSubscribersRepository.php 1 month ago SegmentsFinder.php 3 years ago SegmentsRepository.php 1 month ago SegmentsSimpleListRepository.php 3 months ago SubscribersFinder.php 2 months ago WP.php 14 hours ago WPUserDeleteNotice.php 4 weeks ago WooCommerce.php 14 hours ago index.php 3 years ago
WP.php
641 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 /**
41 * Per-request, keyed by WP user id: whether this sync created the subscriber
42 * row or found one already there. See wasSubscriberCreatedBySync().
43 *
44 * @var array<int, bool>
45 */
46 private $syncCreatedSubscriber = [];
47
48 /** @var SubscribersRepository */
49 private $subscribersRepository;
50
51 /** @var SubscriberChangesNotifier */
52 private $subscriberChangesNotifier;
53
54 /** @var SubscriberSegmentRepository */
55 private $subscriberSegmentRepository;
56
57 /** @var Validator */
58 private $validator;
59
60 /** @var SegmentsRepository */
61 private $segmentsRepository;
62
63 /** @var EntityManager */
64 private $entityManager;
65
66 /** @var DBCollationChecker */
67 private $collationChecker;
68
69 /** @var string */
70 private $subscribersTable;
71
72 /** @var \MailPoetVendor\Doctrine\DBAL\Connection */
73 private $databaseConnection;
74
75 /** @var SegmentsCountRecalculator */
76 private $segmentsCountRecalculator;
77
78 public function __construct(
79 WPFunctions $wp,
80 WelcomeScheduler $welcomeScheduler,
81 WooCommerceHelper $wooHelper,
82 SubscribersRepository $subscribersRepository,
83 SubscriberSegmentRepository $subscriberSegmentRepository,
84 SubscriberChangesNotifier $subscriberChangesNotifier,
85 Validator $validator,
86 SegmentsRepository $segmentsRepository,
87 EntityManager $entityManager,
88 DBCollationChecker $collationChecker,
89 SegmentsCountRecalculator $segmentsCountRecalculator
90 ) {
91 $this->wp = $wp;
92 $this->welcomeScheduler = $welcomeScheduler;
93 $this->wooHelper = $wooHelper;
94 $this->subscribersRepository = $subscribersRepository;
95 $this->subscriberSegmentRepository = $subscriberSegmentRepository;
96 $this->subscriberChangesNotifier = $subscriberChangesNotifier;
97 $this->validator = $validator;
98 $this->segmentsRepository = $segmentsRepository;
99 $this->entityManager = $entityManager;
100 $this->collationChecker = $collationChecker;
101 $this->segmentsCountRecalculator = $segmentsCountRecalculator;
102 $this->databaseConnection = $this->entityManager->getConnection();
103 $this->subscribersTable = $this->entityManager->getClassMetadata(SubscriberEntity::class)->getTableName();
104 }
105
106 /**
107 * @param int $wpUserId
108 * @param array|false $oldWpUserData
109 */
110
111 /**
112 * Whether this request's sync created the subscriber row for the given WP
113 * user, rather than finding one that was already there. Defaults to false,
114 * meaning "treat as pre-existing", for a user this sync never saw: a missing
115 * signal must never cause an unearned overwrite of an earlier consent choice.
116 *
117 * Needed because wp_insert_user() calls set_user_role() before it fires
118 * user_register, and set_user_role is itself hooked to synchronizeUser. So by
119 * the time anything runs on user_register — at any priority — the row already
120 * exists, and a later lookup cannot tell a brand new registrant from someone
121 * who was already on the list.
122 */
123 public function wasSubscriberCreatedBySync(int $wpUserId): bool {
124 return $this->syncCreatedSubscriber[$wpUserId] ?? false;
125 }
126
127 public function synchronizeUser(int $wpUserId, $oldWpUserData = false): void {
128 $wpUser = $this->wp->getUserdata($wpUserId);
129 if ($wpUser === false) return;
130
131 $subscriber = $this->subscribersRepository->findOneBy(['wpUserId' => $wpUserId]);
132 if (!isset($this->syncCreatedSubscriber[$wpUserId])) {
133 $this->syncCreatedSubscriber[$wpUserId] = !$subscriber instanceof SubscriberEntity
134 && $this->subscribersRepository->findOneBy(['email' => $wpUser->user_email]) === null; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
135 }
136
137 $currentFilter = $this->wp->currentFilter();
138 // Delete
139 if (in_array($currentFilter, ['delete_user', 'deleted_user', 'remove_user_from_blog'])) {
140 if ($subscriber instanceof SubscriberEntity) {
141 $this->unlinkSubscriberFromWpUser($subscriber, $wpUser);
142 }
143 return;
144 }
145 $this->handleCreatingOrUpdatingSubscriber($currentFilter, $wpUser, $subscriber, $oldWpUserData);
146
147 // In WP::synchronizeUser, after the subscriber is created
148 $this->wp->doAction('mailpoet_user_registered', $wpUserId, $subscriber);
149 }
150
151 private function deleteSubscriber(SubscriberEntity $subscriber): void {
152 $this->entityManager->wrapInTransaction(function() use ($subscriber): void {
153 $this->subscriberSegmentRepository->deleteAllBySubscriber($subscriber);
154 $this->subscribersRepository->remove($subscriber);
155 $this->subscribersRepository->flush();
156 });
157 }
158
159 private function unlinkSubscriberFromWpUser(SubscriberEntity $subscriber, \WP_User $wpUser): void {
160 // Backwards-compat escape hatch for sites that need the legacy hard-delete behavior
161 // (e.g. GDPR-driven account deletion flows). Returning true reproduces pre-STOMAIL-8018 behavior.
162 $hardDelete = (bool)$this->wp->applyFilters(
163 'mailpoet_delete_subscriber_on_wp_user_delete',
164 false,
165 $subscriber,
166 (int)$wpUser->ID // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
167 );
168 if ($hardDelete) {
169 $this->deleteSubscriber($subscriber);
170 return;
171 }
172
173 $this->entityManager->wrapInTransaction(function() use ($subscriber, $wpUser): void {
174 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
175
176 // Remove only the WP-Users segment membership; other list subscriptions stay intact.
177 $this->entityManager->createQueryBuilder()
178 ->delete(SubscriberSegmentEntity::class, 'ss')
179 ->where('ss.subscriber = :subscriber AND ss.segment = :segment')
180 ->setParameter('subscriber', $subscriber)
181 ->setParameter('segment', $wpSegment)
182 ->getQuery()
183 ->execute();
184
185 $subscriber->setWpUserId(null);
186 $subscriber->setSource(Source::WORDPRESS_USER_DELETED);
187
188 // If the subscriber was only on the WP-Users list and is not a WC customer,
189 // they had no list of their own to remain on — trash them instead of leaving a floating row.
190 // Skip when the subscriber was already trashed (e.g. manually by an admin) so we keep
191 // the original deleted_at and status as audit information.
192 $hasOtherActiveSegments = $this->hasOtherActiveSegments($subscriber);
193 $isWooCustomer = $this->wooHelper->isWooCommerceActive() && in_array('customer', $wpUser->roles, true); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
194 if (!$hasOtherActiveSegments && !$isWooCustomer && $subscriber->getDeletedAt() === null) {
195 $subscriber->setStatus(SubscriberEntity::STATUS_UNCONFIRMED);
196 $subscriber->setDeletedAt(Carbon::now()->millisecond(0));
197 }
198
199 $this->subscribersRepository->persist($subscriber);
200 $this->subscribersRepository->flush();
201 });
202 $this->segmentsCountRecalculator->recalculateForSubscribers([(int)$subscriber->getId()]);
203 }
204
205 private function hasOtherActiveSegments(SubscriberEntity $subscriber): bool {
206 $subscriberId = $subscriber->getId();
207 if ($subscriberId === null) {
208 return false;
209 }
210
211 $count = $this->entityManager->createQueryBuilder()
212 ->select('COUNT(segment.id)')
213 ->from(SubscriberSegmentEntity::class, 'subscriberSegment')
214 ->innerJoin('subscriberSegment.segment', 'segment')
215 ->where('subscriberSegment.subscriber = :subscriber')
216 ->andWhere('segment.type != :wpType')
217 ->andWhere('segment.deletedAt IS NULL')
218 ->setParameter('subscriber', $subscriber)
219 ->setParameter('wpType', SegmentEntity::TYPE_WP_USERS)
220 ->getQuery()
221 ->getSingleScalarResult();
222
223 return is_numeric($count) && (int)$count > 0;
224 }
225
226 /**
227 * @param string $currentFilter
228 * @param \WP_User $wpUser
229 * @param ?SubscriberEntity $subscriber
230 * @param array|false $oldWpUserData
231 */
232 private function handleCreatingOrUpdatingSubscriber(string $currentFilter, \WP_User $wpUser, ?SubscriberEntity $subscriber = null, $oldWpUserData = false): void {
233 // Add or update
234 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
235
236 // find subscriber by email when is null
237 if (is_null($subscriber)) {
238 $subscriber = $this->subscribersRepository->findOneBy(['email' => $wpUser->user_email]); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
239 }
240
241 // get first name & last name
242 $firstName = $this->decodeUserName($wpUser->first_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
243 $lastName = $this->decodeUserName($wpUser->last_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
244 if (empty($wpUser->first_name) && empty($wpUser->last_name)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
245 $firstName = $this->decodeUserName($wpUser->display_name); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
246 }
247 $signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
248 $status = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
249 // we want to mark a new subscriber as unsubscribe when the checkbox from registration is unchecked
250 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- type narrowing only, value is read as bool
251 $mailpoetPost = isset($_POST['mailpoet']) && is_array($_POST['mailpoet']) ? $_POST['mailpoet'] : [];
252 if (isset($mailpoetPost['subscribe_on_register_active']) && (bool)$mailpoetPost['subscribe_on_register_active'] === true) {
253 $status = SubscriberEntity::STATUS_UNSUBSCRIBED;
254 }
255
256 // subscriber data
257 $wpUserId = (int)$wpUser->ID; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
258 $data = [
259 'wp_user_id' => $wpUserId,
260 'email' => $wpUser->user_email, // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
261 'first_name' => $firstName,
262 'last_name' => $lastName,
263 'status' => $status,
264 'source' => Source::WORDPRESS_USER,
265 ];
266
267 $isRelinkingDeletedWpUser = $subscriber !== null
268 && $subscriber->getSource() === Source::WORDPRESS_USER_DELETED
269 && $subscriber->getWpUserId() !== $wpUserId;
270 if (!is_null($subscriber)) {
271 $data['id'] = $subscriber->getId();
272 unset($data['status']); // don't override status for existing users
273 unset($data['source']); // don't override source for existing users
274 if ($isRelinkingDeletedWpUser) {
275 // Restore the live WP-user source on any re-link, not only the auto-trashed case.
276 // Only revive deleted_at when the subscriber was actually trashed by the unlink
277 // (subscribers kept on other lists were never trashed and must keep deleted_at IS NULL).
278 $data['source'] = Source::WORDPRESS_USER;
279 if ($subscriber->getDeletedAt() !== null) {
280 $data['deleted_at'] = null;
281 }
282 }
283 }
284
285 $addingNewUserToDisabledWPSegment = $wpSegment->getDeletedAt() !== null && $currentFilter === 'user_register';
286
287 $otherActiveSegments = [];
288 if ($subscriber) {
289 $otherActiveSegments = array_filter($subscriber->getSegments()->toArray() ?? [], function (SegmentEntity $segment) {
290 return $segment->getType() !== SegmentEntity::TYPE_WP_USERS && $segment->getDeletedAt() === null;
291 });
292 }
293 $isWooCustomer = $this->wooHelper->isWooCommerceActive() && in_array('customer', $wpUser->roles, true);
294 // When WP Segment is disabled force trashed state and unconfirmed status for new WPUsers without active segment
295 // or who are not WooCommerce customers at the same time since customers are added to the WooCommerce list
296 if ($addingNewUserToDisabledWPSegment && !$isRelinkingDeletedWpUser && !$otherActiveSegments && !$isWooCustomer) {
297 $data['deleted_at'] = Carbon::now()->millisecond(0);
298 $data['status'] = SubscriberEntity::STATUS_UNCONFIRMED;
299 }
300
301 // Apply filter to allow modifying subscriber data before save
302 $data = $this->wp->applyFilters('mailpoet_subscriber_data_before_save', $data);
303
304 // Ensure data is an array
305 if (!is_array($data)) {
306 // If the filter returned a non-array, log it and use the original data
307 $logger = LoggerFactory::getInstance()->getLogger();
308 $logger->error(
309 'Filter mailpoet_subscriber_data_before_save returned non-array data.',
310 ['data_type' => gettype($data)]
311 );
312 return;
313 }
314
315 // When updating an existing subscriber's email, remove any other subscriber
316 // that already holds the new email to avoid a unique constraint violation.
317 // This can happen when a WP user registers with email A, checks out with
318 // email B (creating a second subscriber), then changes their account email
319 // from A to B.
320 // Uses bulkDelete() to properly clean up related data (segments, tags,
321 // custom fields) and to restrict deletion to safe duplicates (non-WP,
322 // non-WooCommerce subscribers).
323 if ($subscriber !== null && $subscriber->getEmail() !== $data['email']) {
324 $existingSubscriber = $this->subscribersRepository->findOneBy(['email' => $data['email']]);
325 if ($existingSubscriber !== null && $existingSubscriber->getId() !== $subscriber->getId()) {
326 $duplicateId = $existingSubscriber->getId();
327 $this->entityManager->detach($existingSubscriber);
328 $deletedCount = $this->subscribersRepository->bulkDelete([$duplicateId]);
329 if ($deletedCount === 0) {
330 // The duplicate is a WP user or WooCommerce customer and cannot be
331 // safely removed. Skip the email update to avoid a constraint violation.
332 $logger = LoggerFactory::getInstance()->getLogger();
333 $logger->warning(
334 'Cannot update subscriber email: duplicate subscriber is a WP user or WooCommerce customer',
335 ['subscriber_id' => $subscriber->getId(), 'duplicate_id' => $duplicateId, 'email' => $data['email']]
336 );
337 return;
338 }
339 }
340 }
341
342 try {
343 $subscriber = $this->createOrUpdateSubscriber($data, $subscriber);
344 } catch (\Exception $e) {
345 return; // fails silently as this was the behavior before the Doctrine refactor.
346 }
347
348 // add subscriber to the WP Users segment
349 $this->subscriberSegmentRepository->subscribeToSegments(
350 $subscriber,
351 [$wpSegment]
352 );
353
354 if (!$signupConfirmationEnabled && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED && $currentFilter === 'user_register') {
355 $subscriberSegment = $this->subscriberSegmentRepository->findOneBy([
356 'subscriber' => $subscriber->getId(),
357 'segment' => $wpSegment->getId(),
358 ]);
359
360 if (!is_null($subscriberSegment)) {
361 $this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
362 }
363 }
364
365 $subscribeOnRegisterEnabled = SettingsController::getInstance()->get('subscribe.on_register.enabled');
366 $sendConfirmationEmail =
367 $signupConfirmationEnabled
368 && $subscribeOnRegisterEnabled
369 && $currentFilter !== 'profile_update'
370 && !$addingNewUserToDisabledWPSegment;
371
372 if ($sendConfirmationEmail && ($subscriber->getStatus() === SubscriberEntity::STATUS_UNCONFIRMED)) {
373 /** @var ConfirmationEmailMailer $confirmationEmailMailer */
374 $confirmationEmailMailer = ContainerWrapper::getInstance()->get(ConfirmationEmailMailer::class);
375 try {
376 // Per-list confirmation settings are not resolved here because this path
377 // subscribes to the WordPress Users segment (TYPE_WP_USERS),
378 // which does not support custom confirmation overrides.
379 $confirmationEmailMailer->sendConfirmationEmailOnce($subscriber);
380 } catch (\Exception $e) {
381 // ignore errors
382 }
383 }
384
385 // welcome email
386 $scheduleWelcomeNewsletter = false;
387 if (in_array($currentFilter, ['profile_update', 'user_register', 'add_user_role', 'set_user_role'])) {
388 $scheduleWelcomeNewsletter = true;
389 }
390 if ($scheduleWelcomeNewsletter === true) {
391 $this->welcomeScheduler->scheduleWPUserWelcomeNotification(
392 $subscriber->getId(),
393 (array)$wpUser,
394 (array)$oldWpUserData
395 );
396 }
397 }
398
399 /**
400 * WordPress stores user names entity-encoded (see the `pre_user_first_name`,
401 * `pre_user_last_name` and `pre_user_display_name` filters). We decode them so a
402 * name such as "Family & friends" is stored the way it was written, then run the
403 * same text sanitizer the other subscriber write paths use, so decoding can never
404 * turn encoded markup back into markup.
405 *
406 * @param mixed $name
407 */
408 private function decodeUserName($name): string {
409 $decoded = html_entity_decode(is_string($name) ? $name : '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401);
410
411 return $this->wp->sanitizeTextField($decoded);
412 }
413
414 private function createOrUpdateSubscriber(array $data, ?SubscriberEntity $subscriber = null): SubscriberEntity {
415 if (is_null($subscriber)) {
416 $subscriber = new SubscriberEntity();
417 }
418
419 $subscriber->setWpUserId($data['wp_user_id']);
420 $subscriber->setEmail($data['email']);
421
422 // Only set first_name if it's present in the data array
423 if (isset($data['first_name'])) {
424 $subscriber->setFirstName($data['first_name']);
425 }
426
427 // Only set last_name if it's present in the data array
428 if (isset($data['last_name'])) {
429 $subscriber->setLastName($data['last_name']);
430 }
431
432 if (isset($data['status'])) {
433 $subscriber->setStatus($data['status']);
434 }
435
436 if (isset($data['source'])) {
437 $subscriber->setSource($data['source']);
438 }
439
440 if (array_key_exists('deleted_at', $data)) {
441 $subscriber->setDeletedAt($data['deleted_at']);
442 }
443
444 $this->subscribersRepository->persist($subscriber);
445 $this->subscribersRepository->flush();
446
447 return $subscriber;
448 }
449
450 public function synchronizeUsers(): bool {
451 // Temporarily skip synchronization in WP Playground.
452 // Some of the queries are not yet supported by the SQLite integration.
453 if (Connection::isSQLite()) {
454 return true;
455 }
456
457 // Save timestamp about changes and update before insert
458 $this->subscriberChangesNotifier->subscribersBatchCreate();
459 $this->subscriberChangesNotifier->subscribersBatchUpdate();
460
461 $updatedUsersEmails = $this->updateSubscribersEmails();
462 $insertedUsersEmails = $this->insertSubscribers();
463 $this->removeUpdatedSubscribersWithInvalidEmail(array_merge($updatedUsersEmails, $insertedUsersEmails));
464 // There is high chance that an update will be made
465 $this->subscriberChangesNotifier->subscribersBatchUpdate();
466 unset($updatedUsersEmails);
467 unset($insertedUsersEmails);
468 $this->updateFirstNames();
469 $this->updateLastNames();
470 $this->updateFirstNameIfMissing();
471 $this->insertUsersToSegment();
472 $this->removeOrphanedSubscribers();
473 // insertUsersToSegment adds WP users to the WP-Users segment via raw SQL,
474 // so refresh segments_count for that segment's members.
475 // recalculateForSegment() only sees subscribers that still have a membership
476 // row. Orphans that are hard-deleted by removeOrphanedSubscribers() are fine
477 // (row gone, count moot). Orphans whose membership is deleted but who survive
478 // (soft-trashed or still on other lists) are recalculated explicitly inside
479 // removeOrphanedSubscribersFromWpSegment() before the membership DELETE.
480 $this->segmentsCountRecalculator->recalculateForSegment((int)$this->segmentsRepository->getWPUsersSegment()->getId());
481 $this->subscribersRepository->invalidateTotalSubscribersCache();
482 $this->subscribersRepository->refreshAll();
483
484 return true;
485 }
486
487 private function removeUpdatedSubscribersWithInvalidEmail(array $updatedEmails): void {
488 $invalidWpUserIds = array_map(function($item) {
489 return $item['id'];
490 },
491 array_filter($updatedEmails, function($updatedEmail) {
492 return !$this->validator->validateEmail($updatedEmail['email']) && $updatedEmail['id'] !== null;
493 }));
494 if (!$invalidWpUserIds) {
495 return;
496 }
497
498 $this->subscribersRepository->removeByWpUserIds($invalidWpUserIds);
499 }
500
501 private function updateSubscribersEmails(): array {
502 global $wpdb;
503
504 $stmt = $this->databaseConnection->executeQuery('SELECT NOW();');
505 $startTime = $stmt->fetchOne();
506
507 if (!is_string($startTime)) {
508 throw new \RuntimeException("Failed to fetch the current time.");
509 }
510
511 $updateSql =
512 "UPDATE IGNORE {$this->subscribersTable} s
513 INNER JOIN {$wpdb->users} as wu ON s.wp_user_id = wu.id
514 SET s.email = wu.user_email";
515 $this->databaseConnection->executeStatement($updateSql);
516
517 $selectSql =
518 "SELECT wp_user_id as id, email FROM {$this->subscribersTable}
519 WHERE updated_at >= '{$startTime}'";
520 $updatedEmails = $this->databaseConnection->fetchAllAssociative($selectSql);
521
522 return $updatedEmails;
523 }
524
525 private function insertSubscribers(): array {
526 global $wpdb;
527 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
528
529 if ($wpSegment->getDeletedAt() !== null) {
530 $subscriberStatus = SubscriberEntity::STATUS_UNCONFIRMED;
531 $deletedAt = 'CURRENT_TIMESTAMP()';
532 } else {
533 $signupConfirmationEnabled = SettingsController::getInstance()->get('signup_confirmation.enabled');
534 $subscriberStatus = $signupConfirmationEnabled ? SubscriberEntity::STATUS_UNCONFIRMED : SubscriberEntity::STATUS_SUBSCRIBED;
535 $deletedAt = 'null';
536 }
537
538 // Fetch users that are not in the subscribers table
539 $selectSql =
540 "SELECT u.id, u.user_email as email
541 FROM {$wpdb->users} u
542 LEFT JOIN {$this->subscribersTable} AS s ON s.wp_user_id = u.id
543 WHERE s.wp_user_id IS NULL AND u.user_email != ''";
544 $insertedUserIds = $this->databaseConnection->fetchAllAssociative($selectSql);
545
546 // Insert new users into the subscribers table.
547 // The email-based join can cross a collation boundary when wp_users.user_email and
548 // mailpoet_subscribers.email use different (but compatible) collations — force a
549 // matching collation to avoid "Illegal mix of collations" errors.
550 $emailCollation = $this->collationChecker->getCollateIfNeeded(
551 $wpdb->users,
552 'user_email',
553 $this->subscribersTable,
554 'email'
555 );
556 $insertSql =
557 "INSERT IGNORE INTO {$this->subscribersTable} (wp_user_id, email, status, created_at, `source`, deleted_at)
558 SELECT wu.id, wu.user_email, :subscriberStatus, CURRENT_TIMESTAMP(), :source, {$deletedAt}
559 FROM {$wpdb->users} wu
560 LEFT JOIN {$this->subscribersTable} s ON wu.id = s.wp_user_id
561 LEFT JOIN {$this->subscribersTable} existingSubscriber ON wu.user_email = existingSubscriber.email {$emailCollation}
562 WHERE s.wp_user_id IS NULL AND wu.user_email != ''
563 ON DUPLICATE KEY UPDATE
564 wp_user_id = wu.id,
565 deleted_at = IF(
566 existingSubscriber.`source` = :deletedSource AND existingSubscriber.deleted_at IS NOT NULL,
567 NULL,
568 existingSubscriber.deleted_at
569 ),
570 `source` = IF(
571 existingSubscriber.`source` = :deletedSource,
572 :source,
573 existingSubscriber.`source`
574 )";
575 $stmt = $this->databaseConnection->prepare($insertSql);
576 $stmt->bindValue('subscriberStatus', $subscriberStatus);
577 $stmt->bindValue('source', Source::WORDPRESS_USER);
578 $stmt->bindValue('deletedSource', Source::WORDPRESS_USER_DELETED);
579 $stmt->executeStatement();
580
581 return $insertedUserIds;
582 }
583
584 private function updateFirstNames(): void {
585 global $wpdb;
586
587 $sql =
588 "UPDATE {$this->subscribersTable} s
589 JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'first_name'
590 SET s.first_name = SUBSTRING(wpum.meta_value, 1, 255)
591 WHERE s.first_name = ''
592 AND s.wp_user_id IS NOT NULL
593 AND wpum.meta_value IS NOT NULL";
594
595 $this->databaseConnection->executeStatement($sql);
596 }
597
598 private function updateLastNames(): void {
599 global $wpdb;
600
601 $sql =
602 "UPDATE {$this->subscribersTable} s
603 JOIN {$wpdb->usermeta} as wpum ON s.wp_user_id = wpum.user_id AND wpum.meta_key = 'last_name'
604 SET s.last_name = SUBSTRING(wpum.meta_value, 1, 255)
605 WHERE s.last_name = ''
606 AND s.wp_user_id IS NOT NULL
607 AND wpum.meta_value IS NOT NULL";
608
609 $this->databaseConnection->executeStatement($sql);
610 }
611
612 private function updateFirstNameIfMissing(): void {
613 global $wpdb;
614
615 $sql =
616 "UPDATE {$this->subscribersTable} s
617 JOIN {$wpdb->users} wu ON s.wp_user_id = wu.id
618 SET s.first_name = wu.display_name
619 WHERE s.first_name = ''
620 AND s.wp_user_id IS NOT NULL";
621
622 $this->databaseConnection->executeStatement($sql);
623 }
624
625 private function insertUsersToSegment(): void {
626 $wpSegment = $this->segmentsRepository->getWPUsersSegment();
627 $subscribersSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
628
629 $sql =
630 "INSERT IGNORE INTO {$subscribersSegmentTable} (subscriber_id, segment_id, created_at)
631 SELECT s.id, '{$wpSegment->getId()}', CURRENT_TIMESTAMP() FROM {$this->subscribersTable} s
632 WHERE s.wp_user_id > 0";
633
634 $this->databaseConnection->executeStatement($sql);
635 }
636
637 private function removeOrphanedSubscribers(): void {
638 $this->subscribersRepository->removeOrphanedSubscribersFromWpSegment();
639 }
640 }
641