diff --git a/src/lettermint/client.py b/src/lettermint/client.py index ec66a6f..9f54766 100644 --- a/src/lettermint/client.py +++ b/src/lettermint/client.py @@ -201,6 +201,19 @@ def put( except httpx.TimeoutException as e: raise TimeoutError(f"Request timeout after {self._timeout}s") from e + def patch( + self, + path: str, + data: Any | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """Make a PATCH request to the API.""" + try: + response = self._client.patch(path, json=data, headers=self._request_headers(headers)) + return self._handle_response(response) + except httpx.TimeoutException as e: + raise TimeoutError(f"Request timeout after {self._timeout}s") from e + def delete( self, path: str, @@ -429,6 +442,21 @@ async def put( except httpx.TimeoutException as e: raise TimeoutError(f"Request timeout after {self._timeout}s") from e + async def patch( + self, + path: str, + data: Any | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """Make a PATCH request to the API.""" + try: + response = await self._client.patch( + path, json=data, headers=self._request_headers(headers) + ) + return self._handle_response(response) + except httpx.TimeoutException as e: + raise TimeoutError(f"Request timeout after {self._timeout}s") from e + async def delete( self, path: str, diff --git a/src/lettermint/endpoints/api.py b/src/lettermint/endpoints/api.py index d35a6b9..1b10503 100644 --- a/src/lettermint/endpoints/api.py +++ b/src/lettermint/endpoints/api.py @@ -78,6 +78,24 @@ def retrieve(self, message_id: str, query: Query | None = None) -> lm_types.Mess ), ) + def reschedule( + self, message_id: str, data: lm_types.RescheduleMessageRequest + ) -> lm_types.RescheduleMessageResponse: + return cast( + lm_types.RescheduleMessageResponse, + self._client.patch( + self._path("/messages/{messageId}", messageId=message_id), data=data + ), + ) + + def cancel(self, message_id: str) -> lm_types.RescheduleMessageResponse: + return cast( + lm_types.RescheduleMessageResponse, + self._client.post( + self._path("/messages/{messageId}/cancel", messageId=message_id), data={} + ), + ) + def events(self, message_id: str, query: Query | None = None) -> lm_types.MessageEventsResponse: return cast( lm_types.MessageEventsResponse, @@ -338,6 +356,24 @@ 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 delete(self, domain_id: str) -> lm_types.DomainDestroyResponse: return cast( lm_types.DomainDestroyResponse, diff --git a/src/lettermint/endpoints/email.py b/src/lettermint/endpoints/email.py index e00529f..92d025b 100644 --- a/src/lettermint/endpoints/email.py +++ b/src/lettermint/endpoints/email.py @@ -128,6 +128,11 @@ def subject(self, subject: str) -> Self: self._payload["subject"] = subject return self + def scheduled_at(self, scheduled_at: str) -> Self: + """Set the requested delivery time for the email.""" + self._payload["scheduled_at"] = scheduled_at + return self + def html(self, html: str | None) -> Self: """Set the HTML body of the email. @@ -424,6 +429,11 @@ def subject(self, subject: str) -> Self: self._payload["subject"] = subject return self + def scheduled_at(self, scheduled_at: str) -> Self: + """Set the requested delivery time for the email.""" + self._payload["scheduled_at"] = scheduled_at + return self + def html(self, html: str | None) -> Self: """Set the HTML body of the email. diff --git a/src/lettermint/types.py b/src/lettermint/types.py index e0ff536..9768eae 100644 --- a/src/lettermint/types.py +++ b/src/lettermint/types.py @@ -7,6 +7,7 @@ from typing_extensions import NotRequired, Required, TypeAlias MessageStatus: TypeAlias = Literal[ + "scheduled", "pending", "queued", "suppressed", @@ -21,6 +22,7 @@ "blocked", "policy_rejected", "unsubscribed", + "canceled", ] TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"] SendMailRequest = TypedDict( @@ -33,6 +35,7 @@ "bcc": "NotRequired[list[str]]", "reply_to": "NotRequired[list[str]]", "subject": "Required[str]", + "scheduled_at": "NotRequired[str]", "headers": "NotRequired[dict[str, str]]", "metadata": "NotRequired[dict[str, str]]", "tag": "NotRequired[str | None]", @@ -150,6 +153,7 @@ "type": "Required[MessageType]", "status": "Required[MessageStatus]", "status_changed_at": "Required[str | None]", + "scheduled_at": "Required[str | None]", "tag": "Required[str | None]", "tags": "Required[list[dict[str, Any]]]", "from_email": "Required[str]", @@ -169,6 +173,10 @@ ) MessageEventType: TypeAlias = Literal[ + "scheduled", + "rescheduled", + "canceled", + "released", "queued", "processed", "suppressed", @@ -207,6 +215,7 @@ "id": "Required[str]", "type": "Required[MessageType]", "status": "Required[MessageStatus]", + "scheduled_at": "Required[str | None]", "spam_score": "NotRequired[float | None]", "from_email": "Required[str]", "from_name": "Required[str | None]", @@ -792,6 +801,20 @@ { "message_id": "Required[str]", "status": "Required[MessageStatus]", + "scheduled_at": "NotRequired[str]", + }, +) + +RescheduleMessageRequest = TypedDict( + "RescheduleMessageRequest", + {"scheduled_at": "Required[str]"}, +) +RescheduleMessageResponse = TypedDict( + "RescheduleMessageResponse", + { + "message_id": "Required[str]", + "status": "Required[MessageStatus | None]", + "scheduled_at": "Required[str | None]", }, ) diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index e1ed555..e00bdaf 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -169,6 +169,38 @@ def test_message_raw_body_endpoints(self) -> None: assert html_route.called assert text_route.called + @respx.mock + def test_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} + ) + ) + + with Lettermint.api("api-token") as api: + assert ( + api.messages.reschedule("message/id", {"scheduled_at": "2026-08-27T09:00:00Z"})[ + "status" + ] + == "scheduled" + ) + assert api.messages.cancel("message/id")["status"] == "canceled" + + assert json.loads(reschedule_route.calls.last.request.content) == { + "scheduled_at": "2026-08-27T09:00:00Z" + } + assert cancel_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( @@ -210,6 +242,8 @@ def test_documented_operations_are_exposed(self) -> None: (Lettermint.api("token").domains, "update_projects"), (Lettermint.api("token").messages, "list"), (Lettermint.api("token").messages, "retrieve"), + (Lettermint.api("token").messages, "reschedule"), + (Lettermint.api("token").messages, "cancel"), (Lettermint.api("token").messages, "events"), (Lettermint.api("token").messages, "source"), (Lettermint.api("token").messages, "html"), @@ -253,7 +287,7 @@ def test_documented_operations_are_exposed(self) -> None: assert missing == [] def test_generated_types_match_current_team_schema(self) -> None: - assert "auto_replied" in get_args(lm_types.MessageEventType) + assert "scheduled" 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) @@ -278,3 +312,5 @@ def test_generated_types_match_current_team_schema(self) -> None: 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")