diff --git a/README.md b/README.md index cf02882..f20f16e 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ lettermint = Lettermint( ) ``` -Each part uses its own token and never falls back to the other one. If the token a method needs is missing, it raises `LettermintConfigError` that names the argument (`domains.list needs team_token; …`), before any request. `lettermint.ping()` uses the team token when it is set, otherwise the sending token. `messages.reschedule()` and `messages.cancel()` accept either token in the same way. +Each part uses its own token and never falls back to the other one. If the token a method needs is missing, it raises `LettermintConfigError` that names the argument (`domains.list needs team_token; …`), before any request. `lettermint.ping()` uses the team token when it is set, otherwise the sending token. `messages.reschedule()`, `messages.cancel()` and `messages.resend()` accept either token in the same way. You can also pass a single token string. The SDK chooses its type by the format: `lm_team_` followed by letters and digits is a team token, and `lm_` followed by letters and digits is a sending token. Any other value, such as an SSO verification token (`lm_sso_…`), raises `LettermintConfigError`; pass `sending_token=` or `team_token=` explicitly in that case. @@ -257,10 +257,17 @@ stats = lettermint.stats.retrieve({"from": "2026-10-01", "to": "2026-10-31"}) html = lettermint.messages.html("message-id") ``` +`messages.resend()` sends the stored content of a sent message again, as a new message with its own ID. Leave out the body to resend to every original recipient, or choose some of them. It takes an idempotency key like `emails.send()`. A message can be resent while its content is retained (the API answers `410` otherwise), at most 5 times in 24 hours. + +```python +resent = lettermint.messages.resend("message-id", {"recipients": ["jane@example.com"]}) +print(resent["message_id"]) # the new message; it links back through resent_from_message_id +``` + | Attribute | Methods | | --- | --- | | `domains` | `list`, `iterate`, `create`, `retrieve`, `delete`, `verify_dns_records`, `verify_dns_record`, `update_projects` | -| `messages` | `list`, `iterate`, `retrieve`, `events`, `iterate_events`, `source`, `html`, `text`, `reschedule`, `cancel`, `process` | +| `messages` | `list`, `iterate`, `retrieve`, `events`, `iterate_events`, `source`, `html`, `text`, `reschedule`, `cancel`, `process`, `resend` | | `projects` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `rotate_token` | | `projects.report_forwarding` | `retrieve`, `update`, `delete`, `verify`, `resend_code` | | `routes` | `list(project_id)`, `iterate(project_id)`, `create(project_id, …)`, `retrieve`, `update`, `delete`, `verify_inbound_domain` | diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py index ad1ff7e..902e7d1 100644 --- a/src/lettermint/_async/resources.py +++ b/src/lettermint/_async/resources.py @@ -1,8 +1,8 @@ """The Team API sub-clients of :class:`~lettermint.AsyncLettermint`. -Every method uses the team token, except ``messages.reschedule`` and -``messages.cancel``, which use the team token when it is set and otherwise -the sending token. +Every method uses the team token, except ``messages.reschedule``, +``messages.cancel`` and ``messages.resend``, which use the team token when it +is set and otherwise the sending token. """ from __future__ import annotations @@ -47,6 +47,7 @@ MessageEventData, MessageListData, MessageResponse, + PendingSendMailResponse, ProcessInboundMessageResponse, ProjectCreatedData, ProjectData, @@ -54,6 +55,7 @@ ProjectMutationResponse, ReportForwardingRequest, RescheduleMessageRequest, + ResendMessageRequest, ResendReportForwardingCodeResponse, RotateProjectTokenResponse, RouteData, @@ -265,8 +267,8 @@ async def update_projects( class AsyncMessages(AsyncResource): """Sent and received messages. Team token. - ``reschedule`` and ``cancel`` also accept the sending token when no team - token is configured. + ``reschedule``, ``cancel`` and ``resend`` also accept the sending token when + no team token is configured. """ async def list( @@ -412,6 +414,34 @@ async def process( ), ) + async def resend( + self, + message_id: str, + body: ResendMessageRequest | None = None, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> PendingSendMailResponse: + """Sends the stored content of a sent message again, as a new message with its own ID. + + Without ``body``, every original recipient receives it again; pass + ``{"recipients": [...]}`` to choose some of them. The new message links to + the original through ``resent_from_message_id``. A message can be resent + while its content is retained (otherwise HTTP 410), at most 5 times in 24 + hours. + """ + return cast( + PendingSendMailResponse, + await self._request( + "POST /messages/{messageId}/resend", + "messages.resend", + path={"messageId": message_id}, + **({} if body is None else {"body": body}), + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + class AsyncReportForwarding(AsyncResource): """DMARC and complaint report forwarding of a project. Team token.""" diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py index 206ba2d..8b039b5 100644 --- a/src/lettermint/_generated/__init__.py +++ b/src/lettermint/_generated/__init__.py @@ -1,6 +1,6 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main) -# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d -# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138 +# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc +# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 # Naming profile: next """Generated from the Lettermint OpenAPI specifications. Do not edit; regenerate.""" diff --git a/src/lettermint/_generated/operations.py b/src/lettermint/_generated/operations.py index afb3e33..e4f0278 100644 --- a/src/lettermint/_generated/operations.py +++ b/src/lettermint/_generated/operations.py @@ -1,7 +1,7 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main) -# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d -# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138 +# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc +# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 # Naming profile: next """Every operation of the Sending and Team APIs, keyed by ``" "``. @@ -689,6 +689,22 @@ class Operation: pagination=None, deprecated=False, ), + # Resend a message + "POST /messages/{messageId}/resend": Operation( + operation_id="resendMessage", + method="POST", + path="/messages/{messageId}/resend", + auth="either", + surfaces=("team",), + path_params=("messageId",), + query=None, + headers=("Idempotency-Key",), + request=RequestBody(type="ResendMessageRequest", content_type="application/json", required=False), + response=ResponseBody(status=(202,), content_type="application/json", type="PendingSendMailResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("ApiErrorBody", "ErrorMessage"), 410: ("ApiErrorBody",), 422: ("ApiErrorBody", "ValidationErrorBody", "ErrorMessage"), 429: ("ApiErrorBody",), 500: ("ApiErrorBody",)}, + pagination=None, + deprecated=False, + ), # Create a new project "POST /projects": Operation( operation_id="createProject", @@ -814,7 +830,7 @@ class Operation: headers=("Idempotency-Key",), request=RequestBody(type="SendBatchMailRequest", content_type="application/json", required=True), response=ResponseBody(status=(202,), content_type="application/json", type="SendBatchMailResponse"), - errors={401: ("ErrorMessage",), 403: ("ApiErrorBody",), 409: ("ErrorMessage",), 422: ("ApiErrorBody", "ValidationErrorBody", "ErrorMessage"), 429: ("ApiErrorBody",), 500: ("ApiErrorBody",)}, + errors={401: ("ErrorMessage",), 403: ("ApiErrorBody",), 409: ("ApiErrorBody", "ErrorMessage"), 422: ("ApiErrorBody", "ValidationErrorBody", "ErrorMessage"), 429: ("ApiErrorBody",), 500: ("ApiErrorBody",)}, pagination=None, deprecated=False, ), diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py index 59a3bcb..491b856 100644 --- a/src/lettermint/_generated/types.py +++ b/src/lettermint/_generated/types.py @@ -1,7 +1,7 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@bff20c7d86dd05b926713f89c0ad716ef630ae21 (main) -# sending-openapi.json SHA-256: 958303afdf2971b372cab97e72845db2568bc0b6cb6f5c41b2a0dad0386df15d -# team-openapi.json SHA-256: 04718e1e006be90fe586f30754a73a4fd69233119dc36862ded9fb98d69aa138 +# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc +# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 # Naming profile: next """Request and response types of the Lettermint API. @@ -162,6 +162,7 @@ "ReportForwardingRequest", "ReportForwardingResource", "RescheduleMessageRequest", + "ResendMessageRequest", "ResendReportForwardingCodeResponse", "RotateProjectTokenResponse", "RouteData", @@ -173,6 +174,7 @@ "SandboxResult", "ScheduledMessage", "ScheduledSendMailResponse", + "SendBatchMailItem", "SendBatchMailRequest", "SendBatchMailResponse", "SendMailRequest", @@ -1797,6 +1799,8 @@ class CursorPage(TypedDict, Generic[T]): "spam_symbols": NotRequired["list[SpamSymbol]"], "route_id": Required[str], "created_at": Required[str], + # The ID of the message this message resent. Null when it is not a resend + "resent_from_message_id": NotRequired[str | None], }, ) @@ -2133,6 +2137,15 @@ class CursorPage(TypedDict, Generic[T]): ) +ResendMessageRequest = TypedDict( + "ResendMessageRequest", + { + # Recipients of the original message to resend to. Omit to resend to every original recipient. + "recipients": NotRequired[list[str]], + }, +) + + ResendReportForwardingCodeResponse = TypedDict( "ResendReportForwardingCodeResponse", { @@ -2282,8 +2295,8 @@ class CursorPage(TypedDict, Generic[T]): ) -SendMailRequest = TypedDict( - "SendMailRequest", +SendBatchMailItem = TypedDict( + "SendBatchMailItem", { "route": NotRequired[str], "from": Required[str], @@ -2314,11 +2327,13 @@ class CursorPage(TypedDict, Generic[T]): "html": NotRequired[str | None], "text": NotRequired[str | None], "attachments": NotRequired["list[MessageAttachmentInput]"], + # Optional key (1-255 characters) that makes this email safe to send again, in this or a later batch. If an email with the same key and content was accepted in the last 24 hours, its original result is returned and it is not sent again. Keys must be unique within a batch. Reusing a key with different content, or while the email with that key is still being processed, rejects the whole batch with `409 Conflict` and sends nothing. + "idempotency_key": NotRequired[str], }, ) -SendBatchMailRequest: TypeAlias = list[SendMailRequest] +SendBatchMailRequest: TypeAlias = list[SendBatchMailItem] SendMailResponse: TypeAlias = PendingSendMailResponse | ScheduledSendMailResponse @@ -2327,6 +2342,42 @@ class CursorPage(TypedDict, Generic[T]): SendBatchMailResponse: TypeAlias = list[SendMailResponse] +SendMailRequest = TypedDict( + "SendMailRequest", + { + "route": NotRequired[str], + "from": Required[str], + "to": Required[list[str]], + "cc": NotRequired[list[str]], + "bcc": NotRequired[list[str]], + "reply_to": NotRequired[list[str]], + "subject": Required[str], + # ISO 8601 or English delivery time. A time without a timezone uses UTC. + "scheduled_at": NotRequired[str], + # The result that a Sandbox project simulates for every recipient. + "sandbox_result": NotRequired["SandboxResult"], + # Custom headers to include in the email. To preserve a submitted RFC Message-ID, + # include both Message-ID and X-LM-Preserve-Message-ID with a string value of true or 1. + # Header names and control values are case-insensitive; false or 0 retains replacement. + # Send values as plain text: Lettermint encodes non-ASCII characters and repairs small formatting + # errors, such as line breaks, control characters and missing angle brackets. Header names must + # be printable ASCII without a colon. + "headers": NotRequired[dict[str, str]], + # Metadata to track with the email (not added as email headers). + "metadata": NotRequired[dict[str, str]], + # Tag to categorize and filter emails (alphanumeric, underscores, hyphens, spaces allowed). + "tag": NotRequired[str | None], + # Reusable exact-match tags. Names must be unique and are case-sensitive. + "tags": NotRequired["list[MessageTagInput]"], + # Per-email settings that override the selected route settings for this email only. + "settings": NotRequired["SendMailRequestSettings"], + "html": NotRequired[str | None], + "text": NotRequired[str | None], + "attachments": NotRequired["list[MessageAttachmentInput]"], + }, +) + + # Per-email settings that override the selected route settings for this email only. SendMailRequestSettings = TypedDict( "SendMailRequestSettings", diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py index 43e71ec..3309a30 100644 --- a/src/lettermint/_sync/resources.py +++ b/src/lettermint/_sync/resources.py @@ -1,9 +1,9 @@ # Generated from src/lettermint/_async/resources.py by scripts/unasync.py — do not edit. """The Team API sub-clients of :class:`~lettermint.Lettermint`. -Every method uses the team token, except ``messages.reschedule`` and -``messages.cancel``, which use the team token when it is set and otherwise -the sending token. +Every method uses the team token, except ``messages.reschedule``, +``messages.cancel`` and ``messages.resend``, which use the team token when it +is set and otherwise the sending token. """ from __future__ import annotations @@ -48,6 +48,7 @@ MessageEventData, MessageListData, MessageResponse, + PendingSendMailResponse, ProcessInboundMessageResponse, ProjectCreatedData, ProjectData, @@ -55,6 +56,7 @@ ProjectMutationResponse, ReportForwardingRequest, RescheduleMessageRequest, + ResendMessageRequest, ResendReportForwardingCodeResponse, RotateProjectTokenResponse, RouteData, @@ -266,8 +268,8 @@ def update_projects( class Messages(Resource): """Sent and received messages. Team token. - ``reschedule`` and ``cancel`` also accept the sending token when no team - token is configured. + ``reschedule``, ``cancel`` and ``resend`` also accept the sending token when + no team token is configured. """ def list( @@ -413,6 +415,34 @@ def process( ), ) + def resend( + self, + message_id: str, + body: ResendMessageRequest | None = None, + *, + idempotency_key: str | None = None, + timeout: float | None = None, + ) -> PendingSendMailResponse: + """Sends the stored content of a sent message again, as a new message with its own ID. + + Without ``body``, every original recipient receives it again; pass + ``{"recipients": [...]}`` to choose some of them. The new message links to + the original through ``resent_from_message_id``. A message can be resent + while its content is retained (otherwise HTTP 410), at most 5 times in 24 + hours. + """ + return cast( + PendingSendMailResponse, + self._request( + "POST /messages/{messageId}/resend", + "messages.resend", + path={"messageId": message_id}, + **({} if body is None else {"body": body}), + idempotency_key=idempotency_key, + timeout=timeout, + ), + ) + class ReportForwarding(Resource): """DMARC and complaint report forwarding of a project. Team token.""" diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..731926f --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,120 @@ +"""``messages.resend``: the request, both tokens, idempotency and the errors of the endpoint.""" + +from __future__ import annotations + +import pytest + +from lettermint import ( + APIError, + AsyncLettermint, + Lettermint, + LettermintConfigError, + LettermintValidationError, + RateLimitError, +) +from lettermint.types import PendingSendMailResponse + +from .conftest import BASE_URL, SENDING_TOKEN, TEAM_TOKEN, MockAPI + +ACCEPTED = {"message_id": "m2", "status": "pending"} + + +def test_resend_without_a_body_resends_to_every_recipient(api: MockAPI, client: Lettermint) -> None: + api.json(202, ACCEPTED) + result: PendingSendMailResponse = client.messages.resend("m1") + assert result == ACCEPTED + assert api.last.method == "POST" + assert str(api.last.url) == f"{BASE_URL}/messages/m1/resend" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert "x-lettermint-token" not in api.last.headers + assert api.last.content == b"" + assert "content-type" not in api.last.headers + assert "idempotency-key" not in api.last.headers + + +def test_resend_to_chosen_recipients(api: MockAPI, client: Lettermint) -> None: + api.json(202, ACCEPTED) + client.messages.resend("m1", {"recipients": ["jane@example.test", "john@example.test"]}) + assert api.last.headers["content-type"] == "application/json" + assert api.body() == {"recipients": ["jane@example.test", "john@example.test"]} + + +def test_resend_with_an_empty_body_sends_an_empty_object(api: MockAPI, client: Lettermint) -> None: + api.json(202, ACCEPTED) + client.messages.resend("m1", {}) + assert api.body() == {} + + +def test_resend_takes_an_idempotency_key(api: MockAPI, client: Lettermint) -> None: + api.json(202, ACCEPTED) + client.messages.resend("m1", idempotency_key="resend-m1") + assert api.last.headers["idempotency-key"] == "resend-m1" + client.messages.resend("m1", {"recipients": ["jane@example.test"]}, idempotency_key="k2") + assert api.last.headers["idempotency-key"] == "k2" + assert api.body() == {"recipients": ["jane@example.test"]} + with pytest.raises(LettermintValidationError) as caught: + client.messages.resend("m1", idempotency_key="") + assert caught.value.field == "idempotency_key" + assert len(api.requests) == 2 + + +def test_resend_encodes_and_checks_the_message_id(api: MockAPI, client: Lettermint) -> None: + api.json(202, ACCEPTED) + client.messages.resend("a/b c") + assert api.last.url.raw_path == b"/v1/messages/a%2Fb%20c/resend" + with pytest.raises(LettermintConfigError, match=r"^messages\.resend: messageId must be"): + client.messages.resend("") + assert len(api.requests) == 1 + + +def test_resend_accepts_the_sending_token(api: MockAPI) -> None: + api.json(202, ACCEPTED) + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + sending_only.messages.resend("m1") + assert api.last.headers["x-lettermint-token"] == SENDING_TOKEN + assert "authorization" not in api.last.headers + + +def test_resend_of_a_message_whose_content_is_gone_is_an_api_error( + api: MockAPI, client: Lettermint +) -> None: + body = { + "error": { + "code": "MESSAGE_CONTENT_UNAVAILABLE", + "message": "The content of this message is no longer retained.", + } + } + api.json(410, body) + with pytest.raises(APIError) as caught: + client.messages.resend("m1") + assert type(caught.value) is APIError + assert (caught.value.status, caught.value.code, caught.value.body) == ( + 410, + "MESSAGE_CONTENT_UNAVAILABLE", + body, + ) + + +def test_resend_limit_is_a_rate_limit_error(api: MockAPI, client: Lettermint) -> None: + api.json( + 429, + {"error": {"code": "RESEND_LIMIT_REACHED", "message": "Resend limit reached."}}, + ) + with pytest.raises(RateLimitError) as caught: + client.messages.resend("m1") + assert caught.value.code == "RESEND_LIMIT_REACHED" + + +@pytest.mark.anyio +async def test_async_resend(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(202, ACCEPTED) + result: PendingSendMailResponse = await aclient.messages.resend( + "m1", {"recipients": ["jane@example.test"]}, idempotency_key="resend-m1" + ) + assert result == ACCEPTED + assert str(api.last.url) == f"{BASE_URL}/messages/m1/resend" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert api.last.headers["idempotency-key"] == "resend-m1" + assert api.body() == {"recipients": ["jane@example.test"]} + await aclient.messages.resend("m1") + assert api.last.content == b"" diff --git a/tests/test_surface.py b/tests/test_surface.py index 50f37bb..251bda1 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -59,6 +59,7 @@ ), ("messages.cancel", ("m1",), "POST /messages/{messageId}/cancel"), ("messages.process", ("m1",), "POST /messages/{messageId}/process"), + ("messages.resend", ("m1",), "POST /messages/{messageId}/resend"), ("projects.list", (), "GET /projects"), ("projects.iterate", (), "GET /projects"), ("projects.create", ({"name": "Production"},), "POST /projects"), @@ -160,6 +161,7 @@ "list", "process", "reschedule", + "resend", "retrieve", "source", "text",