From 5711eeda02a6f3b8c3aa92a6224484c8b66f6e00 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 | 10 ++++++- examples/async_send.py | 3 +- examples/basic_send.py | 3 +- src/lettermint/__init__.py | 2 ++ src/lettermint/endpoints/email.py | 41 ++++++++++++++++++++----- src/lettermint/message_tag.py | 50 +++++++++++++++++++++++++++++++ tests/test_email.py | 28 +++++++++++++++++ 7 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/lettermint/message_tag.py diff --git a/README.md b/README.md index f8347a0..3a18b8a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/async_send.py b/examples/async_send.py index 1e3b1fc..842cf8f 100644 --- a/examples/async_send.py +++ b/examples/async_send.py @@ -8,7 +8,7 @@ import asyncio import os -from lettermint import AsyncLettermint +from lettermint import AsyncLettermint, MessageTag async def send_emails(): @@ -28,6 +28,7 @@ async def send_emails(): .to(email["to"]) .subject(f"Hello {email['name']}!") .html(f"

Welcome aboard, {email['name']}!

") + .tags([MessageTag(name="campaign", value="onboarding")]) .send() for email in emails ] diff --git a/examples/basic_send.py b/examples/basic_send.py index a1b22cf..fc00d58 100644 --- a/examples/basic_send.py +++ b/examples/basic_send.py @@ -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"]) @@ -16,6 +16,7 @@ .to("recipient@example.com") .subject("Hello from Lettermint!") .html("

Welcome!

This is a test email.

") + .tags([MessageTag(name="campaign", value="welcome")]) .send() ) diff --git a/src/lettermint/__init__.py b/src/lettermint/__init__.py index 5b82a73..e8bb6e5 100644 --- a/src/lettermint/__init__.py +++ b/src/lettermint/__init__.py @@ -51,6 +51,7 @@ WebhookVerificationError, ) from .lettermint import ApiClient, AsyncApiClient, AsyncLettermint, Lettermint +from .message_tag import MessageTag from .types import ( EmailAttachment, EmailPayload, @@ -86,4 +87,5 @@ "EmailStatus", "SendEmailResponse", "SendBatchEmailResponse", + "MessageTag", ] diff --git a/src/lettermint/endpoints/email.py b/src/lettermint/endpoints/email.py index 92d025b..d050ebe 100644 --- a/src/lettermint/endpoints/email.py +++ b/src/lettermint/endpoints/email.py @@ -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 @@ -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: @@ -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 @@ -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]: @@ -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 diff --git a/src/lettermint/message_tag.py b/src/lettermint/message_tag.py new file mode 100644 index 0000000..59e1035 --- /dev/null +++ b/src/lettermint/message_tag.py @@ -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 diff --git a/tests/test_email.py b/tests/test_email.py index 9a56cdc..0fe8085 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -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."""