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
28 changes: 28 additions & 0 deletions src/lettermint/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/lettermint/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/lettermint/endpoints/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions src/lettermint/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing_extensions import NotRequired, Required, TypeAlias

MessageStatus: TypeAlias = Literal[
"scheduled",
"pending",
"queued",
"suppressed",
Expand All @@ -21,6 +22,7 @@
"blocked",
"policy_rejected",
"unsubscribed",
"canceled",
]
TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"]
SendMailRequest = TypedDict(
Expand All @@ -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]",
Expand Down Expand Up @@ -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]",
Expand All @@ -169,6 +173,10 @@
)

MessageEventType: TypeAlias = Literal[
"scheduled",
"rescheduled",
"canceled",
"released",
"queued",
"processed",
"suppressed",
Expand Down Expand Up @@ -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]",
Expand Down Expand Up @@ -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]",
},
)

Expand Down
38 changes: 37 additions & 1 deletion tests/test_api_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand All @@ -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")