Skip to content

Commit 7d8270a

Browse files
authored
feat: add route settings, webhook auto-reply events, and blocked file types (#16)
* Add latest Team API support * Fix styling * refactor: replace `TYPE_CHECKING` import and add type casting for blocked file types
1 parent b13a00c commit 7d8270a

3 files changed

Lines changed: 132 additions & 44 deletions

File tree

src/lettermint/lettermint.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from __future__ import annotations
44

55
import sys
6-
from typing import Any
6+
from typing import Any, cast
77

88
if sys.version_info >= (3, 11):
99
from typing import Self
1010
else:
1111
from typing_extensions import Self
1212

13+
from . import types as lm_types
1314
from .client import AsyncLettermintClient, LettermintClient
1415
from .endpoints.api import (
1516
AsyncDomainsEndpoint,
@@ -87,6 +88,12 @@ def __exit__(self, *args: Any) -> None:
8788
def ping(self) -> str:
8889
return self._client.get_raw("/ping").strip()
8990

91+
def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse:
92+
return cast(
93+
lm_types.BlockedFileTypesResponse,
94+
self._client.get("/blocked-file-types"),
95+
)
96+
9097

9198
class AsyncApiClient:
9299
"""Asynchronous client for the full Lettermint API."""
@@ -125,6 +132,12 @@ async def __aexit__(self, *args: Any) -> None:
125132
async def ping(self) -> str:
126133
return (await self._client.get_raw("/ping")).strip()
127134

135+
async def blocked_file_types(self) -> lm_types.BlockedFileTypesResponse:
136+
return cast(
137+
lm_types.BlockedFileTypesResponse,
138+
await self._client.get("/blocked-file-types"),
139+
)
140+
128141

129142
class Lettermint:
130143
"""Synchronous Lettermint SDK client.

src/lettermint/types.py

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@
9797
},
9898
)
9999

100+
MessageRecipientData = TypedDict(
101+
"MessageRecipientData",
102+
{
103+
"email": "Required[str]",
104+
"name": "Required[str | None]",
105+
},
106+
)
107+
108+
MessageType: TypeAlias = Literal["inbound", "outbound"]
100109
SpamSymbol = TypedDict(
101110
"SpamSymbol",
102111
{
@@ -107,15 +116,6 @@
107116
},
108117
)
109118

110-
MessageType: TypeAlias = Literal["inbound", "outbound"]
111-
MessageRecipientData = TypedDict(
112-
"MessageRecipientData",
113-
{
114-
"email": "Required[str]",
115-
"name": "Required[str | None]",
116-
},
117-
)
118-
119119
MessageData = TypedDict(
120120
"MessageData",
121121
{
@@ -145,6 +145,7 @@
145145
"processed",
146146
"suppressed",
147147
"delivered",
148+
"auto_replied",
148149
"soft_bounced",
149150
"hard_bounced",
150151
"spam_complaint",
@@ -199,7 +200,26 @@
199200
)
200201

201202
Plan: TypeAlias = Literal["free", "starter", "growth", "pro"]
202-
RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"]
203+
UserData = TypedDict(
204+
"UserData",
205+
{
206+
"id": "Required[str]",
207+
"name": "Required[str]",
208+
"email": "Required[str]",
209+
"avatar": "Required[str | None]",
210+
},
211+
)
212+
213+
TeamMemberData = TypedDict(
214+
"TeamMemberData",
215+
{
216+
"id": "Required[str]",
217+
"user": "NotRequired[UserData]",
218+
"role": "Required[str | None]",
219+
"joined_at": "Required[str | None]",
220+
},
221+
)
222+
203223
RouteStatisticData = TypedDict(
204224
"RouteStatisticData",
205225
{
@@ -217,6 +237,7 @@
217237
},
218238
)
219239

240+
RouteType: TypeAlias = Literal["transactional", "broadcast", "inbound"]
220241
RouteData = TypedDict(
221242
"RouteData",
222243
{
@@ -240,32 +261,13 @@
240261
},
241262
)
242263

243-
UserData = TypedDict(
244-
"UserData",
245-
{
246-
"id": "Required[str]",
247-
"name": "Required[str]",
248-
"email": "Required[str]",
249-
"avatar": "Required[str | None]",
250-
},
251-
)
252-
253-
TeamMemberData = TypedDict(
254-
"TeamMemberData",
255-
{
256-
"id": "Required[str]",
257-
"user": "NotRequired[UserData]",
258-
"role": "Required[str | None]",
259-
"joined_at": "Required[str | None]",
260-
},
261-
)
262-
263264
ProjectData = TypedDict(
264265
"ProjectData",
265266
{
266267
"id": "Required[str]",
267268
"name": "Required[str]",
268269
"smtp_enabled": "Required[bool]",
270+
"redact_email_content": "Required[bool]",
269271
"default_route_id": "Required[str | None]",
270272
"token_generated_at": "Required[str | None]",
271273
"token_last_used_at": "Required[str | None]",
@@ -312,13 +314,6 @@
312314
},
313315
)
314316

315-
StatsInboundData = TypedDict(
316-
"StatsInboundData",
317-
{
318-
"received": "Required[int]",
319-
},
320-
)
321-
322317
StatsTypeData = TypedDict(
323318
"StatsTypeData",
324319
{
@@ -328,6 +323,13 @@
328323
},
329324
)
330325

326+
StatsInboundData = TypedDict(
327+
"StatsInboundData",
328+
{
329+
"received": "Required[int]",
330+
},
331+
)
332+
331333
StatsDailyData = TypedDict(
332334
"StatsDailyData",
333335
{
@@ -398,6 +400,7 @@
398400
"name": "Required[str]",
399401
"smtp_enabled": "NotRequired[bool]",
400402
"initial_routes": "NotRequired[InitialRoutes]",
403+
"short_token": "NotRequired[bool]",
401404
},
402405
)
403406

@@ -410,13 +413,14 @@
410413
},
411414
)
412415

416+
SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"]
413417
SuppressionReason: TypeAlias = Literal["spam_complaint", "hard_bounce", "unsubscribe", "manual"]
414418
StoreSuppressionData = TypedDict(
415419
"StoreSuppressionData",
416420
{
417421
"email": "NotRequired[str | None]",
418422
"reason": "Required[SuppressionReason]",
419-
"scope": "Required[Literal['team', 'project', 'route']]",
423+
"scope": "Required[SuppressionScope]",
420424
"route_id": "NotRequired[str | None]",
421425
"project_id": "NotRequired[str | None]",
422426
"emails": "NotRequired[list[str] | None]",
@@ -427,6 +431,7 @@
427431
"message.created",
428432
"message.sent",
429433
"message.delivered",
434+
"message.auto_replied",
430435
"message.hard_bounced",
431436
"message.soft_bounced",
432437
"message.spam_complaint",
@@ -451,7 +456,6 @@
451456
},
452457
)
453458

454-
SuppressionScope: TypeAlias = Literal["global", "team", "project", "route"]
455459
SuppressionType: TypeAlias = Literal["email", "domain", "extension"]
456460
SuppressedRecipientData = TypedDict(
457461
"SuppressedRecipientData",
@@ -477,7 +481,7 @@
477481
)
478482

479483
TeamType: TypeAlias = Literal["personal", "business"]
480-
VolumeTier: TypeAlias = Literal[300, 10000, 50000, 125000, 500000, 750000, 1000000, 1500000]
484+
VolumeTier: TypeAlias = Literal[300, 10000, 50000, 125000, 300000, 500000, 750000, 1000000, 1500000]
481485
TeamData = TypedDict(
482486
"TeamData",
483487
{
@@ -526,6 +530,7 @@
526530
{
527531
"name": "NotRequired[str | None]",
528532
"smtp_enabled": "NotRequired[bool | None]",
533+
"redact_email_content": "NotRequired[bool | None]",
529534
"default_route_id": "NotRequired[str | None]",
530535
},
531536
)
@@ -537,12 +542,32 @@
537542
},
538543
)
539544

545+
UpdateRouteInboundSettingsData = TypedDict(
546+
"UpdateRouteInboundSettingsData",
547+
{
548+
"inbound_domain": "NotRequired[str | None]",
549+
"inbound_spam_threshold": "NotRequired[float | None]",
550+
"attachment_delivery": "NotRequired[AttachmentDelivery | Any]",
551+
},
552+
)
553+
554+
UpdateRouteSettingsData = TypedDict(
555+
"UpdateRouteSettingsData",
556+
{
557+
"track_opens": "NotRequired[bool | None]",
558+
"track_clicks": "NotRequired[bool | None]",
559+
"disable_plaintext_generation": "NotRequired[bool | None]",
560+
"disable_hosted_unsubscribe": "NotRequired[bool | None]",
561+
"redact_email_content": "NotRequired[bool | None]",
562+
},
563+
)
564+
540565
UpdateRouteData = TypedDict(
541566
"UpdateRouteData",
542567
{
543568
"name": "NotRequired[str | None]",
544-
"settings": "NotRequired[dict[str, Any]]",
545-
"inbound_settings": "NotRequired[dict[str, Any]]",
569+
"settings": "NotRequired[UpdateRouteSettingsData | Any]",
570+
"inbound_settings": "NotRequired[UpdateRouteInboundSettingsData | Any]",
546571
},
547572
)
548573

@@ -700,6 +725,14 @@
700725
},
701726
)
702727

728+
BlockedFileTypesResponse = TypedDict(
729+
"BlockedFileTypesResponse",
730+
{
731+
"extensions": "Required[list[str]]",
732+
"mime_types": "Required[list[str]]",
733+
},
734+
)
735+
703736
MessageIndexResponse: TypeAlias = Union[dict[str, Any], list[MessageListData]]
704737
MessageShowResponse: TypeAlias = MessageData
705738
MessageEventsResponse = TypedDict(
@@ -760,7 +793,7 @@
760793
{
761794
"data": "Required[ProjectData]",
762795
"new_token": "Required[str]",
763-
"message": "Required[Literal['API token rotated successfully. Please update your integrations.']]",
796+
"message": "Required[Literal['Project API token rotated successfully. Please update your integrations.']]",
764797
},
765798
)
766799

tests/test_api_surface.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
from __future__ import annotations
44

55
import json
6+
from typing import get_args
67

78
import pytest
89
import respx
910
from httpx import Response
1011

1112
from lettermint import AsyncLettermint, Lettermint
13+
from lettermint import types as lm_types
1214

1315

1416
class TestV2Entrypoints:
@@ -38,6 +40,26 @@ def test_api_entrypoint_uses_bearer_auth_and_raw_ping(self) -> None:
3840
assert request.headers["authorization"] == "Bearer api-token"
3941
assert "x-lettermint-token" not in request.headers
4042

43+
@respx.mock
44+
def test_api_blocked_file_types_uses_bearer_auth(self) -> None:
45+
route = respx.get("https://api.lettermint.co/v1/blocked-file-types").mock(
46+
return_value=Response(
47+
200,
48+
json={
49+
"extensions": ["exe"],
50+
"mime_types": ["application/x-msdownload"],
51+
},
52+
)
53+
)
54+
55+
with Lettermint.api("api-token") as api:
56+
response = api.blocked_file_types()
57+
58+
assert response["extensions"] == ["exe"]
59+
request = route.calls.last.request
60+
assert request.headers["authorization"] == "Bearer api-token"
61+
assert "x-lettermint-token" not in request.headers
62+
4163
@respx.mock
4264
@pytest.mark.asyncio
4365
async def test_async_entrypoints_use_matching_auth(self) -> None:
@@ -70,6 +92,7 @@ def test_async_api_exposes_full_endpoint_groups(self) -> None:
7092
assert hasattr(api, "suppressions")
7193
assert hasattr(api, "team")
7294
assert hasattr(api, "webhooks")
95+
assert hasattr(api, "blocked_file_types")
7396

7497

7598
class TestSendingEndpoint:
@@ -148,6 +171,7 @@ def test_documented_operations_are_exposed(self) -> None:
148171
(Lettermint.email("token"), "send_batch"),
149172
(Lettermint.email("token"), "ping"),
150173
(Lettermint.api("token"), "ping"),
174+
(Lettermint.api("token"), "blocked_file_types"),
151175
(Lettermint.api("token").domains, "list"),
152176
(Lettermint.api("token").domains, "create"),
153177
(Lettermint.api("token").domains, "retrieve"),
@@ -198,3 +222,21 @@ def test_documented_operations_are_exposed(self) -> None:
198222
missing = [method for endpoint, method in operations if not hasattr(endpoint, method)]
199223

200224
assert missing == []
225+
226+
def test_generated_types_match_current_team_schema(self) -> None:
227+
assert "auto_replied" in get_args(lm_types.MessageEventType)
228+
assert "message.auto_replied" in get_args(lm_types.WebhookEvent)
229+
assert 300000 in get_args(lm_types.VolumeTier)
230+
assert "global" in get_args(lm_types.SuppressionScope)
231+
232+
assert "short_token" in lm_types.StoreProjectData.__annotations__
233+
assert "redact_email_content" in lm_types.ProjectData.__annotations__
234+
assert "redact_email_content" in lm_types.UpdateProjectData.__annotations__
235+
assert hasattr(lm_types, "UpdateRouteSettingsData")
236+
assert hasattr(lm_types, "UpdateRouteInboundSettingsData")
237+
assert hasattr(lm_types, "BlockedFileTypesResponse")
238+
assert "extensions" in lm_types.BlockedFileTypesResponse.__annotations__
239+
assert "mime_types" in lm_types.BlockedFileTypesResponse.__annotations__
240+
assert "redact_email_content" in lm_types.UpdateRouteSettingsData.__annotations__
241+
assert "disable_plaintext_generation" in lm_types.UpdateRouteSettingsData.__annotations__
242+
assert "inbound_spam_threshold" in lm_types.UpdateRouteInboundSettingsData.__annotations__

0 commit comments

Comments
 (0)