From ba3b639f5a8658a808ced6100b6a40920950c432 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Thu, 10 Sep 2026 19:44:58 +0200 Subject: [PATCH] feat: add typed message tags (ENG-577) --- README.md | 20 +++++++++++++++ src/Endpoints/EmailEndpoint.php | 31 ++++++++++++++++++++++-- src/Objects/MessageTag.php | 32 ++++++++++++++++++++++++ tests/Endpoints/EmailEndpointTest.php | 35 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/Objects/MessageTag.php diff --git a/README.md b/README.md index 5026cbe..4516b4a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,26 @@ same request with the same idempotency key, the API will return the same respons For more information, refer to the [documentation](https://docs.lettermint.co/platform/emails/idempotency). +### Message tags + +```php +use Lettermint\Objects\MessageTag; + +$response = $lettermint->email + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Welcome') + ->tag('legacy-tag') + ->tags([ + new MessageTag('campaign', 'welcome'), + new MessageTag('customer', 'new'), + ]) + ->send(); +``` + +`tag()` remains available for the legacy single tag. `tags()` accepts typed +`MessageTag` values and the previous name/value array form. + ### API Client Use the API client for team-scoped resources such as projects, domains, routes, suppressions, stats, messages, and webhooks: diff --git a/src/Endpoints/EmailEndpoint.php b/src/Endpoints/EmailEndpoint.php index 9355149..e6058a0 100644 --- a/src/Endpoints/EmailEndpoint.php +++ b/src/Endpoints/EmailEndpoint.php @@ -2,6 +2,8 @@ namespace Lettermint\Endpoints; +use InvalidArgumentException; +use Lettermint\Objects\MessageTag; use Lettermint\Responses\SendBatchMailResponse; use Lettermint\Responses\SendMailResponse; @@ -247,6 +249,10 @@ public function metadata(array $metadata): self */ public function tag(?string $tag): self { + if ($tag !== null && count($this->payload['tags'] ?? []) >= 20) { + throw new InvalidArgumentException('A legacy tag and no more than 19 message tags are permitted.'); + } + $this->payload['tag'] = $tag; return $this; @@ -255,11 +261,32 @@ public function tag(?string $tag): self /** * Set reusable name-value tags for the email. * - * @param list $tags + * Existing name/value arrays remain supported for backward compatibility. + * + * @param list $tags */ public function tags(array $tags): self { - $this->payload['tags'] = $tags; + $maximum = isset($this->payload['tag']) ? 19 : 20; + if (count($tags) > $maximum) { + throw new InvalidArgumentException("No more than {$maximum} message tags are permitted."); + } + + $normalized = array_map( + static fn (MessageTag|array $tag): MessageTag => $tag instanceof MessageTag + ? $tag + : new MessageTag($tag['name'], $tag['value']), + $tags, + ); + $names = array_map(static fn (MessageTag $tag): string => $tag->name, $normalized); + if (count($names) !== count(array_unique($names, SORT_STRING))) { + throw new InvalidArgumentException('Message tag names must be unique and case-sensitive.'); + } + + $this->payload['tags'] = array_map( + static fn (MessageTag $tag): array => $tag->toArray(), + $normalized, + ); return $this; } diff --git a/src/Objects/MessageTag.php b/src/Objects/MessageTag.php new file mode 100644 index 0000000..e20a3a7 --- /dev/null +++ b/src/Objects/MessageTag.php @@ -0,0 +1,32 @@ + $this->name, 'value' => $this->value]; + } +} diff --git a/tests/Endpoints/EmailEndpointTest.php b/tests/Endpoints/EmailEndpointTest.php index c9aba19..5ea51d9 100644 --- a/tests/Endpoints/EmailEndpointTest.php +++ b/tests/Endpoints/EmailEndpointTest.php @@ -2,6 +2,7 @@ use Lettermint\Client\HttpClient; use Lettermint\Endpoints\EmailEndpoint; +use Lettermint\Objects\MessageTag; use Lettermint\Responses\SendBatchMailResponse; use Lettermint\Responses\SendMailResponse; @@ -517,3 +518,37 @@ ->tags($tags) ->send(); }); + +test('it handles typed message tags and legacy arrays', function () { + $this->httpClient + ->shouldReceive('post') + ->once() + ->with('/v1/send', Mockery::on(fn (array $payload): bool => $payload['tags'] === [ + ['name' => 'campaign', 'value' => 'welcome'], + ['name' => 'customer', 'value' => 'new'], + ]), []) + ->andReturn(['message_id' => '123', 'status' => 'pending']); + + $this->endpoint + ->from('sender@example.com') + ->to('recipient@example.com') + ->subject('Test Subject') + ->tags([ + new MessageTag('campaign', 'welcome'), + ['name' => 'customer', 'value' => 'new'], + ]) + ->send(); +}); + +test('it rejects invalid reusable tags', function (array $tags) { + $this->endpoint->tags($tags); +})->with([ + 'duplicate names' => [[['name' => 'same', 'value' => 'one'], ['name' => 'same', 'value' => 'two']]], + 'reserved name' => [[['name' => '__LETTERMINT_internal', 'value' => 'one']]], + 'invalid value' => [[['name' => 'valid', 'value' => 'invalid value']]], +])->throws(InvalidArgumentException::class); + +test('it counts the legacy tag in the reusable tag limit', function () { + $tags = array_map(fn (int $index): array => ['name' => "tag_{$index}", 'value' => 'one'], range(1, 20)); + $this->endpoint->tag('legacy')->tags($tags); +})->throws(InvalidArgumentException::class);