From a5881183cba9d91c7d6c3b6d14ae255aba0fc23a Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Tue, 2 Jun 2026 09:48:20 +0200 Subject: [PATCH 1/3] fix: surface unexpected Brevo API status codes (401/403/429/5xx) Throw ApiResponseException with the status code and response body instead of silently returning false, so callers can diagnose failures like a 401 caused by a non-whitelisted server IP. --- .../Brevo/Add existing contacts to a list.bru | 2 +- bruno/subscribeme/Brevo/Get a Contact.bru | 29 +++++++++++ .../Brevo/Get a list's details.bru | 24 +++++++++ ... new contacts into the specified list..bru | 2 +- .../Exception/ApiResponseException.php | 7 ++- .../Subscriber/BrevoSubscriber.php | 24 +++++++-- .../Subscriber/ResponseValidationTrait.php | 2 +- tests/BrevoMailerTest.php | 51 +++++++++++++++++++ 8 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 bruno/subscribeme/Brevo/Get a Contact.bru create mode 100644 bruno/subscribeme/Brevo/Get a list's details.bru diff --git a/bruno/subscribeme/Brevo/Add existing contacts to a list.bru b/bruno/subscribeme/Brevo/Add existing contacts to a list.bru index a50f488..f1951d8 100644 --- a/bruno/subscribeme/Brevo/Add existing contacts to a list.bru +++ b/bruno/subscribeme/Brevo/Add existing contacts to a list.bru @@ -21,7 +21,7 @@ headers { body:json { { "emails": [ - "eliot@rezo-zero.com" + "john.doe@contact.com" ] } } diff --git a/bruno/subscribeme/Brevo/Get a Contact.bru b/bruno/subscribeme/Brevo/Get a Contact.bru new file mode 100644 index 0000000..dcaf268 --- /dev/null +++ b/bruno/subscribeme/Brevo/Get a Contact.bru @@ -0,0 +1,29 @@ +meta { + name: Get a Contact + type: http + seq: 6 +} + +get { + url: {{brevo_base_url}}/v3/contacts/:identifier?startDate=&endDate= + body: none + auth: none +} + +params:query { + ~startDate: + ~endDate: +} + +params:path { + identifier: +} + +headers { + api-key: {{brevo_api_key}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/subscribeme/Brevo/Get a list's details.bru b/bruno/subscribeme/Brevo/Get a list's details.bru new file mode 100644 index 0000000..90524bd --- /dev/null +++ b/bruno/subscribeme/Brevo/Get a list's details.bru @@ -0,0 +1,24 @@ +meta { + name: Get a list's details + type: http + seq: 7 +} + +get { + url: {{brevo_base_url}}/v3/contacts/lists/:listId + body: none + auth: none +} + +params:path { + listId: 4 +} + +headers { + api-key: {{brevo_api_key}} +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/subscribeme/OxiMailing/Add new contacts into the specified list..bru b/bruno/subscribeme/OxiMailing/Add new contacts into the specified list..bru index 2bd6141..4d16240 100644 --- a/bruno/subscribeme/OxiMailing/Add new contacts into the specified list..bru +++ b/bruno/subscribeme/OxiMailing/Add new contacts into the specified list..bru @@ -27,7 +27,7 @@ auth:basic { body:json { { "contacts" : { - "email@rezo-zero.com": { + "email@test.com": { "firstName": "firstName", "lastName": "lastName" } diff --git a/src/SubscribeMe/Exception/ApiResponseException.php b/src/SubscribeMe/Exception/ApiResponseException.php index f08502d..5d68889 100644 --- a/src/SubscribeMe/Exception/ApiResponseException.php +++ b/src/SubscribeMe/Exception/ApiResponseException.php @@ -8,7 +8,7 @@ final class ApiResponseException extends \RuntimeException { - public function __construct(private array $responseBody, Throwable $previous = null) + public function __construct(private array $responseBody, private ?int $statusCode = null, Throwable $previous = null) { parent::__construct('Api response error', 0, $previous); } @@ -17,4 +17,9 @@ public function getResponseBody(): array { return $this->responseBody; } + + public function getStatusCode(): ?int + { + return $this->statusCode; + } } diff --git a/src/SubscribeMe/Subscriber/BrevoSubscriber.php b/src/SubscribeMe/Subscriber/BrevoSubscriber.php index cb2e290..45f5800 100644 --- a/src/SubscribeMe/Subscriber/BrevoSubscriber.php +++ b/src/SubscribeMe/Subscriber/BrevoSubscriber.php @@ -122,12 +122,20 @@ protected function doSubscribe(string $uri, array $body): bool|int $body['message'] == 'Contact already exist') { return true; } + + return false; } + + /* + * Any other status code (401, 403, 429, 5xx…) is an unexpected error: do not fail + * silently, surface the status code and response body so the caller can diagnose it. + */ + /** @var array $body */ + $body = json_decode($res->getBody()->getContents(), true) ?? []; + throw new ApiResponseException($body, $res->getStatusCode()); } catch (ClientExceptionInterface $exception) { throw new CannotSubscribeException($exception->getMessage(), $exception); } - - return false; } /** @@ -241,11 +249,19 @@ public function unsubscribe(string $email): bool if (isset($body['success'])) { return true; } + + return false; } + + /* + * Any other status code (401, 403, 429, 5xx…) is an unexpected error: do not fail + * silently, surface the status code and response body so the caller can diagnose it. + */ + /** @var array $body */ + $body = json_decode($res->getBody()->getContents(), true) ?? []; + throw new ApiResponseException($body, $res->getStatusCode()); } catch (ClientExceptionInterface $exception) { throw new CannotSubscribeException($exception->getMessage(), $exception); } - - return false; } } diff --git a/src/SubscribeMe/Subscriber/ResponseValidationTrait.php b/src/SubscribeMe/Subscriber/ResponseValidationTrait.php index 1498241..141f5b8 100644 --- a/src/SubscribeMe/Subscriber/ResponseValidationTrait.php +++ b/src/SubscribeMe/Subscriber/ResponseValidationTrait.php @@ -16,6 +16,6 @@ protected function validateResponse(ResponseInterface $response): string } /** @var array $result */ $result = json_decode($response->getBody()->getContents(), true); - throw new ApiResponseException($result); + throw new ApiResponseException($result, $response->getStatusCode()); } } diff --git a/tests/BrevoMailerTest.php b/tests/BrevoMailerTest.php index 3ece387..f41faa1 100644 --- a/tests/BrevoMailerTest.php +++ b/tests/BrevoMailerTest.php @@ -10,6 +10,7 @@ use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase; use SubscribeMe\Exception\ApiCredentialsException; +use SubscribeMe\Exception\ApiResponseException; use SubscribeMe\Exception\CannotSendTransactionalEmailException; use SubscribeMe\Subscriber\BrevoSubscriber; use SubscribeMe\ValueObject\EmailAddress; @@ -160,6 +161,56 @@ public function testSubscribeWithoutId(): void $this->assertEquals('api.brevo.com', $requests[0]->getUri()->getHost()); } + /** + * @throws JsonException + */ + public function testSubscribeThrowsOnUnauthorized(): void + { + $client = new Client(); + $factory = new Psr17Factory(); + + $client->setDefaultResponse( + new Response(401, [], json_encode(['message' => 'IP address not authorized'], JSON_THROW_ON_ERROR)) + ); + + $brevoSubscriber = new BrevoSubscriber($client, $factory, $factory); + $brevoSubscriber->setContactListId('3,5'); + $brevoSubscriber->setApiKey('928f601b-5476-4480-8eb0-c8d979f3b68f'); + + try { + $brevoSubscriber->subscribe('elly@example.com', []); + $this->fail('Expected ApiResponseException was not thrown.'); + } catch (ApiResponseException $exception) { + $this->assertSame(401, $exception->getStatusCode()); + $this->assertSame('IP address not authorized', $exception->getResponseBody()['message']); + } + } + + /** + * @throws JsonException + */ + public function testUnsubscribeThrowsOnUnauthorized(): void + { + $client = new Client(); + $factory = new Psr17Factory(); + + $client->setDefaultResponse( + new Response(401, [], json_encode(['message' => 'IP address not authorized'], JSON_THROW_ON_ERROR)) + ); + + $brevoSubscriber = new BrevoSubscriber($client, $factory, $factory); + $brevoSubscriber->setContactListId('3'); + $brevoSubscriber->setApiKey('928f601b-5476-4480-8eb0-c8d979f3b68f'); + + try { + $brevoSubscriber->unsubscribe('elly@example.com'); + $this->fail('Expected ApiResponseException was not thrown.'); + } catch (ApiResponseException $exception) { + $this->assertSame(401, $exception->getStatusCode()); + $this->assertSame('IP address not authorized', $exception->getResponseBody()['message']); + } + } + /** * @throws JsonException */ From fd58d919b6ffc902a9c85c3b532197833e8562b7 Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Tue, 2 Jun 2026 11:09:30 +0200 Subject: [PATCH 2/3] chore: fix phpstan --- src/SubscribeMe/Subscriber/BrevoSubscriber.php | 2 +- src/SubscribeMe/Subscriber/SubscriberInterface.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/SubscribeMe/Subscriber/BrevoSubscriber.php b/src/SubscribeMe/Subscriber/BrevoSubscriber.php index 45f5800..d1c3912 100644 --- a/src/SubscribeMe/Subscriber/BrevoSubscriber.php +++ b/src/SubscribeMe/Subscriber/BrevoSubscriber.php @@ -84,7 +84,7 @@ protected function getAttributes(array $options, array $userConsents = []): arra } /** - * @throws JsonException + * @throws JsonException|ApiResponseException */ protected function doSubscribe(string $uri, array $body): bool|int { diff --git a/src/SubscribeMe/Subscriber/SubscriberInterface.php b/src/SubscribeMe/Subscriber/SubscriberInterface.php index cfcb5a2..214bfb3 100644 --- a/src/SubscribeMe/Subscriber/SubscriberInterface.php +++ b/src/SubscribeMe/Subscriber/SubscriberInterface.php @@ -5,6 +5,7 @@ namespace SubscribeMe\Subscriber; use JsonException; +use SubscribeMe\Exception\ApiResponseException; use SubscribeMe\Exception\UnsupportedTransactionalEmailPlatformException; use SubscribeMe\Exception\UnsupportedUnsubscribePlatformException; use SubscribeMe\GDPR\UserConsent; @@ -25,14 +26,14 @@ public function setContactListId(?string $contactListId): SubscriberInterface; * @param array $options * @param UserConsent[] $userConsents * @return bool|int Contact ID if succeeded or false - * @throws JsonException + * @throws JsonException|ApiResponseException */ public function subscribe(string $email, array $options, array $userConsents = []): bool|int; /** * @param string $email * @return bool true on succeeded or false - * @throws JsonException|UnsupportedUnsubscribePlatformException + * @throws JsonException|UnsupportedUnsubscribePlatformException|ApiResponseException */ public function unsubscribe(string $email): bool; From dd74bd6e285acd8ed97da3405eb38de98ed76eb3 Mon Sep 17 00:00:00 2001 From: eliot lauger Date: Wed, 3 Jun 2026 16:21:48 +0200 Subject: [PATCH 3/3] fix: correct ApiResponseException constructor parameters --- src/SubscribeMe/Exception/ApiResponseException.php | 2 +- src/SubscribeMe/Subscriber/BrevoSubscriber.php | 8 +++----- src/SubscribeMe/Subscriber/ResponseValidationTrait.php | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/SubscribeMe/Exception/ApiResponseException.php b/src/SubscribeMe/Exception/ApiResponseException.php index 5d68889..bc39bbe 100644 --- a/src/SubscribeMe/Exception/ApiResponseException.php +++ b/src/SubscribeMe/Exception/ApiResponseException.php @@ -8,7 +8,7 @@ final class ApiResponseException extends \RuntimeException { - public function __construct(private array $responseBody, private ?int $statusCode = null, Throwable $previous = null) + public function __construct(private array $responseBody, ?Throwable $previous = null, private ?int $statusCode = null) { parent::__construct('Api response error', 0, $previous); } diff --git a/src/SubscribeMe/Subscriber/BrevoSubscriber.php b/src/SubscribeMe/Subscriber/BrevoSubscriber.php index d1c3912..1fd707d 100644 --- a/src/SubscribeMe/Subscriber/BrevoSubscriber.php +++ b/src/SubscribeMe/Subscriber/BrevoSubscriber.php @@ -4,13 +4,11 @@ namespace SubscribeMe\Subscriber; -use JsonException; use Psr\Http\Client\ClientExceptionInterface; use SubscribeMe\Exception\ApiResponseException; use SubscribeMe\Exception\CannotSendTransactionalEmailException; use SubscribeMe\Exception\CannotSubscribeException; use SubscribeMe\Exception\ApiCredentialsException; -use SubscribeMe\Exception\UnsupportedUnsubscribePlatformException; use SubscribeMe\GDPR\UserConsent; use SubscribeMe\ValueObject\EmailAddress; @@ -84,7 +82,7 @@ protected function getAttributes(array $options, array $userConsents = []): arra } /** - * @throws JsonException|ApiResponseException + * @throws \JsonException|ApiResponseException */ protected function doSubscribe(string $uri, array $body): bool|int { @@ -132,7 +130,7 @@ protected function doSubscribe(string $uri, array $body): bool|int */ /** @var array $body */ $body = json_decode($res->getBody()->getContents(), true) ?? []; - throw new ApiResponseException($body, $res->getStatusCode()); + throw new ApiResponseException($body, null, $res->getStatusCode()); } catch (ClientExceptionInterface $exception) { throw new CannotSubscribeException($exception->getMessage(), $exception); } @@ -259,7 +257,7 @@ public function unsubscribe(string $email): bool */ /** @var array $body */ $body = json_decode($res->getBody()->getContents(), true) ?? []; - throw new ApiResponseException($body, $res->getStatusCode()); + throw new ApiResponseException($body, null, $res->getStatusCode()); } catch (ClientExceptionInterface $exception) { throw new CannotSubscribeException($exception->getMessage(), $exception); } diff --git a/src/SubscribeMe/Subscriber/ResponseValidationTrait.php b/src/SubscribeMe/Subscriber/ResponseValidationTrait.php index 141f5b8..fd4f95a 100644 --- a/src/SubscribeMe/Subscriber/ResponseValidationTrait.php +++ b/src/SubscribeMe/Subscriber/ResponseValidationTrait.php @@ -16,6 +16,6 @@ protected function validateResponse(ResponseInterface $response): string } /** @var array $result */ $result = json_decode($response->getBody()->getContents(), true); - throw new ApiResponseException($result, $response->getStatusCode()); + throw new ApiResponseException($result, null, $response->getStatusCode()); } }