From 37c4184eee44561196e66af05297ed8fdbd3ff84 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 12 Aug 2026 23:12:39 +0200 Subject: [PATCH 1/2] Sync SDK with current OpenAPI specifications --- src/lettermint/endpoints/api.py | 117 +++++-------- src/lettermint/types.py | 285 ++++++++++++++++++++++---------- tests/test_api_surface.py | 47 +++++- 3 files changed, 280 insertions(+), 169 deletions(-) diff --git a/src/lettermint/endpoints/api.py b/src/lettermint/endpoints/api.py index 8b3c6af..d35a6b9 100644 --- a/src/lettermint/endpoints/api.py +++ b/src/lettermint/endpoints/api.py @@ -138,44 +138,6 @@ def rotate_token(self, project_id: str) -> lm_types.ProjectRotateTokenResponse: ), ) - def update_members( - self, project_id: str, data: lm_types.ProjectUpdateMembersRequest - ) -> lm_types.ProjectUpdateMembersResponse: - return cast( - lm_types.ProjectUpdateMembersResponse, - self._client.put( - self._path("/projects/{projectId}/members", projectId=project_id), - data=data, - ), - ) - - def add_member(self, project_id: str, team_member_id: str) -> lm_types.ProjectAddMemberResponse: - return cast( - lm_types.ProjectAddMemberResponse, - self._client.post( - self._path( - "/projects/{projectId}/members/{teamMemberId}", - projectId=project_id, - teamMemberId=team_member_id, - ), - data={}, - ), - ) - - def remove_member( - self, project_id: str, team_member_id: str - ) -> lm_types.ProjectRemoveMemberResponse: - return cast( - lm_types.ProjectRemoveMemberResponse, - self._client.delete( - self._path( - "/projects/{projectId}/members/{teamMemberId}", - projectId=project_id, - teamMemberId=team_member_id, - ) - ), - ) - def routes(self, project_id: str, query: Query | None = None) -> lm_types.RouteIndexResponse: return cast( lm_types.RouteIndexResponse, @@ -263,9 +225,29 @@ def update(self, data: lm_types.TeamUpdateRequest) -> lm_types.TeamUpdateRespons def usage(self) -> lm_types.TeamUsageResponse: return cast(lm_types.TeamUsageResponse, self._client.get("/team/usage")) + def roles(self) -> lm_types.TeamRolesResponse: + return cast(lm_types.TeamRolesResponse, self._client.get("/team/roles")) + def members(self, query: Query | None = None) -> lm_types.TeamMembersResponse: return cast(lm_types.TeamMembersResponse, self._client.get("/team/members", params=query)) + def member(self, user_id: str) -> lm_types.TeamMembersShowResponse: + return cast( + lm_types.TeamMembersShowResponse, + self._client.get(self._path("/team/members/{userId}", userId=user_id)), + ) + + def update_member_assignment( + self, user_id: str, data: lm_types.TeamMembersAssignmentUpdateRequest + ) -> lm_types.TeamMembersAssignmentUpdateResponse: + return cast( + lm_types.TeamMembersAssignmentUpdateResponse, + self._client.put( + self._path("/team/members/{userId}/assignment", userId=user_id), + data=data, + ), + ) + class WebhooksEndpoint(Endpoint): def list(self, query: Query | None = None) -> lm_types.WebhookIndexResponse: @@ -481,45 +463,6 @@ async def rotate_token(self, project_id: str) -> lm_types.ProjectRotateTokenResp ), ) - async def update_members( - self, project_id: str, data: lm_types.ProjectUpdateMembersRequest - ) -> lm_types.ProjectUpdateMembersResponse: - return cast( - lm_types.ProjectUpdateMembersResponse, - await self._client.put( - self._path("/projects/{projectId}/members", projectId=project_id), data=data - ), - ) - - async def add_member( - self, project_id: str, team_member_id: str - ) -> lm_types.ProjectAddMemberResponse: - return cast( - lm_types.ProjectAddMemberResponse, - await self._client.post( - self._path( - "/projects/{projectId}/members/{teamMemberId}", - projectId=project_id, - teamMemberId=team_member_id, - ), - data={}, - ), - ) - - async def remove_member( - self, project_id: str, team_member_id: str - ) -> lm_types.ProjectRemoveMemberResponse: - return cast( - lm_types.ProjectRemoveMemberResponse, - await self._client.delete( - self._path( - "/projects/{projectId}/members/{teamMemberId}", - projectId=project_id, - teamMemberId=team_member_id, - ) - ), - ) - async def routes( self, project_id: str, query: Query | None = None ) -> lm_types.RouteIndexResponse: @@ -612,11 +555,31 @@ async def update(self, data: lm_types.TeamUpdateRequest) -> lm_types.TeamUpdateR async def usage(self) -> lm_types.TeamUsageResponse: return cast(lm_types.TeamUsageResponse, await self._client.get("/team/usage")) + async def roles(self) -> lm_types.TeamRolesResponse: + return cast(lm_types.TeamRolesResponse, await self._client.get("/team/roles")) + async def members(self, query: Query | None = None) -> lm_types.TeamMembersResponse: return cast( lm_types.TeamMembersResponse, await self._client.get("/team/members", params=query) ) + async def member(self, user_id: str) -> lm_types.TeamMembersShowResponse: + return cast( + lm_types.TeamMembersShowResponse, + await self._client.get(self._path("/team/members/{userId}", userId=user_id)), + ) + + async def update_member_assignment( + self, user_id: str, data: lm_types.TeamMembersAssignmentUpdateRequest + ) -> lm_types.TeamMembersAssignmentUpdateResponse: + return cast( + lm_types.TeamMembersAssignmentUpdateResponse, + await self._client.put( + self._path("/team/members/{userId}/assignment", userId=user_id), + data=data, + ), + ) + class AsyncWebhooksEndpoint(AsyncEndpoint): async def list(self, query: Query | None = None) -> lm_types.WebhookIndexResponse: diff --git a/src/lettermint/types.py b/src/lettermint/types.py index 56bafca..aeff92e 100644 --- a/src/lettermint/types.py +++ b/src/lettermint/types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Literal, TypedDict, Union +from typing import Any, Literal, TypedDict from typing_extensions import NotRequired, Required, TypeAlias @@ -22,29 +22,49 @@ "policy_rejected", "unsubscribed", ] +TlsPolicy: TypeAlias = Literal["opportunistic", "enforced"] SendMailRequest = TypedDict( "SendMailRequest", { "route": "NotRequired[str]", "from": "Required[str]", - "subject": "Required[str]", - "tag": "NotRequired[str | None]", - "html": "NotRequired[str | None]", - "text": "NotRequired[str | None]", "to": "Required[list[str]]", "cc": "NotRequired[list[str]]", "bcc": "NotRequired[list[str]]", "reply_to": "NotRequired[list[str]]", + "subject": "Required[str]", "headers": "NotRequired[dict[str, str]]", "metadata": "NotRequired[dict[str, str]]", + "tag": "NotRequired[str | None]", "settings": "NotRequired[dict[str, Any] | None]", + "html": "NotRequired[str | None]", + "text": "NotRequired[str | None]", "attachments": "NotRequired[list[dict[str, Any]]]", }, ) SendBatchMailRequest: TypeAlias = list[dict[str, Any]] AttachmentDelivery: TypeAlias = Literal["inline", "url"] +BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] +CursorPaginator = TypedDict( + "CursorPaginator", + { + "data": "Required[list[str]]", + "path": "Required[str | None]", + "per_page": "Required[int]", + "next_cursor": "Required[str | None]", + "next_page_url": "Required[str | None]", + "prev_cursor": "Required[str | None]", + "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" +] DnsRecordStatus: TypeAlias = Literal["active", "failed", "pending"] +DnsVerificationScope: TypeAlias = Literal["required", "recommended", "migration", "deprecated"] RecordType: TypeAlias = Literal["TXT", "CNAME", "MX"] DomainDnsRecordData = TypedDict( "DomainDnsRecordData", @@ -55,6 +75,9 @@ "fqdn": "Required[str]", "content": "Required[str]", "status": "Required[DnsRecordStatus]", + "purpose": "Required[DnsRecordPurpose]", + "verification_scope": "Required[DnsVerificationScope]", + "required_for_verification": "Required[bool]", "verified_at": "Required[str | None]", "last_checked_at": "Required[str | None]", }, @@ -65,6 +88,8 @@ { "id": "Required[str]", "domain": "Required[str]", + "dkim_mode": "Required[DkimMode]", + "rotation_ready": "Required[bool]", "status_changed_at": "Required[str | None]", "dns_records": "NotRequired[list[DomainDnsRecordData]]", "projects": "NotRequired[list[dict[str, Any]]]", @@ -81,6 +106,7 @@ "id": "Required[str]", "domain": "Required[str]", "status": "Required[DomainStatus]", + "dkim_mode": "Required[DkimMode]", "status_changed_at": "Required[str | None]", "created_at": "Required[str]", }, @@ -177,6 +203,7 @@ "id": "Required[str]", "type": "Required[MessageType]", "status": "Required[MessageStatus]", + "spam_score": "NotRequired[float | None]", "from_email": "Required[str]", "from_name": "Required[str | None]", "subject": "Required[str | None]", @@ -185,6 +212,7 @@ "bcc": "Required[list[MessageRecipientData] | None]", "reply_to": "Required[list[str] | None]", "tag": "Required[str | None]", + "status_changed_at": "Required[str | None]", "created_at": "Required[str]", }, ) @@ -200,26 +228,8 @@ ) Plan: TypeAlias = Literal["free", "starter", "growth", "pro"] -UserData = TypedDict( - "UserData", - { - "id": "Required[str]", - "name": "Required[str]", - "email": "Required[str]", - "avatar": "Required[str | None]", - }, -) - -TeamMemberData = TypedDict( - "TeamMemberData", - { - "id": "Required[str]", - "user": "NotRequired[UserData]", - "role": "Required[str | None]", - "joined_at": "Required[str | None]", - }, -) - +ProjectAccessScope: TypeAlias = Literal["all", "selected"] +RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] RouteStatisticData = TypedDict( "RouteStatisticData", { @@ -231,13 +241,15 @@ "hard_bounce_count": "Required[int]", "spam_complaint_count": "Required[int]", "inbound_received_count": "Required[int]", + "observed_opened_count": "NotRequired[int | None]", + "human_opened_count": "NotRequired[int | None]", + "privacy_opened_count": "NotRequired[int | None]", "effective_opened_count": "Required[int | None]", "machine_opened_count": "Required[int | None]", "machine_clicked_count": "Required[int | None]", }, ) -RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] RouteData = TypedDict( "RouteData", { @@ -247,15 +259,16 @@ "name": "Required[str]", "route_type": "Required[RouteType]", "is_default": "Required[bool]", - "inbound_address": "NotRequired[str]", - "inbound_domain": "NotRequired[str]", - "inbound_domain_verified_at": "NotRequired[str]", - "inbound_spam_threshold": "NotRequired[float]", + "inbound_address": "NotRequired[str | None]", + "inbound_domain": "NotRequired[str | None]", + "inbound_domain_verified_at": "NotRequired[str | None]", + "inbound_spam_threshold": "NotRequired[float | None]", "attachment_delivery": "NotRequired[AttachmentDelivery]", + "settings": "NotRequired[dict[str, Any] | None]", "project": "NotRequired[ProjectData]", "webhooks_count": "NotRequired[int]", "suppressed_recipients_count": "NotRequired[int]", - "statistics": "NotRequired[dict[str, Any] | list[RouteStatisticData]]", + "statistics": "NotRequired[list[RouteStatisticData]]", "created_at": "Required[str]", "updated_at": "Required[str]", }, @@ -276,9 +289,7 @@ "routes_count": "NotRequired[int]", "domains": "NotRequired[list[DomainData]]", "domains_count": "NotRequired[int]", - "team_members": "NotRequired[list[TeamMemberData]]", - "team_members_count": "NotRequired[int]", - "last_28_days": "NotRequired[MessageStatsData | Any]", + "last_28_days": "NotRequired[MessageStatsData | None]", "created_at": "Required[str]", "updated_at": "Required[str]", }, @@ -292,13 +303,61 @@ "smtp_enabled": "Required[bool]", "routes_count": "Required[int]", "domains_count": "Required[int]", - "team_members_count": "Required[int]", "last_28_days": "Required[MessageStatsData]", "created_at": "Required[str]", "updated_at": "Required[str]", }, ) +RbacConflictCode: TypeAlias = Literal[ + "stale_resource", + "owner_protected", + "last_owner", + "built_in_role_immutable", + "custom_role_requires_pro", +] +RbacPermission: TypeAlias = Literal[ + "team:manage", + "billing:manage", + "security:manage", + "audit:read", + "support:manage", + "members:read", + "members:manage", + "roles:manage", + "team_tokens:read", + "team_tokens:manage", + "team_tokens:rotate", + "team_tokens:revoke", + "projects:create", + "team_suppressions:read", + "team_suppressions:add", + "team_suppressions:remove", + "projects:read", + "projects:manage", + "projects:delete", + "routes:read", + "routes:manage", + "routes:delete", + "domains:read", + "domains:manage", + "domains:delete", + "project_tokens:read", + "project_tokens:manage", + "project_tokens:rotate", + "project_tokens:revoke", + "webhooks:read", + "webhooks:manage", + "webhooks:delete", + "webhooks:rotate_secret", + "stats:read", + "messages:read", + "messages:read_content", + "messages:send", + "suppressions:read", + "suppressions:add", + "suppressions:remove", +] RouteListData = TypedDict( "RouteListData", { @@ -341,8 +400,11 @@ "opened": "Required[int | None]", "clicked": "Required[int | None]", "inbound": "Required[StatsInboundData]", - "transactional": "Required[StatsTypeData | Any]", - "broadcast": "Required[StatsTypeData | Any]", + "transactional": "Required[StatsTypeData | None]", + "broadcast": "Required[StatsTypeData | None]", + "observed_opened": "NotRequired[int | None]", + "human_opened": "NotRequired[int | None]", + "privacy_opened": "NotRequired[int | None]", "effective_opened": "Required[int | None]", "machine_opened": "Required[int | None]", "machine_clicked": "Required[int | None]", @@ -359,8 +421,11 @@ "opened": "Required[int | None]", "clicked": "Required[int | None]", "inbound": "Required[StatsInboundData]", - "transactional": "Required[StatsTypeData | Any]", - "broadcast": "Required[StatsTypeData | Any]", + "transactional": "Required[StatsTypeData | None]", + "broadcast": "Required[StatsTypeData | None]", + "observed_opened": "NotRequired[int | None]", + "human_opened": "NotRequired[int | None]", + "privacy_opened": "NotRequired[int | None]", "effective_opened": "Required[int | None]", "machine_opened": "Required[int | None]", "machine_clicked": "Required[int | None]", @@ -419,11 +484,11 @@ "StoreSuppressionData", { "email": "NotRequired[str | None]", + "emails": "NotRequired[list[str] | None]", "reason": "Required[SuppressionReason]", "scope": "Required[SuppressionScope]", "route_id": "NotRequired[str | None]", "project_id": "NotRequired[str | None]", - "emails": "NotRequired[list[str] | None]", }, ) @@ -442,6 +507,8 @@ "message.clicked", "message.inbound", "message.policy_rejected", + "suppression.added", + "suppression.removed", "webhook.test", ] StoreWebhookData = TypedDict( @@ -450,13 +517,23 @@ "route_id": "Required[str]", "name": "Required[str]", "url": "Required[str]", + "events": "Required[list[WebhookEvent]]", "enabled": "NotRequired[bool | None]", "include_machine_events": "NotRequired[bool | None]", - "events": "Required[list[WebhookEvent]]", }, ) SuppressionType: TypeAlias = Literal["email", "domain", "extension"] +SuppressionSourceMessageData = TypedDict( + "SuppressionSourceMessageData", + { + "id": "Required[str]", + "available": "Required[bool]", + "subject": "Required[str | None]", + "created_at": "Required[str | None]", + }, +) + SuppressedRecipientData = TypedDict( "SuppressedRecipientData", { @@ -467,6 +544,7 @@ "scope": "Required[SuppressionScope]", "project_id": "Required[str | None]", "route_id": "Required[str | None]", + "source_message": "NotRequired[SuppressionSourceMessageData | None]", "created_at": "Required[str]", "updated_at": "Required[str]", }, @@ -481,7 +559,6 @@ ) TeamType: TypeAlias = Literal["personal", "business"] -VolumeTier: TypeAlias = Literal[300, 10000, 50000, 125000, 300000, 500000, 750000, 1000000, 1500000] TeamData = TypedDict( "TeamData", { @@ -489,7 +566,8 @@ "name": "Required[str]", "type": "Required[TeamType]", "plan": "Required[Plan]", - "tier": "Required[VolumeTier]", + "included_volume": "Required[int]", + "tier": "Required[int]", "verified_at": "Required[str | None]", "features": "NotRequired[list[str]]", "addons": "NotRequired[list[TeamAddonData]]", @@ -500,6 +578,37 @@ }, ) +TeamMemberProjectAccessData = TypedDict( + "TeamMemberProjectAccessData", + { + "scope": "Required[ProjectAccessScope]", + "projects": "Required[list[dict[str, Any]]]", + }, +) + +TeamMemberData = TypedDict( + "TeamMemberData", + { + "id": "Required[str]", + "name": "Required[str]", + "email": "Required[str]", + "role": "Required[dict[str, Any]]", + "project_access": "Required[TeamMemberProjectAccessData]", + "joined_at": "Required[str | None]", + }, +) + +TeamRoleData = TypedDict( + "TeamRoleData", + { + "id": "Required[str]", + "name": "Required[str]", + "system_key": "Required[BuiltInTeamRole | None]", + "permissions": "Required[list[RbacPermission]]", + "assignable": "Required[bool]", + }, +) + TeamUsagePeriodData = TypedDict( "TeamUsagePeriodData", { @@ -535,10 +644,16 @@ }, ) -UpdateProjectMembersData = TypedDict( - "UpdateProjectMembersData", +UpdateRouteSettingsData = TypedDict( + "UpdateRouteSettingsData", { - "team_member_ids": "Required[list[str]]", + "track_opens": "NotRequired[bool | None]", + "track_clicks": "NotRequired[bool | None]", + "generate_plaintext_fallback": "NotRequired[bool | None]", + "suppress_auto_responders": "NotRequired[bool | None]", + "tls": "NotRequired[TlsPolicy | None]", + "disable_hosted_unsubscribe": "NotRequired[bool | None]", + "redact_email_content": "NotRequired[bool | None]", }, ) @@ -547,18 +662,7 @@ { "inbound_domain": "NotRequired[str | None]", "inbound_spam_threshold": "NotRequired[float | None]", - "attachment_delivery": "NotRequired[AttachmentDelivery | Any]", - }, -) - -UpdateRouteSettingsData = TypedDict( - "UpdateRouteSettingsData", - { - "track_opens": "NotRequired[bool | None]", - "track_clicks": "NotRequired[bool | None]", - "disable_plaintext_generation": "NotRequired[bool | None]", - "disable_hosted_unsubscribe": "NotRequired[bool | None]", - "redact_email_content": "NotRequired[bool | None]", + "attachment_delivery": "NotRequired[AttachmentDelivery | None]", }, ) @@ -566,8 +670,8 @@ "UpdateRouteData", { "name": "NotRequired[str | None]", - "settings": "NotRequired[UpdateRouteSettingsData | Any]", - "inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | Any]", + "settings": "NotRequired[UpdateRouteSettingsData | None]", + "inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | None]", }, ) @@ -578,14 +682,22 @@ }, ) +UpdateTeamMemberAssignmentData = TypedDict( + "UpdateTeamMemberAssignmentData", + { + "role_id": "Required[str]", + "project_access": "Required[dict[str, Any]]", + }, +) + UpdateWebhookData = TypedDict( "UpdateWebhookData", { "name": "NotRequired[str]", "url": "NotRequired[str]", + "events": "NotRequired[list[WebhookEvent]]", "enabled": "NotRequired[bool]", "include_machine_events": "NotRequired[bool]", - "events": "NotRequired[list[WebhookEvent]]", }, ) @@ -706,6 +818,7 @@ "DomainVerifyDnsRecordsResponse", { "message": "Required[str]", + "recommended_failed_records": "Required[list[dict[str, Any]]]", }, ) @@ -733,7 +846,15 @@ }, ) -MessageIndexResponse: TypeAlias = Union[dict[str, Any], list[MessageListData]] +MessageIndexResponse = TypedDict( + "MessageIndexResponse", + { + "data": "Required[list[MessageListData]]", + "links": "Required[list[str]]", + "meta": "Required[dict[str, Any]]", + }, +) + MessageShowResponse: TypeAlias = MessageData MessageEventsResponse = TypedDict( "MessageEventsResponse", @@ -793,30 +914,7 @@ { "data": "Required[ProjectData]", "new_token": "Required[str]", - "message": "Required[Literal['Project API token rotated successfully. Please update your integrations.']]", - }, -) - -ProjectUpdateMembersRequest: TypeAlias = UpdateProjectMembersData -ProjectUpdateMembersResponse = TypedDict( - "ProjectUpdateMembersResponse", - { - "data": "Required[ProjectData]", - "message": "Required[Literal['Project members updated successfully.']]", - }, -) - -ProjectAddMemberResponse = TypedDict( - "ProjectAddMemberResponse", - { - "message": "Required[Literal['Team member added to project successfully.']]", - }, -) - -ProjectRemoveMemberResponse = TypedDict( - "ProjectRemoveMemberResponse", - { - "message": "Required[Literal['Team member removed from project successfully.']]", + "message": "Required[Literal['API token rotated successfully. Please update your integrations.']]", }, ) @@ -892,7 +990,10 @@ SuppressionDestroyResponse = TypedDict( "SuppressionDestroyResponse", { - "message": "Required[Literal['Email removed from suppression list successfully.']]", + "success": "Required[bool]", + "status": "Required[Literal['removed']]", + "message": "Required[str]", + "confidence": "NotRequired[float]", }, ) @@ -907,6 +1008,13 @@ ) TeamUsageResponse: TypeAlias = TeamUsageDetailData +TeamRolesResponse = TypedDict( + "TeamRolesResponse", + { + "data": "Required[list[TeamRoleData]]", + }, +) + TeamMembersResponse = TypedDict( "TeamMembersResponse", { @@ -920,6 +1028,9 @@ }, ) +TeamMembersShowResponse: TypeAlias = TeamMemberData +TeamMembersAssignmentUpdateRequest: TypeAlias = UpdateTeamMemberAssignmentData +TeamMembersAssignmentUpdateResponse: TypeAlias = TeamMemberData WebhookIndexResponse = TypedDict( "WebhookIndexResponse", { diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 76f10e7..1b43c4c 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -93,6 +93,9 @@ def test_async_api_exposes_full_endpoint_groups(self) -> None: assert hasattr(api, "team") assert hasattr(api, "webhooks") assert hasattr(api, "blocked_file_types") + assert hasattr(api.team, "roles") + assert hasattr(api.team, "member") + assert hasattr(api.team, "update_member_assignment") class TestSendingEndpoint: @@ -165,6 +168,31 @@ def test_message_raw_body_endpoints(self) -> None: assert html_route.called assert text_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( + return_value=Response(200, json={"data": []}) + ) + member_route = respx.get("https://api.lettermint.co/v1/team/members/user%2Fid").mock( + return_value=Response(200, json={"id": "user/id"}) + ) + assignment_route = respx.put( + "https://api.lettermint.co/v1/team/members/user%2Fid/assignment" + ).mock(return_value=Response(200, json={"id": "user/id"})) + assignment: lm_types.TeamMembersAssignmentUpdateRequest = { + "role_id": "role_123", + "project_access": {"scope": "all"}, + } + + with Lettermint.api("api-token") as api: + assert api.team.roles()["data"] == [] + assert api.team.member("user/id")["id"] == "user/id" + assert api.team.update_member_assignment("user/id", assignment)["id"] == "user/id" + + assert roles_route.called + assert member_route.called + assert json.loads(assignment_route.calls.last.request.content) == assignment + def test_documented_operations_are_exposed(self) -> None: operations = [ (Lettermint.email("token"), "send"), @@ -191,9 +219,6 @@ def test_documented_operations_are_exposed(self) -> None: (Lettermint.api("token").projects, "update"), (Lettermint.api("token").projects, "delete"), (Lettermint.api("token").projects, "rotate_token"), - (Lettermint.api("token").projects, "update_members"), - (Lettermint.api("token").projects, "add_member"), - (Lettermint.api("token").projects, "remove_member"), (Lettermint.api("token").projects, "routes"), (Lettermint.api("token").projects, "create_route"), (Lettermint.api("token").routes, "retrieve"), @@ -207,7 +232,10 @@ def test_documented_operations_are_exposed(self) -> None: (Lettermint.api("token").team, "retrieve"), (Lettermint.api("token").team, "update"), (Lettermint.api("token").team, "usage"), + (Lettermint.api("token").team, "roles"), (Lettermint.api("token").team, "members"), + (Lettermint.api("token").team, "member"), + (Lettermint.api("token").team, "update_member_assignment"), (Lettermint.api("token").webhooks, "list"), (Lettermint.api("token").webhooks, "create"), (Lettermint.api("token").webhooks, "retrieve"), @@ -226,7 +254,9 @@ def test_documented_operations_are_exposed(self) -> None: def test_generated_types_match_current_team_schema(self) -> None: assert "auto_replied" in get_args(lm_types.MessageEventType) assert "message.auto_replied" in get_args(lm_types.WebhookEvent) - assert 300000 in get_args(lm_types.VolumeTier) + 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 "short_token" in lm_types.StoreProjectData.__annotations__ @@ -238,5 +268,12 @@ def test_generated_types_match_current_team_schema(self) -> None: assert "extensions" in lm_types.BlockedFileTypesResponse.__annotations__ assert "mime_types" in lm_types.BlockedFileTypesResponse.__annotations__ assert "redact_email_content" in lm_types.UpdateRouteSettingsData.__annotations__ - assert "disable_plaintext_generation" 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__ From 15d3efd4946fbdd4127a78dc9a6e115069ad32ac Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Thu, 13 Aug 2026 11:39:42 +0200 Subject: [PATCH 2/2] Complete email settings, attachments, and batch idempotency --- src/lettermint/endpoints/email.py | 50 ++++++++++++++++++++--- src/lettermint/types.py | 2 +- tests/test_api_surface.py | 3 +- tests/test_email.py | 68 +++++++++++++++++++++++++++---- 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/lettermint/endpoints/email.py b/src/lettermint/endpoints/email.py index 1d20a28..087769a 100644 --- a/src/lettermint/endpoints/email.py +++ b/src/lettermint/endpoints/email.py @@ -12,7 +12,7 @@ else: from typing_extensions import Self -from ..types import SendBatchEmailResponse, SendEmailResponse +from ..types import SendBatchEmailResponse, SendBatchMailRequest, SendEmailResponse, TlsPolicy from .endpoint import AsyncEndpoint, Endpoint if TYPE_CHECKING: @@ -207,6 +207,7 @@ def attach( filename: str, content: str, content_id: str | None = None, + content_type: str | None = None, ) -> Self: """Attach a file to the email. @@ -214,6 +215,7 @@ def attach( filename: The attachment filename. content: The base64-encoded file content. content_id: Optional Content-ID for inline attachments. + content_type: Optional MIME type for the attachment. Returns: The current instance for method chaining. @@ -233,10 +235,17 @@ def attach( } if content_id is not None: attachment["content_id"] = content_id + if content_type is not None: + attachment["content_type"] = content_type self._payload["attachments"].append(attachment) return self + def settings(self, settings: dict[str, bool | TlsPolicy]) -> Self: + """Set per-email settings that override the selected route.""" + self._payload["settings"] = settings + return self + def metadata(self, metadata: dict[str, str]) -> Self: """Set metadata for the email. @@ -297,10 +306,21 @@ def send(self) -> SendEmailResponse: finally: self._reset() - def send_batch(self, payload: list[dict[str, Any]]) -> SendBatchEmailResponse: + def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailResponse: """Send multiple emails in one batch.""" - response: SendBatchEmailResponse = self._client.post("/send/batch", data=payload) - return response + headers: dict[str, str] | None = None + if self._idempotency_key is not None: + headers = {"Idempotency-Key": self._idempotency_key} + + try: + response: SendBatchEmailResponse = self._client.post( + "/send/batch", + data=payload, + headers=headers, + ) + return response + finally: + self._reset() def ping(self) -> str: """Ping the Sending API.""" @@ -478,6 +498,7 @@ def attach( filename: str, content: str, content_id: str | None = None, + content_type: str | None = None, ) -> Self: """Attach a file to the email. @@ -485,6 +506,7 @@ def attach( filename: The attachment filename. content: The base64-encoded file content. content_id: Optional Content-ID for inline attachments. + content_type: Optional MIME type for the attachment. Returns: The current instance for method chaining. @@ -498,10 +520,17 @@ def attach( } if content_id is not None: attachment["content_id"] = content_id + if content_type is not None: + attachment["content_type"] = content_type self._payload["attachments"].append(attachment) return self + def settings(self, settings: dict[str, bool | TlsPolicy]) -> Self: + """Set per-email settings that override the selected route.""" + self._payload["settings"] = settings + return self + def metadata(self, metadata: dict[str, str]) -> Self: """Set metadata for the email. @@ -554,9 +583,18 @@ async def _send() -> SendEmailResponse: return _send() - async def send_batch(self, payload: list[dict[str, Any]]) -> SendBatchEmailResponse: + async def send_batch(self, payload: SendBatchMailRequest) -> SendBatchEmailResponse: """Send multiple emails in one batch asynchronously.""" - response: SendBatchEmailResponse = await self._client.post("/send/batch", data=payload) + headers: dict[str, str] | None = None + if self._idempotency_key is not None: + headers = {"Idempotency-Key": self._idempotency_key} + self._reset() + + response: SendBatchEmailResponse = await self._client.post( + "/send/batch", + data=payload, + headers=headers, + ) return response async def ping(self) -> str: diff --git a/src/lettermint/types.py b/src/lettermint/types.py index aeff92e..bd87644 100644 --- a/src/lettermint/types.py +++ b/src/lettermint/types.py @@ -43,7 +43,7 @@ }, ) -SendBatchMailRequest: TypeAlias = list[dict[str, Any]] +SendBatchMailRequest: TypeAlias = list[SendMailRequest] AttachmentDelivery: TypeAlias = Literal["inline", "url"] BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] CursorPaginator = TypedDict( diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 1b43c4c..e1ed555 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -106,7 +106,7 @@ def test_send_batch_posts_list_payload(self) -> None: ) with Lettermint.email("sending-token") as email: - response = email.send_batch( + response = email.idempotency_key("batch-key").send_batch( [ { "from": "sender@example.com", @@ -118,6 +118,7 @@ def test_send_batch_posts_list_payload(self) -> None: assert response[0]["message_id"] == "msg_123" assert json.loads(route.calls.last.request.content)[0]["subject"] == "Hello" + assert route.calls.last.request.headers["Idempotency-Key"] == "batch-key" class TestFullApiEndpoints: diff --git a/tests/test_email.py b/tests/test_email.py index 1d68b9a..596c2c2 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -121,7 +121,7 @@ def test_send_with_attachments(self, api_token: str) -> None: client.email.from_("sender@example.com").to("recipient@example.com").subject( "Test" ).attach("document.pdf", "base64content").attach( - "logo.png", "base64image", "logo@example.com" + "logo.png", "base64image", "logo@example.com", "image/png" ).send() import json @@ -133,6 +133,7 @@ def test_send_with_attachments(self, api_token: str) -> None: "filename": "logo.png", "content": "base64image", "content_id": "logo@example.com", + "content_type": "image/png", } @respx.mock @@ -145,12 +146,20 @@ 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({"X-Custom-Header": "value"}).send() + ).headers( + { + "Message-ID": "", + "X-LM-Preserve-Message-ID": "true", + } + ).send() import json body = json.loads(route.calls.last.request.content) - assert body["headers"] == {"X-Custom-Header": "value"} + assert body["headers"] == { + "Message-ID": "", + "X-LM-Preserve-Message-ID": "true", + } @respx.mock def test_send_with_idempotency_key(self, api_token: str) -> None: @@ -177,13 +186,20 @@ 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").send() + ).metadata({"campaign_id": "123"}).tag("welcome").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["settings"] == { + "track_opens": False, + "track_clicks": True, + "tls": "enforced", + } @respx.mock def test_send_with_route(self, api_token: str) -> None: @@ -321,10 +337,16 @@ 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({"X-Custom": "value"}) - .attach("file.pdf", "base64content") + .headers( + { + "Message-ID": "", + "X-LM-Preserve-Message-ID": "true", + } + ) + .attach("file.pdf", "base64content", None, "application/pdf") .metadata({"key": "value"}) .tag("campaign") + .settings({"track_opens": False, "tls": "enforced"}) .route("my-route") .idempotency_key("unique-key") .send() @@ -341,15 +363,45 @@ 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"] == {"X-Custom": "value"} - assert body["attachments"] == [{"filename": "file.pdf", "content": "base64content"}] + 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["metadata"] == {"key": "value"} assert body["tag"] == "campaign" assert body["route"] == "my-route" + assert body["settings"] == {"track_opens": False, "tls": "enforced"} request = route.calls.last.request assert request.headers["Idempotency-Key"] == "unique-key" + @respx.mock + @pytest.mark.asyncio + async def test_send_batch_with_idempotency_key_async(self, api_token: str) -> None: + """Test an asynchronous batch idempotency key.""" + route = respx.post("https://api.lettermint.co/v1/send/batch").mock( + return_value=Response(200, json=[{"message_id": "msg_123", "status": "pending"}]) + ) + payload = [ + { + "from": "sender@example.com", + "to": ["recipient@example.com"], + "subject": "Test", + } + ] + + async with AsyncLettermint(api_token=api_token) as client: + await client.email.idempotency_key("batch-key").send_batch(payload) + + assert route.calls.last.request.headers["Idempotency-Key"] == "batch-key" + @respx.mock @pytest.mark.asyncio async def test_deferred_async_sends_use_payload_snapshots(self, api_token: str) -> None: