senddy 0.2.0__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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
senddy-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: senddy
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for Senddy.io
5
+ Project-URL: Homepage, https://github.com/senddy-io/senddy-python
6
+ Project-URL: Repository, https://github.com/senddy-io/senddy-python
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx<1,>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.13; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Requires-Dist: respx>=0.22; extra == 'dev'
15
+ Requires-Dist: ruff>=0.8; extra == 'dev'
senddy-0.2.0/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # Senddy Python SDK
2
+
3
+ Official Python SDK for the [Senddy.io](https://senddy.io) email API.
4
+
5
+ ## Installation
6
+
7
+ Requires **Python 3.9+**.
8
+
9
+ ```bash
10
+ pip install senddy
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from senddy import Senddy, SendEmailParams
17
+
18
+ client = Senddy("senddy_live_api_key")
19
+
20
+ result = client.emails.send(SendEmailParams(
21
+ from_="sender@senddy.io",
22
+ to="recipient@example.com",
23
+ subject="Hello",
24
+ html="<p>Welcome!</p>",
25
+ ))
26
+
27
+ print(result.id) # email_abc123
28
+ ```
29
+
30
+ ## Async Support
31
+
32
+ ```python
33
+ from senddy import AsyncSenddy, SendEmailParams
34
+
35
+ async with AsyncSenddy("senddy_live_api_key") as client:
36
+ result = await client.emails.send(SendEmailParams(
37
+ from_="sender@senddy.io",
38
+ to="recipient@example.com",
39
+ subject="Hello",
40
+ html="<p>Welcome!</p>",
41
+ ))
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ All options are optional — sensible defaults are used if you don't override them.
47
+
48
+ ```python
49
+ client = Senddy(
50
+ "senddy_live_api_key",
51
+ timeout=30.0, # seconds
52
+ retries=2,
53
+ headers={"X-Custom": "value"}, # extra headers on every request
54
+ )
55
+ ```
56
+
57
+ If no API key is passed to the constructor, the SDK reads from the `SENDDY_API_KEY` environment variable.
58
+
59
+ Both clients support context managers for proper resource cleanup:
60
+
61
+ ```python
62
+ with Senddy("senddy_live_api_key") as client:
63
+ # client.close() called automatically on exit
64
+ ...
65
+ ```
66
+
67
+ ## Emails
68
+
69
+ ### Send
70
+
71
+ ```python
72
+ from senddy import SendEmailParams, Attachment, RequestOptions
73
+
74
+ result = client.emails.send(
75
+ SendEmailParams(
76
+ from_="sender@senddy.io",
77
+ to=["alice@example.com", "bob@example.com"],
78
+ subject="Hello",
79
+ html="<p>Hi there</p>",
80
+ text="Hi there", # optional plain-text fallback
81
+ cc="cc@example.com", # optional
82
+ bcc="bcc@example.com", # optional
83
+ reply_to="reply@example.com", # optional
84
+ tags={"campaign": "welcome"}, # optional metadata
85
+ attachments=[Attachment( # optional
86
+ filename="report.pdf",
87
+ content=base64_string,
88
+ content_type="application/pdf",
89
+ )],
90
+ ),
91
+ options=RequestOptions(idempotency_key="unique-key"), # prevents duplicate sends
92
+ )
93
+ ```
94
+
95
+ > **Note:** The `from_` parameter uses a trailing underscore because `from` is a Python reserved word. The SDK maps it to `from` in the API request automatically.
96
+
97
+ ### Get
98
+
99
+ ```python
100
+ email = client.emails.get("email_abc123")
101
+ print(email.status) # 'delivered'
102
+ print(email.recipients) # list of EmailRecipient
103
+ print(email.events) # list of EmailEvent
104
+ ```
105
+
106
+ ### List
107
+
108
+ ```python
109
+ from senddy import ListEmailsParams
110
+
111
+ result = client.emails.list(ListEmailsParams(
112
+ limit=25,
113
+ offset=0,
114
+ status="delivered",
115
+ since="2026-01-01T00:00:00Z",
116
+ ))
117
+
118
+ for email in result.data:
119
+ print(email.subject)
120
+ print(result.pagination.total)
121
+ ```
122
+
123
+ ### Get rendered content
124
+
125
+ ```python
126
+ content = client.emails.get_content("email_abc123")
127
+ print(content.html) # str or None
128
+ print(content.text) # str or None
129
+ ```
130
+
131
+ ### Download EML
132
+
133
+ ```python
134
+ download = client.emails.download("email_abc123")
135
+ # download.content is bytes containing the raw EML
136
+ # download.content_type is 'message/rfc822'
137
+ ```
138
+
139
+ ## Suppressions
140
+
141
+ ### List
142
+
143
+ ```python
144
+ from senddy import ListSuppressionsParams
145
+
146
+ result = client.suppressions.list(ListSuppressionsParams(
147
+ limit=50,
148
+ search="example.com",
149
+ reason="hard_bounce",
150
+ ))
151
+ ```
152
+
153
+ ### Create
154
+
155
+ ```python
156
+ from senddy import CreateSuppressionParams
157
+
158
+ entry = client.suppressions.create(CreateSuppressionParams(
159
+ email_address="block@example.com",
160
+ ))
161
+ ```
162
+
163
+ ### Delete
164
+
165
+ ```python
166
+ result = client.suppressions.delete("block@example.com")
167
+ print(result.removed) # True
168
+ ```
169
+
170
+ ## Domains
171
+
172
+ ```python
173
+ from senddy import CreateDomainParams
174
+
175
+ # Add a sending domain
176
+ domain = client.domains.create(CreateDomainParams(domain="mail.example.com"))
177
+
178
+ # Trigger DNS verification
179
+ client.domains.verify(domain.id)
180
+
181
+ # List, get, delete
182
+ client.domains.list()
183
+ client.domains.get(domain.id)
184
+ client.domains.delete(domain.id)
185
+
186
+ # Rotate DKIM keys
187
+ client.domains.regenerate_dkim(domain.id)
188
+
189
+ # Bulk DNS health check across all domains
190
+ client.domains.check_dns_health()
191
+ ```
192
+
193
+ ## Inbound Routes
194
+
195
+ ```python
196
+ from senddy import CreateInboundRouteParams, UpdateInboundRouteParams
197
+
198
+ # Create a catchall route that forwards to your webhook
199
+ route = client.inbound_routes.create(CreateInboundRouteParams(
200
+ match_type="catchall",
201
+ webhook_url="https://your-app.example.com/inbound",
202
+ ))
203
+
204
+ # List, get, update, delete
205
+ client.inbound_routes.list()
206
+ client.inbound_routes.get(route.id)
207
+ client.inbound_routes.update(route.id, UpdateInboundRouteParams(enabled=False))
208
+ client.inbound_routes.delete(route.id)
209
+
210
+ # Verify the MX record points at Senddy
211
+ client.inbound_routes.verify_mx(route.id)
212
+ ```
213
+
214
+ See the [inbound docs](https://senddy.io/docs/inbound) for match patterns, webhook signing, and full configuration.
215
+
216
+ ## Inbound Emails
217
+
218
+ ```python
219
+ from senddy import ListInboundEmailsParams
220
+
221
+ result = client.inbound_emails.list(ListInboundEmailsParams(route_id=42, limit=50))
222
+
223
+ email = client.inbound_emails.get("inbound_xyz")
224
+ print(email.text_body, email.attachments)
225
+ ```
226
+
227
+ ## Webhook Endpoints
228
+
229
+ ```python
230
+ from senddy import CreateWebhookEndpointParams, UpdateWebhookEndpointParams
231
+
232
+ # Register an endpoint to receive event callbacks
233
+ result = client.webhook_endpoints.create(CreateWebhookEndpointParams(
234
+ url="https://your-app.example.com/webhooks/senddy",
235
+ domain="mail.example.com",
236
+ event_type="email.delivered",
237
+ ))
238
+ # Save result.secret — you'll need it to verify the signature on incoming webhooks.
239
+
240
+ # Test, list, update, delete
241
+ client.webhook_endpoints.test(result.id)
242
+ client.webhook_endpoints.list()
243
+ client.webhook_endpoints.update(result.id, UpdateWebhookEndpointParams(url="https://new-url.example.com"))
244
+ client.webhook_endpoints.delete(result.id)
245
+
246
+ # Inspect delivery history
247
+ deliveries = client.webhook_endpoints.deliveries(result.id)
248
+ ```
249
+
250
+ ## Billing
251
+
252
+ ```python
253
+ balance = client.billing.get_balance()
254
+ print(balance.credit_balance, balance.billing_tier)
255
+ ```
256
+
257
+ ## Error Handling
258
+
259
+ ```python
260
+ from senddy import (
261
+ APIError,
262
+ ValidationError,
263
+ RateLimitError,
264
+ AuthenticationError,
265
+ )
266
+
267
+ try:
268
+ client.emails.send(params)
269
+ except ValidationError as err:
270
+ print(f"Validation: {err.details}")
271
+ except RateLimitError as err:
272
+ print(f"Rate limited. Retry after {err.retry_after}s")
273
+ except AuthenticationError as err:
274
+ print(f"Auth error: {err}")
275
+ except APIError as err:
276
+ print(f"API error {err.status_code}: {err}")
277
+ ```
278
+
279
+ ### Error Types
280
+
281
+ | Class | Status | Description |
282
+ | --------------------- | ------ | -------------------------------------------- |
283
+ | `ValidationError` | 400 | Invalid request, includes `.details` list |
284
+ | `AuthenticationError` | 401 | Missing or invalid API key |
285
+ | `ForbiddenError` | 403 | Insufficient permissions |
286
+ | `NotFoundError` | 404 | Resource not found |
287
+ | `RateLimitError` | 429 | Rate limit exceeded, includes `.retry_after` |
288
+ | `InternalError` | 5xx | Server error |
289
+
290
+ All error classes inherit from `APIError`, which inherits from `Exception`.
291
+
292
+ ## Retries
293
+
294
+ The SDK automatically retries on 429 and 5xx responses with exponential backoff and jitter. Retries respect the `Retry-After` header. Non-retryable errors (4xx except 429) are raised immediately.
295
+
296
+ ## Requirements
297
+
298
+ - Python >= 3.9
299
+ - Runtime dependency: [httpx](https://www.python-httpx.org/)
300
+
301
+ ## Type Safety
302
+
303
+ The package ships with a `py.typed` marker (PEP 561) and all public types are fully annotated. Works with mypy, pyright, and IDE autocompletion out of the box.
304
+
305
+ ## License
306
+
307
+ MIT
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "senddy"
3
+ version = "0.2.0"
4
+ description = "Official Python SDK for Senddy.io"
5
+ requires-python = ">=3.9"
6
+ license = "MIT"
7
+ dependencies = ["httpx>=0.27,<1"]
8
+
9
+ [project.urls]
10
+ Homepage = "https://github.com/senddy-io/senddy-python"
11
+ Repository = "https://github.com/senddy-io/senddy-python"
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8",
16
+ "pytest-asyncio>=0.24",
17
+ "respx>=0.22",
18
+ "ruff>=0.8",
19
+ "mypy>=1.13",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/senddy"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+
32
+ [tool.ruff]
33
+ target-version = "py39"
34
+ line-length = 100
35
+
36
+ [tool.mypy]
37
+ strict = true
@@ -0,0 +1,128 @@
1
+ """Official Python SDK for Senddy.io."""
2
+
3
+ from ._client import AsyncSenddy, Senddy
4
+ from ._errors import (
5
+ APIError,
6
+ AuthenticationError,
7
+ ForbiddenError,
8
+ InternalError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ValidationError,
12
+ )
13
+ from ._types import (
14
+ Attachment,
15
+ BillingBalance,
16
+ CreateDomainParams,
17
+ CreateInboundRouteParams,
18
+ CreateInboundRouteResponse,
19
+ CreateSuppressionParams,
20
+ CreateWebhookEndpointParams,
21
+ CreateWebhookEndpointResponse,
22
+ DeleteDomainResponse,
23
+ DeleteInboundRouteResponse,
24
+ DeleteSuppressionResponse,
25
+ DeleteWebhookEndpointResponse,
26
+ DnsHealthEntry,
27
+ DnsHealthResponse,
28
+ Domain,
29
+ Email,
30
+ EmailContentResponse,
31
+ EmailDownloadResponse,
32
+ EmailEvent,
33
+ EmailRecipient,
34
+ EmailSummary,
35
+ InboundEmail,
36
+ InboundEmailSummary,
37
+ InboundRoute,
38
+ InboundRouteDetail,
39
+ ListDomainsParams,
40
+ ListEmailsParams,
41
+ ListInboundEmailsParams,
42
+ ListInboundRoutesParams,
43
+ ListSuppressionsParams,
44
+ ListWebhookEndpointsParams,
45
+ PaginatedResponse,
46
+ Pagination,
47
+ RequestOptions,
48
+ SendEmailParams,
49
+ SendEmailResponse,
50
+ SuppressionEntry,
51
+ TestWebhookResponse,
52
+ UpdateInboundRouteParams,
53
+ UpdateInboundRouteResponse,
54
+ UpdateWebhookEndpointParams,
55
+ VerifyDomainResponse,
56
+ VerifyMxResponse,
57
+ WebhookDelivery,
58
+ WebhookEndpoint,
59
+ )
60
+
61
+ __all__ = [
62
+ # Clients
63
+ "Senddy",
64
+ "AsyncSenddy",
65
+ # Errors
66
+ "APIError",
67
+ "ValidationError",
68
+ "AuthenticationError",
69
+ "ForbiddenError",
70
+ "NotFoundError",
71
+ "RateLimitError",
72
+ "InternalError",
73
+ # Common
74
+ "RequestOptions",
75
+ "PaginatedResponse",
76
+ "Pagination",
77
+ # Emails
78
+ "Attachment",
79
+ "SendEmailParams",
80
+ "SendEmailResponse",
81
+ "ListEmailsParams",
82
+ "EmailSummary",
83
+ "Email",
84
+ "EmailRecipient",
85
+ "EmailEvent",
86
+ "EmailDownloadResponse",
87
+ "EmailContentResponse",
88
+ # Suppressions
89
+ "ListSuppressionsParams",
90
+ "SuppressionEntry",
91
+ "CreateSuppressionParams",
92
+ "DeleteSuppressionResponse",
93
+ # Domains
94
+ "Domain",
95
+ "CreateDomainParams",
96
+ "ListDomainsParams",
97
+ "VerifyDomainResponse",
98
+ "DeleteDomainResponse",
99
+ "DnsHealthEntry",
100
+ "DnsHealthResponse",
101
+ # Inbound Routes
102
+ "InboundRoute",
103
+ "InboundRouteDetail",
104
+ "ListInboundRoutesParams",
105
+ "CreateInboundRouteParams",
106
+ "CreateInboundRouteResponse",
107
+ "UpdateInboundRouteParams",
108
+ "UpdateInboundRouteResponse",
109
+ "DeleteInboundRouteResponse",
110
+ "VerifyMxResponse",
111
+ # Inbound Emails
112
+ "InboundEmail",
113
+ "InboundEmailSummary",
114
+ "ListInboundEmailsParams",
115
+ # Webhook Endpoints
116
+ "WebhookEndpoint",
117
+ "ListWebhookEndpointsParams",
118
+ "CreateWebhookEndpointParams",
119
+ "CreateWebhookEndpointResponse",
120
+ "UpdateWebhookEndpointParams",
121
+ "DeleteWebhookEndpointResponse",
122
+ "TestWebhookResponse",
123
+ "WebhookDelivery",
124
+ # Billing
125
+ "BillingBalance",
126
+ ]
127
+
128
+ __version__ = "0.2.0"
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import warnings
5
+ from types import TracebackType
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from ._errors import AuthenticationError
11
+ from ._http import DEFAULT_BASE_URL, DEFAULT_RETRIES, DEFAULT_TIMEOUT
12
+ from .resources._billing import AsyncBilling, Billing
13
+ from .resources._domains import AsyncDomains, Domains
14
+ from .resources._emails import AsyncEmails, Emails
15
+ from .resources._inbound_emails import AsyncInboundEmails, InboundEmails
16
+ from .resources._inbound_routes import AsyncInboundRoutes, InboundRoutes
17
+ from .resources._suppressions import AsyncSuppressions, Suppressions
18
+ from .resources._webhook_endpoints import AsyncWebhookEndpoints, WebhookEndpoints
19
+
20
+ ENV_VAR_NAME = "SENDDY_API_KEY"
21
+
22
+
23
+ def _resolve_api_key(api_key: Optional[str]) -> str:
24
+ if api_key is not None:
25
+ if not api_key.strip():
26
+ raise AuthenticationError(
27
+ "API key must not be blank.",
28
+ "missing_api_key",
29
+ "",
30
+ )
31
+ return api_key
32
+
33
+ env_key = os.environ.get(ENV_VAR_NAME, "").strip()
34
+ if env_key:
35
+ warnings.warn(
36
+ f"Using API key from {ENV_VAR_NAME} environment variable. "
37
+ "Pass the key explicitly to suppress this warning.",
38
+ stacklevel=3,
39
+ )
40
+ return env_key
41
+
42
+ raise AuthenticationError(
43
+ f"API key is required. Pass it to the constructor or set the {ENV_VAR_NAME} "
44
+ "environment variable.",
45
+ "missing_api_key",
46
+ "",
47
+ )
48
+
49
+
50
+ class Senddy:
51
+ """Synchronous client for the Senddy.io API."""
52
+
53
+ billing: Billing
54
+ domains: Domains
55
+ emails: Emails
56
+ inbound_emails: InboundEmails
57
+ inbound_routes: InboundRoutes
58
+ suppressions: Suppressions
59
+ webhook_endpoints: WebhookEndpoints
60
+
61
+ def __init__(
62
+ self,
63
+ api_key: Optional[str] = None,
64
+ *,
65
+ timeout: float = DEFAULT_TIMEOUT,
66
+ retries: int = DEFAULT_RETRIES,
67
+ headers: Optional[dict[str, str]] = None,
68
+ ) -> None:
69
+ resolved_key = _resolve_api_key(api_key)
70
+
71
+ self._http_client = httpx.Client(
72
+ base_url=DEFAULT_BASE_URL,
73
+ timeout=timeout,
74
+ headers=headers,
75
+ )
76
+ self._api_key = resolved_key
77
+ self._retries = retries
78
+
79
+ self.billing = Billing(self._http_client, resolved_key, retries)
80
+ self.domains = Domains(self._http_client, resolved_key, retries)
81
+ self.emails = Emails(self._http_client, resolved_key, retries)
82
+ self.inbound_emails = InboundEmails(self._http_client, resolved_key, retries)
83
+ self.inbound_routes = InboundRoutes(self._http_client, resolved_key, retries)
84
+ self.suppressions = Suppressions(self._http_client, resolved_key, retries)
85
+ self.webhook_endpoints = WebhookEndpoints(self._http_client, resolved_key, retries)
86
+
87
+ def close(self) -> None:
88
+ self._http_client.close()
89
+
90
+ def __enter__(self) -> Senddy:
91
+ return self
92
+
93
+ def __exit__(
94
+ self,
95
+ exc_type: Optional[type[BaseException]],
96
+ exc_val: Optional[BaseException],
97
+ exc_tb: Optional[TracebackType],
98
+ ) -> None:
99
+ self.close()
100
+
101
+
102
+ class AsyncSenddy:
103
+ """Asynchronous client for the Senddy.io API."""
104
+
105
+ billing: AsyncBilling
106
+ domains: AsyncDomains
107
+ emails: AsyncEmails
108
+ inbound_emails: AsyncInboundEmails
109
+ inbound_routes: AsyncInboundRoutes
110
+ suppressions: AsyncSuppressions
111
+ webhook_endpoints: AsyncWebhookEndpoints
112
+
113
+ def __init__(
114
+ self,
115
+ api_key: Optional[str] = None,
116
+ *,
117
+ timeout: float = DEFAULT_TIMEOUT,
118
+ retries: int = DEFAULT_RETRIES,
119
+ headers: Optional[dict[str, str]] = None,
120
+ ) -> None:
121
+ resolved_key = _resolve_api_key(api_key)
122
+
123
+ self._http_client = httpx.AsyncClient(
124
+ base_url=DEFAULT_BASE_URL,
125
+ timeout=timeout,
126
+ headers=headers,
127
+ )
128
+ self._api_key = resolved_key
129
+ self._retries = retries
130
+
131
+ self.billing = AsyncBilling(self._http_client, resolved_key, retries)
132
+ self.domains = AsyncDomains(self._http_client, resolved_key, retries)
133
+ self.emails = AsyncEmails(self._http_client, resolved_key, retries)
134
+ self.inbound_emails = AsyncInboundEmails(self._http_client, resolved_key, retries)
135
+ self.inbound_routes = AsyncInboundRoutes(self._http_client, resolved_key, retries)
136
+ self.suppressions = AsyncSuppressions(self._http_client, resolved_key, retries)
137
+ self.webhook_endpoints = AsyncWebhookEndpoints(self._http_client, resolved_key, retries)
138
+
139
+ async def close(self) -> None:
140
+ await self._http_client.aclose()
141
+
142
+ async def __aenter__(self) -> AsyncSenddy:
143
+ return self
144
+
145
+ async def __aexit__(
146
+ self,
147
+ exc_type: Optional[type[BaseException]],
148
+ exc_val: Optional[BaseException],
149
+ exc_tb: Optional[TracebackType],
150
+ ) -> None:
151
+ await self.close()