PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.2
MailPoet – Newsletters, Email Marketing, and Automation v5.34.2
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 5 days ago BouncesReportException.php 5 days ago index.php 3 years ago
API.php
561 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 ($this->wp->isWpError($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.
182 *
183 * @return array{recipients: string[], page: int, has_more: bool}
184 * @throws BouncesReportException The response status is carried on the
185 * exception code so callers can distinguish a rejected key (401/403), which
186 * no amount of retrying will fix, from a transient failure.
187 */
188 public function getBouncesReport(\DateTimeInterface $from, \DateTimeInterface $to, int $page = 1): array {
189 $utc = new \DateTimeZone('UTC');
190 $fromUtc = (new \DateTimeImmutable('@' . $from->getTimestamp()))->setTimezone($utc);
191 $toUtc = (new \DateTimeImmutable('@' . $to->getTimestamp()))->setTimezone($utc);
192
193 $url = $this->wp->addQueryArg(
194 [
195 'from' => $fromUtc->format(self::BOUNCES_REPORT_DATE_FORMAT),
196 'to' => $toUtc->format(self::BOUNCES_REPORT_DATE_FORMAT),
197 'p' => $page,
198 ],
199 $this->urlBouncesReport
200 );
201
202 $result = $this->request($url, null, 'GET');
203 $responseCode = (int)$this->wp->wpRemoteRetrieveResponseCode($result);
204 if ($responseCode !== 200) {
205 $isWpError = $this->wp->isWpError($result);
206 $logData = [
207 'code' => $responseCode,
208 'error' => $isWpError ? $result->get_error_message() : $this->wp->wpRemoteRetrieveBody($result),
209 ];
210 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('getBouncesReport API call failed.', $logData);
211 // The request never reached the service, so there is no status to report:
212 // say so rather than describing it as "response code 0". The code stays 0
213 // either way, which is what marks the failure as transient for callers.
214 $message = $isWpError
215 ? __('The bounces report request failed without a response', 'mailpoet')
216 // translators: %d is the HTTP response code.
217 : sprintf(__('The bounces report request failed with response code %d', 'mailpoet'), $responseCode);
218 throw BouncesReportException::create()
219 ->withCode($responseCode)
220 ->withMessage($message);
221 }
222 $body = $this->wp->wpRemoteRetrieveBody($result);
223 $data = json_decode($body, true);
224 if (!$this->isValidBouncesReport($data)) {
225 // A 200 with a malformed payload must not be treated as a successful empty
226 // page: that would advance the report window and silently skip bounces.
227 $this->logInvalidDataFormat('getBouncesReport', is_string($body) ? $body : null);
228 throw BouncesReportException::create()
229 ->withMessage(__('The bounces report response was not in the expected format', 'mailpoet'));
230 }
231 $data['recipients'] = array_map(
232 function (array $recipient): string {
233 return $recipient['email'];
234 },
235 $data['recipients']
236 );
237 return $data;
238 }
239
240 /**
241 * @param mixed $data
242 * @phpstan-assert-if-true array{recipients: array<array{email: string}>, page: int, has_more: bool} $data
243 */
244 private function isValidBouncesReport($data): bool {
245 if (
246 !is_array($data)
247 || !isset($data['recipients'], $data['page'], $data['has_more'])
248 || !is_array($data['recipients'])
249 || !is_int($data['page'])
250 || !is_bool($data['has_more'])
251 ) {
252 return false;
253 }
254 foreach ($data['recipients'] as $recipient) {
255 if (!is_array($recipient) || !isset($recipient['email']) || !is_string($recipient['email'])) {
256 return false;
257 }
258 }
259 return true;
260 }
261
262 public function updateSubscriberCount($count): bool {
263 $result = $this->request(
264 $this->urlStats,
265 ['subscriber_count' => (int)$count],
266 'PUT'
267 );
268 $code = $this->wp->wpRemoteRetrieveResponseCode($result);
269 $isSuccess = $code === self::RESPONSE_CODE_STATS_SAVED;
270 if (!$isSuccess) {
271 $logData = [
272 'code' => $code,
273 'error' => $this->wp->isWpError($result) ? $result->get_error_message() : null,
274 ];
275 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('Stats API call failed.', $logData);
276 }
277 return $isSuccess;
278 }
279
280 public function getAuthorizedEmailAddresses(): ?array {
281 $result = $this->request(
282 $this->urlAuthorizedEmailAddresses,
283 null,
284 'GET'
285 );
286 if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
287 return null;
288 }
289 $data = json_decode($this->wp->wpRemoteRetrieveBody($result), true);
290 return is_array($data) ? $data : null;
291 }
292
293 /**
294 * Create Authorized Email Address
295 *
296 * @param string $emailAddress
297 * @return array{status: string, code?: int, error?: string, message?: string}
298 */
299 public function createAuthorizedEmailAddress(string $emailAddress): array {
300 $body = ['email' => $emailAddress];
301 $result = $this->request(
302 $this->urlAuthorizedEmailAddresses,
303 $body
304 );
305
306 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
307
308 if ($responseCode !== self::RESPONSE_CODE_CREATED) {
309 $errorBody = $this->wp->wpRemoteRetrieveBody($result);
310 $logData = [
311 'code' => $responseCode,
312 'error' => $this->wp->isWpError($result) ? $result->get_error_message() : $errorBody,
313 ];
314 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('CreateAuthorizedEmailAddress API call failed.', $logData);
315
316 $errorResponseData = json_decode($errorBody, true);
317 // translators: %d is the error code.
318 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
319
320 $error = is_array($errorResponseData) && isset($errorResponseData['error']) && is_string($errorResponseData['error'])
321 ? $errorResponseData['error']
322 : $fallbackError;
323 return $this->createErrorResponse((int)$responseCode, $error);
324 }
325
326 return ['status' => self::RESPONSE_STATUS_OK];
327 }
328
329 /**
330 * Get a list of sender domains
331 * Fetched from API
332 * @see https://github.com/mailpoet/services-bridge#sender-domains
333 */
334 public function getAuthorizedSenderDomains(): ?array {
335 $result = $this->request(
336 $this->urlAuthorizedSenderDomains,
337 null,
338 'GET'
339 );
340 if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
341 return null;
342 }
343 $rawData = $this->wp->wpRemoteRetrieveBody($result);
344 $data = json_decode($rawData, true);
345 if (!is_array($data)) {
346 $this->logInvalidDataFormat('getAuthorizedSenderDomains', $rawData);
347 return null;
348 }
349 return $data;
350 }
351
352 /**
353 * Create Sender domain record
354 * Done via API
355 * Returns same response se sender_domain_verify @see https://github.com/mailpoet/services-bridge#verify-a-sender-domain
356 */
357 public function createAuthorizedSenderDomain(string $domain): array {
358 $body = ['domain' => strtolower($domain)];
359 $result = $this->request(
360 $this->urlAuthorizedSenderDomains,
361 $body
362 );
363
364 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
365 $rawResponseBody = $this->wp->wpRemoteRetrieveBody($result);
366
367 $responseBody = json_decode($rawResponseBody, true);
368
369 if ($responseCode !== self::RESPONSE_CODE_CREATED) {
370 $logData = [
371 'code' => $responseCode,
372 'error' => is_wp_error($result) ? $result->get_error_message() : $rawResponseBody,
373 ];
374 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('createAuthorizedSenderDomain API call failed.', $logData);
375
376 // translators: %d will be replaced by an error code
377 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
378
379 $error = is_array($responseBody) && isset($responseBody['error']) && is_string($responseBody['error'])
380 ? $responseBody['error']
381 : $fallbackError;
382 return $this->createErrorResponse((int)$responseCode, $error);
383 }
384
385 if (!is_array($responseBody)) {
386 $this->logInvalidDataFormat('createAuthorizedSenderDomain', $rawResponseBody);
387 return [];
388 }
389
390 $responseBody['status'] = self::RESPONSE_STATUS_OK;
391 return $responseBody;
392 }
393
394 /**
395 * Verify Sender Domain records
396 * returns an Array of DNS response or an array of error
397 * @see https://github.com/mailpoet/services-bridge#verify-a-sender-domain
398 */
399 public function verifyAuthorizedSenderDomain(string $domain): array {
400 $url = $this->urlAuthorizedSenderDomainVerification . '/' . urlencode(strtolower($domain));
401 $result = $this->request(
402 $url,
403 null
404 );
405
406 $responseCode = $this->wp->wpRemoteRetrieveResponseCode($result);
407 $rawResponseBody = $this->wp->wpRemoteRetrieveBody($result);
408
409 $responseBody = json_decode($rawResponseBody, true);
410 if ($responseCode !== 200) {
411 if ($responseCode === 400) {
412 // we need to return the body as it is, but for consistency we add status and translated error message
413 $response = is_array($responseBody) ? $responseBody : [];
414 $response['status'] = self::RESPONSE_STATUS_ERROR;
415 $errorMessage = isset($response['error']) && is_string($response['error']) ? $response['error'] : '';
416 $response['message'] = $this->getTranslatedErrorMessage($errorMessage);
417 return $response;
418 }
419 $logData = [
420 'code' => $responseCode,
421 'error' => $this->wp->isWpError($result) ? $result->get_error_message() : $rawResponseBody,
422 ];
423 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error('verifyAuthorizedSenderDomain API call failed.', $logData);
424
425 // translators: %d will be replaced by an error code
426 $fallbackError = sprintf(__('An error has happened while performing a request, the server has responded with response code %d', 'mailpoet'), $responseCode);
427
428 $error = is_array($responseBody) && isset($responseBody['error']) && is_string($responseBody['error'])
429 ? $responseBody['error']
430 : $fallbackError;
431 return $this->createErrorResponse((int)$responseCode, $error);
432 }
433
434 if (!is_array($responseBody)) {
435 $this->logInvalidDataFormat('verifyAuthorizedSenderDomain', $rawResponseBody);
436 return [];
437 }
438
439 $responseBody['status'] = self::RESPONSE_STATUS_OK;
440 return $responseBody;
441 }
442
443 public function setKey($apiKey) {
444 $this->apiKey = $apiKey;
445 }
446
447 public function getKey() {
448 return $this->apiKey;
449 }
450
451 public function getTranslatedErrorMessage(string $errorMessage): string {
452 switch ($errorMessage) {
453 case self::ERROR_MESSAGE_BANNED:
454 return __('Key is valid, but the action is forbidden.', 'mailpoet');
455 case self::ERROR_MESSAGE_INVALID_FROM:
456 return __('The email address is not authorized.', 'mailpoet');
457 case self::ERROR_MESSAGE_PENDING_APPROVAL:
458 return __('Key is valid, but not approved yet; you can send only to authorized email addresses at the moment.', 'mailpoet');
459 case self::ERROR_MESSAGE_DMRAC:
460 return __("Email violates Sender Domain's DMARC policy. Please set up sender authentication.", 'mailpoet');
461 case self::ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN:
462 return __('Email violates Sender Domain requirements. Please authenticate the sender domain.', 'mailpoet');
463 case self::ERROR_MESSAGE_UNAUTHORIZED:
464 return __('No valid API key provided.', 'mailpoet');
465 case self::ERROR_MESSAGE_INSUFFICIENT_PRIVILEGES:
466 return __('Insufficient privileges.', 'mailpoet');
467 case self::ERROR_MESSAGE_EMAIL_VOLUME_LIMIT_REACHED:
468 return __('Email volume limit reached.', 'mailpoet');
469 case self::ERROR_MESSAGE_SUBSCRIBERS_LIMIT_REACHED:
470 return __('Subscribers limit reached.', 'mailpoet');
471 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_NO_FREE:
472 return __('You cannot use a free email address. Please use an address from your website’s domain, for example.', 'mailpoet');
473 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_INVALID:
474 return __('Invalid email.', 'mailpoet');
475 case self::ERROR_MESSAGE_AUTHORIZED_EMAIL_ALREADY_ADDED:
476 return __('This email was already added to the list.', 'mailpoet');
477 case self::ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_NOT_FOUND:
478 return __('Domain not found.', 'mailpoet');
479 case self::ERROR_MESSAGE_AUTHORIZED_DOMAIN_VERIFY_FAILED:
480 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');
481 case self::ERROR_MESSAGE_SENDER_DOMAIN_INVALID:
482 return __('Invalid domain. Please enter a valid domain name.', 'mailpoet');
483 case self::ERROR_MESSAGE_SENDER_DOMAIN_ALREADY_ADDED:
484 return __('This domain was already added to the list.', 'mailpoet');
485 // when we don't match translation we return the origin
486 default:
487 return $errorMessage;
488 }
489 }
490
491 private function auth() {
492 return 'Basic ' . base64_encode('api:' . $this->apiKey);
493 }
494
495 private function request($url, $body, $method = 'POST') {
496 $params = [
497 'timeout' => $this->wp->applyFilters('mailpoet_bridge_api_request_timeout', self::REQUEST_TIMEOUT),
498 'httpversion' => '1.0',
499 'method' => $method,
500 'headers' => [
501 'Content-Type' => 'application/json',
502 'Authorization' => $this->auth(),
503 ],
504 'body' => $body !== null ? json_encode($body) : null,
505 ];
506 return $this->wp->wpRemotePost($url, $params);
507 }
508
509 private function logCurlError(WP_Error $error) {
510 // $this->curlHandle is set by setCurlHandle() from a WP Requests action; type is
511 // \CurlHandle on PHP 8+ and resource on PHP 7.4. PHPStan stubs (min PHP 7.4) only
512 // declare `resource` for curl_*(), so the PHP 8 path needs inline ignores.
513 /** @phpstan-ignore-next-line argument.type */
514 $errno = $this->curlHandle ? curl_errno($this->curlHandle) : 'n/a';
515 /** @phpstan-ignore-next-line argument.type */
516 $errMsg = $this->curlHandle ? curl_error($this->curlHandle) : $error->get_error_message();
517 /** @phpstan-ignore-next-line argument.type */
518 $info = $this->curlHandle ? curl_getinfo($this->curlHandle) : 'n/a';
519 $logData = [
520 'curl_errno' => $errno,
521 'curl_error' => $errMsg,
522 'curl_info' => $info,
523 ];
524 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_MSS)->error('requests-curl.failed', $logData);
525 }
526
527 private function logKeyCheckError(int $code, string $keyType): void {
528 $topic = LoggerFactory::TOPIC_MSS;
529 if ($keyType === self::KEY_CHECK_TYPE_PREMIUM) {
530 $topic = LoggerFactory::TOPIC_PREMIUM;
531 }
532
533 $logData = [
534 'http_code' => $code,
535 'home_url' => $this->wp->homeUrl(),
536 'key_type' => $keyType,
537 ];
538 $this->loggerFactory->getLogger($topic)->info('key-validation.failed', $logData);
539 }
540
541 private function logInvalidDataFormat(string $method, ?string $response = null): void {
542 $logData = [
543 'code' => json_last_error(),
544 'response' => $response,
545 ];
546 $this->loggerFactory->getLogger(LoggerFactory::TOPIC_BRIDGE)->error($method . ' API response was not in expected format.', $logData);
547 }
548
549 /**
550 * @return array{status: string, code: int, error: string, message: string}
551 */
552 private function createErrorResponse(int $responseCode, string $error, string $errorStatus = self::RESPONSE_STATUS_ERROR): array {
553 return [
554 'status' => $errorStatus,
555 'code' => $responseCode,
556 'error' => $error,
557 'message' => $this->getTranslatedErrorMessage($error),
558 ];
559 }
560 }
561