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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,19 @@ client.email.from_("sender@example.com").to("recipient@example.com").subject(
### Metadata and Tags

```python
from lettermint import MessageTag

client.email.from_("sender@example.com").to("recipient@example.com").subject(
"Hello"
).metadata({"campaign_id": "123", "user_id": "456"}).tag("welcome-campaign").send()
).metadata({"campaign_id": "123", "user_id": "456"}).tag("welcome-campaign").tags([
MessageTag(name="campaign", value="welcome"),
MessageTag(name="customer", value="new"),
]).send()
```

`tag()` remains available for the legacy single tag. `tags()` accepts typed
`MessageTag` values and the previous dictionary form.

### Routing

```python
Expand Down
3 changes: 2 additions & 1 deletion examples/async_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import asyncio
import os

from lettermint import AsyncLettermint
from lettermint import AsyncLettermint, MessageTag


async def send_emails():
Expand All @@ -28,6 +28,7 @@ async def send_emails():
.to(email["to"])
.subject(f"Hello {email['name']}!")
.html(f"<p>Welcome aboard, {email['name']}!</p>")
.tags([MessageTag(name="campaign", value="onboarding")])
.send()
for email in emails
]
Expand Down
3 changes: 2 additions & 1 deletion examples/basic_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import os

from lettermint import Lettermint
from lettermint import Lettermint, MessageTag

# Initialize the client with your API token
client = Lettermint(os.environ["LETTERMINT_API_TOKEN"])
Expand All @@ -16,6 +16,7 @@
.to("recipient@example.com")
.subject("Hello from Lettermint!")
.html("<h1>Welcome!</h1><p>This is a test email.</p>")
.tags([MessageTag(name="campaign", value="welcome")])
.send()
)

Expand Down
2 changes: 2 additions & 0 deletions src/lettermint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
WebhookVerificationError,
)
from .lettermint import ApiClient, AsyncApiClient, AsyncLettermint, Lettermint
from .message_tag import MessageTag
from .types import (
EmailAttachment,
EmailPayload,
Expand Down Expand Up @@ -86,4 +87,5 @@
"EmailStatus",
"SendEmailResponse",
"SendBatchEmailResponse",
"MessageTag",
]
41 changes: 33 additions & 8 deletions src/lettermint/endpoints/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
else:
from typing_extensions import Self

from ..message_tag import MessageTag, normalize_message_tags
from ..types import SendBatchEmailResponse, SendBatchMailRequest, SendEmailResponse, TlsPolicy
from .endpoint import AsyncEndpoint, Endpoint

Expand Down Expand Up @@ -278,12 +279,24 @@ def tag(self, tag: str) -> Self:
Example:
>>> client.email.tag("welcome-campaign")
"""
if len(self._payload.get("tags", [])) >= 20:
raise ValueError("A legacy tag and no more than 19 message tags are permitted")
self._payload["tag"] = tag
return self

def tags(self, tags: list[dict[str, str]]) -> Self:
"""Set reusable name-value tags for the email."""
self._payload["tags"] = tags
def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self:
"""Set reusable name-value tags for the email.

Dictionaries remain supported for backward compatibility.
"""
maximum = 19 if self._payload.get("tag") is not None else 20
if len(tags) > maximum:
raise ValueError(f"No more than {maximum} message tags are permitted")
normalized = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in tags]
names = [tag.name for tag in normalized]
if len(names) != len(set(names)):
raise ValueError("Message tag names must be unique and case-sensitive")
self._payload["tags"] = [tag.to_dict() for tag in normalized]
return self

def send(self) -> SendEmailResponse:
Expand Down Expand Up @@ -325,7 +338,7 @@ def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailResponse:
try:
response: SendBatchEmailResponse = self._client.post(
"/send/batch",
data=payload,
data=normalize_message_tags(payload),
headers=headers,
)
return response
Expand Down Expand Up @@ -567,12 +580,24 @@ def tag(self, tag: str) -> Self:
Returns:
The current instance for method chaining.
"""
if len(self._payload.get("tags", [])) >= 20:
raise ValueError("A legacy tag and no more than 19 message tags are permitted")
self._payload["tag"] = tag
return self

def tags(self, tags: list[dict[str, str]]) -> Self:
"""Set reusable name-value tags for the email."""
self._payload["tags"] = tags
def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self:
"""Set reusable name-value tags for the email.

Dictionaries remain supported for backward compatibility.
"""
maximum = 19 if self._payload.get("tag") is not None else 20
if len(tags) > maximum:
raise ValueError(f"No more than {maximum} message tags are permitted")
normalized = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in tags]
names = [tag.name for tag in normalized]
if len(names) != len(set(names)):
raise ValueError("Message tag names must be unique and case-sensitive")
self._payload["tags"] = [tag.to_dict() for tag in normalized]
return self

def send(self) -> Coroutine[Any, Any, SendEmailResponse]:
Expand Down Expand Up @@ -612,7 +637,7 @@ async def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailRespo

response: SendBatchEmailResponse = await self._client.post(
"/send/batch",
data=payload,
data=normalize_message_tags(payload),
headers=headers,
)
return response
Expand Down
50 changes: 50 additions & 0 deletions src/lettermint/message_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Typed reusable message tags."""

import re
from dataclasses import dataclass
from typing import Any

_NAME = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
_VALUE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")


@dataclass(frozen=True)
class MessageTag:
"""A reusable exact-match message tag."""

name: str
value: str

def __post_init__(self) -> None:
if not _NAME.fullmatch(self.name):
raise ValueError("Message tag names must match ^[A-Za-z0-9_-]{1,32}$")
if self.name.lower().startswith("__lettermint"):
raise ValueError("Message tag names must not start with __lettermint")
if not _VALUE.fullmatch(self.value):
raise ValueError("Message tag values must match ^[A-Za-z0-9_-]{1,64}$")

def to_dict(self) -> dict[str, str]:
"""Return the Sending API representation."""
return {"name": self.name, "value": self.value}


def normalize_message_tags(payload: Any) -> Any:
"""Normalize and validate typed tags in one message or a batch."""
if isinstance(payload, list):
return [normalize_message_tags(message) for message in payload]
if not isinstance(payload, dict) or "tags" not in payload:
return payload

result = dict(payload)
raw_tags = result["tags"]
if not isinstance(raw_tags, list):
raise ValueError("Message tags must be a list")
maximum = 19 if result.get("tag") is not None else 20
if len(raw_tags) > maximum:
raise ValueError(f"No more than {maximum} message tags are permitted")
tags = [tag if isinstance(tag, MessageTag) else MessageTag(**tag) for tag in raw_tags]
names = [tag.name for tag in tags]
if len(names) != len(set(names)):
raise ValueError("Message tag names must be unique and case-sensitive")
result["tags"] = [tag.to_dict() for tag in tags]
return result
28 changes: 28 additions & 0 deletions tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,34 @@ def test_send_with_metadata_and_tag(self, api_token: str) -> None:
"tls": "enforced",
}

def test_typed_message_tags_and_legacy_dictionary_support(self, api_token: str) -> None:
from lettermint import MessageTag

with Lettermint(api_token=api_token) as client:
endpoint = client.email.tags(
[
MessageTag(name="campaign", value="welcome"),
{"name": "customer", "value": "new"},
]
)
assert endpoint._payload["tags"] == [
{"name": "campaign", "value": "welcome"},
{"name": "customer", "value": "new"},
]

def test_rejects_invalid_message_tags(self, api_token: str) -> None:
with Lettermint(api_token=api_token) as client:
with pytest.raises(ValueError):
client.email.tags(
[{"name": "duplicate", "value": "one"}, {"name": "duplicate", "value": "two"}]
)
with pytest.raises(ValueError):
client.email.tags([{"name": "__LETTERMINT_internal", "value": "one"}])
with pytest.raises(ValueError):
client.email.tag("legacy").tags(
[{"name": f"tag_{index}", "value": "one"} for index in range(20)]
)

@respx.mock
def test_send_with_route(self, api_token: str) -> None:
"""Test sending email with route."""
Expand Down