PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.33.1
MailPoet – Newsletters, Email Marketing, and Automation v5.33.1
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 / Services / Bridge / API.php
mailpoet / lib / Services / Bridge Last commit date
API.php 2 months ago index.php 3 years ago
API.php
542 lines
1 <?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
2
3 namespace MailPoet\Services\Bridge;
4
5 if (!defined('ABSPATH')) exit;
6
7
8 use MailPoet\Logging\LoggerFactory;
9 use MailPoet\WP\Functions as WPFunctions;
10 use WP_Error;
11
12 class API {
13 const RESPONSE_STATUS_OK = 'ok';
14 const RESPONSE_STATUS_ERROR = 'error';
15 const SENDING_STATUS_CONNECTION_ERROR = 'connection_error';
16 const SENDING_STATUS_SEND_ERROR = 'send_error';
17
18 const REQUEST_TIMEOUT = 10; // seconds
19
20 // ISO 8601 in UTC, e.g. 2026-06-15T23:59:59Z. The bounces report endpoint
21 // parses the `from`/`to` parameters with `new DateTime($value, UTC)`.
22 const BOUNCES_REPORT_DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
23
24 const RESPONSE_CODE_KEY_INVALID = 401;
25 const RESPONSE_CODE_STATS_SAVED = 204;
26 const RESPONSE_CODE_CREATED = 201;
27 const RESPONSE_CODE_INTERNAL_SERVER_ERROR = 500;
28 const RESPONSE_CODE_BAD_GATEWAY = 502;
29 const RESPONSE_CODE_TEMPORARY_UNAVAILABLE = 503;
30 const RESPONSE_CODE_GATEWAY_TIMEOUT = 504;
31 const RESPONSE_CODE_NOT_ARRAY = 422;
32 const RESPONSE_CODE_PAYLOAD_TOO_BIG = 413;
33 const RESPONSE_CODE_PAYLOAD_ERROR = 400;
34 const RESPONSE_CODE_CAN_NOT_SEND = 403;
35
36 // Bridge messages from https://github.com/mailpoet/services-bridge/blob/master/api/messages.rb
37 public const ERROR_MESSAGE_BANNED = 'Key is valid, but the action is forbidden';
38 public const ERROR_MESSAGE_INVALID_FROM = 'The email address is not authorized';
39 public const ERROR_MESSAGE_PENDING_APPROVAL = 'Key is valid, but not approved yet; you can send only to authorized email addresses at the moment';
40 public const ERROR_MESSAGE_DMRAC = "Email violates Sender Domain's DMARC policy. Please set up sender authentication.";
41 public const ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN = 'Please update the plugin and add/update your sender domain (refer to https://account.mailpoet.com/sender_domains)';
42 // Bridge message from https://github.com/mailpoet/services-bridge/blob/master/extensions/authentication/basic_strategy.rb
43 public const ERROR_MESSAGE_UNAUTHORIZED = 'No valid API key provided';
44 public const ERROR_MESSAGE_INSUFFICIENT_PRIVILEGES = 'Insufficient privileges';
45 public const ERROR_MESSAGE_EMAIL_VOLUME_LIMIT_REACHED = 'Email volume limit reached';
46 public const ERROR_MESSAGE_SUBSCRIBERS_LIMIT_REACHED = 'Subscribers limit reached';
47 // Proxy request `authorized_email_address` from shop https://github.com/mailpoet/shop/blob/master/routes/hooks/sending/v1/index.js#L65
48 public const ERROR_MESSAGE_AUTHORIZED_EMAIL_NO_FREE = 'You cannot use a free email address. Please use an address from your website’s domain, for example.';
49 public const ERROR_MESSAGE_AUTHORIZED_EMAIL_INVALID = 'Invalid email.';
50 public const ERROR_MESSAGE_AUTHORIZED_EMAIL_ALREADY_ADDED = 'This email was already added to the list.';
51 // Proxy request `sender_domain_verify` from shop https://github.com/mailpoet/shop/blob/master/routes/hooks/sending/v1/index.js#L137
52 public const ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_NOT_FOUND = 'Domain not found';
53 public const ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_FAILED = 'Some DNS records were not set up correctly. Please check the records again. You may need to wait up to 24 hours for DNS changes to propagate.';
54 // Proxy request `sender_domain` from shop https://github.com/mailpoet/shop/blob/master/routes/hooks/sending/v1/index.js#L65
55 public const ERROR_MESSAGE_SENDER_DOMAIN_INVALID = 'Invalid domain. Please enter a valid domain name.';
56 public const ERROR_MESSAGE_SENDER_DOMAIN_ALREADY_ADDED = 'This domain was already added to the list.';
57
58 public const KEY_CHECK_TYPE_PREMIUM = 'premium';
59 public const KEY_CHECK_TYPE_MSS = 'mss';
60
61 private $apiKey;
62 private $wp;
63 /** @var LoggerFactory */
64 private $loggerFactory;
65 /** @var mixed|null It is an instance of \CurlHandle in PHP8 and above but a resource in PHP7 */
66 private $curlHandle = null;
67
68 public $urlMe = 'https://bridge.mailpoet.com/api/v0/me';
69 public $urlPremium = 'https://bridge.mailpoet.com/api/v0/premium';
70 public $urlMessages = 'https://bridge.mailpoet.com/api/v0/messages';
71 // Registered directly on the WPCOM mailpoet-bridge plugin, not proxied through
72 // bridge.mailpoet.com like the other endpoints. Authenticated with the same
73 // `Basic api:<key>` header that auth() produces.
74 public $urlBouncesReport = 'https://public-api.wordpress.com/wpcom/v2/mailpoet-bridge/v2/bounces/report';
75 public $urlStats = 'https://bridge.mailpoet.com/api/v0/stats';
76 public $urlAuthorizedEmailAddresses = 'https://bridge.mailpoet.com/api/v1/authorized_email_address';
77 public $urlAuthorizedSenderDomains = 'https://bridge.mailpoet.com/api/v1/sender_domain';
78 public $urlAuthorizedSenderDomainVerification = 'https://bridge.mailpoet.com/api/v1/sender_domain_verify';
79
80 public function __construct(
81 $apiKey,
82 $wp = null
83 ) {
84 $this->setKey($apiKey);
85 if (is_null($wp)) {
86 $this->wp = new WPFunctions();
87 } else {
88 $this->wp = $wp;
89 }
90 $this->loggerFactory = LoggerFactory::getInstance();
91 }
92
93 public function checkMSSKey() {
94 return $this->checkKey(self::KEY_CHECK_TYPE_MSS);
95 }
96
97 public function checkPremiumKey() {
98 return $this->checkKey(self::KEY_CHECK_TYPE_PREMIUM);
99 }
100
101 private function checkKey(string $keyCheckType): array {
102 if ($keyCheckType === self::KEY_CHECK_TYPE_PREMIUM) {
103 $apiUrl = $this->urlPremium;
104 } else {
105 $apiUrl = $this->urlMe;
106 }
107 $result = $this->request(
108 $apiUrl,
109 ['site' => strtolower(WPFunctions::get()->homeUrl())]
110 );
111
112 $errorMessage = null;
113 $code = $this->wp->wpRemoteRetrieveResponseCode($result);
114 switch ($code) {
115 case 200:
116 $body = $this->wp->wpRemoteRetrieveBody($result);
117 if ($body) {
118 $body = json_decode($body, true);
119 }
120 break;
121 default:
122 $this->logKeyCheckError((int)$code, $keyCheckType);
123 $body = null;
124 $errorMessage = $this->wp->wpRemoteRetrieveBody($result);
125 break;
126 }
127
128 return ['code' => $code, 'data' => $body, 'error_message' => $errorMessage];
129 }
130
131 /**
132 * This method logs data from 'requests-curl.after_request' hook.
133 * The hook is mostly called with two parameters but sometimes only with one.
134 */
135 public function logCurlInformation($headers, $info = null) {
136 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_MSS)->info(
137 'requests-curl.after_request',
138 ['headers' => $headers, 'curl_info' => $info]
139 );
140 }
141
142 public function setCurlHandle($handle) {
143 $this->curlHandle = $handle;
144 }
145
146 public function sendMessages($messageBody) {
147 $this->curlHandle = null;
148 add_action('requests-curl.before_request', [$this, 'setCurlHandle'], 10, 1);
149 add_action('requests-curl.after_request', [$this, 'logCurlInformation'], 10, 2);
150 $result = $this->request(
151 $this->urlMessages,
152 $messageBody
153 );
154 remove_action('requests-curl.after_request', [$this, 'logCurlInformation']);
155 remove_action('requests-curl.before_request', [$this, 'setCurlHandle']);
156 if (is_wp_error($result)) {
157 $this->logCurlError($result);
158 return [
159 'status' => self::SENDING_STATUS_CONNECTION_ERROR,
160 'message' => $result->get_error_message(),
161 ];
162 }
163
164 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
165 if ($responseCode !== 201) {
166 $response = ($this->wp->wpRemoteRetrieveBody($result)) ?
167 $this->wp->wpRemoteRetrieveBody($result) :
168 $this->wp->wpRemoteRetrieveResponseMessage($result);
169 return $this->createErrorResponse((int)$responseCode, $response, self::SENDING_STATUS_SEND_ERROR);
170 }
171 return ['status' => self::RESPONSE_STATUS_OK];
172 }
173
174 /**
175 * Fetch a single page of bounced recipients reported between $from and $to.
176 *
177 * Mirrors the WordPress-registered `GET bounces/report` endpoint: required
178 * `from`/`to` datetime range, 1-based `p` pagination, and a response of the
179 * shape `{ recipients: array<{email: string, type: string}>, page: int,
180 * has_more: bool }`. The returned `recipients` are flattened to their email
181 * addresses so callers receive a plain list of strings. Returns null on a
182 * failed request.
183 *
184 * @return array{recipients: string[], page: int, has_more: bool}|null
185 */
186 public function getBouncesReport(\DateTimeInterface $from, \DateTimeInterface $to, int $page = 1): ?array {
187 $utc = new \DateTimeZone('UTC');
188 $fromUtc = (new \DateTimeImmutable('@' . $from->getTimestamp()))->setTimezone($utc);
189 $toUtc = (new \DateTimeImmutable('@' . $to->getTimestamp()))->setTimezone($utc);
190
191 $url = $this->wp->addQueryArg(
192 [
193 'from' => $fromUtc->format(self::BOUNCES_REPORT_DATE_FORMAT),
194 'to' => $toUtc->format(self::BOUNCES_REPORT_DATE_FORMAT),
195 'p' => $page,
196 ],
197 $this->urlBouncesReport
198 );
199
200 $result = $this->request($url, null, 'GET');
201 if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
202 return null;
203 }
204 $body = $this->wp->wpRemoteRetrieveBody($result);
205 $data = json_decode($body, true);
206 if (!$this->isValidBouncesReport($data)) {
207 // A 200 with a malformed payload must not be treated as a successful empty
208 // page: that would advance the report window and silently skip bounces.
209 $this->logInvalidDataFormat('getBouncesReport', is_string($body) ? $body : null);
210 return null;
211 }
212 $data['recipients'] = array_map(
213 function (array $recipient): string {
214 return $recipient['email'];
215 },
216 $data['recipients']
217 );
218 return $data;
219 }
220
221 /**
222 * @param mixed $data
223 * @phpstan-assert-if-true array{recipients: array<array{email: string}>, page: int, has_more: bool} $data
224 */
225 private function isValidBouncesReport($data): bool {
226 if (
227 !is_array($data)
228 || !isset($data['recipients'], $data['page'], $data['has_more'])
229 || !is_array($data['recipients'])
230 || !is_int($data['page'])
231 || !is_bool($data['has_more'])
232 ) {
233 return false;
234 }
235 foreach ($data['recipients'] as $recipient) {
236 if (!is_array($recipient) || !isset($recipient['email']) || !is_string($recipient['email'])) {
237 return false;
238 }
239 }
240 return true;
241 }
242
243 public function updateSubscriberCount($count): bool {
244 $result = $this->request(
245 $this->urlStats,
246 ['subscriber_count' => (int)$count],
247 'PUT'
248 );
249 $code = $this->wp->wpRemoteRetrieveResponseCode($result);
250 $isSuccess = $code === self::RESPONSE_CODE_STATS_SAVED;
251 if (!$isSuccess) {
252 $logData = [
253 'code' => $code,
254 'error' => is_wp_error($result) ? $result->get_error_message() : null,
255 ];
256 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('Stats API call failed.', $logData);
257 }
258 return $isSuccess;
259 }
260
261 public function getAuthorizedEmailAddresses(): ?array {
262 $result = $this->request(
263 $this->urlAuthorizedEmailAddresses,
264 null,
265 'GET'
266 );
267 if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
268 return null;
269 }
270 $data = json_decode($this->wp->wpRemoteRetrieveBody($result), true);
271 return is_array($data) ? $data : null;
272 }
273
274 /**
275 * Create Authorized Email Address
276 *
277 * @param string $emailAddress
278 * @return array{status: string, code?: int, error?: string, message?: string}
279 */
280 public function createAuthorizedEmailAddress(string $emailAddress): array {
281 $body = ['email' => $emailAddress];
282 $result = $this->request(
283 $this->urlAuthorizedEmailAddresses,
284 $body
285 );
286
287 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
288
289 if ($responseCode !== self::RESPONSE_CODE_CREATED) {
290 $errorBody = $this->wp->wpRemoteRetrieveBody($result);
291 $logData = [
292 'code' => $responseCode,
293 'error' => is_wp_error($result) ? $result->get_error_message() : $errorBody,
294 ];
295 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('CreateAuthorizedEmailAddress API call failed.', $logData);
296
297 $errorResponseData = json_decode($errorBody, true);
298 // translators: %d is the error code.
299 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
300
301 $error = is_array($errorResponseData) && isset($errorResponseData['error']) && is_string($errorResponseData['error'])
302 ? $errorResponseData['error']
303 : $fallbackError;
304 return $this->createErrorResponse((int)$responseCode, $error);
305 }
306
307 return ['status' => self::RESPONSE_STATUS_OK];
308 }
309
310 /**
311 * Get a list of sender domains
312 * Fetched from API
313 * @see https://github.com/mailpoet/services-bridge#sender-domains
314 */
315 public function getAuthorizedSenderDomains(): ?array {
316 $result = $this->request(
317 $this->urlAuthorizedSenderDomains,
318 null,
319 'GET'
320 );
321 if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
322 return null;
323 }
324 $rawData = $this->wp->wpRemoteRetrieveBody($result);
325 $data = json_decode($rawData, true);
326 if (!is_array($data)) {
327 $this->logInvalidDataFormat('getAuthorizedSenderDomains', $rawData);
328 return null;
329 }
330 return $data;
331 }
332
333 /**
334 * Create Sender domain record
335 * Done via API
336 * Returns same response se sender_domain_verify @see https://github.com/mailpoet/services-bridge#verify-a-sender-domain
337 */
338 public function createAuthorizedSenderDomain(string $domain): array {
339 $body = ['domain' => strtolower($domain)];
340 $result = $this->request(
341 $this->urlAuthorizedSenderDomains,
342 $body
343 );
344
345 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
346 $rawResponseBody = $this->wp->wpRemoteRetrieveBody($result);
347
348 $responseBody = json_decode($rawResponseBody, true);
349
350 if ($responseCode !== self::RESPONSE_CODE_CREATED) {
351 $logData = [
352 'code' => $responseCode,
353 'error' => is_wp_error($result) ? $result->get_error_message() : $rawResponseBody,
354 ];
355 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('createAuthorizedSenderDomain API call failed.', $logData);
356
357 // translators: %d will be replaced by an error code
358 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
359
360 $error = is_array($responseBody) && isset($responseBody['error']) && is_string($responseBody['error'])
361 ? $responseBody['error']
362 : $fallbackError;
363 return $this->createErrorResponse((int)$responseCode, $error);
364 }
365
366 if (!is_array($responseBody)) {
367 $this->logInvalidDataFormat('createAuthorizedSenderDomain', $rawResponseBody);
368 return [];
369 }
370
371 $responseBody['status'] = self::RESPONSE_STATUS_OK;
372 return $responseBody;
373 }
374
375 /**
376 * Verify Sender Domain records
377 * returns an Array of DNS response or an array of error
378 * @see https://github.com/mailpoet/services-bridge#verify-a-sender-domain
379 */
380 public function verifyAuthorizedSenderDomain(string $domain): array {
381 $url = $this->urlAuthorizedSenderDomainVerification . '/' . urlencode(strtolower($domain));
382 $result = $this->request(
383 $url,
384 null
385 );
386
387 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
388 $rawResponseBody = $this->wp->wpRemoteRetrieveBody($result);
389
390 $responseBody = json_decode($rawResponseBody, true);
391 if ($responseCode !== 200) {
392 if ($responseCode === 400) {
393 // we need to return the body as it is, but for consistency we add status and translated error message
394 $response = is_array($responseBody) ? $responseBody : [];
395 $response['status'] = self::RESPONSE_STATUS_ERROR;
396 $errorMessage = isset($response['error']) && is_string($response['error']) ? $response['error'] : '';
397 $response['message'] = $this->getTranslatedErrorMessage($errorMessage);
398 return $response;
399 }
400 $logData = [
401 'code' => $responseCode,
402 'error' => is_wp_error($result) ? $result->get_error_message() : $rawResponseBody,
403 ];
404 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('verifyAuthorizedSenderDomain API call failed.', $logData);
405
406 // translators: %d will be replaced by an error code
407 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
408
409 $error = is_array($responseBody) && isset($responseBody['error']) && is_string($responseBody['error'])
410 ? $responseBody['error']
411 : $fallbackError;
412 return $this->createErrorResponse((int)$responseCode, $error);
413 }
414
415 if (!is_array($responseBody)) {
416 $this->logInvalidDataFormat('verifyAuthorizedSenderDomain', $rawResponseBody);
417 return [];
418 }
419
420 $responseBody['status'] = self::RESPONSE_STATUS_OK;
421 return $responseBody;
422 }
423
424 public function setKey($apiKey) {
425 $this->apiKey = $apiKey;
426 }
427
428 public function getKey() {
429 return $this->apiKey;
430 }
431
432 public function getTranslatedErrorMessage(string $errorMessage): string {
433 switch ($errorMessage) {
434 case self::ERROR_MESSAGE_BANNED:
435 return __('Key is valid, but the action is forbidden.', 'mailpoet');
436 case self::ERROR_MESSAGE_INVALID_FROM:
437 return __('The email address is not authorized.', 'mailpoet');
438 case self::ERROR_MESSAGE_PENDING_APPROVAL:
439 return __('Key is valid, but not approved yet; you can send only to authorized email addresses at the moment.', 'mailpoet');
440 case self::ERROR_MESSAGE_DMRAC:
441 return __("Email violates Sender Domain's DMARC policy. Please set up sender authentication.", 'mailpoet');
442 case self::ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN:
443 return __('Email violates Sender Domain requirements. Please authenticate the sender domain.', 'mailpoet');
444 case self::ERROR_MESSAGE_UNAUTHORIZED:
445 return __('No valid API key provided.', 'mailpoet');
446 case self::ERROR_MESSAGE_INSUFFICIENT_PRIVILEGES:
447 return __('Insufficient privileges.', 'mailpoet');
448 case self::ERROR_MESSAGE_EMAIL_VOLUME_LIMIT_REACHED:
449 return __('Email volume limit reached.', 'mailpoet');
450 case self::ERROR_MESSAGE_SUBSCRIBERS_LIMIT_REACHED:
451 return __('Subscribers limit reached.', 'mailpoet');
452 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_NO_FREE:
453 return __('You cannot use a free email address. Please use an address from your website’s domain, for example.', 'mailpoet');
454 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_INVALID:
455 return __('Invalid email.', 'mailpoet');
456 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_ALREADY_ADDED:
457 return __('This email was already added to the list.', 'mailpoet');
458 case self::ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_NOT_FOUND:
459 return __('Domain not found.', 'mailpoet');
460 case self::ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_FAILED:
461 return __('Some DNS records were not set up correctly. Please check the records again. You may need to wait up to 24 hours for DNS changes to propagate.', 'mailpoet');
462 case self::ERROR_MESSAGE_SENDER_DOMAIN_INVALID:
463 return __('Invalid domain. Please enter a valid domain name.', 'mailpoet');
464 case self::ERROR_MESSAGE_SENDER_DOMAIN_ALREADY_ADDED:
465 return __('This domain was already added to the list.', 'mailpoet');
466 // when we don't match translation we return the origin
467 default:
468 return $errorMessage;
469 }
470 }
471
472 private function auth() {
473 return 'Basic ' . base64_encode('api:' . $this->apiKey);
474 }
475
476 private function request($url, $body, $method = 'POST') {
477 $params = [
478 'timeout' => $this->wp->applyFilters('mailpoet_bridge_api_request_timeout', self::REQUEST_TIMEOUT),
479 'httpversion' => '1.0',
480 'method' => $method,
481 'headers' => [
482 'Content-Type' => 'application/json',
483 'Authorization' => $this->auth(),
484 ],
485 'body' => $body !== null ? json_encode($body) : null,
486 ];
487 return $this->wp->wpRemotePost($url, $params);
488 }
489
490 private function logCurlError(WP_Error $error) {
491 // $this->curlHandle is set by setCurlHandle() from a WP Requests action; type is
492 // \CurlHandle on PHP 8+ and resource on PHP 7.4. PHPStan stubs (min PHP 7.4) only
493 // declare `resource` for curl_*(), so the PHP 8 path needs inline ignores.
494 /** @phpstan-ignore-next-line argument.type */
495 $errno = $this->curlHandle ? curl_errno($this->curlHandle) : 'n/a';
496 /** @phpstan-ignore-next-line argument.type */
497 $errMsg = $this->curlHandle ? curl_error($this->curlHandle) : $error->get_error_message();
498 /** @phpstan-ignore-next-line argument.type */
499 $info = $this->curlHandle ? curl_getinfo($this->curlHandle) : 'n/a';
500 $logData = [
501 'curl_errno' => $errno,
502 'curl_error' => $errMsg,
503 'curl_info' => $info,
504 ];
505 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_MSS)->error('requests-curl.failed', $logData);
506 }
507
508 private function logKeyCheckError(int $code, string $keyType): void {
509 $topic = LoggerFactory::TOPIC_MSS;
510 if ($keyType === self::KEY_CHECK_TYPE_PREMIUM) {
511 $topic = LoggerFactory::TOPIC_PREMIUM;
512 }
513
514 $logData = [
515 'http_code' => $code,
516 'home_url' => $this->wp->homeUrl(),
517 'key_type' => $keyType,
518 ];
519 $this->loggerFactory->getLogger($topic)->info('key-validation.failed', $logData);
520 }
521
522 private function logInvalidDataFormat(string $method, ?string $response = null): void {
523 $logData = [
524 'code' => json_last_error(),
525 'response' => $response,
526 ];
527 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error($method . ' API response was not in expected format.', $logData);
528 }
529
530 /**
531 * @return array{status: string, code: int, error: string, message: string}
532 */
533 private function createErrorResponse(int $responseCode, string $error, string $errorStatus = self::RESPONSE_STATUS_ERROR): array {
534 return [
535 'status' => $errorStatus,
536 'code' => $responseCode,
537 'error' => $error,
538 'message' => $this->getTranslatedErrorMessage($error),
539 ];
540 }
541 }
542