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 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()