eusend 0.1.0__py3-none-any.whl
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.
- eusend/__init__.py +72 -0
- eusend/_compat.py +15 -0
- eusend/_response.py +19 -0
- eusend/_util.py +58 -0
- eusend/api_keys.py +47 -0
- eusend/audiences.py +111 -0
- eusend/batch.py +35 -0
- eusend/broadcasts.py +94 -0
- eusend/domains.py +45 -0
- eusend/emails.py +116 -0
- eusend/exceptions.py +158 -0
- eusend/models.py +118 -0
- eusend/py.typed +0 -0
- eusend/request.py +95 -0
- eusend/templates.py +52 -0
- eusend/version.py +2 -0
- eusend/webhooks.py +52 -0
- eusend-0.1.0.dist-info/METADATA +364 -0
- eusend-0.1.0.dist-info/RECORD +21 -0
- eusend-0.1.0.dist-info/WHEEL +4 -0
- eusend-0.1.0.dist-info/licenses/LICENSE +21 -0
eusend/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Official Python SDK for the Eusend API — the EU-native transactional email platform.
|
|
2
|
+
|
|
3
|
+
Configure a module-level API key, then call the resource classes directly
|
|
4
|
+
(mirrors resend-python)::
|
|
5
|
+
|
|
6
|
+
import eusend
|
|
7
|
+
|
|
8
|
+
eusend.api_key = "eu_live_..." # or set EUSEND_API_KEY
|
|
9
|
+
|
|
10
|
+
email = eusend.Emails.send({
|
|
11
|
+
"from": "Acme <you@yourdomain.com>",
|
|
12
|
+
"to": "user@example.com",
|
|
13
|
+
"subject": "Hello",
|
|
14
|
+
"html": "<p>Hello world</p>",
|
|
15
|
+
})
|
|
16
|
+
print(email["id"])
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# Module-level configuration, read at request time.
|
|
23
|
+
api_key: Optional[str] = os.environ.get("EUSEND_API_KEY")
|
|
24
|
+
api_url: str = os.environ.get("EUSEND_API_URL", "https://api.eusend.dev")
|
|
25
|
+
|
|
26
|
+
from eusend.version import get_version # noqa: E402
|
|
27
|
+
|
|
28
|
+
# Resource classes are imported after the config above so `import eusend`
|
|
29
|
+
# inside the request layer sees an initialized module.
|
|
30
|
+
from eusend.emails import Emails # noqa: E402
|
|
31
|
+
from eusend.batch import Batch # noqa: E402
|
|
32
|
+
from eusend.domains import Domains # noqa: E402
|
|
33
|
+
from eusend.api_keys import ApiKeys # noqa: E402
|
|
34
|
+
from eusend.audiences import Audiences # noqa: E402
|
|
35
|
+
from eusend.templates import Templates # noqa: E402
|
|
36
|
+
from eusend.webhooks import Webhooks # noqa: E402
|
|
37
|
+
from eusend.broadcasts import Broadcasts # noqa: E402
|
|
38
|
+
|
|
39
|
+
from eusend.exceptions import ( # noqa: E402
|
|
40
|
+
ApplicationError,
|
|
41
|
+
EusendError,
|
|
42
|
+
InvalidApiKeyError,
|
|
43
|
+
MissingApiKeyError,
|
|
44
|
+
NoContentError,
|
|
45
|
+
NotFoundError,
|
|
46
|
+
RateLimitError,
|
|
47
|
+
ValidationError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = get_version()
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"api_key",
|
|
54
|
+
"api_url",
|
|
55
|
+
"Emails",
|
|
56
|
+
"Batch",
|
|
57
|
+
"Domains",
|
|
58
|
+
"ApiKeys",
|
|
59
|
+
"Audiences",
|
|
60
|
+
"Templates",
|
|
61
|
+
"Webhooks",
|
|
62
|
+
"Broadcasts",
|
|
63
|
+
"EusendError",
|
|
64
|
+
"ApplicationError",
|
|
65
|
+
"InvalidApiKeyError",
|
|
66
|
+
"MissingApiKeyError",
|
|
67
|
+
"NoContentError",
|
|
68
|
+
"NotFoundError",
|
|
69
|
+
"RateLimitError",
|
|
70
|
+
"ValidationError",
|
|
71
|
+
"__version__",
|
|
72
|
+
]
|
eusend/_compat.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Typing-helper compatibility shim.
|
|
2
|
+
|
|
3
|
+
``NotRequired`` landed in the stdlib ``typing`` in Python 3.11; on 3.8–3.10 it
|
|
4
|
+
lives in ``typing_extensions``. Import these names from here so callers don't
|
|
5
|
+
need to care.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
from typing import Literal, NotRequired, TypedDict
|
|
12
|
+
else: # pragma: no cover
|
|
13
|
+
from typing_extensions import Literal, NotRequired, TypedDict
|
|
14
|
+
|
|
15
|
+
__all__ = ["TypedDict", "NotRequired", "Literal"]
|
eusend/_response.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from eusend._util import normalize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ResponseDict(dict):
|
|
7
|
+
"""A dict response with the HTTP response headers attached at ``.headers``."""
|
|
8
|
+
|
|
9
|
+
headers: Dict[str, str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_response(data: Any, headers: Dict[str, str]) -> Any:
|
|
13
|
+
"""Normalize response keys to snake_case; wrap dicts so ``resp["id"]`` works."""
|
|
14
|
+
data = normalize(data)
|
|
15
|
+
if isinstance(data, dict):
|
|
16
|
+
resp = ResponseDict(data)
|
|
17
|
+
resp.headers = headers
|
|
18
|
+
return resp
|
|
19
|
+
return data
|
eusend/_util.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_snake(key: str) -> str:
|
|
10
|
+
return _CAMEL_BOUNDARY.sub("_", key).lower()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize(obj: Any) -> Any:
|
|
14
|
+
"""Recursively convert response keys from camelCase to snake_case.
|
|
15
|
+
|
|
16
|
+
The Eusend API returns camelCase for most resources but snake_case for a
|
|
17
|
+
few (API keys, pagination cursors); this collapses both into snake_case so
|
|
18
|
+
callers always use the same key style, e.g. ``email["created_at"]``.
|
|
19
|
+
"""
|
|
20
|
+
if isinstance(obj, dict):
|
|
21
|
+
return {to_snake(k): normalize(v) for k, v in obj.items()}
|
|
22
|
+
if isinstance(obj, list):
|
|
23
|
+
return [normalize(v) for v in obj]
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_query(params: Optional[Dict[str, Any]]) -> str:
|
|
28
|
+
if not params:
|
|
29
|
+
return ""
|
|
30
|
+
q: Dict[str, Any] = {}
|
|
31
|
+
for key, value in params.items():
|
|
32
|
+
if value is None:
|
|
33
|
+
continue
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
value = "true" if value else "false"
|
|
36
|
+
q[key] = value
|
|
37
|
+
return "?" + urlencode(q) if q else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode_attachment(att: Dict[str, Any]) -> Dict[str, Any]:
|
|
41
|
+
out = dict(att)
|
|
42
|
+
content = out.get("content")
|
|
43
|
+
if isinstance(content, (bytes, bytearray)):
|
|
44
|
+
# Raw bytes are base64-encoded; a str is assumed to already be base64.
|
|
45
|
+
out["content"] = base64.b64encode(bytes(content)).decode("ascii")
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prepare_email_payload(params: Dict[str, Any], allow_scheduling: bool = True) -> Dict[str, Any]:
|
|
50
|
+
payload = dict(params)
|
|
51
|
+
attachments = payload.get("attachments")
|
|
52
|
+
if attachments:
|
|
53
|
+
payload["attachments"] = [_encode_attachment(a) for a in attachments]
|
|
54
|
+
if not allow_scheduling:
|
|
55
|
+
# The batch endpoint rejects attachments and scheduling.
|
|
56
|
+
payload.pop("attachments", None)
|
|
57
|
+
payload.pop("scheduled_at", None)
|
|
58
|
+
return payload
|
eusend/api_keys.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from eusend._compat import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend.request import Request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiKeys:
|
|
9
|
+
class CreateParams(TypedDict):
|
|
10
|
+
name: str
|
|
11
|
+
test_mode: NotRequired[bool]
|
|
12
|
+
|
|
13
|
+
class CreateResponse(TypedDict):
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
key: str
|
|
17
|
+
prefix: str
|
|
18
|
+
test_mode: bool
|
|
19
|
+
created_at: str
|
|
20
|
+
|
|
21
|
+
class ApiKey(TypedDict):
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
prefix: str
|
|
25
|
+
test_mode: bool
|
|
26
|
+
created_at: str
|
|
27
|
+
last_used_at: str
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def create(cls, params: "ApiKeys.CreateParams") -> "ApiKeys.CreateResponse":
|
|
31
|
+
"""Create an API key. The full ``key`` is returned only once.
|
|
32
|
+
|
|
33
|
+
Pass ``{"test_mode": True}`` for a sandbox key — its sends are accepted
|
|
34
|
+
and tracked but never delivered.
|
|
35
|
+
"""
|
|
36
|
+
body = {"name": params["name"], "test_mode": params.get("test_mode", False)}
|
|
37
|
+
return Request[ApiKeys.CreateResponse](
|
|
38
|
+
path="/api-keys", params=body, verb="post"
|
|
39
|
+
).perform_with_content()
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def list(cls) -> List["ApiKeys.ApiKey"]:
|
|
43
|
+
return Request[List[ApiKeys.ApiKey]](path="/api-keys", verb="get").perform_with_content()
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def remove(cls, api_key_id: str) -> None:
|
|
47
|
+
Request(path=f"/api-keys/{api_key_id}", verb="delete").perform()
|
eusend/audiences.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, cast
|
|
2
|
+
|
|
3
|
+
from eusend._compat import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend._util import build_query
|
|
6
|
+
from eusend.models import Contact
|
|
7
|
+
from eusend.request import Request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Audiences:
|
|
11
|
+
class Audience(TypedDict):
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
organization_id: str
|
|
15
|
+
created_at: str
|
|
16
|
+
updated_at: str
|
|
17
|
+
|
|
18
|
+
class AudienceListItem(TypedDict):
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
created_at: str
|
|
22
|
+
contact_count: int
|
|
23
|
+
|
|
24
|
+
class CreateContactParams(TypedDict):
|
|
25
|
+
email: str
|
|
26
|
+
first_name: NotRequired[str]
|
|
27
|
+
last_name: NotRequired[str]
|
|
28
|
+
|
|
29
|
+
class UpdateContactParams(TypedDict):
|
|
30
|
+
first_name: NotRequired[str]
|
|
31
|
+
last_name: NotRequired[str]
|
|
32
|
+
unsubscribed: NotRequired[bool]
|
|
33
|
+
|
|
34
|
+
class ListContactsParams(TypedDict):
|
|
35
|
+
limit: NotRequired[int]
|
|
36
|
+
cursor: NotRequired[str]
|
|
37
|
+
search: NotRequired[str]
|
|
38
|
+
subscribed: NotRequired[bool]
|
|
39
|
+
|
|
40
|
+
class ListContactsResponse(TypedDict):
|
|
41
|
+
data: List[Contact]
|
|
42
|
+
next_cursor: Optional[str]
|
|
43
|
+
|
|
44
|
+
class BatchCreateContactsResponse(TypedDict):
|
|
45
|
+
count: int
|
|
46
|
+
|
|
47
|
+
# --- Audiences ---------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def create(cls, name: str) -> "Audiences.Audience":
|
|
51
|
+
return Request[Audiences.Audience](
|
|
52
|
+
path="/audiences", params={"name": name}, verb="post"
|
|
53
|
+
).perform_with_content()
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def list(cls) -> List["Audiences.AudienceListItem"]:
|
|
57
|
+
resp = Request[Dict[str, Any]](path="/audiences", verb="get").perform_with_content()
|
|
58
|
+
return cast(List["Audiences.AudienceListItem"], resp["data"])
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def remove(cls, audience_id: str) -> None:
|
|
62
|
+
Request(path=f"/audiences/{audience_id}", verb="delete").perform()
|
|
63
|
+
|
|
64
|
+
# --- Contacts (nested under an audience) -------------------------------
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create_contact(cls, audience_id: str, params: "Audiences.CreateContactParams") -> Contact:
|
|
68
|
+
"""Add a contact to an audience, upserting on email."""
|
|
69
|
+
return Request[Contact](
|
|
70
|
+
path=f"/audiences/{audience_id}/contacts",
|
|
71
|
+
params=cast(Dict[str, Any], params),
|
|
72
|
+
verb="post",
|
|
73
|
+
).perform_with_content()
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def batch_create_contacts(
|
|
77
|
+
cls, audience_id: str, contacts: List["Audiences.CreateContactParams"]
|
|
78
|
+
) -> "Audiences.BatchCreateContactsResponse":
|
|
79
|
+
"""Upsert up to 1,000 contacts; returns the number written."""
|
|
80
|
+
return Request[Audiences.BatchCreateContactsResponse](
|
|
81
|
+
path=f"/audiences/{audience_id}/contacts/batch",
|
|
82
|
+
params={"contacts": cast(List[Any], contacts)},
|
|
83
|
+
verb="post",
|
|
84
|
+
).perform_with_content()
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def list_contacts(
|
|
88
|
+
cls, audience_id: str, params: Optional["Audiences.ListContactsParams"] = None
|
|
89
|
+
) -> "Audiences.ListContactsResponse":
|
|
90
|
+
path = f"/audiences/{audience_id}/contacts" + build_query(cast(Optional[Dict[str, Any]], params))
|
|
91
|
+
return Request[Audiences.ListContactsResponse](path=path, verb="get").perform_with_content()
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def get_contact(cls, audience_id: str, contact_id: str) -> Contact:
|
|
95
|
+
return Request[Contact](
|
|
96
|
+
path=f"/audiences/{audience_id}/contacts/{contact_id}", verb="get"
|
|
97
|
+
).perform_with_content()
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def update_contact(
|
|
101
|
+
cls, audience_id: str, contact_id: str, params: "Audiences.UpdateContactParams"
|
|
102
|
+
) -> Contact:
|
|
103
|
+
return Request[Contact](
|
|
104
|
+
path=f"/audiences/{audience_id}/contacts/{contact_id}",
|
|
105
|
+
params=cast(Dict[str, Any], params),
|
|
106
|
+
verb="patch",
|
|
107
|
+
).perform_with_content()
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def remove_contact(cls, audience_id: str, contact_id: str) -> None:
|
|
111
|
+
Request(path=f"/audiences/{audience_id}/contacts/{contact_id}", verb="delete").perform()
|
eusend/batch.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Dict, List, cast
|
|
2
|
+
|
|
3
|
+
from eusend._compat import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend._util import prepare_email_payload
|
|
6
|
+
from eusend.emails import Emails
|
|
7
|
+
from eusend.request import Request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Batch:
|
|
11
|
+
class BatchItemResult(TypedDict):
|
|
12
|
+
id: NotRequired[str]
|
|
13
|
+
error: NotRequired[str]
|
|
14
|
+
code: NotRequired[str]
|
|
15
|
+
|
|
16
|
+
class SendResponse(TypedDict):
|
|
17
|
+
data: List["Batch.BatchItemResult"]
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def send(cls, params: List[Emails.SendParams]) -> "Batch.SendResponse":
|
|
21
|
+
"""Send up to 100 emails in one request.
|
|
22
|
+
|
|
23
|
+
Attachments and scheduling are not supported on the batch endpoint and
|
|
24
|
+
are stripped from each item — send those individually via ``Emails.send``.
|
|
25
|
+
The result maps positionally to the input: queued items carry ``id``,
|
|
26
|
+
rejected items carry ``error`` and ``code``.
|
|
27
|
+
"""
|
|
28
|
+
payload = [
|
|
29
|
+
prepare_email_payload(cast(Dict[str, Any], p), allow_scheduling=False) for p in params
|
|
30
|
+
]
|
|
31
|
+
return Request[Batch.SendResponse](
|
|
32
|
+
path="/emails/batch",
|
|
33
|
+
params=cast(List[Any], payload),
|
|
34
|
+
verb="post",
|
|
35
|
+
).perform_with_content()
|
eusend/broadcasts.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, cast
|
|
2
|
+
|
|
3
|
+
from eusend._compat import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend.models import Broadcast
|
|
6
|
+
from eusend.request import Request
|
|
7
|
+
|
|
8
|
+
# `from` is a reserved keyword, declared via functional TypedDict syntax.
|
|
9
|
+
_BroadcastFrom = TypedDict("_BroadcastFrom", {"from": str})
|
|
10
|
+
_BroadcastFromOpt = TypedDict("_BroadcastFromOpt", {"from": NotRequired[str]})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Broadcasts:
|
|
14
|
+
class CreateParams(_BroadcastFrom):
|
|
15
|
+
name: str
|
|
16
|
+
audience_id: str
|
|
17
|
+
subject: str
|
|
18
|
+
html: NotRequired[str]
|
|
19
|
+
template_id: NotRequired[str]
|
|
20
|
+
template_variables: NotRequired[Dict[str, str]]
|
|
21
|
+
|
|
22
|
+
class UpdateParams(_BroadcastFromOpt):
|
|
23
|
+
name: NotRequired[str]
|
|
24
|
+
audience_id: NotRequired[str]
|
|
25
|
+
subject: NotRequired[str]
|
|
26
|
+
html: NotRequired[str]
|
|
27
|
+
template_id: NotRequired[str]
|
|
28
|
+
template_variables: NotRequired[Dict[str, str]]
|
|
29
|
+
scheduled_at: NotRequired[str]
|
|
30
|
+
|
|
31
|
+
class SendParams(TypedDict):
|
|
32
|
+
scheduled_at: NotRequired[str]
|
|
33
|
+
|
|
34
|
+
class SendResponse(TypedDict):
|
|
35
|
+
id: str
|
|
36
|
+
status: str
|
|
37
|
+
scheduled_at: Optional[str]
|
|
38
|
+
|
|
39
|
+
class BroadcastListItem(TypedDict):
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
status: str
|
|
43
|
+
audience_id: str
|
|
44
|
+
from_address: str
|
|
45
|
+
subject: str
|
|
46
|
+
recipient_count: Optional[int]
|
|
47
|
+
sent_count: Optional[int]
|
|
48
|
+
scheduled_at: Optional[str]
|
|
49
|
+
created_at: str
|
|
50
|
+
audience_name: Optional[str]
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def create(cls, params: "Broadcasts.CreateParams") -> Broadcast:
|
|
54
|
+
return Request[Broadcast](
|
|
55
|
+
path="/broadcasts", params=cast(Dict[str, Any], params), verb="post"
|
|
56
|
+
).perform_with_content()
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def list(cls) -> List["Broadcasts.BroadcastListItem"]:
|
|
60
|
+
resp = Request[Dict[str, Any]](path="/broadcasts", verb="get").perform_with_content()
|
|
61
|
+
return cast(List["Broadcasts.BroadcastListItem"], resp["data"])
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def get(cls, broadcast_id: str) -> Broadcast:
|
|
65
|
+
"""Get a broadcast including delivery stats."""
|
|
66
|
+
return Request[Broadcast](path=f"/broadcasts/{broadcast_id}", verb="get").perform_with_content()
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def update(cls, broadcast_id: str, params: "Broadcasts.UpdateParams") -> Broadcast:
|
|
70
|
+
return Request[Broadcast](
|
|
71
|
+
path=f"/broadcasts/{broadcast_id}", params=cast(Dict[str, Any], params), verb="patch"
|
|
72
|
+
).perform_with_content()
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def send(
|
|
76
|
+
cls, broadcast_id: str, params: Optional["Broadcasts.SendParams"] = None
|
|
77
|
+
) -> "Broadcasts.SendResponse":
|
|
78
|
+
"""Send a broadcast now, or schedule it with ``{"scheduled_at": ...}``.
|
|
79
|
+
Calling send on a paused broadcast resumes it from where it stopped."""
|
|
80
|
+
return Request[Broadcasts.SendResponse](
|
|
81
|
+
path=f"/broadcasts/{broadcast_id}/send",
|
|
82
|
+
params=cast(Dict[str, Any], params or {}),
|
|
83
|
+
verb="post",
|
|
84
|
+
).perform_with_content()
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def cancel(cls, broadcast_id: str) -> Broadcast:
|
|
88
|
+
return Request[Broadcast](
|
|
89
|
+
path=f"/broadcasts/{broadcast_id}/cancel", verb="post"
|
|
90
|
+
).perform_with_content()
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def remove(cls, broadcast_id: str) -> None:
|
|
94
|
+
Request(path=f"/broadcasts/{broadcast_id}", verb="delete").perform()
|
eusend/domains.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from eusend._compat import TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend.models import Domain, DnsRecord
|
|
6
|
+
from eusend.request import Request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Domains:
|
|
10
|
+
class CreateResponse(TypedDict):
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
dkim: DnsRecord
|
|
14
|
+
spf: DnsRecord
|
|
15
|
+
dmarc: DnsRecord
|
|
16
|
+
|
|
17
|
+
class DomainListItem(TypedDict):
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
status: str
|
|
21
|
+
created_at: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def create(cls, name: str) -> "Domains.CreateResponse":
|
|
25
|
+
"""Add a domain and return the DNS records to publish."""
|
|
26
|
+
return Request[Domains.CreateResponse](
|
|
27
|
+
path="/domains", params={"name": name}, verb="post"
|
|
28
|
+
).perform_with_content()
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def list(cls) -> List["Domains.DomainListItem"]:
|
|
32
|
+
return Request[List[Domains.DomainListItem]](path="/domains", verb="get").perform_with_content()
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get(cls, domain_id: str) -> Domain:
|
|
36
|
+
return Request[Domain](path=f"/domains/{domain_id}", verb="get").perform_with_content()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def verify(cls, domain_id: str) -> None:
|
|
40
|
+
"""Trigger DNS verification after publishing the records."""
|
|
41
|
+
Request(path=f"/domains/{domain_id}/verify", verb="post").perform()
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def remove(cls, domain_id: str) -> None:
|
|
45
|
+
Request(path=f"/domains/{domain_id}", verb="delete").perform()
|
eusend/emails.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Union, cast
|
|
2
|
+
|
|
3
|
+
from eusend._compat import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
from eusend._util import build_query, prepare_email_payload
|
|
6
|
+
from eusend.models import Email, EmailListItem
|
|
7
|
+
from eusend.request import Request
|
|
8
|
+
|
|
9
|
+
# `from` is a reserved keyword, declared via functional TypedDict syntax.
|
|
10
|
+
_SendParamsFrom = TypedDict("_SendParamsFrom", {"from": str})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Attachment(TypedDict):
|
|
14
|
+
filename: str
|
|
15
|
+
content: NotRequired[Union[str, bytes]]
|
|
16
|
+
path: NotRequired[str]
|
|
17
|
+
content_type: NotRequired[str]
|
|
18
|
+
content_id: NotRequired[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SendParamsDefault(_SendParamsFrom):
|
|
22
|
+
to: Union[str, List[str]]
|
|
23
|
+
subject: NotRequired[str]
|
|
24
|
+
cc: NotRequired[Union[str, List[str]]]
|
|
25
|
+
bcc: NotRequired[Union[str, List[str]]]
|
|
26
|
+
reply_to: NotRequired[Union[str, List[str]]]
|
|
27
|
+
html: NotRequired[str]
|
|
28
|
+
text: NotRequired[str]
|
|
29
|
+
template_id: NotRequired[str]
|
|
30
|
+
variables: NotRequired[Dict[str, Any]]
|
|
31
|
+
headers: NotRequired[Dict[str, str]]
|
|
32
|
+
track_opens: NotRequired[bool]
|
|
33
|
+
track_clicks: NotRequired[bool]
|
|
34
|
+
attachments: NotRequired[List[Attachment]]
|
|
35
|
+
scheduled_at: NotRequired[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# `from` again for the list filter.
|
|
39
|
+
_ListParamsFrom = TypedDict("_ListParamsFrom", {"from": NotRequired[str]})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _ListParamsDefault(_ListParamsFrom):
|
|
43
|
+
limit: NotRequired[int]
|
|
44
|
+
cursor: NotRequired[str]
|
|
45
|
+
status: NotRequired[str]
|
|
46
|
+
to: NotRequired[str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Emails:
|
|
50
|
+
class SendParams(_SendParamsDefault):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
class SendResponse(TypedDict):
|
|
54
|
+
id: str
|
|
55
|
+
|
|
56
|
+
class SendOptions(TypedDict):
|
|
57
|
+
idempotency_key: NotRequired[str]
|
|
58
|
+
|
|
59
|
+
class ListParams(_ListParamsDefault):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
class ListResponse(TypedDict):
|
|
63
|
+
data: List[EmailListItem]
|
|
64
|
+
next_cursor: Optional[str]
|
|
65
|
+
|
|
66
|
+
class UpdateParams(TypedDict):
|
|
67
|
+
id: str
|
|
68
|
+
scheduled_at: str
|
|
69
|
+
|
|
70
|
+
class UpdateResponse(TypedDict):
|
|
71
|
+
id: str
|
|
72
|
+
status: str
|
|
73
|
+
scheduled_at: str
|
|
74
|
+
|
|
75
|
+
class CancelResponse(TypedDict):
|
|
76
|
+
id: str
|
|
77
|
+
status: str
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def send(cls, params: "Emails.SendParams", options: Optional["Emails.SendOptions"] = None) -> "Emails.SendResponse":
|
|
81
|
+
"""Send a single email. Pass ``options={"idempotency_key": ...}`` to make
|
|
82
|
+
the send safe to retry without duplicating."""
|
|
83
|
+
payload = prepare_email_payload(cast(Dict[str, Any], params))
|
|
84
|
+
return Request[Emails.SendResponse](
|
|
85
|
+
path="/emails",
|
|
86
|
+
params=payload,
|
|
87
|
+
verb="post",
|
|
88
|
+
options=cast(Optional[Dict[str, Any]], options),
|
|
89
|
+
).perform_with_content()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def get(cls, email_id: str) -> Email:
|
|
93
|
+
"""Retrieve an email by ID, including its delivery events."""
|
|
94
|
+
return Request[Email](path=f"/emails/{email_id}", verb="get").perform_with_content()
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def list(cls, params: Optional["Emails.ListParams"] = None) -> "Emails.ListResponse":
|
|
98
|
+
"""List emails, most recent first. Filter by ``status``, ``from``, ``to``."""
|
|
99
|
+
path = "/emails" + build_query(cast(Optional[Dict[str, Any]], params))
|
|
100
|
+
return Request[Emails.ListResponse](path=path, verb="get").perform_with_content()
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def update(cls, params: "Emails.UpdateParams") -> "Emails.UpdateResponse":
|
|
104
|
+
"""Reschedule a scheduled email. Fails once it has started sending."""
|
|
105
|
+
return Request[Emails.UpdateResponse](
|
|
106
|
+
path=f"/emails/{params['id']}",
|
|
107
|
+
params={"scheduled_at": params["scheduled_at"]},
|
|
108
|
+
verb="patch",
|
|
109
|
+
).perform_with_content()
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def cancel(cls, email_id: str) -> "Emails.CancelResponse":
|
|
113
|
+
"""Cancel a scheduled email. Fails once it has started sending."""
|
|
114
|
+
return Request[Emails.CancelResponse](
|
|
115
|
+
path=f"/emails/{email_id}/cancel", verb="post"
|
|
116
|
+
).perform_with_content()
|