From 49d51fce58c6fad7c5caec33a69bc0499167dd41 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Tue, 20 Oct 2026 22:37:53 +0200 Subject: [PATCH 1/2] chore: regenerate types from the latest API spec - New team invite operations (team token): GET /team/invites (cursor paginated), GET /team/invites/{inviteId}, POST /team/invites (201), PUT /team/invites/{inviteId} and DELETE /team/invites/{inviteId}. New TeamInviteData, CreateTeamInviteData, UpdateTeamInviteData, ListTeamInvitesQuery and ListTeamInvitesResponse types. - New team role operations (team token): GET /team/roles/{roleId}, POST /team/roles (201), PUT /team/roles/{roleId}, DELETE /team/roles/{roleId} with an optional replacement_role_id query (DeleteTeamRoleQuery) and POST /team/roles/{roleId}/duplicate (201). New CreateTeamRoleData, UpdateTeamRoleData and DuplicateTeamRoleData. - New DELETE /team/members/{userId} (team token) removes a non-Owner member. - The delete and revoke operations return a MessageResponse; the role, invite and member-removal operations can answer 409 with an RbacConflictException. - GET /team/members gains an optional filter[project_id] query (ListTeamMembersQueryFilter). - TeamRoleData gains an optional, nullable description. Co-Authored-By: Claude --- src/lettermint/_generated/__init__.py | 4 +- src/lettermint/_generated/operations.py | 180 +++++++++++++++++++++++- src/lettermint/_generated/types.py | 152 +++++++++++++++++++- 3 files changed, 330 insertions(+), 6 deletions(-) diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py index 91a996d..abbc898 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@6ef4036dede6326ad2200b945e689e319faf4c0a (main) +# Spec: lettermint/lettermint@a14a098fd3173b67bc366163127ee3715457cd59 (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 +# team-openapi.json SHA-256: da4398ead370c9b61eadb4007087a6da945df3c609801325fd38fc780cc0e707 # 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 5281bed..e85805a 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@6ef4036dede6326ad2200b945e689e319faf4c0a (main) +# Spec: lettermint/lettermint@a14a098fd3173b67bc366163127ee3715457cd59 (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 +# team-openapi.json SHA-256: da4398ead370c9b61eadb4007087a6da945df3c609801325fd38fc780cc0e707 # Naming profile: next """Every operation of the Sending and Team APIs, keyed by ``" "``. @@ -145,6 +145,54 @@ class Operation: pagination=None, deprecated=False, ), + # Revoke a pending team invite + "DELETE /team/invites/{inviteId}": Operation( + operation_id="deleteTeamInvite", + method="DELETE", + path="/team/invites/{inviteId}", + auth="team", + surfaces=("team",), + path_params=("inviteId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Remove a team member + "DELETE /team/members/{userId}": Operation( + operation_id="deleteTeamMember", + method="DELETE", + path="/team/members/{userId}", + auth="team", + surfaces=("team",), + path_params=("userId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Delete a custom team role + "DELETE /team/roles/{roleId}": Operation( + operation_id="deleteTeamRole", + method="DELETE", + path="/team/roles/{roleId}", + auth="team", + surfaces=("team",), + path_params=("roleId",), + query="DeleteTeamRoleQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="MessageResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Delete a webhook "DELETE /webhooks/{webhookId}": Operation( operation_id="deleteWebhook", @@ -449,6 +497,38 @@ class Operation: pagination=None, deprecated=False, ), + # Get team invites + "GET /team/invites": Operation( + operation_id="listTeamInvites", + method="GET", + path="/team/invites", + auth="team", + surfaces=("team",), + path_params=(), + query="ListTeamInvitesQuery", + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="ListTeamInvitesResponse"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 429: ("TooManyRequests",)}, + pagination=Pagination(style="cursor", items="TeamInviteData", cursor_param="page[cursor]", size_param="page[size]"), + deprecated=False, + ), + # Get a team invite + "GET /team/invites/{inviteId}": Operation( + operation_id="getTeamInvite", + method="GET", + path="/team/invites/{inviteId}", + auth="team", + surfaces=("team",), + path_params=("inviteId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamInviteData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Get team members "GET /team/members": Operation( operation_id="listTeamMembers", @@ -497,6 +577,22 @@ class Operation: pagination=None, deprecated=False, ), + # Get a team role + "GET /team/roles/{roleId}": Operation( + operation_id="getTeamRole", + method="GET", + path="/team/roles/{roleId}", + auth="team", + surfaces=("team",), + path_params=("roleId",), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(200,), content_type="application/json", type="TeamRoleData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Get team usage statistics "GET /team/usage": Operation( operation_id="getTeamUsage", @@ -850,6 +946,54 @@ class Operation: pagination=None, deprecated=False, ), + # Invite a person to the team + "POST /team/invites": Operation( + operation_id="createTeamInvite", + method="POST", + path="/team/invites", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="CreateTeamInviteData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="TeamInviteData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ModelNotFoundException",), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Create a custom team role + "POST /team/roles": Operation( + operation_id="createTeamRole", + method="POST", + path="/team/roles", + auth="team", + surfaces=("team",), + path_params=(), + query=None, + headers=(), + request=RequestBody(type="CreateTeamRoleData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="TeamRoleData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), + # Duplicate a team role + "POST /team/roles/{roleId}/duplicate": Operation( + operation_id="duplicateTeamRole", + method="POST", + path="/team/roles/{roleId}/duplicate", + auth="team", + surfaces=("team",), + path_params=("roleId",), + query=None, + headers=(), + request=RequestBody(type="DuplicateTeamRoleData", content_type="application/json", required=True), + response=ResponseBody(status=(201,), content_type="application/json", type="TeamRoleData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Create a new webhook "POST /webhooks": Operation( operation_id="createWebhook", @@ -994,6 +1138,22 @@ class Operation: pagination=None, deprecated=False, ), + # Replace a pending team invite + "PUT /team/invites/{inviteId}": Operation( + operation_id="updateTeamInvite", + method="PUT", + path="/team/invites/{inviteId}", + auth="team", + surfaces=("team",), + path_params=("inviteId",), + query=None, + headers=(), + request=RequestBody(type="UpdateTeamInviteData", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="TeamInviteData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Replace a team member's assignment "PUT /team/members/{userId}/assignment": Operation( operation_id="updateTeamMemberAssignment", @@ -1010,6 +1170,22 @@ class Operation: pagination=None, deprecated=False, ), + # Replace a custom team role + "PUT /team/roles/{roleId}": Operation( + operation_id="updateTeamRole", + method="PUT", + path="/team/roles/{roleId}", + auth="team", + surfaces=("team",), + path_params=("roleId",), + query=None, + headers=(), + request=RequestBody(type="UpdateTeamRoleData", content_type="application/json", required=True), + response=ResponseBody(status=(200,), content_type="application/json", type="TeamRoleData"), + errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 409: ("RbacConflictException",), 422: ("ValidationException",), 429: ("TooManyRequests",)}, + pagination=None, + deprecated=False, + ), # Update webhook settings "PUT /webhooks/{webhookId}": Operation( operation_id="updateWebhook", diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py index 25df65f..91d9331 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@6ef4036dede6326ad2200b945e689e319faf4c0a (main) +# Spec: lettermint/lettermint@a14a098fd3173b67bc366163127ee3715457cd59 (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 +# team-openapi.json SHA-256: da4398ead370c9b61eadb4007087a6da945df3c609801325fd38fc780cc0e707 # Naming profile: next """Request and response types of the Lettermint API. @@ -51,8 +51,12 @@ "AttachmentDelivery", "BlockedFileTypes", "BuiltInTeamRole", + "CreateTeamInviteData", + "CreateTeamInviteDataProjectAccess", + "CreateTeamRoleData", "CursorPage", "DeleteSuppressionResponse", + "DeleteTeamRoleQuery", "DeliveryMode", "DkimMode", "DmarcPolicy", @@ -72,6 +76,7 @@ "DomainListData", "DomainMutationResponse", "DomainStatus", + "DuplicateTeamRoleData", "ErrorMessage", "GetDomainQuery", "GetDomainQueryIncludeItem", @@ -116,7 +121,11 @@ "ListSuppressionsQueryPage", "ListSuppressionsQuerySortItem", "ListSuppressionsResponse", + "ListTeamInvitesQuery", + "ListTeamInvitesQueryPage", + "ListTeamInvitesResponse", "ListTeamMembersQuery", + "ListTeamMembersQueryFilter", "ListTeamMembersQueryPage", "ListTeamMembersResponse", "ListWebhookDeliveriesQuery", @@ -207,6 +216,8 @@ "SuppressionType", "TeamAddonData", "TeamData", + "TeamInviteData", + "TeamInviteDataRole", "TeamMemberData", "TeamMemberDataRole", "TeamMemberProjectAccessData", @@ -227,8 +238,11 @@ "UpdateRouteInboundSettingsData", "UpdateRouteSettingsData", "UpdateTeamData", + "UpdateTeamInviteData", + "UpdateTeamInviteDataProjectAccess", "UpdateTeamMemberAssignmentData", "UpdateTeamMemberAssignmentDataProjectAccess", + "UpdateTeamRoleData", "UpdateWebhookData", "ValidationErrorBody", "ValidationException", @@ -758,6 +772,35 @@ BuiltInTeamRole: TypeAlias = Literal["owner", "admin", "member"] | str +CreateTeamInviteData = TypedDict( + "CreateTeamInviteData", + { + "email": Required[str], + "role_id": Required[str], + "project_access": Required["CreateTeamInviteDataProjectAccess"], + }, +) + + +CreateTeamInviteDataProjectAccess = TypedDict( + "CreateTeamInviteDataProjectAccess", + { + "scope": Required["ProjectAccessScope"], + "project_ids": NotRequired[list[str]], + }, +) + + +CreateTeamRoleData = TypedDict( + "CreateTeamRoleData", + { + "name": Required[str], + "description": NotRequired[str | None], + "permissions": Required["list[RbacPermission]"], + }, +) + + class CursorPage(TypedDict, Generic[T]): """A page of a cursor-paginated list. @@ -811,6 +854,16 @@ class CursorPage(TypedDict, Generic[T]): DeleteSuppressionResponse: TypeAlias = SuppressionDeletedResponse | SuppressionReviewResponse +# Query parameters of `DELETE /team/roles/{roleId}` (deleteTeamRole). +DeleteTeamRoleQuery = TypedDict( + "DeleteTeamRoleQuery", + { + # Same-team non-Owner role that receives existing assignments. Required only while the deleted role is assigned. + "replacement_role_id": NotRequired[str | None], + }, +) + + DeliveryMode: TypeAlias = Literal["live", "sandbox"] | str @@ -1023,6 +1076,15 @@ class CursorPage(TypedDict, Generic[T]): ) +DuplicateTeamRoleData = TypedDict( + "DuplicateTeamRoleData", + { + "name": Required[str], + "description": NotRequired[str | None], + }, +) + + # Error with only a message. ErrorMessage = TypedDict( "ErrorMessage", @@ -1566,11 +1628,58 @@ class CursorPage(TypedDict, Generic[T]): ListSuppressionsResponse: TypeAlias = CursorPage[SuppressedRecipientData] +# Query parameters of `GET /team/invites` (listTeamInvites). +ListTeamInvitesQuery = TypedDict( + "ListTeamInvitesQuery", + { + "page": NotRequired["ListTeamInvitesQueryPage"], + }, +) + + +# The `page[...]` query parameters of `GET /team/invites`. +ListTeamInvitesQueryPage = TypedDict( + "ListTeamInvitesQueryPage", + { + # The number of results that will be returned per page. + "size": NotRequired[int], + # The cursor to start the pagination from. + "cursor": NotRequired[str], + }, +) + + +TeamInviteData = TypedDict( + "TeamInviteData", + { + "id": Required[str], + "email": Required[str], + "role": Required["TeamInviteDataRole"], + "project_access": Required["TeamMemberProjectAccessData"], + "created_at": Required[str], + }, +) + + +ListTeamInvitesResponse: TypeAlias = CursorPage[TeamInviteData] + + # Query parameters of `GET /team/members` (listTeamMembers). ListTeamMembersQuery = TypedDict( "ListTeamMembersQuery", { "page": NotRequired["ListTeamMembersQueryPage"], + "filter": NotRequired["ListTeamMembersQueryFilter"], + }, +) + + +# The `filter[...]` query parameters of `GET /team/members`. +ListTeamMembersQueryFilter = TypedDict( + "ListTeamMembersQueryFilter", + { + # Only members who can access this project: members with access to all projects and members selected for it. An unknown project returns an empty list. + "project_id": NotRequired[str], }, ) @@ -2691,6 +2800,15 @@ class CursorPage(TypedDict, Generic[T]): ) +TeamInviteDataRole = TypedDict( + "TeamInviteDataRole", + { + "id": Required[str], + "name": Required[str], + }, +) + + TeamMemberDataRole = TypedDict( "TeamMemberDataRole", { @@ -2734,6 +2852,7 @@ class CursorPage(TypedDict, Generic[T]): { "id": Required[str], "name": Required[str], + "description": NotRequired[str | None], "system_key": Required["BuiltInTeamRole | None"], "permissions": Required["list[RbacPermission]"], "assignable": Required[bool], @@ -2879,6 +2998,25 @@ class CursorPage(TypedDict, Generic[T]): ) +UpdateTeamInviteData = TypedDict( + "UpdateTeamInviteData", + { + "email": Required[str], + "role_id": Required[str], + "project_access": Required["UpdateTeamInviteDataProjectAccess"], + }, +) + + +UpdateTeamInviteDataProjectAccess = TypedDict( + "UpdateTeamInviteDataProjectAccess", + { + "scope": Required["ProjectAccessScope"], + "project_ids": NotRequired[list[str]], + }, +) + + UpdateTeamMemberAssignmentData = TypedDict( "UpdateTeamMemberAssignmentData", { @@ -2897,6 +3035,16 @@ class CursorPage(TypedDict, Generic[T]): ) +UpdateTeamRoleData = TypedDict( + "UpdateTeamRoleData", + { + "name": Required[str], + "description": NotRequired[str | None], + "permissions": Required["list[RbacPermission]"], + }, +) + + UpdateWebhookData = TypedDict( "UpdateWebhookData", { From 53d2fd2048a5f327bd18ff73f9e4f44a2b68d4e1 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Tue, 20 Oct 2026 22:41:36 +0200 Subject: [PATCH 2/2] feat: add team invites - New team.invites sub-client (TeamInvites / AsyncTeamInvites, exported for type hints) with list(query), iterate(query), retrieve(invite_id), create(body), update(invite_id, body) and revoke(invite_id). Every method uses the team token and sends no Idempotency-Key header. - create() emails the invitation; update() replaces the email address, role and project access and sends a new email only when the address changes; revoke() returns a MessageResponse. - A 409 raises ConflictError with the error code; a 422 raises ValidationError with the field errors. - The coverage test lists the team role and member-removal operations as pending; the next PRs in this stack add them. - Document team.invites in the README and test the requests, the token, the bodies, the parsed responses, the invite ID checks, the cursor pagination and the 409 and 422 errors. Co-Authored-By: Claude --- README.md | 12 ++ src/lettermint/__init__.py | 4 + src/lettermint/_async/__init__.py | 2 + src/lettermint/_async/resources.py | 86 +++++++++++++ src/lettermint/_sync/__init__.py | 2 + src/lettermint/_sync/resources.py | 86 +++++++++++++ tests/test_surface.py | 31 ++++- tests/test_team.py | 190 +++++++++++++++++++++++++++++ 8 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 tests/test_team.py diff --git a/README.md b/README.md index c770abc..a2121e2 100644 --- a/README.md +++ b/README.md @@ -292,10 +292,22 @@ print(resent["message_id"]) # the new message; it links back through resent_fro | `suppressions` | `list`, `iterate`, `create`, `delete` | | `team` | `retrieve`, `update`, `usage`, `roles` | | `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` | +| `team.invites` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` | | `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` | | `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)`, `retry(webhook_id, delivery_id)` | | (root) | `ping`, `analytics`, `analytics_pages`, `blocked_file_types` | +`team.invites.create()` invites a person to the team with a role and project access, and emails the invitation. The team needs a free seat, and the Owner role cannot be assigned. `team.invites.update()` replaces the email address, role and project access of a pending invite; it sends a new email only when the address changes. `team.invites.revoke()` revokes it. + +```python +invite = lettermint.team.invites.create({ + "email": "jane@acme.com", + "role_id": "role-id", + "project_access": {"scope": "selected", "project_ids": ["project-id"]}, +}) +lettermint.team.invites.revoke(invite["id"]) +``` + `webhooks.deliveries.retry()` sends a delivery's original payload to the webhook again now. The retry counts as a delivery attempt; if it fails with a retryable error, the automatic retries continue. The API answers `409` (`ConflictError`) when the webhook is disabled, the delivery cannot be retried or a retry is already in progress. ### Query parameters and pagination diff --git a/src/lettermint/__init__.py b/src/lettermint/__init__.py index 9103150..e5649c3 100644 --- a/src/lettermint/__init__.py +++ b/src/lettermint/__init__.py @@ -30,6 +30,7 @@ AsyncStats, AsyncSuppressions, AsyncTeam, + AsyncTeamInvites, AsyncTeamMembers, AsyncWebhookDeliveries, AsyncWebhooks, @@ -48,6 +49,7 @@ Stats, Suppressions, Team, + TeamInvites, TeamMembers, WebhookDeliveries, Webhooks, @@ -89,6 +91,7 @@ "AsyncStats", "AsyncSuppressions", "AsyncTeam", + "AsyncTeamInvites", "AsyncTeamMembers", "AsyncWebhookDeliveries", "AsyncWebhooks", @@ -118,6 +121,7 @@ "Stats", "Suppressions", "Team", + "TeamInvites", "TeamMembers", "UnexpectedResponseError", "ValidationError", diff --git a/src/lettermint/_async/__init__.py b/src/lettermint/_async/__init__.py index d9870e4..1b1afe9 100644 --- a/src/lettermint/_async/__init__.py +++ b/src/lettermint/_async/__init__.py @@ -12,6 +12,7 @@ AsyncStats, AsyncSuppressions, AsyncTeam, + AsyncTeamInvites, AsyncTeamMembers, AsyncWebhookDeliveries, AsyncWebhooks, @@ -30,6 +31,7 @@ "AsyncStats", "AsyncSuppressions", "AsyncTeam", + "AsyncTeamInvites", "AsyncTeamMembers", "AsyncWebhookDeliveries", "AsyncWebhooks", diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py index 43f194c..077cc1b 100644 --- a/src/lettermint/_async/resources.py +++ b/src/lettermint/_async/resources.py @@ -13,6 +13,7 @@ from .._generated.operations import OPERATIONS from .._generated.types import ( + CreateTeamInviteData, DeleteSuppressionResponse, DnsVerificationSuccessResponse, DomainData, @@ -37,6 +38,8 @@ ListRoutesResponse, ListSuppressionsQuery, ListSuppressionsResponse, + ListTeamInvitesQuery, + ListTeamInvitesResponse, ListTeamMembersQuery, ListTeamMembersResponse, ListWebhookDeliveriesQuery, @@ -72,6 +75,7 @@ SuppressedRecipientData, SuppressionStoreResponse, TeamData, + TeamInviteData, TeamMemberData, TeamMutationResponse, TeamRoleListResponse, @@ -82,6 +86,7 @@ UpdateReportForwardingResponse, UpdateRouteData, UpdateTeamData, + UpdateTeamInviteData, UpdateTeamMemberAssignmentData, UpdateWebhookData, VerifyReportForwardingRequest, @@ -106,6 +111,7 @@ "AsyncStats", "AsyncSuppressions", "AsyncTeam", + "AsyncTeamInvites", "AsyncTeamMembers", "AsyncWebhookDeliveries", "AsyncWebhooks", @@ -800,6 +806,84 @@ async def update_assignment( ) +class AsyncTeamInvites(AsyncResource): + """Pending invitations to the team. Team token.""" + + async def list( + self, query: ListTeamInvitesQuery | None = None, *, timeout: float | None = None + ) -> ListTeamInvitesResponse: + """Lists pending invites, one page at a time.""" + return cast( + ListTeamInvitesResponse, + await self._request( + "GET /team/invites", "team.invites.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListTeamInvitesQuery | None = None, *, timeout: float | None = None + ) -> AsyncIterator[TeamInviteData]: + """Iterates over every pending invite, following ``next_cursor``.""" + return self._paginate( + "GET /team/invites", "team.invites.iterate", query=query, timeout=timeout + ) + + async def retrieve(self, invite_id: str, *, timeout: float | None = None) -> TeamInviteData: + return cast( + TeamInviteData, + await self._request( + "GET /team/invites/{inviteId}", + "team.invites.retrieve", + path={"inviteId": invite_id}, + timeout=timeout, + ), + ) + + async def create( + self, body: CreateTeamInviteData, *, timeout: float | None = None + ) -> TeamInviteData: + """Invites a person to the team and emails the invitation. + + The team needs a free seat, and the Owner role cannot be assigned. + """ + return cast( + TeamInviteData, + await self._request( + "POST /team/invites", "team.invites.create", body=body, timeout=timeout + ), + ) + + async def update( + self, invite_id: str, body: UpdateTeamInviteData, *, timeout: float | None = None + ) -> TeamInviteData: + """Replaces the email address, role and project access of a pending invite. + + A new invitation email is sent only when the address changes. + """ + return cast( + TeamInviteData, + await self._request( + "PUT /team/invites/{inviteId}", + "team.invites.update", + path={"inviteId": invite_id}, + body=body, + timeout=timeout, + ), + ) + + async def revoke(self, invite_id: str, *, timeout: float | None = None) -> MessageResponse: + """Revokes a pending invite.""" + return cast( + MessageResponse, + await self._request( + "DELETE /team/invites/{inviteId}", + "team.invites.revoke", + path={"inviteId": invite_id}, + timeout=timeout, + ), + ) + + class AsyncTeam(AsyncResource): """The team of the token. Team token.""" @@ -807,6 +891,8 @@ def __init__(self, transport: AsyncTransport) -> None: super().__init__(transport) #: Team members. self.members = AsyncTeamMembers(transport) + #: Pending invitations to the team. + self.invites = AsyncTeamInvites(transport) async def retrieve( self, query: GetTeamQuery | None = None, *, timeout: float | None = None diff --git a/src/lettermint/_sync/__init__.py b/src/lettermint/_sync/__init__.py index c0d6d2e..29be3bb 100644 --- a/src/lettermint/_sync/__init__.py +++ b/src/lettermint/_sync/__init__.py @@ -13,6 +13,7 @@ Stats, Suppressions, Team, + TeamInvites, TeamMembers, WebhookDeliveries, Webhooks, @@ -31,6 +32,7 @@ "Stats", "Suppressions", "Team", + "TeamInvites", "TeamMembers", "WebhookDeliveries", "Webhooks", diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py index 170a43b..4cf256c 100644 --- a/src/lettermint/_sync/resources.py +++ b/src/lettermint/_sync/resources.py @@ -14,6 +14,7 @@ from .._generated.operations import OPERATIONS from .._generated.types import ( + CreateTeamInviteData, DeleteSuppressionResponse, DnsVerificationSuccessResponse, DomainData, @@ -38,6 +39,8 @@ ListRoutesResponse, ListSuppressionsQuery, ListSuppressionsResponse, + ListTeamInvitesQuery, + ListTeamInvitesResponse, ListTeamMembersQuery, ListTeamMembersResponse, ListWebhookDeliveriesQuery, @@ -73,6 +76,7 @@ SuppressedRecipientData, SuppressionStoreResponse, TeamData, + TeamInviteData, TeamMemberData, TeamMutationResponse, TeamRoleListResponse, @@ -83,6 +87,7 @@ UpdateReportForwardingResponse, UpdateRouteData, UpdateTeamData, + UpdateTeamInviteData, UpdateTeamMemberAssignmentData, UpdateWebhookData, VerifyReportForwardingRequest, @@ -107,6 +112,7 @@ "Stats", "Suppressions", "Team", + "TeamInvites", "TeamMembers", "WebhookDeliveries", "Webhooks", @@ -801,6 +807,84 @@ def update_assignment( ) +class TeamInvites(Resource): + """Pending invitations to the team. Team token.""" + + def list( + self, query: ListTeamInvitesQuery | None = None, *, timeout: float | None = None + ) -> ListTeamInvitesResponse: + """Lists pending invites, one page at a time.""" + return cast( + ListTeamInvitesResponse, + self._request( + "GET /team/invites", "team.invites.list", query=query, timeout=timeout + ), + ) + + def iterate( + self, query: ListTeamInvitesQuery | None = None, *, timeout: float | None = None + ) -> Iterator[TeamInviteData]: + """Iterates over every pending invite, following ``next_cursor``.""" + return self._paginate( + "GET /team/invites", "team.invites.iterate", query=query, timeout=timeout + ) + + def retrieve(self, invite_id: str, *, timeout: float | None = None) -> TeamInviteData: + return cast( + TeamInviteData, + self._request( + "GET /team/invites/{inviteId}", + "team.invites.retrieve", + path={"inviteId": invite_id}, + timeout=timeout, + ), + ) + + def create( + self, body: CreateTeamInviteData, *, timeout: float | None = None + ) -> TeamInviteData: + """Invites a person to the team and emails the invitation. + + The team needs a free seat, and the Owner role cannot be assigned. + """ + return cast( + TeamInviteData, + self._request( + "POST /team/invites", "team.invites.create", body=body, timeout=timeout + ), + ) + + def update( + self, invite_id: str, body: UpdateTeamInviteData, *, timeout: float | None = None + ) -> TeamInviteData: + """Replaces the email address, role and project access of a pending invite. + + A new invitation email is sent only when the address changes. + """ + return cast( + TeamInviteData, + self._request( + "PUT /team/invites/{inviteId}", + "team.invites.update", + path={"inviteId": invite_id}, + body=body, + timeout=timeout, + ), + ) + + def revoke(self, invite_id: str, *, timeout: float | None = None) -> MessageResponse: + """Revokes a pending invite.""" + return cast( + MessageResponse, + self._request( + "DELETE /team/invites/{inviteId}", + "team.invites.revoke", + path={"inviteId": invite_id}, + timeout=timeout, + ), + ) + + class Team(Resource): """The team of the token. Team token.""" @@ -808,6 +892,8 @@ def __init__(self, transport: Transport) -> None: super().__init__(transport) #: Team members. self.members = TeamMembers(transport) + #: Pending invitations to the team. + self.invites = TeamInvites(transport) def retrieve( self, query: GetTeamQuery | None = None, *, timeout: float | None = None diff --git a/tests/test_surface.py b/tests/test_surface.py index ac1eee4..1477b0e 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -120,6 +120,20 @@ ("u1", {"role": "admin"}), "PUT /team/members/{userId}/assignment", ), + ("team.invites.list", (), "GET /team/invites"), + ("team.invites.iterate", (), "GET /team/invites"), + ("team.invites.retrieve", ("i1",), "GET /team/invites/{inviteId}"), + ( + "team.invites.create", + ({"email": "jane@acme.test", "role_id": "r1", "project_access": {"scope": "all"}},), + "POST /team/invites", + ), + ( + "team.invites.update", + ("i1", {"email": "jane@acme.test", "role_id": "r1", "project_access": {"scope": "all"}}), + "PUT /team/invites/{inviteId}", + ), + ("team.invites.revoke", ("i1",), "DELETE /team/invites/{inviteId}"), ("webhooks.list", (), "GET /webhooks"), ("webhooks.iterate", (), "GET /webhooks"), ( @@ -146,6 +160,16 @@ ), ] +# Operations without a method yet: added by the next PRs in this stack. +PENDING = { + "GET /team/roles/{roleId}", + "POST /team/roles", + "PUT /team/roles/{roleId}", + "DELETE /team/roles/{roleId}", + "POST /team/roles/{roleId}/duplicate", + "DELETE /team/members/{userId}", +} + LAYOUT = { "domains": [ "create", @@ -185,6 +209,7 @@ "stats": ["retrieve"], "suppressions": ["create", "delete", "iterate", "list"], "team": ["retrieve", "roles", "update", "usage"], + "team.invites": ["create", "iterate", "list", "retrieve", "revoke", "update"], "team.members": ["iterate", "list", "retrieve", "update_assignment"], "webhooks": [ "create", @@ -246,7 +271,8 @@ def test_every_operation_is_reachable_from_the_sync_client( assert list(result) == [{} if dotted == "analytics_pages" else {"id": "x"}] assert [operation_of(r) for r in api.requests[before:]] == [key], dotted reached.add(key) - assert reached == set(OPERATIONS) + assert set(OPERATIONS) >= PENDING + assert reached == set(OPERATIONS) - PENDING @pytest.mark.anyio @@ -266,7 +292,8 @@ async def test_every_operation_is_reachable_from_the_async_client( await result assert [operation_of(r) for r in api.requests[before:]] == [key], dotted reached.add(key) - assert reached == set(OPERATIONS) + assert set(OPERATIONS) >= PENDING + assert reached == set(OPERATIONS) - PENDING def _public_methods(obj: Any) -> list[str]: diff --git a/tests/test_team.py b/tests/test_team.py new file mode 100644 index 0000000..30b92e4 --- /dev/null +++ b/tests/test_team.py @@ -0,0 +1,190 @@ +"""``team.invites``: the requests, the token, the bodies and the errors.""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest + +from lettermint import ( + AsyncLettermint, + ConflictError, + Lettermint, + LettermintConfigError, + ValidationError, +) +from lettermint.types import ( + CreateTeamInviteData, + ListTeamInvitesResponse, + MessageResponse, + TeamInviteData, + UpdateTeamInviteData, +) + +from .conftest import BASE_URL, SENDING_TOKEN, TEAM_TOKEN, MockAPI + +INVITE: TeamInviteData = { + "id": "i1", + "email": "jane@acme.test", + "role": {"id": "r1", "name": "Member"}, + "project_access": {"scope": "selected", "projects": [{"id": "p1", "name": "Production"}]}, + "created_at": "2026-10-20T09:00:00Z", +} + +NEW_INVITE: CreateTeamInviteData = { + "email": "jane@acme.test", + "role_id": "r1", + "project_access": {"scope": "selected", "project_ids": ["p1"]}, +} + +RBAC_CONFLICT = {"error": {"code": "stale_resource", "message": "The role changed."}} + + +def assert_team_request(api: MockAPI, method: str, path: str) -> None: + assert api.last.method == method + assert str(api.last.url) == f"{BASE_URL}{path}" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert "x-lettermint-token" not in api.last.headers + assert "idempotency-key" not in api.last.headers + + +def test_list_invites(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"data": [INVITE], "next_cursor": "c2"}) + page: ListTeamInvitesResponse = client.team.invites.list({"page": {"size": 10}}) + assert page["data"] == [INVITE] + assert page["next_cursor"] == "c2" + assert_team_request(api, "GET", "/team/invites?page%5Bsize%5D=10") + + +def test_iterate_invites_follows_the_cursor(api: MockAPI, client: Lettermint) -> None: + second = {**INVITE, "id": "i2"} + pages = { + None: {"data": [INVITE], "next_cursor": "c2"}, + "c2": {"data": [second], "next_cursor": None}, + } + api.respond( + lambda request: httpx.Response(200, json=pages[request.url.params.get("page[cursor]")]) + ) + invites: list[TeamInviteData] = list(client.team.invites.iterate({"page": {"size": 1}})) + assert invites == [INVITE, second] + assert [r.url.params.get("page[cursor]") for r in api.requests] == [None, "c2"] + assert all(r.url.params["page[size]"] == "1" for r in api.requests) + + +def test_retrieve_invite(api: MockAPI, client: Lettermint) -> None: + api.json(200, INVITE) + invite: TeamInviteData = client.team.invites.retrieve("i1") + assert invite == INVITE + assert_team_request(api, "GET", "/team/invites/i1") + assert api.last.content == b"" + + +def test_create_invite_sends_the_body(api: MockAPI, client: Lettermint) -> None: + api.json(201, INVITE) + invite: TeamInviteData = client.team.invites.create(NEW_INVITE) + assert invite == INVITE + assert_team_request(api, "POST", "/team/invites") + assert api.last.headers["content-type"] == "application/json" + assert api.body() == NEW_INVITE + + +def test_create_invite_with_access_to_all_projects(api: MockAPI, client: Lettermint) -> None: + api.json(201, INVITE) + body: CreateTeamInviteData = { + "email": "jane@acme.test", + "role_id": "r1", + "project_access": {"scope": "all"}, + } + client.team.invites.create(body) + assert api.body() == body + + +def test_update_invite_replaces_it(api: MockAPI, client: Lettermint) -> None: + api.json(200, INVITE) + body: UpdateTeamInviteData = { + "email": "john@acme.test", + "role_id": "r2", + "project_access": {"scope": "all"}, + } + invite: TeamInviteData = client.team.invites.update("i1", body) + assert invite == INVITE + assert_team_request(api, "PUT", "/team/invites/i1") + assert api.body() == body + + +def test_revoke_invite(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"message": "Invite revoked."}) + result: MessageResponse = client.team.invites.revoke("i1") + assert result == {"message": "Invite revoked."} + assert_team_request(api, "DELETE", "/team/invites/i1") + assert api.last.content == b"" + + +def test_invites_need_the_team_token(api: MockAPI) -> None: + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises(LettermintConfigError, match=r"^team\.invites\.list needs team_token"): + sending_only.team.invites.list() + with pytest.raises(LettermintConfigError, match=r"^team\.invites\.iterate needs team_token"): + sending_only.team.invites.iterate() + with pytest.raises(LettermintConfigError, match=r"^team\.invites\.create needs team_token"): + sending_only.team.invites.create(NEW_INVITE) + assert api.requests == [] + + +@pytest.mark.parametrize( + ("call", "label"), + [ + (lambda c, i: c.team.invites.retrieve(i), "retrieve"), + (lambda c, i: c.team.invites.update(i, NEW_INVITE), "update"), + (lambda c, i: c.team.invites.revoke(i), "revoke"), + ], +) +def test_invite_ids_are_checked_and_encoded( + api: MockAPI, client: Lettermint, call: Any, label: str +) -> None: + api.json(200, INVITE) + for invalid in ("", ".."): + with pytest.raises(LettermintConfigError, match=rf"^team\.invites\.{label}: inviteId"): + call(client, invalid) + assert api.requests == [] + call(client, "a/b") + assert api.last.url.raw_path == b"/v1/team/invites/a%2Fb" + + +def test_an_invite_conflict_is_a_conflict_error(api: MockAPI, client: Lettermint) -> None: + api.json(409, RBAC_CONFLICT) + with pytest.raises(ConflictError) as caught: + client.team.invites.update("i1", NEW_INVITE) + assert (caught.value.status, caught.value.code, caught.value.body) == ( + 409, + "stale_resource", + RBAC_CONFLICT, + ) + + +def test_an_invalid_invite_is_a_validation_error(api: MockAPI, client: Lettermint) -> None: + body = { + "message": "The team has no free seat.", + "errors": {"email": ["The team has no free seat."]}, + } + api.json(422, body) + with pytest.raises(ValidationError) as caught: + client.team.invites.create(NEW_INVITE) + assert caught.value.status == 422 + assert caught.value.errors == {"email": ["The team has no free seat."]} + + +@pytest.mark.anyio +async def test_async_invites(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(201, INVITE) + invite: TeamInviteData = await aclient.team.invites.create(NEW_INVITE) + assert invite == INVITE + assert_team_request(api, "POST", "/team/invites") + assert api.body() == NEW_INVITE + api.json(200, {"data": [INVITE], "next_cursor": None}) + assert [item async for item in aclient.team.invites.iterate()] == [INVITE] + assert_team_request(api, "GET", "/team/invites") + api.json(200, {"message": "Invite revoked."}) + assert await aclient.team.invites.revoke("i1") == {"message": "Invite revoked."} + assert_team_request(api, "DELETE", "/team/invites/i1")