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
15 changes: 14 additions & 1 deletion src/lettermint/lettermint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 76 additions & 43 deletions src/lettermint/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@
},
)

MessageRecipientData = TypedDict(
"MessageRecipientData",
{
"email": "Required[str]",
"name": "Required[str | None]",
},
)

MessageType: TypeAlias = Literal["inbound", "outbound"]
SpamSymbol = TypedDict(
"SpamSymbol",
{
Expand All @@ -107,15 +116,6 @@
},
)

MessageType: TypeAlias = Literal["inbound", "outbound"]
MessageRecipientData = TypedDict(
"MessageRecipientData",
{
"email": "Required[str]",
"name": "Required[str | None]",
},
)

MessageData = TypedDict(
"MessageData",
{
Expand Down Expand Up @@ -145,6 +145,7 @@
"processed",
"suppressed",
"delivered",
"auto_replied",
"soft_bounced",
"hard_bounced",
"spam_complaint",
Expand Down Expand Up @@ -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",
{
Expand All @@ -217,6 +237,7 @@
},
)

RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"]
RouteData = TypedDict(
"RouteData",
{
Expand All @@ -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]",
Expand Down Expand Up @@ -312,13 +314,6 @@
},
)

StatsInboundData = TypedDict(
"StatsInboundData",
{
"received": "Required[int]",
},
)

StatsTypeData = TypedDict(
"StatsTypeData",
{
Expand All @@ -328,6 +323,13 @@
},
)

StatsInboundData = TypedDict(
"StatsInboundData",
{
"received": "Required[int]",
},
)

StatsDailyData = TypedDict(
"StatsDailyData",
{
Expand Down Expand Up @@ -398,6 +400,7 @@
"name": "Required[str]",
"smtp_enabled": "NotRequired[bool]",
"initial_routes": "NotRequired[InitialRoutes]",
"short_token": "NotRequired[bool]",
},
)

Expand All @@ -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]",
Expand All @@ -427,6 +431,7 @@
"message.created",
"message.sent",
"message.delivered",
"message.auto_replied",
"message.hard_bounced",
"message.soft_bounced",
"message.spam_complaint",
Expand All @@ -451,7 +456,6 @@
},
)

SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"]
SuppressionType: TypeAlias = Literal["email", "domain", "extension"]
SuppressedRecipientData = TypedDict(
"SuppressedRecipientData",
Expand All @@ -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",
{
Expand Down Expand Up @@ -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]",
},
)
Expand All @@ -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]",
},
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.']]",
},
)

Expand Down
42 changes: 42 additions & 0 deletions tests/test_api_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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__