This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,249 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\Methods\Common\BlacklistCheck;
use MailPoet\Mailer\Methods\ErrorMappers\AmazonSESMapper;
use MailPoet\Util\Url;
use MailPoet\WP\Functions as WPFunctions;
use PHPMailer\PHPMailer\PHPMailer;
class AmazonSES extends PHPMailerMethod {
/** @var string */
public $awsAccessKey;
/** @var string */
public $awsSecretKey;
/** @var string */
public $awsRegion;
/** @var string */
public $awsEndpoint;
/** @var string */
public $awsSigningAlgorithm;
/** @var string */
public $awsService;
/** @var string */
public $awsTerminationString;
/** @var string */
public $hashAlgorithm;
/** @var string */
public $url;
/** @var string */
public $rawMessage;
/** @var string */
public $date;
/** @var string */
public $dateWithoutTime;
/** @var string[] */
private $availableRegions = [
'US East (N. Virginia)' => 'us-east-1',
'US East (Ohio)' => 'us-east-2',
'US West (N. California)' => 'us-west-1',
'US West (Oregon)' => 'us-west-2',
'EU (Ireland)' => 'eu-west-1',
'EU (London)' => 'eu-west-2',
'EU (Paris)' => 'eu-west-3',
'EU (Milan)' => 'eu-south-1',
'EU (Frankfurt)' => 'eu-central-1',
'EU (Stockholm)' => 'eu-north-1',
'Canada (Central)' => 'ca-central-1',
'China (Beijing)' => 'cn-north-1',
'China (Ningxia)' => 'cn-northwest-1',
'Africa (Cape Town)' => 'af-south-1',
'Asia Pacific (Hong Kong)' => 'ap-east-1',
'Asia Pacific (Jakarta)' => 'ap-southeast-3',
'Asia Pacific (Mumbai)' => 'ap-south-1',
'Asia Pacific (Seoul)' => 'ap-northeast-2',
'Asia Pacific (Osaka)' => 'ap-northeast-3',
'Asia Pacific (Singapore)' => 'ap-southeast-1',
'Asia Pacific (Sydney)' => 'ap-southeast-2',
'Asia Pacific (Tokyo)' => 'ap-northeast-1',
'Middle East (Bahrain)' => 'me-south-1',
'South America (Sao Paulo)' => 'sa-east-1',
'AWS GovCloud (US)' => 'us-gov-west-1',
];
/** @var AmazonSESMapper */
protected $errorMapper;
/** @var WPFunctions */
protected $wp;
public function __construct(
$region,
$accessKey,
$secretKey,
$sender,
$replyTo,
$returnPath,
AmazonSESMapper $errorMapper,
WPFunctions $wp,
Url $urlUtils
) {
$this->awsAccessKey = $accessKey;
$this->awsSecretKey = $secretKey;
$this->awsRegion = (in_array($region, $this->availableRegions)) ? $region : false;
if (!$this->awsRegion) {
throw new \Exception(__('Unsupported Amazon SES region', 'mailpoet'));
}
$this->awsEndpoint = sprintf('email.%s.amazonaws.com', $this->awsRegion);
$this->awsSigningAlgorithm = 'AWS4-HMAC-SHA256';
$this->awsService = 'ses';
$this->awsTerminationString = 'aws4_request';
$this->hashAlgorithm = 'sha256';
$this->url = 'https://' . $this->awsEndpoint;
$this->sender = $sender;
$this->replyTo = $replyTo;
$this->returnPath = $returnPath;
$this->date = gmdate('Ymd\THis\Z');
$this->dateWithoutTime = gmdate('Ymd');
$this->errorMapper = $errorMapper;
$this->wp = $wp;
$this->urlUtils = $urlUtils;
$this->blacklist = new BlacklistCheck();
$this->mailer = $this->buildMailer();
}
public function send($newsletter, $subscriber, $extraParams = []): array {
if ($this->blacklist->isBlacklisted($subscriber)) {
$error = $this->errorMapper->getBlacklistError($subscriber);
return Mailer::formatMailerErrorResult($error);
}
try {
$result = $this->wp->wpRemotePost(
$this->url,
$this->request($newsletter, $subscriber, $extraParams)
);
} catch (\Exception $e) {
$error = $this->errorMapper->getErrorFromException($e, $subscriber);
return Mailer::formatMailerErrorResult($error);
}
if (is_wp_error($result)) {
$error = $this->errorMapper->getConnectionError($result->get_error_message());
return Mailer::formatMailerErrorResult($error);
}
if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
$response = simplexml_load_string($this->wp->wpRemoteRetrieveBody($result));
$error = $this->errorMapper->getErrorFromResponse($response, $subscriber);
return Mailer::formatMailerErrorResult($error);
}
return Mailer::formatMailerSendSuccessResult();
}
public function buildMailer(): PHPMailer {
return new PHPMailer(true);
}
public function getBody($newsletter, $subscriber, $extraParams = []) {
/* Configure mailer and call preSend() method to prepare message */
$mailer = $this->configureMailerWithMessage($newsletter, $subscriber, $extraParams);
$mailer->preSend();
/* When message is prepared, we can get the raw message */
$this->rawMessage = $mailer->getSentMIMEMessage();
return [
'Action' => 'SendRawEmail',
'Version' => '2010-12-01',
'Source' => $this->sender['from_name_email'],
'RawMessage.Data' => $this->encodeMessage($this->rawMessage),
];
}
public function encodeMessage(string $message) {
return base64_encode($message);
}
public function request($newsletter, $subscriber, $extraParams = []) {
$body = array_map('urlencode', $this->getBody($newsletter, $subscriber, $extraParams));
return [
'timeout' => 10,
'httpversion' => '1.1',
'method' => 'POST',
'headers' => [
'Host' => $this->awsEndpoint,
'Authorization' => $this->signRequest($body),
'X-Amz-Date' => $this->date,
],
'body' => urldecode(http_build_query($body, '', '&')),
];
}
public function signRequest($body) {
$stringToSign = $this->createStringToSign(
$this->getCredentialScope(),
$this->getCanonicalRequest($body)
);
$signature = hash_hmac(
$this->hashAlgorithm,
$stringToSign,
$this->getSigningKey()
);
return sprintf(
'%s Credential=%s/%s, SignedHeaders=host;x-amz-date, Signature=%s',
$this->awsSigningAlgorithm,
$this->awsAccessKey,
$this->getCredentialScope(),
$signature
);
}
public function getCredentialScope() {
return sprintf(
'%s/%s/%s/%s',
$this->dateWithoutTime,
$this->awsRegion,
$this->awsService,
$this->awsTerminationString
);
}
public function getCanonicalRequest($body) {
return implode("\n", [
'POST',
'/',
'',
'host:' . $this->awsEndpoint,
'x-amz-date:' . $this->date,
'',
'host;x-amz-date',
hash($this->hashAlgorithm, urldecode(http_build_query($body, '', '&'))),
]);
}
public function createStringToSign($credentialScope, $canonicalRequest) {
return implode("\n", [
$this->awsSigningAlgorithm,
$this->date,
$credentialScope,
hash($this->hashAlgorithm, $canonicalRequest),
]);
}
public function getSigningKey() {
$dateKey = hash_hmac(
$this->hashAlgorithm,
$this->dateWithoutTime,
'AWS4' . $this->awsSecretKey,
true
);
$regionKey = hash_hmac(
$this->hashAlgorithm,
$this->awsRegion,
$dateKey,
true
);
$serviceKey = hash_hmac(
$this->hashAlgorithm,
$this->awsService,
$regionKey,
true
);
return hash_hmac(
$this->hashAlgorithm,
$this->awsTerminationString,
$serviceKey,
true
);
}
}
@@ -0,0 +1,35 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods\Common;
if (!defined('ABSPATH')) exit;
use MailPoet\Subscription\Blacklist;
class BlacklistCheck {
/** @var Blacklist */
private $blacklist;
public function __construct(
Blacklist $blacklist = null
) {
if (is_null($blacklist)) {
$blacklist = new Blacklist();
}
$this->blacklist = $blacklist;
}
public function isBlacklisted($subscriber) {
$email = $this->getSubscriberEmailForBlacklistCheck($subscriber);
return $this->blacklist->isBlacklisted($email);
}
private function getSubscriberEmailForBlacklistCheck($subscriberString) {
preg_match('!(?P<name>.*?)\s<(?P<email>.*?)>!', $subscriberString, $subscriberData);
if (!isset($subscriberData['email'])) {
return $subscriberString;
}
return $subscriberData['email'];
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\SubscriberError;
class AmazonSESMapper {
use BlacklistErrorMapperTrait;
use ConnectionErrorMapperTrait;
const METHOD = Mailer::METHOD_AMAZONSES;
public function getErrorFromException(\Exception $e, $subscriber) {
$level = MailerError::LEVEL_HARD;
if (strpos($e->getMessage(), 'Invalid address') !== false && strpos($e->getMessage(), '(to):') !== false) {
$level = MailerError::LEVEL_SOFT;
}
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, $level, $e->getMessage(), null, $subscriberErrors);
}
/**
* @see https://docs.aws.amazon.com/ses/latest/DeveloperGuide/api-error-codes.html
* @return MailerError
*/
public function getErrorFromResponse($response, $subscriber) {
$message = ($response) ?
$response->Error->Message->__toString() : // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
// translators: %s is the name of the method.
sprintf(__('%s has returned an unknown error.', 'mailpoet'), Mailer::METHOD_AMAZONSES);
$level = MailerError::LEVEL_HARD;
if ($response && $response->Error->Code->__toString() === 'MessageRejected') { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$level = MailerError::LEVEL_SOFT;
}
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, $level, $message, null, $subscriberErrors);
}
}
@@ -0,0 +1,18 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\SubscriberError;
trait BlacklistErrorMapperTrait {
public function getBlacklistError($subscriber) {
// translators: %s is the name of the method.
$message = sprintf(__('%s has returned an unknown error.', 'mailpoet'), self::METHOD);
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, MailerError::LEVEL_SOFT, $message, null, $subscriberErrors);
}
}
@@ -0,0 +1,18 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\MailerError;
trait ConnectionErrorMapperTrait {
public function getConnectionError($message) {
return new MailerError(
MailerError::OPERATION_CONNECT,
MailerError::LEVEL_HARD,
$message
);
}
}
@@ -0,0 +1,280 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use InvalidArgumentException;
use MailPoet\Config\ServicesChecker;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\SubscriberError;
use MailPoet\Services\Bridge\API;
use MailPoet\Util\Helpers;
use MailPoet\Util\License\Features\Subscribers as SubscribersFeature;
use MailPoet\Util\Notices\PendingApprovalNotice;
use MailPoet\Util\Notices\UnauthorizedEmailNotice;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
class MailPoetMapper {
use BlacklistErrorMapperTrait;
use ConnectionErrorMapperTrait;
const METHOD = Mailer::METHOD_MAILPOET;
const TEMPORARY_UNAVAILABLE_RETRY_INTERVAL = 300; // seconds
/** @var ServicesChecker */
private $servicesChecker;
/** @var SubscribersFeature */
private $subscribersFeature;
/** @var WPFunctions */
private $wp;
/** @var PendingApprovalNotice */
private $pendingApprovalNotice;
public function __construct(
ServicesChecker $servicesChecker,
SubscribersFeature $subscribers,
WPFunctions $wp,
PendingApprovalNotice $pendingApprovalNotice
) {
$this->servicesChecker = $servicesChecker;
$this->subscribersFeature = $subscribers;
$this->wp = $wp;
$this->pendingApprovalNotice = $pendingApprovalNotice;
}
public function getInvalidApiKeyError() {
return new MailerError(
MailerError::OPERATION_SEND,
MailerError::LEVEL_HARD,
__('MailPoet API key is invalid!', 'mailpoet')
);
}
public function getErrorForResult(array $result, $subscribers, $sender = null, $newsletter = null) {
$level = MailerError::LEVEL_HARD;
$operation = MailerError::OPERATION_SEND;
$retryInterval = null;
$subscribersErrors = [];
$resultCode = !empty($result['code']) ? $result['code'] : null;
switch ($resultCode) {
case API::RESPONSE_CODE_NOT_ARRAY:
$message = __('JSON input is not an array', 'mailpoet');
break;
case API::RESPONSE_CODE_PAYLOAD_ERROR:
$resultParsed = json_decode($result['message'], true);
$message = __('Error while sending.', 'mailpoet');
if (is_array($resultParsed)) {
try {
$subscribersErrors = $this->getSubscribersErrors($resultParsed, $subscribers);
$level = MailerError::LEVEL_SOFT;
} catch (InvalidArgumentException $e) {
$message .= ' ' . $e->getMessage();
}
break;
}
$appendedMessage = ' ' . $result['message'];
if (isset($result['error']) && in_array($result['error'], [API::ERROR_MESSAGE_DMRAC, API::ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN])) {
$appendedMessage = $this->getDmarcMessage($result, $sender);
if ($result['error'] === API::ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN) {
$operation = MailerError::OPERATION_DOMAIN_AUTHORIZATION;
$level = MailerError::LEVEL_SOFT;
}
}
$message .= $appendedMessage;
break;
case API::RESPONSE_CODE_INTERNAL_SERVER_ERROR:
case API::RESPONSE_CODE_BAD_GATEWAY:
case API::RESPONSE_CODE_TEMPORARY_UNAVAILABLE:
case API::RESPONSE_CODE_GATEWAY_TIMEOUT:
$message = __('Email service is temporarily not available, please try again in a few minutes.', 'mailpoet');
$retryInterval = self::TEMPORARY_UNAVAILABLE_RETRY_INTERVAL;
break;
case API::RESPONSE_CODE_CAN_NOT_SEND:
[$operation, $message] = $this->getCanNotSendError($result, $sender);
break;
case API::RESPONSE_CODE_KEY_INVALID:
case API::RESPONSE_CODE_PAYLOAD_TOO_BIG:
default:
$message = $result['message'];
}
return new MailerError($operation, $level, $message, $retryInterval, $subscribersErrors);
}
private function getSubscribersErrors($resultParsed, $subscribers) {
$errors = [];
foreach ($resultParsed as $resultError) {
if (!is_array($resultError) || !isset($resultError['index']) || !isset($subscribers[$resultError['index']])) {
throw new InvalidArgumentException(__('Invalid MSS response format.', 'mailpoet'));
}
$subscriberErrors = [];
if (isset($resultError['errors']) && is_array($resultError['errors'])) {
array_walk_recursive($resultError['errors'], function($item) use (&$subscriberErrors) {
$subscriberErrors[] = $item;
});
}
$message = join(', ', $subscriberErrors);
$errors[] = new SubscriberError($subscribers[$resultError['index']], $message);
}
return $errors;
}
private function getUnauthorizedEmailMessage($sender) {
$email = $sender ? $sender['from_email'] : __('Unknown address', 'mailpoet');
$validationError = ['invalid_sender_address' => $email];
$notice = new UnauthorizedEmailNotice($this->wp, null);
$message = $notice->getMessage($validationError);
return $message;
}
private function getSubscribersLimitReachedMessage(): string {
$message = __('You have reached the subscriber limit of your plan. Please [link1]upgrade your plan[/link1], or [link2]contact our support team[/link2] if you have any questions.', 'mailpoet');
$message = Helpers::replaceLinkTags(
$message,
'https://account.mailpoet.com/account/',
[
'target' => '_blank',
'rel' => 'noopener noreferrer',
],
'link1'
);
$message = Helpers::replaceLinkTags(
$message,
'https://www.mailpoet.com/support/',
[
'target' => '_blank',
'rel' => 'noopener noreferrer',
],
'link2'
);
return "{$message}<br/>";
}
private function getAccountBannedMessage(): string {
$message = __('The MailPoet Sending Service has been temporarily suspended for your site due to a high number of [link1]undeliverable emails or emails marked as unwanted by recipients[/link1]. Please [link2]contact our support team[/link2] to resolve the issue.', 'mailpoet');
$message = Helpers::replaceLinkTags(
$message,
'https://kb.mailpoet.com/article/231-sending-does-not-work#suspended',
[
'target' => '_blank',
'rel' => 'noopener noreferrer',
],
'link1'
);
$message = Helpers::replaceLinkTags(
$message,
'https://www.mailpoet.com/support-for-banned-users/',
[
'target' => '_blank',
'rel' => 'noopener noreferrer',
],
'link2'
);
return "{$message}<br/>";
}
private function getDmarcMessage($result, $sender): string {
$messageToAppend = __('[link1]Click here to start the authentication[/link1].', 'mailpoet');
$senderEmail = $sender['from_email'] ?? '';
$appendMessage = Helpers::replaceLinkTags(
$messageToAppend,
'#',
[
'class' => 'mailpoet-js-button-authorize-email-and-sender-domain',
'data-email' => $senderEmail,
'data-type' => 'domain',
'rel' => 'noopener noreferrer',
],
'link1'
);
$final = ' ' . $result['message'] . ' ' . $appendMessage;
return $final;
}
private function getEmailVolumeLimitReachedMessage(): string {
$partialApiKey = $this->servicesChecker->generatePartialApiKey();
$emailVolumeLimit = $this->subscribersFeature->getEmailVolumeLimit();
$date = Carbon::now()->startOfMonth()->addMonth();
if ($emailVolumeLimit) {
$message = sprintf(
// translators: %1$s is email volume limit and %2$s the date when you can resume sending.
__('You have sent more emails this month than your MailPoet plan includes (%1$s), and sending has been temporarily paused. To continue sending with MailPoet Sending Service please [link]upgrade your plan[/link], or wait until sending is automatically resumed on %2$s.', 'mailpoet'),
$emailVolumeLimit,
$this->wp->dateI18n($this->wp->getOption('date_format'), $date->getTimestamp())
);
} else {
$message = sprintf(
// translators: %1$s the date when you can resume sending.
__('You have sent more emails this month than your MailPoet plan includes, and sending has been temporarily paused. To continue sending with MailPoet Sending Service please [link]upgrade your plan[/link], or wait until sending is automatically resumed on %1$s.', 'mailpoet'),
$this->wp->dateI18n($this->wp->getOption('date_format'), $date->getTimestamp())
);
}
$message = Helpers::replaceLinkTags(
$message,
"https://account.mailpoet.com/orders/upgrade/{$partialApiKey}",
[
'target' => '_blank',
'rel' => 'noopener noreferrer',
]
);
return "{$message}<br/>";
}
/**
* Returns error $message and $operation for API::RESPONSE_CODE_CAN_NOT_SEND
*/
private function getCanNotSendError(array $result, $sender): array {
if ($result['error'] === API::ERROR_MESSAGE_PENDING_APPROVAL) {
$operation = MailerError::OPERATION_PENDING_APPROVAL;
$message = $this->pendingApprovalNotice->getPendingApprovalMessage() . '<br/>';
return [$operation, $message];
}
// Backward compatibility for older blocked keys.
// Exceeded subscribers limit used to use the same error message as insufficient privileges.
// We can change the message to "Insufficient privileges" like wording a couple of months after releasing SHOP-1228
if ($result['error'] === API::ERROR_MESSAGE_INSUFFICIENT_PRIVILEGES) {
$operation = MailerError::OPERATION_INSUFFICIENT_PRIVILEGES;
$message = $this->getSubscribersLimitReachedMessage();
return [$operation, $message];
}
if ($result['error'] === API::ERROR_MESSAGE_SUBSCRIBERS_LIMIT_REACHED) {
$operation = MailerError::OPERATION_SUBSCRIBER_LIMIT_REACHED;
$message = $this->getSubscribersLimitReachedMessage();
return [$operation, $message];
}
if ($result['error'] === API::ERROR_MESSAGE_EMAIL_VOLUME_LIMIT_REACHED) {
$operation = MailerError::OPERATION_EMAIL_LIMIT_REACHED;
$message = $this->getEmailVolumeLimitReachedMessage();
return [$operation, $message];
}
if ($result['error'] === API::ERROR_MESSAGE_INVALID_FROM) {
$operation = MailerError::OPERATION_AUTHORIZATION;
$message = $this->getUnauthorizedEmailMessage($sender);
return [$operation, $message];
}
$message = $this->getAccountBannedMessage();
$operation = MailerError::OPERATION_SEND;
return [$operation, $message];
}
}
@@ -0,0 +1,18 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
class PHPMailMapper extends PHPMailerMapper {
use BlacklistErrorMapperTrait;
public const METHOD = Mailer::METHOD_PHPMAIL;
protected function getMethodName(): string {
return self::METHOD;
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\SubscriberError;
abstract class PHPMailerMapper {
use ConnectionErrorMapperTrait;
public function getErrorFromException(\Exception $e, $subscriber) {
$level = MailerError::LEVEL_HARD;
if (strpos($e->getMessage(), 'Invalid address') === 0) {
$level = MailerError::LEVEL_SOFT;
}
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, $level, $e->getMessage(), null, $subscriberErrors);
}
public function getErrorForSubscriber($subscriber) {
// translators: %s is the name of the method.
$message = sprintf(__('%s has returned an unknown error.', 'mailpoet'), $this->getMethodName());
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, MailerError::LEVEL_HARD, $message, null, $subscriberErrors);
}
abstract protected function getMethodName(): string;
}
@@ -0,0 +1,18 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
class SMTPMapper extends PHPMailerMapper {
use BlacklistErrorMapperTrait;
public const METHOD = Mailer::METHOD_SMTP;
protected function getMethodName(): string {
return self::METHOD;
}
}
@@ -0,0 +1,31 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods\ErrorMappers;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\MailerError;
use MailPoet\Mailer\SubscriberError;
class SendGridMapper {
use BlacklistErrorMapperTrait;
use ConnectionErrorMapperTrait;
const METHOD = Mailer::METHOD_SENDGRID;
public function getErrorFromResponse($response, $subscriber) {
$response = (!empty($response['errors'][0])) ?
$response['errors'][0] :
// translators: %s is the name of the method.
sprintf(__('%s has returned an unknown error.', 'mailpoet'), Mailer::METHOD_SENDGRID);
$level = MailerError::LEVEL_HARD;
if (strpos($response, 'Invalid email address') === 0) {
$level = MailerError::LEVEL_SOFT;
}
$subscriberErrors = [new SubscriberError($subscriber, null)];
return new MailerError(MailerError::OPERATION_SEND, $level, $response, null, $subscriberErrors);
}
}
@@ -0,0 +1 @@
<?php
@@ -0,0 +1,186 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\ServicesChecker;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\Methods\Common\BlacklistCheck;
use MailPoet\Mailer\Methods\ErrorMappers\MailPoetMapper;
use MailPoet\Services\AuthorizedEmailsController;
use MailPoet\Services\Bridge;
use MailPoet\Services\Bridge\API;
use MailPoet\Util\Url;
class MailPoet implements MailerMethod {
public $api;
public $sender;
public $replyTo;
public $servicesChecker;
/** @var AuthorizedEmailsController */
private $authorizedEmailsController;
/** @var MailPoetMapper */
private $errorMapper;
/** @var BlacklistCheck */
private $blacklist;
/*** @var Url */
private $url;
/** @var Bridge */
private $bridge;
public function __construct(
$apiKey,
$sender,
$replyTo,
MailPoetMapper $errorMapper,
AuthorizedEmailsController $authorizedEmailsController,
Bridge $bridge,
Url $url
) {
$this->api = new API($apiKey);
$this->sender = $sender;
$this->replyTo = $replyTo;
$this->servicesChecker = new ServicesChecker();
$this->errorMapper = $errorMapper;
$this->bridge = $bridge;
$this->authorizedEmailsController = $authorizedEmailsController;
$this->blacklist = new BlacklistCheck();
$this->url = $url;
}
public function send($newsletter, $subscriber, $extraParams = []): array {
if ($this->servicesChecker->isMailPoetAPIKeyValid() === false) {
return Mailer::formatMailerErrorResult($this->errorMapper->getInvalidApiKeyError());
}
$subscribersForBlacklistCheck = is_array($subscriber) ? $subscriber : [$subscriber];
foreach ($subscribersForBlacklistCheck as $sub) {
if ($this->blacklist->isBlacklisted($sub)) {
$error = $this->errorMapper->getBlacklistError($sub);
return Mailer::formatMailerErrorResult($error);
}
}
$messageBody = $this->getBody($newsletter, $subscriber, $extraParams);
$result = $this->api->sendMessages($messageBody);
switch ($result['status']) {
case API::SENDING_STATUS_CONNECTION_ERROR:
$error = $this->errorMapper->getConnectionError($result['message']);
return Mailer::formatMailerErrorResult($error);
case API::SENDING_STATUS_SEND_ERROR:
$error = $this->processSendError($result, $subscriber, $newsletter);
return Mailer::formatMailerErrorResult($error);
case API::RESPONSE_STATUS_OK:
default:
return Mailer::formatMailerSendSuccessResult();
}
}
public function processSendError($result, $subscriber, $newsletter) {
if (empty($result['code'])) {
return $this->errorMapper->getErrorForResult($result, $subscriber, $this->sender, $newsletter);
}
switch ($result['code']) {
case API::RESPONSE_CODE_KEY_INVALID:
$this->bridge->invalidateMssKey();
break;
case API::RESPONSE_CODE_CAN_NOT_SEND:
if ($result['error'] === API::ERROR_MESSAGE_INVALID_FROM) {
$this->authorizedEmailsController->checkAuthorizedEmailAddresses();
}
break;
case API::RESPONSE_CODE_PAYLOAD_ERROR:
if (!empty($result['error']) && $result['error'] === API::ERROR_MESSAGE_BULK_EMAIL_FORBIDDEN) {
$this->authorizedEmailsController->checkAuthorizedEmailAddresses();
}
break;
}
return $this->errorMapper->getErrorForResult($result, $subscriber, $this->sender, $newsletter);
}
public function processSubscriber($subscriber) {
preg_match('!(?P<name>.*?)\s<(?P<email>.*?)>!', $subscriber, $subscriberData);
if (!isset($subscriberData['email'])) {
$subscriberData = [
'email' => $subscriber,
];
}
return [
'email' => $subscriberData['email'],
'name' => (isset($subscriberData['name'])) ? $subscriberData['name'] : '',
];
}
public function getBody($newsletter, $subscriber, $extraParams = []) {
if (is_array($newsletter) && is_array($subscriber)) {
$body = [];
for ($record = 0; $record < count($newsletter); $record++) {
$body[] = $this->composeBody(
$newsletter[$record],
$this->processSubscriber($subscriber[$record]),
(!empty($extraParams['unsubscribe_url'][$record])) ? $extraParams['unsubscribe_url'][$record] : false,
(!empty($extraParams['one_click_unsubscribe'][$record])) ? $extraParams['one_click_unsubscribe'][$record] : false,
(!empty($extraParams['meta'][$record])) ? $extraParams['meta'][$record] : false
);
}
} else {
$body[] = $this->composeBody(
$newsletter,
$this->processSubscriber($subscriber),
(!empty($extraParams['unsubscribe_url'])) ? $extraParams['unsubscribe_url'] : false,
(!empty($extraParams['one_click_unsubscribe'])) ? $extraParams['one_click_unsubscribe'] : false,
(!empty($extraParams['meta'])) ? $extraParams['meta'] : false
);
}
return $body;
}
private function composeBody($newsletter, $subscriber, $unsubscribeUrl, $oneClickUnsubscribeUrl, $meta): array {
$body = [
'to' => ([
'address' => $subscriber['email'],
'name' => $subscriber['name'],
]),
'from' => ([
'address' => $this->sender['from_email'],
'name' => $this->sender['from_name'],
]),
'reply_to' => ([
'address' => $this->replyTo['reply_to_email'],
]),
'subject' => $newsletter['subject'],
];
if (!empty($this->replyTo['reply_to_name'])) {
$body['reply_to']['name'] = $this->replyTo['reply_to_name'];
}
if (!empty($newsletter['body']['html'])) {
$body['html'] = $newsletter['body']['html'];
}
if (!empty($newsletter['body']['text'])) {
$body['text'] = $newsletter['body']['text'];
}
if ($unsubscribeUrl) {
$isHttps = $this->url->isUsingHttps($unsubscribeUrl);
$body['unsubscribe'] = [
'url' => $isHttps && $oneClickUnsubscribeUrl ? $oneClickUnsubscribeUrl : $unsubscribeUrl,
'post' => $isHttps,
];
}
if ($meta) {
$body['meta'] = $meta;
}
return $body;
}
}
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
interface MailerMethod {
public function send(array $newsletter, array $subscriber, array $extraParams = []): array;
}
@@ -0,0 +1,17 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use PHPMailer\PHPMailer\PHPMailer;
class PHPMail extends PHPMailerMethod {
public function buildMailer(): PHPMailer {
$mailer = new PHPMailer(true);
// send using PHP's mail() function
$mailer->isMail();
return $mailer;
}
}
@@ -0,0 +1,126 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\Methods\Common\BlacklistCheck;
use MailPoet\Mailer\WordPress\PHPMailerLoader;
use MailPoet\Util\Url;
use PHPMailer\PHPMailer\PHPMailer;
PHPMailerLoader::load();
abstract class PHPMailerMethod implements MailerMethod {
/** @var string[] */
public $sender;
/** @var string[] */
public $replyTo;
/** @var string */
public $returnPath;
/** @var PHPMailer */
public $mailer;
protected $errorMapper;
/** @var Url */
protected $urlUtils;
/** @var BlacklistCheck */
protected $blacklist;
public function __construct(
$sender,
$replyTo,
$returnPath,
$errorMapper,
Url $urlUtils
) {
$this->sender = $sender;
$this->replyTo = $replyTo;
$this->returnPath = $returnPath;
$this->mailer = $this->buildMailer();
$this->errorMapper = $errorMapper;
$this->urlUtils = $urlUtils;
$this->blacklist = new BlacklistCheck();
}
public function send($newsletter, $subscriber, $extraParams = []): array {
if ($this->blacklist->isBlacklisted($subscriber)) {
$error = $this->errorMapper->getBlacklistError($subscriber);
return Mailer::formatMailerErrorResult($error);
}
try {
$mailer = $this->configureMailerWithMessage($newsletter, $subscriber, $extraParams);
$result = $mailer->send();
} catch (\Exception $e) {
return Mailer::formatMailerErrorResult($this->errorMapper->getErrorFromException($e, $subscriber));
}
if ($result === true) {
return Mailer::formatMailerSendSuccessResult();
} else {
$error = $this->errorMapper->getErrorForSubscriber($subscriber);
return Mailer::formatMailerErrorResult($error);
}
}
abstract public function buildMailer(): PHPMailer;
public function configureMailerWithMessage($newsletter, $subscriber, $extraParams = []) {
$mailer = $this->mailer;
$mailer->clearAddresses();
$mailer->clearCustomHeaders();
$mailer->isHTML(!empty($newsletter['body']['html']));
$mailer->CharSet = 'UTF-8'; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->setFrom($this->sender['from_email'], $this->sender['from_name'], false);
$mailer->addReplyTo($this->replyTo['reply_to_email'], $this->replyTo['reply_to_name']);
$subscriber = $this->processSubscriber($subscriber);
$mailer->addAddress($subscriber['email'], $subscriber['name']);
$mailer->Subject = (!empty($newsletter['subject'])) ? $newsletter['subject'] : ''; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->Body = (!empty($newsletter['body']['html'])) ? $newsletter['body']['html'] : $newsletter['body']['text']; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
if ($mailer->ContentType !== 'text/plain') { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->AltBody = (!empty($newsletter['body']['text'])) ? $newsletter['body']['text'] : ''; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
$mailer->Sender = $this->returnPath; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
// unsubscribe header
$unsubscribeUrl = $extraParams['unsubscribe_url'] ?? null;
$oneClickUnsubscribeUrl = $extraParams['one_click_unsubscribe'] ?? null;
if ($unsubscribeUrl) {
$isHttps = $this->urlUtils->isUsingHttps($unsubscribeUrl);
$url = $isHttps && $oneClickUnsubscribeUrl ? $oneClickUnsubscribeUrl : $unsubscribeUrl;
if ($isHttps) {
$mailer->addCustomHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
}
$mailer->addCustomHeader('List-Unsubscribe', '<' . $url . '>');
}
// Enforce base64 encoding when lines are too long, otherwise quoted-printable encoding
// is automatically used which can occasionally break the email body.
// Explanation:
// The bug occurs on Unix systems where mail() function passes email to a variation of
// sendmail command which expects only NL as line endings (POSIX). Since quoted-printable
// requires CRLF some of those commands convert LF to CRLF which can break the email body
// because it already (correctly) uses CRLF. Such CRLF then (wrongly) becomes CRCRLF.
if (PHPMailer::hasLineLongerThanMax($mailer->Body)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->Encoding = 'base64'; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
return $mailer;
}
public function processSubscriber($subscriber) {
preg_match('!(?P<name>.*?)\s<(?P<email>.*?)>!', $subscriber, $subscriberData);
if (!isset($subscriberData['email'])) {
$subscriberData = [
'email' => $subscriber,
];
}
return [
'email' => $subscriberData['email'],
'name' => (isset($subscriberData['name'])) ? $subscriberData['name'] : '',
];
}
}
@@ -0,0 +1,94 @@
<?php declare(strict_types = 1);
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Methods\ErrorMappers\SMTPMapper;
use MailPoet\RuntimeException;
use MailPoet\Util\Url;
use MailPoet\WP\Functions as WPFunctions;
use PHPMailer\PHPMailer\PHPMailer;
class SMTP extends PHPMailerMethod {
const SMTP_CONNECTION_TIMEOUT = 15; // seconds
/** @var string */
public $host;
/** @var int */
public $port;
/** @var int */
public $authentication;
/** @var string */
public $login;
/** @var string */
public $password;
/** @var string */
public $encryption;
/** @var PHPMailer */
public $mailer;
/** @var WPFunctions */
protected $wp;
public function __construct(
$host,
$port,
$authentication,
$encryption,
$sender,
$replyTo,
$returnPath,
SMTPMapper $errorMapper,
Url $urlUtils,
$login = null,
$password = null
) {
$this->wp = new WPFunctions;
$this->host = $host;
$this->port = $port;
$this->authentication = $authentication;
$this->login = $login;
$this->password = $password;
$this->encryption = $encryption;
parent::__construct($sender, $replyTo, $returnPath, $errorMapper, $urlUtils);
}
public function buildMailer(): PHPMailer {
$mailer = new PHPMailer(true);
$mailer->isSMTP();
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->Host = $this->wp->applyFilters('mailpoet_mailer_smtp_host', $this->host); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->Port = $this->wp->applyFilters('mailpoet_mailer_smtp_port', $this->port); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->SMTPSecure = $this->wp->applyFilters('mailpoet_mailer_smtp_encryption', $this->encryption);
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->SMTPOptions = $this->wp->applyFilters('mailpoet_mailer_smtp_options', []);
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->Timeout = $this->wp->applyFilters('mailpoet_mailer_smtp_connection_timeout', self::SMTP_CONNECTION_TIMEOUT); // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
if ($this->authentication === 1) {
$mailer->SMTPAuth = true; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->Username = $this->login; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$mailer->Password = $this->password; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
// values from filters can overwrite username and password
$filterUsername = $this->wp->applyFilters('mailpoet_mailer_smtp_username', null);
$filterPassword = $this->wp->applyFilters('mailpoet_mailer_smtp_password', null);
if ($filterUsername && $filterPassword) {
$mailer->SMTPAuth = true; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->Username = $filterUsername; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
/** @phpstan-ignore-next-line - we cannot annotate the return type from a filter */
$mailer->Password = $filterPassword; // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
}
$mailer = $this->wp->applyFilters('mailpoet_mailer_smtp_instance', $mailer);
if (!$mailer instanceof PHPMailer) {
throw new RuntimeException(__('Filter "mailpoet_mailer_smtp_instance" must return an instance of PHPMailer.', 'mailpoet'));
}
return $mailer;
}
}
@@ -0,0 +1,117 @@
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
namespace MailPoet\Mailer\Methods;
if (!defined('ABSPATH')) exit;
use MailPoet\Mailer\Mailer;
use MailPoet\Mailer\Methods\Common\BlacklistCheck;
use MailPoet\Mailer\Methods\ErrorMappers\SendGridMapper;
use MailPoet\Util\Url;
use MailPoet\WP\Functions as WPFunctions;
class SendGrid implements MailerMethod {
public $url = 'https://api.sendgrid.com/api/mail.send.json';
public $apiKey;
public $sender;
public $replyTo;
/** @var SendGridMapper */
private $errorMapper;
/** @var Url */
private $urlUtils;
/** @var BlacklistCheck */
private $blacklist;
private $wp;
public function __construct(
$apiKey,
$sender,
$replyTo,
SendGridMapper $errorMapper,
Url $urlUtils
) {
$this->apiKey = $apiKey;
$this->sender = $sender;
$this->replyTo = $replyTo;
$this->errorMapper = $errorMapper;
$this->urlUtils = $urlUtils;
$this->wp = new WPFunctions();
$this->blacklist = new BlacklistCheck();
}
public function send($newsletter, $subscriber, $extraParams = []): array {
if ($this->blacklist->isBlacklisted($subscriber)) {
$error = $this->errorMapper->getBlacklistError($subscriber);
return Mailer::formatMailerErrorResult($error);
}
$result = $this->wp->wpRemotePost(
$this->url,
$this->request($newsletter, $subscriber, $extraParams)
);
if (is_wp_error($result)) {
$error = $this->errorMapper->getConnectionError($result->get_error_message());
return Mailer::formatMailerErrorResult($error);
}
if ($this->wp->wpRemoteRetrieveResponseCode($result) !== 200) {
$response = json_decode($result['body'], true);
$error = $this->errorMapper->getErrorFromResponse($response, $subscriber);
return Mailer::formatMailerErrorResult($error);
}
return Mailer::formatMailerSendSuccessResult();
}
public function getBody($newsletter, $subscriber, $extraParams = []) {
$body = [
'to' => $subscriber,
'from' => $this->sender['from_email'],
'fromname' => $this->sender['from_name'],
'replyto' => $this->replyTo['reply_to_email'],
'subject' => $newsletter['subject'],
];
$headers = [];
// unsubscribe header
$unsubscribeUrl = $extraParams['unsubscribe_url'] ?? null;
$oneClickUnsubscribeUrl = $extraParams['one_click_unsubscribe'] ?? null;
if ($unsubscribeUrl) {
$isHttps = $this->urlUtils->isUsingHttps($unsubscribeUrl);
$url = $isHttps && $oneClickUnsubscribeUrl ? $oneClickUnsubscribeUrl : $unsubscribeUrl;
if ($isHttps) {
$headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
}
$headers['List-Unsubscribe'] = '<' . $url . '>';
}
if ($headers) {
$body['headers'] = json_encode($headers);
}
if (!empty($newsletter['body']['html'])) {
$body['html'] = $newsletter['body']['html'];
}
if (!empty($newsletter['body']['text'])) {
$body['text'] = $newsletter['body']['text'];
}
return $body;
}
public function auth() {
return 'Bearer ' . $this->apiKey;
}
public function request($newsletter, $subscriber, $extraParams = []) {
$body = $this->getBody($newsletter, $subscriber, $extraParams);
return [
'timeout' => 10,
'httpversion' => '1.1',
'method' => 'POST',
'headers' => [
'Authorization' => $this->auth(),
],
'body' => http_build_query($body, '', '&'),
];
}
}
@@ -0,0 +1 @@
<?php