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