Skip to content

Commit

Permalink
feature #52916 [Mailer] Dispatch event for Postmark's "inactive recip…
Browse files Browse the repository at this point in the history
…ient" API error (vicdelfant)

This PR was squashed before being merged into the 7.1 branch.

Discussion
----------

[Mailer] Dispatch event for Postmark's "inactive recipient" API error

| Q             | A
| ------------- | ---
| Branch?       | 7.1
| Bug fix?      |no
| New feature?  | yes
| Deprecations? | no
| Issues        | Fix #50165
| License       | MIT

Given the use of Postmark and a recipient that previously generated a bounce, attempting to send another email to that email address results in an HTTP 422 response, along with the error code [`406 - Inactive Recipient`](https://postmarkapp.com/developer/api/overview#error-codes). In the real world, this situation can arise easily in cases of a typo or an email address that had temporary issues.

Because `PostmarkApiTransport` requires an HTTP 200 and throws a `HttpTransportException` for any other HTTP code, something that's of minor interest to the application itself (i.e. a _possibly_ inactive e-mail address) now causes an exception. Depending on the userland logic, this can halt a process that sends out survey reminders, cause the Messenger component to queue the message for retrying etc.

To handle this more elegantly, I'm proposing the following changes:
* Add a `PostmarkDeliveryEventFactory` for casting any (future) delivery events to an instance of `PostmarkDeliveryEvent`. Currently, only support the 406 'inactive recipient' error is included;
* Adjust the `PostmarkApiTransport` so it checks for supported delivery events on an HTTP code other than 200, and if so, dispatches the event accordingly.

We cannot port this logic to the Postmark SMTP transport because, according to Postmark's own documentation, these error response codes are only provided by their API endpoints.

Commits
-------

6ffd173 [Mailer] Dispatch event for Postmark's "inactive recipient" API error
  • Loading branch information
fabpot committed Dec 8, 2023
2 parents 5d50fa8 + 6ffd173 commit 05b677c
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 0 deletions.
@@ -0,0 +1,64 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Postmark\Event;

use Symfony\Component\Mime\Header\Headers;

class PostmarkDeliveryEvent
{
public const CODE_INACTIVE_RECIPIENT = 406;

private int $errorCode;

private Headers $headers;

private ?string $message;

public function __construct(string $message, int $errorCode)
{
$this->message = $message;
$this->errorCode = $errorCode;

$this->headers = new Headers();
}

public function getErrorCode(): int
{
return $this->errorCode;
}

public function getHeaders(): Headers
{
return $this->headers;
}

public function getMessage(): ?string
{
return $this->message;
}

public function getMessageId(): ?string
{
if (!$this->headers->has('Message-ID')) {
return null;
}

return $this->headers->get('Message-ID')->getBodyAsString();
}

public function setHeaders(Headers $headers): self
{
$this->headers = $headers;

return $this;
}
}
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Postmark\Event;

use Symfony\Component\Mime\Email;

class PostmarkDeliveryEventFactory
{
public function create(int $errorCode, string $message, Email $email): PostmarkDeliveryEvent
{
if (!$this->supports($errorCode)) {
throw new \InvalidArgumentException(sprintf('Error code "%s" is not supported.', $errorCode));
}

return (new PostmarkDeliveryEvent($message, $errorCode))
->setHeaders($email->getHeaders());
}

public function supports(int $errorCode): bool
{
return \in_array($errorCode, [
PostmarkDeliveryEvent::CODE_INACTIVE_RECIPIENT,
]);
}
}
@@ -0,0 +1,17 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Postmark\Event;

class PostmarkEvents
{
public const DELIVERY = 'postmark.delivery';
}
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Mailer\Bridge\Postmark\Tests\Event;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Bridge\Postmark\Event\PostmarkDeliveryEvent;
use Symfony\Component\Mailer\Bridge\Postmark\Event\PostmarkDeliveryEventFactory;

class PostmarkDeliveryEventFactoryTest extends TestCase
{
public function testFactorySupportsInactiveRecipient()
{
$factory = new PostmarkDeliveryEventFactory();

$this->assertTrue($factory->supports(PostmarkDeliveryEvent::CODE_INACTIVE_RECIPIENT));
}
}
Expand Up @@ -12,8 +12,10 @@
namespace Symfony\Component\Mailer\Bridge\Postmark\Tests\Transport;

use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
use Symfony\Component\Mailer\Bridge\Postmark\Event\PostmarkDeliveryEvent;
use Symfony\Component\Mailer\Bridge\Postmark\Transport\MessageStreamHeader;
use Symfony\Component\Mailer\Bridge\Postmark\Transport\PostmarkApiTransport;
use Symfony\Component\Mailer\Envelope;
Expand Down Expand Up @@ -119,6 +121,38 @@ public function testSendThrowsForErrorResponse()
$transport->send($mail);
}

public function testSendDeliveryEventIsDispatched()
{
$client = new MockHttpClient(static fn (string $method, string $url, array $options): ResponseInterface => new JsonMockResponse(['Message' => 'Inactive recipient', 'ErrorCode' => 406], [
'http_code' => 422,
]));

$mail = new Email();
$mail->subject('Hello!')
->to(new Address('saif.gmati@symfony.com', 'Saif Eddin'))
->from(new Address('fabpot@symfony.com', 'Fabien'))
->text('Hello There!');

$expectedEvent = (new PostmarkDeliveryEvent('Inactive recipient', 406))
->setHeaders($mail->getHeaders());

$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher
->method('dispatch')
->willReturnCallback(function ($event) use ($expectedEvent) {
if ($event instanceof PostmarkDeliveryEvent) {
$this->assertEquals($event, $expectedEvent);
}

return $event;
});

$transport = new PostmarkApiTransport('KEY', $client, $dispatcher);
$transport->setPort(8984);

$transport->send($mail);
}

public function testTagAndMetadataAndMessageStreamHeaders()
{
$email = new Email();
Expand Down
Expand Up @@ -13,6 +13,8 @@

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Bridge\Postmark\Event\PostmarkDeliveryEventFactory;
use Symfony\Component\Mailer\Bridge\Postmark\Event\PostmarkEvents;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\HttpTransportException;
use Symfony\Component\Mailer\Exception\TransportException;
Expand All @@ -33,13 +35,16 @@ class PostmarkApiTransport extends AbstractApiTransport
{
private const HOST = 'api.postmarkapp.com';

private ?EventDispatcherInterface $dispatcher;

private string $key;

private ?string $messageStream = null;

public function __construct(string $key, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null)
{
$this->key = $key;
$this->dispatcher = $dispatcher;

parent::__construct($client, $dispatcher, $logger);
}
Expand Down Expand Up @@ -69,6 +74,18 @@ protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $e
}

if (200 !== $statusCode) {
$eventFactory = new PostmarkDeliveryEventFactory();

// Some delivery issues can be handled silently - route those through EventDispatcher
if (null !== $this->dispatcher && $eventFactory->supports($result['ErrorCode'])) {
$this->dispatcher->dispatch(
$eventFactory->create($result['ErrorCode'], $result['Message'], $email),
PostmarkEvents::DELIVERY,
);

return $response;
}

throw new HttpTransportException('Unable to send an email: '.$result['Message'].sprintf(' (code %d).', $result['ErrorCode']), $response);
}

Expand Down
5 changes: 5 additions & 0 deletions src/Symfony/Component/Mailer/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.1
---

* Dispatch Postmark's "406 - Inactive recipient" API error code as a `PostmarkDeliveryEvent` instead of throwing an exception

7.0
---

Expand Down

0 comments on commit 05b677c

Please sign in to comment.