From 7d1fd904a3fcb66d3e98cedf69806f473dae7cb0 Mon Sep 17 00:00:00 2001 From: Nick van der Veeken Date: Fri, 16 Jul 2021 14:02:56 +0200 Subject: [PATCH 1/2] Add SparkPost template support --- .../Message/Converter/SparkPostConverter.php | 57 +++++ src/Mail/Message/SparkPost.php | 148 ++++++++++- src/Service/SparkPostService.php | 230 ++++++++++++------ 3 files changed, 357 insertions(+), 78 deletions(-) create mode 100644 src/Mail/Message/Converter/SparkPostConverter.php diff --git a/src/Mail/Message/Converter/SparkPostConverter.php b/src/Mail/Message/Converter/SparkPostConverter.php new file mode 100644 index 0000000..9a70695 --- /dev/null +++ b/src/Mail/Message/Converter/SparkPostConverter.php @@ -0,0 +1,57 @@ +getOptions())); + + $sparkPost->setHeaders($mandrill->getHeaders()); + $sparkPost->setTo($mandrill->getTo()); + $sparkPost->setFrom($mandrill->getFrom()); + if ($mandrill->getSender()) { + $sparkPost->setSender($mandrill->getSender()); + } + $sparkPost->setReplyTo($mandrill->getReplyTo()); + $sparkPost->setCc($mandrill->getCc()); + $sparkPost->setBcc($mandrill->getBcc()); + if ($mandrill->getSubject()) { + $sparkPost->setSubject($mandrill->getSubject()); + } + $sparkPost->setEncoding($mandrill->getEncoding()); + $sparkPost->setBody($mandrill->getBody()); + $sparkPost->setTemplateId($mandrill->getTemplate()); + $sparkPost->setAllVariables($mandrill->getVariables()); + $sparkPost->setGlobalVariables($mandrill->getGlobalVariables()); + + return $sparkPost; + } + + /** + * Translate/copy options that map (roughly) 1:1 between Mandrill and SparkPost + */ + public static function fromMandrillOptions($mandrillOptions): array + { + $optionsMap = [ + 'important' => 'important', + 'track_clicks' => 'click_tracking', + 'track_open' => 'open_tracking', + ]; + + $sparkPostOptions = []; + + foreach($optionsMap as $hasOption) { + if(array_key_exists($hasOption, $mandrillOptions)) { + $sparkPostOptions[$hasOption] = $mandrillOptions[$hasOption]; + } + } + + return $sparkPostOptions; + } +} diff --git a/src/Mail/Message/SparkPost.php b/src/Mail/Message/SparkPost.php index 1af51b9..741f79f 100644 --- a/src/Mail/Message/SparkPost.php +++ b/src/Mail/Message/SparkPost.php @@ -2,18 +2,39 @@ namespace SlmMail\Mail\Message; +use Laminas\Mail\Address\AddressInterface; use Laminas\Mail\Message; class SparkPost extends Message { /** - * @var array + * Options that will be passed along with the API call when sending the message */ - protected $options = []; + protected array $options = []; + + /** + * SparkPost Template ID to be rendered, if specified + */ + protected ?string $template = null; + + /** + * Array of global substitution variables for email (template) rendering + */ + protected array $globalVariables = []; + + /** + * Array of recipient-specific substitution variables for email (template) rendering + */ + protected array $variables = []; public function __construct(array $options = []) { $this->setOptions($options); + + // make SparkPost message transactional by default (API defaults to non-transactional) + if (!array_key_exists('transactional', $options)) { + $this->setTransactional(); + } } public function setOptions(array $options): SparkPost @@ -27,4 +48,127 @@ public function getOptions(): array { return $this->options; } + + /** + * Set the value of a single option by name + */ + public function setOption(string $name, $value): SparkPost + { + $this->options[$name] = $value; + + return $this; + } + + /** + * Get the value of a single option by name, or null if the option is undefined + */ + public function getOption(string $name) + { + if (array_key_exists($name, $this->options)) { + return $this->options[$name]; + } + + return null; + } + + /** + * Indicate to SparkPost that this is a transactional message + */ + public function setTransactional(bool $transactional = true): SparkPost + { + return $this->setOption('transactional', $transactional); + } + + /** + * Returns true when this is a transactional message + */ + public function isTransactional(): bool + { + return $this->getOption('transactional'); + } + + /** + * Set SparkPost template ID to use + * + * @param string|null $template + * @return self + */ + public function setTemplateId(?string $template): SparkPost + { + $this->template = $template; + return $this; + } + + /** + * Get SparkPost template ID to use + * + * @return string|null + */ + public function getTemplateId(): ?string + { + return $this->template; + } + + /** + * Set the global substitution variables to use with the template + */ + public function setGlobalVariables(array $globalVariables): SparkPost + { + $this->globalVariables = $globalVariables; + return $this; + } + + /** + * Get the global substitution variables to use with the template + */ + public function getGlobalVariables(): array + { + return $this->globalVariables; + } + + /** + * Set the substitution variables for a given recipient as identified by its email address + */ + public function setVariables(string $recipient, array $variables): SparkPost + { + $this->variables[$recipient] = $variables; + return $this; + } + + /** + * Set the substitution variables for all recipients (indexed array where recipient's email address is the key) + */ + public function setAllVariables(array $variablesPerRecipient): SparkPost + { + $this->variables = $variablesPerRecipient; + return $this; + } + + /** + * Get the substitution variables for all recipients + * + * @return array + */ + public function getVariables(): array + { + return $this->variables; + } + + public function getSender(): ?AddressInterface + { + $sender = parent::getSender(); + + if (!($sender instanceof AddressInterface)) { + $from = parent::getFrom(); + if (!count($from)) { + return null; + } + + // get first sender from the list + $from->rewind(); + $sender = $from->current(); + } + + return $sender; + } } diff --git a/src/Service/SparkPostService.php b/src/Service/SparkPostService.php index c509625..edb7557 100644 --- a/src/Service/SparkPostService.php +++ b/src/Service/SparkPostService.php @@ -12,7 +12,7 @@ use Laminas\Http\Client as HttpClient; use Laminas\Http\Request as HttpRequest; use Laminas\Http\Response as HttpResponse; -use Laminas\Mail\Address; +use Laminas\Mail\Address\AddressInterface; use Laminas\Mail\Message; use SlmMail\Mail\Message\SparkPost as SparkPostMessage; @@ -25,55 +25,43 @@ class SparkPostService extends AbstractMailService /** * SparkPost API key - * - * @var string */ - protected $apiKey; + protected string $apiKey; /** - * Constructor. - * - * @param string $apiKey + * Constructor */ public function __construct(string $apiKey) { $this->apiKey = $apiKey; } - + /** + * Send a message via the SparkPost Transmissions API + */ public function send(Message $message): array { - if ($message instanceof SparkPostMessage) { - $options = $message->getOptions(); - } - - $spec['api_key'] = $this->apiKey; - - // Prepare message - $from = $this->prepareFromAddress($message); $recipients = $this->prepareRecipients($message); - $headers = $this->prepareHeaders($message); - $body = $this->prepareBody($message); - - $post = [ - 'options' => $options, - 'content' => [ - 'from' => $from, - 'subject' => $message->getSubject(), - 'html' => $body, - ], - ]; - $post = array_merge($post, $recipients); - if ((count($recipients) == 0) && (!empty($headers) || !empty($body))) { + if (count($recipients) == 0) { throw new Exception\RuntimeException( sprintf( - '%s transport expects at least one recipient if the message has at least one header or body', + '%s transport expects at least one recipient', __CLASS__ ) ); } + // Prepare POST-body + $post = $recipients; + $post['content'] = $this->prepareContent($message); + $post['options'] = $message instanceof SparkPostMessage ? $message->getOptions() : []; + $post['metadata'] = $this->prepareMetadata($message); + + if($message instanceof SparkPostMessage && $message->getGlobalVariables()) { + $post['substitution_data'] = $message->getGlobalVariables(); + } + $response = $this->prepareHttpClient('/transmissions', $post) ->send() ; @@ -82,26 +70,52 @@ public function send(Message $message): array } /** - * Retrieve email address for envelope FROM + * Prepare the 'content' structure for the SparkPost Transmission call + */ + protected function prepareContent(Message $message): array + { + $content = [ + 'from' => $this->prepareFromAddress($message), + 'subject' => $message->getSubject(), + ]; + + if ($message instanceof SparkPostMessage && $message->getTemplateId()) { + $content['template_id'] = $message->getTemplateId(); + } else { + $content['html'] = $this->prepareBody($message); + } + + if ($message->getHeaders()) { + $content['headers'] = $this->prepareHeaders($message); + } + + if ($message->getReplyTo()) { + $replyToList = $message->getReplyTo(); + $replyToList->rewind(); + $replyToAddress = $replyToList->current(); + + if($replyToAddress instanceof AddressInterface) { + $content['reply_to'] = $replyToAddress->getName() ? $replyToAddress->toString() : $replyToAddress->getEmail(); + } + } + + return $content; + } + + /** + * Retrieve From address from Message and format it according to + * the structure that the SparkPost API expects * * @param Message $message * * @throws Exception\RuntimeException - * @return string + * @return array */ - protected function prepareFromAddress(Message $message): string + protected function prepareFromAddress(Message $message): array { - #if ($this->getEnvelope() && $this->getEnvelope()->getFrom()) { - # return $this->getEnvelope()->getFrom(); - #} - $sender = $message->getSender(); - if ($sender instanceof Address\AddressInterface) { - return $sender->getEmail(); - } - $from = $message->getFrom(); - if (!count($from)) { + if (!($sender instanceof AddressInterface)) { // Per RFC 2822 3.6 throw new Exception\RuntimeException( sprintf( @@ -111,18 +125,18 @@ protected function prepareFromAddress(Message $message): string ); } - $from->rewind(); - $sender = $from->current(); + $fromStructure = []; + $fromStructure['email'] = $sender->getEmail(); + + if ($sender->getName()) { + $fromStructure['name'] = $sender->getName(); + } - return $sender->getEmail(); + return $fromStructure; } /** - * Prepare array of email address recipients - * - * @param Message $message - * - * @return array + * Prepare array of recipients (note: multiple keys are used to distinguish To/Cc/Bcc-lists) */ protected function prepareRecipients(Message $message): array { @@ -131,49 +145,112 @@ protected function prepareRecipients(Message $message): array #} $recipients = []; - $recipients['recipients'] = $this->prepareAddresses($message->getTo()); + $recipients['recipients'] = $this->prepareAddresses($message->getTo(), $message); //preparing email recipients we set $recipients['xx'] to be equal to prepareAddress() for different messages - !($cc = $this->prepareAddresses($message->getCc())) || $recipients['cc'] = $cc; - !($bcc = $this->prepareAddresses($message->getBcc())) || $recipients['bcc'] = $bcc; + !($cc = $this->prepareAddresses($message->getCc(), $message)) || $recipients['cc'] = $cc; + !($bcc = $this->prepareAddresses($message->getBcc(), $message)) || $recipients['bcc'] = $bcc; return $recipients; } - protected function prepareAddresses($addresses) + /** + * Prepare an addressee-sub structure based on (a subset of) addresses from a corresponding message + */ + protected function prepareAddresses($addresses, $message): array { $recipients = []; + foreach ($addresses as $address) { - $item = []; + $recipient = []; // will contain addressee-block and optional substitution_data-block + + // Format address-block + $addressee = []; + $addressee['email'] = $address->getEmail(); + if ($address->getName()) { - $item['name'] = $address->getName(); + $addressee['name'] = $address->getName(); } - $recipients[]['address'] = $address->getEmail(); + + $recipient['address'] = $addressee; + + // Format optional substitution_data-block + if ($message instanceof SparkPostMessage && $message->getVariables()) + { + // Array of recipient-specific substitution variables indexed by email address + $substitutionVariables = $message->getVariables(); + + if (array_key_exists($addressee['email'], $substitutionVariables)) { + $recipient['substitution_data'] = $substitutionVariables[$addressee['email']]; + } + } + + $recipients[] = $recipient; } return $recipients; } /** - * Prepare header string from message - * - * @param Message $message - * - * @return string + * Prepare header structure from message */ - protected function prepareHeaders(Message $message): string + protected function prepareHeaders(Message $message): array { $headers = clone $message->getHeaders(); - $headers->removeHeader('Bcc'); - return $headers->toString(); + $removeTheseHeaders = [ + 'Bcc', + 'Subject', + 'From', + 'To', + 'Reply-To', + 'Content-Type', + 'Content-Transfer-Encoding', + ]; + + foreach ($removeTheseHeaders as $headerName) { + $headers->removeHeader($headerName); + } + + return $headers->toArray(); + } + + /** + * Prepare the 'metadata' structure for the SparkPost Transmission call + */ + protected function prepareMetadata(Message $message): array + { + $metadata = []; + + if ($message->getSubject()) { + $metadata['subject'] = $message->getSubject(); + } + + if ($message->getSender()) { + $sender = $message->getSender(); + + if($sender instanceof AddressInterface) { + $metadata['from'] = []; + $metadata['from']['email'] = $sender->getEmail(); + $metadata['from']['name'] = $sender->getName() ?: $sender->getEmail(); + } + } + + if ($message->getReplyTo()) { + $replyToList = $message->getReplyTo(); + $replyToList->rewind(); + $replyToAddress = $replyToList->current(); + + if ($replyToAddress instanceof AddressInterface) { + \Logger::info('Reply-to: ' + $replyToAddress->toString()); + $metadata['reply_to'] = $replyToAddress->getName() ? $replyToAddress->toString() : $replyToAddress->getEmail(); + } + } + + return $metadata; } /** * Prepare body string from message - * - * @param Message $message - * - * @return string */ protected function prepareBody(Message $message): string { @@ -188,15 +265,16 @@ protected function prepareBody(Message $message): string private function prepareHttpClient(string $uri, array $parameters = []): HttpClient { $parameters = json_encode($parameters); - $return = $this->getClient() + return $this->getClient() ->resetParameters() - ->setHeaders(['Authorization' => $this->apiKey]) + ->setHeaders([ + 'Authorization' => $this->apiKey, + 'Content-Type' => 'application/json', + ]) ->setMethod(HttpRequest::METHOD_POST) ->setUri(self::API_ENDPOINT . $uri) - ->setRawBody($parameters, 'application/json') + ->setRawBody($parameters) ; - - return $return; } /** @@ -211,7 +289,7 @@ private function parseResponse(HttpResponse $response): array if (!is_array($result)) { throw new Exception\RuntimeException(sprintf( - 'An error occured on Sparkpost (http code %s), could not interpret result as JSON. Body: %s', + 'An error occurred on Sparkpost (http code %s), could not interpret result as JSON. Body: %s', $response->getStatusCode(), $response->getBody() )); @@ -238,7 +316,7 @@ function ($error) { throw new Exception\RuntimeException( sprintf( - 'An error occured on SparkPost (http code %s), messages: %s', + 'An error occurred on SparkPost (http code %s), messages: %s', $response->getStatusCode(), $message ) From 4d8fc6a630f3c5ab68ece1b5ec37ba4be8cc1121 Mon Sep 17 00:00:00 2001 From: Nick van der Veeken Date: Tue, 3 Aug 2021 16:33:36 +0200 Subject: [PATCH 2/2] Add SparkPost Sending Domain Registration-function --- docs/SparkPost.md | 10 +- src/Factory/SparkPostServiceFactory.php | 3 +- .../Message/Converter/SparkPostConverter.php | 57 ------ src/Mail/Message/SparkPost.php | 12 +- src/Service/SparkPostService.php | 125 ++++++++++-- .../Service/SparkPostServiceTest.php | 182 +++++++++++++++++- 6 files changed, 307 insertions(+), 82 deletions(-) delete mode 100644 src/Mail/Message/Converter/SparkPostConverter.php diff --git a/docs/SparkPost.md b/docs/SparkPost.md index 9f41fe3..2546fd4 100644 --- a/docs/SparkPost.md +++ b/docs/SparkPost.md @@ -1,10 +1,9 @@ - SparkPost - +========= This transport layer forms the coupling between Laminas\Mail and the Email Service Provider [SparkPost](http://sparkpost.com). The transport is a drop-in component and can be used to send email messages including Cc & Bcc addresses and attachments. -The SparkPost api docks are here: https://developers.sparkpost.com/api/ . +The SparkPost API docks are here: https://developers.sparkpost.com/api/ . Installation @@ -22,6 +21,11 @@ Usage SlmMail consumes for SparkPost just the standard `Laminas\Mail\Message` object. +When the SparkPostService was constructed with a DKIM-config object, the following methods let you register, verify and remove sending domains: + +* registerSendingDomain: Registers a new sending domain using the default DKIM keypair and selector that were configured using the constructor. If the sending domain already exists in SparkPost, the existing sending domain is preserved and the function returns successfully. +* removeSendingDomain: Remove a sending domain. If the sending domains does not exist on SparkPost the fuction returns successfully. +* verifySendingDomain: Requests verification of the DKIM-record of a previously registered sending domain. #### Attachments diff --git a/src/Factory/SparkPostServiceFactory.php b/src/Factory/SparkPostServiceFactory.php index 0eede91..f330a3a 100644 --- a/src/Factory/SparkPostServiceFactory.php +++ b/src/Factory/SparkPostServiceFactory.php @@ -58,7 +58,8 @@ public function __invoke(ContainerInterface $container, $requestedName, array $o ); } - $service = new SparkPostService($config['slm_mail']['spark_post']['key']); + $apiKey = $config['slm_mail']['spark_post']['key']; + $service = new SparkPostService($apiKey); $client = $container->get('SlmMail\Http\Client'); $service->setClient($client); diff --git a/src/Mail/Message/Converter/SparkPostConverter.php b/src/Mail/Message/Converter/SparkPostConverter.php deleted file mode 100644 index 9a70695..0000000 --- a/src/Mail/Message/Converter/SparkPostConverter.php +++ /dev/null @@ -1,57 +0,0 @@ -getOptions())); - - $sparkPost->setHeaders($mandrill->getHeaders()); - $sparkPost->setTo($mandrill->getTo()); - $sparkPost->setFrom($mandrill->getFrom()); - if ($mandrill->getSender()) { - $sparkPost->setSender($mandrill->getSender()); - } - $sparkPost->setReplyTo($mandrill->getReplyTo()); - $sparkPost->setCc($mandrill->getCc()); - $sparkPost->setBcc($mandrill->getBcc()); - if ($mandrill->getSubject()) { - $sparkPost->setSubject($mandrill->getSubject()); - } - $sparkPost->setEncoding($mandrill->getEncoding()); - $sparkPost->setBody($mandrill->getBody()); - $sparkPost->setTemplateId($mandrill->getTemplate()); - $sparkPost->setAllVariables($mandrill->getVariables()); - $sparkPost->setGlobalVariables($mandrill->getGlobalVariables()); - - return $sparkPost; - } - - /** - * Translate/copy options that map (roughly) 1:1 between Mandrill and SparkPost - */ - public static function fromMandrillOptions($mandrillOptions): array - { - $optionsMap = [ - 'important' => 'important', - 'track_clicks' => 'click_tracking', - 'track_open' => 'open_tracking', - ]; - - $sparkPostOptions = []; - - foreach($optionsMap as $hasOption) { - if(array_key_exists($hasOption, $mandrillOptions)) { - $sparkPostOptions[$hasOption] = $mandrillOptions[$hasOption]; - } - } - - return $sparkPostOptions; - } -} diff --git a/src/Mail/Message/SparkPost.php b/src/Mail/Message/SparkPost.php index 741f79f..8f40552 100644 --- a/src/Mail/Message/SparkPost.php +++ b/src/Mail/Message/SparkPost.php @@ -9,23 +9,27 @@ class SparkPost extends Message { /** * Options that will be passed along with the API call when sending the message + * @var array $options */ - protected array $options = []; + protected $options = []; /** * SparkPost Template ID to be rendered, if specified + * @var string|null $template */ - protected ?string $template = null; + protected $template = null; /** * Array of global substitution variables for email (template) rendering + * @var array $globalVariables */ - protected array $globalVariables = []; + protected $globalVariables = []; /** * Array of recipient-specific substitution variables for email (template) rendering + * @var array $variables */ - protected array $variables = []; + protected $variables = []; public function __construct(array $options = []) { diff --git a/src/Service/SparkPostService.php b/src/Service/SparkPostService.php index edb7557..61ba260 100644 --- a/src/Service/SparkPostService.php +++ b/src/Service/SparkPostService.php @@ -14,6 +14,7 @@ use Laminas\Http\Response as HttpResponse; use Laminas\Mail\Address\AddressInterface; use Laminas\Mail\Message; +use SlmMail\Service\Exception\RuntimeException; use SlmMail\Mail\Message\SparkPost as SparkPostMessage; class SparkPostService extends AbstractMailService @@ -25,8 +26,9 @@ class SparkPostService extends AbstractMailService /** * SparkPost API key + * @var string $apiKey */ - protected string $apiKey; + protected $apiKey; /** * Constructor @@ -36,6 +38,29 @@ public function __construct(string $apiKey) $this->apiKey = $apiKey; } + private function validateDkimConfig(array $dkimConfig): void + { + if (!is_array($dkimConfig)) { + throw new RuntimeException( + 'Invalid SparkPost DKIM-configuration object, expected an associative array' + ); + } + + foreach(['public', 'private', 'selector'] as $keyName) { + if (!isset($dkimConfig[$keyName])) { + throw new RuntimeException( + 'SparkPost DKIM-configuration contains an error: Missing value for "' . $keyName . '".' + ); + } + + if (!is_string($dkimConfig[$keyName])) { + throw new RuntimeException( + 'SparkPost DKIM-configuration contains an error: Invalid type for "' . $keyName . '", expected a string.' + ); + } + } + } + /** * Send a message via the SparkPost Transmissions API */ @@ -44,7 +69,7 @@ public function send(Message $message): array $recipients = $this->prepareRecipients($message); if (count($recipients) == 0) { - throw new Exception\RuntimeException( + throw new RuntimeException( sprintf( '%s transport expects at least one recipient', __CLASS__ @@ -108,7 +133,7 @@ protected function prepareContent(Message $message): array * * @param Message $message * - * @throws Exception\RuntimeException + * @throws RuntimeException * @return array */ protected function prepareFromAddress(Message $message): array @@ -117,7 +142,7 @@ protected function prepareFromAddress(Message $message): array if (!($sender instanceof AddressInterface)) { // Per RFC 2822 3.6 - throw new Exception\RuntimeException( + throw new RuntimeException( sprintf( '%s transport expects either a Sender or at least one From address in the Message; none provided', __CLASS__ @@ -241,7 +266,6 @@ protected function prepareMetadata(Message $message): array $replyToAddress = $replyToList->current(); if ($replyToAddress instanceof AddressInterface) { - \Logger::info('Reply-to: ' + $replyToAddress->toString()); $metadata['reply_to'] = $replyToAddress->getName() ? $replyToAddress->toString() : $replyToAddress->getEmail(); } } @@ -257,6 +281,79 @@ protected function prepareBody(Message $message): string return $message->getBodyText(); } + public function registerSendingDomain(string $domain, array $options = []): bool + { + $post = [ + 'domain' => urlencode($domain), + ]; + + if (array_key_exists('dkim', $options)) { + $this->validateDkimConfig($options['dkim']); + $post['dkim'] = $options['dkim']; + } + + $response = $this->prepareHttpClient('/sending-domains', $post) + ->send() + ; + + // A 409-status means that the domains is already registered, which we consider a 'successful' result + $results = $this->parseResponse($response, [409]); + + if($results && isset($results['results']) && isset($results['results']['message']) + && ($results['results']['message'] === 'Successfully Created domain.')) { + return true; + } + + if ($response->getStatusCode() === 409) { + return true; + } + + return false; + } + + public function verifySendingDomain(string $domain, array $options = []): bool + { + $dkimVerify = array_key_exists('dkim_verify', $options) && $options['dkim_verify'] === true; + $post = []; + + if ($dkimVerify) { + $post['dkim_verify'] = true; + } + + $response = $this->prepareHttpClient(sprintf('/sending-domains/%s/verify', urlencode($domain)), $post) + ->send() + ; + + $results = $this->parseResponse($response); + + if (!$results || !isset($results['results'])) { + return false; + } + + if ($dkimVerify) { + if (!isset($results['results']['dkim_status'])) { + return false; + } + + if ($results['results']['dkim_status'] !== 'valid') { + return false; + } + } + + return true; + } + + public function removeSendingDomain(string $domain): void + { + $response = $this->prepareHttpClient(sprintf('/sending-domains/%s', urlencode($domain))) + ->setMethod(HttpRequest::METHOD_DELETE) + ->send() + ; + + // When a 404-status is returned, the domains wasn't found, which is considered as a 'successful' response too + $this->parseResponse($response, [404]); + } + /** * @param string $uri * @param array $parameters @@ -280,22 +377,26 @@ private function prepareHttpClient(string $uri, array $parameters = []): HttpCli /** * @param HttpResponse $response * - * @throws Exception\RuntimeException + * @throws RuntimeException * @return array */ - private function parseResponse(HttpResponse $response): array + private function parseResponse(HttpResponse $response, array $successCodes = []): array { - $result = json_decode($response->getBody(), true); + if ($response->getBody()) { + $result = json_decode($response->getBody(), true); + } else { + $result = []; // represent an empty body by an empty array; json_decode would fail on an empty string + } if (!is_array($result)) { - throw new Exception\RuntimeException(sprintf( + throw new RuntimeException(sprintf( 'An error occurred on Sparkpost (http code %s), could not interpret result as JSON. Body: %s', $response->getStatusCode(), $response->getBody() )); } - if ($response->isSuccess()) { + if ($response->isSuccess() || in_array($response->getStatusCode(), $successCodes)) { return $result; } @@ -314,7 +415,7 @@ function ($error) { $message = 'Unknown error'; } - throw new Exception\RuntimeException( + throw new RuntimeException( sprintf( 'An error occurred on SparkPost (http code %s), messages: %s', $response->getStatusCode(), @@ -324,6 +425,6 @@ function ($error) { } // There is a 5xx error - throw new Exception\RuntimeException('SparkPost server error, please try again'); + throw new RuntimeException('SparkPost server error, please try again'); } } diff --git a/tests/SlmMailTest/Service/SparkPostServiceTest.php b/tests/SlmMailTest/Service/SparkPostServiceTest.php index 6590cd1..df79a20 100644 --- a/tests/SlmMailTest/Service/SparkPostServiceTest.php +++ b/tests/SlmMailTest/Service/SparkPostServiceTest.php @@ -2,11 +2,19 @@ namespace SlmMailTest\Service; +use Laminas\Http\Client as HttpClient; +use Laminas\Http\Response as HttpResponse; +use Laminas\Mail\Address; +use Laminas\Mail\AddressList; +use Laminas\Mail\Message; +use PHPUnit\Framework\MockObject\MockBuilder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use ReflectionMethod; +use SlmMail\Mail\Message\SparkPost; +use SlmMail\Service\Exception\RuntimeException; use SlmMail\Service\SparkPostService; use SlmMailTest\Util\ServiceManagerFactory; -use Laminas\Http\Response as HttpResponse; class SparkPostServiceTest extends TestCase { @@ -20,6 +28,51 @@ protected function setUp(): void $this->service = new SparkPostService('my-secret-key'); } + /** Stub the HTTP response from SparkPost with a custom response */ + private function expectApiResponse(int $statusCode = 200, string $responseBody = '', array $responseHeaders = []): SparkPostService + { + + $httpClientMock = $this->createPartialMock(HttpClient::class, [ + 'send' + ]); + + $sendMessageResponse = new HttpResponse(); + $sendMessageResponse->setStatusCode($statusCode); + if ($responseHeaders) { + $sendMessageResponse->setHeaders($responseHeaders); + } + $sendMessageResponse->setContent($responseBody); + + $httpClientMock->expects($this->once()) + ->method('send') + ->willReturn($sendMessageResponse); + + $sparkPostServiceMock = new SparkPostService('MyApiKey'); + $sparkPostServiceMock->setClient($httpClientMock); + + return $sparkPostServiceMock; + } + + private function getMessageObject(): Message + { + $message = new SparkPost(); + $toAddress = new Address('to-address@sparkpost-test.com'); + $fromAddress = new Address('from-address@sparkpost-test.com'); + + $to = new AddressList(); + $to->add($toAddress); + + $from = new AddressList(); + $from->add($fromAddress); + + $message->setFrom($from); + $message->setTo($to); + $message->setSubject('Test-email'); + $message->setBody('Content of the test-email.'); + + return $message; + } + public function testCreateFromFactory() { $service = ServiceManagerFactory::getServiceManager()->get('SlmMail\Service\SparkPostService'); @@ -41,18 +94,19 @@ public function testResultIsProperlyParsed() $this->assertEquals($payload, $actual); } - public function exceptionDataProvider() + public function exceptionDataProvider(): array { return [ - [400, '{"name":"UnknownError","message":"An error occured on SparkPost (http code 400), message: Unknown error", "code":4}', 'SlmMail\Service\Exception\RuntimeException'], + [400, '{"name":"UnknownError","message":"An error occurred on SparkPost (http code 400), message: Unknown error", "code":4}', 'SlmMail\Service\Exception\RuntimeException'], [500, '{"name":"GeneralError","message":"SparkPost server error, please try again", "code":4}', 'SlmMail\Service\Exception\RuntimeException'], + [204, '', null, []], // An empty 204-response should not throw an exception ]; } /** * @dataProvider exceptionDataProvider */ - public function testExceptionsAreThrownOnErrors($statusCode, $content, $expectedException) + public function testExceptionsAreThrownOnErrors($statusCode, $content, $expectedException, $expectedResult = null) { $method = new ReflectionMethod('SlmMail\Service\SparkPostService', 'parseResponse'); $method->setAccessible(true); @@ -66,6 +120,124 @@ public function testExceptionsAreThrownOnErrors($statusCode, $content, $expected } $actual = $method->invoke($this->service, $response); - $this->assertNull($actual); + + if ($expectedException === null) { + $this->assertEquals($expectedResult, $actual); + } else { + $this->assertNull($actual); + } + } + + public function testSend() + { + $message = $this->getMessageObject(); + + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"total_rejected_recipients":0,"total_accepted_recipients":1,"id":"11668787484950529"}}' + ); + $sparkPostServiceMock->send($message); + } + + public function testRegisterSendingDomain() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"message":"Successfully Created domain.","domain":"sparkpost-sending-domain.com","headers":"from:to:subject:date"}}' + ); + $this->assertTrue($sparkPostServiceMock->registerSendingDomain('sparkpost-sending-domain.com')); + } + + public function testRegisterSendingDomainWithDkim(): void + { + $dkimConfig = [ + 'public' => 'iAmAPublicKey', + 'private' => 'iAmAPrivateKey', + 'selector' => 'iAmASelector', + ]; + + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"message":"Successfully Created domain.","domain":"sparkpost-sending-domain.com","dkim":{"public":"iAmAPublicKey","selector":"iAmASelector","signing_domain":"sparkpost-sending-domain.com","headers":"from:to:subject:date"}}}' + ); + $result = $sparkPostServiceMock->registerSendingDomain('sparkpost-sending-domain.com', array('dkim' => $dkimConfig)); + $this->assertTrue($result); + } + + public function testRegisterSendingDomainWithIncompleteDkimConfig(): void + { + $dkimConfig = [ + 'public' => 'iAmAPublicKey', + // missing private key to test validation + 'selector' => 'iAmASelector', + ]; + + $this->expectException(RuntimeException::class); + $this->service->registerSendingDomain('sparkpost-sending-domain.com', array('dkim' => $dkimConfig)); + } + + public function testRegisterExistingSendingDomain() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse(409, '{"results":{"message":"resource conflict"}}'); + $this->assertTrue($sparkPostServiceMock->registerSendingDomain('sparkpost-sending-domain.com')); + } + + public function testRemoveSendingDomain() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse(204); + $this->assertNull($sparkPostServiceMock->removeSendingDomain('sparkpost-sending-domain.com')); + } + + public function testRemoveNonExistingSendingDomain() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse(404); + $this->assertNull($sparkPostServiceMock->removeSendingDomain('sparkpost-sending-domain.com')); + } + + public function testVerifySendingDomain() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"ownership_verified":true,"dkim_status":"unverified","cname_status":"unverified","mx_status":"unverified","compliance_status":"pending","spf_status":"unverified","abuse_at_status":"unverified","postmaster_at_status":"unverified","verification_mailbox_status":"unverified"}}' + ); + $this->assertTrue($sparkPostServiceMock->verifySendingDomain('sparkpost-sending-domain.com')); + } + + public function testVerifySendingDomainWithDkimRecord() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"ownership_verified":true,"dns":{"dkim_record":"k=rsa; h=sha256; p=iAmApublicKey"},"dkim_status":"valid","cname_status":"unverified","mx_status":"unverified","compliance_status":"pending","spf_status":"unverified","abuse_at_status":"unverified","postmaster_at_status":"unverified","verification_mailbox_status":"unverified"}}' + ); + $this->assertTrue($sparkPostServiceMock->verifySendingDomain('sparkpost-sending-domain.com', ['dkim_verify' => true])); + } + + public function testVerifySendingDomainWithInvalidDkimRecord() + { + /** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 200, + '{"results":{"ownership_verified":true,"dns":{"dkim_record":"k=rsa; h=sha256; p=iAmApublicKey"},"dkim_status":"invalid","cname_status":"unverified","mx_status":"unverified","compliance_status":"pending","spf_status":"unverified","abuse_at_status":"unverified","postmaster_at_status":"unverified","verification_mailbox_status":"unverified"}}' + ); + $this->assertFalse($sparkPostServiceMock->verifySendingDomain('sparkpost-sending-domain.com', ['dkim_verify' => true])); + } + + public function testVerifyUnregisteredSendingDomain() + { + //** @var SparkPostService $sparkPostServiceMock */ + $sparkPostServiceMock = $this->expectApiResponse( + 404, + '{"errors":[{"message":"invalid params","description":"Sending domain \'sparkpost-sending-domain.com\' is not a registered sending domain","code":"1200"}]}' + ); + $this->expectException(RuntimeException::class); + $sparkPostServiceMock->verifySendingDomain('sparkpost-sending-domain.com'); } }