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 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
+
@@ -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,6 @@
1
+ """Python client for the UseSend API."""
2
+
3
+ from .usesend import UseSend, UseSendHTTPError
4
+ from . import types
5
+
6
+ __all__ = ["UseSend", "UseSendHTTPError", "types"]
@@ -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