Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 29 additions & 2 deletions src/Endpoints/EmailEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Lettermint\Endpoints;

use InvalidArgumentException;
use Lettermint\Objects\MessageTag;
use Lettermint\Responses\SendBatchMailResponse;
use Lettermint\Responses\SendMailResponse;

Expand Down Expand Up @@ -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;
Expand All @@ -255,11 +261,32 @@ public function tag(?string $tag): self
/**
* Set reusable name-value tags for the email.
*
* @param list<array{name: string, value: string}> $tags
* Existing name/value arrays remain supported for backward compatibility.
*
* @param list<MessageTag|array{name: string, value: string}> $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;
}
Expand Down
32 changes: 32 additions & 0 deletions src/Objects/MessageTag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Lettermint\Objects;

use InvalidArgumentException;

/** A reusable exact-match message tag. */
final readonly class MessageTag
{
public function __construct(
public string $name,
public string $value,
) {
if (! preg_match('/^[A-Za-z0-9_-]{1,32}$/D', $name)) {
throw new InvalidArgumentException('Message tag names must match ^[A-Za-z0-9_-]{1,32}$');
}

if (str_starts_with(strtolower($name), '__lettermint')) {
throw new InvalidArgumentException('Message tag names must not start with __lettermint');
}

if (! preg_match('/^[A-Za-z0-9_-]{1,64}$/D', $value)) {
throw new InvalidArgumentException('Message tag values must match ^[A-Za-z0-9_-]{1,64}$');
}
}

/** @return array{name: string, value: string} */
public function toArray(): array
{
return ['name' => $this->name, 'value' => $this->value];
}
}
35 changes: 35 additions & 0 deletions tests/Endpoints/EmailEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use Lettermint\Client\HttpClient;
use Lettermint\Endpoints\EmailEndpoint;
use Lettermint\Objects\MessageTag;
use Lettermint\Responses\SendBatchMailResponse;
use Lettermint\Responses\SendMailResponse;

Expand Down Expand Up @@ -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);