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
38 changes: 38 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Upgrade To v2

This guide covers upgrading from the latest released v1 Python SDK to v2.

## Highlights

- Sending email now lives behind `Lettermint.email(token)`.
- The full Lettermint API is available through `Lettermint.api(token)`.
- Sending tokens use `x-lettermint-token`; full API tokens use `Authorization: Bearer`.
- `ping()` returns the raw trimmed `pong` response.
- Request and response shapes are generated from the OpenAPI specs as `TypedDict`/`Literal` types.

## Replace Client Construction

```python
from lettermint import Lettermint

email = Lettermint.email("sending-token")
api = Lettermint.api("api-token")
```

Existing `Lettermint(api_token="...").email` sending usage still works.

## Batch Sending

```python
email.send_batch([
{"from": "sender@example.com", "to": ["user@example.com"], "subject": "Hello", "text": "Hi"}
])
```

## Full API

```python
domains = api.domains.list()
message_html = api.messages.html("message-id")
```

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ ignore = [

[tool.ruff.lint.isort]
known-first-party = ["lettermint"]

[tool.ruff.lint.per-file-ignores]
"src/lettermint/types.py" = ["UP013"]
13 changes: 11 additions & 2 deletions src/lettermint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,14 @@
ValidationError,
WebhookVerificationError,
)
from .lettermint import AsyncLettermint, Lettermint
from .types import EmailAttachment, EmailPayload, EmailStatus, SendEmailResponse
from .lettermint import ApiClient, AsyncApiClient, AsyncLettermint, Lettermint
from .types import (
EmailAttachment,
EmailPayload,
EmailStatus,
SendBatchEmailResponse,
SendEmailResponse,
)
from .webhook import Webhook

__version__ = "1.0.0"
Expand All @@ -60,6 +66,8 @@
# Main clients
"Lettermint",
"AsyncLettermint",
"ApiClient",
"AsyncApiClient",
# Webhook
"Webhook",
# Exceptions
Expand All @@ -77,4 +85,5 @@
"EmailPayload",
"EmailStatus",
"SendEmailResponse",
"SendBatchEmailResponse",
]
104 changes: 94 additions & 10 deletions src/lettermint/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,19 @@ def __init__(
api_token: str,
base_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
auth_scheme: str = "sending",
) -> None:
self._api_token = api_token
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
self._timeout = timeout
self._auth_scheme = auth_scheme
self._client = httpx.Client(
base_url=self._base_url,
timeout=self._timeout,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": f"Lettermint/{version('lettermint')} (Python; python {platform.python_version()})",
"x-lettermint-token": self._api_token,
},
)

Expand Down Expand Up @@ -94,6 +95,18 @@ def _handle_response(self, response: httpx.Response) -> Any:
response_body,
)

def _request_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
safe_headers = {
key: value
for key, value in (headers or {}).items()
if key.lower() not in {"authorization", "x-lettermint-token"}
}

if self._auth_scheme == "bearer":
return {**safe_headers, "Authorization": f"Bearer {self._api_token}"}

return {**safe_headers, "x-lettermint-token": self._api_token}

def get(
self,
path: str,
Expand All @@ -115,11 +128,27 @@ def get(
TimeoutError: On request timeout.
"""
try:
response = self._client.get(path, params=params, headers=headers)
response = self._client.get(path, params=params, headers=self._request_headers(headers))
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

def get_raw(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> str:
"""Make a GET request and return the raw response body."""
try:
response = self._client.get(path, params=params, headers=self._request_headers(headers))
if response.is_success:
return response.text
self._handle_response(response)
raise AssertionError("unreachable")
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

def post(
self,
path: str,
Expand All @@ -141,7 +170,7 @@ def post(
TimeoutError: On request timeout.
"""
try:
response = self._client.post(path, json=data, headers=headers)
response = self._client.post(path, json=data, headers=self._request_headers(headers))
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e
Expand All @@ -167,14 +196,15 @@ def put(
TimeoutError: On request timeout.
"""
try:
response = self._client.put(path, json=data, headers=headers)
response = self._client.put(path, json=data, headers=self._request_headers(headers))
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

def delete(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
"""Make a DELETE request to the API.
Expand All @@ -191,7 +221,11 @@ def delete(
TimeoutError: On request timeout.
"""
try:
response = self._client.delete(path, headers=headers)
response = self._client.delete(
path,
params=params,
headers=self._request_headers(headers),
)
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e
Expand All @@ -211,18 +245,19 @@ def __init__(
api_token: str,
base_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
auth_scheme: str = "sending",
) -> None:
self._api_token = api_token
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
self._timeout = timeout
self._auth_scheme = auth_scheme
self._client = httpx.AsyncClient(
base_url=self._base_url,
timeout=self._timeout,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": f"Lettermint/{version('lettermint')} (Python; python {platform.python_version()})",
"x-lettermint-token": self._api_token,
},
)

Expand Down Expand Up @@ -272,6 +307,18 @@ def _handle_response(self, response: httpx.Response) -> Any:
response_body,
)

def _request_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
safe_headers = {
key: value
for key, value in (headers or {}).items()
if key.lower() not in {"authorization", "x-lettermint-token"}
}

if self._auth_scheme == "bearer":
return {**safe_headers, "Authorization": f"Bearer {self._api_token}"}

return {**safe_headers, "x-lettermint-token": self._api_token}

async def get(
self,
path: str,
Expand All @@ -293,11 +340,35 @@ async def get(
TimeoutError: On request timeout.
"""
try:
response = await self._client.get(path, params=params, headers=headers)
response = await self._client.get(
path,
params=params,
headers=self._request_headers(headers),
)
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

async def get_raw(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> str:
"""Make a GET request and return the raw response body."""
try:
response = await self._client.get(
path,
params=params,
headers=self._request_headers(headers),
)
if response.is_success:
return response.text
self._handle_response(response)
raise AssertionError("unreachable")
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

async def post(
self,
path: str,
Expand All @@ -319,7 +390,11 @@ async def post(
TimeoutError: On request timeout.
"""
try:
response = await self._client.post(path, json=data, headers=headers)
response = await self._client.post(
path,
json=data,
headers=self._request_headers(headers),
)
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e
Expand All @@ -345,14 +420,19 @@ async def put(
TimeoutError: On request timeout.
"""
try:
response = await self._client.put(path, json=data, headers=headers)
response = await self._client.put(
path,
json=data,
headers=self._request_headers(headers),
)
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e

async def delete(
self,
path: str,
params: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
"""Make a DELETE request to the API.
Expand All @@ -369,7 +449,11 @@ async def delete(
TimeoutError: On request timeout.
"""
try:
response = await self._client.delete(path, headers=headers)
response = await self._client.delete(
path,
params=params,
headers=self._request_headers(headers),
)
return self._handle_response(response)
except httpx.TimeoutException as e:
raise TimeoutError(f"Request timeout after {self._timeout}s") from e
Loading