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 / API / MP / v1 / Subscribers.php
mailpoet / lib / API / MP / v1 Last commit date
API.php 4 months ago APIException.php 4 months ago CustomFields.php 3 months ago Segments.php 3 months ago Subscribers.php 23 hours ago Tags.php 4 months ago index.php 3 years ago
Subscribers.php
779 lines
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\API\MP\v1;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\API\JSON\ResponseBuilders\SubscribersResponseBuilder;
9 use MailPoet\Entities\SegmentEntity;
10 use MailPoet\Entities\StatisticsUnsubscribeEntity;
11 use MailPoet\Entities\SubscriberEntity;
12 use MailPoet\Entities\SubscriberTagEntity;
13 use MailPoet\Entities\TagEntity;
14 use MailPoet\Listing\ListingDefinition;
15 use MailPoet\Newsletter\Scheduler\WelcomeScheduler;
16 use MailPoet\Segments\SegmentsRepository;
17 use MailPoet\Settings\SettingsController;
18 use MailPoet\Statistics\Track\Unsubscribes;
19 use MailPoet\Subscribers\ConfirmationEmailMailer;
20 use MailPoet\Subscribers\ConfirmationEmailResolver;
21 use MailPoet\Subscribers\NewSubscriberNotificationMailer;
22 use MailPoet\Subscribers\RequiredCustomFieldValidator;
23 use MailPoet\Subscribers\Source;
24 use MailPoet\Subscribers\SubscriberListingRepository;
25 use MailPoet\Subscribers\SubscriberSaveController;
26 use MailPoet\Subscribers\SubscriberSegmentRepository;
27 use MailPoet\Subscribers\SubscribersRepository;
28 use MailPoet\Subscribers\SubscriberTagRepository;
29 use MailPoet\Subscribers\TrackingConsentCapture;
30 use MailPoet\Tags\TagRepository;
31 use MailPoet\Util\Helpers;
32 use MailPoet\WP\Functions as WPFunctions;
33 use MailPoetVendor\Carbon\Carbon;
34
35 class Subscribers {
36 const CONTEXT_SUBSCRIBE = 'subscribe';
37 const CONTEXT_UNSUBSCRIBE = 'unsubscribe';
38
39 /** @var SettingsController */
40 private $settings;
41
42 /** @var SubscribersRepository */
43 private $subscribersRepository;
44
45 /** @var SegmentsRepository */
46 private $segmentsRepository;
47
48 /** @var SubscriberSegmentRepository */
49 private $subscribersSegmentRepository;
50
51 /** @var ConfirmationEmailMailer */
52 private $confirmationEmailMailer;
53
54 /** @var WelcomeScheduler */
55 private $welcomeScheduler;
56
57 /** @var SubscribersResponseBuilder */
58 private $subscribersResponseBuilder;
59
60 /** @var NewSubscriberNotificationMailer */
61 private $newSubscriberNotificationMailer;
62
63 /** @var SubscriberSaveController */
64 private $subscriberSaveController;
65
66 /** @var RequiredCustomFieldValidator */
67 private $requiredCustomFieldsValidator;
68
69 /** @var WPFunctions */
70 private $wp;
71
72 /** @var SubscriberListingRepository */
73 private $subscriberListingRepository;
74
75 /** @var Unsubscribes */
76 private $unsubscribesTracker;
77
78 /** @var TagRepository */
79 private $tagRepository;
80
81 /** @var SubscriberTagRepository */
82 private $subscriberTagRepository;
83
84 /** @var ConfirmationEmailResolver */
85 private $confirmationEmailResolver;
86
87 /** @var TrackingConsentCapture */
88 private $trackingConsentCapture;
89
90 public function __construct (
91 ConfirmationEmailMailer $confirmationEmailMailer,
92 NewSubscriberNotificationMailer $newSubscriberNotificationMailer,
93 SegmentsRepository $segmentsRepository,
94 SettingsController $settings,
95 SubscriberSegmentRepository $subscriberSegmentRepository,
96 SubscribersRepository $subscribersRepository,
97 SubscriberSaveController $subscriberSaveController,
98 SubscribersResponseBuilder $subscribersResponseBuilder,
99 WelcomeScheduler $welcomeScheduler,
100 RequiredCustomFieldValidator $requiredCustomFieldsValidator,
101 SubscriberListingRepository $subscriberListingRepository,
102 WPFunctions $wp,
103 Unsubscribes $unsubscribesTracker,
104 TagRepository $tagRepository,
105 SubscriberTagRepository $subscriberTagRepository,
106 ConfirmationEmailResolver $confirmationEmailResolver,
107 TrackingConsentCapture $trackingConsentCapture
108 ) {
109 $this->confirmationEmailMailer = $confirmationEmailMailer;
110 $this->newSubscriberNotificationMailer = $newSubscriberNotificationMailer;
111 $this->segmentsRepository = $segmentsRepository;
112 $this->settings = $settings;
113 $this->subscribersSegmentRepository = $subscriberSegmentRepository;
114 $this->subscribersRepository = $subscribersRepository;
115 $this->subscriberSaveController = $subscriberSaveController;
116 $this->subscribersResponseBuilder = $subscribersResponseBuilder;
117 $this->welcomeScheduler = $welcomeScheduler;
118 $this->requiredCustomFieldsValidator = $requiredCustomFieldsValidator;
119 $this->wp = $wp;
120 $this->subscriberListingRepository = $subscriberListingRepository;
121 $this->unsubscribesTracker = $unsubscribesTracker;
122 $this->tagRepository = $tagRepository;
123 $this->subscriberTagRepository = $subscriberTagRepository;
124 $this->confirmationEmailResolver = $confirmationEmailResolver;
125 $this->trackingConsentCapture = $trackingConsentCapture;
126 }
127
128 public function getSubscriber($subscriberIdOrEmail): array {
129 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
130 return $this->subscribersResponseBuilder->build($subscriber);
131 }
132
133 public function addSubscriber(array $data, array $listIds = [], array $options = []): array {
134 $sendConfirmationEmail = !(isset($options['send_confirmation_email']) && $options['send_confirmation_email'] === false);
135 $scheduleWelcomeEmail = !(isset($options['schedule_welcome_email']) && $options['schedule_welcome_email'] === false);
136 $skipSubscriberNotification = (isset($options['skip_subscriber_notification']) && $options['skip_subscriber_notification'] === true);
137
138 // throw exception when subscriber email is missing
139 if (empty($data['email'])) {
140 throw new APIException(
141 __('Subscriber email address is required.', 'mailpoet'),
142 APIException::EMAIL_ADDRESS_REQUIRED
143 );
144 }
145
146 // throw exception when subscriber already exists
147 if ($this->subscribersRepository->findOneBy(['email' => $data['email']])) {
148 throw new APIException(
149 __('This subscriber already exists.', 'mailpoet'),
150 APIException::SUBSCRIBER_EXISTS
151 );
152 }
153
154 [$defaultFields, $customFields] = $this->extractCustomFieldsFromFromSubscriberData($data);
155
156 $this->requiredCustomFieldsValidator->validate($customFields);
157
158 // filter out all incoming data that we don't want to change, like status ...
159 $defaultFields = array_intersect_key($defaultFields, array_flip(['email', 'first_name', 'last_name', 'subscribed_ip', 'tracking_consent', 'tracking_consent_copy']));
160 $defaultFields = $this->resolveTrackingConsentFields($defaultFields);
161
162 if (empty($defaultFields['subscribed_ip'])) {
163 $defaultFields['subscribed_ip'] = Helpers::getIP();
164 }
165 $defaultFields['source'] = Source::API;
166
167 // Pre-resolve tag names before any persistence so invalid tags fail fast
168 // and don't leave a half-created subscriber behind.
169 $resolvedTagNames = array_key_exists('tags', $data) ? $this->resolveTagNames((array)$data['tags']) : null;
170
171 try {
172 $subscriberEntity = $this->subscriberSaveController->createOrUpdate($defaultFields, null);
173 } catch (\Exception $e) {
174 throw new APIException(
175 // translators: %s is an error message.
176 sprintf(__('Failed to add subscriber: %s', 'mailpoet'), $e->getMessage()),
177 APIException::FAILED_TO_SAVE_SUBSCRIBER
178 );
179 }
180
181 try {
182 $this->subscriberSaveController->updateCustomFields($customFields, $subscriberEntity);
183 } catch (\Exception $e) {
184 throw new APIException(
185 // translators: %s is an error message
186 sprintf(__('Failed to save subscriber custom fields: %s', 'mailpoet'), $e->getMessage()),
187 APIException::FAILED_TO_SAVE_SUBSCRIBER
188 );
189 }
190
191 if ($resolvedTagNames !== null) {
192 try {
193 $this->subscriberSaveController->updateTags($resolvedTagNames, $subscriberEntity);
194 } catch (\Exception $e) {
195 throw new APIException(
196 // translators: %s is an error message
197 sprintf(__('Failed to save subscriber tags: %s', 'mailpoet'), $e->getMessage()),
198 APIException::FAILED_TO_SAVE_SUBSCRIBER
199 );
200 }
201 }
202
203 // subscribe to segments and optionally: 1) send confirmation email, 2) schedule welcome email(s)
204 if (!empty($listIds)) {
205 $this->subscribeToLists($subscriberEntity->getId(), $listIds, [
206 'send_confirmation_email' => $sendConfirmationEmail,
207 'schedule_welcome_email' => $scheduleWelcomeEmail,
208 'skip_subscriber_notification' => $skipSubscriberNotification,
209 ]);
210 }
211 return $this->subscribersResponseBuilder->build($subscriberEntity);
212 }
213
214 public function updateSubscriber($subscriberIdOrEmail, array $data): array {
215 $this->checkSubscriberParam($subscriberIdOrEmail);
216
217 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
218
219 [$defaultFields, $customFields] = $this->extractCustomFieldsFromFromSubscriberData($data);
220
221 $this->requiredCustomFieldsValidator->validate($customFields);
222
223 // filter out all incoming data that we don't want to change, like status ...
224 $defaultFields = array_intersect_key($defaultFields, array_flip(['email', 'first_name', 'last_name', 'subscribed_ip', 'tracking_consent', 'tracking_consent_copy']));
225 $defaultFields = $this->resolveTrackingConsentFields($defaultFields);
226
227 if ($subscriber->getWpUserId() !== null) {
228 unset($defaultFields['email']);
229 unset($defaultFields['first_name']);
230 unset($defaultFields['last_name']);
231 };
232
233 if (empty($defaultFields['subscribed_ip'])) {
234 $defaultFields['subscribed_ip'] = Helpers::getIP();
235 }
236 $defaultFields['source'] = Source::API;
237
238 // Pre-resolve tag names before any persistence so invalid tags fail fast
239 // and don't leave the subscriber partially updated.
240 $resolvedTagNames = array_key_exists('tags', $data) ? $this->resolveTagNames((array)$data['tags']) : null;
241
242 try {
243 $subscriberEntity = $this->subscriberSaveController->createOrUpdate($defaultFields, $subscriber);
244 } catch (\Exception $e) {
245 throw new APIException(
246 // translators: %s is an error message.
247 sprintf(__('Failed to update subscriber: %s', 'mailpoet'), $e->getMessage()),
248 APIException::FAILED_TO_SAVE_SUBSCRIBER
249 );
250 }
251
252 try {
253 $this->subscriberSaveController->updateCustomFields($customFields, $subscriberEntity);
254 } catch (\Exception $e) {
255 throw new APIException(
256 // translators: %s is an error message
257 sprintf(__('Failed to save subscriber custom fields: %s', 'mailpoet'), $e->getMessage()),
258 APIException::FAILED_TO_SAVE_SUBSCRIBER
259 );
260 }
261
262 if ($resolvedTagNames !== null) {
263 try {
264 $this->subscriberSaveController->updateTags($resolvedTagNames, $subscriberEntity);
265 } catch (\Exception $e) {
266 throw new APIException(
267 // translators: %s is an error message
268 sprintf(__('Failed to save subscriber tags: %s', 'mailpoet'), $e->getMessage()),
269 APIException::FAILED_TO_SAVE_SUBSCRIBER
270 );
271 }
272 }
273
274 return $this->subscribersResponseBuilder->build($subscriberEntity);
275 }
276
277 /**
278 * Adds a tag to a subscriber. Idempotent: no-op if the subscriber already has the tag.
279 * Accepts a tag id (int or numeric string) or name. Names that don't match an existing tag are created.
280 *
281 * @param int|string $subscriberIdOrEmail
282 * @param int|string $tagIdOrName
283 * @throws APIException
284 */
285 public function tagSubscriber($subscriberIdOrEmail, $tagIdOrName): array {
286 $this->checkSubscriberParam($subscriberIdOrEmail);
287 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
288 $tag = $this->resolveOrCreateTag($tagIdOrName);
289
290 $subscriberTag = $subscriber->getSubscriberTag($tag);
291 if (!$subscriberTag) {
292 $subscriberTag = new SubscriberTagEntity($tag, $subscriber);
293 $subscriber->getSubscriberTags()->add($subscriberTag);
294 $this->subscriberTagRepository->persist($subscriberTag);
295 $this->subscriberTagRepository->flush();
296 $this->wp->doAction('mailpoet_subscriber_tag_added', $subscriberTag);
297 }
298
299 $this->subscribersRepository->refresh($subscriber);
300 return $this->subscribersResponseBuilder->build($subscriber);
301 }
302
303 /**
304 * Removes a tag from a subscriber. Idempotent: no-op if the subscriber doesn't have the tag.
305 * Accepts a tag id (int or numeric string) or name. Name must match an existing tag.
306 *
307 * @param int|string $subscriberIdOrEmail
308 * @param int|string $tagIdOrName
309 * @throws APIException
310 */
311 public function untagSubscriber($subscriberIdOrEmail, $tagIdOrName): array {
312 $this->checkSubscriberParam($subscriberIdOrEmail);
313 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
314 $tag = $this->resolveTag($tagIdOrName);
315
316 $subscriberTag = $subscriber->getSubscriberTag($tag);
317 if ($subscriberTag) {
318 $subscriber->getSubscriberTags()->removeElement($subscriberTag);
319 $this->subscriberTagRepository->remove($subscriberTag);
320 $this->subscriberTagRepository->flush();
321 $this->wp->doAction('mailpoet_subscriber_tag_removed', $subscriberTag);
322 }
323
324 $this->subscribersRepository->refresh($subscriber);
325 return $this->subscribersResponseBuilder->build($subscriber);
326 }
327
328 /**
329 * @throws APIException
330 */
331 public function subscribeToLists(
332 $subscriberId,
333 array $listIds,
334 array $options = []
335 ): array {
336 $scheduleWelcomeEmail = !((isset($options['schedule_welcome_email']) && $options['schedule_welcome_email'] === false));
337 $sendConfirmationEmail = !((isset($options['send_confirmation_email']) && $options['send_confirmation_email'] === false));
338 $skipSubscriberNotification = isset($options['skip_subscriber_notification']) && $options['skip_subscriber_notification'] === true;
339 $signupConfirmationEnabled = (bool)$this->settings->get('signup_confirmation.enabled');
340
341 $this->checkSubscriberAndListParams($subscriberId, $listIds);
342 $subscriber = $this->findSubscriber($subscriberId);
343 $wasAlreadySubscribed = $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED;
344 $foundSegments = $this->getAndValidateSegments($listIds, self::CONTEXT_SUBSCRIBE);
345
346 // restore trashed subscriber
347 if ($subscriber->getDeletedAt()) {
348 $subscriber->setDeletedAt(null);
349 }
350
351 $this->subscribersSegmentRepository->subscribeToSegments($subscriber, $foundSegments);
352
353 // set status depending on signup confirmation setting
354 if ($subscriber->getStatus() !== SubscriberEntity::STATUS_SUBSCRIBED) {
355 if ($signupConfirmationEnabled === true) {
356 $subscriber->setStatus(SubscriberEntity::STATUS_UNCONFIRMED);
357 } else {
358 $subscriber->setStatus(SubscriberEntity::STATUS_SUBSCRIBED);
359 }
360 try {
361 $this->subscribersRepository->flush();
362 } catch (\Exception $e) {
363 throw new APIException(
364 // translators: %s is the error message
365 sprintf(__('Failed to save a status of a subscriber : %s', 'mailpoet'), $e->getMessage()),
366 APIException::FAILED_TO_SAVE_SUBSCRIBER
367 );
368 }
369
370 // when global status changes to subscribed, fire subscribed hook for all subscribed segments
371 /** @var SubscriberEntity $subscriber - From some reason PHPStan evaluates $subscriber->getStatus() as mixed */
372 if ($subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
373 $subscriberSegments = $subscriber->getSubscriberSegments();
374 foreach ($subscriberSegments as $subscriberSegment) {
375 if ($subscriberSegment->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
376 $this->wp->doAction('mailpoet_segment_subscribed', $subscriberSegment);
377 }
378 }
379 }
380 }
381
382 // schedule welcome email
383 $foundSegmentsIds = array_map(
384 function(SegmentEntity $segment) {
385 return $segment->getId();
386 },
387 $foundSegments
388 );
389 if ($scheduleWelcomeEmail && $subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED) {
390 $this->_scheduleWelcomeNotification($subscriber, $foundSegmentsIds);
391 }
392
393 // send confirmation email
394 if ($sendConfirmationEmail && !$wasAlreadySubscribed) {
395 [$confirmationEmailId, $confirmationPageId] = $this->confirmationEmailResolver->resolveFromSegments($foundSegments);
396 $this->_sendConfirmationEmail($subscriber, $confirmationEmailId, $confirmationPageId);
397 }
398
399 if (!$skipSubscriberNotification && ($subscriber->getStatus() === SubscriberEntity::STATUS_SUBSCRIBED)) {
400 $this->newSubscriberNotificationMailer->send($subscriber, $this->segmentsRepository->findByIds($foundSegmentsIds));
401 }
402
403 $this->subscribersRepository->refresh($subscriber);
404 return $this->subscribersResponseBuilder->build($subscriber);
405 }
406
407 public function unsubscribe($subscriberIdOrEmail): array {
408 $this->checkSubscriberParam($subscriberIdOrEmail);
409 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
410
411 if ($subscriber->getStatus() === SubscriberEntity::STATUS_UNSUBSCRIBED) {
412 throw new APIException(__('This subscriber is already unsubscribed.', 'mailpoet'), APIException::SUBSCRIBER_ALREADY_UNSUBSCRIBED);
413 }
414
415 $this->unsubscribesTracker->track(
416 (int)$subscriber->getId(),
417 StatisticsUnsubscribeEntity::SOURCE_MP_API
418 );
419
420 $subscriber->setStatus(SubscriberEntity::STATUS_UNSUBSCRIBED);
421 $this->subscribersRepository->persist($subscriber);
422 $this->subscribersRepository->flush();
423
424 $this->subscribersSegmentRepository->unsubscribeFromSegments($subscriber);
425
426 return $this->subscribersResponseBuilder->build($subscriber);
427 }
428
429 public function unsubscribeFromLists($subscriberIdOrEmail, array $listIds): array {
430 $this->checkSubscriberAndListParams($subscriberIdOrEmail, $listIds);
431 $subscriber = $this->findSubscriber($subscriberIdOrEmail);
432 $foundSegments = $this->getAndValidateSegments($listIds, self::CONTEXT_UNSUBSCRIBE);
433 $this->subscribersSegmentRepository->unsubscribeFromSegments($subscriber, $foundSegments);
434
435 return $this->subscribersResponseBuilder->build($subscriber);
436 }
437
438 public function getSubscribers(array $filter, int $limit, int $offset): array {
439 $listingDefinition = $this->buildListingDefinition($filter, $limit, $offset);
440 $subscribers = $this->subscriberListingRepository->getData($listingDefinition);
441 $result = [];
442 foreach ($subscribers as $subscriber) {
443 $result[] = $this->subscribersResponseBuilder->build($subscriber);
444 }
445 return $result;
446 }
447
448 public function getSubscribersCount(array $filter): int {
449 $listingDefinition = $this->buildListingDefinition($filter);
450 return $this->subscriberListingRepository->getCount($listingDefinition);
451 }
452
453 /**
454 * @param array $filter {
455 * Filters to retrieve subscribers.
456 *
457 * @type string $status One of values: subscribed, unconfirmed, unsubscribed, inactive, bounced
458 * @type int $listId id of a list or dynamic segment
459 * @type \DateTimeInterface|int $minUpdatedAt DateTime/DateTimeImmutable instance or timestamp of last update of subscriber.
460 * }
461 */
462 private function buildListingDefinition(array $filter, int $limit = 50, int $offset = 0): ListingDefinition {
463 $group = isset($filter['status']) && is_string($filter['status']) ? $filter['status'] : null;
464 $listingFilters = [];
465 // Set filtering by listId
466 if (isset($filter['listId']) && is_int($filter['listId'])) {
467 $listingFilters['segment'] = $filter['listId'];
468 }
469 // Set filtering by minimal updatedAt
470 if (isset($filter['minUpdatedAt'])) {
471 if ($filter['minUpdatedAt'] instanceof \DateTimeInterface) {
472 $listingFilters['minUpdatedAt'] = $filter['minUpdatedAt'];
473 } elseif (is_int($filter['minUpdatedAt'])) {
474 $listingFilters['minUpdatedAt'] = Carbon::createFromTimestamp($filter['minUpdatedAt']);
475 }
476 }
477
478 return new ListingDefinition($group, $listingFilters, null, [], 'id', 'asc', $offset, $limit);
479 }
480
481 /**
482 * @throws APIException
483 */
484 protected function _scheduleWelcomeNotification(SubscriberEntity $subscriber, array $segments) {
485 try {
486 $this->welcomeScheduler->scheduleSubscriberWelcomeNotification($subscriber->getId(), $segments);
487 } catch (\Throwable $e) {
488 throw new APIException(
489 // translators: %s is an error message
490 sprintf(__('Subscriber added, but welcome email failed to send: %s', 'mailpoet'), $e->getMessage()),
491 APIException::WELCOME_FAILED_TO_SEND
492 );
493 }
494 }
495
496 /**
497 * @throws APIException
498 */
499 protected function _sendConfirmationEmail(SubscriberEntity $subscriberEntity, ?int $confirmationEmailId = null, ?int $confirmationPageId = null) {
500 try {
501 $this->confirmationEmailMailer->sendConfirmationEmailOnce($subscriberEntity, $confirmationEmailId, $confirmationPageId);
502 } catch (\Exception $e) {
503 throw new APIException(
504 // translators: %s is the error message
505 sprintf(__('Subscriber added to lists, but confirmation email failed to send: %s', 'mailpoet'), strtolower($e->getMessage())),
506 APIException::CONFIRMATION_FAILED_TO_SEND
507 );
508 }
509 }
510
511 /**
512 * @throws APIException
513 */
514 private function checkSubscriberAndListParams($subscriberIdOrEmail, array $listIds): void {
515 if (empty($listIds)) {
516 throw new APIException(__('At least one segment ID is required.', 'mailpoet'), APIException::SEGMENT_REQUIRED);
517 }
518 $this->checkSubscriberParam($subscriberIdOrEmail);
519 }
520
521 /**
522 * @throws APIException
523 */
524 private function checkSubscriberParam($subscriberIdOrEmail): void {
525 if (empty($subscriberIdOrEmail)) {
526 throw new APIException(__('A subscriber is required.', 'mailpoet'), APIException::SUBSCRIBER_NOT_EXISTS);
527 }
528 }
529
530 /**
531 * @throws APIException
532 */
533 private function findSubscriber($subscriberIdOrEmail): SubscriberEntity {
534 // throw exception when subscriber does not exist
535 $subscriber = null;
536 if (is_int($subscriberIdOrEmail) || (string)(int)$subscriberIdOrEmail === $subscriberIdOrEmail) {
537 $subscriber = $this->subscribersRepository->findOneById($subscriberIdOrEmail);
538 } else if (strlen(trim($subscriberIdOrEmail)) > 0) {
539 $subscriber = $this->subscribersRepository->findOneBy(['email' => $subscriberIdOrEmail]);
540 }
541
542 if (!$subscriber) {
543 throw new APIException(__('This subscriber does not exist.', 'mailpoet'), APIException::SUBSCRIBER_NOT_EXISTS);
544 }
545
546 return $subscriber;
547 }
548
549 /**
550 * @return SegmentEntity[]
551 * @throws APIException
552 */
553 private function getAndValidateSegments(array $listIds, string $context): array {
554 // throw exception when none of the segments exist
555 $foundSegments = $this->segmentsRepository->findByIds($listIds);
556 if (!$foundSegments) {
557 $exception = _n('This list does not exist.', 'These lists do not exist.', count($listIds), 'mailpoet');
558 throw new APIException($exception, APIException::LIST_NOT_EXISTS);
559 }
560
561 // throw exception when trying to subscribe to WP Users or WooCommerce Customers segments
562 $foundSegmentsIds = [];
563 foreach ($foundSegments as $foundSegment) {
564 if ($foundSegment->getType() === SegmentEntity::TYPE_WP_USERS) {
565 if ($context === self::CONTEXT_SUBSCRIBE) {
566 // translators: %d is the ID of the segment
567 $message = __("Can't subscribe to a WordPress Users list with ID '%d'.", 'mailpoet');
568 } else {
569 // translators: %d is the ID of the segment
570 $message = __("Can't unsubscribe from a WordPress Users list with ID '%d'.", 'mailpoet');
571 }
572 throw new APIException(sprintf($message, $foundSegment->getId()), APIException::SUBSCRIBING_TO_WP_LIST_NOT_ALLOWED);
573 }
574 if ($foundSegment->getType() === SegmentEntity::TYPE_WC_USERS) {
575 if ($context === self::CONTEXT_SUBSCRIBE) {
576 // translators: %d is the ID of the segment
577 $message = __("Can't subscribe to a WooCommerce Customers list with ID '%d'.", 'mailpoet');
578 } else {
579 // translators: %d is the ID of the segment
580 $message = __("Can't unsubscribe from a WooCommerce Customers list with ID '%d'.", 'mailpoet');
581 }
582 throw new APIException(sprintf($message, $foundSegment->getId()), APIException::SUBSCRIBING_TO_WC_LIST_NOT_ALLOWED);
583 }
584 if ($foundSegment->getType() !== SegmentEntity::TYPE_DEFAULT) {
585 if ($context === self::CONTEXT_SUBSCRIBE) {
586 // translators: %d is the ID of the segment
587 $message = __("Can't subscribe to a list with ID '%d'.", 'mailpoet');
588 } else {
589 // translators: %d is the ID of the segment
590 $message = __("Can't unsubscribe from a list with ID '%d'.", 'mailpoet');
591 }
592 throw new APIException(sprintf($message, $foundSegment->getId()), APIException::SUBSCRIBING_TO_LIST_NOT_ALLOWED);
593 }
594 $foundSegmentsIds[] = $foundSegment->getId();
595 }
596
597 // throw an exception when one or more segments do not exist
598 if (count($foundSegmentsIds) !== count($listIds)) {
599 $missingIds = array_values(array_diff($listIds, $foundSegmentsIds));
600 $exception = sprintf(
601 // translators: %s is the count of lists
602 _n("List with ID '%s' does not exist.", "Lists with IDs '%s' do not exist.", count($missingIds), 'mailpoet'),
603 implode(', ', $missingIds)
604 );
605 throw new APIException(sprintf($exception, implode(', ', $missingIds)), APIException::LIST_NOT_EXISTS);
606 }
607
608 return $foundSegments;
609 }
610
611 /**
612 * Resolves a tag by id (int or numeric string) or existing name. Throws when the tag cannot be found.
613 *
614 * @param int|string $tagIdOrName
615 * @throws APIException
616 */
617 private function resolveTag($tagIdOrName): TagEntity {
618 $tag = $this->findTag($tagIdOrName);
619 if (!$tag instanceof TagEntity) {
620 throw new APIException(__('The tag does not exist.', 'mailpoet'), APIException::TAG_NOT_EXISTS);
621 }
622 return $tag;
623 }
624
625 /**
626 * Like resolveTag(), but when given a non-numeric name that doesn't match an existing tag,
627 * the tag is created. Numeric id lookups still throw when no tag matches (never auto-created).
628 *
629 * @param int|string $tagIdOrName
630 * @throws APIException
631 */
632 private function resolveOrCreateTag($tagIdOrName): TagEntity {
633 $tag = $this->findTag($tagIdOrName);
634 if ($tag instanceof TagEntity) {
635 return $tag;
636 }
637
638 if (!is_string($tagIdOrName) || (string)(int)$tagIdOrName === $tagIdOrName) {
639 throw new APIException(__('The tag does not exist.', 'mailpoet'), APIException::TAG_NOT_EXISTS);
640 }
641
642 return $this->tagRepository->createOrUpdate(['name' => $this->sanitizeTagName($tagIdOrName)]);
643 }
644
645 /**
646 * Looks up a tag by id (int/numeric-string) or existing name. Returns null if not found.
647 * Throws when the input is unusable (non-string/non-int, or an empty/sanitizes-to-empty name).
648 *
649 * @param int|string $tagIdOrName
650 * @throws APIException
651 */
652 private function findTag($tagIdOrName): ?TagEntity {
653 if (is_int($tagIdOrName) || (is_string($tagIdOrName) && (string)(int)$tagIdOrName === $tagIdOrName)) {
654 return $this->tagRepository->findOneById((int)$tagIdOrName);
655 }
656
657 if (!is_string($tagIdOrName)) {
658 throw new APIException(__('Tag name is required.', 'mailpoet'), APIException::TAG_NAME_REQUIRED);
659 }
660
661 $name = $this->sanitizeTagName($tagIdOrName);
662 $tag = $this->tagRepository->findOneBy(['name' => $name]);
663 return $tag instanceof TagEntity ? $tag : null;
664 }
665
666 /**
667 * Normalizes the `tags` key from addSubscriber/updateSubscriber data to an array of tag names.
668 * Accepts:
669 * - integer or numeric-string scalars: the id of an existing tag (resolved to its name);
670 * - non-numeric string scalars: a tag name (sanitized);
671 * - `['id' => ...]`: id of an existing tag (resolved to its name);
672 * - `['name' => ...]`: a tag name (sanitized).
673 *
674 * Unrecognized entries (null, booleans, arrays without `id`/`name`, empty names) throw so
675 * callers don't silently drop tags - `updateTags` replaces the full tag set.
676 *
677 * @param array $tags
678 * @return string[]
679 * @throws APIException
680 */
681 private function resolveTagNames(array $tags): array {
682 $names = [];
683 foreach ($tags as $tag) {
684 if (is_array($tag)) {
685 if (array_key_exists('id', $tag)) {
686 $names[] = $this->resolveTag($tag['id'])->getName();
687 continue;
688 }
689 if (array_key_exists('name', $tag) && is_string($tag['name'])) {
690 $names[] = $this->sanitizeTagName($tag['name']);
691 continue;
692 }
693 throw new APIException(__('Tag name is required.', 'mailpoet'), APIException::TAG_NAME_REQUIRED);
694 }
695 if (is_int($tag) || (is_string($tag) && (string)(int)$tag === $tag)) {
696 $names[] = $this->resolveTag($tag)->getName();
697 continue;
698 }
699 if (is_string($tag)) {
700 $names[] = $this->sanitizeTagName($tag);
701 continue;
702 }
703 throw new APIException(__('Tag name is required.', 'mailpoet'), APIException::TAG_NAME_REQUIRED);
704 }
705 return $names;
706 }
707
708 private function sanitizeTagName(string $name): string {
709 $sanitized = sanitize_text_field($name);
710 if (trim($sanitized) === '') {
711 throw new APIException(__('Tag name is required.', 'mailpoet'), APIException::TAG_NAME_REQUIRED);
712 }
713 return $sanitized;
714 }
715
716 /**
717 * Validates and resolves the tracking-consent fields a public-API caller sent.
718 *
719 * Only the three known states can be stored. Anything else is dropped rather
720 * than stored or thrown on, so the subscriber keeps whatever consent they
721 * already had (or the unknown default, for one being created now) — the same
722 * shape as the rest of this whitelist, which silently drops fields it does
723 * not accept.
724 *
725 * The method is always stamped 'api': the caller rendered its own consent
726 * control, so it IS the collection point and never gets to name a different
727 * one. The wording is the caller's own when it sends one, otherwise the
728 * application default, read through TrackingConsentCapture::getCopy() so a
729 * site's mailpoet_tracking_consent_copy filter still applies. getCopy() is
730 * used deliberately instead of getConsentData()/applyToSubscriber(): those
731 * two gate on isCaptureEnabled(), which would silently drop a decline on a
732 * site whose own consent controls are switched off, and a decline has to be
733 * honoured either way.
734 */
735 private function resolveTrackingConsentFields(array $fields): array {
736 if (!isset($fields['tracking_consent'])) {
737 unset($fields['tracking_consent_copy']);
738 return $fields;
739 }
740 $validStates = [
741 SubscriberEntity::TRACKING_CONSENT_GRANTED,
742 SubscriberEntity::TRACKING_CONSENT_DENIED,
743 SubscriberEntity::TRACKING_CONSENT_UNKNOWN,
744 ];
745 // Checked with is_string() rather than cast: casting an array or object here throws
746 // an \Error, which does not extend \Exception and so escapes the try/catch this
747 // method's callers wrap createOrUpdate() in. That would turn "invalid values are
748 // ignored" into a fatal.
749 $consent = $fields['tracking_consent'];
750 if (!is_string($consent) || !in_array($consent, $validStates, true)) {
751 unset($fields['tracking_consent'], $fields['tracking_consent_copy']);
752 return $fields;
753 }
754 $fields['tracking_consent'] = $consent;
755 $fields['tracking_consent_method'] = SubscriberEntity::TRACKING_CONSENT_METHOD_API;
756 $copy = $fields['tracking_consent_copy'] ?? null;
757 $fields['tracking_consent_copy'] = $this->trackingConsentCapture->getCopy(
758 SubscriberEntity::TRACKING_CONSENT_METHOD_API,
759 is_string($copy) ? $copy : null
760 );
761 return $fields;
762 }
763
764 /**
765 * Splits subscriber data into two arrays with basic data (index 0) and custom fields data (index 1)
766 * @return array<int, array>
767 */
768 private function extractCustomFieldsFromFromSubscriberData($data): array {
769 $customFields = [];
770 foreach ($data as $key => $value) {
771 if (strpos($key, 'cf_') === 0) {
772 $customFields[$key] = $value;
773 unset($data[$key]);
774 }
775 }
776 return [$data, $customFields];
777 }
778 }
779