diff --git a/src/lettermint/lettermint.py b/src/lettermint/lettermint.py index 5234663..90010f4 100644 --- a/src/lettermint/lettermint.py +++ b/src/lettermint/lettermint.py @@ -3,13 +3,14 @@ from __future__ import annotations import sys -from typing import Any +from typing import Any, cast if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self +from . import types as lm_types from .client import AsyncLettermintClient, LettermintClient from .endpoints.api import ( AsyncDomainsEndpoint, @@ -87,6 +88,12 @@ def __exit__(self, *args: Any) -> None: def ping(self) -> str: return self._client.get_raw("/ping").strip() + def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse: + return cast( + lm_types.BlockedFileTypesResponse, + self._client.get("/blocked-file-types"), + ) + class AsyncApiClient: """Asynchronous client for the full Lettermint API.""" @@ -125,6 +132,12 @@ async def __aexit__(self, *args: Any) -> None: async def ping(self) -> str: return (await self._client.get_raw("/ping")).strip() + async def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse: + return cast( + lm_types.BlockedFileTypesResponse, + await self._client.get("/blocked-file-types"), + ) + class Lettermint: """Synchronous Lettermint SDK client. diff --git a/src/lettermint/types.py b/src/lettermint/types.py index 380f83c..56bafca 100644 --- a/src/lettermint/types.py +++ b/src/lettermint/types.py @@ -97,6 +97,15 @@ }, ) +MessageRecipientData = TypedDict( + "MessageRecipientData", + { + "email": "Required[str]", + "name": "Required[str | None]", + }, +) + +MessageType: TypeAlias = Literal["inbound", "outbound"] SpamSymbol = TypedDict( "SpamSymbol", { @@ -107,15 +116,6 @@ }, ) -MessageType: TypeAlias = Literal["inbound", "outbound"] -MessageRecipientData = TypedDict( - "MessageRecipientData", - { - "email": "Required[str]", - "name": "Required[str | None]", - }, -) - MessageData = TypedDict( "MessageData", { @@ -145,6 +145,7 @@ "processed", "suppressed", "delivered", + "auto_replied", "soft_bounced", "hard_bounced", "spam_complaint", @@ -199,7 +200,26 @@ ) Plan: TypeAlias = Literal["free", "starter", "growth", "pro"] -RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] +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]", + }, +) + RouteStatisticData = TypedDict( "RouteStatisticData", { @@ -217,6 +237,7 @@ }, ) +RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"] RouteData = TypedDict( "RouteData", { @@ -240,32 +261,13 @@ }, ) -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]", - }, -) - ProjectData = TypedDict( "ProjectData", { "id": "Required[str]", "name": "Required[str]", "smtp_enabled": "Required[bool]", + "redact_email_content": "Required[bool]", "default_route_id": "Required[str | None]", "token_generated_at": "Required[str | None]", "token_last_used_at": "Required[str | None]", @@ -312,13 +314,6 @@ }, ) -StatsInboundData = TypedDict( - "StatsInboundData", - { - "received": "Required[int]", - }, -) - StatsTypeData = TypedDict( "StatsTypeData", { @@ -328,6 +323,13 @@ }, ) +StatsInboundData = TypedDict( + "StatsInboundData", + { + "received": "Required[int]", + }, +) + StatsDailyData = TypedDict( "StatsDailyData", { @@ -398,6 +400,7 @@ "name": "Required[str]", "smtp_enabled": "NotRequired[bool]", "initial_routes": "NotRequired[InitialRoutes]", + "short_token": "NotRequired[bool]", }, ) @@ -410,13 +413,14 @@ }, ) +SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"] SuppressionReason: TypeAlias = Literal["spam_complaint", "hard_bounce", "unsubscribe", "manual"] StoreSuppressionData = TypedDict( "StoreSuppressionData", { "email": "NotRequired[str | None]", "reason": "Required[SuppressionReason]", - "scope": "Required[Literal['team', 'project', 'route']]", + "scope": "Required[SuppressionScope]", "route_id": "NotRequired[str | None]", "project_id": "NotRequired[str | None]", "emails": "NotRequired[list[str] | None]", @@ -427,6 +431,7 @@ "message.created", "message.sent", "message.delivered", + "message.auto_replied", "message.hard_bounced", "message.soft_bounced", "message.spam_complaint", @@ -451,7 +456,6 @@ }, ) -SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"] SuppressionType: TypeAlias = Literal["email", "domain", "extension"] SuppressedRecipientData = TypedDict( "SuppressedRecipientData", @@ -477,7 +481,7 @@ ) TeamType: TypeAlias = Literal["personal", "business"] -VolumeTier: TypeAlias = Literal[300, 10000, 50000, 125000, 500000, 750000, 1000000, 1500000] +VolumeTier: TypeAlias = Literal[300, 10000, 50000, 125000, 300000, 500000, 750000, 1000000, 1500000] TeamData = TypedDict( "TeamData", { @@ -526,6 +530,7 @@ { "name": "NotRequired[str | None]", "smtp_enabled": "NotRequired[bool | None]", + "redact_email_content": "NotRequired[bool | None]", "default_route_id": "NotRequired[str | None]", }, ) @@ -537,12 +542,32 @@ }, ) +UpdateRouteInboundSettingsData = TypedDict( + "UpdateRouteInboundSettingsData", + { + "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]", + }, +) + UpdateRouteData = TypedDict( "UpdateRouteData", { "name": "NotRequired[str | None]", - "settings": "NotRequired[dict[str, Any]]", - "inbound_settings": "NotRequired[dict[str, Any]]", + "settings": "NotRequired[UpdateRouteSettingsData | Any]", + "inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | Any]", }, ) @@ -700,6 +725,14 @@ }, ) +BlockedFileTypesResponse = TypedDict( + "BlockedFileTypesResponse", + { + "extensions": "Required[list[str]]", + "mime_types": "Required[list[str]]", + }, +) + MessageIndexResponse: TypeAlias = Union[dict[str, Any], list[MessageListData]] MessageShowResponse: TypeAlias = MessageData MessageEventsResponse = TypedDict( @@ -760,7 +793,7 @@ { "data": "Required[ProjectData]", "new_token": "Required[str]", - "message": "Required[Literal['API token rotated successfully. Please update your integrations.']]", + "message": "Required[Literal['Project API token rotated successfully. Please update your integrations.']]", }, ) diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index c55d0e9..76f10e7 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from typing import get_args import pytest import respx from httpx import Response from lettermint import AsyncLettermint, Lettermint +from lettermint import types as lm_types class TestV2Entrypoints: @@ -38,6 +40,26 @@ def test_api_entrypoint_uses_bearer_auth_and_raw_ping(self) -> None: assert request.headers["authorization"] == "Bearer api-token" assert "x-lettermint-token" not in request.headers + @respx.mock + def test_api_blocked_file_types_uses_bearer_auth(self) -> None: + route = respx.get("https://api.lettermint.co/v1/blocked-file-types").mock( + return_value=Response( + 200, + json={ + "extensions": ["exe"], + "mime_types": ["application/x-msdownload"], + }, + ) + ) + + with Lettermint.api("api-token") as api: + response = api.blocked_file_types() + + assert response["extensions"] == ["exe"] + request = route.calls.last.request + assert request.headers["authorization"] == "Bearer api-token" + assert "x-lettermint-token" not in request.headers + @respx.mock @pytest.mark.asyncio async def test_async_entrypoints_use_matching_auth(self) -> None: @@ -70,6 +92,7 @@ def test_async_api_exposes_full_endpoint_groups(self) -> None: assert hasattr(api, "suppressions") assert hasattr(api, "team") assert hasattr(api, "webhooks") + assert hasattr(api, "blocked_file_types") class TestSendingEndpoint: @@ -148,6 +171,7 @@ def test_documented_operations_are_exposed(self) -> None: (Lettermint.email("token"), "send_batch"), (Lettermint.email("token"), "ping"), (Lettermint.api("token"), "ping"), + (Lettermint.api("token"), "blocked_file_types"), (Lettermint.api("token").domains, "list"), (Lettermint.api("token").domains, "create"), (Lettermint.api("token").domains, "retrieve"), @@ -198,3 +222,21 @@ def test_documented_operations_are_exposed(self) -> None: missing = [method for endpoint, method in operations if not hasattr(endpoint, method)] assert missing == [] + + 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 "global" in get_args(lm_types.SuppressionScope) + + assert "short_token" in lm_types.StoreProjectData.__annotations__ + assert "redact_email_content" in lm_types.ProjectData.__annotations__ + assert "redact_email_content" in lm_types.UpdateProjectData.__annotations__ + assert hasattr(lm_types, "UpdateRouteSettingsData") + assert hasattr(lm_types, "UpdateRouteInboundSettingsData") + assert hasattr(lm_types, "BlockedFileTypesResponse") + 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 "inbound_spam_threshold" in lm_types.UpdateRouteInboundSettingsData.__annotations__