-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Mailer] Dispatch event for Postmark's "inactive recipient" API error #52916
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this nullable when the constructor does not allow passing null ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good eye, this could indeed be a non-nullable property. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. already fixed in #53242 |
||
|
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what is the reason for making this mutable instead of passing it in the constructor ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No particular reason — it's a personal preference to keep the constructor as compact as possible, with the absolute minimal required data to set up the object in a valid, initialized state. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This setter means that listeners can replace headers in the event (which will at least impact next listeners). Anything that should not be mutable from listeners should not have setters. |
||
{ | ||
$this->headers = $headers; | ||
|
||
return $this; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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, | ||
]); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we need this class with event names. For new code, we should rely on the event class name instead (by passing only 1 argument to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point, should this be adjusted while the PR has already been merged @fabpot? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has not been released yet, so we can still adjust it. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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('[email protected]', 'Saif Eddin')) | ||
->from(new Address('[email protected]', '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(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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); | ||
} | ||
|
@@ -69,6 +74,18 @@ protected function doSendApi(SentMessage $sentMessage, Email $email, Envelope $e | |
} | ||
|
||
if (200 !== $statusCode) { | ||
$eventFactory = new PostmarkDeliveryEventFactory(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we instantiate a new one each time or treat it as a dependency of the class stored in a property ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Certainly, this could be a dependency that we instantiate in the constructor. |
||
|
||
// 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't we still throw an exception by default to report that sending the email failed ? Right now, if you have no listener doing something special, the failure will be silently ignored. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my opinion, this would defeat the purpose of the PR. If we would take a traditional SMTP server as a reference, any e-mail that is valid gets sent - simple as that. This also means that you can send 100 e-mails to an e-mail address that bounced on the first try, because the SMTP server will simply accept the e-mails and try to deliver them. There would be no way for the SMTP server or the application to know that delivering the e-mail, in fact, failed (or would fail down the line). Ergo, no exceptions. Postmark's API, on the other hand, has stricter policies and is kind enough to throw an 'inactive recipient' API error to let you know that the e-mail address bounced before, or is otherwise suppressed. This is where the problem lies: with the pre-PR implementation, the application all of a sudden has to deal with exceptions while trying to deliver an otherwise valid e-mail. Also because the exception means that the recipient could be inactive, it's in fact not 100% sure that it still is. Sure we could wrap every sending operation in a try/catch, but in my opinion we should keep the logic of the mailer as close to its core as possible: using a traditional SMTP server that just queues mails and tries to deliver them. Anything extra is, well, extra. Then again this is my opinion, but if we would be throwing an exception I'd rather revert the PR so keep things as simple as possible, and rely on the webhooks to detect post-delivery issues. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but this won't be sent to webhooks either (as the API will reject the sending) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the intent is to have an exception then reverting this PR would do just that, you'd get the exception back. The bounce will actually trigger a webhook, but only the first time: https://postmarkapp.com/developer/webhooks/bounce-webhook. |
||
} | ||
|
||
throw new HttpTransportException('Unable to send an email: '.$result['Message'].sprintf(' (code %d).', $result['ErrorCode']), $response); | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what is the reason to expose this constant in the public API of the bridge ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No specific reason, except for keeping the event with the error code and the error code itself close together. Alternatively, the mapping/constant could be moved to
PostmarkDeliveryEventFactory
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As the factory is the only usage of the constant (outside tests), I would suggest moving it there as a private constant (and making tests use the integer directly). Once we introduce a public API, it becomes covered by the BC promise of Symfony. So we should not introduce them without reason (it only make maintenance harder)