diff --git a/src/lettermint/endpoints/api.py b/src/lettermint/endpoints/api.py index 8d52cc3..ba81f95 100644 --- a/src/lettermint/endpoints/api.py +++ b/src/lettermint/endpoints/api.py @@ -88,9 +88,9 @@ def reschedule( ), ) - def cancel(self, message_id: str) -> lm_types.RescheduleMessageResponse: + def cancel(self, message_id: str) -> lm_types.CancelScheduledMessageResponse: return cast( - lm_types.RescheduleMessageResponse, + lm_types.CancelScheduledMessageResponse, self._client.post( self._path("/messages/{messageId}/cancel", messageId=message_id), data={} ), @@ -364,32 +364,6 @@ async def retrieve( ), ) - async def reschedule( - self, message_id: str, data: lm_types.RescheduleMessageRequest - ) -> lm_types.RescheduleMessageResponse: - return cast( - lm_types.RescheduleMessageResponse, - await self._client.patch( - self._path("/messages/{messageId}", messageId=message_id), data=data - ), - ) - - async def cancel(self, message_id: str) -> lm_types.RescheduleMessageResponse: - return cast( - lm_types.RescheduleMessageResponse, - await self._client.post( - self._path("/messages/{messageId}/cancel", messageId=message_id), data={} - ), - ) - - async def process(self, message_id: str) -> lm_types.ProcessInboundMessageResponse: - return cast( - lm_types.ProcessInboundMessageResponse, - await self._client.post( - self._path("/messages/{messageId}/process", messageId=message_id), data={} - ), - ) - async def delete(self, domain_id: str) -> lm_types.DomainDestroyResponse: return cast( lm_types.DomainDestroyResponse, @@ -446,6 +420,32 @@ async def retrieve( ), ) + async def reschedule( + self, message_id: str, data: lm_types.RescheduleMessageRequest + ) -> lm_types.RescheduleMessageResponse: + return cast( + lm_types.RescheduleMessageResponse, + await self._client.patch( + self._path("/messages/{messageId}", messageId=message_id), data=data + ), + ) + + async def cancel(self, message_id: str) -> lm_types.CancelScheduledMessageResponse: + return cast( + lm_types.CancelScheduledMessageResponse, + await self._client.post( + self._path("/messages/{messageId}/cancel", messageId=message_id), data={} + ), + ) + + async def process(self, message_id: str) -> lm_types.ProcessInboundMessageResponse: + return cast( + lm_types.ProcessInboundMessageResponse, + await self._client.post( + self._path("/messages/{messageId}/process", messageId=message_id), data={} + ), + ) + async def events( self, message_id: str, query: Query | None = None ) -> lm_types.MessageEventsResponse: diff --git a/src/lettermint/endpoints/email.py b/src/lettermint/endpoints/email.py index d050ebe..8a35314 100644 --- a/src/lettermint/endpoints/email.py +++ b/src/lettermint/endpoints/email.py @@ -13,7 +13,13 @@ from typing_extensions import Self from ..message_tag import MessageTag, normalize_message_tags -from ..types import SendBatchEmailResponse, SendBatchMailRequest, SendEmailResponse, TlsPolicy +from ..types import ( + SandboxResult, + SendBatchEmailResponse, + SendBatchMailRequest, + SendEmailResponse, + TlsPolicy, +) from .endpoint import AsyncEndpoint, Endpoint if TYPE_CHECKING: @@ -299,6 +305,11 @@ def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self: self._payload["tags"] = [tag.to_dict() for tag in normalized] return self + def sandbox_result(self, result: SandboxResult) -> Self: + """Select the simulated result for a Sandbox project.""" + self._payload["sandbox_result"] = result + return self + def send(self) -> SendEmailResponse: """Send the composed email. @@ -600,6 +611,11 @@ def tags(self, tags: list[MessageTag | dict[str, str]]) -> Self: self._payload["tags"] = [tag.to_dict() for tag in normalized] return self + def sandbox_result(self, result: SandboxResult) -> Self: + """Select the simulated result for a Sandbox project.""" + self._payload["sandbox_result"] = result + return self + def send(self) -> Coroutine[Any, Any, SendEmailResponse]: """Send the composed email asynchronously. diff --git a/src/lettermint/types.py b/src/lettermint/types.py index 4b012f5..22ecd6f 100644 --- a/src/lettermint/types.py +++ b/src/lettermint/types.py @@ -25,6 +25,19 @@ "unsubscribed", "canceled", ] +SandboxResult: TypeAlias = Literal[ + "delivered", + "hard_bounced", + "soft_bounced", + "deferred", + "failed", + "suppressed", + "spam_complaint", + "auto_replied", + "opened", + "clicked", + "unsubscribed", +] TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"] SendMailRequest = TypedDict( "SendMailRequest", @@ -45,10 +58,11 @@ "html": "NotRequired[str | None]", "text": "NotRequired[str | None]", "attachments": "NotRequired[list[dict[str, Any]]]", + "sandbox_result": "NotRequired[SandboxResult]", }, ) -SendBatchMailRequest: TypeAlias = list[dict[str, Any]] +SendBatchMailRequest: TypeAlias = list[SendMailRequest] AttachmentDelivery: TypeAlias = Literal["inline", "url"] BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] CursorPaginator = TypedDict( @@ -63,7 +77,6 @@ "prev_page_url": "Required[str | None]", }, ) - DkimMode: TypeAlias = Literal["legacy_txt", "managed_cname"] DnsRecordPurpose: TypeAlias = Literal[ "return_path", "dmarc", "dkim_legacy", "dkim_primary", "dkim_secondary" @@ -128,15 +141,16 @@ }, ) +DeliveryMode: TypeAlias = Literal["live", "sandbox"] MessageRecipientData = TypedDict( "MessageRecipientData", { "email": "Required[str]", "name": "Required[str | None]", + "sandbox_result": "Required[SandboxResult | None]", }, ) -MessageType: TypeAlias = Literal["inbound", "outbound"] SpamSymbol = TypedDict( "SpamSymbol", { @@ -147,6 +161,7 @@ }, ) +MessageType: TypeAlias = Literal["inbound", "outbound"] MessageData = TypedDict( "MessageData", { @@ -170,6 +185,8 @@ "spam_symbols": "NotRequired[list[SpamSymbol]]", "route_id": "Required[str]", "created_at": "Required[str]", + "delivery_mode": "Required[DeliveryMode]", + "sandbox_result": "Required[SandboxResult | None]", }, ) @@ -230,6 +247,8 @@ "tags": "Required[list[dict[str, Any]]]", "status_changed_at": "Required[str | None]", "created_at": "Required[str]", + "delivery_mode": "Required[DeliveryMode]", + "sandbox_result": "Required[SandboxResult | None]", }, ) @@ -276,6 +295,7 @@ "route_type": "Required[RouteType]", "is_default": "Required[bool]", "inbound_address": "NotRequired[str | None]", + "inbound_mx_hostname": "NotRequired[str]", "inbound_domain": "NotRequired[str | None]", "inbound_domain_verified_at": "NotRequired[str | None]", "inbound_spam_threshold": "NotRequired[float | None]", @@ -308,6 +328,7 @@ "last_28_days": "NotRequired[MessageStatsData | None]", "created_at": "Required[str]", "updated_at": "Required[str]", + "delivery_mode": "Required[DeliveryMode]", }, ) @@ -374,6 +395,13 @@ "suppressions:add", "suppressions:remove", ] +RescheduleMessageRequest = TypedDict( + "RescheduleMessageRequest", + { + "scheduled_at": "Required[str]", + }, +) + RouteListData = TypedDict( "RouteListData", { @@ -482,6 +510,7 @@ "smtp_enabled": "NotRequired[bool]", "initial_routes": "NotRequired[InitialRoutes]", "short_token": "NotRequired[bool]", + "delivery_mode": "NotRequired[DeliveryMode]", }, ) @@ -495,7 +524,8 @@ ) SuppressionReason: TypeAlias = Literal["spam_complaint", "hard_bounce", "unsubscribe", "manual"] -SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"] +SuppressionScope: TypeAlias = Literal["team", "project", "route"] +SuppressionAppliesTo: TypeAlias = Literal["all", "broadcast"] StoreSuppressionData = TypedDict( "StoreSuppressionData", { @@ -505,6 +535,7 @@ "scope": "Required[SuppressionScope]", "route_id": "NotRequired[str | None]", "project_id": "NotRequired[str | None]", + "applies_to": "NotRequired[SuppressionAppliesTo | None]", }, ) @@ -523,23 +554,32 @@ "message.clicked", "message.inbound", "message.policy_rejected", + "message.scheduled", + "message.rescheduled", + "message.canceled", + "message.released", "suppression.added", "suppression.removed", "webhook.test", ] +WebhookScope: TypeAlias = Literal["team", "project", "route"] +WebhookDeliveryModeFilter: TypeAlias = Literal["live", "sandbox", "both"] StoreWebhookData = TypedDict( "StoreWebhookData", { - "route_id": "Required[str]", "name": "Required[str]", "url": "Required[str]", "events": "Required[list[WebhookEvent]]", "enabled": "NotRequired[bool | None]", "include_machine_events": "NotRequired[bool | None]", + "scope": "NotRequired[WebhookScope | None]", + "project_ids": "NotRequired[list[str]]", + "route_ids": "NotRequired[list[str]]", + "route_id": "NotRequired[str | None]", + "delivery_mode_filter": "NotRequired[WebhookDeliveryModeFilter | None]", }, ) -SuppressionType: TypeAlias = Literal["email", "domain", "extension"] SuppressionSourceMessageData = TypedDict( "SuppressionSourceMessageData", { @@ -550,6 +590,7 @@ }, ) +SuppressionType: TypeAlias = Literal["email", "domain", "extension"] SuppressedRecipientData = TypedDict( "SuppressedRecipientData", { @@ -558,11 +599,11 @@ "value": "Required[str]", "reason": "Required[SuppressionReason]", "scope": "Required[SuppressionScope]", + "applies_to": "Required[SuppressionAppliesTo]", "project_id": "Required[str | None]", "route_id": "Required[str | None]", "source_message": "NotRequired[SuppressionSourceMessageData | None]", "created_at": "Required[str]", - "updated_at": "Required[str]", }, ) @@ -657,6 +698,7 @@ "smtp_enabled": "NotRequired[bool | None]", "redact_email_content": "NotRequired[bool | None]", "default_route_id": "NotRequired[str | None]", + "delivery_mode": "NotRequired[DeliveryMode | None]", }, ) @@ -715,6 +757,11 @@ "events": "NotRequired[list[WebhookEvent]]", "enabled": "NotRequired[bool]", "include_machine_events": "NotRequired[bool]", + "scope": "NotRequired[WebhookScope]", + "project_ids": "NotRequired[list[str]]", + "route_ids": "NotRequired[list[str]]", + "route_id": "NotRequired[str | None]", + "delivery_mode_filter": "NotRequired[WebhookDeliveryModeFilter]", }, ) @@ -722,16 +769,19 @@ "WebhookData", { "id": "Required[str]", - "route_id": "Required[str]", + "scope": "Required[WebhookScope]", + "project_ids": "Required[list[str]]", + "route_ids": "Required[list[str]]", + "route_id": "Required[str | None]", "name": "Required[str]", "url": "Required[str]", "events": "Required[list[str]]", "enabled": "Required[bool]", "include_machine_events": "Required[bool]", - "secret": "NotRequired[str]", "last_called_at": "Required[str | None]", "created_at": "Required[str]", "updated_at": "Required[str]", + "delivery_mode_filter": "Required[WebhookDeliveryModeFilter]", }, ) @@ -744,6 +794,9 @@ "id": "Required[str]", "webhook_id": "Required[str]", "event_type": "Required[WebhookEvent]", + "source_scope": "Required[str | None]", + "source_project_id": "Required[str | None]", + "source_route_id": "Required[str | None]", "status": "Required[WebhookDeliveryStatus]", "attempt_number": "Required[int]", "http_status_code": "Required[int | None]", @@ -754,6 +807,7 @@ "error_message": "Required[str | None]", "delivered_at": "Required[str | None]", "timestamp": "Required[str]", + "sandbox": "Required[bool]", }, ) @@ -763,6 +817,9 @@ "id": "Required[str]", "webhook_id": "Required[str]", "event_type": "Required[WebhookEvent]", + "source_scope": "Required[str | None]", + "source_project_id": "Required[str | None]", + "source_route_id": "Required[str | None]", "status": "Required[WebhookDeliveryStatus]", "attempt_number": "Required[int]", "http_status_code": "Required[int | None]", @@ -776,14 +833,38 @@ "WebhookListData", { "id": "Required[str]", - "route_id": "Required[str]", + "scope": "Required[WebhookScope]", + "project_ids": "Required[list[str]]", + "route_ids": "Required[list[str]]", + "route_id": "Required[str | None]", "name": "Required[str]", "url": "Required[str]", - "events": "Required[list[WebhookEvent]]", + "events": "Required[list[str]]", + "enabled": "Required[bool]", + "last_called_at": "Required[str | None]", + "created_at": "Required[str]", + "updated_at": "Required[str]", + }, +) + +WebhookSecretData = TypedDict( + "WebhookSecretData", + { + "id": "Required[str]", + "scope": "Required[WebhookScope]", + "project_ids": "Required[list[str]]", + "route_ids": "Required[list[str]]", + "route_id": "Required[str | None]", + "name": "Required[str]", + "url": "Required[str]", + "events": "Required[list[str]]", "enabled": "Required[bool]", + "include_machine_events": "Required[bool]", + "secret": "Required[str]", "last_called_at": "Required[str | None]", "created_at": "Required[str]", "updated_at": "Required[str]", + "delivery_mode_filter": "Required[WebhookDeliveryModeFilter]", }, ) @@ -801,26 +882,15 @@ SendMailResponse = TypedDict( "SendMailResponse", { - "message_id": "Required[str]", + "message_id": "Required[str | None]", "status": "Required[MessageStatus]", "scheduled_at": "NotRequired[str]", + "sandbox": "NotRequired[Literal[True]]", + "sandbox_result": "NotRequired[SandboxResult]", }, ) -RescheduleMessageRequest = TypedDict( - "RescheduleMessageRequest", - {"scheduled_at": "Required[str]"}, -) -RescheduleMessageResponse = TypedDict( - "RescheduleMessageResponse", - { - "message_id": "Required[str]", - "status": "Required[MessageStatus | None]", - "scheduled_at": "Required[str | None]", - }, -) - -SendBatchMailResponse: TypeAlias = list[dict[str, Any]] +SendBatchMailResponse: TypeAlias = list[SendMailResponse] PingResponse: TypeAlias = Literal[200] DomainIndexResponse = TypedDict( "DomainIndexResponse", @@ -877,6 +947,25 @@ }, ) +RescheduleMessageResponse = TypedDict( + "RescheduleMessageResponse", + { + "message_id": "Required[str]", + "status": "Required[MessageStatus | None]", + "scheduled_at": "Required[str | None]", + }, +) + +MessageShowResponse: TypeAlias = MessageData +CancelScheduledMessageResponse = TypedDict( + "CancelScheduledMessageResponse", + { + "message_id": "Required[str]", + "status": "Required[MessageStatus | None]", + "scheduled_at": "Required[str | None]", + }, +) + MessageIndexResponse = TypedDict( "MessageIndexResponse", { @@ -886,13 +975,6 @@ }, ) -MessageShowResponse: TypeAlias = MessageData -ProcessInboundMessageResponse = TypedDict( - "ProcessInboundMessageResponse", - { - "data": "Required[dict[str, str | int]]", - }, -) MessageEventsResponse = TypedDict( "MessageEventsResponse", { @@ -902,6 +984,13 @@ }, ) +ProcessInboundMessageResponse = TypedDict( + "ProcessInboundMessageResponse", + { + "data": "Required[dict[str, Any]]", + }, +) + ProjectIndexResponse = TypedDict( "ProjectIndexResponse", { @@ -1081,7 +1170,7 @@ WebhookStoreResponse = TypedDict( "WebhookStoreResponse", { - "data": "Required[WebhookData]", + "data": "Required[WebhookSecretData]", "message": "Required[Literal['Webhook created successfully. Please save the secret as it will not be shown again.']]", }, ) @@ -1114,7 +1203,7 @@ WebhookRegenerateSecretResponse = TypedDict( "WebhookRegenerateSecretResponse", { - "data": "Required[WebhookData]", + "data": "Required[WebhookSecretData]", "message": "Required[Literal['Webhook secret regenerated successfully. Please update your integration.']]", }, ) diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 91746c5..22b5eea 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import get_args +from typing import get_args, get_type_hints import pytest import respx @@ -183,11 +183,23 @@ def test_scheduled_message_endpoints(self) -> None: ) cancel_route = respx.post("https://api.lettermint.co/v1/messages/message%2Fid/cancel").mock( return_value=Response( - 200, json={"message_id": "message/id", "status": "canceled", "scheduled_at": None} + 200, + json={"message_id": "message/id", "status": "canceled", "scheduled_at": None}, ) ) - process_route = respx.post("https://api.lettermint.co/v1/messages/message%2Fid/process").mock( - return_value=Response(202, json={"data": {"message_id": "message/id", "status": "queued", "webhook_target_count": 1}}) + process_route = respx.post( + "https://api.lettermint.co/v1/messages/message%2Fid/process" + ).mock( + return_value=Response( + 202, + json={ + "data": { + "message_id": "message/id", + "status": "queued", + "webhook_target_count": 1, + } + }, + ) ) with Lettermint.api("api-token") as api: @@ -206,6 +218,53 @@ def test_scheduled_message_endpoints(self) -> None: assert cancel_route.called assert process_route.called + @respx.mock + @pytest.mark.asyncio + async def test_async_scheduled_message_endpoints(self) -> None: + reschedule_route = respx.patch("https://api.lettermint.co/v1/messages/message%2Fid").mock( + return_value=Response( + 200, + json={ + "message_id": "message/id", + "status": "scheduled", + "scheduled_at": "2026-08-27T09:00:00Z", + }, + ) + ) + cancel_route = respx.post("https://api.lettermint.co/v1/messages/message%2Fid/cancel").mock( + return_value=Response( + 200, + json={"message_id": "message/id", "status": "canceled", "scheduled_at": None}, + ) + ) + process_route = respx.post( + "https://api.lettermint.co/v1/messages/message%2Fid/process" + ).mock( + return_value=Response( + 202, + json={ + "data": { + "message_id": "message/id", + "status": "queued", + "webhook_target_count": 1, + } + }, + ) + ) + + async with AsyncLettermint.api("api-token") as api: + assert ( + await api.messages.reschedule( + "message/id", {"scheduled_at": "2026-08-27T09:00:00Z"} + ) + )["status"] == "scheduled" + assert (await api.messages.cancel("message/id"))["status"] == "canceled" + assert (await api.messages.process("message/id"))["data"]["status"] == "queued" + + assert reschedule_route.called + assert cancel_route.called + assert process_route.called + @respx.mock def test_team_role_and_member_assignment_endpoints(self) -> None: roles_route = respx.get("https://api.lettermint.co/v1/team/roles").mock( @@ -290,15 +349,15 @@ def test_documented_operations_are_exposed(self) -> None: missing = [method for endpoint, method in operations if not hasattr(endpoint, method)] + assert len(operations) == 53 assert missing == [] def test_generated_types_match_current_team_schema(self) -> None: - assert "scheduled" in get_args(lm_types.MessageEventType) + assert "auto_replied" in get_args(lm_types.MessageEventType) assert "message.auto_replied" in get_args(lm_types.WebhookEvent) assert "admin" in get_args(lm_types.BuiltInTeamRole) assert "members:manage" in get_args(lm_types.RbacPermission) - assert "enforced" in get_args(lm_types.TlsPolicy) - assert "global" in get_args(lm_types.SuppressionScope) + assert "team" in get_args(lm_types.SuppressionScope) assert "short_token" in lm_types.StoreProjectData.__annotations__ assert "redact_email_content" in lm_types.ProjectData.__annotations__ @@ -310,13 +369,34 @@ def test_generated_types_match_current_team_schema(self) -> None: assert "mime_types" in lm_types.BlockedFileTypesResponse.__annotations__ assert "redact_email_content" in lm_types.UpdateRouteSettingsData.__annotations__ assert "generate_plaintext_fallback" in lm_types.UpdateRouteSettingsData.__annotations__ - assert "tls" in lm_types.UpdateRouteSettingsData.__annotations__ assert "inbound_spam_threshold" in lm_types.UpdateRouteInboundSettingsData.__annotations__ assert "included_volume" in lm_types.TeamData.__annotations__ assert "assignable" in lm_types.TeamRoleData.__annotations__ assert "role_id" in lm_types.UpdateTeamMemberAssignmentData.__annotations__ - assert "dkim_mode" in lm_types.DomainData.__annotations__ - assert "source_message" in lm_types.SuppressedRecipientData.__annotations__ - assert "spam_score" in lm_types.MessageListData.__annotations__ - assert "scheduled_at" in lm_types.SendMailRequest.__annotations__ assert hasattr(lm_types, "RescheduleMessageRequest") + assert hasattr(lm_types, "RescheduleMessageResponse") + assert hasattr(lm_types, "CancelScheduledMessageResponse") + assert hasattr(lm_types, "ProcessInboundMessageResponse") + assert hasattr(lm_types, "CursorPaginator") + assert ( + get_type_hints(type(Lettermint.api("token").messages).cancel)["return"] + is lm_types.CancelScheduledMessageResponse + ) + + def test_generated_types_include_sandbox_contracts(self) -> None: + assert set(get_args(lm_types.DeliveryMode)) == {"live", "sandbox"} + assert "clicked" in get_args(lm_types.SandboxResult) + assert set(get_args(lm_types.WebhookDeliveryModeFilter)) == {"live", "sandbox", "both"} + + assert "sandbox_result" in lm_types.SendMailRequest.__annotations__ + assert "sandbox" in lm_types.SendMailResponse.__annotations__ + assert "sandbox_result" in lm_types.SendMailResponse.__annotations__ + assert "delivery_mode" in lm_types.ProjectData.__annotations__ + assert "delivery_mode" in lm_types.StoreProjectData.__annotations__ + assert "delivery_mode" in lm_types.UpdateProjectData.__annotations__ + assert "delivery_mode" in lm_types.MessageData.__annotations__ + assert "sandbox_result" in lm_types.MessageData.__annotations__ + assert "delivery_mode_filter" in lm_types.StoreWebhookData.__annotations__ + assert "delivery_mode_filter" in lm_types.UpdateWebhookData.__annotations__ + assert "delivery_mode_filter" in lm_types.WebhookData.__annotations__ + assert "sandbox" in lm_types.WebhookDeliveryData.__annotations__ diff --git a/tests/test_email.py b/tests/test_email.py index 0fe8085..111b044 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -146,20 +146,12 @@ def test_send_with_custom_headers(self, api_token: str) -> None: with Lettermint(api_token=api_token) as client: client.email.from_("sender@example.com").to("recipient@example.com").subject( "Test" - ).headers( - { - "Message-ID": "", - "X-LM-Preserve-Message-ID": "true", - } - ).send() + ).headers({"X-Custom-Header": "value"}).send() import json body = json.loads(route.calls.last.request.content) - assert body["headers"] == { - "Message-ID": "", - "X-LM-Preserve-Message-ID": "true", - } + assert body["headers"] == {"X-Custom-Header": "value"} @respx.mock def test_send_with_idempotency_key(self, api_token: str) -> None: @@ -186,15 +178,18 @@ def test_send_with_metadata_and_tag(self, api_token: str) -> None: with Lettermint(api_token=api_token) as client: client.email.from_("sender@example.com").to("recipient@example.com").subject( "Test" - ).metadata({"campaign_id": "123"}).tag("welcome").tags( - [{"name": "campaign", "value": "welcome-v2"}] - ).settings({"track_opens": False, "track_clicks": True, "tls": "enforced"}).send() + ).scheduled_at("2026-10-01T09:00:00Z").metadata({"campaign_id": "123"}).tag( + "welcome" + ).tags([{"name": "campaign", "value": "welcome-v2"}]).settings( + {"track_opens": False, "track_clicks": True, "tls": "enforced"} + ).send() import json body = json.loads(route.calls.last.request.content) assert body["metadata"] == {"campaign_id": "123"} assert body["tag"] == "welcome" + assert body["scheduled_at"] == "2026-10-01T09:00:00Z" assert body["tags"] == [{"name": "campaign", "value": "welcome-v2"}] assert body["settings"] == { "track_opens": False, @@ -247,6 +242,35 @@ def test_send_with_route(self, api_token: str) -> None: body = json.loads(route.calls.last.request.content) assert body["route"] == "my-route" + @respx.mock + def test_send_with_sandbox_result(self, api_token: str) -> None: + """Test selecting a simulated Sandbox result.""" + route = respx.post("https://api.lettermint.co/v1/send").mock( + return_value=Response( + 200, + json={ + "message_id": "msg_123", + "status": "delivered", + "sandbox": True, + "sandbox_result": "clicked", + }, + ) + ) + + with Lettermint(api_token=api_token) as client: + response = ( + client.email.from_("sender@example.com") + .to("recipient@example.com") + .subject("Test") + .sandbox_result("clicked") + .send() + ) + + body = json.loads(route.calls.last.request.content) + assert body["sandbox_result"] == "clicked" + assert response["sandbox"] is True + assert response["sandbox_result"] == "clicked" + @respx.mock def test_validation_error(self, api_token: str) -> None: """Test handling validation errors (422).""" @@ -366,17 +390,12 @@ async def test_send_with_all_options_async(self, api_token: str) -> None: .cc("cc@example.com") .bcc("bcc@example.com") .reply_to("reply@example.com") - .headers( - { - "Message-ID": "", - "X-LM-Preserve-Message-ID": "true", - } - ) - .attach("file.pdf", "base64content", None, "application/pdf") + .headers({"X-Custom": "value"}) + .attach("file.pdf", "base64content") .metadata({"key": "value"}) .tag("campaign") - .settings({"track_opens": False, "tls": "enforced"}) .route("my-route") + .sandbox_result("opened") .idempotency_key("unique-key") .send() ) @@ -392,21 +411,12 @@ async def test_send_with_all_options_async(self, api_token: str) -> None: assert body["cc"] == ["cc@example.com"] assert body["bcc"] == ["bcc@example.com"] assert body["reply_to"] == ["reply@example.com"] - assert body["headers"] == { - "Message-ID": "", - "X-LM-Preserve-Message-ID": "true", - } - assert body["attachments"] == [ - { - "filename": "file.pdf", - "content": "base64content", - "content_type": "application/pdf", - } - ] + assert body["headers"] == {"X-Custom": "value"} + assert body["attachments"] == [{"filename": "file.pdf", "content": "base64content"}] assert body["metadata"] == {"key": "value"} assert body["tag"] == "campaign" assert body["route"] == "my-route" - assert body["settings"] == {"track_opens": False, "tls": "enforced"} + assert body["sandbox_result"] == "opened" request = route.calls.last.request assert request.headers["Idempotency-Key"] == "unique-key"