anypost 1.0.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.
anypost/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Official Python SDK for the Anypost email API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._async import AsyncAnypost
6
+ from ._client import Anypost
7
+ from ._exceptions import (
8
+ AnypostError,
9
+ APIConnectionError,
10
+ APIError,
11
+ AuthenticationError,
12
+ ConflictError,
13
+ IdempotencyMismatchError,
14
+ NotFoundError,
15
+ PayloadTooLargeError,
16
+ PermissionError,
17
+ RateLimitError,
18
+ ValidationError,
19
+ )
20
+ from ._pagination import AsyncPage, Page
21
+ from ._version import __version__
22
+ from ._webhook_signature import (
23
+ WebhookDelivery,
24
+ WebhookDeliveryEvent,
25
+ WebhookVerificationError,
26
+ WebhookVerificationFailure,
27
+ unwrap_webhook_event,
28
+ verify_webhook_signature,
29
+ )
30
+ from .resources import (
31
+ ApiKeys,
32
+ Domains,
33
+ Email,
34
+ Events,
35
+ Identity,
36
+ Suppressions,
37
+ Templates,
38
+ Webhooks,
39
+ )
40
+
41
+ __all__ = [
42
+ "Anypost",
43
+ "AsyncAnypost",
44
+ "Page",
45
+ "AsyncPage",
46
+ "__version__",
47
+ # Resources
48
+ "ApiKeys",
49
+ "Domains",
50
+ "Email",
51
+ "Events",
52
+ "Identity",
53
+ "Suppressions",
54
+ "Templates",
55
+ "Webhooks",
56
+ # Errors
57
+ "AnypostError",
58
+ "APIConnectionError",
59
+ "APIError",
60
+ "AuthenticationError",
61
+ "ConflictError",
62
+ "IdempotencyMismatchError",
63
+ "NotFoundError",
64
+ "PayloadTooLargeError",
65
+ "PermissionError",
66
+ "RateLimitError",
67
+ "ValidationError",
68
+ # Webhooks
69
+ "WebhookDelivery",
70
+ "WebhookDeliveryEvent",
71
+ "WebhookVerificationError",
72
+ "WebhookVerificationFailure",
73
+ "unwrap_webhook_event",
74
+ "verify_webhook_signature",
75
+ ]
@@ -0,0 +1,7 @@
1
+ """Asynchronous client surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .client import AsyncAnypost
6
+
7
+ __all__ = ["AsyncAnypost"]
@@ -0,0 +1,109 @@
1
+ """The asynchronous :class:`AsyncAnypost` client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from types import TracebackType
7
+ from typing import Mapping, Optional, Type
8
+
9
+ import httpx
10
+
11
+ from .._async_http import AsyncHttpClient
12
+ from ..types.identity import WhoamiResponse
13
+ from .resources import (
14
+ AsyncApiKeys,
15
+ AsyncDomains,
16
+ AsyncEmail,
17
+ AsyncEvents,
18
+ AsyncIdentity,
19
+ AsyncSuppressions,
20
+ AsyncTemplates,
21
+ AsyncWebhooks,
22
+ )
23
+
24
+ DEFAULT_BASE_URL = "https://api.anypost.com/v1"
25
+ DEFAULT_TIMEOUT = 30.0
26
+ DEFAULT_MAX_RETRIES = 2
27
+
28
+
29
+ class AsyncAnypost:
30
+ """Async client for the Anypost email API.
31
+
32
+ ::
33
+
34
+ import asyncio
35
+ from anypost import AsyncAnypost
36
+
37
+ async def main():
38
+ async with AsyncAnypost("ap_your_api_key") as client:
39
+ result = await client.email.send({
40
+ "from": "Acme <you@yourdomain.com>",
41
+ "to": ["someone@example.com"],
42
+ "subject": "Hello",
43
+ "html": "<p>It worked.</p>",
44
+ })
45
+
46
+ asyncio.run(main())
47
+ """
48
+
49
+ email: AsyncEmail
50
+ domains: AsyncDomains
51
+ api_keys: AsyncApiKeys
52
+ templates: AsyncTemplates
53
+ suppressions: AsyncSuppressions
54
+ webhooks: AsyncWebhooks
55
+ events: AsyncEvents
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: Optional[str] = None,
60
+ *,
61
+ base_url: str = DEFAULT_BASE_URL,
62
+ timeout: float = DEFAULT_TIMEOUT,
63
+ max_retries: int = DEFAULT_MAX_RETRIES,
64
+ default_headers: Optional[Mapping[str, str]] = None,
65
+ http_client: Optional[httpx.AsyncClient] = None,
66
+ ) -> None:
67
+ key = api_key or os.environ.get("ANYPOST_API_KEY")
68
+ if not key:
69
+ raise ValueError(
70
+ "An Anypost API key is required. Pass it to the constructor "
71
+ "or set ANYPOST_API_KEY."
72
+ )
73
+
74
+ self._http = AsyncHttpClient(
75
+ api_key=key,
76
+ base_url=base_url.rstrip("/"),
77
+ timeout=timeout,
78
+ max_retries=max_retries,
79
+ default_headers=default_headers or {},
80
+ client=http_client,
81
+ )
82
+ self._identity = AsyncIdentity(self._http)
83
+
84
+ self.email = AsyncEmail(self._http)
85
+ self.domains = AsyncDomains(self._http)
86
+ self.api_keys = AsyncApiKeys(self._http)
87
+ self.templates = AsyncTemplates(self._http)
88
+ self.suppressions = AsyncSuppressions(self._http)
89
+ self.webhooks = AsyncWebhooks(self._http)
90
+ self.events = AsyncEvents(self._http)
91
+
92
+ async def whoami(self) -> WhoamiResponse:
93
+ """Identify the team and permission level behind the current API key."""
94
+ return await self._identity.whoami()
95
+
96
+ async def aclose(self) -> None:
97
+ """Close the underlying HTTP connection pool."""
98
+ await self._http.aclose()
99
+
100
+ async def __aenter__(self) -> "AsyncAnypost":
101
+ return self
102
+
103
+ async def __aexit__(
104
+ self,
105
+ exc_type: Optional[Type[BaseException]],
106
+ exc: Optional[BaseException],
107
+ tb: Optional[TracebackType],
108
+ ) -> None:
109
+ await self.aclose()
@@ -0,0 +1,346 @@
1
+ """Async resource clients. Mirror the sync resources in :mod:`anypost.resources`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+ from urllib.parse import quote
7
+
8
+ from .._async_http import AsyncHttpClient
9
+ from .._pagination import AsyncPage
10
+ from ..resources.email import _encode_attachments
11
+ from ..types.api_key import (
12
+ ApiKey,
13
+ ApiKeyCreateParams,
14
+ ApiKeyUpdateParams,
15
+ ApiKeyWithSecret,
16
+ )
17
+ from ..types.common import ListParams
18
+ from ..types.domain import Domain, DomainCreateParams, DomainUpdateParams
19
+ from ..types.email import BatchResponse, EmailBatchParams, EmailSendParams, SendResponse
20
+ from ..types.event import Event, EventListParams
21
+ from ..types.identity import WhoamiResponse
22
+ from ..types.suppression import (
23
+ Suppression,
24
+ SuppressionCreateParams,
25
+ SuppressionListParams,
26
+ )
27
+ from ..types.template import (
28
+ Template,
29
+ TemplateCreateParams,
30
+ TemplateDraft,
31
+ TemplateDraftParams,
32
+ TemplateDuplicateParams,
33
+ TemplateUpdateParams,
34
+ )
35
+ from ..types.webhook import (
36
+ Webhook,
37
+ WebhookCreateParams,
38
+ WebhookTestResult,
39
+ WebhookUpdateParams,
40
+ WebhookWithSecret,
41
+ )
42
+
43
+
44
+ class AsyncEmail:
45
+ """Operations on the ``/email`` endpoints."""
46
+
47
+ def __init__(self, http: AsyncHttpClient) -> None:
48
+ self._http = http
49
+
50
+ async def send(
51
+ self, email: EmailSendParams, *, idempotency_key: Optional[str] = None
52
+ ) -> SendResponse:
53
+ """Send a single message. See :meth:`anypost.resources.email.Email.send`."""
54
+ return await self._http.request(
55
+ "POST",
56
+ "/email",
57
+ body=_encode_attachments(email),
58
+ idempotent=True,
59
+ idempotency_key=idempotency_key,
60
+ )
61
+
62
+ async def send_batch(
63
+ self, batch: EmailBatchParams, *, idempotency_key: Optional[str] = None
64
+ ) -> BatchResponse:
65
+ """Send 1-100 messages. A ``207`` returns normally; it does not raise."""
66
+ body: dict[str, Any] = dict(batch)
67
+ if batch.get("defaults"):
68
+ body["defaults"] = _encode_attachments(batch["defaults"])
69
+ body["emails"] = [_encode_attachments(e) for e in batch["emails"]]
70
+ return await self._http.request(
71
+ "POST",
72
+ "/email/batch",
73
+ body=body,
74
+ idempotent=True,
75
+ idempotency_key=idempotency_key,
76
+ )
77
+
78
+
79
+ class AsyncDomains:
80
+ """Operations on the ``/domains`` endpoints."""
81
+
82
+ def __init__(self, http: AsyncHttpClient) -> None:
83
+ self._http = http
84
+
85
+ async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Domain]:
86
+ return await self._fetch_page(params)
87
+
88
+ async def create(self, params: DomainCreateParams) -> Domain:
89
+ return await self._http.request("POST", "/domains", body=params)
90
+
91
+ async def get(self, id: str) -> Domain:
92
+ return await self._http.request("GET", f"/domains/{quote(id, safe='')}")
93
+
94
+ async def update(self, id: str, params: DomainUpdateParams) -> Domain:
95
+ return await self._http.request(
96
+ "PATCH", f"/domains/{quote(id, safe='')}", body=params
97
+ )
98
+
99
+ async def delete(self, id: str) -> None:
100
+ await self._http.request("DELETE", f"/domains/{quote(id, safe='')}")
101
+
102
+ async def verify(self, id: str) -> Domain:
103
+ return await self._http.request("POST", f"/domains/{quote(id, safe='')}/verify")
104
+
105
+ async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Domain]:
106
+ params = params or {}
107
+ response = await self._http.request(
108
+ "GET",
109
+ "/domains",
110
+ query={"limit": params.get("limit"), "after": params.get("after")},
111
+ )
112
+ return AsyncPage(
113
+ response, lambda after: self._fetch_page({**params, "after": after})
114
+ )
115
+
116
+
117
+ class AsyncApiKeys:
118
+ """Operations on the ``/api-keys`` endpoints."""
119
+
120
+ def __init__(self, http: AsyncHttpClient) -> None:
121
+ self._http = http
122
+
123
+ async def list(self, params: Optional[ListParams] = None) -> AsyncPage[ApiKey]:
124
+ return await self._fetch_page(params)
125
+
126
+ async def create(self, params: ApiKeyCreateParams) -> ApiKeyWithSecret:
127
+ return await self._http.request("POST", "/api-keys", body=params)
128
+
129
+ async def get(self, id: str) -> ApiKey:
130
+ return await self._http.request("GET", f"/api-keys/{quote(id, safe='')}")
131
+
132
+ async def update(self, id: str, params: ApiKeyUpdateParams) -> ApiKey:
133
+ return await self._http.request(
134
+ "PATCH", f"/api-keys/{quote(id, safe='')}", body=params
135
+ )
136
+
137
+ async def delete(self, id: str) -> None:
138
+ await self._http.request("DELETE", f"/api-keys/{quote(id, safe='')}")
139
+
140
+ async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[ApiKey]:
141
+ params = params or {}
142
+ response = await self._http.request(
143
+ "GET",
144
+ "/api-keys",
145
+ query={"limit": params.get("limit"), "after": params.get("after")},
146
+ )
147
+ return AsyncPage(
148
+ response, lambda after: self._fetch_page({**params, "after": after})
149
+ )
150
+
151
+
152
+ class AsyncTemplates:
153
+ """Operations on the ``/templates`` endpoints."""
154
+
155
+ def __init__(self, http: AsyncHttpClient) -> None:
156
+ self._http = http
157
+
158
+ async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Template]:
159
+ return await self._fetch_page(params)
160
+
161
+ async def create(self, params: TemplateCreateParams) -> Template:
162
+ return await self._http.request("POST", "/templates", body=params)
163
+
164
+ async def get(self, id: str) -> Template:
165
+ return await self._http.request("GET", f"/templates/{quote(id, safe='')}")
166
+
167
+ async def update(self, id: str, params: TemplateUpdateParams) -> Template:
168
+ return await self._http.request(
169
+ "PATCH", f"/templates/{quote(id, safe='')}", body=params
170
+ )
171
+
172
+ async def delete(self, id: str) -> None:
173
+ await self._http.request("DELETE", f"/templates/{quote(id, safe='')}")
174
+
175
+ async def duplicate(
176
+ self, id: str, params: Optional[TemplateDuplicateParams] = None
177
+ ) -> Template:
178
+ return await self._http.request(
179
+ "POST", f"/templates/{quote(id, safe='')}/duplicate", body=params or None
180
+ )
181
+
182
+ async def get_draft(self, id: str) -> TemplateDraft:
183
+ return await self._http.request("GET", f"/templates/{quote(id, safe='')}/draft")
184
+
185
+ async def update_draft(self, id: str, params: TemplateDraftParams) -> TemplateDraft:
186
+ return await self._http.request(
187
+ "PATCH", f"/templates/{quote(id, safe='')}/draft", body=params
188
+ )
189
+
190
+ async def delete_draft(self, id: str) -> None:
191
+ await self._http.request("DELETE", f"/templates/{quote(id, safe='')}/draft")
192
+
193
+ async def publish(self, id: str) -> Template:
194
+ return await self._http.request("POST", f"/templates/{quote(id, safe='')}/publish")
195
+
196
+ async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Template]:
197
+ params = params or {}
198
+ response = await self._http.request(
199
+ "GET",
200
+ "/templates",
201
+ query={"limit": params.get("limit"), "after": params.get("after")},
202
+ )
203
+ return AsyncPage(
204
+ response, lambda after: self._fetch_page({**params, "after": after})
205
+ )
206
+
207
+
208
+ class AsyncSuppressions:
209
+ """Operations on the ``/suppressions`` endpoints."""
210
+
211
+ def __init__(self, http: AsyncHttpClient) -> None:
212
+ self._http = http
213
+
214
+ async def list(
215
+ self, params: Optional[SuppressionListParams] = None
216
+ ) -> AsyncPage[Suppression]:
217
+ return await self._fetch_page(params)
218
+
219
+ async def create(self, params: SuppressionCreateParams) -> Suppression:
220
+ return await self._http.request("POST", "/suppressions", body=params)
221
+
222
+ async def get(self, email: str, topic: str) -> Suppression:
223
+ return await self._http.request(
224
+ "GET", f"/suppressions/{quote(email, safe='')}/{quote(topic, safe='')}"
225
+ )
226
+
227
+ async def delete(self, email: str, topic: str) -> None:
228
+ await self._http.request(
229
+ "DELETE", f"/suppressions/{quote(email, safe='')}/{quote(topic, safe='')}"
230
+ )
231
+
232
+ async def list_for_email(self, email: str) -> List[Suppression]:
233
+ response = await self._http.request("GET", f"/suppressions/{quote(email, safe='')}")
234
+ return response["data"]
235
+
236
+ async def delete_for_email(self, email: str) -> None:
237
+ await self._http.request("DELETE", f"/suppressions/{quote(email, safe='')}")
238
+
239
+ async def _fetch_page(
240
+ self, params: Optional[SuppressionListParams]
241
+ ) -> AsyncPage[Suppression]:
242
+ params = params or {}
243
+ response = await self._http.request(
244
+ "GET",
245
+ "/suppressions",
246
+ query={
247
+ "limit": params.get("limit"),
248
+ "after": params.get("after"),
249
+ "email_contains": params.get("email_contains"),
250
+ "topic": params.get("topic"),
251
+ "reason": params.get("reason"),
252
+ "origin": params.get("origin"),
253
+ },
254
+ )
255
+ return AsyncPage(
256
+ response, lambda after: self._fetch_page({**params, "after": after})
257
+ )
258
+
259
+
260
+ class AsyncWebhooks:
261
+ """Operations on the ``/webhooks`` endpoints."""
262
+
263
+ def __init__(self, http: AsyncHttpClient) -> None:
264
+ self._http = http
265
+
266
+ async def list(self, params: Optional[ListParams] = None) -> AsyncPage[Webhook]:
267
+ return await self._fetch_page(params)
268
+
269
+ async def create(self, params: WebhookCreateParams) -> WebhookWithSecret:
270
+ return await self._http.request("POST", "/webhooks", body=params)
271
+
272
+ async def get(self, id: str) -> Webhook:
273
+ return await self._http.request("GET", f"/webhooks/{quote(id, safe='')}")
274
+
275
+ async def update(self, id: str, params: WebhookUpdateParams) -> Webhook:
276
+ return await self._http.request(
277
+ "PATCH", f"/webhooks/{quote(id, safe='')}", body=params
278
+ )
279
+
280
+ async def delete(self, id: str) -> None:
281
+ await self._http.request("DELETE", f"/webhooks/{quote(id, safe='')}")
282
+
283
+ async def test(self, id: str) -> WebhookTestResult:
284
+ return await self._http.request("POST", f"/webhooks/{quote(id, safe='')}/test")
285
+
286
+ async def rotate_secret(self, id: str) -> WebhookWithSecret:
287
+ return await self._http.request(
288
+ "POST", f"/webhooks/{quote(id, safe='')}/rotate-secret"
289
+ )
290
+
291
+ async def _fetch_page(self, params: Optional[ListParams]) -> AsyncPage[Webhook]:
292
+ params = params or {}
293
+ response = await self._http.request(
294
+ "GET",
295
+ "/webhooks",
296
+ query={"limit": params.get("limit"), "after": params.get("after")},
297
+ )
298
+ return AsyncPage(
299
+ response, lambda after: self._fetch_page({**params, "after": after})
300
+ )
301
+
302
+
303
+ class AsyncEvents:
304
+ """Read access to the ``/events`` stream."""
305
+
306
+ def __init__(self, http: AsyncHttpClient) -> None:
307
+ self._http = http
308
+
309
+ async def list(self, params: Optional[EventListParams] = None) -> AsyncPage[Event]:
310
+ return await self._fetch_page(params)
311
+
312
+ async def _fetch_page(self, params: Optional[EventListParams]) -> AsyncPage[Event]:
313
+ params = params or {}
314
+ tags = params.get("tags")
315
+ response = await self._http.request(
316
+ "GET",
317
+ "/events",
318
+ query={
319
+ "limit": params.get("limit"),
320
+ "after": params.get("after"),
321
+ "start": params.get("start"),
322
+ "end": params.get("end"),
323
+ "event_type": params.get("event_type"),
324
+ "recipient": params.get("recipient"),
325
+ "email_id": params.get("email_id"),
326
+ "message_id": params.get("message_id"),
327
+ "domain": params.get("domain"),
328
+ "topic": params.get("topic"),
329
+ "campaign": params.get("campaign"),
330
+ "template_id": params.get("template_id"),
331
+ "tags": ",".join(tags) if tags else None,
332
+ },
333
+ )
334
+ return AsyncPage(
335
+ response, lambda after: self._fetch_page({**params, "after": after})
336
+ )
337
+
338
+
339
+ class AsyncIdentity:
340
+ """Identity operations."""
341
+
342
+ def __init__(self, http: AsyncHttpClient) -> None:
343
+ self._http = http
344
+
345
+ async def whoami(self) -> WhoamiResponse:
346
+ return await self._http.request("GET", "/whoami")
anypost/_async_http.py ADDED
@@ -0,0 +1,96 @@
1
+ """Asynchronous HTTP transport. Mirrors :mod:`anypost._http`, sharing its
2
+ pure helpers (header building, backoff, decoding)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from typing import Any, Mapping, Optional
8
+
9
+ import httpx
10
+
11
+ from ._exceptions import APIConnectionError, parse_error
12
+ from ._http import (
13
+ RETRYABLE_STATUS,
14
+ QueryValue,
15
+ backoff_seconds,
16
+ build_headers,
17
+ clean_query,
18
+ connection_message,
19
+ decode_body,
20
+ )
21
+
22
+
23
+ class AsyncHttpClient:
24
+ """Owns an :class:`httpx.AsyncClient` and implements the async request loop."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ api_key: str,
30
+ base_url: str,
31
+ timeout: float,
32
+ max_retries: int,
33
+ default_headers: Mapping[str, str],
34
+ client: Optional[httpx.AsyncClient] = None,
35
+ ) -> None:
36
+ self._api_key = api_key
37
+ self._max_retries = max_retries
38
+ self._default_headers = dict(default_headers)
39
+ self._owns_client = client is None
40
+ self._client = client or httpx.AsyncClient(base_url=base_url, timeout=timeout)
41
+
42
+ async def request(
43
+ self,
44
+ method: str,
45
+ path: str,
46
+ *,
47
+ body: Any = None,
48
+ query: Optional[Mapping[str, QueryValue]] = None,
49
+ idempotent: bool = False,
50
+ idempotency_key: Optional[str] = None,
51
+ timeout: Optional[float] = None,
52
+ max_retries: Optional[int] = None,
53
+ extra_headers: Optional[Mapping[str, str]] = None,
54
+ ) -> Any:
55
+ retries = self._max_retries if max_retries is None else max_retries
56
+ headers = build_headers(
57
+ self._api_key,
58
+ self._default_headers,
59
+ has_body=body is not None,
60
+ idempotent=idempotent,
61
+ idempotency_key=idempotency_key,
62
+ max_retries=retries,
63
+ extra_headers=extra_headers,
64
+ )
65
+ kwargs: dict[str, Any] = {"headers": headers, "params": clean_query(query)}
66
+ if body is not None:
67
+ kwargs["json"] = body
68
+ if timeout is not None:
69
+ kwargs["timeout"] = timeout
70
+
71
+ attempt = 0
72
+ while True:
73
+ try:
74
+ response = await self._client.request(method, path, **kwargs)
75
+ except httpx.RequestError as exc:
76
+ if attempt < retries:
77
+ await asyncio.sleep(backoff_seconds(attempt, None))
78
+ attempt += 1
79
+ continue
80
+ raise APIConnectionError(connection_message(exc), exc) from exc
81
+
82
+ if response.is_success:
83
+ return decode_body(response)
84
+
85
+ if response.status_code in RETRYABLE_STATUS and attempt < retries:
86
+ await asyncio.sleep(backoff_seconds(attempt, response.headers))
87
+ attempt += 1
88
+ continue
89
+
90
+ raise parse_error(
91
+ response.status_code, decode_body(response), response.headers
92
+ )
93
+
94
+ async def aclose(self) -> None:
95
+ if self._owns_client:
96
+ await self._client.aclose()
anypost/_base64.py ADDED
@@ -0,0 +1,10 @@
1
+ """Base64 encoding for attachment content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+
7
+
8
+ def bytes_to_base64(data: bytes) -> str:
9
+ """Base64-encode raw bytes into an ASCII string."""
10
+ return base64.b64encode(data).decode("ascii")