usesend 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- usesend-0.2.3/LICENSE +21 -0
- usesend-0.2.3/PKG-INFO +80 -0
- usesend-0.2.3/README.md +61 -0
- usesend-0.2.3/pyproject.toml +20 -0
- usesend-0.2.3/usesend/__init__.py +6 -0
- usesend-0.2.3/usesend/contacts.py +69 -0
- usesend-0.2.3/usesend/emails.py +77 -0
- usesend-0.2.3/usesend/py.typed +1 -0
- usesend-0.2.3/usesend/types.py +317 -0
- usesend-0.2.3/usesend/usesend.py +125 -0
usesend-0.2.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 UseSend
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
usesend-0.2.3/PKG-INFO
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: usesend
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Python SDK for the UseSend API
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: UseSend
|
|
7
|
+
Requires-Python: >=3.8,<4.0
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Dist: requests (>=2.32.0,<3.0.0)
|
|
16
|
+
Requires-Dist: typing_extensions (>=4.7)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# UseSend Python SDK
|
|
20
|
+
|
|
21
|
+
A minimal Python SDK for the [UseSend](https://usesend.com) API, mirroring the structure of the JavaScript SDK.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Install via pip or Poetry:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install usesend
|
|
29
|
+
# or
|
|
30
|
+
poetry add usesend
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from usesend import UseSend, types
|
|
37
|
+
|
|
38
|
+
# By default: raises UseSendHTTPError on non-2xx.
|
|
39
|
+
client = UseSend("us_123")
|
|
40
|
+
|
|
41
|
+
# 1) TypedDict payload (autocomplete in IDEs). Use dict to pass 'from'.
|
|
42
|
+
payload: types.EmailCreate = {
|
|
43
|
+
"to": "test@example.com",
|
|
44
|
+
"from": "no-reply@example.com",
|
|
45
|
+
"subject": "Hello",
|
|
46
|
+
"html": "<strong>Hi!</strong>",
|
|
47
|
+
}
|
|
48
|
+
resp, _ = client.emails.send(payload=payload)
|
|
49
|
+
|
|
50
|
+
# 2) Or pass a plain dict (supports 'from')
|
|
51
|
+
resp, _ = client.emails.send(payload={
|
|
52
|
+
"to": "test@example.com",
|
|
53
|
+
"from": "no-reply@example.com",
|
|
54
|
+
"subject": "Hello",
|
|
55
|
+
"html": "<strong>Hi!</strong>",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# Toggle behavior if desired:
|
|
59
|
+
# - raise_on_error=False: return (None, error_dict) instead of raising
|
|
60
|
+
# No model parsing occurs; methods return plain dicts following the typed shapes.
|
|
61
|
+
client = UseSend("us_123", raise_on_error=False)
|
|
62
|
+
raw, err = client.emails.get(email_id="email_123")
|
|
63
|
+
if err:
|
|
64
|
+
print("error:", err)
|
|
65
|
+
else:
|
|
66
|
+
print("ok:", raw)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
This package is managed with Poetry. Models are maintained in-repo under
|
|
72
|
+
`usesend/types.py` (readable names). Update this file as the API evolves.
|
|
73
|
+
|
|
74
|
+
It is published as `usesend` on PyPI.
|
|
75
|
+
|
|
76
|
+
Notes
|
|
77
|
+
|
|
78
|
+
- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
|
|
79
|
+
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
|
|
80
|
+
|
usesend-0.2.3/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# UseSend Python SDK
|
|
2
|
+
|
|
3
|
+
A minimal Python SDK for the [UseSend](https://usesend.com) API, mirroring the structure of the JavaScript SDK.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install via pip or Poetry:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install usesend
|
|
11
|
+
# or
|
|
12
|
+
poetry add usesend
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from usesend import UseSend, types
|
|
19
|
+
|
|
20
|
+
# By default: raises UseSendHTTPError on non-2xx.
|
|
21
|
+
client = UseSend("us_123")
|
|
22
|
+
|
|
23
|
+
# 1) TypedDict payload (autocomplete in IDEs). Use dict to pass 'from'.
|
|
24
|
+
payload: types.EmailCreate = {
|
|
25
|
+
"to": "test@example.com",
|
|
26
|
+
"from": "no-reply@example.com",
|
|
27
|
+
"subject": "Hello",
|
|
28
|
+
"html": "<strong>Hi!</strong>",
|
|
29
|
+
}
|
|
30
|
+
resp, _ = client.emails.send(payload=payload)
|
|
31
|
+
|
|
32
|
+
# 2) Or pass a plain dict (supports 'from')
|
|
33
|
+
resp, _ = client.emails.send(payload={
|
|
34
|
+
"to": "test@example.com",
|
|
35
|
+
"from": "no-reply@example.com",
|
|
36
|
+
"subject": "Hello",
|
|
37
|
+
"html": "<strong>Hi!</strong>",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# Toggle behavior if desired:
|
|
41
|
+
# - raise_on_error=False: return (None, error_dict) instead of raising
|
|
42
|
+
# No model parsing occurs; methods return plain dicts following the typed shapes.
|
|
43
|
+
client = UseSend("us_123", raise_on_error=False)
|
|
44
|
+
raw, err = client.emails.get(email_id="email_123")
|
|
45
|
+
if err:
|
|
46
|
+
print("error:", err)
|
|
47
|
+
else:
|
|
48
|
+
print("ok:", raw)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
This package is managed with Poetry. Models are maintained in-repo under
|
|
54
|
+
`usesend/types.py` (readable names). Update this file as the API evolves.
|
|
55
|
+
|
|
56
|
+
It is published as `usesend` on PyPI.
|
|
57
|
+
|
|
58
|
+
Notes
|
|
59
|
+
|
|
60
|
+
- Human-friendly models are available under `usesend.types` (e.g., `EmailCreate`, `Contact`, `APIError`).
|
|
61
|
+
- Endpoint methods accept TypedDict payloads or plain dicts via the `payload=` keyword.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "usesend"
|
|
3
|
+
version = "0.2.3"
|
|
4
|
+
description = "Python SDK for the UseSend API"
|
|
5
|
+
authors = ["UseSend"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "usesend" }]
|
|
9
|
+
include = ["usesend/py.typed"]
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = ">=3.8,<4.0"
|
|
13
|
+
requests = "^2.32.0"
|
|
14
|
+
typing_extensions = ">=4.7"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["poetry-core"]
|
|
20
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Contact resource client using TypedDict shapes (no Pydantic)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .types import (
|
|
7
|
+
APIError,
|
|
8
|
+
ContactDeleteResponse,
|
|
9
|
+
Contact,
|
|
10
|
+
ContactUpdate,
|
|
11
|
+
ContactUpdateResponse,
|
|
12
|
+
ContactUpsert,
|
|
13
|
+
ContactUpsertResponse,
|
|
14
|
+
ContactCreate,
|
|
15
|
+
ContactCreateResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Contacts:
|
|
20
|
+
"""Client for `/contactBooks` endpoints."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, usesend: "UseSend") -> None:
|
|
23
|
+
self.usesend = usesend
|
|
24
|
+
|
|
25
|
+
def create(
|
|
26
|
+
self, book_id: str, payload: ContactCreate
|
|
27
|
+
) -> Tuple[Optional[ContactCreateResponse], Optional[APIError]]:
|
|
28
|
+
data, err = self.usesend.post(
|
|
29
|
+
f"/contactBooks/{book_id}/contacts",
|
|
30
|
+
payload,
|
|
31
|
+
)
|
|
32
|
+
return (data, err) # type: ignore[return-value]
|
|
33
|
+
|
|
34
|
+
def get(
|
|
35
|
+
self, book_id: str, contact_id: str
|
|
36
|
+
) -> Tuple[Optional[Contact], Optional[APIError]]:
|
|
37
|
+
data, err = self.usesend.get(
|
|
38
|
+
f"/contactBooks/{book_id}/contacts/{contact_id}"
|
|
39
|
+
)
|
|
40
|
+
return (data, err) # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
def update(
|
|
43
|
+
self, book_id: str, contact_id: str, payload: ContactUpdate
|
|
44
|
+
) -> Tuple[Optional[ContactUpdateResponse], Optional[APIError]]:
|
|
45
|
+
data, err = self.usesend.patch(
|
|
46
|
+
f"/contactBooks/{book_id}/contacts/{contact_id}",
|
|
47
|
+
payload,
|
|
48
|
+
)
|
|
49
|
+
return (data, err) # type: ignore[return-value]
|
|
50
|
+
|
|
51
|
+
def upsert(
|
|
52
|
+
self, book_id: str, contact_id: str, payload: ContactUpsert
|
|
53
|
+
) -> Tuple[Optional[ContactUpsertResponse], Optional[APIError]]:
|
|
54
|
+
data, err = self.usesend.put(
|
|
55
|
+
f"/contactBooks/{book_id}/contacts/{contact_id}",
|
|
56
|
+
payload,
|
|
57
|
+
)
|
|
58
|
+
return (data, err) # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
def delete(
|
|
61
|
+
self, *, book_id: str, contact_id: str
|
|
62
|
+
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:
|
|
63
|
+
data, err = self.usesend.delete(
|
|
64
|
+
f"/contactBooks/{book_id}/contacts/{contact_id}"
|
|
65
|
+
)
|
|
66
|
+
return (data, err) # type: ignore[return-value]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Email resource client using TypedDict shapes (no Pydantic)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
APIError,
|
|
9
|
+
Attachment,
|
|
10
|
+
EmailBatchItem,
|
|
11
|
+
EmailBatchResponse,
|
|
12
|
+
EmailCancelResponse,
|
|
13
|
+
Email,
|
|
14
|
+
EmailUpdate,
|
|
15
|
+
EmailUpdateResponse,
|
|
16
|
+
EmailCreate,
|
|
17
|
+
EmailCreateResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Emails:
|
|
22
|
+
"""Client for `/emails` endpoints."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, usesend: "UseSend") -> None:
|
|
25
|
+
self.usesend = usesend
|
|
26
|
+
|
|
27
|
+
# Basic operations -------------------------------------------------
|
|
28
|
+
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
|
29
|
+
"""Alias for :meth:`create`."""
|
|
30
|
+
return self.create(payload)
|
|
31
|
+
|
|
32
|
+
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
|
|
33
|
+
if isinstance(payload, dict):
|
|
34
|
+
payload = dict(payload)
|
|
35
|
+
|
|
36
|
+
# Normalize fields
|
|
37
|
+
body: Dict[str, Any] = dict(payload)
|
|
38
|
+
# Support accidental 'from_' usage
|
|
39
|
+
if "from_" in body and "from" not in body:
|
|
40
|
+
body["from"] = body.pop("from_")
|
|
41
|
+
# Convert scheduledAt to ISO 8601 if datetime
|
|
42
|
+
if isinstance(body.get("scheduledAt"), datetime):
|
|
43
|
+
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
|
44
|
+
|
|
45
|
+
data, err = self.usesend.post("/emails", body)
|
|
46
|
+
return (data, err) # type: ignore[return-value]
|
|
47
|
+
|
|
48
|
+
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
|
|
49
|
+
items: List[Dict[str, Any]] = []
|
|
50
|
+
for item in payload:
|
|
51
|
+
d = dict(item)
|
|
52
|
+
if "from_" in d and "from" not in d:
|
|
53
|
+
d["from"] = d.pop("from_")
|
|
54
|
+
if isinstance(d.get("scheduledAt"), datetime):
|
|
55
|
+
d["scheduledAt"] = d["scheduledAt"].isoformat()
|
|
56
|
+
items.append(d)
|
|
57
|
+
data, err = self.usesend.post("/emails/batch", items)
|
|
58
|
+
return (data, err) # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
|
|
61
|
+
data, err = self.usesend.get(f"/emails/{email_id}")
|
|
62
|
+
return (data, err) # type: ignore[return-value]
|
|
63
|
+
|
|
64
|
+
def update(self, email_id: str, payload: EmailUpdate) -> Tuple[Optional[EmailUpdateResponse], Optional[APIError]]:
|
|
65
|
+
body: Dict[str, Any] = dict(payload)
|
|
66
|
+
if isinstance(body.get("scheduledAt"), datetime):
|
|
67
|
+
body["scheduledAt"] = body["scheduledAt"].isoformat()
|
|
68
|
+
|
|
69
|
+
data, err = self.usesend.patch(f"/emails/{email_id}", body)
|
|
70
|
+
return (data, err) # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
def cancel(self, email_id: str) -> Tuple[Optional[EmailCancelResponse], Optional[APIError]]:
|
|
73
|
+
data, err = self.usesend.post(f"/emails/{email_id}/cancel", {})
|
|
74
|
+
return (data, err) # type: ignore[return-value]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""TypedDict models for the UseSend API.
|
|
2
|
+
|
|
3
|
+
Lightweight, Pydantic-free types for editor autocomplete and static checks.
|
|
4
|
+
At runtime these are plain dicts and lists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union, TypedDict
|
|
11
|
+
from typing_extensions import NotRequired, Required, Literal
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Domains
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
DomainStatus = Literal[
|
|
18
|
+
'NOT_STARTED',
|
|
19
|
+
'PENDING',
|
|
20
|
+
'SUCCESS',
|
|
21
|
+
'FAILED',
|
|
22
|
+
'TEMPORARY_FAILURE',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Domain(TypedDict, total=False):
|
|
27
|
+
id: float
|
|
28
|
+
name: str
|
|
29
|
+
teamId: float
|
|
30
|
+
status: DomainStatus
|
|
31
|
+
region: str
|
|
32
|
+
clickTracking: bool
|
|
33
|
+
openTracking: bool
|
|
34
|
+
publicKey: str
|
|
35
|
+
dkimStatus: Optional[str]
|
|
36
|
+
spfDetails: Optional[str]
|
|
37
|
+
createdAt: str
|
|
38
|
+
updatedAt: str
|
|
39
|
+
dmarcAdded: bool
|
|
40
|
+
isVerifying: bool
|
|
41
|
+
errorMessage: Optional[str]
|
|
42
|
+
subdomain: Optional[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DomainList = List[Domain]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DomainCreate(TypedDict):
|
|
49
|
+
name: str
|
|
50
|
+
region: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DomainCreateResponse(TypedDict, total=False):
|
|
54
|
+
id: float
|
|
55
|
+
name: str
|
|
56
|
+
teamId: float
|
|
57
|
+
status: DomainStatus
|
|
58
|
+
region: str
|
|
59
|
+
clickTracking: bool
|
|
60
|
+
openTracking: bool
|
|
61
|
+
publicKey: str
|
|
62
|
+
dkimStatus: Optional[str]
|
|
63
|
+
spfDetails: Optional[str]
|
|
64
|
+
createdAt: str
|
|
65
|
+
updatedAt: str
|
|
66
|
+
dmarcAdded: bool
|
|
67
|
+
isVerifying: bool
|
|
68
|
+
errorMessage: Optional[str]
|
|
69
|
+
subdomain: Optional[str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DomainVerifyResponse(TypedDict):
|
|
73
|
+
message: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Emails
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
EmailEventStatus = Literal[
|
|
81
|
+
'SCHEDULED',
|
|
82
|
+
'QUEUED',
|
|
83
|
+
'SENT',
|
|
84
|
+
'DELIVERY_DELAYED',
|
|
85
|
+
'BOUNCED',
|
|
86
|
+
'REJECTED',
|
|
87
|
+
'RENDERING_FAILURE',
|
|
88
|
+
'DELIVERED',
|
|
89
|
+
'OPENED',
|
|
90
|
+
'CLICKED',
|
|
91
|
+
'COMPLAINED',
|
|
92
|
+
'FAILED',
|
|
93
|
+
'CANCELLED',
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EmailEvent(TypedDict, total=False):
|
|
98
|
+
emailId: str
|
|
99
|
+
status: EmailEventStatus
|
|
100
|
+
createdAt: str
|
|
101
|
+
data: Optional[Any]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Email = TypedDict(
|
|
105
|
+
'Email',
|
|
106
|
+
{
|
|
107
|
+
'id': str,
|
|
108
|
+
'teamId': float,
|
|
109
|
+
'to': Union[str, List[str]],
|
|
110
|
+
'replyTo': NotRequired[Union[str, List[str]]],
|
|
111
|
+
'cc': NotRequired[Union[str, List[str]]],
|
|
112
|
+
'bcc': NotRequired[Union[str, List[str]]],
|
|
113
|
+
'from': str,
|
|
114
|
+
'subject': str,
|
|
115
|
+
'html': str,
|
|
116
|
+
'text': str,
|
|
117
|
+
'createdAt': str,
|
|
118
|
+
'updatedAt': str,
|
|
119
|
+
'emailEvents': List[EmailEvent],
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class EmailUpdate(TypedDict):
|
|
125
|
+
# Accept datetime or ISO string; client will JSON-encode
|
|
126
|
+
scheduledAt: Union[datetime, str]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class EmailUpdateResponse(TypedDict, total=False):
|
|
130
|
+
emailId: Optional[str]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
EmailLatestStatus = Literal[
|
|
134
|
+
'SCHEDULED',
|
|
135
|
+
'QUEUED',
|
|
136
|
+
'SENT',
|
|
137
|
+
'DELIVERY_DELAYED',
|
|
138
|
+
'BOUNCED',
|
|
139
|
+
'REJECTED',
|
|
140
|
+
'RENDERING_FAILURE',
|
|
141
|
+
'DELIVERED',
|
|
142
|
+
'OPENED',
|
|
143
|
+
'CLICKED',
|
|
144
|
+
'COMPLAINED',
|
|
145
|
+
'FAILED',
|
|
146
|
+
'CANCELLED',
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
EmailListItem = TypedDict(
|
|
151
|
+
'EmailListItem',
|
|
152
|
+
{
|
|
153
|
+
'id': str,
|
|
154
|
+
'to': Union[str, List[str]],
|
|
155
|
+
'replyTo': NotRequired[Union[str, List[str]]],
|
|
156
|
+
'cc': NotRequired[Union[str, List[str]]],
|
|
157
|
+
'bcc': NotRequired[Union[str, List[str]]],
|
|
158
|
+
'from': str,
|
|
159
|
+
'subject': str,
|
|
160
|
+
'html': str,
|
|
161
|
+
'text': str,
|
|
162
|
+
'createdAt': str,
|
|
163
|
+
'updatedAt': str,
|
|
164
|
+
'latestStatus': EmailLatestStatus,
|
|
165
|
+
'scheduledAt': str,
|
|
166
|
+
'domainId': float,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class EmailsList(TypedDict):
|
|
172
|
+
data: List[EmailListItem]
|
|
173
|
+
count: float
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Attachment(TypedDict):
|
|
177
|
+
filename: str
|
|
178
|
+
content: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
EmailCreate = TypedDict(
|
|
182
|
+
'EmailCreate',
|
|
183
|
+
{
|
|
184
|
+
'to': Required[Union[str, List[str]]],
|
|
185
|
+
'from': Required[str],
|
|
186
|
+
'subject': NotRequired[str],
|
|
187
|
+
'templateId': NotRequired[str],
|
|
188
|
+
'variables': NotRequired[Dict[str, str]],
|
|
189
|
+
'replyTo': NotRequired[Union[str, List[str]]],
|
|
190
|
+
'cc': NotRequired[Union[str, List[str]]],
|
|
191
|
+
'bcc': NotRequired[Union[str, List[str]]],
|
|
192
|
+
'text': NotRequired[str],
|
|
193
|
+
'html': NotRequired[str],
|
|
194
|
+
'attachments': NotRequired[List[Attachment]],
|
|
195
|
+
'scheduledAt': NotRequired[Union[datetime, str]],
|
|
196
|
+
'inReplyToId': NotRequired[str],
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class EmailCreateResponse(TypedDict, total=False):
|
|
202
|
+
emailId: Optional[str]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
EmailBatchItem = TypedDict(
|
|
206
|
+
'EmailBatchItem',
|
|
207
|
+
{
|
|
208
|
+
'to': Required[Union[str, List[str]]],
|
|
209
|
+
'from': Required[str],
|
|
210
|
+
'subject': NotRequired[str],
|
|
211
|
+
'templateId': NotRequired[str],
|
|
212
|
+
'variables': NotRequired[Dict[str, str]],
|
|
213
|
+
'replyTo': NotRequired[Union[str, List[str]]],
|
|
214
|
+
'cc': NotRequired[Union[str, List[str]]],
|
|
215
|
+
'bcc': NotRequired[Union[str, List[str]]],
|
|
216
|
+
'text': NotRequired[str],
|
|
217
|
+
'html': NotRequired[str],
|
|
218
|
+
'attachments': NotRequired[List[Attachment]],
|
|
219
|
+
'scheduledAt': NotRequired[Union[datetime, str]],
|
|
220
|
+
'inReplyToId': NotRequired[str],
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
EmailBatch = List[EmailBatchItem]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class EmailBatchResponseItem(TypedDict):
|
|
229
|
+
emailId: str
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class EmailBatchResponse(TypedDict):
|
|
233
|
+
data: List[EmailBatchResponseItem]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class EmailCancelResponse(TypedDict, total=False):
|
|
237
|
+
emailId: Optional[str]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Contacts
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
class ContactCreate(TypedDict, total=False):
|
|
245
|
+
email: str
|
|
246
|
+
firstName: Optional[str]
|
|
247
|
+
lastName: Optional[str]
|
|
248
|
+
properties: Optional[Dict[str, str]]
|
|
249
|
+
subscribed: Optional[bool]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ContactCreateResponse(TypedDict, total=False):
|
|
253
|
+
contactId: Optional[str]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class ContactListItem(TypedDict, total=False):
|
|
257
|
+
id: str
|
|
258
|
+
firstName: Optional[str]
|
|
259
|
+
lastName: Optional[str]
|
|
260
|
+
email: str
|
|
261
|
+
subscribed: Optional[bool]
|
|
262
|
+
properties: Dict[str, str]
|
|
263
|
+
contactBookId: str
|
|
264
|
+
createdAt: str
|
|
265
|
+
updatedAt: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
ContactList = List[ContactListItem]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ContactUpdate(TypedDict, total=False):
|
|
272
|
+
firstName: Optional[str]
|
|
273
|
+
lastName: Optional[str]
|
|
274
|
+
properties: Optional[Dict[str, str]]
|
|
275
|
+
subscribed: Optional[bool]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class ContactUpdateResponse(TypedDict, total=False):
|
|
279
|
+
contactId: Optional[str]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Contact(TypedDict, total=False):
|
|
283
|
+
id: str
|
|
284
|
+
firstName: Optional[str]
|
|
285
|
+
lastName: Optional[str]
|
|
286
|
+
email: str
|
|
287
|
+
subscribed: Optional[bool]
|
|
288
|
+
properties: Dict[str, str]
|
|
289
|
+
contactBookId: str
|
|
290
|
+
createdAt: str
|
|
291
|
+
updatedAt: str
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class ContactUpsert(TypedDict, total=False):
|
|
295
|
+
email: str
|
|
296
|
+
firstName: Optional[str]
|
|
297
|
+
lastName: Optional[str]
|
|
298
|
+
properties: Optional[Dict[str, str]]
|
|
299
|
+
subscribed: Optional[bool]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class ContactUpsertResponse(TypedDict):
|
|
303
|
+
contactId: str
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ContactDeleteResponse(TypedDict):
|
|
307
|
+
success: bool
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Common
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
class APIError(TypedDict):
|
|
315
|
+
code: str
|
|
316
|
+
message: str
|
|
317
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Core client for interacting with the UseSend API.
|
|
2
|
+
|
|
3
|
+
Enhancements:
|
|
4
|
+
- Optional ``raise_on_error`` to raise ``UseSendHTTPError`` on non-2xx.
|
|
5
|
+
- Reusable ``requests.Session`` support for connection reuse.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://app.usesend.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UseSendHTTPError(Exception):
|
|
19
|
+
"""HTTP error raised when ``raise_on_error=True`` and a request fails."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status_code: int, error: Dict[str, Any], method: str, path: str) -> None:
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.error = error
|
|
24
|
+
self.method = method
|
|
25
|
+
self.path = path
|
|
26
|
+
super().__init__(self.__str__())
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str: # pragma: no cover - presentation only
|
|
29
|
+
code = self.error.get("code", "UNKNOWN_ERROR")
|
|
30
|
+
message = self.error.get("message", "")
|
|
31
|
+
return f"{self.method} {self.path} -> {self.status_code} {code}: {message}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UseSend:
|
|
35
|
+
"""UseSend API client.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
key:
|
|
40
|
+
API key issued by UseSend. If not provided, the client attempts to
|
|
41
|
+
read ``USESEND_API_KEY`` or ``UNSEND_API_KEY`` from the environment.
|
|
42
|
+
url:
|
|
43
|
+
Optional base URL for the API (useful for testing).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
key: Optional[str] = None,
|
|
49
|
+
url: Optional[str] = None,
|
|
50
|
+
*,
|
|
51
|
+
raise_on_error: bool = True,
|
|
52
|
+
session: Optional[requests.Session] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.key = key or os.getenv("USESEND_API_KEY") or os.getenv("UNSEND_API_KEY")
|
|
55
|
+
if not self.key:
|
|
56
|
+
raise ValueError("Missing API key. Pass it to UseSend('us_123')")
|
|
57
|
+
|
|
58
|
+
base = os.getenv("USESEND_BASE_URL") or os.getenv("UNSEND_BASE_URL") or DEFAULT_BASE_URL
|
|
59
|
+
if url:
|
|
60
|
+
base = url
|
|
61
|
+
self.url = f"{base}/api/v1"
|
|
62
|
+
|
|
63
|
+
self.headers = {
|
|
64
|
+
"Authorization": f"Bearer {self.key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
self.raise_on_error = raise_on_error
|
|
69
|
+
self._session = session or requests.Session()
|
|
70
|
+
|
|
71
|
+
# Lazily initialise resource clients.
|
|
72
|
+
self.emails = Emails(self)
|
|
73
|
+
self.contacts = Contacts(self)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Internal request helper
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
def _request(
|
|
79
|
+
self, method: str, path: str, json: Optional[Any] = None
|
|
80
|
+
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
81
|
+
"""Perform an HTTP request and return ``(data, error)``."""
|
|
82
|
+
resp = self._session.request(
|
|
83
|
+
method, f"{self.url}{path}", headers=self.headers, json=json
|
|
84
|
+
)
|
|
85
|
+
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
|
|
86
|
+
|
|
87
|
+
if not resp.ok:
|
|
88
|
+
try:
|
|
89
|
+
payload = resp.json()
|
|
90
|
+
error = payload.get("error", default_error)
|
|
91
|
+
except Exception:
|
|
92
|
+
error = default_error
|
|
93
|
+
if self.raise_on_error:
|
|
94
|
+
raise UseSendHTTPError(resp.status_code, error, method, path)
|
|
95
|
+
return None, error
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
return resp.json(), None
|
|
99
|
+
except Exception:
|
|
100
|
+
return None, default_error
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# HTTP verb helpers
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
106
|
+
return self._request("POST", path, json=body)
|
|
107
|
+
|
|
108
|
+
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
109
|
+
return self._request("GET", path)
|
|
110
|
+
|
|
111
|
+
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
112
|
+
return self._request("PUT", path, json=body)
|
|
113
|
+
|
|
114
|
+
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
115
|
+
return self._request("PATCH", path, json=body)
|
|
116
|
+
|
|
117
|
+
def delete(
|
|
118
|
+
self, path: str, body: Optional[Any] = None
|
|
119
|
+
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
|
120
|
+
return self._request("DELETE", path, json=body)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Import here to avoid circular dependency during type checking
|
|
124
|
+
from .emails import Emails # noqa: E402 pylint: disable=wrong-import-position
|
|
125
|
+
from .contacts import Contacts # noqa: E402 pylint: disable=wrong-import-position
|