feat: add typed Python SDK (#213)
This commit is contained in:
43
.github/workflows/release-python-package.yml
vendored
Normal file
43
.github/workflows/release-python-package.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Publish Python package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "packages/python-sdk/**" # Trigger only changes within sdk/python
|
||||||
|
- ".github/workflows/release-python-package.yml"
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/python-sdk
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- uses: snok/install-poetry@v1
|
||||||
|
with:
|
||||||
|
version: "1.8.2"
|
||||||
|
virtualenvs-create: false
|
||||||
|
|
||||||
|
- name: Build package with Poetry
|
||||||
|
run: poetry build --no-interaction
|
||||||
|
|
||||||
|
- name: List contents of dist
|
||||||
|
run: ls -la dist
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@v1.4.2
|
||||||
|
with:
|
||||||
|
user: __token__
|
||||||
|
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
packages_dir: packages/python-sdk/dist
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,4 +40,5 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
prod_db.tar
|
prod_db.tar
|
||||||
|
|
||||||
bin
|
bin
|
||||||
|
__pycache__
|
@@ -27,6 +27,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"introduction",
|
"introduction",
|
||||||
"get-started/nodejs",
|
"get-started/nodejs",
|
||||||
|
"get-started/python",
|
||||||
"get-started/local",
|
"get-started/local",
|
||||||
"get-started/self-hosting",
|
"get-started/self-hosting",
|
||||||
"get-started/smtp"
|
"get-started/smtp"
|
||||||
@@ -138,4 +139,4 @@
|
|||||||
"github": "https://github.com/usesend"
|
"github": "https://github.com/usesend"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
164
apps/docs/get-started/python.mdx
Normal file
164
apps/docs/get-started/python.mdx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
title: Python SDK
|
||||||
|
description: Official UseSend Python SDK for sending emails and managing contacts.
|
||||||
|
icon: python
|
||||||
|
---
|
||||||
|
|
||||||
|
This guide shows how to install and use the official `usesend` Python SDK.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install from PyPI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install usesend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initialize
|
||||||
|
|
||||||
|
```python
|
||||||
|
from usesend import UseSend, types
|
||||||
|
|
||||||
|
|
||||||
|
# Option A: pass values directly (helpful in scripts/tests)
|
||||||
|
client = UseSend("us_xxx")
|
||||||
|
|
||||||
|
# Option B: custom base URL (self-hosted)
|
||||||
|
client = UseSend("us_xxx", url="https://your-domain.example")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Send an email
|
||||||
|
|
||||||
|
`EmailCreate` is a TypedDict for editor hints; at runtime you pass a regular dict. The client accepts `from` or `from_` (it normalizes `from_` to `from`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from usesend import UseSend, types
|
||||||
|
|
||||||
|
client = UseSend("us_xxx")
|
||||||
|
|
||||||
|
payload: types.EmailCreate = {
|
||||||
|
"to": "user@example.com",
|
||||||
|
"from": "no-reply@yourdomain.com",
|
||||||
|
"subject": "Welcome",
|
||||||
|
"html": "<strong>Hello!</strong>",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err = client.emails.send(payload)
|
||||||
|
print(data or err)
|
||||||
|
```
|
||||||
|
|
||||||
|
Attachments and scheduling:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
payload: types.EmailCreate = {
|
||||||
|
"to": ["user1@example.com", "user2@example.com"],
|
||||||
|
"from": "no-reply@yourdomain.com",
|
||||||
|
"subject": "Report",
|
||||||
|
"text": "See attached.",
|
||||||
|
"attachments": [
|
||||||
|
{"filename": "report.txt", "content": "SGVsbG8gd29ybGQ="}, # base64
|
||||||
|
],
|
||||||
|
"scheduledAt": datetime.utcnow() + timedelta(minutes=10),
|
||||||
|
}
|
||||||
|
data, err = client.emails.create(payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch send
|
||||||
|
|
||||||
|
```python
|
||||||
|
items: list[types.EmailBatchItem] = [
|
||||||
|
{"to": "a@example.com", "from": "no-reply@yourdomain.com", "subject": "A", "html": "<p>A</p>"},
|
||||||
|
{"to": "b@example.com", "from": "no-reply@yourdomain.com", "subject": "B", "html": "<p>B</p>"},
|
||||||
|
]
|
||||||
|
data, err = client.emails.batch(items)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retrieve and manage emails
|
||||||
|
|
||||||
|
Get an email:
|
||||||
|
|
||||||
|
```python
|
||||||
|
email, err = client.emails.get("email_123")
|
||||||
|
```
|
||||||
|
|
||||||
|
Update schedule time:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
update: types.EmailUpdate = {"scheduledAt": datetime.utcnow() + timedelta(hours=1)}
|
||||||
|
data, err = client.emails.update("email_123", update)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cancel a scheduled email:
|
||||||
|
|
||||||
|
```python
|
||||||
|
data, err = client.emails.cancel("email_123")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contacts
|
||||||
|
|
||||||
|
All contact operations require a contact book ID (`book_id`).
|
||||||
|
|
||||||
|
Create a contact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
create: types.ContactCreate = {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"firstName": "Jane",
|
||||||
|
"properties": {"plan": "pro"},
|
||||||
|
}
|
||||||
|
data, err = client.contacts.create("book_123", create)
|
||||||
|
```
|
||||||
|
|
||||||
|
Get a contact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
contact, err = client.contacts.get("book_123", "contact_456")
|
||||||
|
```
|
||||||
|
|
||||||
|
Update a contact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
update: types.ContactUpdate = {"subscribed": False}
|
||||||
|
data, err = client.contacts.update("book_123", "contact_456", update)
|
||||||
|
```
|
||||||
|
|
||||||
|
Upsert a contact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
upsert: types.ContactUpsert = {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"firstName": "Jane",
|
||||||
|
}
|
||||||
|
data, err = client.contacts.upsert("book_123", "contact_456", upsert)
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a contact:
|
||||||
|
|
||||||
|
```python
|
||||||
|
data, err = client.contacts.delete(book_id="book_123", contact_id="contact_456")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
By default the client raises `UseSendHTTPError` for non-2xx responses. To handle errors as return values, pass `raise_on_error=False`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from usesend import UseSend, UseSendHTTPError
|
||||||
|
|
||||||
|
# Raises exceptions on errors (default)
|
||||||
|
client = UseSend("us_xxx")
|
||||||
|
try:
|
||||||
|
data, _ = client.emails.get("email_123")
|
||||||
|
except UseSendHTTPError as e:
|
||||||
|
print("request failed:", e)
|
||||||
|
|
||||||
|
# Returns (None, error) instead of raising
|
||||||
|
client = UseSend("us_xxx", raise_on_error=False)
|
||||||
|
data, err = client.emails.get("email_123")
|
||||||
|
if err:
|
||||||
|
print("error:", err)
|
||||||
|
```
|
@@ -37,6 +37,13 @@ Quicklinks to set up your account and get started
|
|||||||
href="/get-started/nodejs"
|
href="/get-started/nodejs"
|
||||||
>
|
>
|
||||||
Learn how to use our SDK using NodeJS to send emails programmatically.
|
Learn how to use our SDK using NodeJS to send emails programmatically.
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
title="Python"
|
||||||
|
icon="python"
|
||||||
|
href="/get-started/python"
|
||||||
|
>
|
||||||
|
Learn how to use our SDK using Python to send emails programmatically.
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
title="SMTP support"
|
title="SMTP support"
|
||||||
|
21
packages/python-sdk/LICENSE
Normal file
21
packages/python-sdk/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 UseSend
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
61
packages/python-sdk/README.md
Normal file
61
packages/python-sdk/README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# UseSend Python SDK
|
||||||
|
|
||||||
|
A minimal Python SDK for the [UseSend](https://usesend.com) API, mirroring the structure of the JavaScript SDK.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install via pip or Poetry:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install usesend
|
||||||
|
# or
|
||||||
|
poetry add usesend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from usesend import UseSend, types
|
||||||
|
|
||||||
|
# By default: raises UseSendHTTPError on non-2xx.
|
||||||
|
client = UseSend("us_123")
|
||||||
|
|
||||||
|
# 1) TypedDict payload (autocomplete in IDEs). Use dict to pass 'from'.
|
||||||
|
payload: types.EmailCreate = {
|
||||||
|
"to": "test@example.com",
|
||||||
|
"from": "no-reply@example.com",
|
||||||
|
"subject": "Hello",
|
||||||
|
"html": "<strong>Hi!</strong>",
|
||||||
|
}
|
||||||
|
resp, _ = client.emails.send(payload=payload)
|
||||||
|
|
||||||
|
# 2) Or pass a plain dict (supports 'from')
|
||||||
|
resp, _ = client.emails.send(payload={
|
||||||
|
"to": "test@example.com",
|
||||||
|
"from": "no-reply@example.com",
|
||||||
|
"subject": "Hello",
|
||||||
|
"html": "<strong>Hi!</strong>",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Toggle behavior if desired:
|
||||||
|
# - raise_on_error=False: return (None, error_dict) instead of raising
|
||||||
|
# No model parsing occurs; methods return plain dicts following the typed shapes.
|
||||||
|
client = UseSend("us_123", raise_on_error=False)
|
||||||
|
raw, err = client.emails.get(email_id="email_123")
|
||||||
|
if err:
|
||||||
|
print("error:", err)
|
||||||
|
else:
|
||||||
|
print("ok:", raw)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
This package is managed with Poetry. Models are maintained in-repo under
|
||||||
|
`usesend/types.py` (readable names). Update this file as the API evolves.
|
||||||
|
|
||||||
|
It is published as `usesend` on PyPI.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
|
||||||
|
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
|
1139
packages/python-sdk/poetry.lock
generated
Normal file
1139
packages/python-sdk/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
packages/python-sdk/pyproject.toml
Normal file
20
packages/python-sdk/pyproject.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "usesend"
|
||||||
|
version = "0.2.3"
|
||||||
|
description = "Python SDK for the UseSend API"
|
||||||
|
authors = ["UseSend"]
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
packages = [{ include = "usesend" }]
|
||||||
|
include = ["usesend/py.typed"]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = ">=3.8,<4.0"
|
||||||
|
requests = "^2.32.0"
|
||||||
|
typing_extensions = ">=4.7"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
6
packages/python-sdk/usesend/__init__.py
Normal file
6
packages/python-sdk/usesend/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Python client for the UseSend API."""
|
||||||
|
|
||||||
|
from .usesend import UseSend, UseSendHTTPError
|
||||||
|
from . import types
|
||||||
|
|
||||||
|
__all__ = ["UseSend", "UseSendHTTPError", "types"]
|
69
packages/python-sdk/usesend/contacts.py
Normal file
69
packages/python-sdk/usesend/contacts.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Contact resource client using TypedDict shapes (no Pydantic)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
APIError,
|
||||||
|
ContactDeleteResponse,
|
||||||
|
Contact,
|
||||||
|
ContactUpdate,
|
||||||
|
ContactUpdateResponse,
|
||||||
|
ContactUpsert,
|
||||||
|
ContactUpsertResponse,
|
||||||
|
ContactCreate,
|
||||||
|
ContactCreateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Contacts:
|
||||||
|
"""Client for `/contactBooks` endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, usesend: "UseSend") -> None:
|
||||||
|
self.usesend = usesend
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self, book_id: str, payload: ContactCreate
|
||||||
|
) -> Tuple[Optional[ContactCreateResponse], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.post(
|
||||||
|
f"/contactBooks/{book_id}/contacts",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self, book_id: str, contact_id: str
|
||||||
|
) -> Tuple[Optional[Contact], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.get(
|
||||||
|
f"/contactBooks/{book_id}/contacts/{contact_id}"
|
||||||
|
)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, book_id: str, contact_id: str, payload: ContactUpdate
|
||||||
|
) -> Tuple[Optional[ContactUpdateResponse], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.patch(
|
||||||
|
f"/contactBooks/{book_id}/contacts/{contact_id}",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def upsert(
|
||||||
|
self, book_id: str, contact_id: str, payload: ContactUpsert
|
||||||
|
) -> Tuple[Optional[ContactUpsertResponse], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.put(
|
||||||
|
f"/contactBooks/{book_id}/contacts/{contact_id}",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self, *, book_id: str, contact_id: str
|
||||||
|
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.delete(
|
||||||
|
f"/contactBooks/{book_id}/contacts/{contact_id}"
|
||||||
|
)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
|
77
packages/python-sdk/usesend/emails.py
Normal file
77
packages/python-sdk/usesend/emails.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Email resource client using TypedDict shapes (no Pydantic)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
APIError,
|
||||||
|
Attachment,
|
||||||
|
EmailBatchItem,
|
||||||
|
EmailBatchResponse,
|
||||||
|
EmailCancelResponse,
|
||||||
|
Email,
|
||||||
|
EmailUpdate,
|
||||||
|
EmailUpdateResponse,
|
||||||
|
EmailCreate,
|
||||||
|
EmailCreateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Emails:
|
||||||
|
"""Client for `/emails` endpoints."""
|
||||||
|
|
||||||
|
def __init__(self, usesend: "UseSend") -> None:
|
||||||
|
self.usesend = usesend
|
||||||
|
|
||||||
|
# Basic operations -------------------------------------------------
|
||||||
|
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
||||||
|
"""Alias for :meth:`create`."""
|
||||||
|
return self.create(payload)
|
||||||
|
|
||||||
|
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = dict(payload)
|
||||||
|
|
||||||
|
# Normalize fields
|
||||||
|
body: Dict[str, Any] = dict(payload)
|
||||||
|
# Support accidental 'from_' usage
|
||||||
|
if "from_" in body and "from" not in body:
|
||||||
|
body["from"] = body.pop("from_")
|
||||||
|
# Convert scheduledAt to ISO 8601 if datetime
|
||||||
|
if isinstance(body.get("scheduledAt"), datetime):
|
||||||
|
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
||||||
|
|
||||||
|
data, err = self.usesend.post("/emails", body)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for item in payload:
|
||||||
|
d = dict(item)
|
||||||
|
if "from_" in d and "from" not in d:
|
||||||
|
d["from"] = d.pop("from_")
|
||||||
|
if isinstance(d.get("scheduledAt"), datetime):
|
||||||
|
d["scheduledAt"] = d["scheduledAt"].isoformat()
|
||||||
|
items.append(d)
|
||||||
|
data, err = self.usesend.post("/emails/batch", items)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.get(f"/emails/{email_id}")
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def update(self, email_id: str, payload: EmailUpdate) -> Tuple[Optional[EmailUpdateResponse], Optional[APIError]]:
|
||||||
|
body: Dict[str, Any] = dict(payload)
|
||||||
|
if isinstance(body.get("scheduledAt"), datetime):
|
||||||
|
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
||||||
|
|
||||||
|
data, err = self.usesend.patch(f"/emails/{email_id}", body)
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
def cancel(self, email_id: str) -> Tuple[Optional[EmailCancelResponse], Optional[APIError]]:
|
||||||
|
data, err = self.usesend.post(f"/emails/{email_id}/cancel", {})
|
||||||
|
return (data, err) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
|
1
packages/python-sdk/usesend/py.typed
Normal file
1
packages/python-sdk/usesend/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
317
packages/python-sdk/usesend/types.py
Normal file
317
packages/python-sdk/usesend/types.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""TypedDict models for the UseSend API.
|
||||||
|
|
||||||
|
Lightweight, Pydantic-free types for editor autocomplete and static checks.
|
||||||
|
At runtime these are plain dicts and lists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Union, TypedDict
|
||||||
|
from typing_extensions import NotRequired, Required, Literal
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domains
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DomainStatus = Literal[
|
||||||
|
'NOT_STARTED',
|
||||||
|
'PENDING',
|
||||||
|
'SUCCESS',
|
||||||
|
'FAILED',
|
||||||
|
'TEMPORARY_FAILURE',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(TypedDict, total=False):
|
||||||
|
id: float
|
||||||
|
name: str
|
||||||
|
teamId: float
|
||||||
|
status: DomainStatus
|
||||||
|
region: str
|
||||||
|
clickTracking: bool
|
||||||
|
openTracking: bool
|
||||||
|
publicKey: str
|
||||||
|
dkimStatus: Optional[str]
|
||||||
|
spfDetails: Optional[str]
|
||||||
|
createdAt: str
|
||||||
|
updatedAt: str
|
||||||
|
dmarcAdded: bool
|
||||||
|
isVerifying: bool
|
||||||
|
errorMessage: Optional[str]
|
||||||
|
subdomain: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
DomainList = List[Domain]
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCreate(TypedDict):
|
||||||
|
name: str
|
||||||
|
region: str
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCreateResponse(TypedDict, total=False):
|
||||||
|
id: float
|
||||||
|
name: str
|
||||||
|
teamId: float
|
||||||
|
status: DomainStatus
|
||||||
|
region: str
|
||||||
|
clickTracking: bool
|
||||||
|
openTracking: bool
|
||||||
|
publicKey: str
|
||||||
|
dkimStatus: Optional[str]
|
||||||
|
spfDetails: Optional[str]
|
||||||
|
createdAt: str
|
||||||
|
updatedAt: str
|
||||||
|
dmarcAdded: bool
|
||||||
|
isVerifying: bool
|
||||||
|
errorMessage: Optional[str]
|
||||||
|
subdomain: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DomainVerifyResponse(TypedDict):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Emails
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EmailEventStatus = Literal[
|
||||||
|
'SCHEDULED',
|
||||||
|
'QUEUED',
|
||||||
|
'SENT',
|
||||||
|
'DELIVERY_DELAYED',
|
||||||
|
'BOUNCED',
|
||||||
|
'REJECTED',
|
||||||
|
'RENDERING_FAILURE',
|
||||||
|
'DELIVERED',
|
||||||
|
'OPENED',
|
||||||
|
'CLICKED',
|
||||||
|
'COMPLAINED',
|
||||||
|
'FAILED',
|
||||||
|
'CANCELLED',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailEvent(TypedDict, total=False):
|
||||||
|
emailId: str
|
||||||
|
status: EmailEventStatus
|
||||||
|
createdAt: str
|
||||||
|
data: Optional[Any]
|
||||||
|
|
||||||
|
|
||||||
|
Email = TypedDict(
|
||||||
|
'Email',
|
||||||
|
{
|
||||||
|
'id': str,
|
||||||
|
'teamId': float,
|
||||||
|
'to': Union[str, List[str]],
|
||||||
|
'replyTo': NotRequired[Union[str, List[str]]],
|
||||||
|
'cc': NotRequired[Union[str, List[str]]],
|
||||||
|
'bcc': NotRequired[Union[str, List[str]]],
|
||||||
|
'from': str,
|
||||||
|
'subject': str,
|
||||||
|
'html': str,
|
||||||
|
'text': str,
|
||||||
|
'createdAt': str,
|
||||||
|
'updatedAt': str,
|
||||||
|
'emailEvents': List[EmailEvent],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailUpdate(TypedDict):
|
||||||
|
# Accept datetime or ISO string; client will JSON-encode
|
||||||
|
scheduledAt: Union[datetime, str]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailUpdateResponse(TypedDict, total=False):
|
||||||
|
emailId: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
EmailLatestStatus = Literal[
|
||||||
|
'SCHEDULED',
|
||||||
|
'QUEUED',
|
||||||
|
'SENT',
|
||||||
|
'DELIVERY_DELAYED',
|
||||||
|
'BOUNCED',
|
||||||
|
'REJECTED',
|
||||||
|
'RENDERING_FAILURE',
|
||||||
|
'DELIVERED',
|
||||||
|
'OPENED',
|
||||||
|
'CLICKED',
|
||||||
|
'COMPLAINED',
|
||||||
|
'FAILED',
|
||||||
|
'CANCELLED',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
EmailListItem = TypedDict(
|
||||||
|
'EmailListItem',
|
||||||
|
{
|
||||||
|
'id': str,
|
||||||
|
'to': Union[str, List[str]],
|
||||||
|
'replyTo': NotRequired[Union[str, List[str]]],
|
||||||
|
'cc': NotRequired[Union[str, List[str]]],
|
||||||
|
'bcc': NotRequired[Union[str, List[str]]],
|
||||||
|
'from': str,
|
||||||
|
'subject': str,
|
||||||
|
'html': str,
|
||||||
|
'text': str,
|
||||||
|
'createdAt': str,
|
||||||
|
'updatedAt': str,
|
||||||
|
'latestStatus': EmailLatestStatus,
|
||||||
|
'scheduledAt': str,
|
||||||
|
'domainId': float,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailsList(TypedDict):
|
||||||
|
data: List[EmailListItem]
|
||||||
|
count: float
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(TypedDict):
|
||||||
|
filename: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
EmailCreate = TypedDict(
|
||||||
|
'EmailCreate',
|
||||||
|
{
|
||||||
|
'to': Required[Union[str, List[str]]],
|
||||||
|
'from': Required[str],
|
||||||
|
'subject': NotRequired[str],
|
||||||
|
'templateId': NotRequired[str],
|
||||||
|
'variables': NotRequired[Dict[str, str]],
|
||||||
|
'replyTo': NotRequired[Union[str, List[str]]],
|
||||||
|
'cc': NotRequired[Union[str, List[str]]],
|
||||||
|
'bcc': NotRequired[Union[str, List[str]]],
|
||||||
|
'text': NotRequired[str],
|
||||||
|
'html': NotRequired[str],
|
||||||
|
'attachments': NotRequired[List[Attachment]],
|
||||||
|
'scheduledAt': NotRequired[Union[datetime, str]],
|
||||||
|
'inReplyToId': NotRequired[str],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCreateResponse(TypedDict, total=False):
|
||||||
|
emailId: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
EmailBatchItem = TypedDict(
|
||||||
|
'EmailBatchItem',
|
||||||
|
{
|
||||||
|
'to': Required[Union[str, List[str]]],
|
||||||
|
'from': Required[str],
|
||||||
|
'subject': NotRequired[str],
|
||||||
|
'templateId': NotRequired[str],
|
||||||
|
'variables': NotRequired[Dict[str, str]],
|
||||||
|
'replyTo': NotRequired[Union[str, List[str]]],
|
||||||
|
'cc': NotRequired[Union[str, List[str]]],
|
||||||
|
'bcc': NotRequired[Union[str, List[str]]],
|
||||||
|
'text': NotRequired[str],
|
||||||
|
'html': NotRequired[str],
|
||||||
|
'attachments': NotRequired[List[Attachment]],
|
||||||
|
'scheduledAt': NotRequired[Union[datetime, str]],
|
||||||
|
'inReplyToId': NotRequired[str],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EmailBatch = List[EmailBatchItem]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBatchResponseItem(TypedDict):
|
||||||
|
emailId: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBatchResponse(TypedDict):
|
||||||
|
data: List[EmailBatchResponseItem]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCancelResponse(TypedDict, total=False):
|
||||||
|
emailId: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ContactCreate(TypedDict, total=False):
|
||||||
|
email: str
|
||||||
|
firstName: Optional[str]
|
||||||
|
lastName: Optional[str]
|
||||||
|
properties: Optional[Dict[str, str]]
|
||||||
|
subscribed: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreateResponse(TypedDict, total=False):
|
||||||
|
contactId: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactListItem(TypedDict, total=False):
|
||||||
|
id: str
|
||||||
|
firstName: Optional[str]
|
||||||
|
lastName: Optional[str]
|
||||||
|
email: str
|
||||||
|
subscribed: Optional[bool]
|
||||||
|
properties: Dict[str, str]
|
||||||
|
contactBookId: str
|
||||||
|
createdAt: str
|
||||||
|
updatedAt: str
|
||||||
|
|
||||||
|
|
||||||
|
ContactList = List[ContactListItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(TypedDict, total=False):
|
||||||
|
firstName: Optional[str]
|
||||||
|
lastName: Optional[str]
|
||||||
|
properties: Optional[Dict[str, str]]
|
||||||
|
subscribed: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdateResponse(TypedDict, total=False):
|
||||||
|
contactId: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(TypedDict, total=False):
|
||||||
|
id: str
|
||||||
|
firstName: Optional[str]
|
||||||
|
lastName: Optional[str]
|
||||||
|
email: str
|
||||||
|
subscribed: Optional[bool]
|
||||||
|
properties: Dict[str, str]
|
||||||
|
contactBookId: str
|
||||||
|
createdAt: str
|
||||||
|
updatedAt: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpsert(TypedDict, total=False):
|
||||||
|
email: str
|
||||||
|
firstName: Optional[str]
|
||||||
|
lastName: Optional[str]
|
||||||
|
properties: Optional[Dict[str, str]]
|
||||||
|
subscribed: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpsertResponse(TypedDict):
|
||||||
|
contactId: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContactDeleteResponse(TypedDict):
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Common
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class APIError(TypedDict):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
125
packages/python-sdk/usesend/usesend.py
Normal file
125
packages/python-sdk/usesend/usesend.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Core client for interacting with the UseSend API.
|
||||||
|
|
||||||
|
Enhancements:
|
||||||
|
- Optional ``raise_on_error`` to raise ``UseSendHTTPError`` on non-2xx.
|
||||||
|
- Reusable ``requests.Session`` support for connection reuse.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = "https://app.usesend.com"
|
||||||
|
|
||||||
|
|
||||||
|
class UseSendHTTPError(Exception):
|
||||||
|
"""HTTP error raised when ``raise_on_error=True`` and a request fails."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, error: Dict[str, Any], method: str, path: str) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error = error
|
||||||
|
self.method = method
|
||||||
|
self.path = path
|
||||||
|
super().__init__(self.__str__())
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - presentation only
|
||||||
|
code = self.error.get("code", "UNKNOWN_ERROR")
|
||||||
|
message = self.error.get("message", "")
|
||||||
|
return f"{self.method} {self.path} -> {self.status_code} {code}: {message}"
|
||||||
|
|
||||||
|
|
||||||
|
class UseSend:
|
||||||
|
"""UseSend API client.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
key:
|
||||||
|
API key issued by UseSend. If not provided, the client attempts to
|
||||||
|
read ``USESEND_API_KEY`` or ``UNSEND_API_KEY`` from the environment.
|
||||||
|
url:
|
||||||
|
Optional base URL for the API (useful for testing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
raise_on_error: bool = True,
|
||||||
|
session: Optional[requests.Session] = None,
|
||||||
|
) -> None:
|
||||||
|
self.key = key or os.getenv("USESEND_API_KEY") or os.getenv("UNSEND_API_KEY")
|
||||||
|
if not self.key:
|
||||||
|
raise ValueError("Missing API key. Pass it to UseSend('us_123')")
|
||||||
|
|
||||||
|
base = os.getenv("USESEND_BASE_URL") or os.getenv("UNSEND_BASE_URL") or DEFAULT_BASE_URL
|
||||||
|
if url:
|
||||||
|
base = url
|
||||||
|
self.url = f"{base}/api/v1"
|
||||||
|
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {self.key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.raise_on_error = raise_on_error
|
||||||
|
self._session = session or requests.Session()
|
||||||
|
|
||||||
|
# Lazily initialise resource clients.
|
||||||
|
self.emails = Emails(self)
|
||||||
|
self.contacts = Contacts(self)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal request helper
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _request(
|
||||||
|
self, method: str, path: str, json: Optional[Any] = None
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
"""Perform an HTTP request and return ``(data, error)``."""
|
||||||
|
resp = self._session.request(
|
||||||
|
method, f"{self.url}{path}", headers=self.headers, json=json
|
||||||
|
)
|
||||||
|
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
|
||||||
|
|
||||||
|
if not resp.ok:
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
error = payload.get("error", default_error)
|
||||||
|
except Exception:
|
||||||
|
error = default_error
|
||||||
|
if self.raise_on_error:
|
||||||
|
raise UseSendHTTPError(resp.status_code, error, method, path)
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
try:
|
||||||
|
return resp.json(), None
|
||||||
|
except Exception:
|
||||||
|
return None, default_error
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# HTTP verb helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("POST", path, json=body)
|
||||||
|
|
||||||
|
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("GET", path)
|
||||||
|
|
||||||
|
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("PUT", path, json=body)
|
||||||
|
|
||||||
|
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("PATCH", path, json=body)
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self, path: str, body: Optional[Any] = None
|
||||||
|
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
|
return self._request("DELETE", path, json=body)
|
||||||
|
|
||||||
|
|
||||||
|
# Import here to avoid circular dependency during type checking
|
||||||
|
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
|
||||||
|
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position
|
Reference in New Issue
Block a user