From b7c04a324cd04bbab09306333298f0da07bfcd65 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 19:52:58 -0300 Subject: [PATCH 1/2] feat: add broadcasts->recipients() method Retrieves the recipients of a broadcast for a given event type (sent, delivered, opened, clicked, bounced, complained, unsubscribed, suppressed), with optional email and bounce_type filters and cursor pagination. --- src/Broadcasts/Recipient.php | 18 ++++++++ src/Service/Broadcast.php | 17 +++++++ src/Service/Service.php | 2 + src/ValueObjects/Transporter/Payload.php | 2 +- tests/Fixtures/Broadcast.php | 35 ++++++++++++++ tests/Service/Broadcast.php | 59 ++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/Broadcasts/Recipient.php diff --git a/src/Broadcasts/Recipient.php b/src/Broadcasts/Recipient.php new file mode 100644 index 0000000..b5cac0a --- /dev/null +++ b/src/Broadcasts/Recipient.php @@ -0,0 +1,18 @@ +createResource('broadcasts', $result); } + /** + * Retrieve the recipients of a broadcast for a given event type. + * + * @param array{'type': string, 'email'?: string, 'bounce_type'?: string, 'limit'?: int, 'before'?: string, 'after'?: string} $options + * @return \Resend\Collection<\Resend\Broadcasts\Recipient> + * + * @see https://resend.com/docs/api-reference/broadcasts/list-broadcast-recipients + */ + public function recipients(string $id, array $options): \Resend\Collection + { + $payload = Payload::list("broadcasts/{$id}/recipients", $options); + + $result = $this->transporter->request($payload); + + return $this->createResource('broadcast-recipients', $result); + } + /** * Update a broadcast to send to your audience. * diff --git a/src/Service/Service.php b/src/Service/Service.php index a36bc72..f8e63e5 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -8,6 +8,7 @@ use Resend\Automations\Run as AutomationRun; use Resend\Broadcast; use Resend\Broadcasts\ClickedLink as BroadcastClickedLink; +use Resend\Broadcasts\Recipient as BroadcastRecipient; use Resend\Collection; use Resend\Contact; use Resend\ContactProperty; @@ -41,6 +42,7 @@ abstract class Service 'automations' => Automation::class, 'broadcasts' => Broadcast::class, 'broadcast-clicked-links' => BroadcastClickedLink::class, + 'broadcast-recipients' => BroadcastRecipient::class, 'contact-imports' => ContactImport::class, 'contact-properties' => ContactProperty::class, 'contact-topics' => ContactTopic::class, diff --git a/src/ValueObjects/Transporter/Payload.php b/src/ValueObjects/Transporter/Payload.php index 7eb895b..d41c70f 100644 --- a/src/ValueObjects/Transporter/Payload.php +++ b/src/ValueObjects/Transporter/Payload.php @@ -35,7 +35,7 @@ public static function list(string $resource, array $options = []): self $method = Method::GET; // whitelist keys that are actually valid query params, remove anything else - $allowedParams = ['limit', 'after', 'before', 'status', 'origin']; + $allowedParams = ['limit', 'after', 'before', 'status', 'origin', 'type', 'email', 'bounce_type']; $searchParams = array_intersect_key($options, array_flip($allowedParams)); $uri = ResourceUri::list(! empty($searchParams) ? $resource . '?' . http_build_query($searchParams) : $resource); diff --git a/tests/Fixtures/Broadcast.php b/tests/Fixtures/Broadcast.php index 5385527..635b093 100644 --- a/tests/Fixtures/Broadcast.php +++ b/tests/Fixtures/Broadcast.php @@ -39,3 +39,38 @@ function broadcasts(): array ], ]; } + +function broadcastRecipients(): array +{ + return [ + 'object' => 'list', + 'has_more' => true, + 'data' => [ + [ + 'id' => 'b2Zmc2V0OjA', + 'contact_id' => 'e169aa45-1ecf-4183-9955-b1499d5701d3', + 'email' => 'carter@example.com', + 'count' => 3, + 'clicked_links' => [ + ['url' => 'https://resend.com/pricing', 'clicks' => 2], + ], + ], + ], + ]; +} + +function broadcastBouncedRecipients(): array +{ + return [ + 'object' => 'list', + 'has_more' => false, + 'data' => [ + [ + 'id' => 'b2Zmc2V0OjE', + 'contact_id' => null, + 'email' => 'dana@example.com', + 'bounce_type' => 'permanent', + ], + ], + ]; +} diff --git a/tests/Service/Broadcast.php b/tests/Service/Broadcast.php index f634084..cd4c598 100644 --- a/tests/Service/Broadcast.php +++ b/tests/Service/Broadcast.php @@ -1,7 +1,11 @@ data->toBeArray(); }); +it('can get a list of broadcast recipients', function () { + $client = mockClient('GET', 'broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?type=clicked&limit=20', [], [], broadcastRecipients()); + + $result = $client->broadcasts->recipients('559ac32e-9ef5-46fb-82a1-b76b840c0f7b', [ + 'type' => 'clicked', + 'limit' => 20, + ]); + + expect($result)->toBeInstanceOf(Collection::class) + ->has_more->toBeTrue(); + + expect($result->data[0])->toBeInstanceOf(Recipient::class) + ->id->toBe('b2Zmc2V0OjA') + ->contact_id->toBe('e169aa45-1ecf-4183-9955-b1499d5701d3') + ->email->toBe('carter@example.com') + ->count->toBe(3) + ->clicked_links->toBe([ + ['url' => 'https://resend.com/pricing', 'clicks' => 2], + ]); +}); + +it('can get a list of bounced broadcast recipients filtered by bounce type', function () { + $client = mockClient('GET', 'broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/recipients?type=bounced&bounce_type=permanent', [], [], broadcastBouncedRecipients()); + + $result = $client->broadcasts->recipients('559ac32e-9ef5-46fb-82a1-b76b840c0f7b', [ + 'type' => 'bounced', + 'bounce_type' => 'permanent', + ]); + + expect($result)->toBeInstanceOf(Collection::class) + ->has_more->toBeFalse(); + + expect($result->data[0])->toBeInstanceOf(Recipient::class) + ->id->toBe('b2Zmc2V0OjE') + ->contact_id->toBeNull() + ->email->toBe('dana@example.com') + ->bounce_type->toBe('permanent'); +}); + +it('cannot get recipients for a broadcast that does not exist', function () { + /** @var Mockery\MockInterface|Transporter $transporter */ + $transporter = Mockery::mock(Transporter::class); + $transporter->shouldReceive('request')->once()->andThrow(new ErrorException([ + 'statusCode' => 404, + 'name' => 'not_found', + 'message' => 'Broadcast not found', + ])); + + $client = new Client($transporter); + + $client->broadcasts->recipients('559ac32e-9ef5-46fb-82a1-b76b840c0f7b', [ + 'type' => 'sent', + ]); +})->throws(ErrorException::class, 'Broadcast not found'); + it('can send a broadcast resource', function () { $client = mockClient('POST', 'broadcasts/559ac32e-9ef5-46fb-82a1-b76b840c0f7b/send', [ 'scheduled_at' => 'in 1 min', From 2b1133ec2c30fcf7b976a44de65f7baafa20e06e Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 21:59:07 -0300 Subject: [PATCH 2/2] fix: scope recipients query params to the recipients endpoint only --- src/Service/Broadcast.php | 2 +- src/ValueObjects/Transporter/Payload.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Service/Broadcast.php b/src/Service/Broadcast.php index 2799147..9f2ab7e 100644 --- a/src/Service/Broadcast.php +++ b/src/Service/Broadcast.php @@ -75,7 +75,7 @@ public function list(array $options = []): \Resend\Collection */ public function recipients(string $id, array $options): \Resend\Collection { - $payload = Payload::list("broadcasts/{$id}/recipients", $options); + $payload = Payload::list("broadcasts/{$id}/recipients", $options, ['type', 'email', 'bounce_type']); $result = $this->transporter->request($payload); diff --git a/src/ValueObjects/Transporter/Payload.php b/src/ValueObjects/Transporter/Payload.php index d41c70f..57b36a4 100644 --- a/src/ValueObjects/Transporter/Payload.php +++ b/src/ValueObjects/Transporter/Payload.php @@ -29,13 +29,13 @@ public function __construct( /** * Create a new Transporter Payload instance. */ - public static function list(string $resource, array $options = []): self + public static function list(string $resource, array $options = [], array $extraAllowedParams = []): self { $contentType = ContentType::JSON; $method = Method::GET; // whitelist keys that are actually valid query params, remove anything else - $allowedParams = ['limit', 'after', 'before', 'status', 'origin', 'type', 'email', 'bounce_type']; + $allowedParams = array_merge(['limit', 'after', 'before', 'status', 'origin'], $extraAllowedParams); $searchParams = array_intersect_key($options, array_flip($allowedParams)); $uri = ResourceUri::list(! empty($searchParams) ? $resource . '?' . http_build_query($searchParams) : $resource);